De ce contează?
Vrei să găsești cuvântul „recursivitate" într-un dicționar de 1000 de pagini. Ai două variante: răsfoiești pagină cu pagină de la început (în cel mai rău caz, 1000 de pagini) sau deschizi la mijloc, vezi dacă litera căutată e înainte sau după și arunci jumătate din dicționar la fiecare pas (doar vreo 10 deschideri). Ambele „funcționează" pe un dicționar mic. Dar mărește dicționarul la un milion de pagini: prima variantă urcă la un milion de pași, a doua doar la 20. Despre exact asta e complexitatea: nu cât durează acum, ci cât de repede crește efortul când problema se mărește.
Ideea-cheie
Nu te interesează secundele — calculatorul tău e mai rapid sau mai lent decât
al juriului, iar secundele depind de mașină. Te interesează numărul de operații
elementare pe care le face algoritmul, exprimat în funcție de mărimea intrării
n. Asta măsoară notația „O mare": cum crește efortul când n crește.
Iată cele câteva clase pe care le vei întâlni în 99% din probleme, de la cea mai ieftină la cea mai scumpă:
O(1)— constant: același număr de operații indiferent den(ex. citeștia[i], faci o adunare). Nu depinde deloc de cât de mare e șirul.O(log n)— logaritmic: la fiecare pas tai problema în jumătate (căutarea binară, deschiderea dicționarului la mijloc). Pentrun = 1.000.000, log n e în jur de 20. Crește atât de încet încât e aproape gratis.O(n)— liniar: parcurgi intrarea o dată (o singură buclă peste șir).O(n log n)— pragul sortărilor bune (std::sort): o parcurgere, dar multiplicată cu un factor logaritmic mic.O(n²)— pătratic: două bucle imbricate, compari fiecare element cu fiecare. Sare în aer pentrunmare.
Cheia mentală: numeri operațiile în funcție de n, nu secundele. O buclă simplă
peste șir e O(n). O buclă în interiorul alteia, fiecare până la n, e O(n²) —
pentru că pentru fiecare din cele n valori ale buclei exterioare faci n pași
în cea interioară: n × n operații.
Cum alegi
Există un singur criteriu practic, pe care îl folosesc toți olimpicii înainte să scrie cod:
- Citește din enunț cât de mare poate fi
n(de obicei e scris explicit:n ≤ 100.000). - Estimează numărul de operații pe care îl face soluția ta pentru acel
n(substituinîn formula complexității). - Compară cu bugetul: un calculator de concurs face cam 10^8 operații pe secundă (o sută de milioane). Dacă limita e 1 secundă și estimarea ta depășește mult 10^8 — vei lua TLE (Time Limit Exceeded). Atunci ai nevoie de un algoritm mai bun.
Concret, pune numere în formule. Iată câte operații ies pentru fiecare clasă, la
trei valori uzuale ale lui n:
| n | O(n) | O(n log n) | O(n²) |
|---|---|---|---|
| 1.000 (10^3) | 1.000 | ~10.000 | 1.000.000 (10^6) |
| 100.000 (10^5) | 100.000 | ~1.700.000 | 10.000.000.000 (10^10) |
| 1.000.000 (10^6) | 1.000.000 | ~20.000.000 | 10^12 |
Cum citești tabelul față de bugetul de 10^8 operații pe secundă:
O(n)șiO(n log n): chiar și lan = 1.000.000rămân sub sau în jurul lui 10^8. Sigur intră în 1 secundă.O(n²)lan = 1.000: doar 10^6 operații — e ok, sub buget cu mult.O(n²)lan = 100.000: 10^10 operații. Asta e de o sută de ori peste buget — ar dura ~100 de secunde. TLE garantat.
Așa că regula de buzunar: dacă vezi n ≤ 1.000 (sau câteva mii), un algoritm
O(n²) trece liniștit. Dacă vezi n ≤ 100.000 sau mai mult, O(n²) e mort din
start — trebuie să cobori la O(n log n) (sortare) sau O(n).
Mărimea lui n din enunț e un indiciu despre complexitatea așteptată. Juriul
alege n ca să elimine soluțiile slabe. n ≤ 1.000 îți șoptește „un O(n²) e
acceptat". n ≤ 1.000.000 îți strigă „trebuie O(n) sau O(n log n), nimic mai
scump". Citește n înainte să te gândești la algoritm.
Un exemplu de decizie. Problemă: „dat un șir de n numere, există două care se
adună la S?"
- Soluția naivă: pentru fiecare pereche
(i, j)verifici dacăa[i] + a[j] == S. Două bucle imbricate —O(n²). - Dacă
n ≤ 1.000:O(n²)= 10^6 operații. Scrie-o liniștit, e cea mai simplă. - Dacă
n ≤ 200.000:O(n²)= 4 × 10^10 — TLE. Sortezi șirul (O(n log n)) și folosești doi indici de la capete, sau cauți binar complementul. Estimarea de operații ți-a spus înainte să scrii cod că naiva nu e bună.
Iată cum arată cele două bucle care fac diferența între O(n) și O(n²):
#include <iostream>
using namespace std;
int main() {
int n = 100000;
long long operatii = 0;
// O(n): o singura parcurgere -> n operatii
for (int i = 0; i < n; i++) {
operatii++;
}
// aici operatii == n == 100000 (sub buget)
operatii = 0;
// O(n^2): bucla in bucla -> n * n operatii
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
operatii++; // se executa de n*n ori
}
}
// aici operatii == 10000000000 (10^10) -> mult peste 10^8 -> TLE
cout << operatii << "\n";
return 0;
}Diferența nu e în cod (ambele par „doar niște bucle") — e în câte operații
se execută. Bucla dublă nu costă de două ori mai mult decât una simplă, ci de
n ori mai mult.
Cea mai scumpă greșeală de concurs: scrii o soluție O(n²) corectă și ești
convins că e bună pentru că „dă rezultatul corect pe exemple". Dar la n = 100.000
înseamnă 10^10 operații — de o sută de ori peste bugetul de 10^8/secundă, deci
TLE. Soluția e corectă, dar primește 0 puncte. Estimează înainte să scrii:
substitui n-ul maxim din enunț în formula complexității și compari cu 10^8.
A doua capcană, mai subtilă: a confunda O(log n) cu O(1). Nu sunt la fel —
O(log n) crește cu n (lent, dar crește: pentru n = 1.000.000 e ~20 de
pași), pe când O(1) rămâne fix indiferent de n. În practică amândouă sunt
foarte rapide și rar fac diferența între AC și TLE, dar la analiză nu le amesteca:
o căutare binară e O(log n), nu O(1).