Teoria dei numeri e Crittografia: lezione del 12 ottobre 2011
Nella lezione precedente abbiamo detto che sceglieremo come operazione elementare (che funga
da “unità di misura” della complessità di un algoritmo) 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 del tempo di esecuzione dell’algoritmo 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, definiremo taglia dell’input la lunghezza dell’input (se esso è costituito da un
solo numero naturale) oppure la lunghezza massima dei numeri naturali che costituiscono l’input
(se esso appunto è costituito da più numeri naturali).
Dato un algoritmo A potremmo allora definire una funzione TimeA(x) come il numero di operazioni
elementari eseguite dall’algoritmo A quando l’input ha taglia 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 taglia 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 invece in modo più preciso la funzione TimeA(x) (o più brevemente TA(x)) come
il numero di operazioni elementari eseguite dall’algoritmo A quando l’input ha taglia 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 taglia x).
Possiamo dunque considerare TA(x) come una funzione nella variabile x (con x che assume valori
naturali) a valori naturali (perché il numero di operazioni elementari è sempre un numero naturale):
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
taglia dell’input.
In questa stima ci aiuterà ovviamente la “teoria della big-O”: cercheremo opportune funzioni g(x)
tali che TA(x) ha ordine O(g): diremo in tal caso che l’algoritmo A ha complessità computazionale
O(g) (o semplicemente complessità O(g)).
In particolare diremo che l’algoritmo A ha complessità polinomiale se la funzione TA(x) è di ordine
polinomiale, e che l’algoritmo A ha complessità esponenziale se la funzione TA(x) è di ordine
esponenziale.
Consideremo efficienti gli algoritmi di complessità polinomiale, e non efficienti quelli di
complessità superiore (cioè superpolinomiale), per esempio esponenziale.
Negli algoritmi efficienti , al crescere della taglia x dell’input, il tempo di esecuzione cresce molto
meno velocemente che negli altri algoritmi: tuttavia è utile osservare che, per un fissato valore della
taglia 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 poi vero che un algoritmo di complessità polinomiale può essere egualmente “intrattabile”
ed avere tempi di esecuzione molto alti, anche per input di taglia non eccessiva: se per esempio
TA(x)= kxt (con k costante reale >0, t numero naturale) allora TA(x) ha ordine polinomiale 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 taglia 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) sia la taglia dell’input, cioè 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.
Gli steps sono dunque i seguenti:
- 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
Alla fine si esce con output n+m.
Per esempio se n=55=(110111)2, m=12=(1100)2 (quindi x=L(n)=6, y=L(m)=4) allora:
(111000)
1 1 0 1 1 1+
001100
1000011
 (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=67 .
Schematizzando gli steps dell’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
Come si vede, oltre ad operazioni trascurabili (come la scrittura di bits), in tale algoritmo si
eseguono (nel ciclo dell’istruzione 4)) x operazioni elementari, dunque TA(x)=x e l’algoritmo ha
complessità polinomiale (lineare).
b) Costruiamo un algoritmo A=Diff(n,m) per calcolare la differenza n-m di due numeri naturali n,m
(con n>m in modo che la differenza n-m sia un numero naturale) dati in input: se x=L(n),y=L(m)
sono rispettivamente le lunghezze binarie, si avrà certamente xy (in modo che x=max(x,y) ed x
sarà dunque l’argomento della funzione TA(x)).
Si può ricondurre il caso a quello dell’algoritmo Somma(n,m) con il seguente ragionamento:
consideriamo il numero binario m1 ottenuto da m sostituendo ogni bit 0 con 1 e ogni bit 1 con 0 e
poi aggiungendo a sinistra un numero (x-y) di bits =1 (osserviamo che m1 è allora un numero di
lunghezza x tale che la somma m+m1 nella sua rappresentazione binaria abbia tutti i suoi x bits =1
cioè m+m1=2x-1).
Si ha dunque m+(m1+1)=2x (si dice anche che m1+1 è il “complemento a 2 del numero m”) da cui:
n-m=[n+(m1+1)]-2x.
Per ottenere la differenza n-m basta allora sommare n con il complemento a 2 di m e poi sottrarre 2x
al risultato. Esaminiamo la complessità di tale algoritmo:
- costruire m1 comporta solo la scrittura di alcuni bits =1 quindi è trascurabile
-
calcolare m1+1 (complemento a 2 del numero m) comporta la somma di due addendi dei
quali il maggiore (che è m1) ha lunghezza x, dunque comporta x operazioni elementari con
l’algoritmo Somma(m1,1)
- il numero (m1+1) ha lunghezza x (infatti per le regole sulla lunghezza della somma si ha
L(m1+1)=x oppure L(m1+1)=x+1, ma m1+1=2x-m<2x quindi la seconda possibilità si
esclude) dunque per calcolare la somma n+(m1+1) (addendi di lunghezza x) si eseguono x
operazioni elementari (con l’algoritmo Somma(n, m1+1))
- la somma n+(m1+1) ha lunghezza x+1 (essendo x la lunghezza degli addendi, la sua
lunghezza può essere x oppure x+1 ma n+(m1+1) = n-m+2x > 2x dunque la prima possibilità
è esclusa); pertanto sottrarre 2x da n+(m1+1) equivale ad elidere l’ultimo bit =1 a sinistra
nel numero n+(m1+1), quindi è trascurabile
Si conclude allora che il numero di operazioni elementari eseguite dall’algoritmo é TA(x)=x+x=2x,
ossia che O(TA(x))=O(x) e anche questo algoritmo ha complessità polinomiale (lineare).
c) Costruiamo un algoritmo A=Prod(n,m) per calcolare il prodotto nm 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 prodotto si può eseguire con gli stessi metodi “scolastici” che si usano per
moltiplicare numeri naturali rappresentati in base 10: si moltiplica il numero n per ogni cifra del
numero m (per le cifre =0 si salta questo passaggio) shiftando ad ogni passo a sinistra il risultato di
tale prodotto (per esempio formalmente aggiungendo alcune bits =0 a destra) e infine si sommano
tali prodotti per ottenere il risultato finale. Il vantaggio di utilizzare la rappresentazione binaria, e
quindi solo le cifre 0,1, consiste nel fatto che il prodotto di n per 1 (unica cifra binaria non nulla)
non comporta un vero calcolo ma solo una “copia” del numero n, ed è quindi trascurabile nel
calcolo della complessità.
Vediamo un esempio: siano dati n=(1011011)2 , m=(101011)2 (quindi x=L(n)=7,y=L(m)=6) e
calcoliamo il prodotto nm seguendo i vari passi dell’algoritmo:
1011011 x
101011
1011011
 (si moltiplica n per l’ultima cifra 1 di m, ricopiando n)
1011011 x
101011
1011011
 (si moltiplica n per la penultima cifra 1 di m, ricopiando n, ma shiftato
10110110
a sinistra di 1 posizione, con l’aggiunta di 1 bit =0 a destra )
1011011 x
101011
1011011
 (si dovrebbe moltiplicare n per la terzultima cifra 0 di m, ma essendo 0
10110110
il risultato, questo passo si salta, e si moltiplica n per la quartultima
1011011000
cifra 1 di m, ricopiando n, ma shiftato a sinistra di 3 posizioni, con
l’aggiunta di 3 bits =0 a destra )
1011011 x
101011
1011011
 (si dovrebbe moltiplicare n per la quintultima cifra 0 di m, ma questo
10110110
passo si salta, e si moltiplica n per la quartultima cifra 1 di m,
1011011000
ricopiando n, ma shiftato a sinistra di 5 posizioni, con l’aggiunta di
101101100000
5 bits =0 a destra )
Per ottenere il risultato finale si dovrebbero ora sommare i 4 numeri ottenuti nelle 4 righe. A tale
scopo possiamo usare l’algoritmo Somma(n,m), che permette di sommare 2 numeri alla volta:
sommiamo la prima riga con la seconda, poi sommiamo il risultato con la terza riga ed infine
sommiamo il risultato con la quarta riga.
L’esempio può essere utile per calcolare la complessità dell’algoritmo: gli unici passi non
trascurabili sono quelli in cui si sommano le righe (2 alla volta) utilizzando più volte l’algoritmo
Somma. Il caso peggiore si ha quando le righe sono il maggior numero possibile (al massimo il
numero delle righe è y) e precisamente quando nessuna cifra di m è =0 (di modo che nessun passo
viene saltato) e quando x=y, di modo che il numero di righe sia =x. In questo caso, se le righe sono
i numeri a1, a2, …., ax si dovranno sommare a 2 a 2 utilizzando più volte l’algoritmo Somma:
z1=Somma(a1, a2), z2=Somma(z1, a3), z3=Somma(z2, a4),………., zx-1=Somma(zx-2, ax)
e l’output dell’algoritmo sarà nm = zx-1.
Le lunghezze delle righe sono crescenti (perché si aggiungono bits =0 a destra):
L(a1)=x, L(a2)=x+1, L(a3)=x+2,…., L(ax)=x+x-1=2x-1
Ricordando la regola della lunghezza della somma, si avrà (nel caso peggiore):
L(z1)=x+1, L(z2)=x+2, L(z3)=x+3,…., L(zx-1)=x+x-1=2x-1
Dunque (ricordando quanto sappiamo sull’algoritmo Somma) il numero di operazioni elementari
eseguite dall’algoritmo (nel caso peggiore) sarà:
TA(x) = (x+1)+(x+2)+(x+3)+ …. + (2x-1)= x(x-1)+(1+2+3+….+(x-1))=
= x(x-1)+(x-1)x/2 = (3x2-3x)/2 (polinomio di 2° grado in x)
Si può concludere che O(TA(x))=O(x2), e che l’algoritmo ha complessità polinomiale (quadratica).
Osservazione. L’algoritmo Prod(n,m) illustrato sopra per calcolare il prodotto di due numeri
naturali non è il più efficiente: esiste un algoritmo più sofisticato (che non illustreremo: cfr.
Prop.2.5.13 del libro di testo) che ha complessità O(xt) dove
t =log2(3)= 1,58….<2
(dunque di complessità strettamente < O(x2)).
Comunque, per i nostri scopi, sarà sufficiente tener conto dell’algoritmo sopra illustrato, di
complessità quadratica.
Esistono anche algoritmi ancora più efficienti per calcolare il prodotto di due numeri naturali, come
mostra il seguente risultato (che non dimostreremo):
comunque dato un numero reale  >0 esiste un algoritmo per calcolare il prodotto di 2 numeri
naturali che ha complessità ≤ O(x1+).
Spesso in seguito, come in questo caso, ci limiteremo a costruire un algoritmo “efficiente” (quindi
di complessità polinomiale) che risolva un problema, senza indagare se sia in effetti il più efficiente
in termini di complessità.