Nu obții 100 de puncte sau ai nelămuriri în privința problemelor? Scrie-mi pe Instagram.
Ai găsit o greșeală, vrei să raportezi un utilizator sau vrei să comunici altceva? Folosește formularul de contact.
Vrei să ne transmiți o părere despre platformă? Folosește formularul de feedback.
Folosește următoarele shortcuturi pentru a naviga mai ușor pe platformă.
Meniu shortcuturi | ? |
Căutare probleme sau utilizatori | / |
Navigare printre rezultatele căutării | ↑, ↓ |
Meniu de contact și feedback | CTRL + Shift + F |
Ieșire din meniuri | Esc |
Setări editor | CTRL + Shift + S |
Schimbare stil editor | CTRL + Shift + E |
Șabloane de cod | CTRL + Shift + 1/2/3 |
Golire editor | CTRL + Shift + 4 |
De multe ori, pentru aceeași problemă, se găsesc metode diferite de rezolvare — mai exact, avem algoritmi diferiți. În funcție de cât de multe resurse se folosesc pentru rularea unui algoritm (în timp și în spațiu), se determină complexitatea algoritmului și mai apoi eficiența sa.
Complexitatea unui algoritm reprezintă un mod de a măsura eficiența unui algoritm, în funcție de cât de multe resurse consumă acesta din punct de vedere al memoriei și al timpului.
Deși cu seturi mici de date eficiența unui algoritm este probabil neglijabilă, cunoașterea complexității unui algoritm este necesară în momentul în care ai o cantitate mai largă de date, iar metode mai ineficiente nu sunt folosibile.
Să luăm doi algoritmi diferiți care calculează numărul de divizori al unui număr n
. Prima metodă ia toate numerele de la 1
la n
și verifică pentru fiecare dacă este sau nu divizor al lui n
, pe când a doua metodă ia doar numerele de la 1
la radicalul lui n
, folosind o metodă mai eficientă (despre care poți citi în acest articol).
Codul arată astfel:
int nrdiv = 0;
for(int d = 1; d <= n; d++) {
if(n % d == 0) {
nrdiv++;
}
}
cout << n << " are " << nrdiv << " divizori";
Codul eficient arată astfel:
int nrdiv = 0;
for(int d = 1; d * d <= n; d++) {
if(n % d == 0) {
nrdiv++;
if(d != n / d)
nrdiv++;
}
}
cout << n << " are " << nrdiv << " divizori";
Se observă faptul că prima metodă merge de la 1
la n
, astfel făcând n
operații în total. Pe de altă parte, a doua metodă merge de la 1
până la radicalul lui n
, astfel executând un maxim de sqrt(n)
operații. Cum sqrt(n) ≤ n
, al doilea algoritm va face, de regulă, mai puțini pași decât primul, astfel că îl considerăm mai eficient.
Acesta a fost un exemplu de complexitate a timpului, unde am numărat câți pași se efectuează în total pentru cei doi algoritmi. Similar se poate calcula și complexitatea de memorie, care vede cât de multă memorie se consumă.
Complexitatea unui algoritm se calculează în funcție de câte operații se execută în program. Deoarece ne interesează strict complexitatea algoritmului, operațiile de citire și de afișare nu se iau de obicei în calcul — chiar dacă, pentru un vector, se citesc n
numere, nu vom lua în considerare acest lucru.
Pentru restul operațiilor (precum comparații sau atribuiri), vom estima de câte ori apar acestea când se execută codul. Pentru exemplul de mai sus, prima metodă verifică pentru n
numere (de la 1
la n
), câte dintre ele sunt divizori. Astfel, vom estima că se execută n
operații. Pentru a doua metodă, deoarece structura repetitivă parcurge numerele de la 1
la sqrt(n)
, atunci vom spune că acesta execută sqrt(n)
operații.
De menționat este faptul că dacă se execută de mai multe ori n
operații — de pildă, dacă avem două for
-uri separate care merg de la 1
la n
, deci în total 2 * n
operații — atunci vom rotunji numărul total de operații la n
.
Complexitatea unui algoritm se notează cu O(…)
, unde …
reprezintă numărul de operații calculat mai sus. Spre exemplu, pentru primul algoritm din exemplul de mai sus, spunem că are complexitatea O(n)
.
Notația cu O(…)
— numită Big O Notation — se referă la cel mai defavorabil caz din algoritm. Spre exemplu, dacă am avea următoarea structură repetitivă care se oprește atunci când găsește un divizor:
for(int i = 2; i <= n; i++) {
if(n % i == 0)
break;
}
Chiar dacă există cazuri în care codul se oprește instant (n = 2
, spre exemplu) sau foarte rapid (n = 3
), vom lua cel mai rău caz posibil, atunci când n
este un număr prim — astfel că vom nota complexitatea algoritmului cu O(n)
.
În imaginea de mai jos se observă clar diferența complexităților în funcție de un anumit număr n
:
Vom lua fiecare tip de complexitate în parte și vom discuta despre aceasta.
Complexitatea O(1)
(constantă) reprezintă faptul că algoritmul execută instant (dintr-o singură operație) codul. Iată niște exemple de complexități constante:
a + b
;1 + 2 + 3 + … + n
: aceasta se află instant cu operația n * (n + 1) / 2
;n % 2
.Complexitatea logaritmică O(log n)
sau O(log2 n)
sugerează un algoritm prin care se întâmplă împărțiri repetate la 2
a numărului de elemente considerate. În acest sens, exemple relevante sunt:
(1, n)
se împarte repetat la 2
, ajungându-se în final la un singur element;Complexitatea radical O(sqrt(n))
sau O(sqrt n)
apare în algoritmi în care setul de date se împarte în mai multe seturi. În acest sens, avem ca exemplu:
1
la sqrt(n)
.Pe departe cea mai întâlnită complexitate, complexitate liniară O(n)
execută același număr de operații cu lungimea setului de date n
. Exemple de complexități liniare se găsesc în algoritmii:
Complexitatea O(n log n)
apare în cazul metodelor eficiente de sortare, precum:
Complexitatea pătratică O(n * n)
sau O(n2)
se folosește în cazul algoritmilor în care setul de date se parcurge de n
ori. În acest sens, întâlnim următoarele exemple:
n
;Această complexitate se folosește în algoritmi ineficienți sau bruți. Complexități polinomiale pot fi O(n2), O(n3), …
.
Complexități de tipul O(n!)
apar în algoritmi ineficienți sau bruți. Iată câteva exemple:
În algoritmi se pot îmbina complexitățile menționate mai sus, sau pot chiar să formeze complexități noi. Spre exemplu, algoritmul de generare a permutărilor are complexitatea O(n!)
, iar operația de afișare a elementelor unui vector are complexitatea O(n)
, astfel că un algoritm care calculează permutările unei submulțimi și afișează soluțiile are complexitatea O(n! * n)
.