























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
Appunti del primo anno della triennale in informatica, relativi a: -Introduzione generale sugli algoritmi -Studio della complessità temporale (O grande, omega, theta) -Algoritmi di ordinamento (Insertion sort, selection sort, quick-sort, merge-sort, bubble-sort) -Teorema dell'esperto -Strutture dati: liste doppiamente e singolarmente concatenate, pile, code, implementazione mediante array, alberi binari di ricerca, visita preordine, postordine, inordine -Heap (Heapify, Build-heap, heap-sort)
Tipologia: Appunti
1 / 31
Questa pagina non è visibile nell’anteprima
Non perderti parti importanti!
























Cambiago Silvia Anno Accademico 2021- 2022 Prof. Alberto Dennunzio
Un algoritmo è una sequenza finita di istruzioni non ambigue che, eseguita a partire da un
insieme di dati, produce altri dati in un numero finito di passaggi.
I dati devono essere rappresentabili in modo finito, utilizzando quindi una quantità finita di
informazione. Non si possono quindi ricevere in input dati non rappresentabili con un numero
finito di cifre, come ad esempio π. Ciò però non va a limitare l’insieme dei possibili input, il
quale può essere anche costituito da un numero infinito di elementi (es ℕ, ℤ). Non tutto
l’insieme ℝ è, invece, un input idoneo, in quanto contiene anche i numeri irrazionali.
I dati prodotti sono in relazione con i dati a partire dai quali l’esecuzione viene applicata
mediante una funzione (l’algoritmo deve essere quindi deterministico):
Un problema computazionale prevede la presenza di un’istanza, ossia l’input necessario alla
computazione della soluzione, e di quest’ultima, quindi l’output. Il testo del problema descrive
in termini appartenenti al linguaggio comune la funzione che lega input e output.
Esempio:
Problema: Eseguire l’ordinamento di un array di n elementi interi.
Istanza: x ∈ ℤ*
Soluzione: 𝑓(𝑥) = (
() = sequenza vuota
n +
> y n
∀ n ∈ {1,…, n - 1}
Sia g una funzione asintotica non negativa.
Il limite asintotico superiore, od O grande, è definito come:
!
!
A partire da n 0
, c ⋅ g(n) è
sempre più grande di f(n).
g(n) è limite asintotico
superiore per f(n), il che
vuol dire che f(n) cresce al
più come g(n) a meno di un
fattore costante. La funzione
f(n) è detta “O grande” di
g(n).
L’O grande è solitamente impiegato per descrivere la complessità del caso peggiore di un
algoritmo, prendendo l’ordine più grande di una funzione polinomiale ignorando le costanti
(trascurabili per n ® ∞).
Il limite asintotico inferiore, od omega, è definito come:
!
!
A partire da n 0
, c ⋅g(n) è
sempre più piccola di
f(n). g(n) è limite asintotico
inferiore per f(n), il che
vuol dire che f(n) cresce
almeno come g(n) a meno
di un fattore costante. La
funzione f(n) è detta
“omega” di g(n).
Questa notazione è utilizzata per descrivere la complessità del caso migliore di un algoritmo.
Il limite asintotico stretto, o theta, è definito come:
θ(𝑔(𝑛)) = {𝑓 | ∃ 𝑐 "
!
!
"
A partire da n 0
, f(n) è
sempre compresa tra
c 1
⋅g(n) e c 2
⋅g(n). g(n) è
limite asintotico stretto per
f(n), il che vuol dire che
f(n) cresce esattamente
come g(n) a meno di un
fattore costante. La
funzione f(n) è detta
“theta” di g(n).
Questa notazione è utilizzata per descrivere la complessità di tutti i casi di un algoritmo
compresi tra quello migliore e quello peggiore.
Tipicamente, gli algoritmi vengono scritti in pseudocodice, linguaggio che esprime il
significato ed il senso dell’algoritmo rimanendo indipendente dalla macchina su cui dovrà
essere implementato, pur essendo sintatticamente simile ai principali linguaggi di
programmazione.
Nella scrittura dello pseudocodice è necessario rispettare determinate convenzioni:
Nel caso migliore, quello in cui k sia il primo elemento di A, la complessità è 𝜃( 1 ). Nel caso
peggiore, quando l’elemento chiave è l’ultimo della sequenza oppure non è presente, la
complessità è 𝜃(𝑛), in quanto n sono i confronti da effettuare.
Per ottenere la complessità nel caso medio, si somma il numero di confronti da eseguire caso
per caso:
e lo si divide per la lunghezza dell’array n , ne risulta:
La complessità nel caso medio è quindi 𝜃( n /2) = 𝜃( n ).
Una rappresentazione grafica della ricerca sequenziale è la seguente:
L’algoritmo di ricerca dicotomica è impiegato nella ricerca di un numero k all’interno di un
array ordinato V di n elementi. Il processo di ricerca è effettuato dividendo ripetutamente a
metà la sequenza. Se il numero cercato è minore dell’elemento in mezzo all’array, allora
l’operazione viene ripetuta valutando la prima metà, se è maggiore verrà considerata la
seconda.
Di seguito è mostrato lo pseudocodice della ricerca dicotomica:
Caso migliore
1 1 1 1 0 0 / 0 0 1 1 / 0
Caso peggiore
1
1
1
log
!
𝑛 + 1
log
!
𝑛
log
!
𝑛
/
log
!
𝑛
log
!
𝑛
1
0
/
1
Vengono memorizzati gli elementi iniziale e finale della sequenza che si sta analizzando e si
trova il centro ad ogni iterazione facendo la media tra essi.
Il caso migliore si ha quando l’elemento chiave si trova al centro e si effettua una sola
iterazione.
Il tempo di esecuzione è quindi:
= 1 + 1 + 1 + 1 + 1 + 1 = 6 = θ
Il caso peggiore si verifica invece quando l’elemento cercato non è presente nella sequenza.
La lunghezza dell’array si dimezza ad ogni iterazione. Detto x il numero di iterazioni, la
lunghezza dell’array rimanente è
$
!
. Nel caso peggiore tale valore è 1 , quindi:
%
Il numero di iterazioni diventa quindi 𝑙𝑜𝑔
ed il tempo di esecuzione rimane:
La complessità, di conseguenza, è θ(𝑙𝑜𝑔
Di seguito un esempio grafico del funzionamento:
Caso migliore
1
n
n – 1
" +𝑛 − 𝑖 + 1.
! & '
𝑖 # '
" +𝑛 − 𝑖.
! & '
𝑖 # '
0
n – 1
0
Caso peggiore
1
n
n – 1
" +𝑛 − 𝑖 + 1.
! & '
𝑖 # '
" +𝑛 − 𝑖.
! & '
𝑖 # '
" [𝑛 − ( 2 𝑖 − 1 )
! & '
% # '
]
n – 1
3(n – 1 )
%
𝑖 − 1 =
(
) *!
𝑛
( 𝑛 + 1
)
2
− 1 − (𝑛 − 1 ) =
𝑛
( 𝑛 + 1
)
2
− 𝑛
Il tempo totale di esecuzione del caso peggiore risulta:
𝑇(𝑛) = 1 + n + n − 1 + 𝑛 − 1 +
2
= θ
Nella figura seguente è mostrato un esempio grafico di implementazione dell’insertion-sort:
Un algoritmo di ordinamento alternativo all’insertion-sort è il selection-sort, che ordina l’array
cercando l’elemento minimo, piazzandolo all’inizio, e restringendo ad ogni iterazione la
porzione di array in cui esegue la ricerca e la selezione. Lo pseudocodice è il seguente:
Le sommatorie descritte da parte alla figura valgono:
" (𝑛 − 𝑖 + 1 ) = (𝑛 − 1 )𝑛 −
(𝑛 − 1 )𝑛
2
$ % &
' ( &
=
1
2
𝑛(𝑛 − 1 ) + 𝑛 − 1
" (𝑛 − 𝑖)
$ % &
' ( &
=
1
2
𝑛(𝑛 − 1 )
" [𝑛 −
( 2 𝑖 − 1
)
$ % &
' ( &
] = 𝑛(𝑛 − 1 ) − 2 ⋅
1
2
𝑛
( 𝑛 − 1
)
Nel caso migliore, il tempo di esecuzione è:
= 1 + n + n − 1 +
= θ(𝑛
Nel caso peggiore risulta invece:
𝑇(𝑛) = 1 + n + n − 1 +
= θ(𝑛
Questo algoritmo non presenta differenze notevoli tra caso migliore e peggiore, in quanto non
è in grado di individuare un array già ordinato, ma esegue comunque tutti i cicli.
Un esempio grafico di funzionamento è:
Il bubble-sort è un ulteriore algoritmo di ordinamento che, dato un vettore A di lunghezza n ,
scambia, ad ogni iterazione, ogni elemento con il successivo nel caso in cui non siano ordinati.
Lo pseudocodice è:
Ad ogni iterazione, l’algoritmo assicura che l’elemento massimo venga posizionato
correttamente: alla prima esecuzione pone l’elemento più grande nella n - esima posizione, alla
seconda, controllando fino alla posizione n - 1, si assicurerà che il massimo della sequenza
rimanente finisca in posto n - 1 e così via.
L’algoritmo non riconosce se il vettore sia già ordinato o meno, ma esegue ogni volta i cicli
indipendentemente dall’input, risultando quindi inefficiente nel caso migliore.
L’unica differenza tra caso migliore e peggiore sta nell’esecuzione dell’istruzione di scambio.
Le sommatorie descritte da parte alla figura valgono:
Caso migliore
1
n
" (𝑛 − 𝑖 + 1 )
! & '
% # '
" (𝑛 − 𝑖)
! & '
% # '
0
Caso peggiore
1
n
" (𝑛 − 𝑖 + 1 )
! & '
% # '
" (𝑛 − 𝑖)
! & '
% # '
3 *(precedente)
Il merge-sort, dato un array A di n elementi, opera come di seguito al fine di ottenere una
sequenza ordinata:
Il caso base è quello in cui la sequenza da ordinare ha dimensione 1 , e risulta quindi già
ordinata.
Lo pseudocodice è il seguente ( l è l’indice del primo argomento ed r quello dell’ultimo):
Lo pseudocodice della procedura merge è:
Caso base
1
0
0
0
0
Se l = r l’array A [ l … r ] è già ordinato e l’algoritmo non deve eseguire alcuna istruzione.
Altrimenti:
Il merge-sort è un algoritmo stabile, ossia mantiene inalterato l’ordine relativo di elementi
uguali. Non è invece in loco, in quanto non utilizza un numero costante di variabili
indipendentemente dall’input (per la presenza dell’array di appoggio nel merge, di grandezza
direttamente proporzionale a quella dell’input).
Nel calcolo della complessità del merge-sort non si effettua distinzione tra caso peggiore,
medio e migliore in quanto indipendente da come si presenta l’array dato in input vengono
eseguite comunque le stesse istruzioni, le quali a loro volta non presentano differenza in termini
di theta tra caso migliore, medio e peggiore. Si distingue invece tra caso base e passo ricorsivo.
Nel caso base, infatti, l’algoritmo non esegue alcuna istruzione, ad eccezione del primo if per
accertarsi che la lunghezza sia 1. Il tempo di esecuzione T( n ) sarà quindi:
= 1 = θ
Nel passo ricorsivo vengono eseguite la prima istruzione di controllo e l’inizializzazione di m ;
viene poi eseguita una chiamata ricorsiva alla funzione di merge-sort sulla porzione di array
che va da 1 a M
&
N (tetto di
&
, ossia il più piccolo intero maggiore di
&
). In seguito ad essa viene
eseguita un’ulteriore chiamata sulla parte che rimane della sequenza, che ha dimensione O
&
(base di
&
, ossia il più grande intero minore di
&
). C’è poi la procedura merge, che ha
complessità θ
. Il tempo di calcolo totale T( n ) è quindi espresso in maniera ricorsiva:
θ
PS + θ
Questa è un’equazione di ricorrenza, ossia un’espressione che contiene se stessa ma con un
argomento più piccolo di quello iniziale. Volendo risolvere questa ricorrenza si può ricorrere
all’”albero delle chiamate”:
Valutando il passo ricorsivo, il tempo di calcolo si può quindi riscrivere come:
Da qui, T(
$
) vale:
$
'
) corrisponde a:
Dato che n è una potenza di 2, la diramazione proseguirà fino ad ottenere T(
$
$
) = T(1), ma T(1)
è un valore noto, pari a 1. Arrivati a questo punto i T(1) da sommare saranno n.
Passo 2:
Passo 3:
Lo pseudocodice è il seguente:
Per quanto riguarda la procedura partition, ne esistono due: quella di Hoare e quella di Lomuto.
Quella di Hoare funziona come di seguito:
pivot;
A livello di pseudocodice l’implementazione è la seguente:
dove il valore dx che viene restituito corrisponde a q nella procedura del quick-sort.
Il tempo di esecuzione del quick-sort su un vettore V di n elementi è determinato dalla seguente
equazione di ricorrenza:
𝑇(𝑛) = 𝑇(𝑞) + 𝑇(𝑛 − 𝑞) + θ(𝑛)
in quanto la procedura partition ha complessità θ(𝑛), alla quale vanno aggiunti il tempo di
esecuzione del quick-sort sul primo sottoarray di dimensione q ed il tempo di esecuzione del
secondo, di dimensione n – q.
Nel caso base, ossia quando l’array ha lunghezza 1, l’unica istruzione da eseguire è il primo
controllo, quindi il tempo di esecuzione è:
T(𝑛) = 1 = θ( 1 )
Nel caso il vettore abbia lunghezza > 1, considero ora il caso peggiore, ossia degli input V tali
per cui il partizionamento, per ogni chiamata ricorsiva, sia sempre il più sbilanciato possibile,
avendo quindi un solo elemento da una parte e tutti i restanti dall’altra. Il valore q varrebbe
quindi 1 e l’equazione di ricorrenza assumerebbe la forma:
Per trovare T( n - 1) si può sostituire tale espressione a T( n ), ottenendo quindi:
𝑇(𝑛 − 1 ) = 𝑇( 1 ) + 𝑇(𝑛 − 2 ) + θ(𝑛 − 1 )
quindi, unendola alla precedente, rimane:
𝑇(𝑛) = 𝑇( 1 ) + 𝑇( 1 ) + 𝑇(𝑛 − 2 ) + θ(𝑛 − 1 ) + θ(𝑛)
ma tale sostituzione si può applicare anche a T( n – 2), che diventa:
e così via, per n – 1 volte, fino ad arrivare a T( n – (n – 1 )), quindi a θ( 1 ).
Si arriva quindi ad un’equazione della forma:
𝑇(𝑛) = 𝑛 + θ ]^ 𝑖
$
0 2 #
` = 𝑛 + θ a
− 1 b = 𝑛 + θ(𝑛
) = θ(𝑛
Consideriamo ora il caso migliore, quindi la condizione in cui, per ogni chiamata ricorsiva,
l’array sia perfettamente bilanciato, quindi il partizionamento è tale che 𝑞 =
. L’equazione
di ricorrenza diventa dunque:
S + θ(𝑛)
Questa equazione è analoga a quella del merge-sort, e si è dimostrato nel paragrafo dedicato
che ha complessità θ(𝑛 ⋅ 𝑙𝑜𝑔(𝑛)).
Nonostante la complessità sia analoga o maggiore di quella del merge-sort, il quick-sort ha
costanti (c 1
e c 2
) più piccole nel calcolo di θ rispetto a quelle del merge-sort, risultando quindi
più efficiente, a meno del caso peggiore. Inoltre, a differenza di quest’ultimo, il quick-sort è in
loco, in quanto la procedura partition necessita di un numero di variabili aggiuntive costante
ed indipendente dalla dimensione dell’input.
vettore C ausiliario, in modo da sapere il numero di elementi che precedono ogni valore
del range nell’ordine (quindi il numero di elementi minori o uguali all’elemento stesso);
inserendo ogni elemento nella posizione corretta in un altro array B di dimensione
analoga ad A in base alle informazioni ottenute nello step precedente, inserite in C.
Graficamente, il funzionamento del counting sort è:
Il radix sort è un algoritmo di ordinamento non basato sul confronto, ma:
contenitori (bucket), in base al valore della cifra che si sta analizzando (bucket 0…9).
Procederà poi, nelle successive iterazioni, ad analizzare la seconda cifra meno
significativa e così via;
elemento di ogni bucket nell’ordine in cui è stato inserito al passo precedente;
Graficamente, il funzionamento del radix sort è:
Una pila (stack) è un insieme dinamico, quindi variabile nel tempo, definito su un dominio D.
Può essere identificata come una sequenza S dove:
oppure
"
$
0
∈ 𝐷, dove a n
è l’elemento in cima alla
pila S.
Questi insiemi dinamici vengono manipolati ed utilizzati dagli algoritmi. La manipolazione
avviene tramite operazioni dette di modifica (come inserimento e cancellazione), ma ne
esistono altre che non vanno a modificare l’insieme dinamico, nonostante siano anch’esse a
disposizione degli algoritmi; queste operazioni sono dette interrogazioni o query (come i vari
controlli). Le operazioni di inserimento e cancellazione effettuate su una pila S qualunque sono
tali che l’ultimo elemento inserito in S è il primo ad essere cancellato, secondo la politica LIFO.
Sia 𝒮 l’insieme di tutte le possibili pile su un dominio D. Le operazioni su 𝒮 di cui un algoritmo
può disporre sono le seguenti:
o Se S = <> 𝑃𝑈𝑆𝐻
o Se S = < a
"
$
= < a
"
$."
PUSH(𝑆, 𝑥) richiede in input una coppia: una pila ed un elemento del dominio.
∀ 𝑆 ∈ 𝒮 con 𝑆 ≠ <>
o 𝑃𝑂𝑃(𝑆) = G< 𝑎
"
($,")
$
Quindi POP(𝑆) è la pila ottenuta da 𝑆 rimuovendo a n
Essendo l’output di POP(𝑆) un elemento di 𝒮 × 𝐷, dovrà essere una coppia.
o 𝑆𝑇𝐴𝐶𝐾 − 𝐸𝑀𝑃𝑇𝑌(𝑆) = (
∀ 𝑆 ∈ 𝒮 con 𝑆 ≠ <>
o 𝑇𝑂𝑃(𝑆) = 𝑎
$
o 𝐸𝑀𝑃𝑇𝑌 − 𝑄𝑈𝐸𝑈𝐸
Graficamente, le operazioni ENQUEUE() e DEQUEUE() funzionano come di seguito:
Sia data una pila S = < a
"
$
>, con al più m elementi ( n ≤ m ).
Sia data una coda Q = < a
"
$
>, con al più m - 1 elementi ( n ≤ m - 1 ).
allora la coda è piena.
Quindi, dato un array Q[1…12] come quello
in figura, riempito dalla posizione 7 alla 11,
Q.head vale 7, mentre Q.tail, l’indice del
prossimo elemento che verrà aggiunto, vale
se Q.tail raggiunge l’ultima casella dell’array,
torna in posizione 1 , continuando l’incremento
da lì, in maniera circolare. La coda è piena nel
momento in cui Q.tail+1 coincide con Q.head.
Nel terzo passaggio è stata applicata la
procedura DEQUEUE(Q), portando Q.head
ad incrementarsi e l’elemento in posizione 7
(ossia 15) ad essere rimosso dalla coda Q.
Si stabilisce un limite inferiore Ω
per gli algoritmi di ordinamento basati su
confronti del tipo:
a i
≤ a j
a i
< a j
a i
≥ a j
a i
> a j
Poiché inoltre si suppone che tutti gli elementi in input siano distinti, saranno sufficienti test
soltanto del tipo a i
≤ a j
(o, analogamente, a i
< a j
Un albero di decisione è un albero binario pieno che rappresenta i confronti fra elementi che
vengono effettuati da un determinato algoritmo di ordinamento che opera su un input di una
data dimensione.
i
≤ a j
Dimostrazione:
Dato n ∈ N
, l’albero di decisione T n
corrisponde ad un dato algoritmo 𝒜 di ordinamento ed
alla dimensione n si avranno n! foglie.
Poiché un albero binario di altezza K ha al massimo 2
K
foglie, posto h = height(T n
𝒜), si ha:
7
Infatti:
$
0 2 8
$
9
$
0 2 "
$
0 2 8
$
9
Corollario: heap-sort e merge-sort sono algoritmi di ordinamento asintoticamente uguali.