Week 12
VL 22 - 01.07.25
How to Represent Graphs
- Nodes: whole numbers 0, …, N - 1 (or keys related to the application, e.g. Names)
- Edges: Two options
Adjacency matrix:
\(A \in \{0, 1\}^{|V| \times |V|}\), s.t. \(a_{i, j} = 1 \Leftrightarrow \text{edge} i \rightarrow j \text{exixts}\)for an undirected graph: \(A = A^T\) (matri has to be symmetric)
example:
adjecency matrix advantages:
- concepts and techniques can be directly applied
- storage efficient for direct graphs that are not sparse, i.e. \(|E| \in \mathcal{O}(|V|^2)\), (but not for sparse graphs \(|E| \in \mathcal{O(|V|)}\))
Adjacency list: Array of arrays, an array for each node, where the arrays containes the neighbors
above graph can be represented as:
graph = [[1, 3], [0, 2, 3], [1, 3], [0, 1, 2]]
- advantage: storage efficient for sparse matrices, e.g. navigation graphs
an elegant pythonic code for a looping over all neighbours of all nodes:
for node, neighbors in enumerate(graph): # looping over all nodes for neighbor in neighbors: # do something interesting ...
Calculating the Transpose of a Graph
This means reversing all nodes.
for undirected graphs: \(G^T = G \Rightarrow A^T = A, \text{graph}^T = \text{graph}\) (graph is the adjacency list representation)
for directed graphs: \(A_{G^T} = A_G^T\) (for adjacency matrix, tranpose it.) Below we give how to tranpose the adjacency list representation of a graph:
def tranpose_graph(graph): = [[] for i in graph] gt for node, neighbors in enumerate(graph) : for neighbor in neighbors : # reverse edges gt[neighbor].append(node) return gt
Traversing Graphs
Visiting all nodes (or a subset of all nodes) in a specific order.
- Basics:
- Depth First Search (DFS): first traverse the depth, and then move to other neighbors
- Breadth First Search (BFS): First visit all neighbors and then go to the deeper level
- Advanced:
- shortest path (dijkstra shortest path, A*-Alg): visit nodes in an increasing weight order
DFS
implementation:
def dfs(graph, start_node):
= [False] * len(graph)
visited def visit(node):
if visited[node] return # node is already visited, do nothing
= True # register the visit
visited[node] print("discovered", node)
for neighbor in graph[node]:
visit[neighbor]print("finished", node)
visit[start_node]
- Example:
recursive implementation is elegant, but for large graphs stack depth (overflow) can be reached very easily, e.g. for graphs with thousands of nodes. But it is possible to re-write any recursive algorithm iteratively using stack data structure, and this is what we can do for DFS - we can implement it using stack data structure:
stack implementation:
from collections import deque # simultaneously a stack and a queue
def dfs(graph, start_node):
= deque()
d
d.append(start_node)= [False] * len(graph)
visited while len(d) != 0 : # there are still elements in the stack
= d.popright() # LIFO (for BFS we will simply replace with popleft)
node if visited[node]: continue
= True
visited[node] print("discovered", node)
for neighbor in graph[node]:
d.append(neighbor)
print("finished", node)
is now not in post-order. As indicated in the comment above, to get BFS instead of DFS the only necessary difference is to change node = d.popright()
to node = d.popleft()
.
BFS
In BFS first all the neighbors are visited, and then the deeper levels. It means
- replacing stack witha queue (or if a deque is used, replace popright() with popleft()).
VL 23 - 03.07.25
Applications of DFS and BFS
- Copying a graph: Depth search with pre-order traversal, copying a node as soon as a node is discovered
- Deleting a graph: DFS with post-order traversal
- detecting whether a graph is a tree. Graph is a tree \(\Leftrightarrow\) there are no cycles
There is a cycle \(\Rightarrow\) there exists a node that can be reached in multiple ways.
A node is reached second time \(\Rightarrow\)
visited[node]
Flag is alreadyTrue
Trivial cycles are not considered as cycles: n -> m -> n is not a cycle
implementation:
return
True
, is there is a cycledef undirected_cycle_test(graph): # graph given as adjecency list = [False] * len(graph) visited def visit(node, parent): if not visited[node]: # no cycle = True visited[node] for neighbor in gaph[node]: if neighbor == parent: continue # skip trivial cycles if visit(neighbor, node): return True return False else: return True return visit(0, None)
8 Queens Problem
Simplification: 4x4 chessboard with 4 queens.
Each square is a node.
Above graph is represented as:
graph = [[1, 2, 3, 4],
[7, 8],
[8], ...]
Assume that we have a function check_capture(queens)
solved queens: list of nodes, where the
Solution using depth search:
def place_queens(graph):
= []
queens def visit(node, queens):
if node == 17: return True # solution found
#
queens.append(node) if check_capture(queens[1:]): #
def queens[-1]
return False
for neighbor in graph[node]:
if visit[neighbor, queens]: return True
del queens[-1]
return False
if visit(0, queens): return queens[1:]
else: return None
only a single solution is found with this implementation.
All solutions
def place_quuens(graph):
= []
solutions = []
queens def visit(node, queens):
if node == 17:
-1])
solutions.append(queens[return
queens.append(node)if not check_capture(queens[1:]): # valid partial solution
for neightobr in graph[node]:
visit(neighbor, queens)del queens[-1] # backtracking towards the next solution
0, queens) visit(
Searching and Identifying Connected Components
- a graph is connected, if \(\forall u, v \in V\) there is a path \(u \rightsquigarrow v\)
- if the graph is not connected: \(C \subset G\) is a connected component, if \(C\) is connected and \(\forall u \in V \\ C \neg u \rightsquigarrow C\)