Graphs

In this lesson we consider directed graphs (without weights) represented with adjacency lists. We first present how to encode them in an easy way in C++ and then show the implementation of several basic graph algorithms.

Graph representation

In the following, we assume that directed graphs are represented through adjacency lists: For each node, we have an (unordered) list of its succesors. For simplicity, we assume that the nodes (also known as vertices) are identified with integers from 0 up to $n-1$, where $n$ is the total number of nodes in the graph. Consequently, nodes and graphs can simply be declared like this:

using node = int;                       // nodes are represented by indices from 0
using AdjacencyList = vector<node>;     // the adjacency list of one node (arcs to successor nodes)
using Graph = vector<AdjacencyList>;    // the complete adjacency list for all nodes in the graph

The outter vector of the Graph type stores, for each node, an adjacency list. This adjacency list is stored as a vector of nodes. As an example, the graph

Layout:





can be defined as ``` Graph G = {{2, 3}, {4, 2}, {3}, {2}, {1, 3, 5}, {0, 1}}; ``` Observe that we do not add contraints on the order of the nodes in the adjacency lists —even though this could make sense in some settings. However, we do not allow repetitions (this would mean multiple arcs) nor self-loops. This representations has the following interesting properties: - The number of nodes in a graph `G` is `G.size()`. - The out-degree of node `u` in a graph `G` is `G[u].size()`. - One can iterate through all nodes `u` in a graph `G` using a `for (node u = 0; u < G.size(); ++u)` loop. - One can iterate through all succesors `v` of a node `u` in a graph `G` using a `for (node v : G[u])` loop. - It is possible to set attributes or properties of the nodes through additional vectors. In the following, $n$ denotes the number of nodes of a directed graph, and $m$ denotes its number of arcs. Therefore, the size of the graph is $\O(m+n)$. Consequently, algorithms of cost $\O(m+n)$ are linear time algorithms. All given algorithms are given for directed graphs, but work all the same in directed graphs when each edge is given as two symmetric arcs. As an example, the undirected graph

Layout:





can be defined as ``` Graph G = {{1, 2}, {2, 0, 3}, {0, 1, 4}, {1, 4}, {3, 2}}; ``` In the following, some algorithms need the notion of an unknown natural or node value. To do so, we shall use the following `None` constant: ``` const int None = -1; ``` ## Simple traversal The following code snippet shows how to traverse each node in a directed graph and how to traverse its succesors in order to print them. ``` void simple_traversal (const Graph& G) { int n = G.size(); // let n be the number of nodes in G for (node u = 0; u < n; ++u) { // for each node u in G cout << u << ":" << endl; // print u: for (node v : G[u]) { // for each neighbor v of u in G cout << " " << v; // print v } cout << endl; } } ``` Applying this code to the first sample graph above would produce this output: ```text 0: 2 3 1: 4 2 2: 3 3: 2 4: 1 3 5 5: 0 1 ``` ## Depth-first search ordering (recursive) **Depth-first search** (**DFS**) is an algorithm for traversing or searching a graph. It starts from each node in the graph and explores as far as possible along each branch before backtracking. The following code returns a DFS ordering of the nodes of a given graph `G = (V,E)`. This DFS implementation uses recursion and needs to keep track of the subset `S ⊆ V` of nodes that have already been **visited**. Starting from an empty ordering in a list `L`, it adds each new visited node to the end of `L` and recursively visits all its unvisited succesors. The subset of visited nodes `S` is represented as a vector of booleans that tells, for each of the `n` possible nodes, whether they are in `S` or not. Using a `set` for `S` would be overkill. The returned DFS ordering is a list of nodes. For efficiency reasons, one could prefer using a vector of nodes, as the only necessary operation is to push nodes at its end. ``` /** Returns a list of nodes with a DFS ordering of a graph. */ list dfs_ordering (const Graph& G) { int n = G.size(); // number of nodes of G list L; // list with the DFS ordering vector S(n, false); // subset of visited nodes for (node u = 0; u < n; ++u) { dfs_from(G, u, S, L); } return L; } void dfs_from (const Graph& G, node u, vector& S, list& L) { if (not S[u]) { S[u] = true; L.push_back(u); for (node v : G[u]) { dfs_from(G, v, S, L); } } } ``` Applying this code to the first sample graph above produces `[0, 2, 3, 1, 4, 5]`. The running time is $\O(m+n)$, which is linear in the size of the graph. **🤔 Exercice:** How would you modify this program to check if an undirected graph is connected? (An undirected graph is connected if there is a path between any pair of its nodes.) ## Depth-first search ordering (iterative) One may change the previous `dfs_from()` function to work in a iterative rather than recursive way. In this case, a stack of nodes `s` is used to keep track of the nodes that have already been found and their order. ``` void dfs_from (const Graph& G, node u, vector& S, list& L) { if (not S[u]) { stack s; s.push(u); while (not s.empty()) { node v = s.top(); s.pop(); if (not S[v]) { S[v] = true; L.push_back(v); for (node w : G[v]) if (not S[w]) { s.push(w); } } } } } ``` Applying this code to the first sample graph above produces `[0, 3, 2, 1, 4, 5]`. You should think why this DFS ordering is different from the DFS ordering in the recursive implementation and why both are valid. The running time is still $\O(m+n)$. ## Breadth-first search ordering **Breadth-first search** (**BFS**) is an algorithm for traversing or searching a graph. It starts from each node of the graph and explores the succesor nodes first, before moving to the next level of succesors. The following code returns a BFS ordering of the nodes of a given graph. The BFS implementation uses a queue of nodes. It also needs to keep track of the subset of nodes `Q ⊆ V` that have already **enqueued** (🧠remember: *enqueued* for BFS and *visited* for DFS). ``` /** Returns a list of nodes with a BFS ordering of a graph. */ list bfs_ordering (const Graph& G) { int n = G.size(); // number of nodes in G list L; // list with the BFS ordering vector Q(n, false); // subset of enqueued nodes for (node u = 0; u < n; ++u) { bfs_from(G, u, Q, L); } return L; } void bfs_from (const Graph& G, int u, vector& Q, list& L) { if (not Q[u]) { queue q; q.push(u); Q[u] = true; while (not q.empty()) { int v = q.front(); q.pop(); L.push_back(v); for (node w : G[v]) if (not Q[w]) { q.push(w); Q[w] = true; } } } } ``` Applying this code to the above sample graph produces `[0, 2, 3, 1, 4, 5]`. The running time is $\O(m+n)$. **🤔 Exercice:** How would you modify this program to check if an undirected graph is connected? ## Minimum distance from a source node The following code performs a breadth-first search on an undirected graph in order to compute the minimum distance (number of hops) from a given source node to all other nodes in the graph. The input is the graph `G` and the source node `s` (with `0 ≤ s < G.size`), and the returned value is a vector with the distances from `s` for all nodes in the graph. In the case that some node is not reachable from `s`, the computed distance is the special value `None` (defined as `-1` through a constant). The implementation is based on BFS. It starts from `s` and runs until all reachable nodes have been enqueued. For each node, we keep its distance from `s` in `d`, which is initialized to `None` values, to reflect unreachableness. We do not need to use a subset `Q` to tell whether a node has been enqueued or not because `d` can be used for that same purpouse. Its running time is $\O(m+n)$. ``` /** Returns the minimum distance of each node of a graph from a source node s. */ vector minimum_distance (const Graph& G, node s) { int n = G.size(); // number of nodes in G vector d(n, None); // for each node, its distance from s d[s] = 0; queue q; q.push(s); while (not q.empty()) { node v = q.front(); q.pop(); for (node w : G[v]) if (d[w] == None) { d[w] = d[v] + 1; q.push(w); } } return d; } ``` Applying this code to the initial sample graph from source node `1` produces `[3, 0, 1, 2, 1, 2]`. You may think how to modify this function to get the minimum distance between a source node and a target node. **🤔 Exercice:** How would you modify this program to compute the minimum distance from a source node `s` to a target node `t`? ## Topological ordering A **topological ordering** of an acyclic directed graph is a linear ordering of its nodes such that for every directed edge $(u,v)$ from node $u$ to node $v$, $u$ comes before $v$ in the ordering. Given a directed acyclic graph, the following algorithm returns a topological ordering if its nodes. To do so, at step 1, we count the in-degree of all nodes. Then, at step 2, we place all nodes with in-degree zero in some container. In this implementation, the chosen container is a stack, but any other fast data structure would do. Finally, at step 3, the nodes are extracted from the container and placed at the end of the ordering, as they have in-degree zero. Moreover, since they can be cosidered “done”, they can discount one unit to the in-degree of their succesors and, when their in-degree becomes zero, they are placed in the container, as all their dependencies have been fulfilled. ``` /** Returns a list of nodes with a topological ordering of a directed acyclic graph. */ list topological_ordering(const Graph& G) { int n = G.size(); // Step 1: compute the in-degree of each node vector indeg(n, 0); // for each node, its in-degree for (int u = 0; u < n; ++u) { for (node v : G[u]) { ++indeg[v]; } } // Step 2: place all nodes with in-degree zero in some container. stack s; // container with nodes with in-degree zero for (node u = 0; u < n; ++u) { if (indeg[u] == 0) { s.push(u); } } // Step 3: process work from the container. list L; // list with the topological ordering while (not s.empty()) { node u = s.top(); s.pop(); L.push_back(u); for (node v : G[u]) { if (--indeg[v] == 0) { s.push(v); } } } return L; } ``` As an exemple, the previous algorithm ran on the next DAG produces the `[1, 3, 0, 2, 5, 4]` topological ordering.

Layout:





The running time is $\O(m+n)$. **🤔 Exercice:** How would you modify this program to check that the given directed graph is really acyclic? ## Strongly-connected components A **strongly-connected component** of a directed graph is a maximal subset of nodes of the graph such that any pair of them is connected through some directed path. Therefore, the nodes of a graph can be partitioned into several strongly connected components. The following code computes the strongly-connected components of a graph. More precisely, given an undirected graph `G`, it returns the identifier of the strongly connected component of each node, as a number between zero and $C-1$, where $C$ is the total number of strongly connected components. You can get an explanation of this algorithm in [these slides](https://www.cs.upc.edu/~jordicf/Teaching/AP2/pdf/Graphs_Connectivity.pdf). ``` /** Returns the strongly connected component identifier of each nodes of a graph. */ vector strongly_connected_components (const Graph& G) { // Reverse the graph. Graph Gr = reverse(G); // Get a DFS post-ordering L of the nodes and reverse it. list L; // list of vertices in post visit order vector S(n, false); // subset of visited nodes for (node u = 0; u < n; ++u) { dfs1(G, u, S, L); } L.reverse(); // Perform DFS in G according to the order of the nodes in L. int c = 0; // current number of strongly connected component vector scc(n, None); // for each node, number of its strongly connected component for (node u : L) { dfs2(G, u, scc, ++c); } return scc; } Graph reverse(const Graph& G) { int n = G.size(); Graph Gr(n); for (node u = 0; u < n; ++u) { for (node v : G[u]) { Gr[v].push_back(u); } } return Gr; } void dfs1 (const Graph& G, node u, vector& S, list& L) { if (not S[u]) { S[u] = true; for (node v : G[u]) { dfs1(G, v, S, L); } L.push_back(u); } } void dfs2 (const Graph& G, node u, vector& scc, int c) { if (scc[u] == None) { scc[u] = c; for (node v : G[u]) { dfs2(G, v, scc, c); } } } ``` Applying this code to the first sample graph produces `[1, 2, 0, 0, 2, 2]`. These strong connected components are shown in different colors below:

Layout:





The running time is $\O(m+n)$.



Lliçons.jutge.org
Jordi Petit, Jordi Cortadella
Universitat Politècnica de Catalunya, 2023

Prohibit copiar. Tots els drets reservats.
No copy allowed. All rights reserved.