Gli heap - Home di homes.di.unimi.it

annuncio pubblicitario
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
Scarica