Sunteți pe pagina 1din 27

Optimizarea programelor folosind operaţii pe biţi

Obiective
În urma parcurgerii acestui laborator, studentul va fi capabil:

 să înțeleagă conceptele legate de operații pe biți


 să utilizeze operații pe biți pentru optimizare (când este cazul)
 să înteleagă mai multe despre organizarea datelor în memorie
 să gestioneze mai bine memoria folosită într-un anumit program C
 să implementeze o structură de date in C
 Laboratorul curent introduce mai multe noțiuni. Pe multe dintre acestea le veți mai întâlni și la alte cursuri
din următorii ani, precum: IOCLA, CN/CN2, AA/PA, PC, PM, SO.
 Secțiunile Studiu de caz și Probleme de interviu nu sunt obligatorii, dar recomandăm parcurgerea
întregului material pentru o viziune de ansamblu mai bună. Atenție! Problemele de interviu sunt mult
peste nivelul așteptat la această materie!
Motivație
În cadrul acestui articol vă vom prezenta câteva metode prin care programele se pot optimiza dacă utilizăm
eficient operaţiile pe biţi. Un lucru foarte important de reținut este că nu întotdeauna putem folosi aceste operații
pentru optimizare, iar laboratorul are ca scop ilustrarea câtorva exemple, pe care le puteți întâlni și pe viitor.
Veți învăța în anul 2 la Analiza Algoritmilor despre complexitatea unui algoritm. Vom considera momentan că
un algoritm este mai rapid decât altul dacă are mai puțini pași (exemplu un for cu n = 100 de pași este mai
rapid decât un for cu 1000 de pași).

În exemplul anterior performanța se referă la timp (dacă executăm mai puține instrucțiuni într-un program,
ne așteptăm să se termine mai repede). Acest aspect va fi abordat pe larg la materiile AA și PA.
În acest laborator vom vorbi despre altă metrică de măsurare a performanței unui program, mai exact
despre memoria folosită de un program.
De ce este important și acest aspect? Dacă din punct de vedere al timpului de execuție, sunt situații în care
putem aștepta mai mult timp pentru a se termina programul, din punctul de vedere al memoriei folosite avem
o limitare exactă. Un exemplu simplu este calculatorul nostru, care are 4GB/8GB/16GB. Dacă mașina noastră
are X GB RAM, dintre care o parte importantă o ocupă sistemul de operare, asta înseamnă că într-un anumit
program nu putem folosi o cantitate nelimitată de RAM (mai multe detalii la CN2, SO). Pentru simplitate,
momentan presupunem că programul nostru nu poate rula pe o mașină cu X GB, dacă are nevoie de mai mult
de X GB.
Dacă ajungem într-o astfel de situație în mod evident trebuie să schimbăm ceva, însă de multe ori putem păstra
algoritmul și să facem câteva modificări în implementare, care exploatează anumite abilități ale limbajului C
(ex. operații pe biți).

Dimensiunea tipurilor implicite în C. Calculul memoriei unui


program
În laboratorul 2 au fost prezentate tipurile de date implicite din C și dimensiunea acestora.
Pentru a afla dimensiunea în bytes a unei variabile se poate folosi operatorul sizeof.
Exemplu
Fie codul din următorul cod.

#include <stdio.h>

int main() {
// afiseaza dimensiunea tipurilor si a unor variabile de un anumit tip
char xc;
printf("sizeof(unsigned char) = %ld B\n", sizeof(unsigned char));
printf("sizeof(char) = %ld B\n", sizeof(char));
printf("sizeof(xc) = %ld B\n", sizeof(xc));

short int xs;


printf("sizeof(unsigned short int) = %ld B\n", sizeof(unsigned short int));
printf("sizeof(short int) = %ld B\n", sizeof(short int));
printf("sizeof(xs) = %ld B\n", sizeof(xs));

int xi;
printf("sizeof(unsigned int) = %ld B\n", sizeof(unsigned int));
printf("sizeof(int) = %ld B\n", sizeof(int));
printf("sizeof(xi) = %ld B\n", sizeof(xi));

// afiseaza dimensiunea unor tablouri cu dimensiune cunoscuta


char vc[100];
short int vs[100];
int vi[100];
printf("sizeof(vc) = %ld B\n", sizeof(vc));
printf("sizeof(vs) = %ld B\n", sizeof(vs));
printf("sizeof(vi) = %ld B\n", sizeof(vi));

return 0;
}
În urma executării acestui program pe o arhitectură de 32 biți (ceea ce folosim la PC) vom vedea următorul
rezultat.
sizeof(unsigned char) = 1 B

sizeof(char) = 1 B

sizeof(xc) = 1 B

sizeof(unsigned short int) = 2 B

sizeof(short int) = 2 B

sizeof(xs) = 2 B

sizeof(unsigned int) = 4 B

sizeof(int) = 4 B

sizeof(xi) = 4 B

sizeof(vc) = 100 B

sizeof(vs) = 200 B

sizeof(vi) = 400 B

Putem afla dimensiunea unui tip de date / unei variabile de un anumit tip la compile time folosind operatorul
sizeof care returnează dimensiunea în bytes a parametrului dat.

sizeof poate fi folosit și pentru măsurarea dimesiunii unui vector / matrice alocat(a) static.
Memoria totală folosită de un program poate fi calculată ca suma tuturor dimensiunilor ocupate de variabilele
din program.
De obicei, ne interesează să știm ordinul de mărime al spațiului de memorie alocat, astfel, de cele mai multe
ori, putem contoriza doar tablourile.
Un caz special îl poate reprezenta recursivitatea! Punerea parametrilor pe stivă de un număr foarte mare de ori,
este echivalent cu declararea unui tablou de valori pe stivă. Aceste variabile nu pot fi neglijate în calculul
memoriei!

Vom descoperi mai multe în următoare laboratoare.

Operatori pe biți în C
Operatorii limbajului C pot fi unari, binari sau ternari, fiecare având o precedenţă şi o asociativitate bine definite
(vezi lab02).
În tabelul următor reamintim operatorii limbajului C care sunt folosiți la nivel de bit.

Operator Descriere Asociativitate

~ Complement faţă de 1 pe biţi dreapta-stânga

<< si >> Deplasare stânga/dreapta a biţilor stânga-dreapta

& ŞI pe biţi stânga-dreapta

^ SAU-EXCLUSIV pe biţi stânga-dreapta

| SAU pe biţi stânga-dreapta

&= și |= Atribuire cu ŞI/SAU dreapta-stânga

^= Atribuire cu SAU-EXCLUSIV dreapta-stânga

<<= şi >>= Atribuire cu deplasare de biţi dreapta-stânga

Trebuie avută în vedere precedenţa operatorilor pentru obţinerea rezultatelor dorite!

Dacă nu sunteți sigur de precendența unui operator, folosiți o pereche de paranteze rotunde în plus în expresia
voastră! Nu exagerați cu parantezele, codul poate deveni ilizibil.

Bitwise NOT

Semnificație
Bitwise AND

Semnificație
Bitwise OR

Semnificație
Bitwise XOR

Semnificație
Bit LOGICAL SHIFT

În C sunt definite doar shiftări logice. Acestea pot fi la stânga (<<) sau la dreapta (>>), reprezentând deplasarea
in binar a cifrelor și completarea pozițiilor “golite” cu zerouri.
LEFT SHIFT

Efectul unei deplasări la stânga cu un rang binar este echivalent cu înmulțirea cu 2 a numărului din baza 10.
Dacă rezultatul nu are loc pe tipul de date folosit, atunci se pot pierde din biți!

Se poate deduce următoarea relație: n<<k=n∗2kn<<k=n∗2k.


Exemplu
Fie un exemplu de deplasarea la stânga, pentru un număr pe 3 biți .
b2b2 b1b1 b0b0

x=3 0 1 1

x«1=6 1 1 0

x«2=8 1 0 0

RIGHT SHIFT

Efectul unei deplasări la dreapta cu un rang binar este echivalent cu împărțirea întreagă la 2 a numărului din
baza 10.

Se poate deduce următoarea relație: n>>k=[n/2k]n>>k=[n/2k].


Exemplu
Fie un exemplu de deplasarea la dreapta, pentru un număr pe 3 biți .
b2b2 b1b1 b0b0

x=3 0 1 1

x»1=1 0 0 1

x»2=0 0 0 0

Lucrul cu măști
Având la dispoziție operațiile prezentate mai sus, putem răspunde la următoarele întrebări.

 Cum verificăm dacă bitul i dintr-un număr n este setat ?


 Cum setăm bitul i dintr-un număr n?
 Cum resetăm bitul i dintr-un număr n?
Pentru a răspunde ușor, pentru fiecare întrebare vom aplica o operație pe biți între n și o valoarea
numită mască .
Cum verificăm dacă bitul i dintr-un număr n este setat?
Detectarea bitului:

 pas 1: se aplică următoarea operația x = n & mask , unde mask = (1 « i)


b7b7 … bi+1bi+1 bibi bi−1bi−1 … b0b0

n * … * ? * … *

mask 0 … 0 1 0 … 0 op

x 0 … 0 ? 0 … 0

 pas 2: deoarece ? poate avea două valori, x poate fi 00 sau 2i2i


 dacă x == 0 , atunci bitul i este 0
 dacă x > 0 , atunci bitul i este 1
Implementare C

// is_set
// byte - byte de intrare pentru care vreau sa verific un bit
// i - indexul bitului din byte
// @return - 1, daca bitul este 1
// 0, daca bitul este 0
int is_set(char byte, int i) {
int mask = (1 << i);
return (byte & mask) != 0;
}

...
if (is_set(mybyte, i)) {
printf("bitul %d din byteul %d este setat!\n", i, mybyte);
} else {
printf("bitul %d din byteul %d NU este setat!\n", i, mybyte);
}
...
Explicație pas cu pas
Această întrebare ne oferă valoarea bitului i.

Dacă “valoarea este 1”, atunci vom spune că “bitul este setat”.

Dacă “valoarea este 0”, atunci vom spune că “bitul nu este “setat”.

Pentru a verifica valoarea bitului i din numărul n, practic noi ar trebui să privim numărul astfel:
b7b7 b6b6 … bibi … b1b1 b0b0

n * * … ? … * *

unde * înseamnă don't care (de la PL), iar ? este valoarea pe care o cautăm.
Deci am vrea să facem, după cum am zis mai sus, o operație de tipul “scoate” doar bitul i din număr, iar în rest
lasă 0 (pentru a evidenția bitul nostru).

b7b7 … bi+1bi+1 bibi bi−1bi−1 … b0b0

n * … * ? * … *

mask m7m7 … mi+1mi+1 mimi mi−1mi−1 … m0m0 op

n op mask 0 … 0 ? 0 … 0

op este o operație, iar mask un număr. Să analizăm cine pot fi op și biții din mască (mimi).
Dorim ca:

 ? op mi= ?? op mi= ? , adică operația op aplicată pe ?? și mimi, va avea mereu ca rezultat pe ??


 ∗ op mj=0∗ op mj=0 (unde i != j), adică operația op aplicată pe orice valoare și mjmj, va da 0
Observăm că:

 1 este elementul neutru pentru ȘI, ceea ce verifică ? & 1 = ? , oricare are fi ? un bit
 0 este elementul care poate “șterge” un bit prin Și, ceea ce verifică * & 0 = 0 , oricare ar fi * un bit
Cum setăm (valoarea devine 1) bitul i dintr-un număr n?
Setarea bitului:

 pas 1: se aplică următoarea operația n = n | mask , unde mask = (1 « i)


b7b7 … bi+1bi+1 bibi bi−1bi−1 … b0b0

n n7n7 … ni+1ni+1 * ni−1ni−1 … n0n0

mask 00 … 00 11 00 … 00 op

n op mask n7n7 … ni+1ni+1 1 ni−1ni−1 … n0n0

 explicație:
 orice valoare are avea bitul ∗∗ va fi suprascris cu 1
 ceilalti biți vor fi copiați
Implementare C
// set
// byte - byte de intrare pentru care vreau sa setez un bit
// i - indexul bitului din byte
// @return - noul byte
char set(char byte, int i) {
int mask = (1 << i);
return (byte | mask);
}

...
mybyte = set(mybyte, i);
...

Explicație pas cu pas


Dorim să facem următoarea operație: schimba doar bitul i in 1, iar pe ceilalți lasă-i neschimbați.

b7b7 … bi+1bi+1 bibi bi−1bi−1 … b0b0

n n7n7 … ni+1ni+1 * ni−1ni−1 … n0n0

mask m7m7 … mi+1mi+1 mimi mi−1mi−1 … m0m0 op

n op mask n7n7 … ni+1ni+1 1 ni−1ni−1 … n0n0

op este o operație, iar mask un număr. Să analizăm cine pot fi op și biții din mască (mimi).
Dorim ca:

 ∗ op mi=1∗ op mi=1, adică operația op aplicată pe ∗∗ (orice) și mimi, va avea mereu ca rezultat pe 11
 nj op mj=njnj op mj=nj (unde i != j), adică operația opop aplicată pe njnj și mjmj, va da njnj
Observăm că:

 1 este elementul care poate “umple” un bit prin SAU, ceea ce verifică ∗|1=1∗|1=1, oricare ar fi * un bit
 0 este elementul neutru pentru SAU, ceea ce verifică nj|0=njnj|0=nj, oricare are fi njnj un bit
Cum resetăm (valoarea devine 0) bitul i dintr-un număr n?
Resetarea bitului:
 pas 1 / 1: se aplică următoarea operația n = n & mask , unde mask = ~(1 « i)
b7 … bi+1 bi bi−1 … b0

n n7 … ni+1 * ni−1 … n0

mask 1 … 1 0 1 … 1 op

n op mask n7 … ni+1 0 ni−1 … n0

 explicație:
 orice valoare are avea bitul ∗∗ va fi suprascris cu 0
 ceilalti biți vor fi copiați

Implementare C
// reset
// byte - byte de intrare pentru care vreau sa resetez un bit
// i - indexul bitului din byte
// @return - noul byte
char reset(char byte, int i) {
int mask = ~(1 << i);
return (byte & mask);
}

...
mybyte = reset(mybyte, i);
...

Explicație pas cu pas


Dorim să facem următoarea operație: schimba doar bitul i in 0, iar pe ceilalți lasă-i neschimbați.

b7b7 … bi+1bi+1 bibi bi−1bi−1 … b0b0

n n7n7 … ni+1ni+1 * ni−1ni−1 … n0n0

mask m7m7 … mi+1mi+1 mimi mi−1mi−1 … m0m0 op

n op mask n7n7 … ni+1ni+1 0 ni−1ni−1 … n0n0

op este o operație, iar mask un număr. Să analizăm cine pot fi op și biții din mască (mimi).
Dorim ca:

 ∗ op mi=0∗ op mi=0, adică operația op aplicată pe ∗∗ (orice) și mimi, va avea mereu ca rezultat pe 00
 nj op mj=njnj op mj=nj (unde i != j), adică operația opop aplicată pe njnj și mjmj, va da njnj
Observăm că:

 0 este elementul care poate “șterge” un bit prin ȘI, ceea ce verifică * & 0 = 0 * & 0 = 0, oricare ar fi *
un bit
 1 este elementul neutru pentru ȘI, ceea ce verifică nj|1=njnj|1=nj, oricare are fi njnj un bit
Exerciții
Checker laborator 7 CB\CD Teste Problema 1 Teste Problema 2 Teste Problema 3 Teste Problema 4 Teste
Problema 5 Teste Problema 6
Precizari CB\CD

 Arhivele 4, 5, 6 testeaza reuniunea, intersectia respectiv diferenta seturilor.


 Intrarea corespunde functiei set_read (n, urmat de n elemente)
 Ref corespunde functiei set_print aplicata pe setul obtinut (cardinalul setului, urmat pe urmatoarea linie
de elementele din set)
 Arhiva 7 corespunde problemei de bonus B1 (.ref contine rezultatul pentru get_lsb urmat de rezultatul
pentru get_msb)
 Arhiva 8 corespunde problemei B2.
Precizari generale

Rezolvați împreună cu asistentul pe tablă, exercițiile 0-4, apoi rezolvați invidual exercițiul 5.

0: Verificare că un număr e par

Să se verifice folosind operații pe biți că un număr natural n e par.

int is_even(int n);

1. Calcul putere a lui 2 (0p)

Să se scrie o funcție care să calculeze 2n2n, unde n<=30n<=30.


int pow2(int n);
Răspundeți la întrebarea: are sens să scriem o funcție?
2. Negarea biților unui număr n (0p)

Să se scrie o funcție care să nege biții unui număr n (32 biți).

int flip_bits(int n);


Răspundeți la întrebarea: are sens să scriem o funcție?
3. Afișarea biților unui număr n (0p)

Să se scrie o funcție care să afișeze toți biții unui număr întreg pe 32 biți.

void print_bits(int n);

4. Verificare că un număr este putere al lui 2 (0p)

Să se scrie o funcție care verifică dacă un număr întreg n pe 32 biți este puterea a lui 2. Funcția va returna 1
dacă n este putere a lui 2, 0 altfel.

int is_power2(int n);


Hint
Analizați reprezentarea în baza 2 a lui n (ex. n = 16 si n = 5).

Implementați invidual următoarea problemă.


5. bitset (10p )

O mulțime de numere întregi poate fi reprezentată astfel: spunem că un număr i aparține unei mulțimi S dacă
bit-ul al i-lea din vectorul S are valoarea 1.
Pentru eficientă, vectorul S va conține date de tipul unsigned char (reamintim ca sizeof(unsigned char) ==
1 byte adică 8 biți).
Pentru a folosi cu ușurință același cod facând schimbări minime (de exemplu schimbăm dimensiunea maximă a
unei mulțimi), putem să ne definim propriul tip astfel:

#define SET_SIZE 100 // aceasta este o macrodefiniție (momentan o putem privi


ca pe O CONSTANTA CARE ARE VALOAREA 100
typedef unsigned char SET[SET_SIZE]; // definesc tipul SET, care este un vector cu maxim 100
de elemente de tip unsigned char
Cele două linii de mai sus vor fi puse imediat dupa includerea directivelor header!

5.1
Implementați următoarele funcții. Realizați un program în C prin care să demonstrați că funcțiile implementate
funcționează.

Există un exemplu detaliat care vă explică cum functionează aceastea.

Treceți la subpunctul următor abia după ce v-ați asigurat că acestea funcționează.

 adăugarea unui element în mulțime


// insert_in_set(s, n) - adauga numarul n in multimea s
void insert_in_set(SET s, unsigned int n);

 ștergerea unui element din mulțime


// delete_from_set(s, n) - scoate numarul n din multime s
void delete_from_set(SET s, unsigned int n);

 verificarea faptului că un element n aparține unei mulțimi


// is_in_set(s, n) - returneaza 1 daca n este in s, 0 altfel
int is_in_set(SET s, unsigned int n);

 ștergerea tuturor elementelor din mulțime


// delete_all_from_set(s) - elimina toate elementele din multime
void delete_all_from_set(SET s);

 calcularea cardinalul unei mulțimi


// card_set(s) - returneaza cardinalul multimii s
int card_set(SET s);

 verificarea faptului că mulțimea este vidă


// is_empty_set(s) - verifica daca multimea este sau nu goala
// returneaza 1 daca este, 0 daca nu este
int is_empty_set(SET s);

 o funcție care să citească de la tastatură o mulțime


// read_set(s) - functia citeste numarul n de elemente care se afla in a
// apoi citeste cele n numere si le insereaza in a
// va returna numarul n citit (numarul de elemente)
int read_set(SET s);

 o funcție care să afișeze pe ecran elementele care se află într-o mulțime


// print_set(s) - functia printeaza elementele multimii s
void print_set(SET s);

Urmăriți acest exemplu cu bitset pentru a înțele cum funcționeazăaceste operații.


5.2
Realizati un program care, utilizând metodele definite anterior, citește 2 mulțimi A (n și B și
afișează: AUB,A∩B,A−B,B−AAUB,A∩B,A−B,B−A.
Pentru a realiza acest lucru, va trebui să implementați următoarele funcții:

 reuniunea a două mulțimi (1p)


// c = a U b
void merge_set(SET a, SET b, SET c);

 intersecția a două mulțimi (1p)


// c = a n b
void intersect_set(SET a, SET b, SET c);

 diferența a două mulțimi (1p)


// c = a \ b
void diff_set(SET a, SET b, SET c);

În final va trebui sa creați o funcție main și să faceți un program care rezolvă cerința folosind funcțiile
implementate.

B1. Optimizations
O să învățați pe viitor că nu toate înstrucțiunile sunt la fel din punct de vedere al timpului de execuției. Ca un
punct de start, gândiți-vă că dacă ați face pe foaie o adunare sau o înmultire, durează mai mult să înmulțiți
decât să adunați. Această dificulate o are și procesorul din laptopul vostru!

Pentru a-l ajuta și a face programul mai rapid, putem înlocui operații costisitoare ( * , / ) cu altele mai puțin
costistoare ( + , - ). Cele mai rapide instrucțiuni sunt cele care lucrează direct pe biți (deoarece numerele sunt
stocate în memorie în binar, deci este modul natural de lucru pentru calculator).
Acest exercițiu vă propune să aplicați aceste optimizări pe codul vostru!

Hint
Pentru a completa acest bonus, NU aveți voie să folosiți operatorii /, *, % ! Încercați să folosiți operații pe biți!
B2. MSB/LSB
Să se scrie o câte o funcție pentru aflarea MSB(Most significant bit), respectiv LSB(Least significant bit), pentru
un număr n pe 32 biți.

int get_lsb(int n);


int get_msb(int n);
Hint
Analizați reprezentarea în baza 2 a lui n.
Probleme de interviu
Pentru cei interesați, recomandăm rezolvarea și următoarelor probleme, care sunt des întâlnite la interviuri.

Atenție! Problemele din această categorie au un nivel de dificultate ridicat, peste cel cerut la cursul de PC.

Recomandăm totuși rezolvarea acestor probleme pentru cei care doresc să aprofundeze lucrul cu operații pe
biți.

Swap bits
Se dă un număr n natural pe 32 de biți. Se cere să se calculeze numărul obținut prin interschimbarea biților de
rang par cu cel de rang impar.

Exemplu: n = 2863311530 = > m = 1431655765

Hint: Reprezentarea numerelor în baza 2 ([http://www.binaryhexconverter.com/decimal-to-binary-converter |


convertor]]).

Element unic din șir


Fie un șir cu 2n + 1 numere întregi, dintre care n numere apar de câte 2 ore, iar unul singur este unic. Să se
gasească elementul unic.
Exemplu:

n = 5 și sirul [1, 4, 4, 1, 5]

Numărul unic este 5.

Hint: Încercați să nu folosiți tablouri.

Follow-up 1: Șirul are 2∗n+(2∗p+1)2∗n+(2∗p+1) numere. Se sție că un singur număr apare de un număr impar
de ori (2p + 1), iar celelalte apar de un număr par de ori. Cum găsiți numărul care apare de un număr impar
de ori?
Exemplu:

n = 5 și sirul [1, 1, 4, 4, 4, 4, 5, 5, 5]

Răspunsul este 5.

Follow-up 2: Șirul are 2n+22n+2 numere, n numere apar de câte 2 ori, iar 2 numere sunt unice. Cum găsiți cele
2 numer unice?
Exemplu:

n = 5 și sirul [1, 4, 4, 1, 5, 6]

Numărele unice sunt 5 și 6.

TODO: sursă

Căutare binară pe biți


Realizează o funcție de căutare pe binară, utilizând operații pe biți pentru optimizarea acestei implementări.

Follow up: Puteți găsi alt algoritm care să nu se bazeze pe împarțirea vectorului în două și compararea
elementului din mijloc cu cel cautat?

Hint: caut bin și Multe "smenuri" de programare in C/C++... si nu numai!

Împărat, bețiv și nervos


Împăratul Gigel a primit cadou 100 de sticle de vin de la un admirator secret. Acesta i-a lăsat și o scrisoare în
care îi spune despre faptul că este vin nobil, însă a otrăvit o sticlă. Cine va bea din sticla otrăvită va muri în
fix 24h .
Gigel dispune de un număr imens de sclavi (precum număr imens de restanțe in Poli), așa că el își va pune
sclavii să guste din vin. Se gândește totuși că nu se mai găsesc sclavi pe toate drumurile, așa ca dorește să
folosească un număr minim de sclavi (cei care gustă riscă să moară).
Ajutați-l pe Gigel să găsească numărul minim de sclavi pe care trebuie să îi foloseacă astfel:

 pune pe cei n sclavi aleși la masă cu câte un pahar în mână, inițial gol
 poruncește bucătarului să toarne vin în pahare, alegând cui și ce să toarne
 unui sclav i poate turna în pahat vin din oricâte sticle (chiar și simultan)
 după ce s-a turnat în toate cele n pahare, ei vor bea toți simultan
 la finalul celor 24h Gigel trebuie să își dea seama care este sticla otrăvită, altfel se va oftica și va pune
100 de sclavi să guste fiecare din câte o sticlă, iar pe cei care rămân în viață îi va împușca oricum pentru
că e nervos
P.S. Apreciați faptul că Gigel încearcă să nu omoare foarte mulți oameni degeaba. Ajutați-l să
găsească numărulminimndesclavinumărulminimndesclavi care trebuie să guste din vin.
Follow-up: Aceeași problemă, dar pentru n sticle.

TODO: sursă

Hint: Reprezentarea numerelor în baza 2.

Jocul NIM
Se dau n grămezi, fiecare conţinând un anumit număr de pietre. Doi jucători vor începe să ia alternativ din
pietre, astfel: la fiecare pas, jucătorul aflat la mutare trebuie să îndepărteze un număr nenul de pietre dintr-o
singură grămadă. Câştigătorul este cel care ia ultima piatră. Să se determine dacă jucătorul care ia primele
pietre are strategie sigură de câştig.

Exemple

n = 4, gramezi = [1 3 5 7], raspuns = NU

n = 3, gramezi = [4 8 17], raspuns = DA

Hint: nim

Sushi
Enunt: sushi
Optimizarea programelor folosind operaţii pe biţi
(Categoria Algoritmi, Autor Cosmin Negruşeri)

 Conţinut:
 Aplicaţia 1
 Aplicaţia 2
 Aplicaţia 3
 Aplicaţia 4
 Aplicaţia 5
 Aplicaţia 6
 O altă abordare: preprocesarea
 Rezolvarea aplicaţiei 1
 Rezolvarea aplicaţiei 2
 Rezolvarea aplicaţiei 3
 Rezolvarea aplicaţiei 4
 Rezolvarea aplicaţiei 5
 Algoritmi mai serioşi
 Ciurul lui Eratostene
 Bibliografie

În cadrul acestui articol vă vom prezenta câteva metode prin care programele se pot optimiza dacă utilizăm eficient
operaţiile pe biţi sau folosim operaţii pe biţi în locuri în care, la prima vedere, nu s-ar părea că ar fi necesare.

De cele mai multe ori, atât în concursuri cât şi în viaţa de zi cu zi a programatorului, atunci când implementăm o metodă
care este folosită de o aplicaţie şi vrem să facem această metodă eficientă ca timp de execuţie, suntem învăţaţi din şcoală
(sau ar trebui sã fim învăţaţi, chiar dacă unii dintre profesori consideră că cel mai important algoritm învăţat în liceu
este backtraking-ul, soluţia tuturor problemelor) să ne uităm la complexitatea algoritmului implementat şi dacă observăm
că în practică algoritmul este mai încet decât ne dorim noi să fie să încercăm să găsim un algoritm cu un ordin de
complexitate mai mic.
Nu trebuie să uităm că ceea ce numim complexitatea unui algoritm este o aproximare a vitezei unui algoritm şi nu o
măsură absolută. Pentru dimensiuni mici ale datelor, câteodată nu se observă diferenţă dintre O(n) şi O(n log
n) sau O(n3/2). Autorului i s-a întâmplat ca la implementarea soluţiei unei probleme care în engleză se numeşte
"Bottleneck Minimal Spanning Tree" să observe că rezolvarea în O(m log m) (folosind algoritmul de găsire a arborelui
parţial de cost minim al lui Kruskal) să fie mai rapidă decât rezolvarea mai laborioasă în O(m) a acestei probleme. Deci
trebuie dată o atenţie egală cu cea acordată complexităţii algoritmului şi dimensiunii factorilor constanţi care apar.

În continuare vom încerca să găsim soluţii mai rapide decât cele naive pentru unele operaţii de bază (toate operaţiile vor fi
implementate pentru întregi pozitivi reprezentaţi pe 32 de biţi).

Aplicaţia #1
Să se determine numărul de biţi de 1 din reprezentarea binară a lui n.

Rezolvare
Rezolvarea naivă a acestei probleme ar consta în parcurgerea secvenţială a biţilor lui n. Î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 uităm cu atenţie şi analizăm rezultatul operaţiei n & (n - 1) putem obţine o soluţie mai bună. Să luăm un
exemplu:
110111010100002 = n
110111010011112 = n - 1
110111010000002 = n & (n - 1)
Se vede clar de aici că efectul operaţiei 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)

do num++; while (n &= n - 1);

return num;

}
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 număr de paşi egali cu numărul de biţi cu valoarea 1 din număr, deci în medie
jumătate din numărul de paşi efectuaţi de prima metodă.

Aplicaţia #2
Să se determine paritatea numărului de biţi de 1 din reprezentarea binară a unui număr n.
Rezolvare
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)

do num ^= 1; while (n &= n - 1);

return num;

}
Putem obţine o a treia rezolvare făcând câteva observaţii pe un exemplu. Considerăm n = 110110112. Rezultatul căutat
este dat de valoarea 1 ^ 1 ^ 0 ^ 1 ^ 1 ^ 0 ^ 1 ^ 1. Împărţim pe n în partea lui superioară şi partea lui inferioară: 1 1
0 1 ^ 1 0 1 1 = 0 1 1 0. Aplicăm asupra rezultatului acelaşi procedeu: 0 1 ^ 1 0 = 1 1 şi 1 ^ 1 = 0.

Să scriem algoritmul care reiese din acest exemplu, luând în considerare faptul că numărul n este reprezentat pe 32 de
biţi:

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;

Deşi constanta multiplicativa este mai mare, numărul de operaţii are ordin logaritmic faţă de numărul de biţi ai unui
cuvânt al calculatorului.

Aplicaţia #3

Să se determine cel mai puţin semnificativ bit de 1 din reprezentarea binară a lui n.

Rezolvare
Rezolvarea naivă se comportă bine în medie, deoarece numărul de cicluri până la găsirea unui bit cu valoarea 1 este de
obicei mic, dar din cele discutate mai sus putem găsi ceva mai bun.

Asa cum am arătat n & (n - 1) are ca rezultat numărul n din care s-a scăzut cel mai puţin semnificativ bit. Folosind
această idee obţinem următoarea funcţie:

int low1(long n) {
return n ^ (n & (n - 1));

Exemplu:

110110002 = n
110101112 = n - 1
110100002 = n & (n - 1)
110110002 = n
000010002 = n ^ (n & (n - 1))
Această funcţie este foarte importantă pentru structura de date arbori indexaţi binar prezentată într-un un articol mai
vechi din GInfo.

Aplicaţia #4

Să se determine cel mai semnificativ bit cu valoarea 1 din reprezentarea binară a lui n.

Rezolvare
Putem aplica şi aici ideile prezentate mai sus: cea naivă şi cea cu eliminarea biţilor, dar putem găsi şi ceva mai bun.

O abordare ar fi cea a căutării binare (aplicabilă şi în problema anterioară). Verificăm dacă partea superioară a lui n este 0.
Dacă nu este 0, atunci căutăm bitul cel mai semnificativ din ea, iar dacă este, ne ocupăm de partea inferioară, deci
reducem la fiecare pas problema la jumătate.

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;

if (12 & n) {

n = (12 & n) >> 2;

num += 2;

if (2 & n) {

n = (2 & n) >> 1;

num += 1;

return 1 << num;

Aplicaţia #5
Să se determine indexul celui mai semnificativ bit de 1 din reprezentarea binară a lui n.
Rezolvare
Metodele prezentate la rezolvarea problemei 4 pot fi folosite şi aici, dar vom prezenta o nouă rezolvare. Să luăm următorul
şir de operaţii pentru un exemplu:

n = 100000002
n = n | (n >> 1)
n = 110000002
n = n | (n >> 2)
n = 111100002
n = n | (n >> 4)
n = 111111112
Se observă că aplicând o secvenţă asemănătoare de instrucţiuni cu cea de mai sus putem face ca un număr n să se
transforme în alt număr care are un număr de biţi de 1 egal cu 1 plus indexul celui mai semnificativ bit cu valoarea 1 din n.
De aici algoritmul este următorul...

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;

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

Aplicaţia #6
Să se determine dacă n este o putere a lui 2 (întrebare interviu Microsoft).
Rezolvare
int isTwoPower(long n) {

return ( n & (n - 1) ) == 0 ;

O altă abordare: preprocesarea


Operaţiile prezentate mai sus sunt implementate în limbajele de asamblare, dar câteodată se folosesc algoritmi naivi şi
atunci o implementare inteligentă ajunge să fie mai rapidă decât instrucţiunea din limbajul de asamblare. Pentru unele
supercalculatoare aceste instrucţiuni sunt instrucţiuni ale procesorului.

Se pot găsi alţi algoritmi mai rapizi decât cei de mai sus, dar de obicei găsirea unui algoritm mai inteligent este mai grea
decât folosirea unei abordări banale care aduce un câştig foarte mare: preprocesarea.

În continuare vom rezolva problemele prezentate anterior folosindu-ne de preprocesarea datelor.

Rezolvarea aplicaţiei #1
Determinăm, pentru numerele de la 0 la 216 - 1, un tablou num, în care num[i] este egal cu numărul de biţi de 1 din
reprezentarea binară a lui i. De aici obţinem soluţia...

int count(long n) {

return num[n & 0xFFFF] + num[n >> 16];

Rezolvarea aplicaţiei #2
Determinăm, pentru numerele de la 0 la 216 - 1, un tablou par, în care par[i] este egal cu paritatea numărului de biţi
de 1 din reprezentarea binară a lui i. De aici obţinem soluţia...

int parity(long n) {

return par[n & 0xFFFF] ^ par[n >> 16];

Rezolvarea aplicaţiei #3
Determinăm, 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 obţinem soluţia...
int low(long n) {

if (l[n & 0xFFFF]) return l[n & 0xFFFF];

else return l[n >> 16] << 16;

Rezolvarea aplicaţiei #4
Determinăm, 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 obţinem soluţia...

int high(long n) {

if (h[n >> 16]) return h[n >> 16] << 16;

else return h[n & 0xFFFF];

Rezolvarea aplicaţiei #5
Determinăm, 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 obţinem soluţia...

int indexHigh(long n) {

if (iH[n >> 16] != -1) return iH[n >> 16] + 16;

else return iH[n & 0xFFFF];

Algoritmi mai serioşi


Deşi ceea ce am prezentat mai sus sunt mai degrabă trucuri de implementare decât algoritmi serioşi şi în general nu se
acordă aşa mare importanţă detaliilor de genul acesta, câteodată 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 găsirea numărului de
dreptunghiuri conţinute de o matrice binară M care au în colţuri 1. Un algoritm de complexitate O(n2m) ar fi fost următorul:
se consideră fiecare pereche formată din două linii şi se numără câte perechi (Mi,k, Mj,k) pentru care Mi,k = 1 şi Mj,k =
1 există. Fie acest număr x. Atunci numărul de dreptunghiuri care au colţurile pe aceste două linii va fi x(x - 1) / 2.
Un asemenea algoritm ar fi luat numai 64 de puncte deoarece restricţiile date erau mari (n ≤ 200, m ≤ 2500). Putem
rescrie condiţia M[i][k] == 1 && M[j][k] == 1 ca şi M[i][k] ^ M[j][k] == 1, deci putem modifica rezolvarea
anterioară în modul următor...

pentru fiecare i

pentru fiecare j > i

L = M[i] ^ M[j];

sfârşit pentru
sfârşit pentru

numaraBitiUnu(L)
Dacă în loc să păstrăm în M[i][j] un element al matricei binare, păstrăm 16 elemente, atunci în linia L = M[i] ^ M[j] se
efectuează cel mult 2500/16 calcule în loc de 2500 de calcule. Acum, ce a rămas de optimizat este metoda numaraBitiUnu.
Dacă folosim preprocesarea, atunci această metodă va fi compusă şi ea din 2500/16 calcule. Se observă că în acest fel am
mărit viteza de execuţie a algoritmului cu un factor de 16.
Alt exemplu ar fi problema rundei 17. În acea problemă se cere determinarea numărului de sume distincte care se pot
obţine folosind nişte 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 obţinut punctaj maxim. Pentru obţinerea punctajului maxim
următoarea idee ar fi putut fi folosită cu succes: şirul de valori logice se condensează într-un şir de întregi reprezentaţi
pe 16 biţi, şi acum actualizarea se face asupra a 16 valori deodată. Deci şi aici se câştiga 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 mare divizor comun a două numere binare de cel mult 10 000 de cifre. După cum se ştie,
complexitatea algoritmului de determinare a celui mai mare divizor comun a două numere este logaritmică, acest fapt este
adevărat atunci când operaţiile efectuate sunt constante, dar în rezolvarea noastră apar numere mari, deci o estimare a
numărului de calcule ar fi maxn * maxn * log maxn (împărţirea are în medie costul maxn * maxn dacă nu implementăm
metode mai laborioase). Acest număr este prea mare pentru timpul de rezolvare care ne este cerut. Pentru a evita
împărţirea, care este operaţia cea mai costisitoare, putem folosi algoritmul de determinare a celui mai mare divizor comun
binar care se foloseşte de următoarele proprietăţi...
1. cmmdc(a, b) = 2 * cmmdc(a / 2, b / 2), dacă a şi b sunt numere pare;

2. cmmdc(a, b) = cmmdc(a, b / 2), dacă a este impar;

3. cmmdc(a, b) = cmmdc(a - b, b), dacã a este impar, b este impar şi a > b.


Aplicând acest algoritm numărul de calcule s-a redus la maxn * log maxn, dar nici acest număr nu este suficient de mic, şi
deci
pentru accelerarea algoritmului folosim ideea utilizată în cele două probleme de mai sus. Împachetăm numărul în întregi,
în pachete de câte 30 de biţi, şi atunci deplasarea la stânga (adică împărţirea la 2) şi scăderea vor fi de 30 de ori mai
rapide.

Să încercăm acum rezolvarea unei probleme în care eficienţa este cea mai importantă, mai importantă decât lizibilitatea
codului obţinut. Problema are următorul enunţ...

Se consideră un număr 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 primalităţii numerelor mai mici sau egale cu n.
Putem încerca mai multe rezolvări, dar în practică cea mai rapidă se dovedeşte de obicei cea numită ciurul lui Eratostene.
Implementarea banală a algoritmului ar fi următoarea:

define maxsize 10000000

char at[maxsize]; // vector de valori logice în care numerele prime vor fi marcate cu 0

int n;

int isPrime(int x) {

return (!at[x]) ;

}
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; // marcăm toţi multipli lui i ca fiind numere prime

Observăm că jumătate din timpul folosit de noi la preprocesare este pierdut cu numerele pare. Marcându-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;

}
La marcarea multiplilor numărului prim i toate numerele până la i * i au fost deja marcate deoarece i * i este cel mai
mic număr care nu este divizibil cu numere naturale mai mici sau egale cu i - 1. Deci, avem o a treia versiune a
programului...

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;

}
Ultima optimizare este optimizarea spaţiului necesar. Într-un tip de date char putem împacheta opt valori logice şi punând
un test suplimentar în metoda isPrime putem elimina şi numerele pare, astfel vom avea nevoie de un spaţiu 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))) ;

Concluzie
Asemenea optimizări 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 creştere semnificativă de viteză a aplicaţiilor şi trebuie documentate foarte bine, mai ales dacă se
doreşte sau este necesar ca alt programator să poată lucra cu codul respectiv mai târziu.

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. The Aggregate Magic Algorithms

4. TopCoder
Lucrul pe biti in C++
In primul rand, numerele intregi sunt reprezentate in memorie in baza 2, iar lungimea lor poate
avea 8,16 sau 32 de biti. Felul in care sunt memorate aceste numere se numeste cod
complementar. Sa zicem ca vrem sa prezentam numarul 5 in cod complementar. 5 scris in
baza 2 este 101 (se citeste unu zero unu si nu o suta unu). Reprezentarea lui pe 8 biti ar arata
cam asa:

Dupa cate puteti observa, ceilalti biti s-au umplut cu 0 pana la ultimul bit.

Operatiile pe biti

Ei se pot aplica datelor ce fac parte din tipul numerelor intregi.

1.Operatorul de conjunctie
Se noteaza cu & si acesta returneaza numarul intreg a carui reprezentare se obtine prin
conjunctia bitilor din reprezentarea operanzilor in cod complementar.Ca sa puteti intelege o sa
fac un tabel cu valorile care rezulta din aplicarea acestui operator si un exemplu:

Valoare bit 1 Valoare bit 2 Rezultat dupa aplicarea &

1 0 0

0 1 0

1 1 1

0 0 0

Exemplu:Reprezentarea lui 5 este 00000101 iar a lui 3 este 00000011.Cand aplicam operatorul
& rezultatul va arata astfel conform tabelului:
Evident rezultatul final este 1.

2.Operatorul de disjunctie
Se noteaza cu | si este un operator binar. Returneaza numarul intreg a carui reprezentare se
obtine prin disjunctia bitilor din reprezentarea operanzilor. Din nou o sa va arat clasicul tabel.
Vom aplica operatorul | asupra numerelor 15 si 3(reprezentarea lui 15 este 00001111):

Valoare bit 1 Valoare bit 2 Rezultat dupa aplicarea |

1 0 1

0 1 1

1 1 1

0 0 0

Am ales acest exemplu ca sa vedeti ca mai exista si


coincidente la rezultate(in cazul nostru rezultatul este 15)
3.Operatorul „sau exclusiv”
Se noteaza cu ^ si este un operator binar. Returneaza numarul intreg a carui reprezentare se
obtine prin operatia or exclusiv asupra bitilor care apar in reprezentarea operanzilor in cod
complementar.Din nou,clasicul tabel(aplicam din nou acest operator numarului 15 si 3):

Valoare bit 1 Valoare bit 2 Rezultat dupa aplicarea ^

1 0 1

0 1 1

1 1 0

0 0 0

Rezultatul este 12.

4.Operatorii shift left si shift right


Am spus sa ii iau la pachet deoarece fac acelasi lucru dar in directii diferite.Se noteaza
cu << (respectiv >>) si returneaza numarul intreg a carui reprezentare se obtine din
reprezentarea in cod complementar a primului operand prin deplasare la stanga (respectiv la
dreapta) cu un nr de biti egal cu al doilea operand.

Sa luam exemplu numerele 4 si 2.Reprezentarea lui 4 este 00000100. Prin deplasare la stanga se
obtine 00010000, care este de fapt numarul 16. Prin deplasare la dreapta se obtine 00000001,
care este numarul 1.
Operatorul shift right poate fi asociat cu impartirea intreaga la 2. Operatorul shift left poate fi
folosit si pentru a calcula mai rapid numarul 2 la puterea n prin aplicarea lui astfel 1<<n.

Sper ca am fost cat de cat de ajutor. In urmatorul articol o sa vorbim despre operatii la nivel de
bit (asta daca mai am timp). Operatorii pe biti sunt folositi deseori in optimizarea programelor
deoarece sunt operatii care sunt folosite direct de catre microprocesor. Asa ca puteti da copy
paste la codul de mai jos si sa va jucati cu valori.

#include <iostream>

using namespace std;

int main()
{ int n,m;
cin>>n>>m;
cout<<(n & m)<<endl;
cout<<(n | m)<<endl;
cout<<(n ^ m)<<endl;
cout<<(n<<m)<<endl;
cout<<(n>>m);

return 0;}

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