Structuri de date avansate
Studiul structurilor de date dinamice și al modalităților de reprezentare și prelucrare a acestora.
Bacalaureat
Pointeri și alocare dinamică
Un pointer este o variabilă care memorează adresa altei variabile, nu valoarea ei. În C++, `int p;` declară un pointer către int, `&x` este adresa variabilei x, iar `p` (operatorul de dereferențiere) este valoarea de la adresa memorată în p. Confuzia care costă puncte: p este adresa, *p este valoarea — a le amesteca schimbă complet sensul unei atribuiri.
Memoria unui program are două zone cu comportament diferit:
- stiva de execuție — aici trăiesc variabilele locale; se eliberează automat la ieșirea din funcție;
- heap-ul (memoria liberă) — aici se face alocarea dinamică, la cerere, în timpul execuției: `p = new int;` sau `p = new int[n];` pentru un tablou a cărui dimensiune se află abia la rulare.
Ce se alocă cu `new` trebuie eliberat explicit cu `delete p;` respectiv `delete[] p;`. Neeliberarea produce scurgeri de memorie (memory leak): memoria rămâne ocupată deși nu mai e folosită. Alte două erori clasice: pointerul invalid (dangling) — folosirea unui pointer după ce zona lui a fost eliberată — și dereferențierea unui pointer nul (`NULL` sau `nullptr`), care oprește programul.
Alocarea dinamică este fundamentul tuturor structurilor din acest capitol: liste, stive, cozi și arbori cresc și scad în timpul execuției exact pentru că nodurile lor se creează cu new, pe măsură ce e nevoie.
Liste liniare înlănțuite
O listă simplu înlănțuită este o înșiruire de noduri alocate dinamic, fiecare nod conținând o informație utilă și un pointer către nodul următor. Primul nod se numește cap (prim), iar câmpul următor al ultimului nod este NULL — semnul de sfârșit.
Comparația cu vectorul, cerută frecvent:
- lista nu are dimensiune fixă și inserarea/ștergerea la o poziție cunoscută se face în O(1), doar prin mutarea a două legături, fără deplasarea celorlalte elemente;
- în schimb, lista nu are acces direct prin indice: pentru al k-lea element trebuie parcurse k noduri, deci accesul este O(n), iar căutarea binară este imposibilă.
Operațiile de bază și capcanele lor:
- inserarea la început: nodul nou arată spre vechiul cap, apoi capul devine nodul nou — în această ordine; invers, se pierde restul listei;
- ștergerea unui nod: legătura predecesorului se mută peste nodul șters, iar nodul se eliberează cu delete; ștergerea capului mută mai întâi capul pe al doilea nod;
- parcurgerea: un pointer de lucru pornește de la cap și avansează cu `p = p->urm` cât timp p nu e NULL.
Lista dublu înlănțuită adaugă fiecărui nod un pointer către predecesor, permițând parcurgerea în ambele sensuri și ștergerea unui nod fără căutarea predecesorului — cu prețul unei legături suplimentare de întreținut la fiecare operație. Lista circulară leagă ultimul nod înapoi la primul; la parcurgere, condiția de oprire nu mai este NULL, ci revenirea la nodul de start.
Stiva
Stiva (stack) este o structură de date de tip LIFO — Last In, First Out: ultimul element introdus este primul extras. Modelul intuitiv: un teanc de farfurii — pui și iei doar de deasupra.
Operațiile permise, singurele:
- push(x) — adaugă elementul x în vârful stivei;
- pop() — elimină elementul din vârf;
- top() — consultă elementul din vârf fără să-l elimine;
- testul de stivă vidă — obligatoriu înaintea oricărui pop sau top, altfel apare eroarea de subdepășire (underflow).
Esența restricției: nu ai acces decât la vârf. Nu poți citi sau scoate un element din mijlocul stivei fără să scoți mai întâi tot ce e deasupra lui. Grilele exploatează exact această regulă: dacă în stivă s-au introdus, în ordine, 1, 2, 3, extragerea le produce în ordinea 3, 2, 1.
Implementare: pe vector (cu o variabilă care reține indicele vârfului) sau pe listă înlănțuită (push și pop la începutul listei, ambele O(1)).
Aplicații de reținut pentru examen: inversarea unei secvențe, verificarea parantezărilor corecte (fiecare paranteză deschisă se pune pe stivă, fiecare închisă trebuie să se potrivească cu vârful), evaluarea expresiilor și, esențial, stiva de execuție a apelurilor recursive — motivul pentru care recursivitatea și stiva sunt două fețe ale aceluiași mecanism.
Coada
Coada (queue) este structura de tip FIFO — First In, First Out: primul element introdus este primul extras, exact ca la o coadă la casă. Este imaginea în oglindă a stivei, iar perechea LIFO/FIFO este verificată aproape garantat în teste.
Operațiile specifice:
- adăugarea se face întotdeauna la sfârșitul cozii;
- extragerea se face întotdeauna de la începutul cozii;
- consultarea primului element și testul de coadă vidă completează setul.
Dacă în coadă intră, în ordine, 1, 2, 3, ele ies în ordinea 1, 2, 3 — ordinea se păstrează, spre deosebire de stivă, care o inversează.
Implementarea pe vector are o subtilitate: extragerile de la început lasă spațiu nefolosit în față. Soluția standard este coada circulară, în care indicii de început și sfârșit înaintează modulo dimensiunea vectorului, reciclând pozițiile eliberate. Implementarea pe listă înlănțuită ține doi pointeri, către primul și către ultimul nod, ambele operații fiind O(1).
Aplicații: parcurgerea în lățime (BFS) a grafurilor și arborilor — coada este chiar inima algoritmului —, planificarea proceselor în sistemele de operare, tampoanele (buffer) de tipărire, simulările de fire de așteptare. Regula de alegere între cele două structuri: ai nevoie de revenire în ordine inversă → stivă; ai nevoie de servire în ordinea sosirii → coadă.
Arbori binari
Un arbore binar este o structură ierarhică formată din noduri, în care fiecare nod are cel mult doi descendenți: un fiu stâng și un fiu drept. Nodul din vârf, fără părinte, este rădăcina; nodurile fără niciun fiu sunt frunze. Înălțimea arborelui este numărul de niveluri (sau, în alte convenții, numărul de muchii pe cel mai lung drum de la rădăcină la o frunză — la examen se lucrează cu convenția din enunț).
Cele trei parcurgeri în adâncime, definite recursiv prin poziția rădăcinii:
- preordine (RSD): rădăcină, subarbore stâng, subarbore drept;
- inordine (SRD): subarbore stâng, rădăcină, subarbore drept;
- postordine (SDR): subarbore stâng, subarbore drept, rădăcină.
Trucul de memorare: prefixul spune unde e Rădăcina — pre = prima, in = la mijloc, post = ultima. Subarborele stâng vine mereu înaintea celui drept.
Arborele binar de căutare (ABC) adaugă regula de ordine: în subarborele stâng al oricărui nod stau doar valori mai mici, în cel drept doar valori mai mari. Consecința de aur, cerută la examen: parcurgerea în inordine a unui ABC produce valorile în ordine crescătoare. Căutarea unei valori coboară din rădăcină alegând stânga sau dreapta după comparație — în un arbore echilibrat, O(log n) pași; degenerat în „listă” (inserții în ordine crescătoare), O(n).
Reprezentarea în memorie folosește noduri alocate dinamic cu doi pointeri (stâng, drept), iar parcurgerile se implementează natural recursiv.
Grafuri: reprezentare și parcurgere
Un graf neorientat G = (V, E) este format dintr-o mulțime de vârfuri (noduri) și o mulțime de muchii — perechi neordonate de vârfuri. La graful orientat, legăturile sunt arce — perechi ordonate, cu sens. Gradul unui vârf este numărul de muchii incidente lui; suma gradelor tuturor vârfurilor este dublul numărului de muchii — identitate cerută des în grile.
Reprezentări:
- matricea de adiacență — matrice n×n cu a[i][j] = 1 dacă există muchie între i și j; pentru grafuri neorientate este simetrică; ocupă O(n²) memorie, dar răspunde în O(1) la întrebarea „există muchia (i, j)?”;
- listele de adiacență — pentru fiecare vârf, lista vecinilor săi; ocupă O(n + m) și e preferată la grafuri rare.
Un lanț (drum) este o succesiune de vârfuri legate prin muchii; un ciclu este un lanț care se întoarce în vârful de plecare. Un graf este conex dacă între oricare două vârfuri există un lanț; porțiunile conexe maximale se numesc componente conexe. Un arbore este exact un graf conex fără cicluri, cu n - 1 muchii.
Parcurgeri:
- BFS (în lățime) — folosește o coadă: vizitează întâi toate vârfurile la distanță 1 de start, apoi cele la distanța 2 etc.; găsește drumurile cu număr minim de muchii;
- DFS (în adâncime) — folosește stiva (sau recursivitatea): înaintează pe o ramură cât de departe se poate, apoi revine.
Ambele vizitează fiecare vârf o singură dată, cu ajutorul unui vector de marcaje vizitat; fără el, parcurgerea ciclează la nesfârșit pe grafuri cu cicluri. Asocierea de reținut: BFS ↔ coadă, DFS ↔ stivă — inversarea lor este greșeala standard.
De reținut
- pointer
- variabilă care memorează adresa altei variabile; operatorul & furnizează adresa, iar * (dereferențierea) accesează valoarea de la adresa memorată
- alocare dinamică
- rezervarea de memorie în heap în timpul execuției, cu operatorul new; memoria astfel obținută trebuie eliberată explicit cu delete
- listă simplu înlănțuită
- structură dinamică formată din noduri alocate dinamic, fiecare conținând informația utilă și un pointer către nodul următor; ultimul nod are legătura NULL
- stivă (LIFO)
- structură de date în care inserarea (push) și extragerea (pop) se fac la același capăt, vârful; ultimul element introdus este primul extras
- coadă (FIFO)
- structură de date în care adăugarea se face la sfârșit și extragerea de la început; primul element introdus este primul extras
- arbore binar de căutare
- arbore binar în care, pentru orice nod, subarborele stâng conține doar valori mai mici, iar cel drept doar valori mai mari; inordinea îl parcurge crescător
- parcurgere în inordine
- parcurgerea arborelui binar în ordinea: subarbore stâng, rădăcină, subarbore drept; aplicată unui arbore binar de căutare, produce valorile sortate crescător
- matrice de adiacență
- matrice n×n asociată unui graf, cu 1 pe poziția (i, j) dacă există muchie între vârfurile i și j; simetrică pentru grafurile neorientate
- graf conex
- graf neorientat în care între oricare două vârfuri există cel puțin un lanț; porțiunile conexe maximale ale unui graf se numesc componente conexe
- BFS (parcurgere în lățime)
- parcurgere a grafului care folosește o coadă și vizitează vârfurile în ordinea crescătoare a distanței față de vârful de start
Greșeli frecvente
Greșit: Confundarea pointerului p cu valoarea *p în atribuiri
Corect: p memorează o adresă, *p este valoarea de la acea adresă; p = q copiază adrese (ambii pointeri arată spre același loc), *p = *q copiază valori
Greșit: La inserarea în capul listei, mutarea capului înainte de a lega nodul nou
Corect: Întâi nodul nou primește legătura către vechiul cap (nou->urm = prim), abia apoi prim = nou; în ordine inversă se pierde accesul la restul listei
Greșit: Inversarea comportamentelor: stiva ar fi FIFO și coada LIFO
Corect: Stiva este LIFO (ultimul intrat, primul ieșit), coada este FIFO (primul intrat, primul ieșit); introduse 1, 2, 3, stiva le scoate 3, 2, 1, coada le scoate 1, 2, 3
Greșit: Parcurgerea unui graf cu cicluri fără vector de vizitare
Corect: Fără marcarea vârfurilor deja vizitate, BFS și DFS revin la nesfârșit în aceleași vârfuri; vectorul vizitat se verifică înainte de fiecare adăugare în coadă sau stivă
Greșit: Asocierea BFS cu stiva și DFS cu coada
Corect: Este exact invers: BFS folosește coada (explorare pe niveluri), DFS folosește stiva sau recursivitatea (explorare în adâncime pe o ramură)
Test — 6 întrebări ca la examen
1. Într-o stivă inițial vidă se execută: push(4), push(7), pop(), push(9), push(2), pop(). Elementul aflat în vârful stivei este:
- 2
- 4
- 7
- 9
Vezi răspunsul
9. După push(4), push(7) stiva e 4, 7; pop() scoate 7; push(9), push(2) o fac 4, 9, 2; ultimul pop() scoate 2, deci vârful rămâne 9. Răspunsul 2 e al celor care uită că ultimul pop l-a eliminat deja.
2. Într-o coadă inițial vidă intră, în ordine, elementele 5, 8, 1, 6. După două extrageri, primul element al cozii este:
- 1
- 6
- 8
- 5
Vezi răspunsul
1. Coada e FIFO: extragerile scot 5, apoi 8, deci în față rămâne 1. Răspunsul 6 ar corespunde unei stive (LIFO), unde s-ar scoate ultimele intrate — exact confuzia dintre cele două structuri.
3. Avantajul esențial al listei simplu înlănțuite față de vector este:
- accesul direct la al k-lea element în O(1)
- posibilitatea căutării binare în O(log n)
- inserarea și ștergerea la o poziție cunoscută fără deplasarea celorlalte elemente
- ocuparea unei zone mai mici de memorie pentru același număr de elemente
Vezi răspunsul
inserarea și ștergerea la o poziție cunoscută fără deplasarea celorlalte elemente. În listă, inserarea sau ștergerea înseamnă doar mutarea a două legături, pe când vectorul cere deplasarea tuturor elementelor următoare. Primele două variante sunt exact avantajele vectorului — lista nu are acces direct, deci nici căutare binară; iar memoria e mai mare, nu mai mică, din cauza pointerilor.
4. Parcurgerea în inordine a unui arbore binar de căutare produce valorile:
- în ordinea inserării lor
- în ordine crescătoare
- în ordine descrescătoare
- începând cu rădăcina
Vezi răspunsul
în ordine crescătoare. Inordinea vizitează întâi subarborele stâng (valori mai mici), apoi rădăcina, apoi subarborele drept (valori mai mari), deci într-un ABC rezultatul e sortat crescător. Varianta care începe cu rădăcina descrie preordinea — distractorul clasic dintre cele trei parcurgeri.
5. Un graf neorientat are 6 vârfuri cu gradele 2, 3, 1, 2, 3, 1. Numărul de muchii este:
- 12
- 6
- 5
- nu poate fi determinat doar din grade
Vezi răspunsul
6. Suma gradelor este 12, iar fiecare muchie contribuie cu exact 2 la această sumă, deci numărul de muchii este 12 / 2 = 6. Răspunsul 12 este al celor care uită împărțirea la 2 — cea mai frecventă scăpare la această identitate.
6. La parcurgerea BFS a unui graf conex pornind din vârful 1, vârfurile sunt vizitate:
- în ordinea crescătoare a numerelor lor, indiferent de muchii
- pe o singură ramură, cât mai adânc, apoi cu revenire
- în ordinea crescătoare a distanței (în muchii) față de vârful 1
- în ordinea descrescătoare a gradelor
Vezi răspunsul
în ordinea crescătoare a distanței (în muchii) față de vârful 1. BFS explorează pe niveluri, cu ajutorul cozii: întâi vecinii direcți ai startului, apoi vârfurile la distanța 2 și așa mai departe — de aceea găsește drumurile cu număr minim de muchii. Varianta cu înaintarea pe o ramură descrie DFS, perechea-capcană a acestei întrebări.
Deschide varianta interactivă — cu AI care îți explică