¿Cómo describir algoritmos, probarlos y analizarlos?

20

Antes de leer El arte de la programación de computadoras (TAOCP) , no he considerado estas preguntas profundamente. Usaría pseudocódigo para describir algoritmos, comprenderlos y estimar el tiempo de ejecución solo sobre órdenes de crecimiento. El TAOCP cambia completamente de opinión.

TAOCP usa inglés mezclado con pasos y goto para describir el algoritmo, y usa diagramas de flujo para visualizar el algoritmo más fácilmente. Parece de bajo nivel, pero creo que hay algunas ventajas, especialmente con el diagrama de flujo, que he ignorado mucho. Podemos etiquetar cada una de las flechas con una afirmación sobre el estado actual de las cosas en el momento en que el cálculo atraviesa esa flecha, y hacer una prueba inductiva para el algoritmo. El autor dice:

Es la opinión del autor que realmente comprendemos por qué un algoritmo es válido solo cuando llegamos al punto en que nuestras mentes han llenado implícitamente todas las afirmaciones, como se hizo en la figura 4.

No he experimentado tales cosas. Otra ventaja es que podemos contar la cantidad de veces que se ejecuta cada paso. Es fácil verificar con la primera ley de Kirchhoff. No he analizado exactamente el tiempo de ejecución, por lo que algunos podrían haberse omitido cuando estaba estimando el tiempo de ejecución.±1

El análisis de las órdenes de crecimiento a veces es inútil. Por ejemplo, no podemos distinguir la clasificación rápida de la clasificación múltiple porque todas son , donde es el número esperado de la variable aleatoria , por lo que debemos analizar la constante, digamos, y E ( T 2 ( n ) ) = A 2 lg n + B 2 n + O ( log n ) , así podemos comparar T 1 y T 2E(T(n))=Θ(nlogn)EXXE(T1(n))=A1nlgn+B1n+O(logn)E(T2(n))=A2lgn+B2n+O(logn)T1T2mejor. Y también, a veces debemos comparar otras cantidades, como las variaciones. Solo un análisis aproximado de las órdenes de crecimiento del tiempo de ejecución no es suficiente. Como TAOCP traduce los algoritmos al lenguaje ensamblador y calcula el tiempo de ejecución, es demasiado difícil para mí, por lo que quiero conocer algunas técnicas para analizar el tiempo de ejecución un poco más a fondo, lo que también es útil para lenguajes de nivel superior como C , C ++ o pseudocódigos.

Y quiero saber qué estilo de descripción se usa principalmente en trabajos de investigación y cómo tratar estos problemas.

Yai0Phah
fuente
66
Debe tener mucho cuidado al comparar los tiempos de ejecución de algoritmos tan de cerca. Las computadoras reales tienen cachés, registros y tuberías, que pueden cambiar drásticamente el tiempo de ejecución. Si desea averiguar qué algoritmo es realmente más rápido, debe ejecutarlo en una computadora.
svick
1
En realidad, analizar el ensamblador como el que usa Knuth es mucho más fácil que analizar el código de la vida real porque nada está oculto y el flujo de control es fácil. Estás pidiendo práctica; Creo que se aplica el comentario de Dave . Es más probable que los practicantes diseñen sus algoritmos usando mediciones de tiempo de ejecución que hacer un análisis riguroso. Pero entonces, no soy practicante, así que toma lo que digo con un grano de sal.
Raphael
1
@Raphael My en la práctica significa que en la práctica de trabajos de investigación , no de programación .
Yai0Phah
@ Frank, ¿qué quieres decir con variación ? Mis pruebas de rendimiento me dan variaciones de tiempo.
edA-qa mort-ora-y
@Raphael, tu primer punto, esto ya no es realmente cierto. Los chips modernos reordenan su ensamblaje, almacenan / cargan fuera de servicio y ejecutan y cargan de manera predictiva. Para la concurrencia y los problemas anteriores, en realidad se requiere un análisis exhaustivo, pero no lo hago de forma formal.
edA-qa mort-ora-y

Respuestas:

18

Hay una gran variedad de enfoques factibles. Cuál es el más adecuado depende de

  • lo que intentas mostrar,
  • cuántos detalles quieres o necesitas.

Si el algoritmo es ampliamente conocido y lo utiliza como subrutina, a menudo permanece en un nivel superior. Si el algoritmo es el objeto principal bajo investigación, probablemente quiera ser más detallado. Lo mismo puede decirse de los análisis: si necesita un límite de tiempo de ejecución superior aproximado, procederá de manera diferente a cuando desea un recuento preciso de declaraciones.

Le daré tres ejemplos para el conocido algoritmo Mergesort que, con suerte, ilustrarán esto.

Nivel alto

El algoritmo Mergesort toma una lista, la divide en dos (aproximadamente) partes igualmente largas, se repite en esas listas parciales y combina los resultados (ordenados) para que se ordene el resultado final. En listas simples o vacías, devuelve la entrada.

Este algoritmo es obviamente un algoritmo de clasificación correcto. Dividir la lista y fusionarla se puede implementar en el tiempo , lo que nos da una recurrencia para el peor tiempo de ejecución T ( n ) = 2 T ( nΘ(norte). Según el teorema del Maestro, esto se evalúa comoT(n)Θ(nlogn).T(norte)=2T(norte2)+Θ(norte)T(norte)Θ(norteIniciar sesiónnorte)

Nivel medio

El algoritmo Mergesort viene dado por el siguiente pseudocódigo:

procedure mergesort(l : List) {
  if ( l.length < 2 ) {
    return l
  }

  left  = mergesort(l.take(l.length / 2)
  right = mergesort(l.drop(l.length / 2)
  result = []

  while ( left.length > 0 || right.length > 0 ) {
    if ( right.length == 0 || (left.length > 0 && left.head <= right.head) ) {
      result = left.head :: result
      left = left.tail
    }
    else {
      result = right.head :: result
      right = right.tail
    }
  }

  return result.reverse
}

Probamos la corrección por inducción. Para listas de longitud cero o uno, el algoritmo es trivialmente correcto. Como hipótesis de inducción, suponga que mergesortfunciona correctamente en listas de longitud como máximo para algunos arbitrarios, pero fijos naturales n > 1 . Ahora dejemos que L sea ​​una lista de longitud n + 1 . Por hipótesis de inducción, y mantener (sin disminución) versiones ordenadas de la primera resp. segunda mitad de L después de las llamadas recursivas. Por lo tanto, el ciclo selecciona en cada iteración el elemento más pequeño que aún no se ha investigado y lo agrega ; por lo tanto, es una lista no cada vez más ordenada que contiene todos los elementos denortenorte>1Lnorte+1leftrightLwhileresultresultlefty right. El reverso es una versión ordenada no decreciente de , que es el resultado devuelto y deseado.L

En cuanto al tiempo de ejecución, cuentemos las comparaciones de elementos y las operaciones de lista (que dominan el tiempo de ejecución asintóticamente). Las listas de longitud inferior a dos no causan ninguna. Para las listas de longitud , tenemos esas operaciones causadas por la preparación de las entradas para las llamadas recursivas, las de las llamadas recursivas más el bucle y uno . Ambos parámetros recursivos se pueden calcular con a lo sumo n operaciones de lista cada uno. El bucle se ejecuta exactamente n veces y cada iteración causa como máximo una comparación de elementos y exactamente dos operaciones de lista. La final se puede implementar para usar 2 nnorte>1whilereversenortewhilenortereverse2norteoperaciones de lista: cada elemento se elimina de la entrada y se coloca en la lista de salida. Por lo tanto, el recuento de operaciones cumple la siguiente recurrencia:

T(0)=T(1)=0T(n)T(n2)+T(n2)+7n

Como es claramente no decreciente, es suficiente considerar n = 2 k para el crecimiento asintótico. En este caso , la recurrencia se simplifica aTn=2k

T(0)=T(1)=0T(n)2T(n2)+7n

Según el teorema del Maestro, obtenemos que se extiende al tiempo de ejecución de .TΘ(nlogn)mergesort

Nivel ultra bajo

Considere esta implementación (generalizada) de Mergesort en Isabelle / HOL :

types dataset  =  "nat * string"

fun leq :: "dataset \<Rightarrow> dataset \<Rightarrow> bool" where
   "leq (kx::nat, dx) (ky, dy) = (kx \<le> ky)"

fun merge :: "dataset list \<Rightarrow> dataset list \<Rightarrow> dataset list" where
"merge [] b = b" |
"merge a [] = a" |
"merge (a # as) (b # bs) = (if leq a b then a # merge as (b # bs) else b # merge (a # as) bs)"

function (sequential) msort :: "dataset list \<Rightarrow> dataset list" where
  "msort []  = []" |
  "msort [x] = [x]" |
  "msort l   = (let mid = length l div 2 in merge (msort (take mid l)) (msort (drop mid l)))"
by pat_completeness auto
  termination
  apply (relation "measure length")
by simp+

Esto ya incluye pruebas de buena definición y terminación. Encuentre una prueba de corrección (casi) completa aquí .

Para el "tiempo de ejecución", es decir, el número de comparaciones, se puede configurar una recurrencia similar a la de la sección anterior. En lugar de usar el teorema del Maestro y olvidar las constantes, también puede analizarlo para obtener una aproximación que sea asintóticamente igual a la cantidad verdadera. Puede encontrar el análisis completo en [1]; Aquí hay un bosquejo (no necesariamente se ajusta al código Isabelle / HOL):

Como arriba, la recurrencia para el número de comparaciones es

f0=f1=0fn=fn2+fn2+en

donde es el número de comparaciones necesarias para fusionar los resultados parciales². Para deshacernos de los pisos y techos, realizamos una distinción entre mayúsculas y minúsculas sobre si n es par:enn

{f2m=2fm+e2mf2m+1=fm+fm+1+e2m+1

Usando diferencias anidadas hacia adelante / hacia atrás de y e n obtenemos quefnen

.k=1n1(nk)Δfk=fnnf1

La suma coincide con el lado derecho de la fórmula de Perron . Definimos las series generadoras de Dirichlet de comoΔfk

W(s)=k1Δfkks=112sk1Δekks=: (s)

que junto con la fórmula de Perron nos lleva a

fn=nf1+n2πi3i3+i(s)ns(12s)s(s+1)ds

(s)

fnnlog2(n)+nA(log2(n))+1

A[1,0.9]


  1. Transformaciones de Mellin y asintóticas: la recurrencia de mergesort por Flajolet y Golin (1992)
  2. en=n2
    en=n1
    en=nn2n2+1n2n2+1
Rafael
fuente
αβT(n)=T(n/2)+T(n/2)+αn+β
@ Frank: La respuesta corta es que no puedes ; las constantes dependen de los detalles de implementación, incluida la arquitectura de la máquina, el lenguaje y el compilador, que son irrelevantes para el algoritmo subyacente.
JeffE
αβ
@JeffE, por ejemplo, MIX / MMIX en taocp es, pero es demasiado difícil traducir un algoritmo a dicho lenguaje de máquina.
Yai0Phah
αβ
3

La "Disciplina de la programación" de Dijkstra se trata de analizar y probar algoritmos y diseñar para probar. En el prefacio de ese libro, Dijkstra explica cómo un mini lenguaje construido muy simple que está diseñado adecuadamente para ser analizado es suficiente para explicar formalmente muchos algoritmos:

Al comenzar en un libro como este, uno se enfrenta inmediatamente a la pregunta: "¿Qué lenguaje de programación voy a usar?", Y este no es¡Una mera cuestión de presentación! El aspecto más importante, pero también el más escurridizo, de cualquier herramienta es su influencia en los hábitos de quienes se entrenan en su uso. Si la herramienta es un lenguaje de programación, esta influencia es, nos guste o no, una influencia en nuestros hábitos de pensamiento. Habiendo analizado esa influencia a lo mejor de mi conocimiento, llegué a la conclusión de que ninguno de los lenguajes de programación existentes, ni un subconjunto de ellos, satisfaría mi propósito; Por otro lado, me conocía tan poco preparado para el diseño de un nuevo lenguaje de programación que había hecho un voto de no hacerlo durante los próximos cinco años, ¡y tenía la clara sensación de que ese período aún no había transcurrido! (Antes de eso, entre muchas otras cosas, esta monografía tenía que ser escrita.

Más tarde, explica cuán pequeño logró obtener su mini idioma.

Le debo al lector una explicación de por qué he mantenido el minilenguaje tan pequeño que ni siquiera contiene procedimientos y recursividad. ... El punto es que no sentí necesidad de ellos para transmitir mi mensaje, a saber. cómo una separación cuidadosamente elegida de las preocupaciones es esencial para el diseño de programas de alta calidad en todos los aspectos; Las herramientas modestas del mini-lenguaje nos dieron la latitud más que suficiente para diseños no triviales, pero muy satisfactorios.

Mike Samuel
fuente