Introduzione agli algoritmi greedy e l’algoritmo del cassiere

A cura di Giulio Grossi, basato sulle lezioni di Cecilia Verri

Gli algoritmi greedy, che possiamo tradurre con avidi o golosi, sono una classe di algoritmi impiegata spesso nella risoluzione di una vasta varietà di problemi. La loro implementazione segue uno schema di risoluzione che cerca di arrivare ad una soluzione tramite una serie di piccoli passi successivi.

La caratteristica principale di questa classe di algoritmi è quella di massimizzare in maniera miope qualche criterio locale, soddisfacendo in questa maniera le condizioni di ottimo locali, senza preoccuparsi delle conseguenze sui passi successivi.

Inoltre, gli algoritmi greedy non prevedono la modifica di decisioni prese in passato, rendendo di fatto immodificabile il set delle soluzioni che si forma dal soddisfacimento dei criteri locali nei passi del programma.

L’idea dietro gli algoritmi greedy è che una serie di soluzioni localmente ottime possa portare ad una soluzione globale ottima, vedremo che questo è vero per alcuni casi, ma non necessariamente per tutti.

Spesso infatti può succedere, che la scelta di ottimizzazione in ogni passo porti verso soluzioni che non sono ottimali, o addirittura verso situazioni in cui non abbiamo soluzioni possibili, senza la possibilità di poter tornare indietro, da qui l’idea di un algoritmo “ingordo”. Sarà nostro interesse analizzare non solo quando un algoritmo greedy ha soluzione, ma anche i casi in cui l’algoritmo seleziona una soluzione ottima.

Ci sono molti esempi di algoritmi greedy, fra questi abbiamo quelli che tentano di risolvere is seguenti problemi:

Algoritmo del cassiere

L’algoritmo del cassiere può essere un buon esempio di algoritmo greedy: Il problema che ci viene posto è quello di un cassiere zelante che dovendo dare un resto ai clienti, vuole creare una regola di decisione per dare un resto esatto ai clienti, utilizzando il minimo numero di monete possibili.

Gli argomenti in input di questo algoritmo sono chiaramente:

  • \(R\): il resto da fornire al cliente
  • \(C\): L’insieme di monete a disposizione per dare il resto

L’algoritmo del cassiere, in versione Greedy segue un ragionamento di questo tipo:

  • Seleziona la moneta di maggior valore in \(C\), che chiameremo \(X\)
  • Confronta la moneta \(X\) con il resto da fornire \(R\)
  • Se \(X<R\), aggiunge la moneta alla soluzione e la toglie dall’insieme \(C\)
  • Se \(X>R\), non aggiunge la moneta alla soluzione

Questo ragionamento si ripete fino all’individuazione della soluzione, o alla dimostrazione che essa non è raggiungibile.

Di seguito è riportata una rappresentazione dell’algoritmo in Python.

def cassiere_alg (r, c):
print ('Devo fornire', r, 'euro di resto' )
print ('Ho a disposizione monete di taglio:', str(c))
c.sort()
s=[]
while r>=0:
        x=c[-1]
        del c[-1]
        if r>=x:
                r=r-x
                s.append(x)
        if r==0:
                print ("La soluzione e':" + str(s))
                return s
        if c==[]:
                print ("Non e' possibile trovare una soluzione")
                break

cassiere_alg(73, [100, 50, 20, 10, 5, 2, 1])

Vediamo adesso con un esempio il funzionamento in ogni loop dell’algortimo del cassiere, ipotizzando di dover fornire un resto di 73c, avendo a disposizione i tagli di monete degli euro.

  • \(R= 73c\)
  • \(C= (100c, 50c, 20c, 10c, 5c, 2c, 1c)\)

In questa situazione l’algoritmo, pesca ad ogni loop la moneta di maggior valore che ha a disposizione, controlla che non sia maggiore del resto da erogare e la aggiunge alla soluzione quindi

Loop 1:

  • \(X=max(C)=100c\)
  • \(100c \geq 73c\)
  • \(S= (\emptyset)\)
  • \(C=(50c, 20c, 10c, 5c, 2c, 1c)\)
  • \(R= 73-0= 73c\)

Loop 2:

  • \(X=max(C)=50c\)
  • \(50c \leq 73c\)
  • \(S= (50c)\)
  • \(C=(20c, 10c, 5c, 2c, 1c)\)
  • \(R= 73-50= 23c\)

Loop 3:

  • \(X=max(C)=20c\)
  • \(20c \leq 23c\)
  • \(S= (50c, 20c)\)
  • \(C=(10c, 5c, 2c, 1c)\)
  • \(R= 23-20= 3c\)

Loop 4:

  • \(X=max(C)=10c\)
  • \(10c \geq 3c\)
  • \(S= (50c, 20c)\)
  • \(C=(5c, 2c, 1c)\)
  • \(R= 3-0 = 3c\)

Loop 5:

  • \(X=max(C)=5c\)
  • \(5c \geq 3c\)
  • \(S= (50c, 20c)\)
  • \(C=(2c, 1c)\)
  • \(R= 3-0 = 3c\)

Loop 6:

  • \(X=max(C)=2c\)
  • \(2c \leq 3c\)
  • \(S= (50c, 20c, 2c)\)
  • \(C=(1c)\)
  • \(R= 3-2= 1c\)

Loop 7:

  • \(X=max(C)=1c\)
  • \(1c \leq 1c\)
  • \(S= (50c, 20c, 2c, 1c)\)
  • \(C=(\emptyset)\)
  • \(R= 1-1= 0\)

Visto che \(R=0\) siamo arrivati alla soluzione dell’algoritmo, che risulta essere \(S=(50c, 20c, 2c , 1c)\).

In generale, l’algoritmo del cassiere e gli algoritmi greedy devono rispettare alcune caratteristiche, che riportiamo di seguito:

  • Set di candidati: per trovare una soluzione al problema, è necessario che sia presente un insieme di candidati potenziali per la soluzione.
  • Analisi: ogni elemento deve essere esaminato secondo la regola di decisione che ci siamo dati, e una volta esaminato, viene rimosso dall’insieme dei candidati, questo ci ricorda la peculiarità degli algoritmi greedy di non poter tornare indietro.
  • Funzione di selezione: è la funzione che potremmo pensare come il cuore dell’algoritmo, infatti è il momento della scelta greedy. Fra i candidati prendiamo il più promettente seguendo il criterio di ottimo locale, non considerando cosa ciò possa comportare per la ricerca dell’ottimo totale. Nel nostro caso è la funzione che cerca il massimo nel set delle monete in \(C\).
  • Funzione di ammissibilità: Si controlla che il candidato esaminato possa essere soluzione, nell’algoritmo del cassiere questo passo è il controllo che la moneta candidata sia inferiore del resto ancora da erogare.
  • Funzione di Ottimo: è la funzione che controlla che il set di soluzione candidato sia anche l’ottimo specifico.
  • Funzione Obiettivo: E’ la funzione che restituisce il risultato dell’algoritmo

Arriviamo ad una conclusione dell’algoritmo in due casi:

  • Il resto \(R\) è uguale a zero, ed allora abbiamo trovato una soluzione, \(S\)
  • Il resto \(R\) è maggiore di zero, e sono stati esaminati tutti gli elementi in \(C\), questo vuol dire che nessun elemento di \(C\) è ammissibile secondo le regole di decisione e in base alle scelte fatte precedentemente, il problema quindi non ha soluzione.

Discussione delle soluzioni trovate

Vediamo le casistiche dei risultati dell’algoritmo del cassiere nello specifico e con degli esempi:

1- SOLUZIONE GREEDY OTTIMA:

  • \(R=94c\)
  • \(C=(50c, 20c, 20c, 20c, ,20c ,10c, 2c, 2c)\)
  • \(S(Greedy)= (50c, 20c, 20c, 2c, 2c)\)
  • \(S(Alternativa)= (20c, 20c, 20c, ,20c, 10c, 2c, 2c)\)

E’ il caso in cui l’algoritmo, oltre a trovare una soluzione, trova anche la migliore tra quelle possibili. Si capisce come la prima soluzione Greedy sia preferibile alla seconda, seppur anch’essa sia corretta, perchè impiega un numero minore di monete. In questo caso l’algoritmo greedy ci aiuta a trovare la soluzione ottima.

2- SOLUZIONE GREEDY NON OTTIMA

  • \(R=180c\)
  • \(C=(150c, 90c, 90c, 20c, 10c)\)
  • \(S(Greedy)= (150c, 20c, 10c)\)
  • \(S(Alternativa)= (90c, 90c)\)

In questo caso si vede come l’algoritmo greedy arrivi ad una soluzione, che però non è quella ottima. Quella ottima infatti impiega una moneta in meno. Bisogna dunque tenere bene a mente che la soluzione trovata non è necessariamente ottima.

3- SOLUZIONE NON TROVABILE

  • \(R=74c\)
  • \(C=(50c, 20c, 5c, 2c, 1c)\)
  • \(S(Greedy)= (\emptyset)\)
  • \(S(Alternativa)= (\emptyset)\)

In questo caso non è possibile trovare nessuna soluzione al problema, in quanto l’algoritmo nel suo procedere arriverà ad una situazione in cui il resto (4c) non sarà ottenibile con nessuna combinazione delle monete a disposizione. In queste situazione l’algoritmo del cassiere non ha nessuna soluzione. In generale questo è un problema senza soluzione con questo set di monete a disposizione.

4- SOLUZIONE NON TROVABILE CON ALGORITMO GREEDY

  • \(R=130c\)
  • \(C=(100c, 60c, 60c, 10c, 10c)\)
  • \(S(Greedy)= (\emptyset)\)
  • \(S(Alternativa)= (60c, 60c, 10c)\)

In quest’ultima situazione vediamo come sono proprio le regole di decisione degli algoritmi greedy a portarci su una strada sbagliata, non permettendoci di arrivare ad una soluzione.

In generale, la soluzione dell’algoritmo del cassiere dipende fortemente dal set di monete a disposizione \(C\), l’algoritmo quindi non è corretto in generale per tutti i possibili set \(C\), ma esistono set di monete che ci garantiscono la correttezza dell’algoritmo.

In particolare si può dimostrare che, avendo monete infinite, l’algoritmo restituisce sempre una soluzione ottima se \(C= (1,5,10,25,100)\), che curiosamente sono anche i tagli dei centesimi statunitensi, e la stessa cosa è dimostrabile con i tagli di monete dell’euro (\(C=(1,2,5,10,20,50,100)\)).

Questo non è vero per ogni set di valute, per esempio consideriamo i valori dei francobolli USA: \(C=(1, 10, 21, 34, 70, 100, 350, 1225, 1500)\). In questo caso vediamo che per ottenere 140c di francobollo abbiamo che:

  • \(S(Greedy)= (100, 34, 1,1,1,1,1,1,1)\)
  • \(S(Alternativa)= (70,70)\)

Che conferma il fatto che l’algortimo non è corretto per ogni set di valute, anche avendo a disposizione monete (o francobolli) infiniti.

Complessità

Nell’algoritmo del cassiere i momenti dispendiosi in termini di tempo computazionale sono fondamentalmente due

  • Sort :Il momento in cui si ordina il set di monete a disposizione
  • Scan :I momenti in cui si confrontano le monete nel set C per decidere quale è quella maggiore

Per calcolare la complessità totale di questo algoritmo è necessario capire la complessità delle due singole parti. Conosciamo la complessità di alcuni algoritmi ottimizzati per l’ordinamento, che è \(O(n\log{n})\). Nell’algoritmo, questa è la parte dominante, in quanto lo scan impiega \(O(n)\).

E’ possibile migliorare l’algoritmo presentato, specificando l’input in modo compatto, ovvero specificando per ogni taglio il numero di monete di quel taglio (mediante per esempio l’ausilio di un dizionario). Questo farebbe scendere il costo del sorting, lo scan non sarebbe sempre dominato dal sorting, ma la complessità dell’algoritmo sarebbe migliore in ogni caso.

Next Section - Il problema dello scheduling degli intervalli