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). È un albero binario (Alberi binariAlbero binario e albero binario proprio; interfaccia; relazioni tra nodi, foglie e altezza (m = n-m+1, h+1 <= m <= 2^h, 2h+1 <= n <= 2^(h+1)-1) con dimostrazioni; visita inorder; parse tree e valutazione di espressioni; heightSum come esempio di calcolo di un'informazione più ricca.Alberi binari →) tenuto in un array: la struttura implicita sfrutta l'indicizzazione Θ(1)\Theta(1) degli array (Array e stringhe in CArray di dimensione fissa in memoria contigua, inizializzazione, nessun controllo sugli indici, passaggio a funzioni con la lunghezza, matrici; stringhe come array di char terminati da '\0' e funzioni di string.h.Array e stringhe in C →); il codice C è in Alberi e heap in CAlbero binario in C con nodo struct e figli left/right; funzioni ricorsive (conteggio, altezza, visita inorder, liberazione in postorder); inserimento in un albero binario di ricerca con Nodo **; min-heap su array con indici da 1 (insert, removeMin, bottomUp).Alberi e heap in C →. Non va confuso con lo heap della memoria dinamica (Gestione della memoria in CSegmenti di memoria di un processo (codice, dati statici, stack, heap); durata delle variabili; allocazione dinamica con malloc, calloc, realloc e free; errori classici: memory leak, dangling pointer, double free, buffer overflow; strumenti di controllo.Gestione della memoria in C →): sono due cose diverse con lo stesso nome.

Albero binario completo

Definizione (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.

Esempio. Con h=2h=2 i livelli 00 e 11 sono pieni (1+21+2 nodi) e il livello 22 ha da 11 a 44 nodi tutti a sinistra: nn va da 44 a 77.

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

Esempio. n=8n=8 dà h=⌊log⁡28⌋=3h=\lfloor\log_28\rfloor=3, n=15n=15 dà h=3h=3 e n=16n=16 dà h=4h=4.

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. Qui si usa la somma geometrica finita ∑j=0h−12j=2h−1\sum_{j=0}^{h-1}2^j = 2^h-1 (Notazione asintoticaDefinizioni di O, Omega, Theta e o piccolo con le costanti c ed n0; esempi con costanti esplicite; proprietà (polinomi, esponenziali, logaritmi, somme, implicazioni tra notazioni); sommatorie notevoli; terminologia (logaritmica, lineare, polinomiale, esponenziale).Notazione asintotica →, Serie notevoli - geometrica, telescopica, armonicaLe serie di cui si conosce il carattere e da usare come termine di paragone: geometrica (converge a 1/(1-q) se |q|<1), telescopiche (somma b_1 - lim b_n, come Mengoli), armonica generalizzata (1/n^alpha converge se e solo se alpha>1).Serie notevoli - geometrica, telescopica, armonica →), da cui 1+(2h−1)=2h1 + (2^h - 1) = 2^h è il numero minimo di nodi (livelli pieni più un solo nodo nell'ultimo), mentre 2h+1−12^{h+1}-1 è il massimo (tutti i livelli pieni). Applicando log⁡2\log_2 a 2h≤n<2h+12^h \le n < 2^{h+1} (funzione crescente: Esponenziale e logaritmoLa funzione esponenziale a^x (base positiva diversa da 1) e la sua inversa, il logaritmo in base a, con grafici e proprietà.Esponenziale e logaritmo →) si ottiene h≤log⁡2n<h+1h \le \log_2 n < h + 1, cioè h=⌊log⁡2n⌋h = \lfloor \log_2 n \rfloor. □\square

Il grafico mostra l'altezza (e quindi il numero massimo di scambi di insert e removeMin) in funzione di nn: cresce così lentamente che per n=1000n=1000 vale 99 e per un milione 1919.

Grafico interattivo: Altezza di un albero binario completo, floor(log2 n): 3 per n = 11 (gli heap degli esempi), 5 per n = 63; ogni volta che n raddoppia l'altezza cresce di 1

Definizione di heap

Definizione (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.

Esempio. L'array [4,6,9,15,7,16,12][4,6,9,15,7,16,12] è uno heap: 6,9≥46,9\ge4, 15,7≥615,7\ge6 e 16,12≥916,12\ge9.

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). 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).

Esempio. Nello heap [4,6,9,15,7,16,12][4,6,9,15,7,16,12] la radice 44 è il minimo e il cammino 4,6,154,6,15 è non decrescente.

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)

Formula (level numbering di uno heap su array). 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.)

Esempio. In P[1..11]P[1..11] i figli di P[5]P[5] sono P[10]P[10] e P[11]P[11], e il padre di P[11]P[11] è P[⌊11/2⌋]=P[5]P[\lfloor11/2\rfloor]=P[5].

Perché proprio 2i2i e 2i+12i+1. I livelli precedenti al livello ii contengono 1+2+⋯+2i−1=2i−11+2+\dots+2^{i-1}=2^i-1 nodi, quindi il livello ii comincia alla posizione 2i2^i. Il nodo in posizione 2i+k2^i+k è il kk-esimo del suo livello (contando da 00 a sinistra). I suoi due figli sono il 2k2k-esimo e il (2k+1)(2k+1)-esimo del livello i+1i+1, che comincia alla posizione 2i+12^{i+1}: le loro posizioni sono 2i+1+2k=2(2i+k)2^{i+1}+2k = 2(2^i+k) e 2(2i+k)+12(2^i+k)+1, cioè 2j2j e 2j+12j+1 se j=2i+kj=2^i+k è la posizione del padre. Esempio: P[5]P[5] ha figli P[10]P[10] e P[11]P[11]; P[6]P[6] ha P[12]P[12] e P[13]P[13].

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

Proprietà (insert 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.

Esempio. Su P=[4,6,9,15,7,16,12,20,18,8,11]P=[4,6,9,15,7,16,12,20,18,8,11], insert(5) mette 55 in posizione 1212 e lo fa salire fino alla 33: 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].

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 (il ragionamento è quello dell'array estendibile in Liste, pile e codeRipasso degli ADT elementari: lista index-based (array) e position-based (lista doppiamente concatenata con sentinelle), pila LIFO, coda FIFO, deque, iteratori; interfacce, implementazioni e costi; array estendibile e coda circolare.Liste, pile e code →).

Traccia di insert(5) su P=[4,6,9,15,7,16,12,20,18,8,11]P=[4,6,9,15,7,16,12,20,18,8,11]:

passo ii P[i]P[i] P[⌊i/2⌋]P[\lfloor i/2\rfloor] confronto azione
0 12 5 P[6]=16P[6]=16 16>516>5 scambio
1 6 5 P[3]=9P[3]=9 9>59>5 scambio
2 3 5 P[1]=4P[1]=4 4>54>5 falso stop

Due scambi (su un massimo di ⌊log⁡212⌋=3\lfloor\log_2 12\rfloor=3): 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].

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

Proprietà (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.

Esempio. Su P=[4,6,9,15,7,16,12,20,18,8,11]P=[4,6,9,15,7,16,12,20,18,8,11], removeMin() restituisce 44 e lo heap diventa [6,7,9,15,8,16,12,20,18,11][6,7,9,15,8,16,12,20,18,11].

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).

Traccia di removeMin() su P=[4,6,9,15,7,16,12,20,18,8,11]P=[4,6,9,15,7,16,12,20,18,8,11] (last=11\text{last}=11): restituisce 44, mette P[11]=11P[11]=11 in radice e last=10\text{last}=10, ottenendo [11,6,9,15,7,16,12,20,18,8][11,6,9,15,7,16,12,20,18,8].

passo ii figli di P[i]P[i] figlio minore confronto azione
1 1 P[2]=6P[2]=6, P[3]=9P[3]=9 P[2]=6P[2]=6 11>611>6 scambio, i=2i=2
2 2 P[4]=15P[4]=15, P[5]=7P[5]=7 P[5]=7P[5]=7 11>711>7 scambio, i=5i=5
3 5 P[10]=8P[10]=8 (e P[11]P[11] non esiste) P[10]=8P[10]=8 11>811>8 scambio, i=10i=10
4 10 nessuno (2⋅10>102\cdot10>10) fine

Risultato [6,7,9,15,8,16,12,20,18,11][6,7,9,15,8,16,12,20,18,11], tre scambi, come sopra. Se si scambiasse con il figlio maggiore (ad esempio 99 al passo 1) il nuovo padre di 66 sarebbe 9>69>6 e l'heap-order verrebbe violato.

Rimozione di una entry qualsiasi

Proprietà (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].

Esempio. Su P=[4,6,9,15,7,16,12,20,18,8,11]P=[4,6,9,15,7,16,12,20,18,8,11], removeEntry(P, 2) porta 1111 in posizione 22 e lo fa scendere (down-heap): P=[4,7,9,15,8,16,12,20,18,11]P=[4,7,9,15,8,16,12,20,18,11].

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. Con nn nodi, 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 con heap-order: key(v)≥key(padre)\text{key}(v) \ge \text{key}(\text{padre}). Radice = minimo; lungo ogni cammino le chiavi non decrescono; con chiavi distinte il massimo sta in una foglia; ogni nodo è ≤\le dei suoi discendenti. Chiavi duplicate ammesse.
  • Array P[1..n]P[1..n] (level numbering): radice P[1]P[1], figli di P[i]P[i] in P[2i]P[2i] e P[2i+1]P[2i+1], padre P[⌊i/2⌋]P[\lfloor i/2\rfloor], last=n\text{last}=n; spazio Θ(n)\Theta(n). Per alberi non completi lo stesso schema può richiedere 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(kk, xx): si pone la entry in P[last+1]P[\text{last}+1] e si risale: while i > 1 AND P[floor(i/2)].key > P[i].key, scambia e i <- floor(i/2). Invariante: l'unica coppia che può violare è (padre, P[i]P[i]). Θ(log⁡n)\Theta(\log n).
  • removeMin(): minentry <- P[1]; P[1] <- P[last]; last--; poi down-heap scambiando sempre con il figlio di chiave minore, perché così il nuovo padre è ≤\le di entrambi. Θ(log⁡n)\Theta(\log n).
  • removeEntry(jj): P[j]←P[last]P[j] \leftarrow P[\text{last}]; poi, se la chiave kk è maggiore del padre, si scende (down-heap) con k>min⁡(k1,k2)k > \min(k_1,k_2); se è minore del padre, si risale (up-heap) con k<k0k < k_0; altrimenti l'heap è già valido. Θ(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) dà [4,6,5,15,7,9,12,20,18,8,11,16][4,6,5,15,7,9,12,20,18,8,11,16] con 22 scambi; poi insert(3) dà [3,6,4,15,7,5,12,20,18,8,11,16,9][3,6,4,15,7,5,12,20,18,8,11,16,9]. Il successivo removeMin() restituisce 33 e lascia [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 con 77 entry distinte: il preorder può dare le chiavi in ordine crescente; inorder e postorder no, perché la radice minima è preceduta (inorder) o seguita (postorder) dal sottoalbero sinistro.
  • Proprietà dimostrate: il massimo sta in una foglia (per assurdo: un figlio avrebbe chiave maggiore); con 1212 entry distinte P[3]P[3] ha 33 discendenti (P[6]P[6], P[7]P[7], P[12]P[12]), quindi 33 entry sono certamente maggiori.
  • Pseudocodice removeMin: m <- P[1]; P[1] <- P[last--]; i <- 1; while 2i <= last: j <- figlio minimo; if P[i].key <= P[j].key then break; swap(P[i], P[j]); i <- j; return m.
  • 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).
  • Level numbering, perché 2i2i e 2i+12i+1: i livelli precedenti al livello ii contengono 2i−12^i-1 nodi, quindi il livello ii inizia alla posizione 2i2^i. Il nodo in posizione 2i+k2^i+k ha figli nelle posizioni 2(2i+k)2(2^i+k) e 2(2i+k)+12(2^i+k)+1. Esempio: P[5]P[5] ha figli P[10]P[10] e P[11]P[11]; P[6]P[6] ha P[12]P[12] e P[13]P[13].
  • Spazio: per un albero completo con nn nodi i livelli occupano le posizioni [2i,2i+1−1][2^i, 2^{i+1}-1] e l'ultimo [2h,n][2^h, n]; per un albero non completo lo schema può richiedere spazio esponenziale (un cammino di nn nodi tutto a destra arriva alla posizione 2n−12^n-1).
  • Esempio di removeMin: su [4,6,9,15,7,16,12,20,18,8,11][4,6,9,15,7,16,12,20,18,8,11] restituisce 44; 1111 va in radice e lo heap diventa [6,7,9,15,8,16,12,20,18,11][6,7,9,15,8,16,12,20,18,11] con 33 scambi.
  • Errori tipici: scambiare con il figlio sbagliato nel down-heap; last non aggiornato; credere che uno heap sia ordinato; mescolare le formule dei figli con indici 0 e 1.

Esercizi su questo argomento

Teoria collegata