Graph Algorithms: Traversal, Pathfinding, Ranking Internals

intermediate 7 min read updated 27 Jul 2026
On this page 4

Graph Traversal: BFS and DFS Mechanics

Exploring a graph systematically requires a strategy to visit every reachable node without redundant processing. Breadth-First Search (BFS) and Depth-First Search (DFS) are foundational algorithms for this task. Both ensure every node is visited exactly once, provided it is reachable from the starting point.

BFS explores a graph level by level. It starts at a source node, then visits all its direct neighbors, then all their unvisited neighbors, and so on. This process uses a queue to manage the order of nodes to visit. When a node is dequeued, its unvisited neighbors are enqueued and marked as visited. This guarantees that nodes closer to the source are processed before nodes further away.

Consider a simple BFS implementation:

from collections import deque

def bfs(graph, start_node):
    visited = {start_node}
    queue = deque([start_node])
    traversal_order = []

    while queue:
        current_node = queue.popleft()
        traversal_order.append(current_node)

        for neighbor in graph[current_node]:
            if neighbor not in visited:
                visited.add(neighbor)
                queue.append(neighbor)
    return traversal_order

DFS explores a graph by going as deep as possible along each branch before backtracking. It starts at a source node, then picks one unvisited neighbor and explores from there. This continues until no unvisited neighbors remain along the current path, at which point it backtracks to the most recent node with unvisited neighbors. DFS uses a stack, either explicitly or implicitly through recursion, to manage node processing.

A recursive DFS implementation:

def dfs_recursive(graph, start_node, visited=None, traversal_order=None):
    if visited is None:
        visited = set()
    if traversal_order is None:
        traversal_order = []

    visited.add(start_node)
    traversal_order.append(start_node)

    for neighbor in graph[start_node]:
        if neighbor not in visited:
            dfs_recursive(graph, neighbor, visited, traversal_order)
    return traversal_order

The primary distinction lies in their exploration order. BFS finds the shortest path in unweighted graphs because it expands uniformly outwards. Its memory cost can be high for graphs with many neighbors at each level. DFS is suitable for tasks like topological sorting or detecting cycles. Its memory cost depends on the graph’s depth, potentially consuming less memory for wide, shallow graphs compared to BFS.

Dijkstra and A*: Shortest Path Computations

Finding the shortest path between two points in a graph is a common requirement in network routing, logistics, and resource allocation. Dijkstra’s algorithm and A* search are two fundamental methods for solving this problem, each with distinct characteristics and applications.

Dijkstra’s algorithm computes the shortest path from a single source node to all other nodes in a graph. It operates on graphs with non-negative edge weights. The algorithm maintains a set of visited nodes and a priority queue of unvisited nodes, ordered by their current shortest distance from the source. It iteratively extracts the node with the smallest known distance, marks it visited, and updates the distances of its neighbors. With a binary heap, Dijkstra’s algorithm runs in O(E log V) time, where E is the number of edges and V is the number of vertices.

A* search extends Dijkstra’s by incorporating a heuristic function, h(n), which estimates the cost from node n to the target. Instead of prioritizing nodes solely by their actual cost from the source (g(n)), A* uses f(n) = g(n) + h(n). This f(n) value guides the search, directing it towards the goal more efficiently than Dijkstra’s undirected exploration.

For A* to guarantee an optimal path, the heuristic h(n) must be admissible. An admissible heuristic never overestimates the actual cost to the target. For grid-based pathfinding, Manhattan distance or Euclidean distance are common admissible heuristics. When an effective heuristic is available, A* often finds the shortest path significantly faster than Dijkstra’s, particularly in large graphs with a specific target.

Dijkstra’s algorithm is simpler to implement and guarantees optimality for all-pairs shortest paths or when no suitable heuristic is available. A* is faster for single-source-single-destination problems, but requires an admissible heuristic. If the heuristic is poor or non-admissible, A* may perform worse than Dijkstra or fail to find the optimal path.

PageRank: Identifying Influential Nodes

PageRank assigns a numerical score to each node in a directed graph, quantifying its relative importance within the network. This score reflects both the quantity and quality of incoming links. A node receives a higher score if many other nodes link to it, especially if those linking nodes themselves have high scores.

The algorithm operates iteratively. Each node initially receives an equal share of the total “rank.” In subsequent iterations, a node distributes its current score evenly among its outgoing links. Conversely, a node’s new score is calculated as the sum of the scores it receives from its incoming links. This process simulates a random web surfer navigating the graph.

A damping factor (d), typically set to 0.85, is introduced to prevent “rank sinks” and ensure all nodes can be reached. This factor represents the probability that a hypothetical surfer continues clicking links rather than jumping to a random page. The remaining (1-d) probability is distributed uniformly among all nodes, preventing nodes with no incoming links from having a zero score and nodes with no outgoing links from accumulating all rank.

The PageRank score for a node A is updated using the formula:

PR(A) = (1 - d) / N + d * Σ (PR(Ti) / C(Ti))

Here, N is the total number of nodes, d is the damping factor, Ti represents any node linking to A, PR(Ti) is the PageRank of node Ti, and C(Ti) is the number of outgoing links from node Ti. The summation covers all nodes Ti that link to A. The algorithm converges when node scores stabilize between iterations.

Consider a simplified calculation for a node C in a graph where A and B link to C:

# Conceptual representation of PageRank score distribution
# This fragment illustrates how a single node's score might be updated.

scores = {'A': 0.333, 'B': 0.333, 'C': 0.333} # Initial uniform scores
graph = {
    'A': ['B', 'C'],
    'B': ['C'],
    'C': ['A']
}
damping_factor = 0.85
num_nodes = len(scores)

# Calculate out-degrees for score distribution
out_degrees = {node: len(links) for node, links in graph.items()}

# Update score for node 'C'
node_to_update = 'C'
incoming_link_contributions = 0.0

# Nodes linking to 'C' are 'A' and 'B'
if 'A' in graph and node_to_update in graph['A']:
    incoming_link_contributions += scores['A'] / out_degrees['A']

if 'B' in graph and node_to_update in graph['B']:
    incoming_link_contributions += scores['B'] / out_degrees['B']

new_score_C = (1 - damping_factor) / num_nodes + damping_factor * incoming_link_contributions

print(f"Initial scores: {scores}")
print(f"Contribution to C from A: {scores['A'] / out_degrees['A']:.4f}")
print(f"Contribution to C from B: {scores['B'] / out_degrees['B']:.4f}")
print(f"New score for C after one conceptual update: {new_score_C:.4f}")
Initial scores: {'A': 0.333, 'B': 0.333, 'C': 0.333}
Contribution to C from A: 0.1665
Contribution to C from B: 0.3330
New score for C after one conceptual update: 0.4907

PageRank’s primary application is ranking web pages in search engine results. Beyond search, it finds use in recommendation systems to identify influential users or items, in social network analysis to pinpoint central figures, and in scientific literature to rank the importance of academic papers based on citations.

Algorithm Application: Common Pitfalls and Performance

Selecting the correct graph algorithm depends on graph structure and problem constraints. A common mistake is using an algorithm optimized for dense graphs on a sparse graph, or vice-versa, leading to suboptimal performance. For instance, a simple Dijkstra implementation using an array to find the minimum distance node is O(V^2). This is efficient for dense graphs where the number of edges, E, approaches V^2.

However, on sparse graphs where E << V^2, a priority queue (like a binary heap) reduces Dijkstra’s complexity to O(E log V). This makes a significant difference for large graphs with few edges. Consider a graph with 10^5 vertices and 10^6 edges: the array scan leads to approximately 10^10 operations, while a binary heap reduces this to roughly 1.7 * 10^7 operations (10^6 * log₂10^5 ≈ 10^6 * 16.6).

Graph representation also impacts performance. Adjacency matrices require O(V^2) memory, which is prohibitive for large V, even if the graph is sparse. Adjacency lists use O(V + E) memory, making them suitable for sparse graphs and often better for cache locality during traversal operations.

Incorrectly handling edge cases causes bugs or infinite loops. Pathfinding algorithms like Dijkstra assume non-negative edge weights. Introducing negative weights without using Bellman-Ford or SPFA yields incorrect results. Detecting negative cycles is crucial; their presence means no shortest path exists between affected nodes.

Implementation details of backing data structures are equally important. Using std::vector<bool> for visited nodes in C++ can be slower than std::vector<char> due to bit packing, despite its memory efficiency. Similarly, a poorly chosen hash function for std::unordered_set can degrade average-case O(1) lookups to O(N) in worst-case scenarios.

Optimizing performance often involves a tradeoff. A simpler algorithm might be easier to implement and debug for small graphs, but it loses scalability for larger inputs. For example, a breadth-first search on an unweighted graph is simpler than Dijkstra, but it cannot find shortest paths on weighted graphs.