Docsity
Docsity

Prepara i tuoi esami
Prepara i tuoi esami

Studia grazie alle numerose risorse presenti su Docsity


Ottieni i punti per scaricare
Ottieni i punti per scaricare

Guadagna punti aiutando altri studenti oppure acquistali con un piano Premium


Guide e consigli
Guide e consigli


Algoritmi e Strutture Dati (9 CFU), Appunti di Algoritmi E Strutture Di Dati

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

2025/2026

In vendita dal 01/02/2026

gabriele.studente
gabriele.studente 🇮🇹

1 documento

1 / 114

Toggle sidebar

Questa pagina non è visibile nell’anteprima

Non perderti parti importanti!

bg1
Algoritmi e Strutture Dati
pf3
pf4
pf5
pf8
pf9
pfa
pfd
pfe
pff
pf12
pf13
pf14
pf15
pf16
pf17
pf18
pf19
pf1a
pf1b
pf1c
pf1d
pf1e
pf1f
pf20
pf21
pf22
pf23
pf24
pf25
pf26
pf27
pf28
pf29
pf2a
pf2b
pf2c
pf2d
pf2e
pf2f
pf30
pf31
pf32
pf33
pf34
pf35
pf36
pf37
pf38
pf39
pf3a
pf3b
pf3c
pf3d
pf3e
pf3f
pf40
pf41
pf42
pf43
pf44
pf45
pf46
pf47
pf48
pf49
pf4a
pf4b
pf4c
pf4d
pf4e
pf4f
pf50
pf51
pf52
pf53
pf54
pf55
pf56
pf57
pf58
pf59
pf5a
pf5b
pf5c
pf5d
pf5e
pf5f
pf60
pf61
pf62
pf63
pf64

Anteprima parziale del testo

Scarica Algoritmi e Strutture Dati (9 CFU) e più Appunti in PDF di Algoritmi E Strutture Di Dati solo su Docsity!

Algoritmi e Strutture Dati

Capitolo 1

Introduzione agli algoritmi

Problema della bilancia – Parte 1

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…

Risoluzione con algoritmi ricorsivi

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:

I numeri di Fibonacci – Parte 2

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:

  • Una coppia di conigli genera due coniglietti ogni anno;
  • I conigli cominciano a riprodursi soltanto al secondo anno dopo la loro nascita
  • I conigli sono immortali

Algoritmo numerico

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

  • a

n – 2

⇔ a

n

  • a

n – 1

  • a

n – 2

⇔ a

n – 2

(a

2

  • a – 1) = 0 ⇔

⇔ a

2

  • a – 1 = 0 ⇔

α =

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,

F

1

= 1 e l’albero di ricorsione T 1

contiene un solo nodo e quindi una sola foglia; Anche per n =

2, F

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

  • 2 . Dunque, il numero totale delle foglie in T n

è uguale a F n – 1

+ F

n – 2

= F

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

Algoritmo iterativo

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

  • 2 = 3.404.709.508. Basandoci su questa stima, l’algoritmo

fibonacci3(n) è 38 milioni di volte più veloce di fibonacci2(n).

Occupazione di memoria

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. Sia fib[n] un array di n interi
  2. Fib[1] = fib[2] = 1
  3. for i = 3 to n do
  4. Fib[i] = fib[i – 1] + fib[i – 2]
  5. return fib[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)

1. M = (

  1. for i = 1 to n – 1 do

3. M = M · (

  1. return M[0][0]

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:

  1. se una funzione 𝑔(𝑛) ∈ 𝑂(ƒ(𝑛)), allora g(n) cresce al più come ƒ(n);
  2. se una funzione 𝑔(𝑛) ∈ Ω(ƒ(𝑛)), allora g(n) cresce almeno come ƒ(n);
  3. se una funzione 𝑔(𝑛) ∈ θ(ƒ(𝑛)), allora g(n) cresce esattamente come ƒ(n).

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

  • 10 𝑛. È facile vedere che 𝑔(𝑛) = 𝑂(𝑛

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 𝟑𝐧

𝟐

𝟐

Delimitazioni superiori e inferiori

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.

Metodi di analisi

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 :

  • L’analisi del caso medio è in qualche modo più complessa. Una prima ipotesi ragionevole

sembrerebbe la seguente: se x è nella lista, può occupare una qualsiasi posizione con

la stessa probabilità. Possiamo dunque concludere che:

𝐚𝐯𝐠

𝐰𝐨𝐫𝐬𝐭

𝐛𝐞𝐬𝐭

Ricerca binaria

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.

  • Il caso migliore si verifica quando x = L[(n + 1) / 2], ossia quando x si trova nella posizione

centrale dell’array. Pertanto, anche nella ricerca binaria, 𝐓

𝐛𝐞𝐬𝐭

  • Nel caso peggiore x viene trovato all’ultimo confronto, che avviene quando a = b. Per

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)

  1. a = 1
  2. b = lunghezza di L
  3. while (L[(a + b) / 2] ≠ x) do
  4. m = (a + b) / 2
  5. if (L[m] > x) then b = m – 1
  6. else a = m + 1
  7. if (a > b) then return non Trovato
  8. return trovato

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.

Analisi di algoritmi randomizzati

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)

  1. permuta casualmente L
  2. for each (y ∈ L) do
  3. if (y = x) then return trovato
  4. return non trovato

L’algoritmo a fianco calcola una

permutazione casuale della lista in modo

che l’elemento x appaia in una posizione

casuale nella permutazione.