0% found this document useful (0 votes)
27 views15 pages

DSA Chapter 5.1 2024

Graphs .1

Uploaded by

roinieva22
Copyright
© © All Rights Reserved
We take content rights seriously. If you suspect this is your content, claim it here.
Available Formats
Download as PDF, TXT or read online on Scribd
0% found this document useful (0 votes)
27 views15 pages

DSA Chapter 5.1 2024

Graphs .1

Uploaded by

roinieva22
Copyright
© © All Rights Reserved
We take content rights seriously. If you suspect this is your content, claim it here.
Available Formats
Download as PDF, TXT or read online on Scribd
You are on page 1/ 15

Data Structures and Algorithms

Chapter 5.1: Graphs


Graph
Many problems end up becoming graph problems. Graphs are a very useful abstraction for
modeling many real-world problems, so a lot of programming problems can be formulated and
solved as graph problems.
Things like social networks, transportation networks, circuit boards, transactions, etc. can
be very well modelled as graphs. Many domains have data with an interconnected nature that graphs
are well-suited to handle.
What’s a graph in the context of DSA?
A graph is a non-linear data structure that represents connections or relationships between
items. It consists of:
• Vertices: These are the elements or items. Examples could be people in a social network,
locations on a map, etc. We will refer to a node as a vertex instead, especially when using Big
O.
• Edges: These represent the links or connections between the vertices. For example, an edge
between two city nodes could represent a road that connects them.
In other words, it’s a bunch of nodes with up to any number of connections. It doesn't have to be top-
down, nor does it have to be a separate left right partition.
Remember: All trees are graphs, but not all graphs are trees.

Example Graphs

Graph Terminology
• Direction - Edges can be directed (one-way) or undirected (bi-directional). For example, a
social network friendship is undirected (Facebook), but a follower relationship is directed
(Twitter).

Directed vs Undirected
• Weight - Edges can have an associated numerical value called a weight. For example,
representing the time it takes to travel between two cities. For undirected, the weight is
optional and if it has, it would be the same for both directions. For directed graphs, the
weights can be unequal. Weights can be negative or non-negative, but negative weights
appear less frequently than non-negative ones.

• Connected – A graph where every vertex can reach any other vertex either directly with an
edge between them, or indirectly by having a path through other vertices.

A can reach D and C directly; A can reach B by traversing through C. Hence, A can reach all
vertices. Similar with B, C, and D.

• Cycle – A cycle is at least 3 distinct vertices that have a path that starts and ends at the same
vertex without revisiting any other vertices. The minimum cycle would form a triangle.

A->B->D->A is a cycle
• Cyclic – A graph with at least one cycle.
• Acyclic – A graph that contains no cycles. A tree is a connected acyclic graph.

• DAG – A directed, acyclic graph

• Big O – In graphs, Big O is commonly stated in terms of vertices (V) and edges (E). For example,
𝑂(𝑉 ∗ 𝐸) means that we would visit every vertex, and for every vertex, we will check every edge.

Graph Representations
• Adjacency List: For each vertex in the graph, there is a list of its adjacent vertices. The
representation consists of a list of lists, where each inner list corresponds to the neighbors
of a vertex. This is the most common representation you will see.
• Adjacency Matrix: A 2D array of V x V vertices where each cell represents an edge between
the vertices (i, j). If a cell contains 0, there is no edge. Contains either an edge weight or 1 if
an edge exists. You will see this less common than the list because it takes a lot of memory
and setup to do; its running time would be 𝑂(𝑉 2 ).
• Edge List: A list of all edges, where each edge is represented as a tuple of (source,
destination). Edge lists are generally less common than adjacency lists and matrices but can
be useful especially for graphs that have a large number of vertices but only a relatively small
number of edges.
• There are other graph representations, but we won’t discuss it in this course because they
are less common and usually have more niche use cases.
Let’s look at this example graph:

For an adjacency list, it would look like this: For an adjacency matrix, it would look like this:
{ { 0 1 2 3 <- columns
0: {{2, 5}}, <- adj. vertex is 2 w/ weight of 5 0 { 0, 0, 5, 0 },
1: {{0, 9}, {2, 11}}, 1 { 9, 0, 11, 0 },
2: {}, 2 { 0, 0, 0, 0 },
3: {{1, 4}, {2, 3} 3 { 0, 4, 3, 0 }
} }^
rows
Note that the above representations are just pseudocode
Let’s break it down:
For the list, we start with the list of adjacent vertices of 0, then we list the ones for 1, 2, and
then 3.
For the matrix, we create a 2d array, with indices 0 to 3 for the rows and 0 to 3 for the
columns.
Let’s look at vertex 0:

The vertex adjacent to it is 2, and the weight at the edge between them is 5.
• In an adjacency list, we usually list it in this order: the adjacent vertex, then the weight
associated.
• In an adjacency matrix, the rows and columns would represent each vertex. 0 is
connected to 2, so we put the weight of 5 to the row 0, column 2. 0 is not connected to
anything else, not even to itself, so the other values are 0.
Let’s look at vertex 1:

One vertex adjacent to it is 0, and the weight at the edge between them is 9. Another vertex
adjacent to it is 2. We add 0 and 2 to the list. The order for which adjacent vertex will be listed
first could vary depending on implementation or specific problem, but for simplicity, we will
start listing from the lower valued vertex.
For the matrix, 1 is connected to 0, so we enter the weight 9 to the row 1, column 0. 1 is also
connected to 2, so we put 11 to row 1, column 2. The other values are 0.

For vertex 2, it’s not connected to anything (it can’t traverse to any other vertex), so we just
put an empty list. We should still list the adjacency list for every vertex even if they’re empty.
For the matrix, it’s not connected to anything, so everything is 0.
Try checking the list and matrix for vertex 3 to confirm your understanding.

Another Example of Adjacency Matrix

Undirected Graph:
Directed Graph:

When there are no connected edges, we use ∞ so that we can distinguish it from when the
weight is 0.
Another Example of Adjacency List

Undirected Graph:

vertex list

0 1,2

1 0,2,3

2 0,1,3

3 0,1,2

Directed Graph:

vertex list

0 1,2

1 3

2 1,3

3 None
Graph Traversal

Now how do we traverse a graph?


Basic searches are still available. All trees are graphs, so we can use BFS and DFS on graphs. If
you know how to do it on trees, it’s easy to do it on graphs.
Let’s try to do a DFS traversal starting with vertex 0:

The stack will look like this if we pop off vertices when there are no more unvisited adjacent
vertices:

We have 0 in the stack. 0 is connected to 2 so we’ll push 2 to the stack. 2 has no connections
so we will pop it off the stack. We’ll push in 3 to the stack. 3 has two connections, 2 and 1. We
already visited 2 so we don’t push it, then we push 1 to the stack.

1 is connected to 0 but we have already visited 0 so we don’t push it to the stack. 1 has no
other unvisited connections so we pop it off the stack. Now 3 has no other connections
unvisited so we pop it off the stack. 0 has no more unvisited connections so we pop it off the
stack. We have successfully visited all the vertices. The output is 2, 1, 3, 0.
Note that this is only one way of using the stack. You could instead pop off the vertices from the
stack right after they’re visited. In that case, the output would be 0, 2, 3, 1.
Now we’ll do BFS, starting from 0. In BFS, we dequeue a vertex first before enqueueing its
adjacent vertices, if there are.

We have 0 in the queue. We’ll dequeue it. 0 is connected to 2 and 3, so we’ll add them to the
queue in that order. We will dequeue 2. 2 has no connections so we don’t enqueue anything
after 3.
We will dequeue 3. 3 is connected to 2 and 1, but 2 was already visited so we only enqueue 1.
We will dequeue 1. 1 has no more connections and we have successfully visited all the
vertices
Let’s try to implement BFS on an adjacency matrix:
#include <iostream>
#include <vector>
#include <queue>

// Type alias for the adjacency matrix representation of a graph


using AdjacencyMatrix = std::vector<std::vector<int>>;

// Function to perform a breadth-first search (BFS)


// on a graph from a start vertex to a target vertex
std::vector<int> bfs(const AdjacencyMatrix& graph, int startVertex, int targetV
ertex) {
if (startVertex == targetVertex) {
return { startVertex };
}

// Initialize the seen and previous vertex vectors


std::vector<bool> seen(graph.size(), false);
std::vector<int> prev(graph.size(), -1);

// Mark the start vertex as seen and add it to the queue


seen[startVertex] = true;
std::queue<int> q;
q.push(startVertex);

// Perform the BFS


while (!q.empty()) {
int curr{ q.front() };
q.pop();

// If we've reached the target vertex, stop


if (curr == targetVertex) {
break;
}

// Get the adjacency list for the current vertex


const std::vector<int>& adjs{ graph[curr] };

// Visit each adjacent vertex


for (int i{ 0 }; i < adjs.size(); ++i) {

// Skip if there's no edge or if it's already been seen


if (adjs[i] == 0 || seen[i]) {
continue;
}

// Mark as seen, set the previous vertex, and add to the queue
seen[i] = true;
prev[i] = curr;
q.push(i);
}
}
// If the target vertex wasn't reached, return an empty path
if (prev[targetVertex] == -1) {
return {};
}

// Build the path from target to start by following the previous vertice
s
int curr{ targetVertex };
std::vector<int> out;
do {
out.push_back(curr);
curr = prev[curr];
} while (curr != -1);

// Reverse the path to get it from start to target and return it


std::reverse(out.begin(), out.end());

return out;
}

int main() {
// Define a graph as an adjacency matrix
AdjacencyMatrix graph =
{
{0, 1, 1, 0, 0},
{1, 0, 1, 1, 0},
{1, 1, 0, 0, 0},
{0, 1, 0, 0, 1},
{0, 0, 0, 1, 0}
};
int startVertex{ 0 };
int targetVertex{ 4 };

// Print the BFS path from the start vertex to the target vertex
std::cout << "BFS from vertex " << startVertex << " to vertex " << targe
tVertex << ": ";
std::vector<int> path{ bfs(graph, startVertex , targetVertex) };
if (path.empty()) {
std::cout << "No path found." << '\n';
}
else {
for (int i{ 0 }; i < path.size(); ++i) {
std::cout << path[i] << " ";
}
std::cout << '\n';
}

return 0;
}
In the example graph:

The output path is:


BFS from vertex 0 to vertex 4: 0 1 3 4
By just looking at the graph, you will see that 0 2 1 3 4 could have been a valid path.
In our example, the output is not 0 2 1 3 4 because the BFS algorithm always explores the
nearest or adjacent vertices first before moving to the next level vertices. Here’s how it works:
1. The BFS starts at vertex 0. We dequeue 0 and discover vertices 1 and 2. The "prev"
vector is updated to indicate that vertex 0 led to the discovery of vertices 1 and 2.
2. Next, vertex 1 is removed from the queue, and its adjacent vertex 3 is discovered. The
"prev" vector is updated to indicate that vertex 1 led to the discovery of vertex 3.
3. Vertex 2 is removed from the queue, but it doesn't discover any new vertices.
4. Vertex 3 is removed from the queue, and its adjacent vertex 4 is discovered. The "prev"
vector is updated to indicate that vertex 3 led to the discovery of vertex 4.
5. Now, to determine the path from vertex 0 to vertex 4, we start at vertex 4 and follow
the "prev" vector back to the start. Vertex 4 was discovered by vertex 3, vertex 3 was
discovered by vertex 1, and vertex 1 was discovered by vertex 0. Therefore, the path
from vertex 0 to vertex 4 is 0 -> 1 -> 3 -> 4.
This BFS example was for an adjacency matrix, but of course, you can modify it to work
with adjacency lists.
BFS calculates the shortest (least number of edges) paths in unweighted graphs. If the
graph was weighted (e.g., if the edges had different costs), then BFS would not necessarily
find the shortest (lowest total weight) path.
Let’s try to implement DFS on an adjacency list:
#include <iostream>
#include <vector>

// Define a GraphEdge structure to represent an edge in a graph, consisting of


the destination vertex and the weight of the edge.
struct GraphEdge {
int to;
int weight;
};

// Define a type alias for a weighted adjacency list representation of a graph.


using WeightedAdjacencyList = std::vector<std::vector<GraphEdge>>;

// Recursive function to traverse through the graph. It keeps track of the vert
ices visited and the path taken.
bool walk(WeightedAdjacencyList& graph, int curr, int target, std::vector<bool>
& seen, std::vector<int>& path) {
// If the current vertex has been visited before, return false.
if (seen[curr]) {
return false;
}

// Mark the current vertex as visited.


seen[curr] = true;

// Add the current vertex to the path.


path.push_back(curr);

// If the current vertex is the target, return true.


if (curr == target) {
return true;
}

// Get the list of edges from the current vertex.


std::vector<GraphEdge>& list = graph[curr];

// Iterate over each edge.


for (int i = 0; i < list.size(); ++i) {
GraphEdge& edge = list[i];

// Recurse on the destination vertex of the edge.


if (walk(graph, edge.to, target, seen, path)) {
return true;
}
}

// If there is no path to the target, remove the current vertex from the pa
th and return false.
path.pop_back();
return false;
}

// Function to perform depth-


first search (DFS) on the graph from a start vertex to a target vertex.
std::vector<int> dfs(WeightedAdjacencyList& graph, int start, int target) {
// Initialize a vector to keep track of the vertices visited.
std::vector<bool> seen(graph.size(), false);

// Initialize a vector to keep track of the path.


std::vector<int> path;

// Start the walk from the start vertex.


walk(graph, start, target, seen, path);

// If no path is found, return an empty vector.


if (path.size() == 0) {
return std::vector<int>();
}

// Return the path.


return path;
}

int main()
{
// Create a weighted graph
WeightedAdjacencyList graph = {
{{1, 2}, {2, 3}}, // edges from vertex 0
{{2, 2}}, // edges from vertex 1
{{3, 2},{4, 2}}, // edges from vertex 2
{{2, 4}}, // edges from vertex 3
{} // edges from vertex 4
};

// Perform depth-first search from vertex 0 to 4


std::vector<int> path = dfs(graph, 0, 4);

// Print the path


if (path.empty()) {
std::cout << "No path found." << '\n';
}
else {
std::cout << "Path: ";
for (int vertex : path) {
std::cout << vertex << " ";
}
std::cout << '\n';
}

return 0;
}

First, let’s look at the implementation of the adjacency list.


• GraphEdge: Represents an edge in the graph. Each edge has a to attribute indicating the
destination vertex and a weight attribute representing the cost or weight associated with
traversing that edge.
• WeightedAdjacencyList: Represents the entire graph as a vector of vectors of GraphEdge.
Each element in the outer vector represents a vertex, and the inner vector stores the edges
originating from that vertex.
Let’s analyze the output for the given example graph.
The example graph is this:

The output is
Path: 0 1 2 4
Here is how the algorithm works:

1. We start from the vertex 0. We mark it as visited and add it to the path.

2. We explore its neighbors. The first neighbor is 1.

3. We move to vertex 1. We mark it as visited and add it to the path.

4. We explore its neighbors. The first (and only) neighbor is 2.

5. We move to vertex 2. We mark it as visited and add it to the path.

6. We explore its neighbors. The first neighbor is 3, but it does not allow us to go to 4, so we
take the next, 4.

7. We move to vertex 4. We have reached the target vertex, so we return true.

If you just look at the graph, you might think 0 -> 2 -> 4 is a valid path. That's correct. But it doesn't
mean the code is wrong, it's just that DFS chose a different path.
Why would we see different paths with DFS than what we might visually expect in an unweighted
graph?
1. The order in which the edges are stored in the WeightedAdjacencyList can influence the
order of exploration. If the edge (0, 1) comes before (0, 2) in the list for node 0, DFS will
explore the path through node 1 first.
2. DFS prioritizes going deep. It won't consider other options at the same level until it has fully
explored one branch. So, if it finds a path to the target through one branch, it won't find
other branches that might be shorter.

DFS doesn't guarantee finding the shortest path, and the order of exploration is affected by the
structure of the adjacency list and the recursive nature of the algorithm.

You might also like