8. Ordenamiento
Problema Dado una lista de elementos {X1, X2, ..., XN}, se desea reordenarlos para que se cumpla Xi ≤ Xj para i < j Hasta ahora hemos visto 3 métodos: Selección: siempre O(n2) Inserción: mejor caso O(n) si estaba ordenado, peor caso O(n2) si esta ordenado inversamente Burbuja: similar a Inserción ¿Es posible hacerlo más rápido ?.
Cota inferior teórica Para ordenar tres datos A, B y C tenemos el siguiente árbol de decisión
Generalización Si tenemos n elementos hay n! formas de ordenamiento posibles Si partimos de un punto inicial basados en decisiones true-false debemos poder llegar a cada una de las n! hojas del árbol Un árbol binario completo de n! hojas tiene altura log(n!) Usando la aproximación de Stirling, se puede demostrar que log2 n! = n log2 n + O(n), por lo cual la cota inferior es de O(n log n).
Quicksort Inventado por C.A.R. Hoare a comienzos de los '60s, y sigue siendo el método más eficiente para uso general. Ejemplo clásico de la aplicación del principio de dividir para reinar. Su estructura es la siguiente: Primero se elige un elemento al azar, que se denomina el pivote. El arreglo a ordenar se reordena dejando a la izquierda a los elementos menores que el pivote, el pivote al medio, y a la derecha los elementos mayores que el pivote: Luego cada sub-arreglo se ordena recursivamente La recursividad se detiene en principio cuando hay 1 elemento o 0 pero es mejor detenerla con n=0 y aplicar otro algoritmo
Clase23: ordenamiento de arreglos J.Alvarez
Orden Mejor caso: siempre se escoge un pivote que parte el (sub-)arreglo en dos partes (casi) iguales. T(n) = 2T(n/2) + kn Peor caso: siempre se escoge un pivote que es el mayor o el menor del sub-arreglo. T(n) = T(n-1) + kn
Caso promedio El funcionamiento de Quicksort puede graficarse mediante un árbol de partición: El árbol de partición es un árbol de búsqueda binaria, y si el pivote es escogido al azar, la raíz de cada subárbol puede ser cualquiera de los elementos del conjunto en forma equiprobable. En consecuencia, los árboles de partición y los árboles de búsqueda binaria tienen exactamente la misma distribución. En el proceso de partición, cada elemento de los subárboles ha sido comparado contra la raíz (el pivote). Al terminar el proceso, cada elemento ha sido comparado contra todos sus ancestros. Si sumamos todas estas comparaciones, el resultado total es igual al largo de caminos internos. Usando todas estas correspondencias, tenemos que, usando los resultados ya conocidos para árboles, el número promedio de comparaciones que realiza Quicksort es de: 1.38 n log2 n + O(n) Por lo tanto, Quicksort es del mismo orden que la cota inferior (en el caso esperado).
Mejoras a Quicksort Quicksort puede ser optimizado de varias maneras, pero es desaconsejable hacer cosas que aumenten la cantidad de trabajo que se hace dentro del "loop" de partición, porque este es el lugar en donde se concentra el costo O(n log n). Quicksort con "mediana de 3" se extrae una muestra de 3 elementos, y entre ellos se escoge a la mediana de esa muestra como pivote. Si se toman el primer, el del medio y el último , el peor caso (arreglo ordenado) se transforma en mejor caso. Si la muestra se escoge al azar el análisis muestra que el costo esperado para ordenar n elementos es 1.19 n log2 n Esta se debe a que el pivote es ahora una mejor aproximación a la mediana. si en lugar de escoger una muestra de tamaño 3, lo hiciéramos con tamaños como 7, 9, etc., se lograría una mejor aproximación pero con rendimientos rápidamente decrecientes.
Uso de Ordenación por Inserción para ordenar sub-arreglos pequeños No es eficiente ordenar recursivamente sub-arreglos demasiado pequeños. En lugar de esto, se puede establecer un tamaño mínimo M, de modo que los sub-arreglos de tamaño menor que esto se ordenan por inserción en lugar de por Quicksort. Claramente debe haber un valor óptimo para M, porque si creciera indefinidamente se llegaría a un algoritmo cuadrático. Esto se puede analizar, y el óptimo es cercano a 10. Implementación Al detectarse un sub-arreglo de tamaño menor que M, se lo deja sin ordenar, retornando de inmediato de la recursividad. Al final se tiene un arreglo cuyos pivotes están en orden creciente, y encierran entre ellos a bloques de elementos desordenados, pero están en el grupo correcto. Para completar la ordenación, se hace una sola gran pasada de Ordenación por Inserción, la cual ahora no tiene costo O(n2), sino O(nM), porque ningún elemento esta a distancia mayor que M de su ubicación definitiva
Ordenar recursivamente sólo el sub-arreglo más pequeño Un problema potencial con Quicksort es la profundidad que puede llegar a tener el arreglo de recursividad. En el peor caso, ésta puede llegar a ser O(n). Para evitar esto, vemos primero cómo se puede programar Quicksort en forma no recursiva, usando un stack. El esquema del algoritmo sería el siguiente (en seudo-Java): void Quicksort(Object a[]) { Pila S = new Pila(); S.apilar(1,N); // límites iniciales del arreglo while(!S.estaVacia()) { (i,j) = S.desapilar(); // sacar límites if(j-i>0) { // al menos dos elementos para ordenar p = particionar(a,i,j); // pivote queda en a[p] S.apilar(i,p-1); S.apilar(p+1,j); }
Convertir la ultima llamada recursiva en un while Es posible que la pila llegue a tener profundidad O(n). Para evitarlo, colocar en la pila sólo los límites del sub-arreglo más pequeño, dejando el más grande para ordenarlo de inmediato, sin pasar por la pila: void Quicksort(Object a[]) { Pila S = new Pila(); S.apilar(1,N); // límites iniciales del arreglo while(!S.estaVacia()) { (i,j) = S.desapilar(); // sacar límites while(j-i>0) { // al menos dos elementos para ordenar p = particionar(a,i,j); // pivote queda en a[p] if(p-i>j-p) { // mitad izquierda es mayor S.apilar(p+1,j); j=p-1; } else { S.apilar(i,p-1); i=p+1; } Cada intervalo apilado es a lo más de la mitad del tamaño del arreglo, sea S(n) a la profundidad de la pila: S(n) <= 1 + S(n/2) lo cual tiene solución log2 n, de modo que la profundidad de la pila nunca es más que logarítmica.
Un algoritmo de selección basado en Quicksort Seleccionar el k-ésimo elemento de un arreglo. Idea : ejecutar Quicksort, pero ordenar la mitad en donde se encontraría el elemento buscado. Los elementos están en a[1],...,a[n] y k está entre 1 y n. Cuando el algoritmo termina, el k-ésimo elemento se encuentra en a[k]. Quickselect se llama inicialmente como Quickselect(a,k,1,N). void Quickselect(Object a[], int k, int i, int j) { if(j-i>0) { // aún quedan al menos 2 elementos p = particionar(a,i,j); if(p==k) // ¡bingo! return; if(k<p) // seguimos buscando a la izquierda Quickselect(a,k,i,p-1); else Quickselect(a,k,p+1,j); } El análisis de Quickselect es difícil, pero se puede demostrar que el costo esperado es O(n). Sin embargo, el peor caso es O(n2).
Heapsort Idea: dos fases: 1. Construccion del heap 2. Output del heap Para ordenar numeros ascendentemente: mayor valor => mayor prioridad (el mayor esta en la raiz) Heapsort es un procedimiento in-situ
Recordemos Heaps Heap con orden reverso: Para cada nodo x y cada sucesor y de x se cumple que m(x) m(y), left-complete, significa que los niveles se llenan partiendo por la raíz y cada nivel de izquierda a derecha Implementación en arreglo, donde los nodos se guardan en orden (de izquierda a derecha).
Primera Fase: 1. Construccion del Heap in-situ: métido simple : insert n-veces Cost0: O(n log n).
Segunda Fase costo: O(n log n). Sacar n-veces el maximo (en la raíz), e intercambiarlo con ultimo elemento del heap, dejarlo caer. El Heap se reduced en un elemento y el mayor queda al final. Repetir este proceso hasta que haya solo un elemento en el heap (el menor) costo: O(n log n). Heap Heap Ordered elements Ordered elements
Optimización primera Fase Definición segmento de heap como un segmento de arreglo a[ i..k ] ( 1 i k <=n ) donde se cumple: para todo j de {i,...,k} m(a[ j ]) m(a[ 2j ]) if 2j k y m(a[ j ]) m(a[ 2j+1]) if 2j+1 k Si a[i+1..n] es un segmento de heap podemos facilmente convertir a[i…n] en un segmento de heap tambien „hundiendo“ a[ i ].
Optimización primera Fase Definición segmento de heap como un segmento de arreglo a[ i..k ] ( 1 i k <=n ) donde se cumple: para todo j de {i,...,k} m(a[ j ]) m(a[ 2j ]) if 2j k y m(a[ j ]) m(a[ 2j+1]) if 2j+1 k Si a[i+1..n] es un segmento de heap podemos facilmente convertir a[i…n] en un segmento de heap tambien „hundiendo“ a[ i ].
Optimización primera Fase Se puede considerar cualquier arreglo a[1 … n ] como un segmento de heap desde a[n div 2] hasta a[n]. Los elementos de la mitad izquierda aun no ordenados se dejan “caer” en la secuencia: a[n div 2] … a[2] a[1] (los elementos … a[n div 2 +1] están ya en las hojas HH The leafs of the heap
Cost calculation k = [log n+1] es la altura del heap que se está construyendo en la fase 1 Para un elemento en el nivel j, suponiendo que los niveles j+1 hasta k estan construidos, el costo máximo de incluirlo en el segmento será: k – j. Además en cada nivel j hay 2j elementos En suma: {j=0,…,k} (k-j)•2j = 2k • {i=0,…,k} i/2i =2 • 2k = O(n).
Ventajas: Este procedimiento de construccion del heap es más rápido! Uso: cuando se requieren solo los m mayores elementos: 1. construccion en O(n) pasos. 2. obteneción de los m mayores elementos en O(m•log n) pasos. costo total : O( n + m•log n).
Addendum: Ordenando con árboles de búsqueda Algorithm: Construccion del árbol de búsqueda (e.g. AVL-tree) con lo elementos que hay que ordenar haciendo n opearciones de inserción. Obtención de los elementos recorriendo el árbol en secuencia InOrder. Secuencia Ordenada. costo: 1. O(n log n) con AVL-trees, 2. O(n). en total: O(n log n). optimal!
Bucketsort No se basa en comparación entre elementos Adecuado cuando los elementos están compuestos de un número fijo (y ojalá no muy grande) de símbolos Ejemplo: Ordenar 10 claves numéricas de 5 dígitos n = 10 k = 5 73895 93754 82149 99046 04853 94171 54963 70471 80564 66496
Idea general Es fácil ordenar un conjunto de números según una de sus componentes usando un arreglo de listas (colas) igual a la cantidad de símbolos distintos que pueden aparecer En el ejemplo, pueden aparecer 10 símbolos y se ordenó según el tercer digito 99046 82149 94171 70471 66496 80564 93754 73895 04853 54963
Distribución de los elementos en las colas En el caso del ejemplo, se tienen 10 colas Q[0]..Q[9] y para ordenar por el tercer elemento: Poner cada x en la cola Q[ x/100 mod 10] Armar una sola lista poniendo primero las que quedaron en Q[0] luego las de Q[1] .. Y por último las de Q[9] 99046 82149 94171 70471 66496 80564 93754 73895 04853 54963
Iteración Este proceso se hace iterativamente utilizando el último símbolo de la clave, luego el penúltimo y así hasta llegar al primero ¿ análisis ? 73895 93754 82149 99046 04853 94171 54963 70471 80564 66496 94171 70471 04853 54963 80564 93754 73895 66496 99046 82149 99046 82149 04853 93754 54963 80564 94171 70471 73895 66496 99046 82149 94171 70471 66496 80564 93754 73895 04853 54963 70471 80564 82149 93754 73895 94171 04853 54963 66496 99046 04853 54963 66496 70471 73895 80564 82149 93754 94171 99046
Sorting Externo Problema: ordenar un archivo muy grande guardado en bloques (páginas). Eficiencia: numero de acceso a páginas debe minimizarse! Estrategia: Usar un algoritmo que procese los datos en forma secuencial para evitar frecuentes cambios de página: MergeSort!
Forma General para Merge mergesort(S) { # retorna el conjunto S ordenado if(S es vacío o tiene sólo 1 elemento) return(S); else { Dividir S en dos mitades A y B; A'=mergesort(A); B'=mergesort(B); return(merge(A',B')); } Clásico ejemplo de dividir para reinar
void merge(Comparable[]x,int ip,int im,int iu){ Comparable[]a=new Comparable[iu+1]; int i=ip,i1=ip,i2=im+1; while (i1<=im && i2 <= iu) if (x[i1] < x[i2]) a[i++]=x[i1++]; else a[i++]=x[i2++]; while (i1 <= im) a[i++]=x[i1++]; while (i2 <= iu) a[i++]=x[i2++]; for(int i=ip; i<=iu; ++i) x[i]=a[i]; }
Análisis
Meregesort en Archivos: Start: se tienen n datos en un archivo g1, divididos en páginas de tamaño b: Page 1: s1,…,sb Page 2: sb+1,…s2b … Page k: s(k-1)b+1 ,…,sn ( k = [n/b]+ ) Si se procesan secuencialmente se hacen k accesos a paginas, no n.
Variacion de MergeSort para external sorting MergeSort: Divide-and-Conquer-Algorithm Para external sorting: sin el paso divide, solo merge. Definicion: run := subsecuencia ordenada dentro de un archivo. Estrategia: by merging increasingly bigger generated runs until everything is sorted.
Algoritmo 1. Step: Generar del input file g1 „starting runs“ y distribuirlas en dos archivos f1 and f2, con el mismo numero de runs (1) en cada uno (for this there are many strategies, later). Ahora: use 4 files f1, f2, g1, g2.
2. Step (main step): while (number of runs > 1) { Merge each two runs from f1 and f2 to a double sized run alternating to g1 und g2, until there are no more runs in f1 and f2. Merge each two runs from g1 and g2 to a double sized run alternating to f1 and f2, until there are no more runs in g1 und g2. } Each loop = two phases
Example: Start: g1: 64, 17, 3, 99, 79, 78, 19, 13, 67, 34, 8, 12, 50 1st. step (length of starting run= 1): f1: 64 | 3 | 79 | 19 | 67 | 8 | 50 f2: 17 | 99 | 78 | 13 | 34 | 12 Main step, 1st. loop, part 1 (1st. Phase ): g1: 17, 64 | 78, 79 | 34, 67 | 50 g2: 3, 99 | 13, 19 | 8, 12 1st. loop, part 2 (2nd. Phase): f1: 3, 17, 64, 99 | 8, 12, 34, 67 | f2: 13, 19, 78, 79 | 50 |
Example continuation 1st. loop, part 2 (2nd. Phase): f1: 3, 17, 64, 99 | 8, 12, 34, 67 | f2: 13, 19, 78, 79 | 50 | 2nd. loop, part 1 (3rd. Phase): g1: 3, 13, 17, 19, 64, 78, 79, 99 | g2: 8, 12, 34, 50, 67 | 2nd. loop, part 2 (4th. Phase): f1: 3, 8, 12, 13, 17, 19, 34, 50, 64, 67, 78, 79, 99 | f2:
Costs Page accesses during 1. step and each phase: O(n/b) In each phase we divide the number of runs by 2, thus: Total number of accesses to pages: O((n/b) log n), when starting with runs of length 1. Internal computing time in 1 step and each phase is: O(n). Total internal computing time: O( n log n ).
Two variants of the first step: creation of the start runs A) Direct mixing sort in primary memory („internally“) as many data as possible, for example m data sets First run of a (fixed!) length m, thus r := n/m starting runs. Then we have the total number of page accesses: O( (n/b) log(r) ).
Two variants of the first step: creation of the start runs B) Natural mixing Creates starting runs of variable length. Advantage: we can take advantage of ordered subsequences that the file may contain Noteworthy: starting runs can be made longer by using the replacement-selection method by having a bigger primary storage !
Replacement-Selection Read m data from the input file in the primary memory (array). repeat { mark all data in the array as „now“. start a new run. while there is a „now“ marked data in the array { select the smallest (smallest key) from all „now“ marked data, print it in the output file, replace the number in the array with a number read from the input file (if there are still some) mark it „now“ if it is bigger or equal to the last outputted data, else mark it as „not now“. } Until there are no data in the input file.
Example: array in primary storage with capacity of 3 The input file has the following data: 64, 17, 3, 99, 79, 78, 19, 13, 67, 34, 8, 12, 50 In the array: („not now“ data written in parenthesis) Runs : 3, 17, 64, 78, 79, 99 | 13, 19, 34, 67 | 8, 12, 50 64 17 3 99 79 78 (19) (13) (67) 19 13 67 34 (8) (12) (50) 8 12 50
Implementation: In an array: At the front: Heap for „now“ marked data, At the back: refilled „not now“ data. Note: all „now“ elements go to the current generated run.
Expected length of the starting runs using the replace-select method: (m = size of the array in the primary storage = number of data that fit into primary storage) by equally probabilities distribution Even bigger if there is some previous sorting!
Multi-way merging Instead of using two input and two output files (alternating f1, f2 and g1, g2) Use k input and k output files, in order to me able to merge always k runs in one. In each step: take the smallest number among the k runs and output it to the current output file.
Cost: In each phase: number of runs is devided by k, Thus, if we have r starting runs we need only logk(r) phases (instead of log2(r)). Total number of accesses to pages: O( (n/b) logk(r) ). Internal computing time for each phase: O(n log2 (k)) Total internal computing time: O( n log2(k) logk(r)) = O( n log2(r) ).