Sunteți pe pagina 1din 5

n & (n - 1) = 0? ...

focus

Optimizarea programelor folosind OPERAII PE BII


Cosmin-Silvestru Negrueri
n cadrul acestui articol v vom prezenta cteva metode prin care programele se pot optimiza dac utilizm eficient operaiile pe bii sau folosim operaii pe bii n locuri n care, la prima vedere, nu s-ar prea c ar fi necesare. De cele mai multe ori, att n concursuri ct i n viaa de zi cu zi a programatorului, atunci cnd implementm o metod care este folosit de o aplicaie i vrem s facem aceast metod eficient ca timp de execuie, suntem nvai din coal (sau ar trebui s fim nvai, chiar dac unii dintre profesori consider c cel mai important algoritm nvat n liceu este backtraking-ul, soluia tuturor problemelor) s ne uitm la complexitatea algoritmului implementat i dac observm c n practic algoritmul este mai ncet dect ne dorim noi s fie s ncercm s gsim un algoritm cu un ordin de complexitate mai mic. Nu trebuie s uitm c ceea ce numim complexitatea unui algoritm este o aproximare a vitezei unui algoritm i nu o msur absolut. Pentru dimensiuni mici ale datelor, cteodat nu se observ diferena dintre O(n) i O(n log n) sau O(n3/2). Mie personal mi s-a ntmplat ca la implementarea soluiei unei probleme care n englez se numete "Bottleneck Minimal Spanning Tree" s observ c rezolvarea n O(m log m) (folosind algoritmul de gsire a arborelui parial de cost minim al lui Kruskal) s fie mai rapid dect rezolvarea mai laborioas n O(m) a acestei probleme. Deci trebuie dat o atenie egal cu cea acordat complexitii algoritmului i dimensiunii factorilor constani care apar. n continuare vom ncerca s gsim soluii mai rapide dect cele naive pentru unele operaii de baz (toate operaiile vor fi implementate pentru ntregi pozitivi reprezentai pe 32 de bii). n continuare v prezint aceast rezolvare:
int count(long n){ int num = 0; for (int i = 0; i < 32; i++) if (n & (1 << i)) num++; return num; }

Dac ne uitm cu atenie i analizm rezultatul operaiei n & (n - 1) putem obine o soluie mai bun. S lum un exemplu:
n = (11011101010000)2 n-1 = (11011101001111)2 n & (n - 1) = (11011101000000)2

Se vede clar de aici c efectul operaiei n & (n - 1) este anularea celui mai nesemnificativ bit cu valoarea 1. De aici ideea algoritmului:
int count(long n){ int num = 0; if (n) while (n &= n - 1) num++; return num; }

GInfo nr. 14/5 - mai 2004

Problema 1
S se determine numrul de bii 1 din reprezentarea binar a numrului n. Rezolvarea naiv a acestei probleme ar consta n parcurgerea secvenial a biilor lui n.

32

Dar este acest algoritm mai rapid? Am testat pentru toate numerele de la 1 la 224 i rezultatele au fost 2,654 secunde folosind metoda naiv i 0.821 folosind a doua metod. Rezultatul mult mai bun al celei de-a doua metode se bazeaz n principal pe faptul c ea execut un numr de pai egali cu numrul de bii cu valoarea 1 din numr, deci n medie jumtate din numrul de pai efectuai de prima metod.

Problema 2
S se determine paritatea numrului de bii de 1 din reprezentarea binar a unui numr n. Din cele prezentate mai sus se pot determina dou metode evidente:
int parity(long n){ int num = 0; for (int i = 0; i < 32; i++) if (n & (1 << i)) num ^= 1; return num; } int parity(long n){ int num = 0; if (n) while (n &= n - 1) num ^= 1; return num; }

Dei constanta multiplicativ este mai mare, numrul de operaii are ordin logaritmic fa de numrul de bii ai unui cuvnt al calculatorului.

Problema 3
S se determine cel mai puin semnificativ bit de 1 din reprezentarea binar a lui n. Rezolvarea naiv se comport bine n medie, deoarece numrul de cicluri pn la gsirea unui bit cu valoarea 1 este de obicei mic, dar din cele discutate mai sus putem gsi ceva mai bun. Aa cum am artat n & (n - 1) are ca rezultat numrul n din care s-a sczut cel mai puin semnificativ bit. Folosind aceast idee obinem urmtoarea funcie:
int low1(long n){ return n ^ (n & (n - 1)); }

focus

Putem obine o a treia rezolvare fcnd cteva observaii pe un exemplu: Considerm n = (11011011)2. Rezultatul cutat este dat de valoarea 1 ^ 1 ^ 0 ^ 1 ^ 1 ^ 0 ^ 1 ^ 1. mprim pe n n partea lui superioar i partea lui inferioar:
1 1 0 1 ^ 1 0 1 1 = 0 1 1 0

Exemplu:
n = (11011000)2 n-1 = (11010111)2 n & (n - 1) = (11010000)2 n = (11011000)2 n ^ (n & (n - 1)) = (00001000)2

Aceast funcie este foarte important pentru structura de date Arbori Indexai Binar prezentat ntr-un un articol mai vechi din GInfo.

Problema 4
Aplicm asupra rezultatului acelai procedeu:
0 1 ^ 1 0 = 0 1

S se determine cel mai semnificativ bit cu valoarea 1 din reprezentarea binar a lui n. Putem aplica i aici ideile prezentate mai sus: cea naiv i cea cu eliminarea biilor, dar putem gsi i ceva mai bun. O abordare ar fi cea a cutrii binare (aplicabil i n problema anterioar). Verificm dac partea superioar a lui n este 0. Dac nu este 0, atunci cutm bitul cel mai semnificativ din ea, iar dac este, ne ocupm de partea inferioar, deci reducem la fiecare pas problema la jumtate.
int high1(long n) { long num = 0; if (!n) return -1; if (0xFFFF0000 & n) { n = (0xFFFF0000 & n)>>16; num += 16; } if (0xFF00 & n) { n = (0xFF00 & n) >> 8; num += 8; } if (0xF0 & n) { n = (0xF0 & n) >> 4; num += 4;

i
1 ^ 1 = 0

GInfo nr. 14/5 - mai 2004

S scriem algoritmul care reiese din acest exemplu, lund n considerare faptul c numrul n este reprezentat pe 32 de bii:
int parity(long n){ n = ((0xFFFF0000 & n) >> 16) ^ (n & 0xFFFF); n = ((0xFF00 & n) >> 8) ^ (n & 0xFF); n = ((0xF0 & n) >> 4) ^ (n & 0xF); n = ((12 & n) >> 2) ^ (n & 3); n = ((2 & n) >> 1) ^ (n & 1); return n; }

33

focus

} if (12 & n) { n = (12 & n) >> 2; num += 2; } if (2 & n) { n = (2 & n) >> 1; num += 1; } return 1 << num; }

tru unele supercalculatoare aceste instruciuni sunt instruciuni ale procesorului. Se pot gsi ali algoritmi mai rapizi dect cei de mai sus, dar de obicei gsirea unui algoritm mai inteligent este mai grea dect folosirea unei abordri banale care aduce un ctig foarte mare: preprocesarea. n continuare vom rezolva problemele prezentate anterior folosindu-ne de preprocesarea datelor. Problema 1 Determinm, pentru numerele de la 0 la 216 - 1, un tablou num, n care num[i] este egal cu numrul de bii de 1 din reprezentarea binar a lui i. De aici obinem soluia:
int count(long n){ return num[n & 0xFFFF] + num[n >> 16];

Problema 5
S se determine indexul celui mai semnificativ bit de 1 din reprezentarea binar a lui n. Metodele prezentate la rezolvarea problemei 4 pot fi folosite i aici, dar vom prezenta o nou rezolvare. S lum urmtorul ir de operaii pentru un exemplu:
n = (10000000)2 n = n | (n >> 1) n = (11000000)2 n = n | (n >> 2) n = (11110000)2 n = n | (n >> 4) n = (11111111)2

} Problema 2 Determinm, pentru numerele de la 0 la 216 - 1, un tablou par, n care par[i] este egal cu paritatea numrului de bii de 1 din reprezentarea binar a lui i. De aici obinem soluia:
int parity(long n){ return par[n & 0xFFFF] ^ par[n >> 16];

Se observ c aplicnd o secven asemntoare de instruciuni cu cea de mai sus putem face ca un numr n s se transforme n alt numr care are un numr de bii de 1 egal cu 1 + indexul celui mai semnificativ bit cu valoarea 1 din n. De aici algoritmul este urmtorul:
int indexHigh1(long n){ n = n | (n >> 1); n = n | (n >> 2); n = n | (n >> 4); n = n | (n >> 8); n = n | (n >> 16); return count(n) - 1;

} Problema 3 Determinm, pentru numerele de la 0 la 216 - 1, un tablou l, n care l[i] este egal cu cel mai nesemnificativ bit de 1 din reprezentarea binar a lui i. De aici obinem soluia:
int low(long n){ if (l[n & 0xFFFF]) return l[n & 0xFFFF]; else return l[n >> 16] << 16; }

} Pentru ca aceast metod s fie eficient ne trebuie o metod count eficient.

GInfo nr. 14/5 - mai 2004

Problema 6
S se determine dac n este o putere a lui 2 (ntrebare interviu Microsoft).
int isTwoPower(long n){ return (n & (n-1) == 0); }

Problema 4 Determinm, pentru numerele de la 0 la 216 - 1, un tablou h, n care h[i] este egal cu cel mai semnificativ bit de 1 din reprezentarea binar a lui i. De aici obinem soluia:
int high(long n){ if (h[n >> 16]) return h[n >> 16] << 16; else return h[n & 0xFFFF]; }

O alt abordare: preprocesarea


Operaiile prezentate mai sus sunt implementate n limbajele de asamblare, dar cteodat se folosesc algoritmi naivi i atunci o implementare inteligent ajunge s fie mai rapid dect instruciunea din limbajul de asamblare. Pen-

34

Problema 5 Determinm, pentru numerele de la 0 la 216 - 1, un tablou iH, n care iH[i] este egal cu indexul celui mai semnificativ bit de 1 din reprezentarea binar a lui i i este egal cu -1 pentru 0.

De aici obinem soluia:


int indexHigh(long n) { if (iH[n >> 16] != -1) return iH[n >> 16] + 16; else return iH[n & 0xFFFF]; }

Algoritmi mai serioi


Dei ceea ce am prezentat mai sus sunt mai degrab trucuri de implementare dect algoritmi serioi i n general nu se acord aa mare importan detaliilor de genul acesta, cteodat asemenea trucuri se pot dovedi importante mai ales n timpul concursurilor. Un exemplu ar fi problema rundei 12 de la concursul de programare Bursele Agora. Acolo se cerea gsirea numrului de dreptunghiuri coninute de o matrice binar a care au n coluri 1. Un algoritm de complexitate O(n2 m) ar fi fost urmtorul: se consider fiecare pereche format din dou linii i se numr cte perechi (ai,k, aj,k) pentru care ai,k = 1 i aj,k = 1 exist. Fie acest numr x. Atunci numrul de dreptunghiuri care au colurile pe aceste dou linii va fi x (x - 1) / 2. Un asemenea algoritm ar fi luat numai 64 de puncte deoarece restriciile date erau mari (n 200, m 2500 ). Putem rescrie condiia a[i][k] == 1 && a[j][k] == 1 ca i a[i][k] ^ a[j][k] == 1). Deci putem modifica rezolvarea anterioar n modul urmtor:
pentru fiecare i pentru fiecare j > i b = a[i] ^ a[j]; sfrit pentru sfrit pentru numaraBitiUnu(b)

face asupra a 16 valori deodat. Deci i aici se ctig un factor de vitez egal cu 8 (datorit detaliilor de implementare). Un al treilea exemplu ar fi problema Viteza de la a 9-a rund a concursului organizat de .campion anul acesta. Acolo se cerea determinarea celui mai mic divizor comun a dou numere binare de cel mult 10000 de cifre. Dup cum se tie, complexitatea algoritmului de determinare a celui mai mare divizor comun a dou numere este logaritmic, acest fapt este adevrat atunci cnd operaiile efectuate sunt constante, dar n rezolvarea noastr apar numere mari, deci o estimare a numrului de calcule ar fi maxn maxn log maxn (mprirea are n medie costul maxn maxn dac nu implementm metode mai laborioase). Acest numr este prea mare pentru timpul de rezolvare care ne este cerut. Pentru a evita mprirea, care este operaia cea mai costisitoare, putem folosi algoritmul de determinare a celui mai mare divizor comun binar care se folosete de urmtoarele proprieti: cmmdc(a, b) = 2 * cmmdc(a / 2, b / 2), dac a i b sunt numere pare; cmmdc(a, b) = cmmdc(a, b / 2), dac a este impar; cmmdc(a, b) = cmmdc(a - b, b), dac a este impar, b este impar i a > b. Aplicnd acest algoritm numrul de calcule s-a redus la maxn log maxn. Dar nici acest numr nu este suficient de mic, i deci pentru accelerarea algoritmului folosim ideea utilizat n cele dou probleme de mai sus.mpachetm numrul n ntregi n pachete de cte 30 de bii, i atunci deplasarea la stnga (adic mprirea la 2) i scderea vor fi de 30 de ori mai rapide. S ncercm acum rezolvarea unei probleme n care eficiena este cea mai important, mai important dect lizibilitatea codului obinut. Problema are urmtorul enun: Se consider un numr n. S se determine numerele prime mai mici sau egale cu n, unde n 10.000.000. Practic aceast problem ne va fi util n testarea rapid a primalitii numerelor mai mici sau egale cu n .

focus

Dac n loc s pstrm n a[i][j] un element al matricei binare, pstram 16 elemente, atunci n linia b = a[i] ^ a[j] se efectueaz cel mult 2500 / 16 calcule n loc de 2500 de calcule. Acum ce a rmas de optimizat este metoda numaraBitiUnu. Dac folosim preprocesarea, atunci aceast metod va fi compus i ea 2500/16 calcule. Se observ c n acest fel am mrit viteza de execuie a algoritmului cu un factor de 16. Alt exemplu ar fi problema rundei 17. n acea problem se cere determinarea numrului de sume distincte care se pot obine folosind nite obiecte cu valori date folosind fiecare obiect pentru o sum cel mult o dat. Din nou o rezolvare direct, folosind marcarea pe un ir de valori logice n-ar fi obinut punctaj maxim. Pentru obinerea punctajului maxim urmtoarea idee ar fi putut fi folosit cu succes: irul de valori logice se condenseaz ntr-un ir de ntregi reprezentai pe 16 bii, i acum actualizarea se

GInfo nr. 14/5 - mai 2004

Putem ncerca mai multe rezolvri, dar n practic cea mai rapid se dovedete de obicei cea numit ciurul lui Eratostene. Ea se nva n coli nc din clasa a 6-a, deci nu o voi mai explica. Implementarea banal a algoritmului ar fi urmtoarea:
#define maxsize 10000000 char at[maxsize]; //vector de valori logice n care nume//rele prime vor fi marcate cu 0 int n; int isPrime(int x){ return (!at[x]) ; }

35

focus

void sieve(){ int i, j; memset(at,0,sizeof(at)) ; for (i = 2; i <= maxsize; i++) if (!at[i]) for (j = i + i; j <= maxsize; j += i) at[j]=1; //marcm toi multipli lui i ca fiind //numere prime }

Ultima optimizare este optimizarea spaiului necesar. ntr-un tip de date char putem mpacheta opt valori logice i, punnd un test suplimentar n metoda isPrime putem elimina i numerele pare, astfel vom avea nevoie de un spaiu de 16 ori mai mic.
#define maxsize 10000000 char at[maxsize/16]; int n; int isPrime(int x) { if (!(x & 1)) if (x==2) return 0 ; else return 1 ; else return (at[((x - 3) >> 1) >> 8] & (1 << (((x - 3) >> 1) & 7))) ; } void sieve() { int i, j; memset(at, 0, sizeof(at)) ; for (i = 3; i <= maxsize; i += 2) if (!at[((i - 3) >> 1) >> 8] & (1 << (((i - 3) >> 1) & 7))) for (j=i * i ; j <= maxsize ; j += i + i) at[((i - 3) >> 1) >> 8] |= (1 << (((i - 3) >> 1) & 7))) ; }

Observm c jumtate din timpul folosit de noi la preprocesare este pierdut cu numerele pare. Marcndu-le de la nceput vom putea ignora numerele pare n preprocesare:
#define maxsize 10000000 char at[maxsize]; int n; int isPrime(int x){ return (!at[x]) ; } void sieve(){ int i, j; memset(at,0,sizeof(at)); for (i = 4; i <= maxsize; i += 2) at[i] = 1; for (i = 3; i <= maxsize; i += 2) if (!at[i]) for (j = i + i + i; j <= maxsize; j += i + i) at[j] = 1; }

Concluzie
Asemenea optimizri ca i cele prezentate n cadrul acestui articol se pot dovedi foarte utile n unele cazuri, dar ele fac programul ilizibil. Tocmai din acest motiv, asemenea trucuri trebuie aplicate numai n locurile critice ale codurilor surs pentru a duce la o cretere semnificativ de vitez a aplicaiilor i trebuie documentate foarte bine, mai ales dac se dorete sau este necesar ca alt programator s poat lucra cu codul respectiv mai trziu.

La marcarea multiplilor numrului prim i toate numerele pn la i i au fost deja marcate deoarece i i este cel mai mic numr care nu este divizibil cu numere naturale mai mici sau egale cu i - 1. Deci, avem o a treia versiune a programului:
#define maxsize 10000000 char at[maxsize]; int n; int isPrime(int x){ return (!at[x]) ; } void sieve(){ int i, j; memset(at,0,sizeof(at)); for (i = 4; i <= maxsize; i += 2) at[i] = 1; for (i = 3; i <= maxsize; i += 2) if (!at[i]) for (j = i * i; j <= maxsize; j += i + i) at[j] = 1; }

GInfo nr. 14/5 - mai 2004

Bibliografie
1. James A. Storer, An Introduction to Data Structures and Algorithms, Springer Verlag, 2001 2. T. H. Cormen, C. E. Leiserson, R. R. Rivest, Introducere n algoritmi, Editura Computer Libris Agora, 2000 3. ***, colecia GInfo 4. http://www.liis.ro/~campion 5. http://www.aggregate.org/MAGIC/ 6. http://www.topcoder.com RoundTables

Cosmin-Silvestru Negrueri este student n anul III la Universitatea Babe-Bolyai din Cluj-Napoca. El poate fi contactat prin e-mail la adresa kosmin_2000@yahoo.com.

36

S-ar putea să vă placă și