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