Core Algorithms and Patterns Part 2
July 26, 2025
Table of Contents
Core Algorithmic Techniques Part 2
Depth-First Search (DFS)
Definition: Depth-First Search (DFS) is a fundamental traversal algorithm for trees and graphs. The core principle is to explore as far as possible down one branch before backtracking. It operates on a LIFO (Last-In, First-Out) basis, which is naturally implemented using recursion (which uses the implicit call stack) or an explicit stack data structure. When faced with a choice of nodes to visit, DFS will always go deeper into the graph first, only exploring sibling nodes after the entire path from a child has been exhausted.
Time Complexity:O(V + E)
for an explicit graph represented by an adjacency list, whereV
is the number of vertices andE
is the number of edges.- For implicit graphs like a grid or matrix of size
R x C
, the complexity isO(R * C)
, as each cell is visited at most once.
O(H)
, whereH
is the maximum depth of the recursion or the maximum size of the explicit stack. In a graph, this represents the longest path from the source to a leaf.- In the worst case (e.g., a skewed tree or a long, snake-like path in a grid), this can be
O(V)
orO(R * C)
. For a balanced tree, it isO(log N)
. - This does not include the space for a
visited
set, which can also be up toO(V)
.
- Simple Implementation: The recursive version of DFS is often very intuitive and requires minimal code.
- Space Efficiency: For graphs that are very wide but not very deep, DFS can be more space-efficient than Breadth-First Search (BFS).
- Path Finding: It is excellent for problems where you simply need to find if a path exists between two nodes, or for detecting cycles in a graph.
- Non-Optimal Paths: DFS does not guarantee finding the shortest path between two nodes in an unweighted graph; BFS is used for that purpose.
- Stack Overflow Risk: For very deep graphs or trees, the recursive implementation can exceed the call stack limit and cause a stack overflow error. In such cases, an iterative approach with an explicit stack is safer.
Problem: Number of Islands
- Given an
m x n
2D binary gridgrid
which represents a map of '1's (land) and '0's (water), return the number of islands. - An island is surrounded by water and is formed by connecting adjacent lands horizontally or vertically.
- You may assume all four edges of the grid are all surrounded by water.
This process uses DFS to count the number of distinct islands in a 2D grid of '1's (land) and '0's (water).
- Initialize: Set
island_count = 0
. You will also need a way to track visited cells, which can be done by creating a separatevisited
grid or by modifying the input grid in-place (e.g., changing '1's to '#'). - Iterate Grid: Loop through every cell
(row, col)
of the grid. - Find a New Island: If the current cell is land ('1') and has not been visited:
- You have found the start of a new, uncounted island. Increment
island_count
. - Begin a DFS traversal from this starting cell to find and mark all connected land cells belonging to this same island.
- You have found the start of a new, uncounted island. Increment
- DFS Helper Function: The
dfs(row, col)
function will do the following:- Base Case: Check if the current
(row, col)
is out of the grid's bounds or if the cell is water ('0') or has already been visited. If any of these are true, stop and return. - Mark as Visited: Mark the current cell
(row, col)
as visited. - Explore Neighbors: Recursively call the DFS helper function for all four adjacent neighbors (up, down, left, and right).
- Base Case: Check if the current
- Terminate: After the main loop has checked every cell,
island_count
will hold the total number of distinct islands.
This code solves "Number of Islands" (#200).
def num_islands(grid: list[list[str]]) -> int:
"""
Counts the number of islands in a grid.
"""
if not grid:
return 0
rows, cols = len(grid), len(grid[0])
island_count = 0
def dfs(r, c):
# Base case: check for out of bounds or water
if r < 0 or c < 0 or r >= rows or c >= cols or grid[r][c] == '0':
return
# Mark the current cell as visited by changing its value
grid[r][c] = '0'
# Explore neighbors
dfs(r + 1, c)
dfs(r - 1, c)
dfs(r, c + 1)
dfs(r, c - 1)
for r in range(rows):
for c in range(cols):
if grid[r][c] == '1':
island_count += 1
dfs(r, c)
return island_count
# Example Usage
grid = [
["1","1","1","1","0"],
["1","1","0","1","0"],
["1","1","0","0","0"],
["0","0","0","0","0"]
]
print(f"Number of islands: {num_islands(grid)}") # Expected Output: 1
Java Example:
public class NumberOfIslands {
public int numIslands(char[][] grid) {
if (grid == null || grid.length == 0) {
return 0;
}
int rows = grid.length;
int cols = grid[0].length;
int islandCount = 0;
for (int r = 0; r < rows; r++) {
for (int c = 0; c < cols; c++) {
if (grid[r][c] == '1') {
islandCount++;
dfs(grid, r, c);
}
}
}
return islandCount;
}
private void dfs(char[][] grid, int r, int c) {
int rows = grid.length;
int cols = grid[0].length;
// Base case for recursion
if (r < 0 || c < 0 || r >= rows || c >= cols || grid[r][c] == '0') {
return;
}
// Mark as visited by "sinking" the island part
grid[r][c] = '0';
// Explore neighbors
dfs(grid, r + 1, c);
dfs(grid, r - 1, c);
dfs(grid, r, c + 1);
dfs(grid, r, c - 1);
}
public static void main(String[] args) {
char[][] grid = {
{'1','1','0','0','0'},
{'1','1','0','0','0'},
{'0','0','1','0','0'},
{'0','0','0','1','1'}
};
NumberOfIslands solver = new NumberOfIslands();
// Expected Output: Number of islands: 3
System.out.println("Number of islands: " + solver.numIslands(grid));
}
}
Visual Example:
Processing a grid to count islands:
Grid:
[1, 1, 0]
[1, 0, 1]
[0, 0, 1]
1. Main loop starts at (0,0). It's '1'.
- island_count = 1.
- Start DFS at (0,0).
- DFS visits (0,0), (0,1), (1,0) and marks them as '0' (or visited).
- All connected '1's are now visited. DFS ends.
- Grid becomes: [0, 0, 0], [0, 0, 1], [0, 0, 1]
2. Main loop continues. Skips all '0's. Reaches (1,2). It's '1'.
- island_count = 2.
- Start DFS at (1,2).
- DFS visits (1,2) and (2,2). Marks them as '0'.
- Grid becomes: [0, 0, 0], [0, 0, 0], [0, 0, 0]
3. Main loop finishes.
Final count: 2.
Example Problems:
- Number of Islands (#200, Medium): The canonical grid traversal problem using DFS.
- Max Area of Island (#695, Medium): A variation where the DFS function must also return the size of the island it just explored.
- Clone Graph (#133, Medium): A classic graph problem where DFS is used to traverse the original graph while creating a deep copy.
- Course Schedule (#207, Medium): A directed graph problem where DFS is used to detect cycles, which would indicate that the schedule is impossible.
- Number of Connected Components in an Undirected Graph (#323, Medium): The graph equivalent of Number of Islands.
Breadth-First Search (BFS)
Definition: Breadth-First Search (BFS) is a fundamental traversal algorithm for trees and graphs that explores nodes "level by level." It starts at a source node and visits all of its immediate neighbors before moving on to the next level of nodes. This process is managed using a queue data structure, which enforces a FIFO (First-In, First-Out) strategy. Unlike Depth-First Search (DFS), which goes as deep as possible, BFS explores layer by layer, making it ideal for finding the shortest path in an unweighted graph.
Time Complexity:O(V + E)
for an explicit graph represented by an adjacency list, whereV
is the number of vertices andE
is the number of edges.- For implicit graphs like a tree (
N
nodes) or a grid (R x C
), the complexity isO(N)
orO(R * C)
, as each node is enqueued and dequeued exactly once.
O(W)
, whereW
is the maximum width of the graph or tree at any level.- In the worst case of a wide, shallow graph or a complete binary tree, the number of nodes in the queue can be proportional to
N
(up toN/2
nodes on the last level of a balanced tree), leading toO(N)
space complexity.
- Finds Shortest Path: BFS is guaranteed to find the shortest path (in terms of the number of edges) between a starting node and any other node in an unweighted graph. This is its most significant advantage.
- Level-by-Level Traversal: It is naturally suited for problems where you need to process nodes in order of their proximity to the source, such as level-order traversal of a tree.
- Memory Intensive: For graphs that are very wide, the queue can become very large, leading to high memory consumption. DFS might be more space-efficient for tall, narrow graphs.
- Slower for Deep Paths: If the target node is very deep in the graph and far from the source, BFS may be slower than DFS at finding it, as BFS must explore every node at every intermediate level first.
Problem: Binary Tree Level Order Traversal
- Given the
root
of a binary tree, return the level order traversal of its nodes' values (i.e., from left to right, level by level). - Return the result as a list of lists, where each inner list contains the values of nodes at that level.
This process traverses a binary tree level by level.
- Handle Edge Case: If the
root
node is null, return an empty list. - Initialize: Create an empty
result
list to store the final level-by-level output. Create aqueue
and add theroot
node to it. - Loop by Level: Begin a
while
loop that continues as long as thequeue
is not empty. - Process Current Level:
- Before processing, get the number of nodes currently in the queue (
level_size
). This is the number of nodes at the current level. - Create a new list,
current_level
, to store the values of the nodes at this depth.
- Before processing, get the number of nodes currently in the queue (
- Dequeue and Enqueue Children: Start a
for
loop that runslevel_size
times. In each iteration:Dequeue
the node from the front of the queue.- Add its value to the
current_level
list. - If the dequeued node has a non-null left child,
enqueue
the left child. - If the dequeued node has a non-null right child,
enqueue
the right child.
- Store Level Result: After the
for
loop completes, thecurrent_level
list is fully populated. Add it to theresult
list. - Terminate: The
while
loop repeats until the queue is empty, at which point all levels have been processed. Return theresult
list.
This code solves "Binary Tree Level Order Traversal" (#102).
import collections
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
def level_order(root: TreeNode) -> list[list[int]]:
"""
Performs a level-order traversal of a binary tree.
"""
if not root:
return []
result = []
queue = collections.deque([root])
while queue:
level_size = len(queue)
current_level = []
for _ in range(level_size):
node = queue.popleft()
current_level.append(node.val)
if node.left:
queue.append(node.left)
if node.right:
queue.append(node.right)
result.append(current_level)
return result
# Example Usage: Tree = [3, 9, 20, null, null, 15, 7]
# 3
# / \
# 9 20
# / \
# 15 7
root = TreeNode(3, TreeNode(9), TreeNode(20, TreeNode(15), TreeNode(7)))
# Expected Output: [[3], [9, 20], [15, 7]]
print(f"Level order traversal: {level_order(root)}")
Java Example:
import java.util.*;
class TreeNode {
int val;
TreeNode left;
TreeNode right;
TreeNode(int val) { this.val = val; }
TreeNode(int val, TreeNode left, TreeNode right) {
this.val = val; this.left = left; this.right = right;
}
}
public class BfsExample {
public List<List<Integer>> levelOrder(TreeNode root) {
List<List<Integer>> result = new ArrayList<>();
if (root == null) {
return result;
}
Queue<TreeNode> queue = new LinkedList<>();
queue.add(root);
while (!queue.isEmpty()) {
int levelSize = queue.size();
List<Integer> currentLevel = new ArrayList<>();
for (int i = 0; i < levelSize; i++) {
TreeNode node = queue.poll();
currentLevel.add(node.val);
if (node.left != null) {
queue.add(node.left);
}
if (node.right != null) {
queue.add(node.right);
}
}
result.add(currentLevel);
}
return result;
}
public static void main(String[] args) {
TreeNode root = new TreeNode(3, new TreeNode(9), new TreeNode(20, new TreeNode(15), new TreeNode(7)));
BfsExample solver = new BfsExample();
// Expected Output: Level order traversal: [[3], [9, 20], [15, 7]]
System.out.println("Level order traversal: " + solver.levelOrder(root));
}
}
Visual Example:
Level-order traversal of a tree:
Tree:
A
/ \
B C
/ / \
D E F
1. Initial:
- result = []
- queue = [A]
2. Level 1:
- level_size = 1. Dequeue A. Add B, C to queue.
- result = [[A]]
- queue = [B, C]
3. Level 2:
- level_size = 2. Dequeue B, add D. Dequeue C, add E, F.
- result = [[A], [B, C]]
- queue = [D, E, F]
4. Level 3:
- level_size = 3. Dequeue D, E, F. No children to add.
- result = [[A], [B, C], [D, E, F]]
- queue = []
5. Queue is empty. Loop terminates.
Final result: [[A], [B, C], [D, E, F]]
Example Problems:
- Binary Tree Level Order Traversal (#102, Medium): The canonical problem for this pattern.
- Rotting Oranges (#994, Medium): A classic grid-based problem where BFS is used to find the minimum time for a process to spread to all reachable nodes.
- Minimum Depth of Binary Tree (#111, Easy): BFS is ideal for this because the first leaf node it encounters is guaranteed to be at the minimum depth.
- Word Ladder (#127, Hard): A graph problem where nodes are words and edges connect words that are one letter apart. BFS is used to find the shortest transformation sequence.
- Binary Tree Zigzag Level Order Traversal (#103, Medium): A variation that requires alternating the direction of traversal at each level.
Binary Tree Traversal
Definition: Binary Tree Traversal is the fundamental process of visiting (e.g., reading or processing) each node in a binary tree exactly once. The order in which the nodes are visited defines the traversal type and is chosen based on the problem to be solved. There are two main approaches: Depth-First Search (DFS) and Breadth-First Search (BFS).
The four primary traversal orders are:
- In-order (Left, Root, Right): A DFS approach that recursively visits the left subtree, processes the current node (the "root" of that subtree), and finally visits the right subtree. For a Binary Search Tree (BST), this traversal yields the node values in sorted order.
- Pre-order (Root, Left, Right): A DFS approach that processes the current node first, then recursively visits the left and right subtrees. This is useful for creating a copy of a tree or serializing it.
- Post-order (Left, Right, Root): A DFS approach that recursively visits the left and right subtrees before processing the current node. This is useful when you need to process child nodes before the parent, such as when deleting nodes from a tree.
- Level-order (BFS): This approach visits nodes level by level, from top to bottom, and left to right within each level. It uses BFS and is implemented with a queue. It is ideal for finding the shortest path from the root to any other node.
O(N)
for all traversal types, whereN
is the number of nodes in the tree. Each node must be visited once.
The space complexity depends on the traversal type and implementation (recursive vs. iterative).
- DFS (Recursive):
O(H)
, whereH
is the height of the tree. This space is used by the call stack. For a balanced tree, this isO(log N)
; for a completely skewed tree, it becomesO(N)
. - DFS (Iterative):
O(H)
, as the explicit stack used will hold at mostH
nodes. - BFS (Level-order):
O(W)
, whereW
is the maximum width of the tree. In the worst case (a complete, balanced tree), the last level can contain up toN/2
nodes, making the space complexityO(N)
.
- Systematic Processing: Provides a clear and exhaustive way to interact with every node in a tree.
- Problem-Specific Orders: The different traversal orders are tailored to solve specific types of problems efficiently. For example, using in-order traversal on a BST is a simple way to get a sorted list of its elements.
- Stack Overflow Risk: The recursive DFS approaches are elegant but can cause a stack overflow error if the tree is very deep (i.e., highly unbalanced or skewed).
- Iterative Complexity: Iterative DFS traversals, especially post-order, can be more complex to implement correctly compared to their recursive counterparts.
Problem: Binary Tree Inorder Traversal
- Given the
root
of a binary tree, return the inorder traversal of its nodes' values. - Inorder traversal visits nodes in the order: Left subtree, Root, Right subtree.
- Follow up: Recursive solution is trivial, could you do it iteratively?
This process visits nodes in the (Left, Root, Right) order using a stack.
- Initialize: Create an empty
stack
and an emptyresult
list. Initialize acurrent
node pointer to theroot
of the tree. - Loop: Start a loop that continues as long as
current
is not null or thestack
is not empty. - Traverse Left Subtree: While
current
is not null, pushcurrent
onto the stack and move the pointer to its left child (current = current.left
). This phase ensures we go as far left as possible. - Visit Node and Go Right: When
current
becomes null, it means we can't go further left.- Pop the last node from the stack. This is the node we "visit." Add its value to the
result
list. - Set the
current
pointer to the right child of the popped node (current = popped_node.right
). The main loop will then handle this right subtree, starting again by traversing its left path.
- Pop the last node from the stack. This is the node we "visit." Add its value to the
- Terminate: The loop ends when
current
is null and the stack is empty, signifying all nodes have been visited.
This code solves "Binary Tree Inorder Traversal" (#94) and includes both the recursive and iterative solutions. A simple TreeNode
class is included for context.
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
# --- Recursive Solution ---
def inorder_traversal_recursive(root: TreeNode) -> list[int]:
result = []
def dfs(node):
if not node:
return
dfs(node.left)
result.append(node.val)
dfs(node.right)
dfs(root)
return result
# --- Iterative Solution ---
def inorder_traversal_iterative(root: TreeNode) -> list[int]:
result = []
stack = []
current = root
while current or stack:
# Go as far left as possible
while current:
stack.append(current)
current = current.left
# Pop, visit, and go right
current = stack.pop()
result.append(current.val)
current = current.right
return result
# Example Usage: Tree = [1, null, 2, 3]
# 1
# \
# 2
# /
# 3
root = TreeNode(1, None, TreeNode(2, TreeNode(3)))
print(f"Recursive In-order: {inorder_traversal_recursive(root)}") # Expected: [1, 3, 2]
print(f"Iterative In-order: {inorder_traversal_iterative(root)}") # Expected: [1, 3, 2]
Java Example:
import java.util.ArrayList;
import java.util.List;
import java.util.Stack;
class TreeNode {
int val;
TreeNode left;
TreeNode right;
TreeNode(int val) { this.val = val; }
}
public class TreeTraversalExample {
// --- Recursive Solution ---
public List<Integer> inorderTraversalRecursive(TreeNode root) {
List<Integer> result = new ArrayList<>();
dfs(root, result);
return result;
}
private void dfs(TreeNode node, List<Integer> result) {
if (node == null) return;
dfs(node.left, result);
result.add(node.val);
dfs(node.right, result);
}
// --- Iterative Solution ---
public List<Integer> inorderTraversalIterative(TreeNode root) {
List<Integer> result = new ArrayList<>();
Stack<TreeNode> stack = new Stack<>();
TreeNode current = root;
while (current != null || !stack.isEmpty()) {
while (current != null) {
stack.push(current);
current = current.left;
}
current = stack.pop();
result.add(current.val);
current = current.right;
}
return result;
}
public static void main(String[] args) {
// Tree = [1, null, 2, 3]
TreeNode root = new TreeNode(1);
root.right = new TreeNode(2);
root.right.left = new TreeNode(3);
TreeTraversalExample solver = new TreeTraversalExample();
System.out.println("Recursive In-order: " + solver.inorderTraversalRecursive(root));
System.out.println("Iterative In-order: " + solver.inorderTraversalIterative(root));
}
}
Visual Example:
For a tree with root F
, left child B
, right child G
, etc.:
F
/ \
B G
/ \ \
A D I
/ \ /
C E H
- Pre-order (Root, L, R): F, B, A, D, C, E, G, I, H
- In-order (L, Root, R): A, B, C, D, E, F, G, H, I
- Post-order (L, R, Root): A, C, E, D, B, H, I, G, F
- Level-order (BFS): F, B, G, A, D, I, C, E, H
- Binary Tree Inorder Traversal (#94, Easy): The canonical problem for this specific traversal.
- Binary Tree Level Order Traversal (#102, Medium): The canonical problem for BFS traversal.
- Validate Binary Search Tree (#98, Medium): An in-order traversal of a valid BST will produce a sorted sequence of nodes, a property often used in solutions.
- Maximum Depth of Binary Tree (#104, Easy): Can be solved elegantly with any DFS traversal (Pre-order, In-order, or Post-order).
- Same Tree (#100, Easy): Requires traversing two trees simultaneously to check for structural and value equality.
- Subtree of Another Tree (#572, Easy): Often involves serializing trees using pre-order traversal and checking for substrings.
Matrix Traversal
Definition: Matrix Traversal refers to the family of algorithms used to visit and process cells in a 2D grid. Many matrix problems can be modeled as implicit graphs, where each cell (row, col)
is a node and its adjacent cells (up, down, left, right) are its neighbors. Consequently, the most common traversal techniques are graph algorithms like Depth-First Search (DFS) and Breadth-First Search (BFS). These are used for problems involving connectivity, pathfinding, and searching. Other matrix-specific patterns include spiral traversal and dynamic programming approaches. A crucial part of any matrix traversal is carefully handling boundary conditions to avoid going out of bounds.
O(R * C)
, whereR
is the number of rows andC
is the number of columns. In a typical traversal, each cell in the matrix is visited at most once.
- DFS (Recursive):
O(R * C)
in the worst case. The space is determined by the maximum depth of the recursion, which could be the entire grid in a snake-like path. - BFS (Iterative):
O(R * C)
in the worst case, as the queue could potentially hold all cells (e.g., a checkerboard pattern). - A
visited
set or grid of the same dimensions might also be needed, contributingO(R * C)
space if the input matrix cannot be modified.
- Structured Model: It provides a clear way to apply powerful graph traversal algorithms (DFS, BFS) to problems that are not explicitly defined as graphs.
- Versatile: This pattern is fundamental to a wide range of common interview problems, including finding connected components (islands), pathfinding (shortest path in a maze), and searching (word search).
- Boundary Condition Errors: Traversal code is prone to off-by-one errors or
IndexOutOfBounds
exceptions if boundary checks for rows and columns are not handled correctly for every neighbor. - State Management: It is essential to keep track of visited cells to prevent infinite loops and redundant computations. This can be done by modifying the input matrix (in-place) or using an auxiliary data structure.
Problem: Rotting Oranges
-
You are given an
m x n
grid where each cell can have one of three values:0
representing anempty
cell,1
representing afresh
orange, or2
representing arotten
orange.
-
Every minute, any fresh orange that is 4-directionally adjacent to a rotten orange becomes rotten.
-
Return the minimum number of minutes that must elapse until no cell has a fresh orange. If this is impossible, return -1.
This process uses BFS to find the minimum time for all fresh oranges to become rotten.
- Initial Scan and Setup:
- Initialize a
queue
for the BFS process. - Initialize a
fresh_oranges
counter to 0. - Iterate through the entire grid once to:
- Count the total number of
fresh_oranges
(value 1). - Add the starting coordinates of all initially
rotten
oranges (value 2) to thequeue
.
- Count the total number of
- Initialize a
- Initialize Time: Set
minutes = 0
. - BFS Loop: Start a
while
loop that continues as long as thequeue
is not empty and there are stillfresh_oranges
left to rot. - Process a Level (Pass one minute):
- Increment
minutes
. - Get the
level_size
of the queue to know how many currently rotting oranges will spread in this minute. - Start a
for
loop that runslevel_size
times.
- Increment
- Dequeue and Infect Neighbors:
- Inside the
for
loop,dequeue
a rotten orange's coordinates(r, c)
. - For each of its four neighbors (
(nr, nc)
), check if the neighbor is in-bounds and is a fresh orange (value 1). - If it is, "infect" it by changing its value to 2, decrement the
fresh_oranges
count, andenqueue
the neighbor's coordinates.
- Inside the
- Determine Final Result: After the loop terminates, check if the
fresh_oranges
count is 0.- If
fresh_oranges == 0
, it means all oranges rotted successfully. Returnminutes
. - If
fresh_oranges > 0
, some oranges were unreachable. Return -1.
- If
This code solves "Rotting Oranges" (#994).
import collections
def oranges_rotting(grid: list[list[int]]) -> int:
if not grid:
return 0
rows, cols = len(grid), len(grid[0])
queue = collections.deque()
fresh_oranges = 0
# Initial grid scan
for r in range(rows):
for c in range(cols):
if grid[r][c] == 1:
fresh_oranges += 1
elif grid[r][c] == 2:
queue.append((r, c))
if fresh_oranges == 0:
return 0
minutes = -1 # Start at -1 to account for the last empty level check
directions = [(0, 1), (0, -1), (1, 0), (-1, 0)]
# BFS loop
while queue:
level_size = len(queue)
minutes += 1
for _ in range(level_size):
r, c = queue.popleft()
for dr, dc in directions:
nr, nc = r + dr, c + dc
if 0 <= nr < rows and 0 <= nc < cols and grid[nr][nc] == 1:
grid[nr][nc] = 2
fresh_oranges -= 1
queue.append((nr, nc))
return minutes if fresh_oranges == 0 else -1
Java Example: (Rotting Oranges). Note: This uses ArrayDeque instead of LinkedList for the queue because it is the preferred implementation for queues in Java due to its better performance.
import java.util.ArrayDeque;
import java.util.Queue;
public class MatrixTraversalExample {
public int orangesRotting(int[][] grid) {
if (grid == null || grid.length == 0) return 0;
int rows = grid.length;
int cols = grid[0].length;
Queue<int[]> queue = new ArrayDeque<>();
int freshOranges = 0;
for (int r = 0; r < rows; r++) {
for (int c = 0; c < cols; c++) {
if (grid[r][c] == 2) {
queue.offer(new int[]{r, c});
} else if (grid[r][c] == 1) {
freshOranges++;
}
}
}
if (freshOranges == 0) return 0;
int minutes = -1; // Start at -1 because we count a level before processing it
int[][] directions = {{0, 1}, {0, -1}, {1, 0}, {-1, 0}};
while (!queue.isEmpty()) {
minutes++;
int levelSize = queue.size();
for (int i = 0; i < levelSize; i++) {
int[] point = queue.poll();
for (int[] dir : directions) {
int r = point[0] + dir[0];
int c = point[1] + dir[1];
if (r < 0 || c < 0 || r >= rows || c >= cols || grid[r][c] != 1) {
continue;
}
grid[r][c] = 2;
freshOranges--;
queue.offer(new int[]{r, c});
}
}
}
return freshOranges == 0 ? minutes : -1;
}
}
Visual Example:
grid = [
[2,1,1],
[1,1,0],
[0,1,1]
]
Initial State:
- fresh = 5
- queue = [(0,0)]
- minutes = -1
Minute 0 (level_size = 1, minutes = 0):
- Dequeue (0,0). Infect neighbors (0,1) and (1,0).
- fresh = 3
- queue = [(0,1), (1,0)]
- grid = [[2,2,1],[2,1,0],[0,1,1]]
Minute 1 (level_size = 2, minutes = 1):
- Dequeue (0,1). Infect (0,2).
- Dequeue (1,0). Infect (1,1).
- fresh = 1
- queue = [(0,2), (1,1)]
- grid = [[2,2,2],[2,2,0],[0,1,1]]
Minute 2 (level_size = 2, minutes = 2):
- Dequeue (0,2). No fresh neighbors.
- Dequeue (1,1). Infect (2,1).
- fresh = 0
- queue = [(2,1)]
- grid = [[2,2,2],[2,2,0],[0,2,1]]
Minute 3 (level_size = 1, minutes = 3):
- Dequeue (2,1). Infect (2,2).
- fresh = 0
- queue = [(2,2)]
- grid = [[2,2,2],[2,2,0],[0,2,2]]
Minute 4 (level_size = 1, minutes = 4):
- Dequeue (2,2). No fresh neighbors.
- queue = []
Loop ends. fresh_oranges is 0, so return `minutes` which is 4.
Example Problems:
- Number of Islands (#200, Medium): The canonical problem for using DFS or BFS on a grid.
- Rotting Oranges (#994, Medium): A classic multi-source BFS problem to find the minimum time for a process to spread.
- Word Search (#79, Medium): A classic backtracking problem that uses DFS to search for a word in a grid of characters.
- Spiral Matrix (#54, Medium): A different type of traversal that follows a spiral path, often solved by managing boundaries.
- Surrounded Regions (#130, Medium): A clever problem where you start traversals from the borders of the matrix to find regions that are not surrounded.
Top K Elements
Definition: The Top K Elements pattern describes a class of problems where the goal is to find a specific number (k
) of the most frequent, largest, or smallest elements in a collection. While sorting the entire collection and taking the first k
elements is a straightforward approach, it's often inefficient (O(N log N)
). The optimal approach for this pattern typically involves using a Heap (also known as a Priority Queue), which allows the solution to be found in O(N log K)
time. For frequency-based problems, a Hash Map is first used to count occurrences.
The core idea is to maintain a heap of size k
and iterate through the elements. For each element, you compare it to the top of the heap and decide whether to add the new element and remove the top, thereby always keeping the "top k" elements seen so far.
O(N log K)
. The process usually involves two passes. First, building a frequency map takesO(N)
. Second, iterating through theN
unique elements and adding them to a heap of sizek
takesO(log K)
for each element, resulting inO(N log K)
. This is more efficient than theO(N log N)
sorting approach whenk
is significantly smaller thanN
.
O(N + K)
. In the worst case, the frequency map can requireO(N)
space if all elements are unique. The heap requiresO(K)
space. This simplifies toO(N)
.
- Efficient: Far more time-efficient than sorting when you only need a subset of the elements (
k
) and not the entire sorted collection. - Adaptable: The same underlying heap-based logic can be used to find the largest, smallest, most frequent, or least frequent elements with minor tweaks (e.g., using a min-heap vs. a max-heap).
- Implementation Complexity: The solution is more complex to implement than a simple sort, requiring familiarity with Heaps/Priority Queues and, for frequency problems, Hash Maps.
- Space Usage: It's not an in-place algorithm and requires additional space for both the heap and potentially a frequency map, which can be a drawback for very large datasets.
Problem: Top K Frequent Elements
- Given an integer array
nums
and an integerk
, return thek
most frequent elements. - You may return the answer in any order.
- The answer is guaranteed to be unique.
This process uses a min-heap to find the k
most frequent elements.
- Frequency Count: Iterate through the input array and use a Hash Map to build a frequency count for each number. The key will be the number, and the value will be its frequency.
- Initialize Heap: Create a min-heap. This heap will store tuples or objects of
(frequency, number)
. A min-heap is used to easily keep track of the element with the smallest frequency currently in our set of topk
candidates. - Build Heap: Iterate through the items in the frequency map. For each
(number, frequency)
pair:- Push the
(frequency, number)
pair onto the min-heap. - If the heap's size grows larger than
k
, pop from the heap. Since it's a min-heap, this action removes the element with the lowest frequency, ensuring the heap only retains thek
most frequent elements seen so far.
- Push the
- Extract Result: After iterating through all the numbers, the min-heap will contain the
k
most frequent elements. Extract the numbers from the heap to build the final result list.
This code solves "Top K Frequent Elements" (#347).
import collections
import heapq
def top_k_frequent(nums: list[int], k: int) -> list[int]:
"""
Finds the k most frequent elements in an array.
"""
# 1. Build a frequency map.
# O(N) time.
if not nums:
return []
counts = collections.Counter(nums)
# 2. Use a min-heap to find the top k frequent elements.
# O(N log K) time.
min_heap = []
for num, freq in counts.items():
# Push a tuple of (frequency, number) onto the heap
heapq.heappush(min_heap, (freq, num))
# If the heap size exceeds k, pop the element with the smallest frequency
if len(min_heap) > k:
heapq.heappop(min_heap)
# 3. Extract the numbers from the heap to build the result.
result = [num for freq, num in min_heap]
return result
# Example Usage
nums = [1, 1, 1, 2, 2, 3]
k = 2
# Frequencies: {1: 3, 2: 2, 3: 1}. The top 2 are 1 and 2.
print(f"Top {k} frequent elements: {top_k_frequent(nums, k)}") # Expected Output: [2, 1] or [1, 2]
Java Example:
import java.util.*;
public class TopKElementsExample {
public static int[] topKFrequent(int[] nums, int k) {
// 1. Build frequency map - O(N)
Map<Integer, Integer> counts = new HashMap<>();
for (int num : nums) {
counts.put(num, counts.getOrDefault(num, 0) + 1);
}
// 2. Initialize a min-heap. The comparator sorts by frequency (the value).
// The lambda (a, b) -> a.getValue() - b.getValue() creates a min-heap on frequency.
PriorityQueue<Map.Entry<Integer, Integer>> minHeap =
new PriorityQueue<>((a, b) -> a.getValue() - b.getValue());
// 3. Build the heap - O(N log K)
for (Map.Entry<Integer, Integer> entry : counts.entrySet()) {
minHeap.add(entry);
if (minHeap.size() > k) {
minHeap.poll(); // Remove the element with the smallest frequency
}
}
// 4. Extract the result
int[] result = new int[k];
for (int i = 0; i < k; i++) {
result[i] = minHeap.poll().getKey();
}
return result;
}
public static void main(String[] args) {
int[] nums = {1, 1, 1, 2, 2, 3};
int k = 2;
int[] result = topKFrequent(nums, k);
// Expected Output: Top 2 frequent elements: [1, 2] (order may vary)
System.out.println("Top " + k + " frequent elements: " + Arrays.toString(result));
}
}
Visual Example:
Finding top 2 frequent elements in [1,1,1,2,2,3]
:
1. Frequency Map:
{1: 3, 2: 2, 3: 1}
2. Build Min-Heap (k=2):
- Process (num=1, freq=3): push (3,1). Heap: [(3,1)]
- Process (num=2, freq=2): push (2,2). Heap: [(2,2), (3,1)]
- Process (num=3, freq=1): push (1,3). Heap: [(1,3), (3,1), (2,2)]
- Heap size is 3 (>k). Pop smallest: (1,3) is popped.
- Heap: [(2,2), (3,1)]
3. Loop ends. Final Heap contains the top 2 elements.
- Heap: [(2,2), (3,1)]
4. Extract result: [2, 1]
Example Problems:
- Top K Frequent Elements (#347, Medium): The canonical problem for this pattern.
- Kth Largest Element in an Array (#215, Medium): Can be solved efficiently by maintaining a min-heap of size
k
. - K Closest Points to Origin (#973, Medium): Uses a max-heap to keep track of the
k
points with the smallest distances. - Top K Frequent Words (#692, Medium): A variation where ties in frequency must be resolved by alphabetical order, requiring a more complex heap comparator.
- Sort Characters By Frequency (#451, Medium): A similar problem where you sort the entire string based on character frequency, which can be solved efficiently with a heap or bucket sort.
Overlapping Intervals
Definition: The Overlapping Intervals pattern is a common algorithmic approach for problems involving ranges or time intervals (e.g., meeting schedules, geometric ranges). The core idea is that if you sort the intervals by their start times, you can process them in a single, linear pass to solve the problem. Once sorted, determining if two adjacent intervals overlap becomes a simple comparison. This sorted order allows for greedy decisions, such as merging overlapping intervals, counting required resources (like meeting rooms), or finding non-overlapping sets.
Time Complexity:O(N log N)
. The dominant operation is sorting theN
intervals. The subsequent merging or processing step is a single pass, which takesO(N)
time.
O(N)
orO(log N)
. The space complexity of the sorting algorithm can vary depending on the language's implementation (fromO(log N)
toO(N)
). Additionally, if the result requires creating a new list of intervals, it can take up toO(N)
space in the worst case (if no intervals are merged).
- Structured Approach: Provides a reliable and clear strategy for a whole class of interval-based problems. The "sort then process" pattern is very powerful.
- Efficient: Drastically simplifies the problem from a potential brute-force comparison of all pairs (
O(N^2)
) to a much fasterO(N log N)
solution.
- Sorting Bottleneck: The primary performance limitation is the
O(N log N)
time required for sorting. - Specific Data Type: The pattern is only applicable to problems that can be represented as a collection of 1-dimensional intervals, each having a start and an end.
Problem: Merge Intervals
- Given an array of
intervals
whereintervals[i] = [starti, endi]
, merge all overlapping intervals. - Return an array of the non-overlapping intervals that cover all the intervals in the input.
This process merges all overlapping intervals in a collection.
- Handle Edge Cases: If the input list has fewer than two intervals, no merging is possible. Return the list as is.
- Sort: Sort the list of intervals based on their start times in ascending order. This is the most crucial step.
- Initialize Result: Create a new list for the merged intervals (e.g.,
merged_intervals
) and add the very first interval from the sorted list to it. - Iterate and Merge: Loop through the sorted intervals, starting from the second one. For each
current_interval
:- Get a reference to the
last_interval
in themerged_intervals
list. - Check for Overlap: An overlap occurs if the
current_interval
's start is less than or equal to thelast_interval
's end. - If Overlap: Merge them by updating the
last_interval
's end to be the maximum of its current end and thecurrent_interval
's end. - If No Overlap: The intervals are distinct. Add the
current_interval
to themerged_intervals
list.
- Get a reference to the
- Terminate: After the loop finishes,
merged_intervals
contains the final, non-overlapping merged intervals.
This code solves the classic "Merge Intervals" problem (#56).
def merge(intervals: list[list[int]]) -> list[list[int]]:
"""
Merges all overlapping intervals.
"""
if not intervals:
return []
# 1. Sort intervals based on their start time
intervals.sort(key=lambda x: x[0])
merged = [intervals[0]]
for i in range(1, len(intervals)):
current_interval = intervals[i]
last_merged = merged[-1]
# Check for overlap: current_start <= last_end
if current_interval[0] <= last_merged[1]:
# Merge by updating the end of the last merged interval
last_merged[1] = max(last_merged[1], current_interval[1])
else:
# No overlap, add the new interval
merged.append(current_interval)
return merged
# Example Usage
intervals = [[1,3], [2,6], [8,10], [15,18]]
# Expected Output: [[1, 6], [8, 10], [15, 18]]
print(f"Merged intervals: {merge(intervals)}")
intervals2 = [[1,4], [4,5]]
# Expected Output: [[1, 5]]
print(f"Merged intervals: {merge(intervals2)}")
Java Example:
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
public class MergeIntervalsExample {
public static int[][] merge(int[][] intervals) {
if (intervals.length <= 1) {
return intervals;
}
// 1. Sort intervals based on start times
// The lambda (a, b) -> Integer.compare(a[0], b[0]) sorts by the first element.
Arrays.sort(intervals, (a, b) -> Integer.compare(a[0], b[0]));
List<int[]> merged = new ArrayList<>();
merged.add(intervals[0]);
for (int i = 1; i < intervals.length; i++) {
int[] currentInterval = intervals[i];
int[] lastMerged = merged.get(merged.size() - 1);
// Check for overlap: current_start <= last_end
if (currentInterval[0] <= lastMerged[1]) {
// Merge by updating the end of the last merged interval
lastMerged[1] = Math.max(lastMerged[1], currentInterval[1]);
} else {
// No overlap, add the new interval
merged.add(currentInterval);
}
}
return merged.toArray(new int[merged.size()][]);
}
public static void main(String[] args) {
int[][] intervals = {{1,3}, {2,6}, {8,10}, {15,18}};
int[][] result = merge(intervals);
// Expected Output: Merged intervals: [[1, 6], [8, 10], [15, 18]]
System.out.println("Merged intervals: " + Arrays.deepToString(result));
}
}
Visual Example:
Merging [[1,3],[8,10],[2,6],[15,18]]
:
1. Unsorted: [[1,3], [8,10], [2,6], [15,18]]
2. Sorted by start time: [[1,3], [2,6], [8,10], [15,18]]
3. Process:
- merged = []
- Add first interval [1,3].
- merged: [[1,3]]
- Current: [2,6]. Last in merged: [1,3].
- Does it overlap? Yes, 2 <= 3.
- Merge: Update end of [1,3] to max(3, 6) -> 6.
- merged: [[1,6]]
- Current: [8,10]. Last in merged: [1,6].
- Does it overlap? No, 8 > 6.
- Add [8,10] to merged.
- merged: [[1,6], [8,10]]
- Current: [15,18]. Last in merged: [8,10].
- Does it overlap? No, 15 > 10.
- Add [15,18] to merged.
- merged: [[1,6], [8,10], [15,18]]
4. Final Result: [[1,6], [8,10], [15,18]]
Example Problems:
- Merge Intervals (#56, Medium): The canonical problem for this pattern.
- Insert Interval (#57, Medium): Insert a new interval into a sorted list of non-overlapping intervals, then merge if necessary.
- Non-overlapping Intervals (#435, Medium): Find the minimum number of intervals you need to remove to make the rest non-overlapping.
- Meeting Rooms (#252, Easy): Given an array of meeting time intervals, determine if a person could attend all meetings.
- Meeting Rooms II (#253, Medium): Find the minimum number of conference rooms required to hold all meetings.
Modified Binary Search
Definition: A Modified Binary Search is an advanced application of the standard binary search algorithm. While traditional binary search operates on perfectly sorted data, the modified version is adapted for data structures or search spaces that are not strictly sorted but still possess a monotonic property that can be exploited. This allows you to eliminate half of the search space at each step, even in complex scenarios like rotated sorted arrays, bitonic arrays, or when searching for an answer within a range of potential values (e.g., finding the square root of a number). The key is to adjust the logic for how to narrow down the left
and right
boundaries based on the problem's unique conditions.
Monotonic means varying in such a way that it either never decreases or never increases. ie. a monotonic increasing array is an array where each element is greater than or equal to the previous element. A monotonic decreasing array is an array where each element is less than or equal to the previous element.
Time Complexity:O(log N)
, whereN
is the number of elements in the search space. This logarithmic complexity is the primary advantage of any binary search-based approach.
O(1)
for the standard iterative implementation, as it only requires a few variables to keep track of theleft
,right
, andmid
pointers.
- Extremely Efficient: It offers one of the best time complexities possible for search-related problems, making it suitable for very large datasets.
- Versatile: The core principle of "divide and conquer" can be applied to a wide range of problems, not just finding a value in an array. It can find minimums/maximums, insertion points, or a specific value that satisfies a condition within a range.
- Requires Monotonicity: The fundamental requirement is a sorted or monotonic property. If no such property exists, binary search cannot be applied.
- Complex Conditions: The logic to determine which half of the search space to discard is often more complex and error-prone than in a standard binary search. Off-by-one errors and handling boundary conditions (
<=
vs<
) require careful implementation.
Problem: Search in Rotated Sorted Array
- There is an integer array
nums
sorted in ascending order (with distinct values). - Prior to being passed to your function,
nums
is possibly rotated at an unknown pivot indexk
such that the resulting array is[nums[k], nums[k+1], ..., nums[n-1], nums[0], nums[1], ..., nums[k-1]]
. - Given the array
nums
after the possible rotation and an integertarget
, return the index oftarget
if it is innums
, or-1
if it is not innums
. - You must write an algorithm with
O(log n)
runtime complexity.
This process finds a target value in a sorted array that has been rotated at some unknown pivot.
- Initialize Pointers: Set
left = 0
andright = n - 1
, wheren
is the length of the array. - Loop: Continue as long as
left <= right
. - Find Midpoint: Calculate
mid = left + (right - left) // 2
to prevent potential integer overflow. - Check for Target: If
nums[mid] == target
, the element is found. Returnmid
. - Identify Sorted Half: Determine which part of the array is currently sorted.
- Check if the left half is sorted by comparing
nums[left] <= nums[mid]
.
- Check if the left half is sorted by comparing
- Decide Which Half to Search:
- If Left Half is Sorted: Check if the
target
is within the bounds of this sorted half (nums[left] <= target < nums[mid]
).- If yes, the target must be in the left half, so discard the right half:
right = mid - 1
. - If no, the target must be in the unsorted right half:
left = mid + 1
.
- If yes, the target must be in the left half, so discard the right half:
- If Right Half is Sorted: (This is the
else
case, meaningnums[left] > nums[mid]
). Check if thetarget
is within the bounds of this sorted right half (nums[mid] < target <= nums[right]
).- If yes, the target must be in the right half:
left = mid + 1
. - If no, the target must be in the unsorted left half:
right = mid - 1
.
- If yes, the target must be in the right half:
- If Left Half is Sorted: Check if the
- Terminate: If the loop finishes without finding the target, it does not exist in the array. Return -1.
This code solves "Search in Rotated Sorted Array" (#33).
def search(nums: list[int], target: int) -> int:
"""
Searches for a target in a rotated sorted array.
"""
left, right = 0, len(nums) - 1
while left <= right:
mid = left + (right - left) // 2
if nums[mid] == target:
return mid
# Check if the left half is sorted
if nums[left] <= nums[mid]:
# Check if target is in the sorted left half
if nums[left] <= target < nums[mid]:
right = mid - 1
else:
left = mid + 1
# Otherwise, the right half must be sorted
else:
# Check if target is in the sorted right half
if nums[mid] < target <= nums[right]:
left = mid + 1
else:
right = mid - 1
return -1
# Example Usage
nums = [4, 5, 6, 7, 0, 1, 2]
target = 0
# Expected Output: 4
print(f"Index of target {target}: {search(nums, target)}")
target2 = 3
# Expected Output: -1
print(f"Index of target {target2}: {search(nums, target2)}")
Java Example:
public class ModifiedBinarySearchExample {
public static int search(int[] nums, int target) {
int left = 0;
int right = nums.length - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (nums[mid] == target) {
return mid;
}
// Check if the left half is sorted
if (nums[left] <= nums[mid]) {
// Check if target is in the sorted left half
if (target >= nums[left] && target < nums[mid]) {
right = mid - 1;
} else {
left = mid + 1;
}
// Otherwise, the right half must be sorted
} else {
// Check if target is in the sorted right half
if (target > nums[mid] && target <= nums[right]) {
left = mid + 1;
} else {
right = mid - 1;
}
}
}
return -1;
}
public static void main(String[] args) {
int[] nums = {4, 5, 6, 7, 0, 1, 2};
int target = 0;
// Expected Output: Index of target 0: 4
System.out.println("Index of target " + target + ": " + search(nums, target));
int target2 = 3;
// Expected Output: Index of target 3: -1
System.out.println("Index of target " + target2 + ": " + search(nums, target2));
}
}
Visual Example:
Searching for target = 0
in [4, 5, 6, 7, 0, 1, 2]
:
Initial:
L R
[4, 5, 6, 7, 0, 1, 2]
Step 1:
- mid = 3. nums[mid] = 7. Not the target.
- Left half [4,5,6,7] is sorted.
- Target 0 is NOT in [4,7]. Search right.
- L = mid + 1 = 4.
L R
[4, 5, 6, 7, 0, 1, 2]
L R
Step 2:
- mid = 5. nums[mid] = 1. Not the target.
- Left half [0,1] is NOT sorted (since nums[L] > nums[mid]).
- Right half [1,2] is sorted.
- Target 0 is NOT in (1,2]. Search left.
- R = mid - 1 = 4.
L,R
[4, 5, 6, 7, 0, 1, 2]
Step 3:
- mid = 4. nums[mid] = 0. Target found!
- Return mid = 4.
Example Problems:
- Search in Rotated Sorted Array (#33, Medium): The canonical problem for this pattern.
- Find Minimum in Rotated Sorted Array (#153, Medium): Use binary search to find the "pivot" or inflection point, which is the minimum element.
- Find First and Last Position of Element in Sorted Array (#34, Medium): Requires two separate binary searches: one to find the leftmost occurrence and one for the rightmost.
- Find Peak Element (#162, Medium): Use binary search by comparing the middle element with its neighbors to decide which half must contain a peak.
- Sqrt(x) (#69, Easy): An example of using binary search on an answer range (from 0 to x) to find the integer square root.
Remember: The goal isn't just to memorize implementations, but to understand the underlying principles and trade-offs that make each algorithm suitable for specific problems. Practice recognizing patterns and choosing the right algorithmic approach based on problem constraints and requirements.
For part 1, check out Core Algorithms and Patterns Part 1.
For part 3, check out Core Algorithms and Patterns Part 3.
For a comprehensive guide to data structures, check out Core Data Structures and Advanced Data Structures.