Algoritmi și structuri de date
Aprofundarea conceptelor algoritmice și a structurilor de date fundamentale folosite în rezolvarea problemelor.
Bacalaureat
Tablouri unidimensionale și bidimensionale
Un tablou este o structură de date care păstrează, sub un singur nume, mai multe valori de același tip, așezate la poziții numerotate. Poziția unei valori se numește indice.
- Tabloul unidimensional (vectorul) — o singură linie de valori: `v[0], v[1], ..., v[n-1]`. În C++ indexarea începe de la 0, capcană clasică: ultimul element al unui vector cu n elemente este `v[n-1]`, nu `v[n]`.
- Tabloul bidimensional (matricea) — valori organizate pe linii și coloane: `a[i][j]` este elementul de pe linia i, coloana j.
La o matrice pătratică de dimensiune n contează pozițiile speciale, cerute constant la BAC:
- diagonala principală: elementele cu `i == j`;
- diagonala secundară: elementele cu `i + j == n - 1`;
- deasupra diagonalei principale: `i < j`; sub ea: `i > j`.
Parcurgerea completă a unei matrice se face cu două instrucțiuni for imbricate: cea exterioară fixează linia, cea interioară trece prin coloanele liniei respective. Dacă inversezi rolurile, parcurgi pe coloane — rezultatul e același ca mulțime de elemente, dar ordinea vizitării diferă, ceea ce contează la probleme de afișare.
Șiruri de caractere
Un șir de caractere este un vector ale cărui elemente sunt caractere, terminat în C++ (stil C) cu caracterul nul `\0` — un marcaj invizibil care spune unde se termină șirul. Lungimea utilă a șirului nu include acest terminator.
Funcțiile clasice din biblioteca `cstring`, cerute la examen:
- strlen(s) — returnează numărul de caractere până la terminator (nu dimensiunea vectorului!);
- strcpy(d, s) — copiază șirul s peste d;
- strcat(d, s) — concatenează: lipește s la sfârșitul lui d;
- strcmp(a, b) — compară lexicografic: returnează 0 dacă șirurile sunt egale, o valoare negativă dacă a este înaintea lui b în ordine lexicografică, pozitivă altfel;
- strchr(s, c) — caută prima apariție a caracterului c și returnează adresa ei (sau NULL).
Confuzia clasică: `strcmp(a, b) == 0` înseamnă șiruri egale, nu diferite — mulți elevi citesc 0 ca „fals” și inversează condiția. Alt detaliu de punctaj: compararea a două șiruri cu operatorul `==` compară adresele, nu conținutul.
Caracterele au coduri ASCII consecutive pe litere: transformarea literă mare → literă mică se face adunând diferența `'a' - 'A'` (32).
Subprograme: funcții și proceduri
Un subprogram este o porțiune de program cu nume propriu, care rezolvă o sarcină bine delimitată și poate fi apelată ori de câte ori e nevoie. Avantaje: evită duplicarea codului, ușurează testarea și permite lucrul modular.
În pseudocodul de BAC și în C++ distingem:
- funcții care returnează o valoare (în C++ au tip de retur diferit de `void`) — apelul lor apare într-o expresie;
- proceduri (funcții `void`) — execută acțiuni, nu returnează nimic prin nume.
Termeni pe care examenul îi cere exact:
- parametri formali — cei din antetul subprogramului, nume locale folosite în definiție;
- parametri efectivi (actuali) — valorile sau variabilele din apel.
Transmiterea parametrilor:
- prin valoare — subprogramul primește o copie; modificările NU se văd în programul apelant;
- prin referință (în C++ cu `&` în antet) — subprogramul lucrează direct pe variabila originală; modificările rămân după apel.
O variabilă locală există doar pe durata execuției subprogramului; o variabilă globală e vizibilă în tot programul. Dacă o variabilă locală are același nume cu una globală, în interiorul subprogramului locala o ascunde pe cea globală — sursă frecventă de răspunsuri greșite la subiectele de urmărire a codului.
Recursivitate
Un subprogram este recursiv dacă se apelează pe el însuși, direct sau indirect. Orice recursivitate corectă are două componente obligatorii:
- cazul de bază — condiția de oprire, pentru care rezultatul se dă direct, fără alt apel;
- cazul general — problema se reduce la o instanță mai mică a ei însăși.
Fără caz de bază (sau cu o reducere care nu se apropie de el), apelurile se acumulează la nesfârșit și programul se oprește cu depășire de stivă (stack overflow), pentru că fiecare apel ocupă memorie pe stiva de execuție.
Exemple canonice: factorialul (`f(n) = n * f(n-1)`, cu `f(0) = 1`), suma cifrelor unui număr, cel mai mare divizor comun prin resturi (`cmmdc(a, b) = cmmdc(b, a % b)`, cu `cmmdc(a, 0) = a`).
La subiectele de urmărire, cheia este ordinea efectelor față de apelul recursiv: o afișare pusă înainte de apel produce valorile în ordinea coborârii (de exemplu cifrele unui număr de la ultima către prima), iar una pusă după apel produce valorile în ordinea întoarcerii din apeluri — adesea exact ordinea inversă. Elevii care ignoră acest detaliu inversează sistematic rezultatul.
Orice algoritm recursiv poate fi rescris iterativ (cu cicluri), uneori cu o stivă gestionată explicit; alegerea ține de claritate și de consumul de memorie.
Complexitatea algoritmilor — noțiuni introductive
Complexitatea timp a unui algoritm măsoară cum crește numărul de operații în funcție de dimensiunea datelor de intrare n — nu timpul în secunde, care depinde de calculator. Se exprimă cu notația O (ordinul de mărime), reținând doar termenul dominant, fără constante:
- O(1) — timp constant: accesul la un element de tablou prin indice;
- O(log n) — logaritmic: căutarea binară, care înjumătățește zona de căutare la fiecare pas;
- O(n) — liniar: parcurgerea unui vector, căutarea secvențială;
- O(n log n) — sortările eficiente prin interclasare sau QuickSort în cazul mediu;
- O(n²) — pătratic: două cicluri imbricate pe același vector, sortările elementare (selecție, inserție, bule).
Practic, diferența e uriașă: pentru n = 1.000.000, un algoritm O(n²) face aproximativ 10¹² operații (minute sau ore), pe când unul O(n log n) face în jur de 2·10⁷ (o fracțiune de secundă).
Greșeala tipică: a confunda complexitatea cu numărul de linii de cod. Un program scurt cu două for-uri imbricate este O(n²); unul lung, dar cu o singură parcurgere, rămâne O(n). Există și complexitate spațiu — memoria suplimentară folosită — care se analizează cu aceeași notație.
Algoritmi de căutare și sortare
Căutarea secvențială verifică elementele pe rând, de la primul la ultimul: funcționează pe orice vector, sortat sau nu, în timp O(n).
Căutarea binară cere obligatoriu un vector sortat: compară valoarea căutată cu elementul din mijloc, apoi continuă doar în jumătatea care mai poate conține valoarea. La fiecare pas zona se înjumătățește, deci timpul este O(log n). Condiția de vector sortat este exact detaliul pe care grilele îl testează — aplicată pe un vector nesortat, căutarea binară dă rezultate greșite.
Sortări elementare, toate O(n²):
- selecție — la pasul i se caută minimul din zona nesortată și se aduce pe poziția i; face puține interschimbări (cel mult n-1);
- inserție — fiecare element nou se inserează la locul lui în zona deja sortată din stânga; foarte rapidă pe vectori aproape sortați;
- metoda bulelor (bubble sort) — compară și interschimbă perechi vecine, repetând parcurgerile până când nu mai are loc nicio interschimbare.
Sortări eficiente, O(n log n):
- interclasarea (MergeSort) — împarte vectorul în două jumătăți, le sortează recursiv, apoi le interclasează; garantează O(n log n) în orice caz, dar folosește memorie suplimentară;
- QuickSort — alege un pivot și rearanjează elementele în mai mici, respectiv mai mari decât pivotul; în medie O(n log n), dar în cazul cel mai defavorabil O(n²).
Operația de interclasare a doi vectori deja sortați într-unul singur sortat se face într-o singură parcurgere paralelă, în timp O(m + n) — subiect frecvent de sine stătător la BAC.
De reținut
- tablou (vector)
- structură de date care memorează, sub un singur nume, un număr fix de valori de același tip, accesibile prin indice; în C++ indexarea începe de la 0
- diagonala principală
- în matricea pătratică, mulțimea elementelor a[i][j] cu i egal cu j; diagonala secundară are i + j = n - 1
- parametri formali
- parametrii din antetul subprogramului, folosiți ca nume locale în definiția lui; cei din apel se numesc parametri efectivi (actuali)
- transmitere prin valoare
- mecanism prin care subprogramul primește o copie a parametrului efectiv, astfel încât modificările făcute în subprogram nu se propagă în programul apelant
- transmitere prin referință
- mecanism prin care subprogramul lucrează direct pe variabila transmisă, deci modificările rămân vizibile după apel; în C++ se marchează cu & în antet
- recursivitate
- tehnica prin care un subprogram se apelează pe el însuși, obligatoriu cu un caz de bază care oprește apelurile și un caz general care reduce problema
- complexitate O(n)
- ordin de creștere al numărului de operații al unui algoritm în funcție de dimensiunea n a datelor, reținând doar termenul dominant, fără constante
- căutare binară
- algoritm de căutare într-un vector obligatoriu sortat, care compară cu elementul din mijloc și înjumătățește zona de căutare la fiecare pas; complexitate O(log n)
- interclasare
- operația de combinare a doi vectori sortați într-un singur vector sortat, printr-o parcurgere paralelă, în timp O(m + n)
- strlen
- funcție din cstring care returnează numărul de caractere ale unui șir până la terminatorul nul, fără a include terminatorul
Greșeli frecvente
Greșit: Accesarea elementului v[n] într-un vector cu n elemente declarate
Corect: Indexarea începe de la 0, deci elementele valide sunt v[0] ... v[n-1]; v[n] este în afara vectorului și produce comportament imprevizibil
Greșit: Interpretarea lui strcmp(a, b) == 0 ca „șirurile sunt diferite”
Corect: strcmp returnează 0 exact când șirurile sunt egale; valorile negative sau pozitive indică ordinea lexicografică, nu egalitatea
Greșit: Aplicarea căutării binare pe un vector nesortat
Corect: Căutarea binară funcționează corect doar pe vectori sortați; pe date nesortate poate rata elemente existente — întâi se sortează, apoi se caută
Greșit: Așteptarea ca un parametru transmis prin valoare să rămână modificat după apel
Corect: Prin valoare se modifică doar copia locală; pentru ca modificarea să se vadă în apelant, parametrul trebuie transmis prin referință (cu & în C++)
Greșit: Scrierea unei funcții recursive fără caz de bază corect
Corect: Fără o condiție de oprire care e atinsă garantat, apelurile se acumulează până la depășirea stivei; cazul de bază se scrie și se verifică primul
Test — 6 întrebări ca la examen
1. Într-o matrice pătratică a cu n linii și n coloane, indexată de la 0, elementele diagonalei secundare sunt cele pentru care:
- i == j
- i + j == n - 1
- i + j == n
- i - j == 1
Vezi răspunsul
i + j == n - 1. Pe diagonala secundară, când linia crește, coloana scade, iar suma indicilor este constantă: n - 1 la indexarea de la 0. Varianta i == j descrie diagonala principală — cea mai frecventă confuzie; i + j == n ar fi corectă doar la indexarea de la 1.
2. Ce valoare returnează strlen(s) pentru șirul s care conține cuvântul bacalaureat?
- 12, pentru că include terminatorul nul
- 11, numărul de caractere fără terminator
- dimensiunea vectorului în care e memorat șirul
- poziția ultimului caracter, adică 10
Vezi răspunsul
11, numărul de caractere fără terminator. strlen numără caracterele până la terminatorul nul, fără să-l includă: bacalaureat are 11 litere. Distractorul cu 12 e tentant pentru că terminatorul chiar ocupă un octet în memorie, dar el nu face parte din lungimea șirului.
3. Un subprogram primește parametrul x prin valoare și execută x = x + 1. Dacă în apelant variabila a avea valoarea 5 înainte de apelul cu parametrul a, după apel a este:
- 6, pentru că subprogramul a modificat-o
- 5, pentru că s-a modificat doar copia locală
- 0, pentru că variabila locală se reinițializează
- nedefinită, pentru că x nu mai există după apel
Vezi răspunsul
5, pentru că s-a modificat doar copia locală. Prin valoare, subprogramul primește o copie: incrementarea afectează copia, care dispare la finalul apelului, iar a rămâne 5. Răspunsul 6 ar fi corect doar la transmiterea prin referință — exact distincția pe care o testează subiectul.
4. Pentru funcția recursivă f(n) care returnează 1 dacă n == 0 și n * f(n - 1) altfel, valoarea f(4) este:
- 10
- 16
- 24
- 120
Vezi răspunsul
24. f calculează factorialul: f(4) = 4 * 3 * 2 * 1 = 24. Distractorul 120 este 5!, greșeala celor care numără un apel în plus; 10 este suma 1+2+3+4, confuzia dintre produs și sumă în relația de recurență.
5. Care dintre următorii algoritmi are complexitatea O(log n)?
- căutarea secvențială într-un vector
- căutarea binară într-un vector sortat
- sortarea prin selecție
- parcurgerea unei matrice pătratice de ordin n
Vezi răspunsul
căutarea binară într-un vector sortat. Căutarea binară înjumătățește zona de căutare la fiecare comparație, deci face aproximativ log2(n) pași. Căutarea secvențială — distractorul apropiat — verifică elementele pe rând, deci este O(n); selecția e O(n²), iar parcurgerea matricei O(n²).
6. Se interclasează doi vectori sortați crescător, cu m, respectiv n elemente, într-un singur vector sortat. Numărul de comparații este de ordinul:
- O(m * n), pentru că fiecare element se compară cu toate celelalte
- O((m + n) log(m + n)), pentru că rezultatul trebuie sortat
- O(m + n), printr-o singură parcurgere paralelă a celor doi vectori
- O(log(m + n)), prin înjumătățiri succesive
Vezi răspunsul
O(m + n), printr-o singură parcurgere paralelă a celor doi vectori. Interclasarea avansează câte un indice în fiecare vector și fiecare comparație plasează definitiv un element, deci totalul este liniar, O(m + n). Distractorul cu sortare O((m+n)log(m+n)) ignoră exact avantajul interclasării: vectorii sunt deja sortați, nu mai e nevoie de nicio sortare.
Deschide varianta interactivă — cu AI care îți explică