Atlasingeniería

Estructuras de datosÁrbolesTema 4

Heaps y colas de prioridad

Un heap mantiene el mínimo o el máximo en la raíz sin ordenar todo. Su forma completa permite guardarlo en un arreglo y actualizar prioridades en tiempo logarítmico.

Para este tema conviene tener claro:Árboles binarios y recorridos

Si sólo necesitás retirar siempre la tarea más urgente, ordenar toda la colección después de cada cambio es trabajo de más. Un heap mantiene justo la información necesaria para acceder a la mejor prioridad rápidamente.

Las dos invariantes de un heap

Un heap binario combina dos invariantes. Su forma es un árbol completo: todos los niveles están llenos salvo quizá el último, que se ocupa de izquierda a derecha. En un min-heap, la clave de cada padre es menor o igual que la de sus hijos.

Eso garantiza que el mínimo está en la raíz. No garantiza que hermanos ni subárboles estén ordenados entre sí.

Antes de seguir, predecí

El arreglo [1, 3, 2, 7, 4], ¿es un heap mínimo válido?

Sin punteros: la aritmética de índices

La forma completa permite omitir punteros y guardar los nodos en un arreglo. Para una posición ii comenzando en cero, el padre se calcula cuando i>0i>0:

padre(i)=i12,izquierdo(i)=2i+1,derecho(i)=2i+2.padre(i)=\left\lfloor\frac{i-1}{2}\right\rfloor, \quad izquierdo(i)=2i+1, \quad derecho(i)=2i+2.

Las relaciones salen de los índices. La representación es compacta y aprovecha localidad de memoria.

Insertar y subir

Para insertar, agregamos el nuevo valor al final, única posición que conserva la forma completa. Si viola el orden con su padre, los intercambiamos y repetimos hacia arriba. Este proceso se llama ascenso o sift up.

Como el camino hasta la raíz tiene altura O(logn)O(\log n), insertar cuesta O(logn)O(\log n). Consultar el mínimo sólo lee la raíz y cuesta O(1)O(1).

Min-heap con cuatro claves. El mínimo está en la raíz; entre hermanos no hay ningún orden garantizado.

1 / 4
El valor nuevo entra en la única posición que conserva la forma completa y sube mientras viole el orden. Sube por un camino, no por todo el árbol: por eso cuesta la altura.

Extraer y bajar

Para extraer el mínimo, guardamos la raíz, movemos el último elemento a su lugar y reducimos el arreglo. Luego lo intercambiamos con el menor de sus hijos mientras viole la invariante. Ese descenso, o sift down, cuesta O(logn)O(\log n).

Elegir el hijo menor es esencial: intercambiar con cualquiera puede dejar al nuevo padre por encima de una clave todavía menor.

Construirlo de una vez sale lineal

Insertar nn elementos uno por uno cuesta O(nlogn)O(n\log n). Pero si ya tenemos todos los datos, podemos construir el heap desde el último nodo con hijos y aplicar descensos hacia la raíz. Ese algoritmo cuesta O(n)O(n).

Aunque parece haber muchos descensos, la mayoría de los nodos está cerca de las hojas y baja muy pocos niveles. Sólo una fracción pequeña puede recorrer toda la altura.

Dónde aparece una cola de prioridad

Una cola de prioridad expone insertar, consultar y extraer la mejor prioridad; el heap es su implementación habitual. Aparece en planificación de tareas, Dijkstra, simulación de eventos y selección de los kk mejores elementos.

No sirve para buscar una clave arbitraria en tiempo logarítmico. Su orden es parcial, diseñado para optimizar un extremo, no para recorrer todo en orden.

Un heap binario, escrito

Todo el heap vive en un arreglo, y las relaciones de padre e hijo son aritmética de índices.

class MinHeap {
  private readonly values: number[] = [];

  private static parentOf(index: number): number {
    return Math.floor((index - 1) / 2);
  }

  push(value: number): void {
    this.values.push(value);
    let index = this.values.length - 1;

    while (index > 0) {
      const parent = MinHeap.parentOf(index);
      if (this.values[parent]! <= this.values[index]!) break; // ya está en su lugar
      [this.values[parent], this.values[index]] = [this.values[index]!, this.values[parent]!];
      index = parent;
    }
  }

  pop(): number | undefined {
    if (this.values.length === 0) return undefined;
    const top = this.values[0]!;
    const last = this.values.pop()!;
    if (this.values.length === 0) return top;

    this.values[0] = last;
    let index = 0;

    while (true) {
      const left = index * 2 + 1;
      const right = left + 1;
      let smallest = index;

      if (left < this.values.length && this.values[left]! < this.values[smallest]!) smallest = left;
      if (right < this.values.length && this.values[right]! < this.values[smallest]!) smallest = right;
      if (smallest === index) break;

      [this.values[smallest], this.values[index]] = [this.values[index]!, this.values[smallest]!];
      index = smallest;
    }

    return top;
  }
}

Los dos ciclos son la estructura entera. El de push sube el valor nuevo mientras sea menor que su padre; el de pop baja el último valor —que quedó arriba— mientras alguno de sus hijos sea menor. Los dos recorren como mucho la altura del árbol, que es logn\log n porque el árbol es completo, y ahí está el O(logn)O(\log n) de las dos operaciones.

Para qué sirve tener sólo el mínimo

Necesito...HeapArreglo ordenadoÁrbol balanceado
el mínimoO(1)O(1)O(log n)
sacar el mínimoO(log n)O(n) o O(1) desde el finalO(log n)
insertarO(log n)O(n)O(log n)
buscar un valor cualquieraO(n)O(log n)O(log n)
recorrer todo en ordenO(n log n)O(n)O(n)
construir desde n elementosO(n)O(n log n)O(n log n)
La última fila es la que sorprende: construir un heap desde cero es lineal, más barato que ordenar. Y las filas del medio muestran el precio: fuera del mínimo, el heap no sabe nada.
Más a fondo · formalPor qué construirlo cuesta O(n) y no O(n log n)

Meter nn elementos de a uno cuesta O(nlogn)O(n \log n): cada push puede subir hasta la raíz. Pero hay otra forma: poner los nn elementos en el arreglo en cualquier orden y aplicar el descenso desde la mitad hacia atrás. Eso cuesta O(n)O(n), y la razón es una cuenta que vale la pena ver.

En un árbol completo de nn nodos, la mitad son hojas y no hay que hacerles nada. Un cuarto está a altura 1 y puede bajar como mucho un nivel. Un octavo está a altura 2 y puede bajar dos. El trabajo total es hn2h+1h\sum_{h} \frac{n}{2^{h+1}} \cdot h, que converge a 2n2n.

La intuición detrás de la suma es que hay muchos nodos baratos y pocos caros: los que podrían recorrer todo el árbol son los de arriba, y arriba hay uno solo. Meterlos de a uno es caro justamente porque cada elemento nuevo entra por abajo y puede subir todo el camino.

Cierre

El heap evita ordenar información que no necesitamos. Su forma completa habilita un arreglo; su orden parcial deja el mejor elemento en la raíz. Consultar cuesta O(1)O(1), insertar y extraer O(logn)O(\log n), y construir desde todos los datos puede hacerse en O(n)O(n).

Autoevaluación

¿Lo entendiste?

¿Por qué un heap binario se puede guardar en un arreglo sin punteros?
Al extraer el mínimo se baja el último elemento a la raíz. ¿Con cuál hijo hay que intercambiarlo?
Ya tenés los n datos y querés un heap. ¿Cuánto cuesta?
¿Para qué NO sirve un heap?