Esercizi di Algoritmi e Strutture Dati Moreno Marzolla [email protected] Ultimo aggiornamento: 29 novembre 2010 1 Rotazioni semplici in ABR Si consideri l’operazione di rotazione semplice applicata ad un Albero Binario di Ricerca (ABR). Dimostrare che l’operazione di rotazione semplice, come definita a lezione, preserva la proprietà di ordinamento degli ABR. In altre parole, dimostrare che un ABR, dopo una singola operazione di rotazione semplice rispetto ad un qualsiasi nodo x, è ancora un ABR. Soluzione X / \ Y T3 / \ T1 T2 Consideriamo la figura seguente: Y / \ T1 X / \ T2 T3 Supponiamo di effettuare una rotazione semplice verso destra, usando X come perno. Prima della rotazione, valgono le seguenti relazioni: Y ≤ X T1 ≤ Y T2 ≥ Y T2 ≤ X T3 ≥ X (dove per semplicità usiamo la notazione T 1 ≤ Y per indicare che per ogni nodo Z di T1 vale la relazione Z ≤ Y ). È possibile verificare che tutte queste relazioni valgono anche nell’albero di destra (quello ottenuto dopo la rotazione), che quindi è ancora un ABR. 1 2 Costruzione di ABR Dimostrare che qualsiasi algoritmo basato su confronti per la costruzione di un ABR con n nodi ha complessità asintotica Ω(n log n). Soluzione Supponiamo che sia possibile costruire un ABR con n nodi, utilizzando confronti, in tempo strettamente inferiore a Ω(n log n). Ricordiamo che, dato un ABR con n nodi, è possibile ottenere la lista ordinata delle chiavi in esso contenute mediante una visita simmetrica (detta anche visita inordine) dell’albero. La visita simmetrica ha costo Θ(n). Quindi, se potessimo costruire un ABR in tempo inferiore a Ω(n log n), riusciremmo anche a ordinare un insieme di n elementi, usando solo confronti, in tempo inferiore a Ω(n log n), il che è impossibile dato il limite inferiore alla complessità del problema dell’ordinamento mediante confronti 3 Visita di un ABR L’operazione di visita di un ABR con n nodi può essere implementata determinando l’elemento minimo dell’ABR, e poi invocando n − 1 volte l’operazione successor(). Fornire una giustificazione intuitiva del fatto che questo algoritmo di visita abbia complessità asintotica Θ(n). Soluzione Ricordiamo l’algoritmo per determinare il successore di un nodo algorithm successor(nodo v) -> nodo if (v == null) then return null; endif if (v.right != null) then return min(v.right); else p = v.parent while (p != null && v == p.right) do v = p; p = v.parent; endwhile return p; endif dove la procedura min() determina il nodo con chiave minima in un albero radicato nel nodo v, ed è definita come algorithm min(nodo v) -> nodo while (v != null && v.left != null) do v = v.left; endwhile 2 return v; Se proviamo a “visualizzare” il comportamento dell’operazione di visita implementata mediante determinazione dell’elemento minimo seguita da n − 1 invocazioni della procedura successor(), osserviamo che ciascun arco dell’albero viene attraversato esattamente due volte: una volta “in discesa”, ad opera della procedura min(), e una volta “in salita” ad opera della procedura successor(), che nel ramo else risale di figlio in padre fino alla prima “svolta a destra”. È anche importante osservare che una volta attraversato in discesa e in salita, un arco non viene più attraversato. Osserviamo anche che vengono eseguite O(1) operazioni elementari per ogni attraversamento di arco. A questo punto, osserviamo che un albero on n nodi ha esattamente n − 1 archi (si dimostra per induzione, e vale per qualsiasi albero, non solo per alberi binari). Quindi il costo dell’operazione di visita implementata come sopra è 2(n − 1) = Θ(n). 4 Incremento di chiavi in un albero AVL Si consideri un albero AVL contenente n chiavi numeriche. Supponiamo che le chiavi siano tutte distinte tra loro. Vogliamo implementare l’operazione incrementaChiave(k, d) il cui scopo è quello di incrementare il valore della chiave k di una quantità d (che potrebbe anche essere negativa), facendolo diventare k + d. Al termine di questa operazione, la struttura dati risultante deve ancora essere un albero AVL. Per l’ipotesi di unicità delle chiavi, il nodo contenente la chiave k, se esiste, è sempre unico; supponiamo anche che il valore k + d sia unico. Descrivere un algoritmo per realizzare l’operazione incrementaChiave(k,d), stimandone poi il costo computazionale. Soluzione Esiste una soluzione banale, che consiste nel rimuovere il nodo con chiave k (che per ipotesi è unico) e inserire un nuovo nodo con chiave k + d. Il costo complessivo risulta essere O(log n). 5 Implementazione di un albero AVL A lezione abbiamo visto che per una corretta implementazione degli alberi AVL è necessario conoscere l’altezza dei sottoalberi radicati in ciascun nodo. Infatti, questa informazione consente poi di capire quali sono i sottoalberi “pesanti”, e quindi procedere alle operazioni di ribilanciamento appropriate. Ricordiamo che l’altezza di un albero è la massima profondità cui si trova una sua foglia. L’albero composto da un singolo nodo (la radice) ha altezza 0. 1. Consideriamo innanzitutto un generico ABR (non bilanciato). Supponiamo che ciascun nodo v abbia un attributo intero v.h che corrisponde all’altezza del sottoalbero radicato in v. Mostrare come sia possibile estendere le operazioni di inserimento e rimozione di nodi di un ABR per 3 mantenere in modo efficiente il valore corretto di v.h per ciascun nodo. Tale modifica non deve alterare il costo computazionale delle operazioni di inserimento e rimozione, che devono mantenersi O(h) nel caso pessimo, essendo h l’altezza totale dell’albero. 2. Consderiamo ancora un generico ABR. Dimostrare come l’operazione di rotazione semplice può essere estesa per mantenere il valore corretto di v.h per ciascun nodo. Dopo tale modifica, il costo dell’operazione di rotazione semplice deve essere O(h) nel caso pessimo, essendo h l’altezza totale dell’albero. 3. Usare i due punti precedenti per dimostrare come sia possibile mantenere l’informazione sull’altezza di ciascun sottoalbero in un albero AVL senza alterare il costo computazionale delle operazioni di inserimento e rimozione di nodi. Soluzione Assumiamo che un oggetto nodo v abbia gli attributi seguenti: v.left riferimento al figlio sinistro (oppure null); v.right riferimento al figlio destro (oppure null); v.parent riferimento al padre (oppure null se v è la radice); v.h altezza dell’albero radicato in v. Definiamo come prima cosa l’algoritmo aggiusta_h(v). L’algoritmo funziona come segue: assume che i figli del nodo v (se esistono) abbiano il valore corretto dell’attributo h (quindi, assume di conoscere in maniera esatta l’altezza dei sottoalberi radicati nei figli, sempre se non sono vuoti). In base a questa informazione, calcola il valore di v.h. algoritmo aggiusta_h(Nodo v) if ( v.left == null && v.right == null ) then v.h = 0; elseif( v.left == null ) then // v.right != null v.h = v.right.h + 1; elseif( v.right == null ) then // v.left != null v.h = v.left.h + 1; else v.h = max( v.left.h, v.right.h ) + 1; endif A questo punto è facile definire un’altra procedura, che chiameremo aggiusta_h_ric che risale ricorsivamente da un nodo v fino alla radice dell’albero, ricalcolando il valore dell’attributo h di tutti i nodi visitati: 4 algoritmo aggiusta_h_ric(Nodo v) while ( v != null ) do aggiusta_h(v); v = v.parent; endwhile Ora siamo in grado di ricalcolare il valore di h in caso di inserimento o rimozione di nodi. Nel caso di inserimento in un ABR, sappiamo che il nuovo nodo venga inserito in una foglia v. Dopo l’inserimento, chiamiamo semplicemente l’operazione aggiusta_h_ric(v) che ricalcola tutte le altezze da v fino alla radice dell’albero. Il costo complessivo è O(h) nel caso pessimo, essendo h l’altezza dell’albero. Nel caso di rimozione, è necessario distinguere i tre casi: • Il nodo rimosso v era una foglia. Sia p il padre di v (se esiste). Dopo aver staccato v, si invoca aggiusta_h_ric(p) e l’algoritmo termina. • Il nodo rimosso v ha un unico figlio w. Sia p il padre di v. Il nodo w viene reso figlio di p (al posto di v, e si invoca l’operazione aggiusta_h_ric(p). L’algoritmo termina. • Il nodo rimosso v ha due figli. Si individua il nodo predecessore w, il quale non ha figlio destro. Sia p il padre di w. È possibile rimuovere w attaccando il padre p all’unico figlio di w. Il nodo w si sostituisce a v. A questo punto si invoca aggiusta_h_ric(p), essendo sicuri che nel cammino da p alla radice la procedura attraversa anche la posizione precedentemente occupata dal nodo v che è stato rimosso (posizione ora occupata da w). Nel caso della rotazione semplice, consideriamo il caso di rotazione semplice verso destra rispetto ad un nodo x. Si può eseguire la procedura seguente: x / \ y t3 / \ t1 t2 y = x.left; t1 = y.left; t2 = y.right; t3 = x.right; y.right = x; x.left = t2; x.right = t3; aggiusta_h_ric(x); 5 6 Attraversamento in-ordine iterativo Scrivere un algoritmo iterativo per effettuare la visita in-ordine di un albero binario (non necessariamente di ricerca). Soluzione Per implementare l’algoritmo di visita in-ordine facciamo uso di uno stack (pila). Gli elementi che inseriamo nello stack sono coppie < n, b >, essendo n un riferimento ad un nodo dell’albero, e b un valore booleano che può essere true o false. algoritmo inordine-iter(Nodo t) Stack S; S.push( <t, false> ); while (!S.empty()) do <n, f> := S.pop(); // estrai <n,f> dallo stack if (f==true) then visita il nodo n; else if (n.right != null) then S.push( <n.right, false> ); endif S.push( <n, true> ); if (n.left != null) then S.push( <n.left, false> ); endif endif endwhile L’idea dell’algoritmo è la seguente: ogni nodo n viene inizialmente inserito nello stack con flag settato a false. Quando un nodo v con flag settato a false viene estratto dallo stack, inseriamo prima il figlio destro, poi il nodo v con flag settato a true, e quindi il figlio sinistro. Quando estraiamo dallo stack un nodo con flag settato a true, è giunto il momento di “visitare” il nodo, che non verrà ulteriormente reinserito. 7 Albero inverso Dato un albero binario, i cui nodi contengono elementi interi, si scriva una procedura di complessità ottima per ottenere lalbero inverso, ovvero un albero in cui il figlio destro (con relativo sottoalbero) è scambiato con il figlio sinistro (con relativo sottoalbero). Soluzione algoritmo inverti(Nodo t) if (t == NULL) then 6 return; else // scambia i figli sinistro e destro Nodo tmp = t.left; t.left = t.right; t.right = tmp; inverti(t.left); inverti(t.right; endif L’algoritmo viene inizialmente invocato passando come parametro un riferimento alla radice dell’albero da invertire. 8 Cancellazione di nodi da ABR L’operazione di cancellazione da un ABR commutativa? Nel senso che cancellare prima x e poi y, oppure cancellare prima y e poi x, produce lo stesso ABR? Soluzione Consideriamo il seguente ABR 6 / 4 / \ 2 5 / \ 1 3 Rimuovendo prima il valore 5, poi il valore 4 si ottiene il seguente ABR 6 / 2 / \ 1 3 Se invece rimuoviamo prima il valore 4, poi il valore 5 si ottiene il seguente ABR 6 / 3 / 2 / 1 7 9 Verifica ABR (Questo esercizio è stato assegnato nella prova scritta del 20/9/2010) Si consideri un albero binario B in cui a ciascun nodo v è associata una chiave numerica (reale) v.key. Non ci sono chiavi ripetute, e tutte le chiavi appartengono all’intervallo [a, b]. 1. Scrivere un algoritmo efficiente che dato in input l’albero B e gli estremi a e b, restituisce true se e solo se B rappresenta un albero binario di ricerca. Non consentito usare variabili globali. 2. Calcolare il costo computazionale nel caso ottimo e nel caso pessimo dell’algoritmo di cui al punto 1. Disegnare un esempio di albero che produce un caso pessimo, e un esempio di albero che produce il caso ottimo. Soluzione Innanzitutto è utile ricordare che in un ABR i valori delle chiavi contenute nel sottoalbero sinistro di un nodo v sono tutti minori o uguali a v.key, mentre i valori delle chiavi contenute nel sottoalbero destro di v sono tutti maggiori o uguali di v.key. Una possibile soluzione è la seguente algoritmo checkABR(nodo v, float a, float b) -> bool if ( v == null ) then return true; else return (a <= v.key && v.key <= b && checkABR(v.left, a, v.key) && checkABR(v.right, v.key, b) ); endif L’algoritmo ricorsivo verifica che per ogni nodo v valgano le seguenti condizioni: • a ≤ v.key; • v.key ≤ b; • il sottoalbero sinistro è un ABR con chiavi aventi valori in [a, v.key]; • il sottoalbero destro è un ABR con chiavi aventi valori in [v.key, b]. Il costo nel caso ottimo è O(1), e si verifica quando la chiave presente nel figlio sinistro della radice di B viola la propriet di ABR; il costo nel caso pessimo è O(n) (essendo n il numero di nodi dell’albero) e si verifica quando B è effettivamente un ABR; in tal caso l’algoritmo controlla una ed una sola volta ogni nodo. 8