Gli heap Mauro Torelli Note per il corso di Algoritmi e strutture dati – 2009 1 Dai linguaggi agli insiemi numerici e agli heap Nelle note sui linguaggi abbiamo descritto un albero binario come un linguaggio ereditario sull’alfabeto {0, 1}. Ma c’è un modo ancora più semplice di pensare gli alberi binari come insiemi di nodi: è quello di numerare semplicemente i nodi a partire dalla radice (numero 1) e poi per livelli, da sinistra a destra. Naturalmente, numeriamo i nodi dell’albero binario completo (è l’albero binario senza “buchi”, con tutti i nodi possibili) fino al livello che ci interessa, e poi riportiamo nell’insieme di nodi che rappresenta il nostro albero particolare solo quelli che sono effettivamente presenti. La figura (a) a fianco mostra i primi 10 nodi (le etichette sono i numeri da 1 a 10 fuori dai cerchietti); usando questa numerazione, la forma (ignorare i numeri nei nodi) dell’albero binario della figura a destra è descritta dall’insieme {1, 2, 3, 4, 5, 6, 10}: i nodi 7, 8 e 9 mancano. Come possiamo passare dai linguaggi ereditari binari a questa rappresentazione come insiemi di numeri? Semplicemente premettendo un 1 alla codifica binaria: così gli eventuali 0 iniziali diventano significativi, e alla radice ε viene associato il valore 1ε ossia 1, come volevamo. Per esempio, all’albero della figura a destra è associato l’insieme ereditario {ε, 0, 1, 00, 01, 10, 010}: se premettiamo un 1 otteniamo {1, 10, 11, 100, 101, 110, 1010} e i valori decimali sono esattamente quelli riportati sopra. Ma ancora una volta la definizione tramite i linguaggi ereditari ci torna utile: i figli di un nodo x erano x0 e x1, e questo rimane vero anche premettendo 1 alla codifica (1x ha figli 1x0 e 1x1). Se 1x vale y in decimale, quanto vale 1x0? Aggiungere uno 0 binario equivale a raddoppiare, mentre 1x1 varrà 2y + 1: dunque il valore decimale dei figli di un nodo y è 2y per il figlio sinistro, 2y + 1 per il destro, e viceversa il padre di y vale ⎣y/2⎦. Possiamo ora ottenere una nuova definizione di albero binario, in termini di interi positivi: un albero binario è un insieme B di numeri interi positivi tali che, se y ∈ B e y > 1, allora anche ⎣y/2⎦ ∈ B. Quali sono gli alberi binari più semplici? Quelli in cui compaiono tutti i nodi da 1 sino a n. Qualche testo li chiama alberi binari quasi completi, ma altri testi, più esplicitamente li definiscono completi a sinistra: il nome chiarisce che l’ultimo livello dell’albero può non essere completo, ma è riempito “senza buchi” da sinistra verso destra; il nome ha però il difetto di essere lungo: abbreviamolo in abcs! 1 Contare il numero di alberi diversi con n nodi può non essere facile (si veda il problema 12-4 del testo e la nota Grafi e alberi, al §2.6), ma c’è almeno un caso in cui è davvero banale: quello dei nostri abcs! C’è evidentemente un solo albero cosiffatto con n nodi, per ciascun valore di n. I contenuti dei nodi possono variare all’infinito, ma la forma dell’albero con n nodi è unica: lo riempio dall’alto in basso, procedendo da sinistra a destra, fino ad avere n nodi. Le implicazioni pratiche della semplicità di questa struttura sono rilevanti. Come posso tenere concretamente in memoria un abcs? In un array, nelle posizioni da 1 a n. Come trovo padri e figli? Cercandoli nelle posizioni che conosco a priori: i figli del nodo in posizione y stanno nelle posizioni 2y e 2y +1, mentre il padre di y (se y è maggiore di 1) si trova nella posizione ⎣y/2⎦. Abcs con proprietà ulteriori sono poi strutture di dati essenziali in informatica: gli heap. Un heap è un abcs il cui contenuto deve soddisfare la proprietà di ordinamento parziale: la chiave contenuta in un nodo non deve superare quella del padre (ci limitiamo qui, per semplicità, ai max-heap: è immediato definire minheap in cui, al contrario, i padri contengono chiavi non superiori a quelle dei figli). In altre parole, in un heap le chiavi sono ordinate (in ordine debolmente decrescente: ci possono essere chiavi uguali!) lungo ciascun ramo, andando dalla radice alle foglie. L'ordine è solo parziale: non vale, in generale, alcuna relazione d'ordine tra chiavi in rami diversi. Come spiegato nel capitolo 6 del testo, gli heap consentono di ottenere un algoritmo di ordinamento efficiente, heapsort, ma consentono anche di realizzare in modo efficiente strutture di dati astratte come le code con priorità. 2 I linguaggi ci aiutano a risolvere problemi sugli heap Vediamo ora come le nostre codifiche degli alberi possano aiutarci a risolvere vari problemi sugli abcs e quindi sugli heap. Innanzi tutto, quant’è alto un abcs con n nodi? Ricordiamo che l’altezza è la lunghezza della parola più lunga nella codifica, quando la radice è rappresentata dalla parola vuota ε. Il nodo di valore massimo, e cioè n, ha certamente la codifica più lunga, che è quella corrispondente al valore binario di n, tolto l’1 iniziale. Ora, quanti bit occorrono per rappresentare n? Qual è il più grande intero rappresentabile con k bit? Ovviamente 1k (cioè una sequenza binaria di k 1), che rappresenta 2k – 1, quindi 2k richiede k + 1 bit, e in generale n richiede ⎣lg n⎦ + 1 bit. Togliendo l’1 iniziale otteniamo, per l’altezza del nostro abcs con n nodi, esattamente ⎣lg n⎦. Poiché abbiamo “riempito l’albero il più possibile” abbiamo anche ridimostrato che questa è l’altezza minima di qualsiasi albero binario con n nodi. Quante foglie ha un abcs con n nodi? In un abcs, un nodo è una foglia se non ha figlio sinistro (e quindi neanche destro, dato che dev’essere completo a sinistra!). Perciò, se un nodo corrisponde al numero y mentre 2y (che dovrebbe corrispondere al figlio sinistro) è maggiore di n, allora y è una foglia; ma 2y > n implica y > n/2, perciò i nodi numerati da 1 fino a ⎣n/2⎦ hanno figli, i restanti ⎡n/2⎤ nodi sono invece foglie. 2 L’altezza a(x) di un nodo x è semplicemente la lunghezza del più lungo cammino fino a un suo discendente: a(x) ≝ max{|y|: xy ∈ L}. Negli abcs, se x ha altezza h, x0h deve appartenere a L ed essere una foglia, perché altrimenti l’altezza di x sarebbe maggiore di h. La parola x0h rappresenta, in binario, un multiplo di 2h, qualunque sia x, ma naturalmente, cambiando x, cambia anche il multiplo. Ora, quanti multipli di 2h ci possono essere, al più, tra le ⎡n/2⎤ foglie dell’abcs considerato? Evidentemente, al più ⎡(n/2)/2h⎤, ossia ⎡n/2h+1⎤, come specificato nell’esercizio 6.3-3, che non è poi così difficile, nonostante l’asterisco (infatti l'hanno tolto nella nuova edizione). 3 Quanto costa costruire un heap? Ci aiuta sapere quanto costa calcolare una potenza? Supponiamo di voler calcolare il valore di (1+1/n)n per interi n grandi senza ricorrere ai logaritmi (per verificare per esempio l’approssimazione di e al variare di n), oppure di usare il “piccolo” teorema di Fermat per dimostrare che un intero n è composto, calcolando 2n–1 mod n (che è diverso da 1 solo se n è composto): occorre disporre di un algoritmo efficiente per calcolare potenze n-esime anche per valori “grandi” di n, effettuando molto meno di n prodotti. Il metodo binario (o dicotomico) risolve il problema con un numero di prodotti proporzionale a lg n. Lo illustriamo con un esempio. Dovendo calcolare x15 possiamo calcolare x2 con un prodotto, x3 = x x2 con un ulteriore prodotto, x6 = x3 x3 con un terzo, x x6 ci dà x7 con 4 prodotti, x7 x7 ci dà x14 e finalmente x x14 ci dà x15 con 6 prodotti in totale. Poiché interessano sostanzialmente gli esponenti, conviene considerare la sequenza di tali esponenti e organizzare le cose per esempio come segue: partendo da n, se l'esponente è pari, si dimezza, se è dispari, si sottrae uno. Per esempio, 15→14→7→6→3→2→1: come prima, in 6 passaggi si arriva da 15 a 1 o viceversa. Per inciso, possiamo pensare a queste sequenze come a passaggi nell’albero binario da un figlio destro, quando il valore è dispari, al fratello sinistro (valore pari inferiore di 1) e poi al padre (dimezzamento)… ma non è molto utile. Sequenze di questo tipo sono particolari catene di addizioni. In una catena si richiede che ogni elemento della sequenza sia ottenibile come somma di due elementi (eventualmente coincidenti) che lo seguono nella sequenza, o lo precedono se le sequenze sono considerate in ordine crescente come fa Knuth nel secondo volume di The Art of Computer Programming – Seminumerical Algorithms (Addison-Wesley, 19812), formula (1) a pagina 444. Knuth ricorda anche che alcuni autori hanno scritto incautamente che il metodo binario è il migliore possibile. Tuttavia, la catena 1→2→3→6→12→15 richiede soltanto 5 passaggi ed è pertanto più efficiente per n = 15, il più piccolo controesempio. Non è nota una formula che dato n fornisca la lunghezza della catena di addizioni più corta, e vi sono anzi numerosi problemi aperti nella teoria delle catene di addizioni. È interessante conoscere il metodo dicotomico (utile anche per altri algoritmi: la ricerca dicotomica, mergesort…) e sapere che una potenza n-esima può essere calcolata con un numero di prodotti dell’ordine di lg n, e anche che il metodo binario non è necessariamente il migliore. Noi ci ispireremo alle catene di 3 addizioni per calcolare in modo semplice e accurato il costo della costruzione di un heap. Quasi tutti i testi sugli algoritmi mostrano che un heap può essere costruito in tempo lineare. Tuttavia, è possibile dimostrare direttamente un risultato più preciso, e cioè che, per costruire un heap con n chiavi, bastano in ogni caso meno di n spostamenti di chiavi, precisamente al più n meno il numero di 1 che compaiono nella rappresentazione binaria di n (ce n’è almeno uno se n > 0). Questo risultato si può attribuire a un matematico famoso, Adrien-Marie Legendre (1752–1833), che naturalmente lo trovò in un contesto ben diverso, probabilmente quello in cui si chiede di stabilire la massima potenza di 2 che divide il fattoriale di n: è sempre n meno il numero di 1 che compaiono nella rappresentazione binaria di n. Per esempio, 10! = 3.628.800 è divisibile per 28 ma non per 29, dal momento che 10 ha la rappresentazione binaria 1010, che contiene 2 uni (*) . Si può usare questo risultato anche per il problema del contatore binario presentato nel testo (Cormen et al.) al §17.1. Dimostriamo ora quanto affermato. Una chiave che si trovi in un nodo x può essere spostata al più fino a una foglia che sia un discendente di x, quindi al più un numero di volte pari all’altezza a(x). Il numero massimo di spostamenti complessivi è allora pari alla somma di tutte le altezze dei nodi nel nostro albero binario completo a sinistra con n nodi che costituisce l’heap. Possiamo registrare le diverse altezze come dati contenuti nell’heap stesso, in modo assai semplice. Infatti l’altezza di un nodo sarà sempre superiore di 1 rispetto all’altezza del figlio sinistro, se questo esiste, altrimenti è 0, perché il nodo è una foglia. Se rappresentiamo come di consueto l’heap con un array A[1..n] avremo le relazioni di ricorrenza A[i] = 1 + A[2i] se 2i ≤ n e A[i] = 0 se 2i > n e vogliamo calcolare s(n) ≝ ∑ A[i] . Avremo allora che i =1..n s(2n) = n + s(n): infatti passando da n a 2n introduciamo un nuovo livello di foglie (altezza 0) che aumentano di 1 l’altezza di ciascuno degli altri n nodi. Inoltre s(2n + 1) = s(2n) perché l’aggiunta di un figlio destro non altera le altezze. Infine ovviamente s(1) = s(0) = 0. Per esempio, l’array A per n = 5 conterrà i valori 2 1 0 0 0, mentre l’array A per n = 10 conterrà i valori 3 2 1 1 1 0 0 0 0 0 con somma 8, e infatti s(10) = 5 + s(5) = 5 + s(4) = 5 + 2 + s(2) = 5 + 2 +1 + s(1) = 8. Premesso che la costruzione effettiva dell’heap va fatta nel modo efficiente descritto nel testo, possiamo descrivere la crescita dei costi come segue. Per costruire un heap con n nodi possiamo immaginare di partire da un heap vuoto fino ad arrivare a n nodi: ogni volta che raddoppiamo la dimensione dell’heap parziale con k nodi abbiamo un costo (un incremento delle altezze) pari a k, mentre se passiamo semplicemente da 2k a 2k + 1 il costo è nullo. Il costo totale sarà dunque al più n (sarebbe n se non avessimo costi nulli). Come sappiamo quando dobbiamo raddoppiare e quando aggiungere un solo elemento? Esattamente procedendo come per la costruzione efficiente di una potenza n-esima! Partiamo da n e, se è pari, (*) Se la connessione coi fattoriali non è chiara, si può consultare il bel libro di D. E. Knuth e altri, Matematica discreta, Hoepli, 1992, §4.4 (o riferimento [132] nel testo di Cormen et al.). Questo libro cita anche la connessione col gioco detto della torre di Hanoi. Esistono altre connessioni, per esempio con i codici Gray, che realizzano in successione tutte le parole binarie di data lunghezza, cambiando ogni volta un solo bit. 4 dimezziamo, se è dispari togliamo 1. Il costo totale sarà n meno il numero di volte in cui abbiamo tolto 1, che non è altro che il numero di 1 che compaiono nella rappresentazione binaria di n: infatti se la rappresentazione binaria termina per 0 abbiamo un valore pari e dimezziamo, ovvero eliminiamo lo 0 finale, mentre se la rappresentazione termina per 1 abbiamo un valore dispari, togliamo 1 ovvero trasformiamo l’1 in 0 e procediamo. È una consuetudine utile indicare simbolicamente il numero di 1 nella rappresentazione binaria di n tramite la funzione v(n). In conclusione, s(n) = n – v(n), come volevasi dimostrare. La conclusione generale è dunque che la costruzione di un heap può avvenire in tempo lineare, ossia O(n), se si procede bottom-up, ossia dal basso, in modo appunto da operare un numero di spostamenti di un singolo elemento pari al più alla sua altezza, mentre se si procedesse top-down, ovvero per inserimenti successivi (in realtà gli inserimenti verrebbero sempre fatti in fondo, in una nuova foglia, ma l’elemento inserito potrebbe ogni volta risalire sino alla radice, con un numero di spostamenti pari all’altezza dell’albero ⎣lg k⎦, se il numero di elementi in quel momento è k), la complessità nel caso peggiore sarebbe Θ(n lg n). Ogni spostamento di chiave all’interno dello heap sarà preceduto da due confronti tra chiavi, per stabilire se lo spostamento è necessario (è possibile confrontare i due figli del nodo per stabilire il più grande, e poi confrontare quest’ultimo col padre, oppure procedere come nella procedura BUILD-MAX-HEAP del testo). In ogni caso, il numero complessivo di confronti è dello stesso ordine di grandezza di quello degli spostamenti. 4 L’analisi ammortizzata ci dà una mano Questo paragrafo risulterà più chiaro dopo aver studiato il capitolo 17 del testo sull’analisi ammortizzata, ma è perfettamente comprensibile anche senza aver letto quel capitolo. Ottenere il risultato del paragrafo precedente, s(n) = n – v(n), ha richiesto un po’ di lavoro e d’ingegno e magari ha lasciato qualche dubbio. Ora vogliamo mostrare come quel risultato possa essere ottenuto con minor fatica usando metodi dell’analisi ammortizzata. Il metodo degli accantonamenti si basa sull’attribuire un costo fittizio alle varie operazioni disponibili e può essere spesso visto come un trucco per ignorare i costi difficili da calcolare (ponendoli a zero) aumentando invece i costi di facile determinazione, in modo tale però da riuscire a… pagare tutte le spese, senza debiti né crediti residui al termine della sequenza di operazioni considerata, grazie alle somme accantonate. Torniamo ora ai nostri heap, o meglio agli abcs di cui vogliamo calcolare la somma delle altezze s(n), essendo n il numero di nodi nell’abcs (e quindi anche l’etichetta dell’"ultimo" nodo). Se ora aggiungiamo un ulteriore nodo n + 1, di quanto aumenta s(n + 1)? In altre parole, stiamo cercando una relazione di ricorrenza che esprima s(n + 1) in funzione di s(n) anziché in funzione di s(n/2) come nel paragrafo precedente. Notiamo, nel caso ce ne fosse bisogno, che non stiamo cercando di costruire l’heap in maniera più o meno efficiente, ma stiamo solo cercando di valutare la funzione s(n) che ci interessa: per valutare la funzione 5 immaginiamo di aggiungere un nodo dopo l’altro, mentre per costruire lo heap in modo efficiente non si deve fare così (come abbiamo detto anche in precedenza). Quali nodi dell'heap hanno altezza incrementata per effetto dell'aggiunta del nuovo nodo n + 1? Tutti (e soli) quelli che hanno il nodo n + 1 come discendente più a sinistra (ovvero lungo un ramo tutto a sinistra): infatti chi aveva già un discendente al livello del nodo n + 1 non modifica la sua altezza. Riprendendo l’esempio dell’heap con 10 nodi, il nodo 10 aumenta l’altezza del padre 5 ma non quella di 4 o 2. Un nuovo nodo 11 non aumenterebbe nessuna altezza. Se (e solo se) la codifica binaria di n + 1 termina con k zeri possiamo risalire k volte ad antenati lungo un ramo sinistro, e quindi in questo caso vi sono esattamente k nodi che incrementano di 1 la loro altezza. La somma s(n) si costruisce dunque aggiungendo ogni volta il numero di zeri finali dell’intero j, per tutti i j da 1 fino a n. Denotato con z(j) il numero di zeri finali nella rappresentazione binaria dell’intero j, avremo s(n) = n ∑ z ( j ) , il che, di per sé, non sembra aiutarci molto a calcolare s(n)! j =1 Notiamo però che, ogni volta che incrementiamo j, nella rappresentazione binaria dobbiamo trasformare in zeri un numero variabile di uni finali mentre un unico zero diventa uno: per esempio 10111 + 1 = 11000, poi 11000 + 1 = 11001, eccetera. Se attribuiamo un costo unitario alla trasformazione 1 → 0, la nostra somma s(n) diviene precisamente la somma dei costi per incrementare da 1 fino a n, e l’analisi ammortizzata ci suggerisce il trucco di… fare l’opposto: attribuire costo zero a queste trasformazioni in numero di volta in volta difficile da calcolare, e invece costo uno all’unica, certa, trasformazione 0 → 1. Dobbiamo però ovviamente controllare che tutto quadri. Supponiamo di pagare un euro per ogni 0 trasformato in 1 e di lasciare l’euro “attaccato” all’1 fino a quando questo non viene ritrasformato in 0: in questo modo ogni 1 ha attaccato l'euro necessario per ritrasformarlo in 0! Se partiamo da j = 0 e ci fermiamo quando j = n, dovremo pagare n euro e nella stringa binaria che rappresenta n avremo ancora v(n) euro attaccati ai loro uni: la nostra spesa di n – v(n) euro ha pagato le trasformazioni di tutti gli uni in zeri, che sono state dunque esattamente s(n) = n – v(n), come volevasi dimostrare. Il ragionamento fatto qui vale anche per stabilire il costo di n incrementi di un contatore binario, con l’unica differenza che per valutare correttamente tale costo occorre notare che anche la trasformazione 0 → 1 ha un costo unitario (prima trasformavamo 0 in 1 “gratis” lasciando l’euro per pagare in realtà la trasformazione da 1 a 0). Il costo totale per gli incrementi del contatore è quindi 2n – v(n), com’è (faticosamente) dimostrato nel testo a pagina 355 (dove bn è il nostro v(n)). Un bell’esempio di applicazione dell’analisi ammortizzata in un contesto un po’ insolito! Un’ultima osservazione: è facile scrivere le relazioni di ricorrenza che definiscono la funzione z(j), il numero di 0 finali nella rappresentazione binaria dell’intero j, e la funzione v(j), il numero di 1 nella rappresentazione binaria di j. Si provi a farlo per esercizio. Le relazioni di ricorrenza per la funzione z(j) hanno un’interessante peculiarità. Infatti, nelle relazioni di ricorrenza più comuni i casi definiti 6 esplicitamente sono dati per valori piccoli dell’argomento, e sono in numero finito. Per z(j), al contrario, abbiamo infiniti casi espliciti: z(j) = 0 se j è dispari, ossia se j = 2k +1, e invece z(j) = z(j/2) + 1 se j è pari, ossia se j = 2k. La funzione z(j) è talvolta chiamata la funzione righello, perché fornisce l’altezza dei trattini di un righello, se le divisioni sono fatte seguendo le potenze di 2 invece di quelle di 10 (come si usa in America, almeno per le misure in pollici: i sedicesimi di pollice hanno un trattino piccolo, gli ottavi un po’ più grande, e così via). 7