Teoria dei numeri e Crittografia: lezione del 24 marzo 2011
Algoritmi
Gli algoritmi che studieremo nel corso saranno per lo più algoritmi aritmetici relativi ai numeri
naturali: ognuno di essi è un procedimento composto da un valore in entrata (input) costituito da
uno (o più) numeri naturali, da un numero finito di passaggi (steps), e da un valore in uscita
(output) costituito da uno (o più) numeri (non necessariamente naturali) oppure da una
affermazione relativa all’input (del tipo “si”o “no”, “vero” o “falso” etc…)
Esempi di algoritmi aritmetici:
a) algoritmo della divisione: input a,b (numeri naturali); steps: si calcolano 2 interi non negativi q,r
tali che a=bq+r, con r<b; si esce con output q (quoziente), r (resto)
b) test di primalità “ingenuo”: input n (numero naturale >1); steps: per i=2,3,…,n-1 si verifica se i
è divisore di n; se per qualche i la verifica è positiva si esce con output “n non è primo”, altrimenti
si esce con output “n è primo”
Complessità di un algoritmo
La complessità di un algoritmo è una misura delle risorse necessarie per eseguirlo: al giorno d’oggi
ogni algoritmo viene eseguito da un computer.
Ci interesseremo solo di complessità “temporale” (quindi relativa al tempo di esecuzione
dell’algoritmo) e non per esempio di quella “spaziale” (relativa allo spazio occupato nella memoria
del computer dai dati, anche temporanei, utilizzati durante l’esecuzione dell’algoritmo).
I dati numerici coinvolti in un algoritmo saranno rappresentati in base 2 cioè in forma binaria.
Ricordiamo un ben noto risultato aritmetico (che dimostreremo formalmente in seguito): fissato un
numero naturale b>1 (base), ogni numero naturale a si può rappresentare (in modo unico) nella
forma
a=ak-1bk-1+ak-2bk-2+…+a1b1+a0b0 (detta rappresentazione di a in base b)
dove gli ai (cifre) sono numeri interi tali che 0aib-1, e dove ak-1≠0.
In tale caso si usa la simbologia m=(ak-1, ak-2,….,a1, a0)b .
In particolare (scegliendo la base b=2) ogni numero naturale a ha una rappresentazione binaria
della forma
a=at2t+at-12t-1+…+a121+a020
dove gli ai (cifre binarie o bits) assumono come valori possibili 0,1, e dove ak-1=1.
Data la rappresentazione del numero naturale a in base b>1
a=ak-1bk-1+ak-2bk-2+…+a1b1+a0b0
il numero naturale k (coincidente con il numero di cifre usate nella rappresentazione) è detto
lunghezza di a in base b ed è indicato con il simbolo Lb(a).
Nel caso di rappresentazione binaria useremo semplicemente il simbolo L(a) invece di L2(a), e
parleremo di lunghezza di a, sottintendendo la base 2.
La lunghezza k=Lb(a) di a in base b si può caratterizzare con la seguente proprietà:
bk-1 a < bk
(quindi k è il più grande dei numeri naturali che soddisfano la proprietà bk-1 a).
Infatti basta osservare che a=ak-1bk-1+ak-2bk-2+…+a1b1+a0b0  ak-1bk-1  bk-1 (perchè a0,a1,….,ak-2 0
ed ak-11) e che (essendo le cifre ai  (b-1)) si ha anche:
a(b-1)(bk-1+bk-2+…+b1+b)=(bk-1)<bk .
Da ciò segue anche che se k=Lb(a) si ha:
k-1  logb(a) < k
dunque k-1 è il più grande intero logb(a), ossia la parte intera di logb(a):
k-1 = logb(a)
Lb(a) = k = logb(a) +1
In particolare nel caso b=2 la lunghezza di a è L(a) = log2(a) +1 .
Se vogliamo dare una “stima” del tempo necessario affinché un algoritmo venga eseguito, e quindi
della sua “complessità” di calcolo, dobbiamo scegliere delle operazioni “elementari”, che fungano
da “unità di misura” e mediante le quali si possano costruire tutte le altre operazioni eseguite nei
diversi algoritmi; sceglieremo come operazione elementare l’operazione di somma sui singoli
bits con riporto (carry). Tale operazione, dati 2 bits da sommare x,y=0,1 e fissato un valore c=0,1
del carry, calcola il bit risultato x+y e il nuovo valore c1 del carry, secondo le regole esposte nella
seguente tabella:
x
0
0
1
1
0
0
1
1
y
0
0
0
0
1
1
1
1
c x+y c1
0 0 0
1 1 0
0 1 0
1 0 1
0 1 0
1 0 1
0 0 1
1 1 1
Nella prima e seconda colonna vi sono rispettivamente il valore del bit x e il
valore del bit y; nella terza colonna il valore c del carry prima della somma;
nella quarta il valore del bit somma x+y; nella quinta il nuovo valore
aggiornato c1 del carry dopo la somma. Per esempio se x=1, y=0 e se il carry
(prima della somma) è c=1, allora dalla quarta riga si ricava il bit somma
x+y=0 (quarta colonna), e il valore aggiornato del carry c1=1 (quinta colonna).
Formalmente l’operazione di somma sui singoli bits con riporto é una funzione:
BitSum: {0,1}x{0,1}x{0,1}  {0,1}x{0,1} definita da BitSum(x,y,c)=(x+y,c1)
e in genere essa è implementata nell’hardware nel processore centrale (CPU) del computer.
Se trascuriamo il tempo che il computer impiega per altri tipi di operazioni più veloci (accesso alla
memoria, operazioni di shift, confronto fra bits, scrittura di bits etc..) possiamo ragionevolmente
supporre che il tempo totale dell’algoritmo sia proporzionale al numero di operazioni elementari
eseguite (secondo una costante di proporzionalità che all’incirca coincide con il tempo impiegato
dal computer per eseguire una operazione elementare).
(Nota: E’ ovvio che la nostra scelta dell’operazione BitSum come operazione elementare è dovuta
al fatto che ci occuperemo di algoritmi di tipo “aritmetico”. Per altre categorie di algoritmi potrebbe
essere invece opportuno una scelta diversa: per esempio per gli algoritmi di ordinamento (sorting)
sarebbe opportuno scegliere come operazioni elementari quelle di confronto di bits e di accesso alla
memoria per la lettura e scrittura dei dati da ordinare).
E’ opportuno che la nostra stima sia funzione della “grandezza” dell’input, e dunque dobbiamo
scegliere un modo per misurare quest’ultima: poiché le operazioni elementari agiscono sui singoli
bits, è naturale ricorrere al già visto concetto di lunghezza di un numero naturale nella sua
rappresentazione binaria, cioè al numero di bits 0,1 utilizzati per rappresentarlo.
Dato un algoritmo A potremmo allora definire il valore TimeA(x) come il numero di operazioni
elementari eseguite dall’algoritmo A quando l’input ha lunghezza x: in questo modo
moltiplicando tale valore per il tempo impiegato dal computer per svolgere una singola operazione
elementare potremmo ottenere una stima abbastanza valida del tempo impiegato per eseguire
l’algoritmo, sempre quando l’input ha lunghezza x.
Tale definizione non sarebbe però univoca perché due diversi input di eguale lunghezza x
potrebbero dar luogo a un diverso numero di operazioni elementari nell’esecuzione dell’algoritmo.
Definiremo allora il valore TimeA(x) (o più brevemente TA(x)) come il numero di operazioni
elementari eseguite dall’algoritmo A quando l’input ha lunghezza x, nel caso peggiore (cioè
nel caso in cui il numero di operazioni elementari è il massimo, fra tutti i casi in cui l’input ha
lunghezza x).
Nota: Se l’input dell’algoritmo è costituito da diversi numeri naturali, consideremo come variabile
x la lunghezza maggiore fra quelle dei valori in input.
Possiamo dunque considerare TA(x) come una funzione nella variabile x (con x che assume valori
naturali): nei casi concreti non saremo però interessati a conoscere il valore “esatto” di TA(x) (che
spesso è difficile da calcolare) ma piuttosto a stimare la sua “velocità di crescita” cioè in che modo
cresce il numero di operazioni elementari (e quindi il tempo di esecuzione dell’algoritmo) al
crescere della lunghezza dell’input.
In questa stima ci può aiutare la “teoria della big-O”, cercando opportune funzioni g(x) tali che
O(TA(x))=O(g): diremo in tal caso che l’algoritmo A ha complessità computazionale O(g).
In casi particolarmente complessi ci accontenteremo di trovare una funzione g(x) tale che
O(TA(x))O(g): questa maggiorazione sarà spesso sufficiente per avere un’idea della velocità di
crescita di TA(x).
In particolare diremo che l’algoritmo A ha complessità computazionale polinomiale se la
funzione TA(x) è di ordine polinomiale, e che l’algoritmo A ha complessità computazionale
esponenziale se la funzione TA(x) è di ordine esponenziale.
Consideremo efficienti gli algoritmi di complessità polinomiale (o minore di essa) , e non efficienti
quelli di complessità superiore (cioè superpolinomiale).
Negli algoritmi efficienti , al crescere della lunghezza x dell’input, il tempo di esecuzione cresce
molto meno velocemente che negli altri algoritmi: tuttavia è utile osservare che, per un fissato
valore della lunghezza x dell’input, un algoritmo di complessità polinomiale può avere un tempo di
esecuzione più alto di un algoritmo di complessità esponenziale, e quindi essere meno efficiente
(per quel particolare valore x).
Esempio: Siano dati l’algoritmo A con TA(x)=x7 (di complessità polinomiale) e l’algoritmo B con
TA(x)=2x (di complessità esponenziale). Per un input di lunghezza x=32 (quindi un input con 32
cifre binarie) l’algoritmo A esegue (nel caso peggiore) 327=235 operazioni elementari, mentre
l’algoritmo B ne esegue 232 (quindi un numero inferiore): se ogni operazione elementare è eseguita
in 1 milionesimo di secondo, l’algoritmo A impiega (nel caso peggiore) circa 9 ore e mezza,
l’algoritmo B circa 1 ora e 12 minuti. Ma per un input di lunghezza doppia x=64, l’algoritmo A
esegue (nel caso peggiore) 647 operazioni (in circa 52 giorni), ma l’algoritmo B ne esegue 264 (in
circa 585.000 anni !!!).
E’ anche vero che un algoritmo di complessità polinomiale può essere egualmente “intrattabile” ed
avere tempi di esecuzione molto alti, anche per input di lunghezza non eccessiva: se per esempio
TA(x)= kxt (con k costante reale, t naturale) allora O(TA(x))=O(xt) quindi A ha complessità
polinomiale, ma se la costante k è per esempio k=1010000 oppure l’esponente t è t=10000, il numero
di operazioni elementari eseguite dall’algoritmo è “astronomico” (e quindi lo è il tempo di
esecuzione) anche per input di lunghezza x non molto grande.
Complessità di alcuni algoritmi aritmetici.
a) Costruiamo un algoritmo A=Somma(n,m) per calcolare la somma n+m di due numeri naturali n,m
dati in input: supponiamo che x=L(n),y=L(m) siano le lunghezze binarie degli addendi, e che si
abbia xy (in modo che x=max(x,y) ed x sarà dunque l’argomento della funzione TA(x)).
L’algoritmo di somma si può eseguire con gli stessi metodi che si usano per sommare numeri
naturali rappresentati in base 10, utilizzando però le operazioni elementari di somma sui singoli
bits:
- si incolonna la rappresentazione binaria di m sotto quella di n, aggiungendo (se la lunghezza x è
strettamente maggiore della lunghezza y) un numero (x-y) di bits=0 alla sinistra dei bits di m
- si inizializza il valore del riporto c=0
- procedendo da destra verso si sinistra si somma ogni bit di n con il bit dello stesso posto di m (con
l’operazione elementare BitSum di somma di bits), ottenendo ad ogni passo una delle cifre binarie
di n+m e un nuovo valore del riporto c
- se l’ultimo riporto è c=1 si aggiunge a sinistra un ulteriore bit=1 nel risultato n+m
- si esce con output n+m
Per esempio se n=(110011)2, m=(1101)2 (quindi x=L(n)=6, y=L(m)=4) allora:
(111110)
1 1 0 0 1 1+
001101
1000000
 (riporto ad ogni somma di bits)
 (bits dell’addendo n)
 (bits dell’addendo n)
 (bits della somma n+m)
dunque il risultato della somma è x+y=(1000000)2.
Schematizzando l’algoritmo:
1) input n=(ax-1ax-2…..a1a0)2 , m=(by-1by-2…..b1b0)2 con x=L(n)  y=L(m)
2) se x>y si pone by=by+1=…=bx-1=0
3) si inizializza c=0
4) per i=0,1…,x-1 si calcola BitSum(ai,bi,c)=(t,c1) e si pone zi=t (bit della somma n+m), c=c1
(valore aggiornato del carry)
5) se c=0 (ultimo carry) si esce con output n+m=(zn-1…..z1z0)2; se invece c=1 si esce con output
n+m=(1zn-1…..z1z0)2