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.