




























































































Studia grazie alle numerose risorse presenti su Docsity
Guadagna punti aiutando altri studenti oppure acquistali con un piano Premium
Prepara i tuoi esami
Studia grazie alle numerose risorse presenti su Docsity
Prepara i tuoi esami con i documenti condivisi da studenti come te su Docsity
Trova i documenti specifici per gli esami della tua università
Preparati con lezioni e prove svolte basate sui programmi universitari!
Rispondi a reali domande d’esame e scopri la tua preparazione
Riassumi i tuoi documenti, fagli domande, convertili in quiz e mappe concettuali
Studia con prove svolte, tesine e consigli utili
Togliti ogni dubbio leggendo le risposte alle domande fatte da altri studenti come te
Esplora i documenti più scaricati per gli argomenti di studio più popolari
Ottieni i punti per scaricare
Guadagna punti aiutando altri studenti oppure acquistali con un piano Premium
Il documento in questione costituisce un riassunto approfondito del corso di Algoritmi e Strutture Dati da 9 CFU, che ho frequentato all’Università degli Studi di Palermo. I riassunti fanno riferimento al libro di testo “Algoritmi e Strutture dati - Seconda Edizione” degli autori Camil Demetrescu, Irene Finocchi e Giuseppe F. Italiano. Di seguito, sono riportati gli argomenti trattati nel documento e i capitoli corrispondenti nel libro di testo di riferimento: Capitolo 1 - Un’introduzione informale agli algoritmi Capitolo 2 - Modelli di Calcolo e metodologie di analisi Capitolo 3 - Strutture dati elementari Capitolo 4 - Ordinamento Capitolo 6 - Alberi binari di ricerca Capitolo 7 - Tabelle hash Capitolo 12 - Grafi e visite di grafi Capitolo 13 - Minimo albero ricoprente Capitolo 14 - Cammini minimi
Tipologia: Appunti
1 / 114
Questa pagina non è visibile nell’anteprima
Non perderti parti importanti!





























































































Si considerino n monete tutte identiche d’aspetto. Una
delle monete è falsa e pesa leggermente più delle altre.
Avendo a disposizione solo una bilancia a due piatti ,
individuare la moneta falsa strutturando il miglior
algoritmo.
All’interno di questo problema “giocattolo” ricorrono molti dei concetti collegati agli algoritmi:
L’ istanza in questione è n specifiche monete di cui una è quella falsa, di conseguenza la
dimensione dell’istanza è il valore n. Il modello di calcolo è la bilancia a due piatti.
Per costruire l’algoritmo migliore possibile dobbiamo analizzare la complessità temporale del
problema del caso peggiore , ossia (in questo caso) il numero massimo di pesate che la bilancia
esegue. Costruire l’algoritmo più efficiente consiste nell’individuare il miglior algoritmo, in
termini di efficienza, per il caso peggiore, ossia per il caso in cui si ha il numero massimo di
pesate. Vediamo 4 possibili algoritmi valutandone l’efficienza mediante uno pseudocodice.
Primo algoritmo : (sia n = 7,) uso la prima moneta e la confronto con le altre.
L’algoritmo è corretto. Nel caso peggiore (quello in cui la
moneta falsa è l’ultima) effettua n – 1 pesate.
Si osservi che l’ultima pesata non serve, poiché l’unica
moneta che può essere falsa è l’unica non ancora pesata;
pertanto, il costo del primo algoritmo è n – 2.
Secondo algoritmo : (sia n = 7,) peso le monete a coppie.
for i=2 to n do
if peso(𝑥
1
) > peso (𝑥
𝑖
) then return 𝑥
1
if peso(𝑥
1
) < peso (𝑥
𝑖
) then return 𝑥
𝑖
k = n / 2.
for i = 1 to k do
if peso(𝑥
2 𝑖− 1
) > peso(𝑥
2 𝑖
) then return 𝑥
2 𝑖− 1
if peso(𝑥
2 𝑖− 1
) < peso(𝑥
2 𝑖
) then return 𝑥
2 𝑖
/*non ho trovato la moneta falsa; né dipari e manca una
moneta */
return 𝑥
𝑛
L’algoritmo è nuovamente corretto ma, in questo caso, il costo si riduce a n / 2 , quindi abbiamo
già ricavato una soluzione migliore rispetto al primo algoritmo. Successivamente vedremo che
mediante l’ algoritmo ricorsivo otterremo algoritmi ancora più efficienti…
Quando un codice è costituito da istruzioni che si ripetono ci sono due possibili approcci:
l’algoritmo iterativo e l’algoritmo ricorsivo. Molto spesso, però, i costrutti iterativi svolgono, per
ogni ciclo, calcoli che hanno già effettuato nei cicli precedenti; Si osservi, ad esempio, che nel
primo algoritmo del problema della bilancia peso(𝑥
1
) viene calcolato nuovamente per ogni ciclo.
Qui risiede il vantaggio del ciclo ricorsivo, che al contrario dei cicli iterativi sfrutta i calcoli
effettuati in un ciclo anche nei cicli successivi.
Un algoritmo si dice ricorsivo se è espresso in
termini di se stesso. Ad esempio, la funzione di
fibonacci può essere implementata mediante
una funzione ricorsiva, ossia una funzione che
chiama sé stessa direttamente o indirettamente
mediante un’altra funzione.
Cerchiamo adesso di migliorare l’algoritmo del problema della bilancia mediante un’algoritmo
ricorsivo
Terzo algoritmo: (sia n = 10 ,) peso le monete dividendo in due gruppi
if (|X| = 1) then return unica moneta in X
dividi X in due gruppi X 1
e X 2
di uguale
dimensione k = |X| / 2 e se |X| è dispari una
ulteriore moneta y
if peso(X 1
) = peso(X 2
) then return Alg3(X 1
else return Alg3(X 2
Si consideri Alg3() il nome dell’algoritmo e X
l’array di monete.
Sappiamo che l’algoritmo è corretto. Per
calcolarne il costo nel caso peggiore è
meglio ricorrere alla rappresentazione
mediante l’ albero di ricorsione.
Quarto algoritmo : (sia n = 10,) peso dividendo le monete in 3 gruppi invece di 2.
Questa volta l’albero di ricorsione non è binario bensì ternario, per cui il numero di pesate
eseguite nel caso peggiore, nonché l’altezza del rispettivo albero di ricorsione è:
𝟑
E’ stato dimostrato che Alg4 costituisce l’algoritmo migliore per la risoluzione del problema
della bilancia, dunque possiamo concludere che: un qualsiasi algoritmo che correttamente
individua la moneta falsa fra n monete deve effettuare, nel caso peggiore, almeno log 3
n pesate.
Andando a provare sperimentalmente i 4 algoritmi su istanze diverse, ci accorgiamo che al
crescere della dimensione dell’istanza la differenza diventa abissale:
Leonardo di Pisa, noto come Fibonacci, è un celebre matematico italiano vissuto intorno al 1200
che studiò come si espande una popolazione di conigli sotto opportune condizioni. Si immagini
di fare il seguente esperimento: partiamo da una singola coppia di conigli in un’isola deserta e
proviamo a studiarne l’evoluzione nel corso degli anni, supponendo che:
Per ottenere un primo algoritmo, cerchiamo una funzione matematica che calcoli direttamente i
numeri di Fibonacci; Partendo dalla relazione di ricorrenza, proviamo con funzioni esponenziali
della forma a
n
(con a ≠ 0), dunque:
a
n
= a
n – 1
n – 2
⇔ a
n
n – 1
n – 2
⇔ a
n – 2
(a
2
⇔ a
2
α =
1 + √ 5
2
≈ + 1 , 618 ∨ β =
1 − √ 5
2
Tuttavia, nessuna delle due soluzioni calcola i numeri di Fibonacci, poiché non abbiamo
considerato i casi basi della relazione di ricorsione (ossia n = 1 ed n = 2); Cerchiamo allora una
combinazione lineare che soddisfi tali casi, ossia:
Questo fenomeno riproduttivo è
illustrato nella figura a fianco. Sia F (n) il
numero di conigli nell’anno n, accade
che nell’anno n sono presenti tutti i
conigli dell’anno precedente, F (n – 1) ,
ed inoltre abbiamo una coppia di conigli
per ogni coppia presente due anni
prima, ossia F (n – 2).
Il problema che, dunque, risiede nel
trovare la soluzione della seguente
relazione di ricorrenza:
F(n) = {
F(n − 1 ) + F(n − 2 )
se n ≥ 3
se n = 1,
Come facciamo ad analizzare il numero di linee di codice mandato in esecuzione per una
generica chiamata alla funzione fibonacci2(n)? I seguenti lemmi rispondo a questa domanda.
Lemma 1.1 Sia T n
l’albero delle chiamate ricorsive della funzione fibonacci2(n). Il numero di foglie
in T n
è esattamente uguale al numero di Fibonacci F n
Dimostrazione:
Di seguito si dimostra il lemma per induzione. La base è banalmente verificata poiché se n = 1,
1
= 1 e l’albero di ricorsione T 1
contiene un solo nodo e quindi una sola foglia; Anche per n =
2
= 1 quindi valgono gli stessi accorgimenti.
Per il passo induttivo, sia n > 2 ed assumiamo induttivamente che il lemma sia valido per ogni
k ≤ (n - 1). Vogliamo dimostrare che il lemma sia verificato per k = n. Se n >2, sappiamo che
l’albero della ricorsione T n
ha come sottoalbero sinistro T n – 1
, ossia l’albero delle chiamate
ricorsive della funzione fibonacci2(n – 1) e come sottoalbero destro T n – 2
, ossia l’albero delle
chiamate ricorsive della funzione fibonacci2(n – 2). Per l’ipotesi induttiva, il numero delle foglie
di T n – 1
è esattamente uguale a F n – 1
; Analogamente, il numero delle foglie di T n – 2
è uguale a F n
è uguale a F n – 1
n – 2
n
Lemma 1.2 Sia T un albero binario in cui ogni nodo interno ha esattamente due figli. Allora il
numero di nodi interni di T è sempre uguale al numero di foglie diminuito di uno.
Dimostrazione:
Ancora una volta la dimostrazione procederà per induzione. Sia n il numero di nodi dell’albero
T. Esaminiamo la base induttiva: per n = 1, l’albero ha una sola foglia dunque il numero di nodi
interni è 0 = 1 – 1.
Supponiamo ora che per ipotesi la condizione valga per tutti gli alberi con meno di n nodi, e
dimostriamo che deve valere anche per T. Se f e i sono rispettivamente il numero di foglie e il
numero di nodi interni di T, vogliamo dimostrare che i = f – 1. Sia T 2
un albero ottenuto da T
rimovendo una qualunque coppia di foglie con lo stesso padre, allora T 2
avrà i – 1 nodi interni
e f – 2 + 1 = f – 1 foglie (ne togliamo 2 perché abbiamo tolto una coppia di foglie e ne
aggiungiamo una poiché il nodo padre della coppia di foglie tolta è adesso una foglia). Ma per
l’ipotesi induttiva il lemma vale per l’albero T 2
, dunque i – 1 = (f – 1) – 1. Aggiungendo 1 ad
ambo i membri otteniamo i = f – 1.
Attraverso questi due lemmi possiamo esprimere il costo di fibonacci2(n) in termini di linee di
codice: ad ogni foglia corrisponde una linea di codice e ad ogni nodo interno corrispondono
due linee di codice, dunque:
linee di codice = 2 · (F(n) – 1) + F(n) = 3 · F(n) - 2
L’algoritmo fibonacci2(n) è lento poiché continua a ricalcolare ripetutamente la soluzione
dello stesso sottoproblema. Si osservi l’albero di ricorsione per fibonacci2(8) due pagine
prima: La seconda volta che compare ad esempio F 4
perdiamo tempo a ricalcolarlo: n realtà, lo
abbiamo già calcolato nel sottoalbero destro.
Per fare di meglio, potremmo quindi risolvere ogni sottoproblema una volta soltanto,
memorizzare questa soluzione ed usarla nel seguito invece di ricalcolarla. Questa semplice idea
è alla base di una tecnica algoritmica chiamata programmazione dinamica che vedremo più nel
dettaglio nel Capitolo 10.
Quello a fianco è un algoritmo iterativo in
cui utilizziamo i cicli anziché la ricorsione.
Vediamo in seguito perché tale
implementazione è molto più veloce di
fibonacci2(n) calcolando il costo in termini
di linee di codice.
In ogni caso, si mandano in esecuzione sempre in esecuzione 3 linee di codice (righe 1, 2, 5). Il
confronto della riga 3 viene eseguito finchè i > n, dunque (n – 1) volte, considerando che
partiamo da i = 3. La linea di codice 4, invece, viene eseguita ogni qualvolta il confronto vada
a buon fine, quindi (n – 2) volte. Il costo di fibonacci2(n) è dunque:
linee di codice = 3 + (n – 1) + (n – 2) = 2n
Ad esempio, per n = 45 fibonacci3(45) esegue 90 linee di codice, mentre fibonacci3(45) ne
esegue un numero pari a 3 · F 45
fibonacci3(n) è 38 milioni di volte più veloce di fibonacci2(n).
Nel caso in cui un programma richiede una quantità di memoria eccessiva non si ha neppure la
garanzia che ci restituisca un risultato; Il programma potrebbe richiede più spazio di quello
offerto dal disco rigido , oppure il continuo trasferimento di dati da memoria secondaria (disco
rigido) a memoria principale (RAM) potrebbe non fare affatto terminare la sua esecuzione.
L’analisi dell’occupazione di memoria, dunque, sembra essere un parametro molto importante
nell’analisi degli algoritmi. Mentre per gli algoritmi iterativi è sufficiente esaminare la
dichiarazione di variabili e le chiamate di allocazione, per quelli ricorsivi l’analisi sulla gestione
della memoria è più complessa : ogni chiamata ricorsiva richiede una quantità costante di
spazio, in particolare una quantità costante per le variabili locali e per gli argomenti della
funzione ed un’altra quantità costante per memorizzare l’indirizzo di ritorno.
algoritmo fibonacci3(intero n)
𝑛 − 1
𝑛 − 1
𝑛 − 1
𝑛 − 2
𝑛 − 2
𝑛 − 3
𝑛 − 1
𝑛 − 2
𝑛 − 1
𝑛 − 2
𝑛 − 3
𝑛 − 2
𝑛
𝑛 − 1
𝑛 − 1
𝑛 − 2
Basandoci su questo lemma, formuliamo un quinto algoritmo:
Inizializziamo M alla matrice identità (nonché
potenza zero di A) e moltiplichiamo
ripetutamente M per A fino ad ottenere la
potenza (n – 1)-esima.
Secondo il lemma precedente, l’elemento in
alto a sinistra è esattamente F n
. Ancora una
volta, fibonacci5 ha bisogno di una quantità di
memoria costante , dunque indipendente da n.
algoritmo fibonacci5(intero n)
Capitolo 2
Modelli di calcolo e metodologie di analisi
Definizione 2.1 Data una funzione ƒ(n), definiamo:
1. 𝑂(ƒ(𝑛)) = {𝑔(𝑛): ∃𝑐 > 0 𝑒 𝑛
0
≥ 0 𝑡𝑎𝑙𝑖 𝑐ℎ𝑒 𝑔(𝑛) ≤ 𝑐ƒ(𝑛) 𝑝𝑒𝑟 𝑜𝑔𝑛𝑖 𝑛 ≥ 𝑛
0
2. 𝛺(ƒ(𝑛)) = {𝑔(𝑛): ∃𝑐 > 0 𝑒 𝑛
0
≥ 0 𝑡𝑎𝑙𝑖 𝑐ℎ𝑒 𝑔(𝑛) ≥ 𝑐ƒ(𝑛) 𝑝𝑒𝑟 𝑜𝑔𝑛𝑖 𝑛 ≥ 𝑛
0
3. 𝜃(ƒ(𝑛)) = {𝑔(𝑛): ∃𝑐
1
2
0
0
1
ƒ(𝑛) ≤ 𝑔(𝑛) ≤ 𝑐
2
ƒ(𝑛)}
Il vantaggio di queste notazioni è che ci permettono di studiare la crescita di ƒ(n) partendo da
funzioni più semplici. Infatti, per n sufficientemente grande (ossia n ≥ n 0
) e per opportune
costanti moltiplicative, si verifica che:
Osserviamo graficamente queste tre affermazioni:
Dunque gli insiemi O(ƒ(n)), Ω(ƒ(n) e θ(ƒ(n)) descrivono quindi delle classi di funzioni che sono
limitate rispettivamente superiormente, inferiormente o da ambo i lati di ƒ(n).
Proprietà 2.1 Date due funzioni ƒ(n) e g(n), risulta g(n) = θ(ƒ(n)) se e solo se g(n) = O(ƒ(n)) e g(n)
= Ω(ƒ(n)).
Consideriamo la funzione 𝑔(𝑛) = 3 𝑛
2
2
): infatti
scegliendo c= 4 e n 0
= 10, abbiamo che 3 𝑛
2
2
per ogni 𝑛 ≥ 10. Analogamente
risulta anche che 𝑔(𝑛) = 𝛺(𝑛
2
): infatti 3 𝑛
2
2
per ogni n (c = 1 e n 0
= 0). Per la
proprietà appena illustrata 𝟑𝐧
𝟐
𝟐
La notazione asintotica O è adatta ad esprimere delimitazioni superiori (upper bound) sul
costo di esecuzione di un algoritmo o sulla complessità di un problema. In particolare:
Definizione 2.2 Un algoritmo A ha un costo di esecuzione O(ƒ(n)) su istanze di ingresso di
dimensione n e rispetto ad una certa risorsa di calcolo, se la quantità r di risorsa sufficiente per
eseguire A su una qualunque istanza di dimensione n verifica la relazione r(n) = O(ƒ(n)).
Definizione 2.3 Un problema P ha una complessità O(ƒ(n)) rispetto ad una data risorsa di calcolo
se esiste un algoritmo che risolve P il cui costo di esecuzione rispetto a quella risorsa è O(ƒ(n)).
Dunque, è importante sforzarsi costantemente di progettare algoritmi sempre più efficienti, in
modo da poter individuare la più “piccola” funzione ƒ(n) per cui si possa affermare che
l’algoritmo ha costo di esecuzione O(ƒ(n)) su ogni istanza di dimensione n.
Analogamente, la notazione Ω è molto adatta ad esprimere delimitazioni inferiori o lower
bound sul costo di esecuzione di un algoritmo o sulla complessità di un problema, come
precisato dalle seguenti definizioni
Definizione 2.4 Un algoritmo A ha costo di esecuzione Ω(ƒ(n)) su istanze di dimensione n e
rispetto ad una certa risorsa di calcolo, se la massima quantità r di risorsa necessaria per
eseguire A su istanze di dimensione n verifica la relazione r(n) = Ω(ƒ(n)).
Definizione 2.5 Un problema P ha una complessità Ω(ƒ(n)) rispetto ad una data risorsa di calcolo
se ogni algoritmo che risolve P ha costo di esecuzione Ω(ƒ(n)) rispetto a quella risorsa.
Intuitivamente, una delimitazione inferiore dà un’idea di quanto efficientemente possa essere
risolto un problema e se ci sia la possibilità di migliorare gli algoritmi noti. I lower bound sono
espressi in termini di argomentazioni matematiche che dimostrano che non si può risolvere il
problema usando meno di una certa quantità di una certa risorsa di calcolo.
Si osservi che la presenza di una delimitazione inferiore non indica necessariamente che
algoritmi più efficienti siano impossibili , ma solo che è necessario cercarli al di fuori del
modello considerato.
In definitiva, l’algoritmo ideale consiste in un algoritmo con complessità minima rispetto ad una
data risorsa di calcolo e costo di esecuzione ottimale rispetto a quella risorsa, come precisato
formalmente da quest’ultima definizione:
Definizione 2.6 Dato un problema P con complessità Ω(ƒ(n)) rispetto ad una data risorsa di
calcolo, un algoritmo che risolve P è ottimo se ha costo di esecuzione O(ƒ(n)) rispetto a quella
risorsa.
Abbiamo osservato che, per predire il tempo di esecuzione senza essere costretti a guardare i
dettagli dell’istanza del problema, sembra essere una buona idea misurarlo come funzione della
dimensione dell’istanza stessa. Eppure, potrebbe accadere che istanze diverse, sebbene della
stessa dimensione, implichino tempi di esecuzione molto diversi.
Per osservare da vicino questo fenomeno, si consideri un elemento x ed un insieme S; x
appartiene ad s? Esistono due varianti principali del problema di ricerca, a seconda che gli
elementi in S siano ordinati o meno.
In particolare, se gli elementi non sono ordinati (non ho alcuna informazione sul loro possibile
ordinamento) non potemmo far altro che esaminare tutti i numeri telefonici nell’elenco, dal primo
all’ultimo, fino a trovare quello desiderato. Questo approccio è un tipico esempio di ricerca
sequenziale :
sembrerebbe la seguente: se x è nella lista, può occupare una qualsiasi posizione con
la stessa probabilità. Possiamo dunque concludere che:
𝐚𝐯𝐠
𝐰𝐨𝐫𝐬𝐭
𝐛𝐞𝐬𝐭
E’ possibile progettare un algoritmo di ricerca più veloce dell’algoritmo ricercaSequenziale nel
caso in cui l’insieme di elementi sia ordinato. Il procedimento è detto ricerca binaria (o
dicotomica); Per usufruirne dobbiamo assumere di avere accesso diretto agli elementi i n base
alla loro posizione : non possiamo, ad esempio, memorizzare gli elementi in una lista di
puntatori, ma dobbiamo assumere che essi siano contenuti in un array.
La procedura è la seguente: prendiamo un elemento k in posizione centrale e confrontiamolo
con l’elemento x che stiamo cercando: se k < x, restringiamo la ricerca alle posizioni successive
a quella di k, altrimenti procediamo nelle posizioni precedenti.
centrale dell’array. Pertanto, anche nella ricerca binaria, 𝐓
𝐛𝐞𝐬𝐭
semplicità di analisi, assumiamo che n sia una potenza di 2, ossia che n sia pari (nell’analisi
del problema reale, potremmo adottare un approccio simile al problema della bilancia; se
n è dispari, isoliamo l’ultimo elemento e conduciamo la ricerca nel resto dell’array):
Ad ogni passo, la dimensione del sottoarray su cui la ricerca procede viene dimezzata : si parte
quindi da un array di dimensione n, ci si riduce ad un sottoarray di n/2, poi n/4 e così via. In
generale, pertanto, dopo i confronti si ha un sottoarray di dimensione 𝑛 / 2
𝑖
, pertanto:
algoritmo ricercaBinaria(array L, elem x)
Le variabili a e b rappresentano l’estremo
sinistro e destro dell’array su cui la ricerca
deve proseguire.
Vediamo ora quanto costa effettuare la
ricerca binaria su un array di n elementi
rispettivamente nel caso migliore, peggiore
e medio.
𝟐
𝐰𝐨𝐫𝐬𝐭
𝟐
Dunque, la ricerca binaria è esponenzialmente più veloce della ricerca sequenziale.
Ancora non abbiamo gli strumenti per analizzare il caso medio correttamente, poiché non
sappiamo come ricavare la probabilità. Consideriamo, però, un caso in cui il costo del caso
peggiore è molto più elevato di quello del caso medio (𝐓
𝐰𝐨𝐫𝐬𝐭
𝐚𝐯𝐠
(𝐧)); In questa
condizione, potremmo preferire un algoritmo leggermente meno efficiente che riduca il costo
del caso peggiore e che, al contempo, non incrementi troppo quello del caso medio :
I numeri casuali (random) sono molto utili a questo scopo poiché ci permettono di analizzare
il caso medio pur non conoscendo la distribuzione dei dati in ingresso iniziale. L’idea è di
permutare l’istanza rendendola casuale. Diciamo che un algoritmo è randomizzato se usa
numeri casuali. Un algoritmo non randomizzato è invece detto deterministico.
Si osservi che la permutazione rallenta leggermente l’algoritmo, per esempio di un tempo O(n),
perché si perde del tempo per calcolare la permutazione, ma potrebbe rendere più veloce la
ricerca.
Se stiamo cercando un numero in una lista, probabilmente usare la ricerca randomizzata non
sarebbe una buona idea, perché il tempo per calcolare la permutazione sarebbe probabilmente
maggiore del tempo richiesto per risolvere l’algoritmo deterministico di ricerca sequenziale. Ma
se i confronti sono molto lenti (ad esempio, se stiamo confrontando immagini e stringhe di testo
molto lunghe), si potrebbero ottenere dei miglioramenti sostanziali, poiché il tempo per i
confronti dominerebbe di gran lunga il tempo di esecuzione totale.
Chiamiamo il tempo di esecuzione di un algoritmo randomizzato tempo atteso (expected). Si
ha dunque che:
𝑒𝑥𝑝
𝑥,𝐿
𝑝𝑒𝑟𝑚 𝜋
algoritmo ricercaRandomizzata(lista L, elem x)
L’algoritmo a fianco calcola una
permutazione casuale della lista in modo
che l’elemento x appaia in una posizione
casuale nella permutazione.