Algoritmi
La scelta di una struttura di dati è guidata dall'uso
che si vuole fare dei dati (possiamo pensare ai Large Language
Models come un esempio estremo di dati organizzati secondo l'uso
atteso). Ci sono usi che rappresentano una frazione grande
delle applicazioni di tecnologie IT nel mondo - procedure che
meritano di venire standardizzate per le stesse ragioni che
giustificano la standardizzazione delle strutture di dati
(Efficienza, Astrazione, Possibilità di riutilizzo).
A strutture di dati standard possono venire astrattamente
associate alcune procedure standard, e non altre.
Ad una procedura che venga ben definita per operare su un problema "standard",
con una struttura di dati "standard", in modo ripetibile e riutilizzabile,
attribuiamo il nome generico di algoritmo.
Esempio canonico: Ricerca e ordinamento
Il sistema più elementare per ricercare un dato specifico in una
collezione di dati consiste nell'esaminare uno dopo l'altro tutti i dati
presenti nella collezione.
Nel peggiore dei casi, (che può capitare molto spesso, per
esempio se il dato richiesto non c'è...), dovremo esaminare
tutti i dati della nostra collezione: un'operazione il cui costo dipende
linearmente dal numero dei dati.
Se i dati fossero ordinati in base alla grandezza sulla quale
deve avvenire la ricerca e se fosse possibile accedere
direttamente ai dati secondo il loro numero d'ordine, potremmo tentare
un approccio più astuto come quello di una ricerca binaria.
Per effettuare una ricerca binaria su una collezione di dati
contigua e ordinata basta scegliere un punto in mezzo e verificare
se il valore che stiamo cercando è maggiore, minore (o uguale)
al valore scelto. A questo punto basta continuare, ripetendo l'operazione
sulla metà rimasta.
Dal momento che procediamo continuando a dividere il
campione a metà, all'aumentare del numero degli elementi del
campione il numero di operazioni necessario per completare la ricerca
cresce all'incirca secondo il logaritmo in base 2 del numero di elementi,
quindi molto meno che linearmente.
Il problema della ricerca e quello dell'ordinamento sono strettamente
imparentati. Anche l'ordinamento è una procedura
standard. Quali sistemi abbiamo dunque a disposizione per
mettere in ordine una collezione di elementi disordinati?
Ordinamento per inserzione
Un possibile metodo (detto del "giocatore di carte", o anche
insertion
sort) consiste nel prelevare uno ad uno gli elementi da inserire, e
creare una nuova lista ordinata inserendo ogni elemento nella posizione
giusta.
Tale sistema non è particolarmente efficiente, come constateremo
fra poco, ma presenta alcuni innegabili vantaggi:
in primo luogo permette
di effettuare l'ordinamento nella stessa area di memoria occupata
dai dati, senza richiedere la copia in un'area supplementare
(
in-place sort). In secondo luogo è
stabile, nel senso che non viene cambiata la posizione relativa
degli elementi con chiave di ricerca uguale. Infine è adatto all'uso
con contenitori cone le
linked list (che non possono essere
indirizzati in modo assoluto). In moltissime situazioni quindi questo
sistema può risultare più che sufficiente.
Si può fare di meglio? Si, molto probabilmente (ma non
ingenuamente) affidandosi agli algoritmi di ricerca e ordinamento
inclusi nelle librerie standard del nostro linguaggio di
programmazione preferito. Per avere un'idea di come funziona
un algoritmo di ordinamento generico ne descriviamo uno.
Quicksort
Supponiamo di avere una pila di libri da mettere in ordine alfabetico
per autore. Possiamo iniziare scegliendo uno dei libri (ad esempio
un autore che inizia con la "L" o con la "M") e dividendo i libri in due
pile: una con tutti i libri che precedono in ordine alfabetico l'autore scelto,
ed una con tutti i libri che seguono (il libro scelto come "elemento separatore"
può appartenere
ad una qualsiasi delle due pile). A questo punto possiamo ripetere
la procedura su ciascuna delle due pile ottenute (ricorsivamente).
Continuando
a dividere rimarremo con gruppetti di due o tre libri, ed infine con libri
singoli, che a questo punto si troveranno in ordine alfabetico.
Ecco un altro esempio con i numeri, che ci aiuta a capire come questa
strategia possa essere convertita in un algoritmo:
Osservando bene si nota che il caso nella figura è assolutamente ottimale!
Ogni volta siamo riusciti a scegliere un elemento "separatore" che divide la
collezione esattamente in due. Il problema principale del
quicksort riguarda proprio la strategia utilizzata per scegliere
l'elemento separatore. Può sembrare sorprendente che
uno dei sistemi migliori per effettuare tale scelta sia quello
di affidarsi al caso. Questa strategia è in pratica
l'unica utilizzata, eventualmente con una sola miglioria:
scegliere l'elemento intermedio fra tre scelti a caso.
Misura e confronto dell'efficienza degli algoritmi
Esistono vari criteri, più o meno ben definiti, per analizzare l'efficienza di un algoritmo:
- Analisi del caso peggiore. Se è chiaro che le prestazioni
di un algoritmo, in dipendenza dalla quantità e qualità delle operazioni
da svolgere, possono avere elementi di forte discontinuità (legati ad
esempio alle varie strutture condizionali presenti nel codice), non è
atrettanto chiaro perché proprio il comportamento nel caso peggiore
dovrebbe riflettere fedelmente l'efficienza "media" di un algoritmo.
Si tratta effettivamente di un criterio piuttosto empirico,
ma la sua validità è legata all'esistenza
di una vasta classe di applicazioni (come gli algoritmi di ricerca) in cui il
caso "peggiore" (= non trovare il dato cercato) si manifesta abbastanza
spesso da dominare effettivamente le prestazioni.
Inoltre è molto difficile definire l'efficienza "media" di
un algoritmo, nel senso che le caratteristiche "medie" dei dati che
vengono sottoposti all'algoritmo stesso possono variare drammaticamente
con l'applicazione. L'efficienza "nel caso migliore" è invece
decisamente poco utile per confrontare vari tipi di algoritmo, nel
senso che spesso la risposta di vari algoritmi nel caso migliore
è identica. Nel caso degli algoritmi di ricerca, ad esempio,
nel caso "migliore" l'elemento cercato viene trovato subito, e dunque
il tempo e le risorse utilizzate in tutti i casi sono praticamente nulli.
- Valutazione dell'ordine di complessità computazionale dell'algoritmo. Questo è un criterio decisamente più formale, teso a valutare l'andamento
di crescita delle risorse impegnate da un algoritmo al crescere della
quantità di
dati da trattare. Si utilizza la nozione di limite superiore a meno
di una costante (O-grande) analogamente a quanto si fa per le
serie numeriche. Si riesce così a valutare se un certo algoritmo
cresce in complessità in modo lineare (O(n)), in modo logaritmico
(O(log2n)), o in modo quadratico (O(n2)), effettuando
poi un confronto ragionato. Proviamo a stimare l'ordine di complessità
di alcuni algoritmi visti finora:
- Inserzione di un elemento in una linked list: richiede un
tempo costante, dunque l'ordine di complessità è O(c) = O(1).
- Inserzione di un elemento in mezzo ad un array: richiede
di spostare un certo numero degli elementi dell'array per
ricavare lo spazio per il nuovo elemento. L'ordine di
complessità è dunque O(c n)=O(n): la quantità di risorse utilizzate
cresce linearmente con il numero degli elementi.
- Ricerca lineare su un campione di n elementi: richiede di
esaminare una frazione di tutti gli n elementi. Dunque l'ordine di
complessità è ancora una volta O(cn)=O(n).
- Ricerca binaria su un campione di n elementi ordinati:
nella procedura si continua a dividere in due il campione fino a trovare
l'elemento richiesto. Se l'elemento non viene trovato vengono
effettuate log2n divisioni. Di conseguenza l'ordine di
complessità della procedura è O(log2n), e
rappresenta il caso migliore che si possa attendere in pratica da un algoritmo.
- Ordinamento per inserzione di un campione di n elementi:
richiede di esaminare la lista degli elementi finora ordinati per tutti
gli elementi del campione salvo il primo. Sono quindi necessarie
(n-1) operazioni, ciascuna composta
da un ciclo che, nel caso peggiore, è rispettivamente di 1,2,3,4,5,....
iterazioni. Il numero totale di operazioni si comporta dunque
come la somma dei numeri da 1 a (n-1), che è
n(n-1)/2, dunque O(n2).
- Quicksort di un campione di n elementi: nell'algoritmo di
quicksort vengono verificati (ed eventualmente scambiati) tutti
gli n elementi del campione, e questo viene ripetuto continuando a
dividere il campione in due, per un totale di log2n
volte. Pertanto l'ordine di complessità risultante è
O(n log2n).
- Misura del consumo di risorse. Il metodo sperimentale è l'unico che
permetta di tener conto dell'effettiva natura dei dati, come pure di tutte
le peculiarità e dei comportamenti discontinui dell'algoritmo utilizzato.
Se l'importanza della valutazione è solo relativa (quando ad esempio
si vuole fare
un confronto fra varie soluzioni), questo metodo può essere più
che sufficiente. Se invece la complicazione dell'algoritmo è eccessiva
(oppure l'algoritmo è sconosciuto), questa può essere l'unica soluzione
disponibile. Questa misura si può sempre realizzare con
l'aiuto del Sistema Operativo.