VI Colas de prioridad
Una cola de prioridad (cat: cua de prioritat; ing: priority queue) es una colección de elementos donde cada elemento tiene asociado un valor susceptible de ordenación denominado prioridad. Una cola de prioridad se caracteriza por admitir inserciones de nuevos elementos y la consulta y eliminación del elemento de mínima prioridad. Análogamente se pueden definir colas de prioridad que admitan la consulta y eliminación del elemento de máxima prioridad en la colección. Colas de prioridad 323
En esta especificación asumimos que el tipo Prio ofrece una relación de orden total <. Por otra parte, puede darse el caso de que existan varios elementos con igual prioridad y en dicho caso es irrelevante cuál de los elementos es devuelto por min o eliminado por elim min. En ocasiones se utiliza una operación prio min que devuelve la mínima prioridad. Colas de prioridad 324
template <typename Elem, typename Prio> class ColaPrioridad { public: // Constructora, crea una cola vacía. ColaPrioridad() throw( error); // Destructora, constr. por copia y asignación ~ColaPrioridad() throw(); ColaPrioridad(const ColaPrioridad& P) throw(error); ColaPrioridad& operator =( const ColaPrioridad P) throw( error); // A~nade el elemento x con prioridad p a la cola de // prioridad. void inserta(cons Elem& x, const Prio& p) throw(error) // Devuelve un elemento de mínima prioridad en la cola de // prioridad. Se lanza un error si la cola está vacía. Elem min() const throw(error); // Devuelve la mínima prioridad presente en la cola de // prioridad. Se lanza un error si la cola está vacía. Prio prio_min() const throw(error); // Elimina un elemento de mínima prioridad de la cola de // prioridad. Se lanza un error si la cola está vacía. void elim_min() throw(error); // Devuelve cierto si y sólo si la cola está vacía. bool vacia() const throw(); ; Colas de prioridad 325
Las colas de prioridad tienen múltiples usos. Con frecuencia se emplean para implementar algoritmos voraces. Este tipo de algoritmos suele tener una iteración principal, y una de las tareas a realizar en cada una de dichas iteraciones es seleccionar un elemento de entre varios que minimiza (o maximiza) un cierto criterio de optimalidad local. El conjunto de elementos entre los que se ha de efectuar la selección es frecuentemente dinámico y admitir inserciones eficientes. Algunos de estos algoritmos incluyen: Algoritmos de Kruskal y Prim para el cálculo del árbol de expansión mínimo de un grafo etiquetado. Algoritmo de Dijkstra para el cálculo de caminos mínimos en un grafo etiquetado. Construcción de códigos de Huffman (códigos binarios de longitud media mínima). Otra tarea para la que obviamente podemos usar una cola de prioridad es para ordenar. Colas de prioridad 326
// Tenemos dos arrays Peso y Simb con los pesos atómicos // y símbolos de n elementos químicos, // p.e., Simb[i] = "C" y Peso[i] = 12.2. // Utilizamos una cola de prioridad para ordenar la // información de menor a mayor símbolo en orden alfabético ColaPrioridad<double, string> P; for (int i = 0; i < n; ++i) P.inserta(Peso[i], Simb[i]); int i = 0; while(! P.vacia()) { Peso[i] = P.min(); Simb[i] = P.prio_min(); ++i; P.elim_min(); También se usan colas de prioridad para hallar el k-ésimo elemento de un vector no ordenado. Se colocan los k primeros elementos del vector en una max-cola de prioridad y a continuación se recorre el resto del vector, actualizando la cola de prioridad cada vez que el elemento es menor que el mayor de los elementos de la cola, eliminando al máximo e insertando el elemento en curso. Colas de prioridad 327
La mayoría de técnicas empleadas en la implementación de diccionarios puede usarse para implementar colas de prioridad. La excepción la constituyen las tablas de dispersión y los tries. Se puede usar una lista ordenada por prioridad. Tanto la consulta como la eliminación del mínimo son triviales y su coste es Θ(1). Pero las inserciones tienen coste lineal, tanto en caso peor como en promedio. Otra opción es usar un árbol de búsqueda (equilibrado o no), utilizando como criterio de orden de sus elementos las correspondientes prioridades. Hay que modificar ligeramente el invariante de representación para admitir y tratar adecuadamente las prioridades repetidas. Puede garantizarse en tal caso que todas las operaciones (inserciones, consultas, eliminaciones) tienen coste Θ(logn): en caso peor si el BST es equilibrado (AVL), y en caso medio si no lo es. Colas de prioridad 328
También pueden usarse skip lists con idéntico rendimiento al que ofrecen los BSTs (aunque los costes son logarítmicos sólo en caso medio, dicho caso medio no depende ni del orden de inserción ni de la existencia de pocas prioridades repetidas). Si el conjunto de posibles prioridades es reducido entonces será conveniente emplear una tabla de listas, correspondiendo cada lista a una prioridad o intervalo reducido de prioridades. En lo que resta estudiaremos una técnica específica para la implementación de colas de prioridad basada en los denominados montículos. Colas de prioridad 329
Un montículo (ing: heap) es un árbol binario tal que 1. todos las hojas (subárboles son vacíos) se sitúan en los dos últimos niveles del árbol. 2. en el antepenúltimo nivel existe a lo sumo un nodo interno con un sólo hijo, que será su hijo izquierdo, y todos los nodos a su derecha en el mismo nivel son nodos internos sin hijos. 3. el elemento (su prioridad) almacenado en un nodo cualquiera es mayor (menor) o igual que los elementos almacenados en sus hijos izquierdo y derecho. Se dice que un montículo es un árbol binario quasi-completo debido a las propiedades 1-2. La propiedad 3 se denomina orden de montículo, y se habla de max-heaps o min-heaps según que los elementos sean ó ques sus hijos. En lo sucesivo sólo consideraremos max-heaps. Colas de prioridad 330
n = 10 76 nivel 0 altura = 4 72 34 nivel 1 59 63 17 29 nivel 2 37 33 29 nivel 3 nivel 4 hojas Colas de prioridad 331
De las propiedades 1-3 se desprenden dos consecuencias importantes: 1. El elemento máximo se encuentra en la raíz. 2. Un heap de n elementos tiene altura h = log 2 (n+1). La consulta del máximo es sencilla y eficiente pues basta examinar la raíz. Colas de prioridad 332
Cómo eliminar el máximo? Un procedimiento que se emplea a menudo consiste en ubicar al último elemento del montículo (el del último nivel más a la derecha) en la raíz, sustituyendo al máximo; ello garantiza que se preservan las propiedades 1-2. Pero como la propiedad 3 deja eventualmente de satisfacerse, debe reestablecerse el invariante para lo cual se emplea un procedimiento privado denominado hundir. Éste consiste en intercambiar un nodo con el mayor de sus dos hijos si el nodo es menor que alguno de ellos, y repetir este paso hasta que el invariante se haya reestablecido. Colas de prioridad 333
32 72 34 59 63 17 29 37 33 29 72 32 34 59 63 17 29 37 33 29 72 63 34 59 32 17 29 37 33 29 Colas de prioridad 334
Cómo añadir un nuevo elemento? Una posibilidad consiste en colocar el nuevo elemento como último elemento del montículo, justo a la derecha del último o como primero de un nuevo nivel. Para ello hay que localizar al padre de la primera hoja y sustituirla por un nuevo nodo con el elemento a insertar. A continuación hay que reestablecer el orden de montıculo empleando para ello un procedimiento flotar, que trabaja de manera similar pero a la inversa de hundir: el nodo en curso se compara con su nodo padre y se realiza el intercambio si éste es mayor que el padre, iterando este paso mientras sea necesario. Colas de prioridad 335
Puesto que la altura del heap es Θ(logn) el coste de inserciones y eliminaciones es O(logn). Se puede implementar un heap mediante memoria dinámica. La representación elegida debe incluir apuntadores al hijo izquierdo y derecho y también al padre, y resolver de manera eficaz la localización del último elemento y del padre de la primera hoja. Una alternativa atractiva es la implementación de heaps mediante un vector. No se desperdicia demasiado espacio ya que el heap es quasi-completo. Las reglas para representar los elementos del heap en un vector son simples: 1. A[1] contiene la raíz. 2. Si 2i n entonces A[2i] contiene al hijo izquierdo del elemento en A[i] y si 2i + 1 n entonces A[2i+1] contiene al hijo derecho de A[i]. 3. Si idiv 2 1 entonces A[idiv 2] contiene al padre de A[i]. Colas de prioridad 336
Nótese que las reglas anteriores implican que los elementos del montículo se ubican en posiciones consecutivas del vector, colocando la raíz en la primera posición y recorriendo el árbol por niveles, de izquierda a derecha. template <typename Elem, typename Prio> class ColaPrioridad { public:... private: static const int MAX_ELEM =...; int nelems; // número de elementos en el heap // array de MAX_ELEMS pares <Elem, Prio>, // la componente 0 no se usa pair<elem, Prio> h[max_elem + 1]; ; void flotar(int j) throw(); void hundir(int j) throw(); Colas de prioridad 337
template <typename Elem, typename Prio> bool ColaPrioridad<Elem,Prio>::vacia() const throw() { return nelems == 0; template <typename Elem, typename Prio> void ColaPrioridad<Elem,Prio>::inserta(cons Elem& x, cons Prio& p) throw(error) { if (nelems == MAX_ELEMS) throw error(colallena); ++nelems; h[nelems] = make_pair(x, p); flotar(nelems); template <typename Elem, typename Prio> Elem ColaPrioridad<Elem,Prio>::min() const throw(error) { if (nelems == 0) throw error(colavacia); return h[1].first; template <typename Elem, typename Prio> Prio ColaPrioridad<Elem,Prio>::prio_min() const throw(error) { if (nelems == 0) throw error(colavacia); return h[1].second; template <typename Elem, typename Prio> void ColaPrioridad<Elem,Prio>::elim_min() const throw(error) { if (nelems == 0) throw error(colavacia); swap(h[1], h[nelems]); --nelems; hundir(1); Colas de prioridad 338
// Versión recursiva. // Hunde el elemento en la posición j del heap // hasta reestablecer el orden del heap; por // hipótesis los subárboles del nodo j son heaps. // Coste: O(log(n/j)) template <typename Elem, typename Prio> void ColaPrioridad<Elem,Prio>::hundir(int j) throw() { // si j no tiene hijo izquierdo, hemos terminado if (2 * j > nelems) return; int minhijo = 2 * j; if (minhijo < nelems && h[minhijo].second > h[minhijo + 1].second) ++minhijo; // minhijo apunta al hijo de minima prioridad de j // si la prioridad de j es mayor que la de su menor hijo // intercambiar y seguir hundiendo if (h[j].second > h[minhijo].second) { swap(h[j], h[minhijo]); hundir( minhijo); Colas de prioridad 339
// Versión iterativa. // Hunde el elemento en la posición j del heap // hasta reestablecer el orden del heap; por // hipótesis los subárboles del nodo j son heaps. // Coste: O(log(n/j)) template <typename Elem, typename Prio> void ColaPrioridad<Elem,Prio>::hundir(int j) throw() { bool fin = false; while (2 * j <= nelems &&!fin) { int minhijo = 2 * j; if (minhijo < nelems && h[minhijo].second > h[minhijo + 1].second) ++minhijo; if (h[j].second > h[minhijo].second) { swap(h[j], h[minhijo]); j = minhijo; else { fin = true; Colas de prioridad 340
// Flota al nodo j hasta reestablecer el orden del heap; // todos los nodos excepto el j satisfacen la propiedad // de heap // Coste: O(log j) template <typename Elem, typename Prio> void ColaPrioridad<Elem,Prio>::flotar(int j) throw() { // si j es la raíz, hemos terminado if (j == 1) return; int padre = j / 2; // si el padre tiene mayor prioridad // que j, intercambiar y seguir flotando if (h[j].second < h[padre].second) { swap(h[j], h[padre]); flotar(padre); Colas de prioridad 341
Heapsort Heapsort (Williams, 1964) ordena un vector de n elementos construyendo un heap con los n elementos y extrayéndolos, uno a uno del heap a continuación. El propio vector que almacena a los n elementos se emplea para construir el heap, de modo que heapsort actúa in-situ y sólo requiere un espacio auxiliar de memoria constante. El coste de este algoritmo es Θ(nlogn) (incluso en caso mejor) si todos los elementos son diferentes. En la práctica su coste es superior al de quicksort, ya que el factor constante multiplicativo del término nlogn es mayor. Colas de prioridad 342
// Ordena el vector v[1..n] // (v[0] no se usa) // de Elem s de menor a mayor template < typename Elem > void heapsort(elem v[], int n) { crea_max_heap(v, n); for (int i = n; i > 0; --i) { // saca el mayor elemento del heap swap(v[1], v[i]); // hunde el elemento de indice 1 // para reestablecer un max-heap en // el subvector v[1..i-1] hundir(v, i-1, 1); 1 i n A heap ordenado <_ <_ <_ <_ A[1] A[i+1] A[i+2]... A[n] A[1] = max 1 <_ k <_ i A[k] Colas de prioridad 343
76 76 72 34 59 63 17 29 37 33 29 72 34 i = 10 59 63 17 29 37 33 29 29 29 72 34 59 63 17 29 37 33 76 72 34 i = 9 59 63 17 29 37 33 72 72 63 34 59 29 17 29 37 33 76 63 34 i = 9 59 29 17 29 37 33 Colas de prioridad 344
33 33 63 34 59 29 17 29 37 72 76 63 34 i = 8 59 29 17 29 37 33 33 59 34 37 29 17 29 63 72 76 59 34 i = 7 37 29 17 29 59 59 37 34 33 29 17 29 63 72 76 i = 7 37 34 33 29 17 29 Colas de prioridad 345
// Da estructura de max-heap al // vector v[1..n] de Elem s; aquí // cada elemento se identifica con su // prioridad template < typename Elem > void crea_max_heap(elem v[], int n) { for (int i = n/2; i > 0; --i) hundir(v, n, i); Colas de prioridad 346
13 13 2 5 27 8 15 10 4 2 5 i = 8 27 8 15 10 4 satisfacen la propiedad de heap 1 13 hundir(a, 8, 4) hundir(a, 8, 3) 2 2 3 15 4 27 5 8 6 7 5 10 8 4 1 27 hundir(a, 8, 2) hundir(a, 8, 1) 2 13 3 15 4 4 5 8 6 7 5 10 8 2 Colas de prioridad 347
Sea H(n) el coste en caso peor de heapsort y B(n) el coste de crear el heap inicial. El coste en caso peor de hundir(v,i 1,1) es O(logi) y por lo tanto H(n) = B(n)+ i=n i=1 O(log i) ( ) = B(n)+O log 2 i 1 i n = B(n)+O(log(n!)) = B(n)+O(nlogn) Un análisis grueso de B(n) indica que B(n) = O(nlogn) ya que hay Θ(n) llamadas a hundir, cada una de las cuales tiene coste O(logn). Podemos concluir por tanto que H(n) = O(nlogn). No es difícil construir una entrada de tamaño n tal que H(n) = Ω(nlogn) y por tanto H(n) = Θ(nlogn) en caso peor. La demostración de que el coste de heapsort en caso mejor 3 es también Θ(nlogn) es bastante más complicada. 3 Siendo todos los elementos distintos. Colas de prioridad 348
Por otra parte, la cota dada para B(n) podemos refinarla ya que B(n) = O(log(n/i)) 1 i n/2 ) = O (log nn/2 (n/2)! ) = O (log(2e) n/2 = O(n). Puesto que B(n) = Ω(n), podemos afirmar que B(n) = Θ(n). Otra forma de demostrar que B(n) es lineal consiste en razonar del siguiente modo: Sea h = log 2 (n+1) la altura del heap. En el nivel h 1 k hay como mucho 2 h 1 k < n+1 2 k nodos y cada uno de ellos habrá de hundirse en caso peor hasta el nivel h 1; eso tiene coste O(k). Colas de prioridad 349
Por lo tanto, ya que B(n) = = O = O En general, si r < 1, 0 k h 1 ( ( n k 0 O(k) n+1 2 k k 0 k h 1 2 k k n k 0 2 k k r k = k 0 ) k 2 k = 2. ) r (1 r) 2. = O(n), Colas de prioridad 350
Aunque globalmente H(n) = Θ(n log n), es interesante el análisis detallado de B(n). Por ejemplo, utilizando un min-heap podemos hallar los k menores elementos de un vector (y en particular el k-ésimo) con coste: S(n,k) = B(n)+k O(logn) = O(n+klogn). y si k = O(n/ log n) entonces S(n, k) = O(n). Colas de prioridad 351