🐍 INFORMATICĂ — CLASA A IX-A

Python de la zero

Partea 7 — Grafuri și algoritmi fundamentali pe grafuri

De la reprezentarea unui graf până la parcurgeri și drumuri.

1. Legătura cu părțile anterioare

În părțile anterioare am construit treptat instrumentele necesare: variabile, condiții, repetări, șiruri, liste, matrice, funcții, algoritmi de sortare și căutare, recursivitate, Backtracking și programare dinamică.

Variabile
→
Liste
→
Matrice
→
Funcții
→
Grafuri
Ideea principală: un graf nu este o comandă Python. Este o structură matematică pe care o reprezentăm în Python folosind structuri pe care deja le cunoaștem.

2. Ce este un graf?

Graf = o mulțime de vârfuri și o mulțime de muchii care le leagă.

Putem folosi grafuri pentru rețele de drumuri, prietenii între persoane, conexiuni între calculatoare, hărți, relații între obiecte și multe alte situații.

Graf neorientat

Dacă există muchia dintre A și B, putem merge din A în B și din B în A.

Graf orientat

O muchie are direcție: A → B nu înseamnă automat și B → A.

Graf ponderat

Fiecărei muchii îi putem asocia o valoare, de exemplu distanța, timpul sau costul.

TipExempluInterpretare
NeorientatA — BLegătura funcționează în ambele sensuri.
OrientatA → BLegătura are un sens.
PonderatA —5— BMuchia are costul 5.

3. Cum reprezentăm un graf în Python?

Vom folosi două reprezentări importante. Alegerea reprezentării depinde de problemă.

Matrice de adiacență

Este o matrice în care indicăm dacă două vârfuri sunt legate.

Listă de adiacență

Pentru fiecare vârf păstrăm lista vecinilor săi.

Numerotarea vârfurilor

În Python este comod să numerotăm vârfurile de la 0 la n-1.

n = 5
# vârfurile sunt: 0, 1, 2, 3, 4
Atenție: dacă problema numerotează vârfurile de la 1 la n, trebuie să fii atent când le transformi în indici Python.

4. Lista de adiacență

Pentru graful cu muchiile 0—1, 0—2, 1—3, putem scrie:

graf = [
    [1, 2],
    [0, 3],
    [0],
    [1]
]

graf[0] este lista vecinilor lui 0. graf[1] este lista vecinilor lui 1.

Construirea unui graf neorientat

n = 4
graf = [[] for i in range(n)]

a = 0
b = 1
graf[a].append(b)
graf[b].append(a)

Muchia trebuie introdusă în ambele liste deoarece graful este neorientat.

Construirea unui graf orientat

n = 4
graf = [[] for i in range(n)]

a = 0
b = 1
graf[a].append(b)

Aici introducem doar direcția 0 → 1.

5. Matricea de adiacență

Pentru n vârfuri folosim o matrice n × n. Dacă există muchia dintre i și j, punem 1; altfel 0.

n = 4
mat = [[0] * n for i in range(n)]

mat[0][1] = 1
mat[1][0] = 1

Pentru un graf neorientat, matricea este simetrică față de diagonala principală.

Lista sau matrice?

Listă de adiacențăMatrice de adiacență
Foarte bună pentru grafuri cu puține muchii.Foarte simplă pentru a verifica dacă există o muchie.
Memorie mai mică pentru grafuri rare.Folosește n² poziții.

6. BFS — parcurgerea pe niveluri

BFS (Breadth-First Search) vizitează mai întâi vârfurile aflate la distanță 1, apoi cele la distanță 2, apoi cele la distanță 3 etc.

Pentru BFS avem nevoie de o coadă. Vom implementa coada folosind o listă și un indice.

def bfs(graf, start):
    n = len(graf)
    vizitat = [False] * n
    coada = [start]
    vizitat[start] = True
    poz = 0

    while poz < len(coada):
        nod = coada[poz]
        poz = poz + 1
        print(nod)

        for vecin in graf[nod]:
            if not vizitat[vecin]:
                vizitat[vecin] = True
                coada.append(vecin)
De ce marcăm un vârf când îl adăugăm în coadă?
Pentru a nu-l adăuga de mai multe ori dacă este întâlnit prin mai mulți vecini.

BFS pentru distanțe

Într-un graf neponderat, BFS poate calcula numărul minim de muchii de la sursă la fiecare vârf.

def distante(graf, start):
    n = len(graf)
    dist = [-1] * n
    coada = [start]
    dist[start] = 0
    poz = 0

    while poz < len(coada):
        nod = coada[poz]
        poz = poz + 1

        for vecin in graf[nod]:
            if dist[vecin] == -1:
                dist[vecin] = dist[nod] + 1
                coada.append(vecin)

    return dist

7. DFS — parcurgerea în adâncime

DFS (Depth-First Search) merge cât mai adânc pe o ramură înainte de a reveni și a încerca altă ramură.

DFS recursiv

def dfs(graf, nod, vizitat):
    vizitat[nod] = True
    print(nod)

    for vecin in graf[nod]:
        if not vizitat[vecin]:
            dfs(graf, vecin, vizitat)

Observăm legătura directă cu recursivitatea studiată în Partea 5.

BFS și DFS

BFSDFS
Explorează pe niveluri.Explorează în adâncime.
Folosește o coadă.Poate fi implementat recursiv.
Este foarte util pentru distanțe minime în grafuri neponderate.Este foarte util pentru componente conexe, cicluri și structurarea explorării.

8. Existența unui drum

O întrebare frecventă este: „Se poate ajunge de la s la t?”. Putem porni un DFS și verificăm dacă ajungem la destinație.

def exista_drum(graf, start, destinatie):
    vizitat = [False] * len(graf)

    def dfs(nod):
        vizitat[nod] = True

        if nod == destinatie:
            return True

        for vecin in graf[nod]:
            if not vizitat[vecin]:
                if dfs(vecin):
                    return True

        return False

    return dfs(start)
Observație pedagogică: aici combinăm funcții, liste, parcurgere, condiții și recursivitate. De aceea grafurile apar abia după ce aceste concepte au fost studiate separat.

9. Componente conexe

Într-un graf neorientat, două vârfuri aparțin aceleiași componente conexe dacă există un drum între ele.

Algoritmul: parcurgem toate vârfurile. Dacă găsim unul nevizitat, pornim un DFS nou și am găsit o componentă nouă.

def componente(graf):
    n = len(graf)
    vizitat = [False] * n
    nr = 0

    for i in range(n):
        if not vizitat[i]:
            nr = nr + 1
            dfs(graf, i, vizitat)

    return nr

Numărul de porniri DFS este numărul componentelor conexe.

10. Drum minim într-un graf ponderat — ideea lui Dijkstra

Dijkstra determină distanțele minime de la o sursă către celelalte vârfuri atunci când muchiile au ponderi nenegative.

Vom înțelege mai întâi ideea, apoi implementarea. Pentru fiecare vârf păstrăm cea mai bună distanță cunoscută.

  1. Inițial, distanța sursei este 0, iar celelalte sunt foarte mari.
  2. Alegem vârful nevizitat cu distanța cea mai mică.
  3. Încercăm să îmbunătățim distanțele vecinilor.
  4. Marcăm vârful ca procesat.
  5. Repetăm.
def dijkstra(graf, start):
    n = len(graf)
    INF = 10**18
    dist = [INF] * n
    vizitat = [False] * n
    dist[start] = 0

    for pas in range(n):
        nod = -1

        for i in range(n):
            if not vizitat[i] and (nod == -1 or dist[i] < dist[nod]):
                nod = i

        if nod == -1 or dist[nod] == INF:
            break

        vizitat[nod] = True

        for muchie in graf[nod]:
            vecin = muchie[0]
            cost = muchie[1]

            if dist[nod] + cost < dist[vecin]:
                dist[vecin] = dist[nod] + cost

    return dist
Important: în această versiune simplă, fiecare element din lista de adiacență este o listă de două valori: [vecin, cost]. O implementare mai rapidă poate folosi o coadă cu priorități; aceasta va fi introdusă într-o parte ulterioară.

11. Exerciții rezolvate

Problema 1 — Construirea unui graf Ușor

Construiește lista de adiacență pentru muchiile 0—1, 0—2 și 1—3.

graf = [[] for i in range(4)]

muchii = [[0, 1], [0, 2], [1, 3]]

for muchie in muchii:
    a = muchie[0]
    b = muchie[1]
    graf[a].append(b)
    graf[b].append(a)

print(graf)
Problema 2 — Numărul de vecini Ușor

Determină numărul de vecini ai vârfului v.

print(len(graf[v]))
Problema 3 — BFS Mediu

Pornește BFS din vârful 0.

vizitat = [False] * len(graf)
coada = [0]
vizitat[0] = True
poz = 0

while poz < len(coada):
    nod = coada[poz]
    poz = poz + 1
    print(nod)

    for vecin in graf[nod]:
        if not vizitat[vecin]:
            vizitat[vecin] = True
            coada.append(vecin)
Problema 4 — Distanțe minime într-un graf neponderat Mediu

Calculează numărul minim de muchii de la vârful 0.

Folosim funcția distante() din secțiunea BFS și apelăm:

d = distante(graf, 0)
print(d)
Problema 5 — Componente conexe Mediu

Determină numărul componentelor unui graf neorientat.

Parcurgem toate vârfurile și pornim un DFS pentru fiecare vârf nevizitat. Implementarea este funcția componente() de mai sus.

Problema 6 — Drum minim ponderat Dificil

Aplică Dijkstra pe un graf ponderat reprezentat prin liste de forma [vecin, cost].

graf = [
    [[1, 4], [2, 2]],
    [[0, 4], [3, 5]],
    [[0, 2], [3, 1]],
    [[1, 5], [2, 1]]
]

print(dijkstra(graf, 0))

Rezultatul reprezintă distanța minimă de la 0 la fiecare vârf.

12. Exerciții propuse

Nivel 1 — Fundamentale
  1. Construiește un graf neorientat cu 5 vârfuri și 4 muchii.
  2. Afișează vecinii fiecărui vârf.
  3. Construiește matricea de adiacență pentru un graf dat.
  4. Verifică dacă două vârfuri sunt unite direct.
  5. Numără muchiile unui graf neorientat.
  6. Calculează gradul fiecărui vârf.
  7. Construiește un graf orientat dintr-o listă de arce.
  8. Transformă o matrice de adiacență în listă de adiacență.
Nivel 2 — BFS și DFS
  1. Afișează vârfurile în ordinea BFS.
  2. Afișează vârfurile în ordinea DFS.
  3. Verifică dacă există drum între două vârfuri.
  4. Calculează distanța minimă între două vârfuri într-un graf neponderat.
  5. Afișează toate vârfurile accesibile dintr-un vârf dat.
  6. Numără componentele conexe.
  7. Determină cea mai mare componentă conexă.
  8. Verifică dacă toate vârfurile sunt accesibile dintr-o sursă.
  9. Construiește arborele BFS al unui graf.
  10. Determină nivelul fiecărui vârf față de sursă.
Nivel 3 — Probleme algoritmice
  1. Verifică dacă un graf neorientat este conex.
  2. Verifică existența unui ciclu într-un graf neorientat.
  3. Determină numărul de muchii dintre două vârfuri pe un drum minim.
  4. Găsește vârfurile aflate la distanța exactă k de o sursă.
  5. Determină diametrul unui arbore folosind BFS.
  6. Calculează distanțele minime într-un graf ponderat folosind Dijkstra.
  7. Afișează un drum minim, nu doar lungimea lui.
  8. Determină vârful cel mai apropiat de toate celelalte într-un graf mic.
  9. Modelează o rețea de orașe și calculează drumuri minime.
  10. Modelează o rețea socială și găsește numărul minim de legături dintre două persoane.
Nivel 4 — Provocări
  1. Reconstruiește efectiv drumul minim folosind vectorul de părinți.
  2. Găsește toate drumurile de lungime minimă între două vârfuri.
  3. Detectează dacă există un ciclu orientat.
  4. Determină componentele unei rețele orientate într-o problemă simplificată.
  5. Compară BFS și DFS pe aceeași problemă și explică alegerea algoritmului.
  6. Implementează Dijkstra pentru un graf cu intrarea citită de la tastatură.

13. Greșeli frecvente

Regulă: înainte de cod, identifică vârfurile, muchiile, tipul grafului și ce trebuie calculat.

14. Recapitulare

Pot...☐
explica ce este un graf;☐
diferenția graf orientat, neorientat și ponderat;☐
construi o listă de adiacență;☐
construi o matrice de adiacență;☐
explica BFS;☐
explica DFS;☐
calcula distanțe într-un graf neponderat;☐
determina componente conexe;☐
explica ideea algoritmului Dijkstra;☐
alege o reprezentare potrivită pentru un graf;☐
Următorul pas: după grafurile fundamentale putem introduce algoritmi mai avansați pe grafuri, cozi cu priorități, arbori, drumuri minime în variante mai complexe și tehnici de optimizare.