Grafen uitgelegd: BFS, DFS, Dijkstra en topologisch sorteren
Leer grafen representeren met adjacency lists en matrices, en traceer BFS, DFS, Dijkstra en het algoritme van Kahn stap voor stap, met code in Python en Java.
Een graaf (graph) is een verzameling knopen (vertices) die met kanten (edges) verbonden zijn. Dat klinkt abstract, maar bijna alles is een graaf: wegen tussen steden, vrienden op een sociaal netwerk, links tussen webpagina's, vakken met hun voorkennis, of pakketten die van elkaar afhangen.
Wie grafen beheerst, kan een hele klasse problemen oplossen met een handvol algoritmen. Op het tentamen traceer je BFS, DFS en Dijkstra met de hand. In sollicitaties vermomt een graafprobleem zich vaak als iets anders ("hoeveel eilanden staan er in dit raster?"). En in de praktijk draaien routeplanners, build-tools en package managers op precies deze algoritmen.
Begrippen
- Ongericht (undirected): een kant
A–Bkun je in beide richtingen volgen (vriendschap). - Gericht (directed): een kant
A → Bheeft één richting (volgen op sociale media, "A is voorkennis voor B"). - Gewogen (weighted): elke kant heeft een getal, bijvoorbeeld een afstand of een reistijd.
- Buren (neighbours): de knopen die met één kant bereikbaar zijn.
- Graad (degree): het aantal kanten aan een knoop. Bij een gerichte graaf onderscheid je ingangsgraad (in-degree) en uitgangsgraad (out-degree).
Vis het aantal knopen,Ehet aantal kanten.
Knopen noteren we als hoofdletters. Buren bekijk je altijd in alfabetische volgorde; anders zijn er meerdere goede antwoorden op een traceervraag.
We gebruiken deze ongerichte graaf met zes knopen en zeven kanten:
A --- B --- C
| | |
D --- E --- F
Representatie: lijst of matrix
Een adjacency list (buurlijst) slaat per knoop de lijst van buren op. Een adjacency matrix
(buurmatrix) is een V × V-tabel met een 1 als er een kant is.
| Knoop | Buren |
|---|---|
| A | B, D |
| B | A, C, E |
| C | B, F |
| D | A, E |
| E | B, D, F |
| F | C, E |
| A | B | C | D | E | F | |
|---|---|---|---|---|---|---|
| A | 0 | 1 | 0 | 1 | 0 | 0 |
| B | 1 | 0 | 1 | 0 | 1 | 0 |
| C | 0 | 1 | 0 | 0 | 0 | 1 |
| D | 1 | 0 | 0 | 0 | 1 | 0 |
| E | 0 | 1 | 0 | 1 | 0 | 1 |
| F | 0 | 0 | 1 | 0 | 1 | 0 |
| Adjacency list | Adjacency matrix | |
|---|---|---|
| Geheugen | O(V + E) | O(V²) |
Bestaat kant u–v? | O(graad van u) | O(1) |
Alle buren van u | O(graad van u) | O(V) |
| Geschikt voor | ijle grafen (weinig kanten) | dichte grafen |
De meeste echte grafen zijn ijl (sparse): een wegennet heeft miljoenen kruispunten, maar elk kruispunt maar een paar wegen. Daarom is de adjacency list de standaard. Bij een ongerichte graaf staat elke kant twee keer in de lijst (en is de matrix symmetrisch).
Breedte-eerst zoeken (BFS)
Breedte-eerst zoeken (breadth-first search) verkent de graaf in lagen: eerst de startknoop, dan alle buren, dan de buren daarvan. Het gebruikt een wachtrij. Een knoop telt als bezocht zodra hij in de wachtrij komt, zodat hij er nooit twee keer in staat.
BFS vanaf A:
| Stap | Haal uit wachtrij | Nieuwe buren erbij | Wachtrij daarna |
|---|---|---|---|
| start | A | [A] | |
| 1 | A | B, D | [B, D] |
| 2 | B | C, E (A is al bezocht) | [D, C, E] |
| 3 | D | geen (A, E al bezocht) | [C, E] |
| 4 | C | F | [E, F] |
| 5 | E | geen | [F] |
| 6 | F | geen | [] |
Bezoekvolgorde: A, B, D, C, E, F. In een ongewogen graaf vindt BFS de kortste route (in aantal
kanten): F ligt op afstand 3 van A, en BFS bereikt hem pas in de derde laag.
Diepte-eerst zoeken (DFS)
Diepte-eerst zoeken (depth-first search) gaat zo diep mogelijk één kant op, en keert pas terug als er geen onbezochte buur meer is. Recursief is dat het natuurlijkst; de recursie werkt als een stapel.
DFS vanaf A:
| Stap | Bezoek | Waarom |
|---|---|---|
| 1 | A | start |
| 2 | B | eerste onbezochte buur van A |
| 3 | C | eerste onbezochte buur van B (A is al bezocht) |
| 4 | F | eerste onbezochte buur van C |
| 5 | E | eerste onbezochte buur van F (C al bezocht) |
| 6 | D | eerste onbezochte buur van E (B al bezocht) |
Daarna heeft D geen onbezochte buren meer; de recursie keert helemaal terug naar A, waar D nu al
bezocht is. Bezoekvolgorde: A, B, C, F, E, D.
Zelfde graaf, zelfde start, toch een andere volgorde dan BFS. DFS gebruik je voor het vinden van cycli, samenhangende componenten en doolhoven.
from collections import deque
graaf = {
"A": ["B", "D"], "B": ["A", "C", "E"], "C": ["B", "F"],
"D": ["A", "E"], "E": ["B", "D", "F"], "F": ["C", "E"],
}
def bfs(graaf, start):
bezocht = {start}
wachtrij = deque([start])
volgorde = []
while wachtrij:
u = wachtrij.popleft()
volgorde.append(u)
for v in sorted(graaf[u]): # buren alfabetisch
if v not in bezocht:
bezocht.add(v) # bezocht zodra hij in de wachtrij komt
wachtrij.append(v)
return volgorde
def dfs(graaf, u, bezocht=None, volgorde=None):
if bezocht is None:
bezocht, volgorde = set(), []
bezocht.add(u)
volgorde.append(u)
for v in sorted(graaf[u]): # buren alfabetisch
if v not in bezocht:
dfs(graaf, v, bezocht, volgorde)
return volgorde
print(bfs(graaf, "A")) # ['A', 'B', 'D', 'C', 'E', 'F']
print(dfs(graaf, "A")) # ['A', 'B', 'C', 'F', 'E', 'D']
Kortste paden: Dijkstra
In een gewogen graaf is de route met de minste kanten niet altijd de kortste. Het algoritme van Dijkstra vindt de kortste afstand van één startknoop naar alle andere knopen, zolang alle gewichten niet-negatief zijn.
Het idee: houd voor elke knoop de beste bekende afstand bij (start op 0, de rest op ∞). Kies
steeds de nog niet afgeronde knoop met de kleinste afstand; die afstand is definitief. Bekijk dan
zijn buren: is de route via deze knoop korter, verlaag dan hun afstand (relaxatie).
Dezelfde graaf, nu met gewichten:
A --4-- B --3-- C
| | |
1 1 2
| | |
D --2-- E --5-- F
Dijkstra vanaf A (de tabel toont de afstanden na het verwerken van de gekozen knoop):
| Stap | Kies (afstand) | A | B | C | D | E | F |
|---|---|---|---|---|---|---|---|
| start | 0 | ∞ | ∞ | ∞ | ∞ | ∞ | |
| 1 | A (0) | 0 | 4 | ∞ | 1 | ∞ | ∞ |
| 2 | D (1) | 0 | 4 | ∞ | 1 | 3 | ∞ |
| 3 | E (3) | 0 | 4 | ∞ | 1 | 3 | 8 |
| 4 | B (4) | 0 | 4 | 7 | 1 | 3 | 8 |
| 5 | C (7) | 0 | 4 | 7 | 1 | 3 | 8 |
| 6 | F (8) | 0 | 4 | 7 | 1 | 3 | 8 |
Twee momenten om op te letten:
- In stap 3 levert de route
A → D → E → Bafstand3 + 1 = 4op. Dat is niet kleiner dan de huidige 4, dusBverandert niet. - In stap 5 levert
A → B → C → Fafstand7 + 2 = 9op, langer dan de 8 viaE.Fblijft 8.
Antwoord (alfabetisch): A = 0, B = 4, C = 7, D = 1, E = 3, F = 8.
Met negatieve gewichten werkt Dijkstra niet, omdat een "definitieve" afstand later toch nog korter kan worden. Daarvoor gebruik je Bellman-Ford.
import heapq
gewogen = {
"A": {"B": 4, "D": 1}, "B": {"A": 4, "C": 3, "E": 1},
"C": {"B": 3, "F": 2}, "D": {"A": 1, "E": 2},
"E": {"B": 1, "D": 2, "F": 5}, "F": {"C": 2, "E": 5},
}
def dijkstra(graaf, start):
afstand = {v: float("inf") for v in graaf}
afstand[start] = 0
klaar = set()
heap = [(0, start)] # (afstand, knoop)
while heap:
d, u = heapq.heappop(heap) # knoop met kleinste afstand
if u in klaar:
continue # verouderde invoer in de heap
klaar.add(u)
for v, gewicht in sorted(graaf[u].items()):
if d + gewicht < afstand[v]: # relaxatie
afstand[v] = d + gewicht
heapq.heappush(heap, (afstand[v], v))
return dict(sorted(afstand.items()))
print(dijkstra(gewogen, "A"))
# {'A': 0, 'B': 4, 'C': 7, 'D': 1, 'E': 3, 'F': 8}
Topologisch sorteren met Kahn
Een topologische sortering (topological sort) van een gerichte graaf zonder cycli (DAG, directed
acyclic graph) is een volgorde waarin elke kant u → v van links naar rechts wijst: u komt vóór
v. Denk aan vakken met voorkennis of build-stappen die van elkaar afhangen.
Het algoritme van Kahn werkt met ingangsgraden:
- Tel voor elke knoop de ingangsgraad (aantal inkomende kanten).
- Kies een knoop met ingangsgraad 0; zijn er meerdere, neem de alfabetisch kleinste.
- Zet hem in het resultaat en verlaag de ingangsgraad van elk van zijn opvolgers met 1.
- Herhaal tot er geen knopen met ingangsgraad 0 meer zijn.
Voorbeeld met zes vakken en deze voorkennis-kanten:
C → A C → D B → D A → E D → E E → F
Ingangsgraden bij de start: A = 1, B = 0, C = 0, D = 2, E = 2, F = 1.
| Stap | Kandidaten (ingangsgraad 0) | Kies | Gevolg |
|---|---|---|---|
| 1 | B, C | B | D: 2 → 1 |
| 2 | C | C | A: 1 → 0, D: 1 → 0 |
| 3 | A, D | A | E: 2 → 1 |
| 4 | D | D | E: 1 → 0 |
| 5 | E | E | F: 1 → 0 |
| 6 | F | F |
Topologische volgorde: B, C, A, D, E, F. Bevat de graaf een cyclus, dan komen de knopen in die
cyclus nooit op ingangsgraad 0 en is het resultaat korter dan V. Zo detecteert Kahn meteen cycli.
import heapq
voorkennis = {
"A": ["E"], "B": ["D"], "C": ["A", "D"],
"D": ["E"], "E": ["F"], "F": [],
}
def kahn(graaf):
ingang = {v: 0 for v in graaf}
for u in graaf:
for v in graaf[u]:
ingang[v] += 1
klaar = [v for v in graaf if ingang[v] == 0]
heapq.heapify(klaar) # min-heap: alfabetisch kleinste eerst
volgorde = []
while klaar:
u = heapq.heappop(klaar)
volgorde.append(u)
for v in graaf[u]:
ingang[v] -= 1
if ingang[v] == 0:
heapq.heappush(klaar, v)
if len(volgorde) < len(graaf):
raise ValueError("graaf bevat een cyclus")
return volgorde
print(kahn(voorkennis)) # ['B', 'C', 'A', 'D', 'E', 'F']
Complexiteit
| Algoritme | Tijd (adjacency list) | Tijd (matrix) | Extra geheugen |
|---|---|---|---|
| BFS | O(V + E) | O(V²) | O(V) |
| DFS | O(V + E) | O(V²) | O(V) |
| Dijkstra met binaire heap | O((V + E) log V) | — | O(V + E) |
| Dijkstra met array, zonder heap | O(V²) | O(V²) | O(V) |
| Kahn | O(V + E) | O(V²) | O(V) |
BFS en DFS bekijken elke knoop één keer en elke kant (bij een ongerichte graaf) twee keer, vandaar
V + E. Dijkstra doet daarbovenop heap-operaties van O(log V); zie heaps.
Kahn met de alfabetische keuze via een heap kost O(V log V + E); zonder die eis volstaat een gewone
wachtrij en is het O(V + E). Meer over deze notatie lees je bij complexiteit.
Veelgemaakte fouten op het tentamen
- Buren niet alfabetisch bekijken. Bij
Bzijn de burenA, C, E, in die volgorde. Neem je ze in de volgorde waarin de kanten in de opgave staan, dan krijg je een ander antwoord. - BFS: een knoop pas markeren als hij uit de wachtrij komt. Dan kan hij er twee keer in komen. Markeer hem als bezocht zodra hij erin gaat.
- DFS als BFS uitvoeren. Bij DFS ga je na
Bmeteen door naar de eerste onbezochte buur vanB(C), niet eerst terug naar de andere buren vanA. - Dijkstra: een afgeronde knoop opnieuw aanpassen of bij gelijke afstand overschrijven. Een afstand verandert alleen als de nieuwe route strikt korter is.
- Kahn: de ingangsgraden niet bijwerken. Verlaag na elke keuze de ingangsgraad van alle opvolgers; pas dan zie je welke knopen nieuw beschikbaar zijn.
Samenvatting
- Een graaf bestaat uit knopen en kanten, gericht of ongericht, gewogen of niet; meestal sla je hem op
als adjacency list (
O(V + E)geheugen). - BFS gebruikt een wachtrij en verkent laag voor laag; DFS gaat recursief zo diep mogelijk. Beide
O(V + E), buren altijd alfabetisch. - Dijkstra kiest steeds de knoop met de kleinste voorlopige afstand en werkt alleen met
niet-negatieve gewichten:
O((V + E) log V)met een heap. - Kahn sorteert een DAG topologisch door steeds een knoop met ingangsgraad 0 te kiezen (bij ons de alfabetisch kleinste) en detecteert zo ook cycli.
