Eligiendo algoritmos: El caso de ordenamiento

Anuncio
Eligiendo algoritmos:
El caso de ordenamiento
Horst H. von Brand*
Universidad Técnica Federico Santa Marı́a
Departamento de Informática
Valparaı́so, Chile
[email protected]
Abstract
The problem of sorting an array is one of the fundamental problems in computer science, and is furthermore simple to
understand. Many interesting algorithms have been devised for this problem. The algorithms serve as good examples of
algorithm design strategies and programming techniques. Some of the algorithms can be analyzed easily, so they also offer
an opportunity to discuss the selection of an algorithm depending on the specific situation.
This paper roughly follows the classes in which several sorting algorithms are developed. The exposition style exemplified
here has been used successfully in a data structures course.
Keywords: Teaching methodology, programming, data structures
Resumen
El problema de ordenar los elementos de un arreglo es uno de los problemas fundamentales de la ciencia de la computación, y es simple de entender. Se han desarrollado variados e interesantes algoritmos para este problema. Sirven de ejemplos
de técnicas de diseño de algoritmos, y los que son sencillos de analizar dan ejemplos de esta tarea. La variedad de algoritmos
y sus caracterı́sticas dan la oportunidad de mostrar cómo elegir un algoritmo para un uso especı́fico.
El presente trabajo resume las clases en la que se desarrollan los algoritmos, su análisis, y la discusión de los criterios de
selección. El estilo de exposición ejemplificado acá ha sido usado con éxito en un curso de estructuras de datos.
Palabras clave: Metododologı́a de enseñanza, programación, estructuras de datos
* Apoyado por el proyecto BMBF-CH-99/023 – UTFSM/DGIP - Intelligent Data Mining in Complex Systems/2000-2001 (Alemania y Chile). Aportes
de proyectos UTFSM 24.01.11 (Chile) y FONDECYT 1991026 (Chile)
Agradezco además a mis alumnos, quienes hicieron valiosos aportes durante los años.
1.
Introducción
Un problema fundamental de la computación es ordenar datos según algún criterio de orden. El caso más simple se da
cuando los datos están en un arreglo. Para este caso se ha desarrollado una variedad de algoritmos, que dan buenos ejemplos
de cómo desarrollar algoritmos desde una idea básica, y luego llevar esta idea a un programa claro y eficiente en algún
lenguaje de programación. Además, al contar con una variedad de algoritmos para un mismo problema da lugar a discutir
criterios a emplear para elegir entre las distintas opciones, lo que lleva inevitablemente a hacer análisis (aún someros) de los
algoritmos planteados. No se tratará el diseño detallado desarrollado en clase que lleva a los programas presentados acá.
2.
Contenidos tratados
Los algoritmos desarrollados en detalle en el curso son seis. Tres de ellos son los algoritmos simples fundamentales:
Métodos de la burbuja (basado en intercambios), inserción, y selección. Se trata el algoritmo de Shell-Metzner como una
variante simple de inserción. Como algoritmos más complejos se discuten heapsort y quicksort.
2.1.
Preliminares
Los programas desarrollados en clase para estos algoritmos corresponden a las descripciones de textos standard, como [3,
4]. Se presupone que los estudiantes conocen los valores de sumatorias simples, como series aritméticas y geométricas.
Se ha explicado que el obtener medidas precisas de rendimiento (tiempo, en el caso que nos ocupa) en general significa
mucho trabajo, y que para la mayor parte de los efectos basta con aproximaciones bastante burdas. En particular, se insiste
en fijarse únicamente en algunas de las operaciones del algoritmo (comparaciones y asignaciones en el caso presente), cuidando que las demás operaciones en el programa guardan una relación de aproximada proporcionalidad con las operaciones
contabilizadas. Se definió la notación f (N) = O(g(N)) para indicar órdenes de magnitud de crecimiento de funciones.
Se han discutido también los costos relativos del esfuerzo humano contra el tiempo de cómputo ahorrado, siguiendo las
directrices en [1, 2]. Al hablar de los análisis a efectuar se considera también el costo de hacer análisis más detallados que los
que se muestran acá contra el posible beneficio de mayor precisión, y se ha planteado la opción de efectuar mediciones sobre
prototipos de las alternativas para la decisión final si aún hay dudas con el resultado del análisis.
El análisis de rendimiento de los algoritmos es somero, se remite únicamente a analizar los casos extremos. El análisis de
los promedios es relativamente complejo, y escapa claramente los objetivos del ramo presente.
Durante la discusión se hace frecuente referencia al código de los programas desarrollados antes para cada uno de los
algoritmos. Los estudiantes están familiarizados en detalle con los algoritmos, dado que se discutieron diversas maneras de
implementarlos durante su desarrollo, y se mostró con conjuntos de datos cómo opera cada algoritmo durante su diseño, lo
que luego se verificó trazando los programas con ellos.
2.2.
Métodos elementales
En una primera sesión se comparan los métodos simples de ordenamiento desarrollados antes: Método de la burbuja (ver
listado 1), selección simple (listado 2), e inserción (listado 3). Se detalla cómo el rendimiento de los diferentes métodos
depende del orden en que vienen los datos originalmente. Un momento de reflexión muestra que el mejor caso para los tres
algoritmos es cuando los datos ya están ordenados, y el peor caso se da cuando están en orden inverso.
También queda claro de un análisis somero que los tres métodos son O(N 2 ), por lo que es necesario hacer un análisis un
poco más detallado para compararlos. Por ejemplo, al venir los N datos ya ordenados, el método de la burbuja sólo compara
cada elemento con su vecino (lo que significa ∑1≤i≤N−1 i = N(N − 1)/2 comparaciones en total) y no efectúa asignaciones.
Está claro que hay una serie de operaciones adicionales (acceder a los elementos del arreglo a ser comparados, manipulaciones
de los ı́ndices a través de los for), que en total aportarán en forma proporcional al número de comparaciones efectuadas al
tiempo de ejecución. Si los datos vienen en orden inverso, efectúa el mismo número de comparaciones, y ese mismo número
de intercambios (o sea, 3N(N − 1)/2 asignaciones en total).
Selección simple (ver listado 2) efectúa siempre la misma cantidad de comparaciones, ninguna asignación en caso que los
elementos vengan ya ordenados, y tan sólo 3(N − 1) asignaciones (un intercambio por cada uno de N − 1 elementos) en el
caso en que los elementos vengan en orden inverso.
Para el método de inserción (ver listado 3) está claro que en caso que los elementos estén ya ordenados se ejecutan N − 1
comparaciones y ninguna asignación, mientras que en caso que vengan en orden inverso se efectúan i +1 asignaciones cuando
se ubica el elemento i-ésimo en su lugar, para un total de ∑2≤i≤N (i + 1) = (N + 4)(N − 1)/2 asignaciones.
void s o r t ( double a [ ] , i n t n )
{
double tmp ;
int i , j , k ;
for ( i = n − 1; i ; i = k ) {
k = 0;
for ( j = 0 ; j < i ; j ++) {
i f ( a [ j + 1] < a [ j ] ) {
tmp = a [ j ] ; a [ j ] = a [ j + 1 ] ; a [ j + 1 ] = tmp ;
k = j;
}
}
i = k;
}
}
Listado 1: Método de la burbuja
void s o r t ( double a [ ] , i n t n )
{
int i , j , k ;
double min , tmp ;
for ( i = 0 ; i < n ; i ++) {
k = i ; min = a [ i ] ;
f o r ( j = i + 1 ; j < n ; j ++)
i f ( a [ j ] < min ) {
k = j ; min = a [ j ] ;
}
tmp = a [ i ] ; a [ i ] = a [ k ] ; a [ k ] = tmp ;
}
}
Listado 2: Selección simple
De la tabla 1 que resume la discusión anterior1 se desprende que el método de selección es claramente el más ventajoso
en lo que respecta a asignaciones, siendo el peor en términos de comparaciones (el número de comparaciones que efectúa es
constante).
El método de inserción es mejor que el método de la burbuja en términos de asignaciones siempre que N > 2. Para N
grande, se aprecia que el número de asignaciones que efectúa en el peor caso el método de la burbuja es aproximadamente el
triple que para el método de inserción. Intuitivamente, esta diferencia (que se debe a que el método de la burbuja intercambia
elementos, a costa de 3 asignaciones, cuando el método de inserción sólo mueve un elemento, a costa de 1 asignación) debiera
mantenerse en el caso promedio.
1 Los
programas dados en los listados no incluyen las modificaciones obvias para evitar asignaciones inútiles, que se asumen en la tabla
Método
Burbuja
Selección
Inserción
Comparaciones
Mı́nimo
Máximo
N −1
N(N − 1)/2
N(N − 1)/2 N(N − 1)/2
N −1
N(N − 1)/2
Assignaciones
Mı́nimo
Máximo
0
3N(N − 1)/2
0
3(N − 1)
0
(N + 4)(N − 1)/2
Cuadro 1: Comparación de los métodos elementales
void s o r t ( double a [ ] , i n t n )
{
int i , j ;
double tmp ;
for ( i = 1 ; i < n ; i ++) {
tmp = a [ i ] ;
f o r ( j = i − 1 ; j > = 0 && tmp < a [ j ] ; j −−)
a[ j + 1] = a[ j ];
a [ j + 1 ] = tmp ;
}
}
Listado 3: Método de inserción
El que haga menos comparaciones en el mejor caso lo hace aparecer aún más ventajoso. En términos cualitativos, está claro que el método de inserción hace poco más que una asignación por posición que mueve un elemento, mietras el método de
la burbuja hace tres. Si se asume que ambos mueven aproximadamente los elementos la misma suma de distancias, está claro
que el método de inserción resultará más eficiente. Además, un momento de reflexión muestra que el número de asignaciones y comparaciones que hace será aproximadamente proporcional a la suma de las distancias que los elementos deben ser
movidos, y ésta será pequeña siempre que los elementos estén “cerca” de sus posiciones finales en el arreglo ordenado, o sea,
si los datos vienen “casi ordenados”. Ası́, en este caso (bastante común en la práctica) este método es muy atractivo.
Desde el punto de la complejidad de los programas, no hay gran diferencia entre los tres métodos.
2.3.
Métodos más complejos
Luego se comparan los métodos Shellsort (listado 4), heapsort (listado 5), y quicksort (listado 6).
void s o r t ( double a [ ] , i n t n )
{
int i , j , h;
double tmp ;
/ ∗ Generate i n c r e m e n t sequence ∗ /
for ( h = 1 ; 3 ∗ h + 1 < n ; h = 3 ∗ h + 1 )
;
do {
h /= 3;
for ( i = h ; i < n ; i ++) {
tmp = a [ i ] ;
f o r ( j = i − h ; j > = 0 && tmp < a [ j ] ; j − = h )
a[ j + h] = a[ j ];
a [ j + h ] = tmp ;
}
} while ( h > 1 ) ;
}
Listado 4: Shellsort
El análisis de shellsort es extremadamente complejo y no se ha completado aún, y por tanto simplemente se mencionan los
resultados empı́ricos que indican un tiempo de ejecución promedio de ya sea O(N 1,25 ) o O(N log2 N) para la secuencia de
incrementos planteada. La versión de Shellsort discutida en clase es la del listado 4, que como se aprecia no mucho más
compleja que el método de inserción, listado 3. Algunas de las condiciones sobre la secuencia de incrementos son que no
hayan factores comunes entre incrementos sucesivos (cosa que nuestra variante asegura), y que disminuyan rápidamente
(decrecen en forma casi exponencial acá). Esto se discute cualitativamente en clase. No se conocen ni el mejor ni el peor
tiempo en este caso. Se arguye que usar inserción para manejar las subsecuencias resultantes en Shellsort es la mejor opción,
de forma de aprovechar el orden parcial que se va generando.
s t a t i c void downheap ( double [ ] , i n t , i n t ) ;
s t a t i c void swap ( double ∗ , double ∗ ) ;
void s o r t ( double a [ ] , i n t n )
{
int i ;
/ ∗ Make heap ∗ /
f o r ( i = n / 2 − 1 ; i > = 0 ; i −−)
downheap ( a , i , n ) ;
/∗ Sort ∗/
swap(&a [ 0 ] , & a [ n
for ( i = n − 1; i
downheap ( a ,
swap(&a [ 0 ] ,
− 1]);
> 1 ; i −−) {
0 , i );
&a[ i − 1]);
}
}
s t a t i c void downheap ( double a [ ] , i n t l , i n t u )
{
int j , k ;
double tmp ;
j = l ; tmp = a [ j ] ;
for ( ; ; ) {
k = 2 ∗ j + 1;
i f ( k >= u )
break ;
i f ( k + 1 < u && a [ k + 1 ] > a [ k ] )
k ++;
i f ( tmp > a [ k ] )
break ;
a[ j ] = a[k ] ; j = k;
}
a [ j ] = tmp ;
}
s t a t i c void swap ( double ∗ pa , double ∗ pb )
{
double tmp ;
tmp = ∗ pa ; ∗ pa = ∗ pb ; ∗ pb = tmp ;
}
Listado 5: heapsort
En el caso de heapsort (listado 5) un análisis somero es fácil de efectuar. En la primera fase se construye un heap, que como
árbol binario completo tiene altura aproximada log2 N. En el proceso se van integrando en el heap la mitad de los elementos, y
en el peor caso cada uno de ellos recorrerá el camino completo desde su actual posición en el árbol hasta el final (posición de
hoja). En la segunda fase se van eliminando uno a uno los N elementos del heap, y al reconstruir el heap hay N − 1 elementos
que en el peor caso recorrerán el camino de la raı́z a una hoja. Está claro que el total es siempre O(N log N), sin gran variación,
dado que en la segunda fase se toma siempre el último elemento del arreglo (que es una hoja) para ubicarlo en la raı́z y hacerlo
bajar a la posición que le corresponde. Seguramente terminará nuevamente como hoja, haciendo el recorrido completo.
La estuctura heap da lugar a discutir sobre colas de prioridad y su implementación eficiente. Se mencionan algunas
aplicaciones prácticas, como la administración de eventos en simulación por eventos discretos.
void s o r t ( double a [ ] , i n t n )
{
int i , j ;
double p i v , tmp ;
i f ( n > 1) {
piv = a [ 0 ] ;
i = 0; j = n;
do {
do i + + ; while ( i < n & & a [ i ] < p i v ) ;
do j −−; while ( a [ j ] > p i v ) ;
tmp = a [ j ] ; a [ j ] = a [ i ] ; a [ i ] = tmp ;
} while ( i < j ) ;
/ ∗ Place p i v o t , undo e x t r a swap ∗ /
a [ i ] = a [ j ] ; a [ j ] = a [ 0 ] ; a [ 0 ] = tmp ;
sort (a , j ) ;
sort (a + j + 1 , n − j − 1);
}
}
Listado 6: Quicksort simple
En el caso de quicksort el análisis es más complejo. Se argumenta que el peor caso resulta cuando en cada paso se elige el
pivote de forma que queda de primero o último en el arreglo. Ası́, después de la i-ésimo partición quedan N − i elementos
a ordenar. Como cada partición claramente demanda trabajo proporcional al largo de la sección considerada, el trabajo total
será proporcional a ∑0≤i≤N−1 (N − i) = N(N − 1)/2. Se menciona que un análisis detallado muestra que el tiempo promedio
es O(N log N) ası́ como también el mejor caso. Se destaca que quicksort tiene ciclos internos muy simples (en el proceso
de partición), lo que hace que sea extremadamente rápido (de allı́ su nombre). La variante sencilla de quicksort desarrollada
inicialmente en clase (ver listado 6) se extiende luego para mostrar varias técnicas avanzadas, como elegir el pivote como
la mediana de tres elementos (el primero, el último, y uno central; incidentalmente ordenar estos tres provee centinelas
naturales para ambas búsquedas, haciendolas más rápidas), eliminación de recursión de cola (se indica que un compilador
astuto hará esta tarea por sı́ mismo, por lo que es importante investigar ésto antes de complicar el programa sin provecho
real), y terminar la recursión tempranamente para finalizar el trabajo con inserción (que es la mejor técnica para este trabajo
según la discusión previa, dado que los datos resultan estar cerca de sus posiciones finales).
La discusión anterior se resume en la tabla 2, que muestra los tiempos de ejecución en órdenes de magnitud para los
Método
Shellsort
Heapsort
Quicksort
Mı́nimo
?
O(N log N)
O(N log N)
Media
O(N log2 N)
O(N log N)
O(N log N)
Máximo
?
O(N log N)
O(N 2 )
Cuadro 2: Comparación entre los métodos más complejos
métodos descritos.
2.4.
Resumen de la evaluación
Resumiendo la discusion anterior, se ve que los métodos elementales tienen ventaja de simplicidad de código y son
competitivos para arreglos pequeños. Se concluye que de los métodos elementales el método de inserción resulta ser el más
adecuado para uso general, y que es ventajoso incluso para grandes volúmenes de datos cuando éstos ya están parcialmente
ordenados. El método de selección es adecuado cuando los elementos son muy voluminosos (y por tanto las asignaciones
dominan el tiempo de ejecución). Pero en tales casos cabe considerar la opción de manejar ı́ndices o punteros a los datos
mismos, y evitar moverlos (o sólo reorganizarlos una vez se haya completado el ordenamiento).
Entre los métodos más complejos se destaca por su simplicidad de código Shellsort, que muestra buen rendimiento. Sin
embargo, tiene la desventaja de que se desconocen sus parámetros de rendimiento. Heapsort es más complejo de programar
y entender, y tiene la ventaja de tener tiempo de ejecución garantizado, poco variable. Quicksort es en promedio lejos el más
rápido, pero su peor caso es muy malo. Además, el programa es frágil.
3.
Conclusiones
A través del desarrollo en detalle de seis métodos de ordenamiento de arreglos se pueden introducir ideas importantes
de diseño de programas, nociones de análisis de algoritmos, y mostrar cómo pueden aplicarse criterios racionales para la
selección de un método para resolver un problema dado.
Los programas presentados son cortos (tal vez una docena de lı́neas cada uno). A pesar de ésto se requiere un análisis
detallado para diseñarlos (ası́ como para entenderlos). Se enfatiza ası́ lo complejo que resulta diseñar, codificar, y verificar un
programa, y el efecto que las caracterı́sticas del método empleado pueden tener sobre estas tareas.
Referencias
[1] Jon Bentley. Programming Pearls. Prentice Hall, second edition, 2000.
[2] Jon Louis Bentley. Writing Efficient Programs. Prentice Hall, 1982.
[3] Donald E. Knuth. Sorting and Searching, volume 3 of The Art of Computer Programming. Addison-Wesley, second
edition, 1998.
[4] Robert Sedgewick. Algorithms. Addison-Wesley, 1984.
Descargar