Salta al contenuto
Note per Studenti Heap

Heap

In questa pagina 9

Lo heap è l'implementazione efficiente della coda con priorità (vedi Code con prioritàEntry chiave-valore; ADT coda con priorità (insert, min, removeMin) con chiave minima = priorità massima; esempio di esecuzione; applicazioni; implementazioni con lista non ordinata e ordinata e relativi costi; ordinamento tramite coda con priorità.Code con priorità →): bilancia i costi, con min() in Θ(1)\Theta(1) e insert, removeMin() in Θ(log⁡n)\Theta(\log n).

Albero binario completo

Un albero binario di altezza hh è completo se:

  • per ogni livello ii con 0≤i≤h−10 \le i \le h - 1 ci sono 2i2^i nodi (livello pieno);
  • al livello hh i nodi sono tutti a sinistra delle eventuali mancanze: nessun buco, e al più l'ultimo nodo interno ha solo il figlio sinistro.

Proposizione. Un albero binario completo con nn nodi ha altezza h=⌊log⁡2n⌋h = \lfloor \log_2 n \rfloor.

Dimostrazione. I livelli 0,…,h−10, \dots, h-1 sono pieni e l'ultimo ha almeno un nodo: 2h=1+∑j=0h−12j≤n≤∑j=0h2j=2h+1−12^h = 1 + \sum_{j=0}^{h-1} 2^j \le n \le \sum_{j=0}^{h} 2^j = 2^{h+1} - 1. Quindi h≤log⁡2n<h+1h \le \log_2 n < h + 1, cioè h=⌊log⁡2n⌋h = \lfloor \log_2 n \rfloor. □\square

Definizione di heap

Uno heap è un albero binario completo i cui nodi memorizzano entry e che soddisfa la heap-order property: per ogni nodo vv diverso dalla radice, key(v)≥key(padre(v))\text{key}(v) \ge \text{key}(\text{padre}(v)). Equivale a: ogni nodo ha chiave ≤\le quella dei figli. (Un max-heap inverte la disuguaglianza.) Il nodo last è il più a destra del livello hh.

Le due proprietà hanno ciascuna un ruolo: la completezza dà altezza Θ(log⁡n)\Theta(\log n), l'heap-order mette il minimo in cima (min() in O(1)O(1)). Chiavi duplicate sono ammesse.

Proprietà (heap con nn entry):

  1. la radice contiene una entry con chiave minima;
  2. le chiavi lungo un cammino dalla radice a una foglia formano una sequenza non decrescente;
  3. con chiavi distinte, se eie_i è l'entry con ii-esima chiave più piccola (e1e_1 = radice) e sta a profondità dd, allora d<id < i (gli dd antenati di eie_i sono altrettante entry con chiave minore, più eie_i stessa);
  4. con chiavi distinte, l'entry con chiave massima sta in una foglia (per assurdo: se fosse in un nodo interno, un suo figlio avrebbe chiave maggiore);
  5. per ogni nodo vv e ogni nodo uu nel sottoalbero TvT_v, key(u)≥key(v)\text{key}(u) \ge \text{key}(v) (per transitività lungo il cammino).

Conseguenza della 5: le uniche entry di cui si conosce con certezza il confronto con P[3]P[3] sono i suoi antenati (minori) e i suoi discendenti (maggiori). In un heap di 12 entry con chiavi distinte, i discendenti di P[3]P[3] sono P[6]P[6], P[7]P[7] e P[12]P[12] (i figli di P[6]P[6] sono P[12]P[12] e P[13]P[13], ma P[13]P[13] non esiste): quindi 3 entry hanno certamente chiave maggiore (domanda d'esame ricorrente).

Rappresentazione su array (level numbering)

Un heap sta in un array P[1..n]P[1..n] numerando i nodi per livelli, da sinistra a destra: la radice è P[1]P[1], i figli di P[i]P[i] sono P[2i]P[2i] e P[2i+1]P[2i+1], il padre di P[i]P[i] è P[⌊i/2⌋]P[\lfloor i/2 \rfloor] e last=n\text{last} = n. I collegamenti padre-figlio sono impliciti (struttura dati implicita): nessun puntatore, spazio Θ(n)\Theta(n). (Nel testo adottato la numerazione parte da 0: figli 2i+12i+1, 2i+22i+2, padre ⌊(i−1)/2⌋\lfloor (i-1)/2 \rfloor.)

Per un albero completo con n≥1n \ge 1 nodi e altezza hh: i 2i2^i nodi del livello i<hi < h occupano le posizioni 2i≤j≤2i+1−12^i \le j \le 2^{i+1} - 1 e quelli del livello hh le posizioni 2h≤j≤n2^h \le j \le n. Per un albero non completo questo schema può richiedere spazio esponenziale (un cammino di nn nodi sempre a destra occupa la posizione 2n−12^n - 1).

Inserimento: insert(k, x) e up-heap bubbling

Si pone la nuova entry in posizione last+1\text{last} + 1 (la completezza è salva) e si ripristina l'heap-order risalendo.

Algoritmo insert(k, x)
e <- (k, x); last <- last + 1; P[last] <- e; i <- last
while i > 1 AND P[floor(i/2)].getKey() > P[i].getKey() do
    swap(P[i], P[floor(i/2)])
    i <- floor(i/2)
return e

Se l'array è pieno si alloca prima un array più grande (ad esempio di taglia doppia): costo ammortizzato.

Invariante del while: l'unica coppia padre-figlio che può violare l'heap-order è (P[⌊i/2⌋],P[i])(P[\lfloor i/2 \rfloor], P[i]). Vale all'inizio (la sola entry nuova può violare) e si conserva: lo scambio porta la chiave minore sopra, e la chiave scesa è ≥\ge l'antenato che ora è sopra di essa. Alla fine i=1i = 1 o il padre è ≤P[i]\le P[i]: nessuna violazione.

Costo: il while fa al più hh iterazioni, con h=⌊log⁡2n⌋h = \lfloor \log_2 n \rfloor (nn numero di entry dopo l'inserimento), e c'è un'istanza con esattamente hh iterazioni (chiave minore di tutte): Θ(log⁡n)\Theta(\log n).

Rimozione del minimo: removeMin() e down-heap bubbling

Si toglie la radice, si porta in radice l'ultima entry e si ripristina scendendo, scambiando sempre con il figlio di chiave minore.

Algoritmo removeMin()
minentry <- P[1]; P[1] <- P[last]; last <- last - 1; i <- 1
while 2*i <= last do
    j <- indice del figlio di P[i] con chiave minima (2i o 2i+1)
    if P[i].getKey() <= P[j].getKey() then break
    swap(P[i], P[j]); i <- j
return minentry

Invariante: P[j]P[j] è il figlio di chiave minima di P[i]P[i] e le uniche coppie (padre, figlio) che possono violare l'heap-order sono (P[i],P[2i])(P[i], P[2i]) e (P[i],P[2i+1])(P[i], P[2i+1]). Si scambia con il figlio minore perché solo così il nuovo padre di entrambi è ≤\le di tutti e due. Costo: al più hh iterazioni, e c'è un'istanza (l'entry di P[last]P[\text{last}] ha chiave massima) con hh: Θ(log⁡n)\Theta(\log n).

Rimozione di una entry qualsiasi

removeEntry(P, j): si pone P[j]←P[last]P[j] \leftarrow P[\text{last}] e si decrementa last\text{last}. Sia e=(k,v)e = (k, v) l'entry spostata, e0e_0 il nuovo padre, e1,e2e_1, e_2 i figli. Tre casi:

  1. k0≤k≤min⁡(k1,k2)k_0 \le k \le \min(k_1, k_2): l'heap è già valido;
  2. k<k0k < k_0: up-heap bubbling a partire da P[j]P[j];
  3. k>min⁡(k1,k2)k > \min(k_1, k_2): down-heap bubbling a partire da P[j]P[j].

Ciascun ciclo fa O(log⁡n)O(\log n) iterazioni: Θ(log⁡n)\Theta(\log n) al caso pessimo (ad esempio j=1j = 1 e P[last]P[\text{last}] con chiave massima).

Esempio svolto

Heap iniziale P=[4,6,9,15,7,16,12,20,18,8,11]P = [4, 6, 9, 15, 7, 16, 12, 20, 18, 8, 11] (n=11n = 11, h=3h = 3).

  • insert(5): 55 in posizione 1212 (figlio destro di P[6]=16P[6] = 16); 5<165 < 16 scambio, ora in posizione 66 con padre P[3]=9P[3] = 9; 5<95 < 9 scambio, in posizione 33 con padre P[1]=4P[1] = 4; 5>45 > 4 stop. P=[4,6,5,15,7,9,12,20,18,8,11,16]P = [4, 6, 5, 15, 7, 9, 12, 20, 18, 8, 11, 16].
  • insert(3): 33 in posizione 1313 (padre P[6]=9P[6] = 9) →\to sale a 66 (padre 55) →\to 33 (padre 44) →\to 11. P=[3,6,4,15,7,5,12,20,18,8,11,16,9]P = [3, 6, 4, 15, 7, 5, 12, 20, 18, 8, 11, 16, 9].
  • removeMin() restituisce 33: si mette 99 in radice, P=[9,6,4,… ]P = [9, 6, 4, \dots]; figlio minore 44 (9>49 > 4) scambio →\to posizione 33, figli P[6]=5P[6] = 5 e P[7]=12P[7] = 12: minore 55, scambio →\to posizione 66, figlio P[12]=16P[12] = 16: 9<169 < 16 stop. P=[4,6,5,15,7,9,12,20,18,8,11,16]P = [4, 6, 5, 15, 7, 9, 12, 20, 18, 8, 11, 16].
  • removeMin() restituisce 44: radice ←16\leftarrow 16; figli 6,56, 5: scambio con 55; figli di posizione 33: 9,129, 12: scambio con 99; P=[5,6,9,15,7,16,12,20,18,8,11]P = [5, 6, 9, 15, 7, 16, 12, 20, 18, 8, 11].

(Tutti i passaggi sono stati verificati eseguendo un'implementazione.)

Fatti sulle visite di uno heap

Con 77 entry a chiavi distinte, la visita in preorder può restituire le entry in ordine crescente (basta che tutte le chiavi del sottoalbero sinistro siano minori di quelle del destro: 1,2,3,4,5,6,71, 2, 3, 4, 5, 6, 7 per livelli con sinistra {2,3,4}\{2,3,4\}, destra {5,6,7}\{5,6,7\}). Inorder e postorder non possono: nell'inorder la radice (chiave minima) è preceduta dai 33 nodi del sottoalbero sinistro, e nel postorder è l'ultima.

Errori comuni

  • Scambiare con il figlio sbagliato nel down-heap (si sceglie il minore, non il sinistro).
  • Dimenticare di aggiornare last\text{last}, o di spostare in radice l'ultima entry (non una qualsiasi) in removeMin.
  • Ritenere che uno heap sia ordinato: lo è solo lungo i cammini; non c'è relazione tra fratelli né tra livelli diversi su rami diversi.
  • Scambiare le formule dei figli (2i2i, 2i+12i+1 con indici da 1; 2i+12i+1, 2i+22i+2 con indici da 0).

Versione ripasso

  • Albero binario completo: livelli 0..h−10..h-1 pieni, livello hh riempito da sinistra; nn nodi ⇒\Rightarrow h=⌊log⁡2n⌋h = \lfloor \log_2 n \rfloor (da 2h≤n≤2h+1−12^h \le n \le 2^{h+1} - 1).
  • Heap = albero completo + heap-order: key(v)≥key(padre)\text{key}(v) \ge \text{key}(\text{padre}). Radice = minimo; cammini non decrescenti; con chiavi distinte l'entry ii-esima per grandezza ha profondità <i< i e il massimo sta in una foglia; ogni nodo ≤\le i suoi discendenti.
  • Array P[1..n]P[1..n] (level numbering): figli 2i,2i+12i, 2i+1, padre ⌊i/2⌋\lfloor i/2 \rfloor, last=n\text{last} = n, spazio Θ(n)\Theta(n); per alberi non completi può servire spazio esponenziale (vedi Code con prioritàEntry chiave-valore; ADT coda con priorità (insert, min, removeMin) con chiave minima = priorità massima; esempio di esecuzione; applicazioni; implementazioni con lista non ordinata e ordinata e relativi costi; ordinamento tramite coda con priorità.Code con priorità →).
  • insert: in P[last+1]P[\text{last}+1], up-heap: while i>1∧P[⌊i/2⌋]>P[i]\text{while } i>1 \wedge P[\lfloor i/2 \rfloor] > P[i] scambia. Invariante: solo (padre, P[i]P[i]) può violare. Θ(log⁡n)\Theta(\log n).
  • removeMin: P[1]←P[last]P[1] \leftarrow P[\text{last}], last−−\text{last}--, down-heap scambiando col figlio minore. Invariante: solo le coppie (P[i],figli)(P[i], \text{figli}) possono violare. Θ(log⁡n)\Theta(\log n).
  • removeEntry(jj): P[j]←P[last]P[j] \leftarrow P[\text{last}] poi: già valido / up-heap (se k<k0k < k_0) / down-heap (se k>min⁡(k1,k2)k > \min(k_1,k_2)); Θ(log⁡n)\Theta(\log n).
  • Esempio: da [4,6,9,15,7,16,12,20,18,8,11][4,6,9,15,7,16,12,20,18,8,11], insert(5) →[4,6,5,15,7,9,12,20,18,8,11,16]\to [4,6,5,15,7,9,12,20,18,8,11,16]; insert(3) →[3,6,4,15,7,5,12,20,18,8,11,16,9]\to [3,6,4,15,7,5,12,20,18,8,11,16,9]; removeMin →3\to 3 e [4,6,5,15,7,9,12,20,18,8,11,16][4,6,5,15,7,9,12,20,18,8,11,16].
  • Visite (7 entry distinte): preorder può essere crescente; inorder e postorder no.
  • Pseudocodice insert: P[++last] <- e; i <- last; while i > 1 AND P[i/2].key > P[i].key: swap(P[i], P[i/2]); i <- i/2.
  • Pseudocodice removeMin: m <- P[1]; P[1] <- P[last--]; i <- 1; while 2i <= last: j <- indice del figlio minimo; if P[i].key <= P[j].key then break; swap(P[i], P[j]); i <- j; return m.
  • Proprietà dimostrate: altezza ⌊log⁡2n⌋\lfloor \log_2 n \rfloor da 2h≤n≤2h+1−12^h \le n \le 2^{h+1} - 1; il massimo sta in una foglia (per assurdo); 1212 entry distinte: P[3]P[3] ha 33 discendenti (P[6],P[7],P[12]P[6], P[7], P[12]), quindi 33 entry sicuramente maggiori.
  • Costi: min Θ(1)\Theta(1); insert, removeMin, removeEntry Θ(log⁡n)\Theta(\log n); il caso peggiore è una chiave minore di tutte (insert) o massima (removeMin).
  • Errori: figlio sbagliato nel down-heap; last non aggiornato; heap creduto ordinato; formule dei figli con indici 0/1 mescolati.

Esercizi su questo argomento

Teoria collegata