Capı́tulo VI Algoritmos: Ordenamiento VI.1. Algoritmos Un algoritmo es una secuencia de instrucciones precisas para llevar a cabo una tarea. Ya hemos visto algunos ejemplos: un algoritmo para resolver el problema de la torre de Hanoi, y algunos algoritmos numéricos (multiplicación, división). En este capı́tulo usamos el problema de ordenamiento como ejemplo para exponer algunas temas relevantes: métodos generales, análisis del número de operaciones que realizan, etc. Vamos a usar principalmente pseudocódigo para describir los algoritmos de manera relativamente precisa. Esto se precisa un poco en la primera sección. Además del pseudocódigo también se proveen los algoritmos en el lenguaje python, el cual puede ser fácilmente descargado de la red (www.python.org). VI.1.1. Pseudocódigo Instrucciones El pseudocódigo consiste de instrucciones que se ejecutan en la secuencia que aparecen. La instrucción más básica es una asignación: se asigna un valor a una variable. Usamos una flecha para indicar una asignación (en lugar del signo igual, el cual se reserva para comparaciones): variable ← valor Es esencial tener instrucciones especiales que afectan la secuencia de ejecución del algoritmo: si: si la condición se satisface se realiza el Bloque-A de instrucciones, y de lo contrario se realza el Bloque-B. Cada uno de los bloques aparece indentado: si hcondicioni Bloque-A si-no Bloque-B 1 2 CAPÍTULO VI. ALGORITMOS: ORDENAMIENTO Es posible tener más de dos casos: si hcondicion-1i Bloque-A si-no si hcondicion-2i Bloque-B si-no Bloque-C para: se ejecuta el bloque de instrucciones que aparece a continuación indentado para cada valor de la variable en el rango (en orden): para variable en rango Bloque mientras: mientras la condición es verdadera, se realiza el bloque de instrucciones que aparece a continuación indentado: mientras hcondicioni Bloque Comentarios se escriben después del sı́mbolo B. Variables Además de variables que representan números u otros tipos de datos básicos (carácteres, valores lógicos V ó F, etc), se tienen arreglos que son variables indexadas en un rango que tomamos desde 0 hasta su longitud menos 1. Por ejemplo A[5] es la sexta posición del arreglo A. Un rango se especifica como i : j donde por conveniencia adoptamos la convención en python de considerar rangos cerrados por la izquierda y abiertos por la derecha. Es decir, “i : j” corresponde al rango i, i + 1, i + 2, . . . , j − 1. Además “: j” y “i :” son rangos que van desde el comienzo en el primer caso, y hasta el final en el segundo caso. Por ejemplo, A[4 : 8] es el subarreglo que consiste de A[4], A[5], A[6], A[7]; A[: 3] es el subarreglo que consiste de A[0], A[1], A[2]. En algunos casos es más conveniente manejar listas las cuales permiten otras operaciones como eliminar el primer elemento (pop en python), agregar un elemento al final (append en python), y concatenar dos listas (lo cual denotamos L + L 0 como se hace en pyhton). Notamos que el tipo de variable lista en python permite tanto las operaciones de un arreglo como las de una lista, y es esta la que usamos en todos los ejemplos de código python. VI.1. ALGORITMOS VI.1.2. 3 Python Para la mayorı́a de los algoritmos se incluye además de pseudocódigo también una implementación en python, la cual en general es muy similar (pero con las palabras claves de python). Aquı́ sólo mencionamos que lı́neas que comienzan con el carácter # son comentarios. El interpretador de pyhton se puede descargar de www.pyhton.org. VI.1.3. Ejemplo: Torres de Hanoi Ya se ha discutido el problema y algoritmo anteriormente. Ahora lo escribimos en pseudocódigo, y en código python como ejemplo. Pseudocódigo Hanoi 1. si 2. 3. 4. (n, A, C, B) n 6= 0 Hanoi (n − 1, A, B, C) Mueva el anillo (de encima) de A a C Hanoi (n − 1, B, C, A) Código Python # # torres de Hanoi: Mueve n anillos del polo A # al polo C con polo B como intermedio # def Hanoi(n, A, C, B): if n != 0: Hanoi(n - 1, A, B, C) print("Mueva el anillo de", A, "a", C) Hanoi(n - 1, B, C, A) # 4 CAPÍTULO VI. ALGORITMOS: ORDENAMIENTO Ejemplo Ejecución (python) >>> Hanoi(4,’A’,’C’,’B’) Mueva el anillo de A a B Mueva el anillo de A a C Mueva el anillo de B a C Mueva el anillo de A a B Mueva el anillo de C a A Mueva el anillo de C a B Mueva el anillo de A a B Mueva el anillo de A a C Mueva el anillo de B a C Mueva el anillo de B a A Mueva el anillo de C a A Mueva el anillo de B a C Mueva el anillo de A a B Mueva el anillo de A a C Mueva el anillo de B a C VI.2. Búsqueda Una motivación para el problema de ordenamiento, es que una lista ordenada facilita la búsqueda de un ı́tem en la lista. Si la lista no está ordenada, no hay mejor alternativa que simplementa escanear la lista secuencialmente, es decir, en el orden que los ı́tems aparecen en ella (que no corresponde al orden de los ı́tems). El siguiente pseudocódigo busca el ı́tem K en la lista L: Buscar1 (K, L) 1. para i ← 0 a longitud(L) − 1 2. si K = L[i] 3. devolver (i) 4. devolver (−1) Esta búsqueda secuencial requiere un número de comparaciones (lı́nea 2) igual al tamaño de la lista en el peor de los casos. Esto sucede cuando el ı́tem que se busca es el último ó no está en la lista (entonces se regresa −1 en la lı́nea 4). Si n es la longitud de la lista, entonces el número de comparaciones es n en el peor de los casos. Se dice que la búsqueda secuencial tiene “complejidad” O(n) (se lée O de n, ó O grande de n, porque existe otra notación con o pequeña). (Puede leer acerca de esta notación O en Johnsonbaugh, sec 4.3.) VI.2.1. Búsqueda Binaria: Versión Recursiva Si la lista está ordenada, entonces búsqueda binaria es considerablemente más eficiente. En la búsqueda binaria se examina el ı́tem en el centro de la lista, si es el que se busca se devuelve el ı́ndice correspondiente, si no lo es, se continúa la búsqueda recursivamente en la primera ó segunda mitad de la lista/arreglo, dependiendo del resultado de la comparación. En el siguiente pseudocódigo, se VI.2. BÚSQUEDA 5 tienen como parámetros, además de la lista L y el ı́tem a buscar K, los ı́ndices que determinan el intervalo de búsqueda: Buscar2 (K, A, i, j) 1. si i > j 2. devolver (-1) 3. m ← b(i + j)/2c 4. si K = A[m] 5. devolver (m) 6. si-no si K < A[m] 7. devolver (Buscar2 (K, A, i, m − 1)) 8. si-no 9. devolver (Buscar2 (K, A, m + 1, j)) Invariante: El algoritmo mantiene en cada llamada recursiva la propiedad de que si K está en la lista, entonces su celda L[`] satisface i ≤ ` ≤ j. Por esto devuelve −1 en la lı́nea 1, si i > j (ya no es posible que K esté en L). El bloque del si (lı́neas 4-9) se encarga de que se mantenga este invariante a la siguiente llamada recursiva: para esto elimina la “mitad” del rango que no puede contener K. Código Python # busqueda binaria recursiva: # busca llave K en el arreglo A entre indices i y j # def buscar(A,i,j,K): if i>j: return(-1) m=(i+j)//2 if K==A[m]: return(m) elif K<A[m]: return(buscar(A,i,m-1,K)) else: return(buscar(A,m+1,j,K)) # VI.2.2. Búsqueda Binaria: Versión Iterativa Como lo ilustra el algoritmo anterior, la recursión es una herramienta muy poderosa. Sin embargo, recursión agrega carga computacional a la ejecución de un algoritmo (el procesador debe tener una tabla que contiene la información de que las ejecuciones recursivas que están pendientes de completar, y cual se está realizando en el momento), y si se puede evitar es deseable hacerlo. En el caso de la búsqueda binaria es muy fácil hacerlo: 6 CAPÍTULO VI. ALGORITMOS: ORDENAMIENTO Buscar3 (K, A) 1. i ← 0; j ← longitud(A) − 1 2. mientras i ≤ j 3. m ← b(i + j)/2c 4. si K = A[m] 5. devolver (m) 6. si-no si K < A[m] 7. j←m−1 8. si-no 9. i←m+1 10. devolver (−1) B inicializa indices i, j B calcula indice medio B item K esta en posicion m B busqueda en la sublista izquierda B busqueda en la sublista derecha B item K no esta en L Código Python # # busqueda binaria no recursiva: busca llave K # en el arreglo A entre indices i y j # def buscar(K,A): i=0; j=len(A)-1 while i<=j: m=(i+j)//2 if K==A[m]: return(m) elif K<A[m]: j=m-1 else: i=m+1 return(-1) # VI.2.3. Número de Comparaciones Nos interesa analizar cuánto se demora el algoritmo de búsqueda binaria en completar su tarea. Como indicativo de esto, vamos a determinar el número de comparaciones que realiza (lı́nea 4 de la versión iterativa) en el peor de los casos. Si la lista/arreglo es vacı́a, entonces el número de comparaciones es 0. Si no lo es, después de hacer una comparación continúa buscando en una subarreglo de longitud a lo más bn/2c (si la longitud del arreglo es impar entonces ambos subarreglos de elementos menores y mayores que el elemento medio tienen longitud bn/2c; si la longitud del arreglo es par entonces el arreglo de elementos menores tiene longitud bn/2c − 1 y el de elementos mayores tiene longitud bn/2c). Sea an el número de comparaciones realizado en el peor de los casos para un arreglo de longitud n. Continuando con el análisis, el peor caso se realiza cuando el ı́tem que se busca es mayor que todos los elementos del arreglo (y ası́ la búsqueda es siempre en la mitad superior). Por lo tanto, esto muestra entonces que se satisface la recurrencia an = abn/2c + 1 VI.2. BÚSQUEDA 7 con a1 = 1. La presencia de la función piso en la derecha dificulta un poco resolver esta ecuación. Veamos primero el caso en que n = 2` , el cual es más fácil porque sigue siendo entero al dividir por 2 iterativamente hasta que se llaga a 1 (y en un paso más se llega a 0): a2` = a2`−1 + 1 = (a2`−2 + 1) + 1 = a2`−2 + 2 .. . = a2`−` + ` = a1 + ` = ` + 1. Ası́ que an = log2 n + 1 en el caso que n = 2` . Para generalizar esto a cualquier n es útil calcular varios valores de an usando la recurrencia: n : 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 an : 1 2 2 3 3 3 3 4 4 4 4 4 4 4 4 5 5 Se observa que an = blog2 nc + 1 Esto lo podemos verificar por inducción. El caso base es a1 = 1, y para n tenemos (paso de inducción fuerte) dos casos (usando como hipótesis de inducción que ak = blog kc + 1): • si n es par: entonces n = 2k y se tiene que an = a2k = ak +1 = blog2 kc+1+1 = blog2 k+1c+1 = blog2 (2k)c+1 = blog2 nc+1. • si n es impar: entonces n = 2k + 1 y se tiene que an = a2k+1 = ak +1 = blog2 kc+1+1 = blog2 k+1c+1 = blog2 (2k)c+1 = blog2 nc+1. Para la última igualdad, note que blog2 (2k)c = blog2 (2k + 1)c porque el log cambia al sigueinte entero en una potencia de 2, y 2k + 1 no es una potencia de 2. Por lo tanto blog2 (2k)c = blog2 nc. Concluı́mos que el número de comparaciones realizadas por el algoritmo de búsqueda binaria con una arreglo de longitud n es an = blog2 nc + 1 en el peor de los casos. 8 CAPÍTULO VI. ALGORITMOS: ORDENAMIENTO Alternativa. Una forma alternativa de extender el análisis del caso n = 2` al caso general, aunque ya no con igualdad es notar que an es creciente (es decir an ≤ an+1 ), y que cualquier n está entre dos potencias de 2: 2`−1 < n ≤ 2` En este caso dlog2 ne = `. Entonces an ≤ a2` = ` + 1 = dlog2 ne + 1, lo que es muy cercano al valor exacto obtenido arriba. VI.3. Ordenamiento En el problema de ordenamiento se da como entrada una lista L de elementos, números u otro tipo de dato que tiene un orden, y el objetivo es ordenarlos, es decir, producir otra lista en la cual los mismos elementos aparecen en orden creciente ó decreciente (pueden haber copias del mismo elemento, de tal manera que el resultado puede no ser estrictamente creciente ó decreciente). Asumimos el caso de salida creciente para la presentación de los algoritmos. A continuación exploramos cuatro métodos diferentes de resolver este problema. Sea n el tamaño (ó longitud) de la lista de entrada. Un parámetro representativo de que tanto se demora el algoritmo en realizar el odenamiento es el número de comparaciones que realiza entre elementos de la lista (no contamos otras posibles comparaciones que son parte del control del algoritmo, es decir contadores de ciclos, etc). De los algoritmos que estudiamos, los dos primeros realizan un número de comparaciones O(n2 ) y el tercero realiza un número de comparaciones O(n log n). El último realiza un número de comparaciones que es también O(n2 ) en el peor de los casos, pero en promedio el número es O(n log n). VI.3.1. Ordenamiento por Inserción (Insertion Sort) Este algoritmo iterativo que comienza la i-ésima iteración con los primeros i elementos (es decir L[0], L[1], . . . , L[i − 1]) ya ordenados, y busca la posición que debe ocupar el siguiente elemento (L[i]) y lo inserta en esa posición. En el proceso de buscar la posición, se mueven hacia adelante los elementos para desocupar la nueva posición de L[i]. VI.3. ORDENAMIENTO 9 Ordenar-por-Insercion (L) 1. para i ← 1 a longitud(L) − 1 2. K ← L[i] B L[i] es el item a insertar B se busca la posicion donde L[i] debe ser insertado 3. j←i−1 4. mientras j ≥ 0 y L[j] > K 5. L[j + 1] ← L[j] B L[j] se mueve hacia adelante 6. j←j−1 B L[i] es insertado en posicion 7. L[j + 1] ← K Ejemplo: A continuación se muestra un arreglo/lista inicial L, y esta misma al final de cada una de las iteraciones del ciclo principal del algoritmo (para en la lı́nea 1): L= 1, 2, 3, 4, 5, 6, 7, 8, 9, [9, [7, [3, [3, [2, [2, [1, [0, [0, [0, 7, 9, 7, 5, 3, 3, 2, 1, 1, 1, 3, 3, 9, 7, 5, 4, 3, 2, 2, 2, 5, 5, 5, 9, 7, 5, 4, 3, 3, 3, 2, 2, 2, 2, 9, 7, 5, 4, 4, 4, 4, 4, 4, 4, 4, 9, 7, 5, 5, 5, 1, 1, 1, 1, 1, 1, 9, 7, 7, 6, 0, 0, 0, 0, 0, 0, 0, 9, 8, 7, 8, 8, 8, 8, 8, 8, 8, 8, 9, 8, 6] 6] 6] 6] 6] 6] 6] 6] 6] 9] Se observa que Invariante: Al terminar la i-ésima iteración del para (lı́nea 1), la porción del arreglo L[0 : i + 1] está ordenada (recuerde que por convención, esto no incluye L[i + 1]). En la siguiente iteración, las lı́neas 3-6 determinan determinan la posición j + 1 donde K = L[i] es insertado, de tal manera que el invariante se mantiene. Número de Comparaciones: Nos interesa contar el número de comparaciones que el algoritmo realiza en el peor de los casos. Esto sucede, cuando la posición en que se debe insertar L[i] es el comienzo del arreglo, y esto puede ocurrir en cada iteración si el arreglo está inicialmente en orden decreciente. Por lo tanto el número de comparaciones es a lo más 1 1 1 1 + 2 + 3 + 4 + · · · + (n − 1) = n(n − 1) = n2 − n. 2 2 2 Se dice que el número de comparaciones realizadas es cuadrático (ya que el término n/2 es comparativamente menor a medida que n aumenta). 10 CAPÍTULO VI. ALGORITMOS: ORDENAMIENTO Código Python def ordenar_por_insercion(L): for i in range(1,len(L)): K = L[i] j = i-1 while j >= 0 and L[j] > K: L[j+1]=L[j] j=j-1 L[j+1]=K return(L) VI.3.2. Ordenamiento por Selección (Selection Sort) Este algoritmo iterativo comienza la i-ésima iteración con los primeros i elementos (es decir L[0], L[1], . . . , L[i − 1]) ya ordenados y en su posición final (los siguientes elementos no pueden ser menores que alguno de estos), y busca (selecciona) el menor de los restantes y lo intercambia con L[i]. (Note que inicialmente i = 0 y por lo tanto la lista de elementos L[0], L[1], . . . , L[i − 1] es vacı́a.) Ordenar-por-Seleccion (L) 1. para i ← 0 a longitud(L) − 2 B se determina k, indice de min en L[i :] 2. k←i B k es inicialmente i 3. para j ← i a longitud(L) − 1 4. si L[j] < L[k] 5. k←j B j es ahora indice de min B min de L[i :] es colocado en L[i] 6. intercambiar L[i] ↔ L[k] Ejemplo: A coninuación se muestra un arreglo inicial L, y esta misma después de cada una de las iteraciones del ciclo principal del algoritmo (para en la lı́nea 1): L= 0, 1, 2, 3, 4, 5, 6, 7, 8, [9, [0, [0, [0, [0, [0, [0, [0, [0, [0, Se observa que 7, 7, 1, 1, 1, 1, 1, 1, 1, 1, 3, 3, 3, 2, 2, 2, 2, 2, 2, 2, 5, 5, 5, 5, 3, 3, 3, 3, 3, 3, 2, 2, 2, 3, 5, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 5, 5, 5, 5, 5, 1, 1, 7, 7, 7, 7, 7, 6, 6, 6, 0, 9, 9, 9, 9, 9, 9, 9, 7, 7, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 6] 6] 6] 6] 6] 6] 6] 7] 9] 9] VI.3. ORDENAMIENTO 11 Invariante: Al terminar la i-ésima iteración del para (lı́nea 1), la porción del arreglo L[0 : i + 1] está ordenada, y los elementos restantes son mayores ó iguales que L[i]. Las lı́neas 2 − 5 determinan un elemento mı́nimo entre los restantes (puede no ser único) y lo intercambia con L[i] de tal manera que el invariante se preserva. Número de Comparaciones: Con un arreglo de longitud n, en la i-ésima iteración, la detección del mı́nimo puede requerir n − i − 1 comparaciones. Por lo tanto el número de comparaciones es a lo más 1 1 1 (n − 1) + (n − 2) + (n − 3) + · · · + 2 + 1 = n(n − 1) = n2 − n. 2 2 2 Aquı́ también el número de comparaciones realizadas es cuadrático. Código Python def ordenar_por_seleccion(L): for i in range(len(L)-1): k=i for j in range(i+1,len(L)): if L[j]<L[k]: k=j temp=L[i] L[i]=L[k] L[k]=temp return(L) VI.3.3. División y Conquista: Mezcla (Merge Sort) La idea de ordenar con mezcla es dividir la lista a ordenar en dos partes (lo más iguales posibles), ordenar cada una recursivamente, y luego mezclar ó combinar las dos listas ordenadas (entrada) en una sola lista ordenada (salida). Mezclar funciona de forma bastante intuitiva: iterativamente considera el primer elemento de las dos listas, y toma el elemento menor de ellos (ó uno cualquiera de los dos si son iguales) y lo coloca al final de la lista resultante, después de haberlo eliminado de su lista de entrada. Al final cuando una lista de entrada llega a ser vacı́a, lo que queda de la otra se agrega a la lista de salida. El seudocódigo a continuación, asume la existencia de funciones para agregar al final de una lista y eliminar al comienzo de una lista, y para concatenar dos listas (se usa + para representar esta operación porque ese es el sı́mbolo usado en python). Note que en las lı́neas 9 y 10, una de las listas L1 ó L2 es vacı́a; sólo hay que concatenar la no vacı́a, pero la operación con la otra no tiene efecto (y ası́ se simplifica algo el código). 12 CAPÍTULO VI. ALGORITMOS: ORDENAMIENTO Mezclar (L1 , L2 ) 1. L ← [] 2. mientras L1 6= [] y L2 6= [] 3. si L1 [0] ≤ L2 [0] 4. agregar L1 [0] a L 5. eliminar L1 [0] 6. si-no 7. agregar L2 [0] a L 8. eliminar L2 [0] 9. L ← L + L1 10. L ← L + L2 11. devolver (L) B agrega L1 [0] al final de L B elimina L1 [0] de L1 B B B B agrega L2 [0] al final de L elimina L2 [0] de L2 concatena L y L1 concatena L y L2 Ordenar-por-Mezcla (L) si longitud(L) ≤ 1 devolver (L) si-no m ← blongitud(L)/2c L1 ← Ordenar-por-Mezcla(L[: m]) L2 ← Ordenar-por-Mezcla(L[m :]) L ← Mezclar(L1 , L2 ) devolver (L) Ejemplo. El siguiente es un ejemplo de las listas L, L1 , L2 al final de cada una de las iteraciones del mientras en Mezclar, y al final del algoritmo: >>> mezclar([2,4,5,6,9,12],[1,1,4,7,8,10,13,14,15]) L= [1] L1= [2, 4, 5, 6, 9, 12] L2= [1, 4, 7, 8, 10, 13, 14, 15] L= [1, 1] L1= [2, 4, 5, 6, 9, 12] L2= [4, 7, 8, 10, 13, 14, 15] L= [1, 1, 2] L1= [4, 5, 6, 9, 12] L2= [4, 7, 8, 10, 13, 14, 15] L= [1, 1, 2, 4] L1= [5, 6, 9, 12] L2= [4, 7, 8, 10, 13, 14, 15] L= [1, 1, 2, 4, 4] L1= [5, 6, 9, 12] L2= [7, 8, 10, 13, 14, 15] L= [1, 1, 2, 4, 4, 5] L1= [6, 9, 12] L2= [7, 8, 10, 13, 14, 15] L= [1, 1, 2, 4, 4, 5, 6] L1= [9, 12] L2= [7, 8, 10, 13, 14, 15] L= [1, 1, 2, 4, 4, 5, 6, 7] L1= [9, 12] L2= [8, 10, 13, 14, 15] L= [1, 1, 2, 4, 4, 5, 6, 7, 8] L1= [9, 12] L2= [10, 13, 14, 15] L= [1, 1, 2, 4, 4, 5, 6, 7, 8, 9] L1= [12] L2= [10, 13, 14, 15] L= [1, 1, 2, 4, 4, 5, 6, 7, 8, 9, 10] L1= [12] L2= [13, 14, 15] L= [1, 1, 2, 4, 4, 5, 6, 7, 8, 9, 10, 12] L1= [] L2= [13, 14, 15] L=[1, 1, 2, 4, 4, 5, 6, 7, 8, 9, 10, 12, 13, 14, 15] Número de Comparaciones: Sea an el número de comparaciones entre elementos de la lista L realizadas por Ordenar-por-Mezcla en el peor de los casos para listas L de longitud n. Decimos en el peor de los casos porque dependiendo de L se pueden realizar más o menos comparaciones. El algoritmo realiza comparaciones en las ejecuciones recursivas con listas L1 y L2 , y en el algoritmo VI.3. ORDENAMIENTO 13 Mezclar. Note que si n es la longitud de L, entonces las longitudes de L1 y L2 son bn/2c y dn/2e. Ası́ que el número de comparaciones realizadas en estas ejecuciones recursivas es menor ó igual a abn/2c y adn/2e . Por otra parte: Observación. Mezclar realiza un número de comparaciones menor ó igual a |L1 | + |L2 | − 1 si al menos una de las listas no es vacı́a, ó 0 is ambas listas son vacı́as. Prueba. Si inicialmente ambas listas son vacı́as, entonces Mezclar no ejecuta el mientras y no realiza comparaciones, es decir realiza 0 comparaciones. Si inicialmente una lista es vacı́a y la otra no, entonces Mezclar tampoco ejecuta el mientras y no realiza comparaciones; pero como |L1 | + |L2 | ≥ 1 entonces podemos decir que realiza a lo más |L1 | + |L2 | − 1 comparaciones. Si inicialmente ninguna de las listas es vacı́a, entonces se ejecuta el mientras, y cada vez que se realiza una comparación en la lı́nea 2 de Mezclar (en el mientras), se elimina un elemento de L1 ó de L2 (y se agrega a L), excepto al menos un elemento al final cuando una lista llega a ser vacı́a) pero la la otra no lo es) y la ejecución del mientras termina. Ası́ que el número de comparaciones realizadas es la lo más |L1 | + |L2 | − 1. Note que es posible que se realicen menos de |L1 | + |L2 | − 1 comparaciones. Por ejemplo, si L1 = [3, 5, 6, 8] y L2 = [9, 11, 15, 16], entonces se realizan 4 comparaciones hasta que L1 es vacı́a, luego se agrega L2 a L sin realizar más comparaciones. Como se tiene que |L1 | + |L2 | = n, entonces el número de comparaciones realizadas por Ordenar-por-Mezcla con listas de longitud L en el peor de los casos, lo que hemos denotado con an satisface: an ≤ abn/2c + adn/2e + n − 1. (∗) (A diferencia de la recurrencia para la búsqueda binaria, donde tenı́amos igualdad, aquı́ no escribimos igualdad porque no sabemos en general de un ejemplo que es el peor de los casos para longitud n, y que en la recursión resulte en el peor de los casos para los problemas de taño bn/2c y dn/2e, pero esos problemas ciertamente no son peores que los peores.) El caso base para esta recurrencia es a1 = 0 (note la recurrencia expresa los demás en términos de a1 ; por ejemplo, a2 ≤ a1 + a1 + 1, a3 ≤ a1 + a2 + 2, etc.). Al tratar de resolver esta recurrencia encontramos dos problemas. Primero, es una desigualdad, mientras que estamos acostumbrados a resolver recurrencias en la forma de igualdades. Segundo, la presencia de las funciones piso y techo van complicar la solución. Manejamos estas dificultades de la siguiente manera: Desigualdad: Resolvemos la ecuación reemplazando la desigualdad con igualdad. El resultado es una cota superior para an . Más precisamente, para el ejemplo a mano, si reemplazamos la desigualdad con igualdad, cambiando al mismo tiempo an por bn (porque al cambiar por igualdad ya no se trata del mismo an ), se tiene bn = bbn/2c + bdn/2e + n − 1, 14 CAPÍTULO VI. ALGORITMOS: ORDENAMIENTO con b1 = 0. Si ahora resolvemos esta recurrencia y obtenemos una expresión para bn , entonces podemos concluir que an ≤ bn . Por qué ? Esto se puede verificar por inducción (fuerte): Para n = 1 se tiene a1 = b1 y por lo tanto a1 ≤ b1 , y para n < 1 se tiene an ≤ abn/2c + adn/2e + n − 1 ≤ bbn/2c + bdn/2e + n − 1 = bn por recurrencia de an por hipótesis de inducción, abn/2c ≤ bbn/2c y adn/2e ≤ bdn/2e por la recurrencia de bn . Cuando se hace esto, normalmente por simplicidad de notación no se introduce bn sino que simplemente se asume igualdad en la recurrencia de an , con el conocimiento de que el resultado no es exacto sino una cota superior. Pisos y techos: Se resuelve la ecuación primero para valores de n tal que el piso y el techo que aparecen en ella se manejan fácilmente cuando se itera la recurrencia. Esto es el caso por n = 2` donde ` ≥ 0 es un entero, porque al dividir por 2 se sigue obteniendo una potencia de 2. Luego se usa que an es creciente, es decir bn ≤ bn+1 para concluir una cota superior para el caso general. Por qué bn ≤ bn+1 ? Esto se puede verificar por inducción: EL caso base es b1 ≤ b2 (porque b1 = 0 y b2 = 1), y para n > 1 tenemos que bn ≤ bbn/2c + bdn/2e + n − 1 por recurrencia de an ≤ bb(n+1)/2c + bd(n+1)/2e + (n + 1) − 1 por hipótesis de inducción = bn+1 por recurrencia de bn (note que bn/2c ≤ b(n + 1)/2c y dn/2e ≤ d(n + 1)/2e se ha usado en la segunda lı́nea para usar la hipótesis de inducción). VI.3. ORDENAMIENTO 15 Entonces resolvemos ahora (∗) con igualdad para n = 2` iterando la relación de recurrencia (con igualdad) a2` = = = = = = .. . = .. . = = = = a2`−1 + a2`−1 + (2` − 1) 2a2`−1 + (2` − 1) 2(2a2`−2 + 2`−1 − 1) + (2` − 1) 22 a2`−2 + (2` − 2) + (2` − 1) 22 (2a2`−3 + (2`−2 − 1) + (2` − 2) + (2` − 1) 23 a2`−3 + (2` − 22 ) + (2` − 2) + (2` − 1) 2i a2`−i + (2` − 2i−1 ) + (2` − 2i−2 ) + · · · + (2` − 22 ) + (2` − 2) + (2` − 1) 2` a2`−` + (2` − 2`−1 ) + (2` − 2`−2 ) + · · · + (2` − 22 ) + (2` − 2) + (2` − 1) ` · 2` − (2`−1 + 2`−2 + · · · + 22 + 2 + 1) ` · 2` − (2` − 1) (` − 1) · 2` + 1, donde se ha usado que a1 = 0 y 2`−1 + 2`−2 + · · · + 22 + 2 + 1 = 2` − 1. En términos de n, esto es an = n · (log2 n − 1) + 1 si n es de la forma 2` . Veamos como extender esto a cualquier n: Si n no es de la forma 2` entonces está entre dos potencias de 2: 2`−1 < n < 2` (∗∗) y puesto que an es creciente, entonces an ≤ a2` = 2` (` − 1) + 1 Pero, teniendo en cuenta (∗∗), 2` = 2 · 2`−1 < 2n y ` − 1 < log2 n. Por lo tanto an < 2n log2 n + 1, lo cual es válido tambien cuando n es una potencia de 2. Se concluye entonces que an = O(n log2 n). 16 CAPÍTULO VI. ALGORITMOS: ORDENAMIENTO Código Python # ordenamiento: # division y mezcla # def mezclar(L1,L2): L=[] while L1!=[] and L2!=[]: if L1[0]<=L2[0]: L.append(L1[0]) L1.pop(0) else: L.append(L2[0]) L2.pop(0) L=L+L1 L=L+L2 return(L) VI.3.4. # def ordenar_por_mezcla(L): if len(L)<=1: return(L) else: m=int(len(L)/2) L1=ordenar_por_mezcla(L[:m]) L2=ordenar_por_mezcla(L[m:]) L=mezclar(L1,L2) return(L) División y Conquista: Pivote y Separación Este otro método de ordenar también usa el principio de división y conquista. La idea es escoger un pivote, el cual se usa para separar la lista entre elementos menores, iguales y mayores que el pivote. La primera y tercera lista se ordenan recursivamente y el resultado es la concatenación de las tres. Ordenar-por-Separacion (L) si longitud(L) ≤ 1 devolver (L) pivote ← Seleccione-Pivote(L) L1 ← []; L2 ← []; Lp ← [] para x en L si x < pivote agregar x a L1 si-no si L[i] > pivote agregar x a L2 si-no agregar x a Lp devolver (Ordenar-por-Separacion(L1 )+ Lp + Ordenar-por-Separacion(L2 )) Ejemplo: La figura muestra el diagrama de recursión resultante para la lista [3, 5, 1, 4, 8, 9, 2, 7, 6] cuando en cada caso el pivote es el primer elemento del subarreglo siendo ordenado. Número de Comparaciones: El número de comparaciones realizadas depende de la escogencia del pivote. En el peor de los casos, el pivote es siempre el máximo ó mı́nimo elemento y entonces una recursión es sobre una lista vacı́a y la otra sobre una lista con un elemento menos. Se obtiene una situación representada VI.3. ORDENAMIENTO 17 (3,5,1,4,8,9,2,7,6) (1,2) (5,4,8,9,7,6) (2) (4) (8,9,7,6) (7,6) (9) (6) por el diagrama en la figura (izquierda), donde el número en un nodo indica el tamaño de la lista a ordenar. El número de comparaciones es entonces cuadrático (los detalles son similares a los de ordenamiento por inserción y selección). Por otra parte, si el pivote resulta ser el elemento medio, entonces la partición es balanceada como se muestra en la figura (derecha). En este caso el análisis es como el de ordenamiento por mezcla y el resultado es O(n log n). En el diagrama se ignora que n/2, n/4 etc. no son necesariamente enteros (además hace falta restar 1, lo que es menos importante ya que nos interesa una cota superior), pero es útil para entender por qué el resultado es O(n log n): Si se suman los valores de los nodos en un mismo nivel, el resultado es n (porque es n/2i sumado 2i veces). Y el número de niveles es casi exactamente log2 n, porque este es el número de veces que n debe ser dividido por 2 para llegar al caso base de la recursión. Por lo tanto el número de comparaciones es O(n log n). La siguiente sección (incluı́da sólo por información; no fué discutida en clase) muestra que si el pivote se escoge aleatoriamente, entonces en promedio el número de comparaciones realizadas es O(n log n). n n 0 n−1 0 n/2 n−2 0 n−3 n/4 0 n/2 n/4 n/4 n/4 n−4 0 n−5 n/8 n/8 n/8 n/8 n/8 n/8 n/8 n/8 18 CAPÍTULO VI. ALGORITMOS: ORDENAMIENTO Código Python def seleccion_pivote(L): # selecciona un pivote return(L[0]) # def ordenar_por_separacion(L): if len(L)<=1: return(L) pivote=seleccion_pivote(L) L1=[]; L2=[]; Lp=[] for item in L: if item<pivote: L1.append(item) elif item>pivote: L2.append(item) elif item==pivote: Lp.append(pivote) return(ordenar_por_separacion(L1)+Lp+ordenar_por_separacion(L2)) # VI.3.5. Separación con Pivote Aleatorio: Quick Sort QuickSort es el algoritmo de ordenamiento por separación, con la adición de que el pivote se escoge aleatoriamente: cada elemento de la lista puede ser escogido como pivote con igual probabilidad. El siguiente análisis muestra que el número de comparaciones es O(n log n). (Nota: Esto no se hizo en clase y es sólo por beneficio de quien quiera leerlo.) Ecuación de Recurrencia Sea T (n) el número esperado de comparaciones para una lista de entrada de longitud n (el valor esperado es el promedio sobre todas las posibles elecciones del pivote). Por simplicidad asumimos que los elementos de la lista son diferentes. Supongamos que se escoge como pivote el k-ésimo elemento en orden creciente. Entonces, después de compararlo a todos los otros n − 1 elementos, la ejecuciones recursivas tienen listas de longitud k − 1 (elementos menores que el pivote) y de longitud n − k (elementos mayores que el pivote). Ası́ que el número esperado de comparaciones en este caso es (n − 1) + T (k − 1) + T (n − k). Puesto que cada uno de los elementos de la lista tiene igual probabilidad de ser elegido como pivote, es decir probabilidad 1/n, entonces el valor esperado teniendo en cuenta todas las posibles elecciones de pivote es 1X ((n − 1) + T (k − 1) + T (n − k)) T (n) = n k=1 n 1X (T (k − 1) + T (n − k)) , n k=1 n = (n − 1) + VI.3. ORDENAMIENTO 19 con T (0) = T (1) = 0, como casos base. Note que en la suma el primer término resulta en la suma de los términos T (0), T (1), T (2), . . . , T (n − 1) y el segundo en los términos T (n − 1), T (n − 2), . . . , T (0). Ası́ que esto se puede reescribir como 2X T (k). n k=1 n−1 T (n) = (n − 1) + Ahora nos ocupamos de resolver esta ecuación de recurrencia. Alternativa I: Solución aproximada de la ecuación de recurrencia Una posible forma de resolver esta ecuación es verificar que para alguna constante C (por determinar), se tiene que T (n) ≤ Cn ln n. Esto se hace usando inducción (fuerte). El caso base n = 1 es claro puesto que T (1) = 0. Entonces, usando la hipótesis de inducción para k = 1, 2, . . . , n − 1, es decir que T (k) ≤ Ck ln k, se tiene (el paso a la segunda lı́nea se aclara adelante) 2C X k ln k n k=1 2 2C n ln n n2 1 = (n − 1) + · − + n 2 4 4 1 = Cn ln n + (n − 1) − (C/2)n + 4 3 = Cn ln n + (1 − C/2)n − 4 ≤ Cn ln n + (1 − C/2)n. n−1 T (n) ≤ (n − 1) + Si C ≥ 2, esto es menor ó igual a Cn ln n. En la segunda lı́nea hemos usado la desigualdad n−1 X n2 ln n n2 1 k ln k ≤ − + 2 4 4 k=1 la cual se puede obtener acotando la suma con una integral: Zn n−1 X k ln k ≤ k ln k k=1 1 n 1 2 1 2 = k ln k − k 2 4 1 1 2 1 2 1 = n ln n − n + . 2 4 4 20 CAPÍTULO VI. ALGORITMOS: ORDENAMIENTO Alternativa II: Solución exacta de la ecuación de recurrencia. Aunque la recurrencia no se ajusta a algún método de solución que ya conocemos, con algo de manipulación se puede transformar en una que si es fácil de resolver. Primero, multiplicamos la recurrencia por n y restamos de ella la misma expresión para n − 1 (en lugar de n): nT (n) − (n − 1)T (n − 1) = (n − 1)n − (n − 2)(n − 1) + 2T (n − 1), y entonces T (n) = 2 − 2 n+1 n+1 n+1 + T (n − 1) = 4 − 2 + T (n − 1). n n n n Ahora substituı́mos t(n) = T (n)/(n + 1) y entonces obtenemos t(n) = 2 4 − + t(n − 1), n+1 n con t(0) Pn= 10. De aquı́, iterando la recurrencia, obtenemos, usando la notación Hn = i=1 i , t(n) = 4 n X i=1 X1 1 −2 i+1 i i=1 n = 4(Hn+1 − 1) − 2Hn 4 = 2Hn − 4 + . n+1 Finalmente, T (n) = (n + 1)t(n) = 2(n + 1)Hn − 4(n + 1) + 4. Hn es llamado el n-ésimo número armónico, y su valor es muy aproximadamente log n, de hecho log(n + 1) ≤ Hn ≤ log n + 1 lo que se puede obtener acotando con integrales. Por lo tanto se obtiene T (n) = O(n log n).