Laborator 5 - Stivă și Coadă

Obiective

În urma parcurgerii acestui laborator studentul va fi capabil să:

  • înțeleagă principiul de funcționare al structurilor de date stivă și coadă
  • implementeze o stivă și o coadă folosind un vector pentru stocarea elementelor
  • transforme o expresie din forma infixată în formă postfixată
  • să evalueze o expresie în formă postfixată
  • implementeze algoritmul de sortare Radix Sort

Stivă (Stack)

O stivă este o instanță a unui tip de date abstract ce formalizează conceptul de colecție cu acces restricționat. Restricția respectă regula LIFO (Last In, First Out).

Accesul la elementele stivei se face doar prin vârful acesteia.

Operații:

  • void push(struct Stack *stack, void *new_data) – adaugă un element (entitate) în stivă. Adăugarea se poate face doar la vârful stivei.
  • void* pop(struct Stack *stack) – șterge un element din stivă și îl returnează. Ștergerea se poate face doar la vârful stivei.
  • void* peek(struct Stack *stack) – consultă (întoarce) elementul din vârful stivei fără a efectua nicio modificare asupra acesteia.
  • int is_empty(struct Stack *stack) – întoarce 1 dacă stiva este goală; 0 dacă are cel puțin un element

Implementare

O structură de date definește un set de operații și funcționalitatea acestora.
Implementarea efectivă a unei structuri de date poate fi realizată în diverse moduri, cât timp funcționalitatea este păstrată.

O stivă poate fi implementată cu ajutorul unui vector sau cu liste înlănțuite.

În cadrul acestui laborator, ne vom concentra asupra implementării unei stive cu ajutorul unui liste înlănțuite.

Reprezentare internă cu vector

La nivel de implementare, stiva este reprezentată printr-o structură ce folosește un vector de stocare (stack_array) de o dimensiune maximă dată (NMAX) și un indice ce indică vârful stivei (top_level), cât și un set de operații ce pot fi efectuate asupra stivei.

Exemplu de utilizare

Forma poloneză inversă

Forma poloneză inversă este o notație matematică în care fiecare operator urmează după toți operanzii săi.

Cel mai simplu exemplu de notație postfixată este cel pentru doi operanzi și un operator:

5 + 4 se scrie sub forma 5 4 +

În cazul în care există mai multe operații, operatorul apare imediat după cel de-al doilea operand:

2 + 4 - 5 se scrie sub forma 2 4 + 5 -

Avantajul major al formei poloneze inverse este faptul că elimină parantezele din cadrul expresilor:

5 + (1 + 4) se scrie sub forma 5 1 4 + +

Algoritmul de conversie a unei expresii din formă infixată în formă postfixată

1. cât timp există elemente de citit
    1.1 citește un element
    1.2 dacă elementul este un număr, afișare (se adaugă la forma 
        postfixată)
    1.3 dacă elementul este o paranteză stângă, adaugă-l în stivă
    1.4 dacă elementul este o paranteză dreaptă, extrage operatorii din
        stivă și adaugă-i la forma postfixată până când vârful stivei
        ajunge o paranteză stângă (care este extrasă, dar nu este 
        adăugată la forma postfixată).

    !!! dacă stiva s-a golit fără să fie găsită o paranteză stângă, 
        înseamnă că expresia inițială avea paranteze greșite
    1.5 dacă elementul este un operator (fie el O1)
        1.5.1 cât timp există un alt operator în vârful stivei (fie el O2)
        ȘI precedența lui O1 este MAI MICA SAU EGALA decât cea a lui O2, extrage O2
        din stivă, afișare (se adaugă la forma postfixată)
        1.5.2 adaugă O1 în stivă
2. când nu mai există elemente de citit, extrage toate elementele rămase
   în stivă și adaugă-le la forma postfixată (elementele trebuie să fie 
   numai operatori; dacă este extrasă o paranteză stângă expresia inițială
   avea parantezele greșite).

Exemplu

Fie expresia:

1 - 7 * 2 /(3 + 5)^2^5
Element Acțiune Forma postfixată Stiva Observaţii
1 Adaugă element la forma postfixată 1
- Pune elementul în stivă 1 -
7 Adaugă element la forma postfixată 1,7 -
* Pune elementul în stivă 1,7 *
-
* are precedență mai mare decât -
2 Adaugă element la forma postfixată 1,7,2 *
-
/ Extrage element din stivă 1,7,2* - / și * au aceeași prioritate
Pune elementul în stivă /
-
/ are precedență mai mare decât -
( Pune elementul în stivă 1,7,2* (
/
-
3 Adaugă element la forma postfixată 1,7,2*3 (
/
-
+ Pune elementul în stivă 1,7,2*3 +
(
/
-
5 Adaugă element la forma postfixată 1,7,2*3,5 +
(
/
-
) Extrage element din stivă 1,7,2*3,5+ (
/
-
Se repeta până când se întâlnește (
repetă /
-
( a fost ignorat
^ Pune elementul în stivă 1,7,2*3,5+ ^
/
-
^ are precedență mai mare decât /
2 Adaugă element la forma postfixată 1,7,2*3,5+2 ^
/
-
^ Pune elementul în stivă 1,7,2*3,5+2 ^
^
/
-
^ este considerat asociativ-dreapta
5 Adaugă element la forma postfixată 1,7,2*3,5+2,5 ^
^
/
-
Final Extrage toate elementele din stivă 1,7,2*3,5+2,5^^/-

Algoritmul de evaluare a unei expresii în formă postfixată

1. cât timp există elemente de citit
    1.1 citește un element
    1.2 dacă elementul este o valoare
            1.2.1 pune elementul în stivă
        altfel (elementul este un operator)
            1.2.2 extrage 2 operanzi din stivă
            1.2.3 dacă nu există 2 operanzi în stivă
                EROARE: forma postfixată nu este corectă
            1.2.4 evaluează rezultatul aplicării operatorului asupra celor doi
                  operanzi
            1.2.5 pune rezultatul în stivă
2. dacă există o singură valoare în stivă
        2.1 afișează valoarea ca rezultat final al evaluării expresiei
    altfel
        EROARE: forma postfixată nu este corectă

Exemplu de utilizare in sistemele de operare

Sistemele de operare folosesc conceptul de stiva pentru rularea oricarui program prin ceea ce se numeste call stack. Acesta este un mecanism care tine evidenta functiilor care se executa la un moment de timp. Call stack-ul va fi populat cu stack frame-uri, fiecare functie avand propriul frame ce se va distruge la terminarea executiei acesteia. Fiecare frame va stoca mai multe informatii necesare rularii programului precum parametri primiti de functie, variabile locale si adresa de return. La fiecare apel de functie, inainte de a executa codul respectivei functii, procesorul salveaza pe stiva adresa urmatoarei instructiuni de dupa acel apel, astfel incat, la terminarea functiei, sa poata relua executia din punctul corect.

Coadă (Queue)

O coadă este o structură de date organizată după modelul FIFO (First In, First Out): primul element introdus va fi primul eliminat din buffer.

Funcții generale disponibile pentru interacțiunea cu o coadă:

  • void enqueue(struct Queue *queue, void *new_data) – adaugă elementul la sfârșitul cozii. Adăugarea se face doar la sfârșitul cozii.
  • void* dequeue(struct Queue *queue) – șterge un element din coadă și îl returnează. Ștergerea se poate face doar la începutul cozii.
  • void* front(struct Queue *queue) – întoarce primul element din coada fără a-l scoate din aceasta.
  • int is_empty(struct Queue *queue) – întoarce 1 dacă coada este goală; 0 dacă are cel puțin un element

Variante de implementare

O coadă se poate poate implementa folosind pe post de container lista înlănțuită, array de dimensiune fixă, array circular.

Reprezentare internă cu listă înlănțuită

  • void enqueue(struct Queue *queue, void *new_data) - echivalent cu operația de add_last efectuată pe listă. Complexitate: timp constant - O(1)
  • void* dequeue(struct Queue *queue) echivalent cu operația de removeFirst. Complexitate: timp constant - O(1)
  • void* front(struct Queue *queue) presupune returnarea valorii stocate in `head`-ul listei. Complexitate: timp constant - O(1)
  • int is_empty(struct Queue *queue) - la fel ca în cazul listei. Complexitate: timp constant - O(1)

Reprezentare internă cu vector

Vom avea doi indici (head și tail) ce vor reprezenta începutul, respectiv sfârșitul cozii în cadrul vectorului. Apare însă următoarea problemă din punctul de vedere al spațiului neutilizat: întotdeauna spațiul de la 0 la head-1 va fi nefolosit, iar numărul de elemente ce pot fi stocate în coadă va scădea (având inițial N elemente ce pot fi stocate, după ce se extrage prima oară un element, mai pot fi stocate doar N-1 elemente). Vrem ca întotdeauna să putem stoca maxim N elemente.

Soluția: vector circular.

  • void enqueue(struct Queue *queue, void *new_data) - presupune adăugarea noului element la sfârșitul vectorului. Se verifică în prealabil dacă dimensiunea vectorului mai permite adăugarea unui element. Se incrementează tail. Complexitate: O(1)
  • void* dequeue(struct Queue *queue) - “șterge” și întoarce primul element din vector. Se incrementează head. Complexitate: O(1)
  • void* front(struct Queue *queue) - întoarce primul element din vector. Complexitate: O(1)
  • int is_empty(struct Queue *queue) - 1 dacă vectorul nu conține niciun element, 0 în caz contrar. Complexitate: O(1)

Reprezentare internă cu vector circular

La nivel de implementare, coada este reprezentată printr-un vector de stocare (queue_array) de o dimensiune maximă specificată (N), doi indici ce indică începutul (head) și sfârșitul cozii (tail), cât și un set de operații ce pot fi efectuate asupra cozii. De asemenea, se reține și dimensiunea curentă a cozii (size) pentru a putea spune când aceasta este plină sau vidă.

  • void enqueue(struct Queue *queue, void *new_data) - adaugă noul element la sfârșitul vectorului. Se incrementează modulo (dimensiune container) indicele tail. Complexitate: O(1)
  • void* dequeue(struct Queue *queue) - șterge și întoarce primul element din vector. Se incrementează modulo (dimensiune container) indicele head. Complexitate: O(1)
  • void* front(struct Queue *queue) - întoarce primul element din vector. Complexitate: O(1)
  • int is_empty(struct Queue *queue) - la fel ca în celelalte cazuri. Complexitate: O(1)
  • int is_full(struct Queue *queue) - se verifică dacă următorul index după tail (circular) este egal cu head. Dacă da, returnează 1, returnează 0 în caz contrar. Complexitate: O(1)

Exemplu de utilizare a unei cozi (circulare)

Conceptul de CPU scheduling se refera la ordinea in care procesele noastre ruleaza. Deoarece avem mai multe procese decat core-uri, trebuie sa stabilim un algoritm care ghideaza planificarea aceasta: cat timp unele vor rula, altele isi vor astepta randul. Cel mai simplu mecanism de acest tip este folosirea este folosirea unei cozi (circulare), in care toate procesele sunt tratate cu aceeasi prioritate si se executa in ordinea in care au fost introduse in coada. Spunem despre acest tip de algoritm ca este de tip Round Robin.

Pasii sunt urmatorii: Lansam un proces. El este adaugat in coada si primeste o cuanta de timp de nivelul ms (cat timp are voie sa ruleze). Cand se elibereaza un loc pe procesor, se face dequeue, iar procesul scos din coada va rula. Daca isi consuma toata cuanta de timp si inca nu si-a terminat toate instructiunile, este adaugat inapoi in coada, i se reseteaza cuanta si isi asteapta randul.

Alte tipuri de coadă

Double ended queue - Dequeue

Într-o coadă obișnuită accesul la elemente este de tip FIFO - elementele sunt introduse pe la un capăt și scoase la celălalt capăt. În cazul unei Dequeue, se permit ambele operații, la ambele capete. Astfel, în capătul head se pot atât introduce, cât și extrage elemente. La fel și în cazul capătului tail. Se observă că cele două structuri prezentate în acest laborator (stiva și coada) sunt particularizări ale structurii de date Dequeue. Dintre cele 4 operații de adaugare/ștergere puse la dispoziție de o dequeue, atât stiva cât și coada folosesc doar 2 (push_front() și pop_front() în cazul stivei, respectiv push_back() și pop_front() în cazul cozii). Desigur, dequeue permite vizualizarea elementelor din ambele capete ale cozii: front() și back().

Priority Queue

Este o coadă în care un dequeue() / peek() va întoarce primul element din acea coadă în funcție de un anumit criteriu. Exemplu: pentru o coadă cu priorități care organizează elementele în funcție de valoarea maximă, un peek() va întoarce valoarea maximă stocată. Similar, în cazul unei cozi cu priorități de minim, peek() va întoarce valoarea minimă stocată.

Exemple de utilizare

Radix Sort

Radix Sort este un algoritm de sortare care ține cont de cifre individuale ale elementelor sortate. Aceste elemente pot fi nu doar numere, ci orice altceva ce se poate reprezenta prin întregi. Majoritatea calculatoarelor digitale reprezintă datele în memorie sub formă de numere binare, astfel că procesarea cifrelor din această reprezentare se dovedește a fi cea mai convenabilă. Există două tipuri de astfel de sortare: LSD (least significant digit) şi MSD (most significant digit). LSD procesează reprezentările dinspre cea mai puțin semnificativă cifră spre cea mai semnificativă, iar MSD invers.

O versiune simplă a radix sort este cea care folosește 10 cozi (câte una pentru fiecare cifră de la 0 la 9). Aceste cozi vor reține la fiecare pas numerele care au cifra corespunzătoare rangului curent. După această împărțire, elementele se scot din cozi în ordinea crescătoare a indicelui cozii (de la 0 la 9), și se rețin într-un vector (care devine noua secvență de sortat). Exemplu:

Secvența inițială:

170, 45, 75, 90, 2, 24, 802, 66

Numere sunt introduse în 10 cozi (într-un vector de 10 cozi), în funcție de cifrele de la dreapta la stânga fiecărui număr.

Cozile pentru prima iterație vor fi:

  * 0: 170, 090 
  * 1: nimic 
  * 2: 002, 802 
  * 3: nimic 
  * 4: 024 
  * 5: 045, 075 
  * 6: 066 
  * 7 - 9: nimic 

a. Se face dequeue pe toate cozile, în ordinea crescătoare a indexului cozii, și se pun numerele într-un vector, în ordinea astfel obținută:

Noua secvență de sortat:

170, 090, 002, 802, 024, 045, 075, 066

b. A doua iterație:

Cozi:

  * 0: 002, 802 
  * 1: nimic 
  * 2: 024 
  * 3: nimic
  * 4: 045 
  * 5: nimic
  * 6: 066 
  * 7: 170, 075 
  * 8: nimic 
  * 9: 090 

Noua secvență:

002, 802, 024, 045, 066, 170, 075, 090

c. A treia iterație:

Cozi:

  * 0: 002, 024, 045, 066, 075, 090 
  * 1: 170 
  * 2 - 7: nimic
  * 8: 802 
  * 9: nimic

Noua secvență:

002, 024, 045, 066, 075, 090, 170, 802 (sortată)

Observam ca au fost suficiente 3 iterații, adică numărul cel mai mare de cifre dintre numerele din colecție. În acest caz, numerele cu mai puțin de 3 cifre au fost reprezentate cu 0-uri la început pentru a fi mai ușor de înțeles.

Schelet

Daca folositi Github Classroom, va rugam sa va actualizati scheletul cu cel de mai jos. Cel din repo-ul clonat initial nu este la cea mai recenta versiune.

Scheletul de laborator

Exerciţii

Trebuie să vă creați cont de Lambda Checker, dacă nu v-ați creat deja, pe care îl veți folosi la SD pe toată durata semestrului. Aveti grija sa selectati contestul corect la submit, si anume SD-CA-LAB-04 Stiva si Coada

1) [3p] Implementați, plecând de la scheletul de cod, stiva. Atenție! În implementarea stivei se va folosi lista simplu înlănțuită detaliată în secțiunea de mai sus. (problema Stack Implementation pe LambdaChecker)

2) [3p] Implementați, plecând de la scheletul de cod, coada. Atenție! Implementarea are la baza un vector circular despre care puteți citi mai multe în secțiunea de mai sus. (problema Queue Implementation pe LambdaChecker)

3) [1p] Determinați dacă un șir format din caracterele (, [, {, ), ], } este corect parantezat. (problema Valid Parentheses pe LambdaChecker)

(Bonus) [2p] Implementați operațiile elementare ale unei stive folosind două cozi. Problema admite două versiuni: una în care operația pop este eficientă, iar cealaltă în care operația push este eficientă. (pentru a primi punctajul, implementați soluția local, apoi urcați codul pe GitHub)

Interviu

Această secțiune nu este punctată și încearcă să vă facă o oarecare idee a tipurilor de întrebări pe care le puteți întâlni la un job interview (internship, part-time, full-time, etc.) din materia prezentată în cadrul laboratorului.

  1. Implementați, folosind un singur vector, 3 stive
  2. Scrieți un program cu ajutorul căruia să sortați o stivă. Nu aveți acces decât la operațiile push(), pop(), top() și is_empty()
  3. Adăugați structurii de stivă o nouă funcție numită min, funcție care returnează cel mai mic element. Push, pop și min trebuie să aibă complexitate O(1)
  4. Descrieți cum este folosită stiva sistemului în cazul transmiterii parametrilor apelului unei funcții
  5. Implementați operațiile elementare ale unei stive folosind două cozi. Problema admite două versiuni: una în care operația pop este eficientă, iar cealaltă în care operația push este eficientă.
  6. Implementați operațiile elementare ale unei cozi folosind două stive.
  7. Presupunând că avem o coadă ce conține un număr mare de elemente, coada neputând fi ținută în memorie. Prezentați o modalitate de a implementa operațiile enqueue și dequeue.
  8. Să se implementeze o coadă ce are și operația findmax, pe lângă operațiile enqueue și dequeue. findmax trebuie să returneze cea mai mare valoare aflată în coadă la momentul respectiv. Oferiți o implementare eficientă.
  9. Cum s-ar implementa o stivă folosind o coadă de priorități?
  10. De câte cozi este nevoie ca să se poată implementa o coadă de priorități?

Bibliografie

  1. CLRS - Introduction to Algorithms, 3rd edition, capitol 10.1 - Stacks and queues
sd-ca/2021/laboratoare/lab-05.txt · Last modified: 2022/10/03 15:10 (external edit)
CC Attribution-Share Alike 3.0 Unported
www.chimeric.de Valid CSS Driven by DokuWiki do yourself a favour and use a real browser - get firefox!! Recent changes RSS feed Valid XHTML 1.0