LeetCode: Graphs I DFS BFS Union Find

547. Number of Provinces ::3:: - Medium
- Intro
- Abstraction
- Pseudocode
- Solution 1: [DFS] DFS Track Visited Nodes To Count Connected Components - Graph/DFS Adjacency Matrix
- Solution 2: [BFS] BFS Iterative Track Visited Nodes To Count Connected Components - Graph/BFS Adjacency Matrix
- Solution 3: [Union Find] Union Find Early Pruning - Graph/Union Find
695. Max Area of Island ::3:: - Medium
- Intro
- Abstraction
- Pseudocode
- Solution 1: [DFS] DFS Recursive Late Pruning Sink All Visited Land In Current Island - Graph/something
- Solution 2: [BFS] BFS Iterative Early Pruning Sink All Visited Land In Current Island - Graph/something
- Solution 3: [Union Find] Union Find Early Pruning Union By Size To Keep Track Of Island Sizes - Graph/something
2492. Minimum Score of a Path Between Two Cities ::3:: - Medium
- Intro
- Abstraction
- Pseudocode
- Solution 1: [DFS] DFS Connected Component Min Edge Tracking - Graph/DFS Weighted Adjacency List
- Solution 2: [BFS] BFS Iterative Connected Component Min Edge Tracking - Graph/BFS Weighted Adjacency List
- Solution 3: [Union Find] Union Find Component Min Edge Tracking - Graph/something
Graphs Intro
LeetCode problems with graph based solutions.
What is a Graph?
A graph is a data structure used to represent relationships between entities.
Graph Characteristics
- Vertices (n): entities (e.g. nodes)
- Edges (m): connections between entities
- Direction: Edges can be directed (nodes pointing to nodes), or undirected (no one pointing)
- Weight: Edges can have weight (e.g. cost, time) or unweighted
- Unordered: unlike tree, heaps, etc, graphs allow cycles, multiple paths, and varying connectivity
- Representation: Graphs are stored as adjacency matrix, adjacency list, or edge lists depending on the use case
Graph Representations: Adjacency Matrix
Graph and Matrix:
1---2
\ /
3
1 2 3
1 [0, 1, 1]
2 [1, 0, 1]
3 [1, 1, 0]
A[i][j] = 1 -> edge exists between i and j
A[i][j] = 0 -> no edge between i and j
Space complexity O(n2) -> better for dense graphs
Graph Representations: Adjacency List
Graph and List
1---2
\ /
3
1: [2, 3]
2: [1, 3]
3: [1, 2]
Each vertex points to its neighbors
Space complexity: O(n + m) -> efficient for sparse graphs
Graph Representations: Edge List
Graph and Edge List
1---2
\ /
3
Edges:
(1, 2)
(1, 3)
(2, 3)
Stores all edges explicitly as (u, v) pairs
Space complexity O(m) -> useful for algorithms that only need edges
Simplest representation for algorithms that only care about edges.
DFS
Pre Order
Use pre order DFS when you need to process a node immediately before exploring its neighbors
Ex: Sink islands or mark visited immediately in a grid
def dfs_pre(r, c, grid):
if not (0 <= r < len(grid) and 0 <= c < len(grid[0])) or grid[r][c] == '0':
return
grid[r][c] = '0' # mark visited (pre-order processing)
for dr, dc in [(1,0), (-1,0), (0,1), (0,-1)]:
dfs_pre(r + dr, c + dc, grid)
# Example: numIslands uses pre-order DFS to flood fillDFS In Order (Binary Tree Only)
Use in order DFS mainly for binary trees where left -> right order matters
Ex: Extract sorted values from a BST
def inorder(node, res):
if not node:
return
inorder(node.left, res)
res.append(node.val) # process node in between left/right
inorder(node.right, res)
# Example: LeetCode 98, Validate BST or BST inorder traversalPost Order
Use post order DFS when you want to process a node after exploring all its neighbors.
Ex: Calculate size of connected components or backtracking cleanup
def dfs_post(r, c, grid):
if not (0 <= r < len(grid) and 0 <= c < len(grid[0])) or grid[r][c] == '0':
return 0
grid[r][c] = '0' # mark visited
size = 1 # count current cell
for dr, dc in [(1,0), (-1,0), (0,1), (0,-1)]:
size += dfs_post(r + dr, c + dc, grid)
return size # post-order: aggregate after children
# Example: maxAreaOfIsland uses post-order DFS to sum areaBFS
Use BFS when you need shortest path in unweighted graphs, or to expand layers level by level.
Ex: Shortest path in 2D grid:
def bfs_shortest(grid, start):
queue = deque([(*start, 0)]) # (r, c, distance)
visited = set([start])
while queue:
r, c, dist = queue.popleft()
if grid[r][c] == 'target':
return dist
for dr, dc in [(1,0), (-1,0), (0,1), (0,-1)]:
nr, nc = r + dr, c + dc
if 0 <= nr < len(grid) and 0 <= nc < len(grid[0]) and (nr,nc) not in visited:
visited.add((nr,nc))
queue.append((nr,nc, dist + 1))Union Find
Use Union Find to efficiently track connected components in dynamic graphs.
Ex: Count islands or merge friend groups
def numIslandsUnion(grid):
parent = {}
def find(x):
if parent[x] != x:
parent[x] = find(parent[x])
return parent[x]
def union(x, y):
parent[find(x)] = find(y)
for r in range(len(grid)):
for c in range(len(grid[0])):
if grid[r][c] == '1':
parent[(r,c)] = (r,c)
for r in range(len(grid)):
for c in range(len(grid[0])):
if grid[r][c] == '1':
for dr, dc in [(1,0),(0,1)]:
nr, nc = r+dr, c+dc
if (nr,nc) in parent:
union((r,c),(nr,nc))
roots = {find(x) for x in parent}
return len(roots)1971. Find if Path Exists in Graph ::4:: - Easy
Topics: Connected Components, Depth First Search, Breadth First Search, Union Find, Graph Theory, Adjacency Matrix
Intro
There is a bi-directional graph with n vertices, where each vertex is labeled from 0 to n - 1 (inclusive). The edges in the graph are represented as a 2D integer array edges, where each edges[i] = [ui, vi] denotes a bi-directional edge between vertex ui and vertex vi. Every vertex pair is connected by at most one edge, and no vertex has an edge to itself. You want to determine if there is a valid path that exists from vertex source to vertex destination. Given edges and the integers n, source, and destination, return true if there is a valid path from source to destination, or false otherwise.
| Example Input | Output |
|---|---|
| n = 3, edges = [[0,1],[1,2],[2,0]], source = 0, destination = 2 | true |
| n = 6, edges = [[0,1],[0,2],[3,5],[5,4],[4,3]], source = 0, destination = 5 | false |
Constraints:
1 ≤ n ≤ 10^5
0 ≤ edges.length ≤ 2 * 10^5
edges[i].length == 2
0 ≤ ui, vi ≤ n-1
ui != vi
0 ≤ source, destination ≤ n-1
There are no duplicate edges
Abstraction
Determine if two nodes exist within the same connected component and if a path between those two nodes exists. Given an Edge List representation of a graph composed of undirected edges. Union find can operate directly on the Edge List, but DFS and BFS need to build an Adjacency List
Pseudocode
text will go here
Solution 1: [DFS] DFS Track Visited Nodes To Detect Path - Graph/DFS Adjacency Matrix
def validPath(self, n: int, edges: List[List[int]], source: int, destination: int) -> bool:
# Note:
# Detecting a path between two nodes in an undirected graph
# using an Edge List
# Edge List:
# edges = [
# [0, 1],
# [1, 2],
# [2, 0],
# [1, 3],
# ]
# Adjacency List:
# graph = {
# 0: [1, 2],
# 1: [0, 2, 3],
# 2: [1, 0],
# 3: [1],
# }
# DFS Path Existence Check (Undirected Graph)
# Each node is represented as a node in an undirected graph,
# built explicitly from the edges list. Since the graph is
# undirected, each edge (u, v) is added in both directions.
# DFS explores every node reachable from source, marking each
# one visited along the way. If destination is ever reached
# during this exploration, a path is guaranteed to exist.
# If DFS exhausts every reachable node without finding
# destination, no path can exist between the two.
# Edge Case:
# source and destination are the same node, trivially connected
if source == destination:
return True
# Build Adjacency List:
# graph[u] = neighbors of u
# tc: O(V + E)
# sc: O(V + E)
graph = defaultdict(list)
for u, v in edges:
graph[u].append(v)
graph[v].append(u)
# Track visited nodes
# sc: O(V)
visited = set()
def dfs(node):
# Early Return:
# reached the destination, a path exists
if node == destination:
return True
# Mark node as visited
visited.add(node)
# Explore Neighbors:
for nei in graph[node]:
# Early Pruning:
# skip already visited nodes
if nei not in visited:
# If neighbor reaches destination, propagate success upward
if dfs(nei):
return True
# Exhausted all neighbors without finding destination
return False
# overall: tc O(V + E)
# overall: sc O(V + E)
return dfs(source)| Aspect | Time Complexity | Space Complexity | Time Remarks | Space Remarks |
|---|---|---|---|---|
Solution 2: [BFS] BFS Iterative Track Visited Nodes To Detect Path - Graph/BFS Adjacency Matrix
def validPath(self, n: int, edges: List[List[int]], source: int, destination: int) -> bool:
# Note:
# Detecting a path between two nodes in an undirected graph
# using an Edge List
# Edge List:
# edges = [
# [0, 1],
# [1, 2],
# [2, 0],
# [1, 3],
# ]
# Adjacency List:
# graph = {
# 0: [1, 2],
# 1: [0, 2, 3],
# 2: [1, 0],
# 3: [1],
# }
# BFS Path Existence Check (Undirected Graph)
# Each node is represented as a node in an undirected graph,
# built explicitly from the edges list. Since the graph is
# undirected, each edge (u, v) is added in both directions.
# BFS explores nodes level by level starting from source. Any
# node dequeued that equals destination guarantees a path
# exists. If the queue empties without ever dequeuing
# destination, no path can exist between the two.
# Edge Case:
# source and destination are the same node, trivially connected
if source == destination:
return True
# Build Adjacency List:
# graph[u] = neighbors of u
# tc: O(V + E)
# sc: O(V + E)
graph = defaultdict(list)
for u, v in edges:
graph[u].append(v)
graph[v].append(u)
# Iterative BFS Queue:
# start from source
# sc: O(V)
queue = deque([source])
visited = {source}
while queue:
# Pop a node from the queue
node = queue.popleft()
# Early Return:
# reached the destination, a path exists
if node == destination:
return True
# Explore Neighbors:
for nei in graph[node]:
# Early Pruning:
# only queue node if not already visited
if nei not in visited:
visited.add(nei)
queue.append(nei)
# Exhausted all reachable nodes without finding destination
# overall: tc O(V + E)
# overall: sc O(V + E)
return False| Aspect | Time Complexity | Space Complexity | Time Remarks | Space Remarks |
|---|---|---|---|---|
Solution 3: [Union Find] Union Find Early Pruning - Graph/Union Find Edge List
def validPath(self, n: int, edges: List[List[int]], source: int, destination: int) -> bool:
# Note:
# Detecting a path between two nodes in an undirected graph
# using an Edge List
# Edge List:
# edges = [
# [0, 1],
# [1, 2],
# [2, 0],
# [1, 3],
# ]
# Unlike DFS/BFS, Union-Find operates directly on the Edge List —
# there's no need to build an Adjacency List first, since we only
# ever process one edge (pair of nodes) at a time.
# Union-Find (Disjoint Set) for Path Existence in Undirected Graph
# Each node starts as its own disjoint set. Processing an edge
# (u, v) unions the sets containing u and v, merging any two
# nodes directly connected by an edge into the same component.
# After processing every edge, a path exists between source and
# destination if and only if they share the same root, meaning
# they ended up in the same connected component.
# Initialize Parent + Rank Arrays:
# parent[i] = representative of the set containing node i
# rank[i] = size/depth heuristic for union by rank
# tc: O(V)
# sc: O(V)
parent = list(range(n))
rank = [0] * n
# Find():
# with path compression
def find(x):
if parent[x] != x:
# Path Compression:
parent[x] = find(parent[x])
return parent[x]
# Union():
# adding by rank
def union(x, y):
rootX, rootY = find(x), find(y)
# Early Pruning:
# already in the same set, nothing to merge
if rootX == rootY:
return
# Smaller rank tree becomes a subtree of the larger rank tree
if rank[rootX] > rank[rootY]:
parent[rootY] = rootX
elif rank[rootX] < rank[rootY]:
parent[rootX] = rootY
else:
parent[rootY] = rootX
# On a tie, bump rank to mark X the higher rank tree
rank[rootX] += 1
# Process All Edges:
# tc: O(E * α(V))
for u, v in edges:
union(u, v)
# A path exists if and only if source and destination
# share the same root, meaning they're in the same component
# tc: O(α(V))
# overall: tc O((V + E) * α(V))
# overall: sc O(V)
return find(source) == find(destination)| Aspect | Time Complexity | Space Complexity | Time Remarks | Space Remarks |
|---|---|---|---|---|
547. Number of Provinces ::3:: - Medium
Topics: Connected Components, Depth First Search, Breadth First Search, Union Find, Graph Theory, Adjacency Matrix
Intro
There are n cities. Some of them are connected, while some are not. If city a is connected directly with city b, and city b is connected directly with city c, then city a is connected indirectly with city c. A province is a group of directly or indirectly connected cities and no other cities outside of the group. You are given an n x n matrix isConnected where isConnected[i][j] = 1 if the ith city and the jth city are directly connected, and isConnected[i][j] = 0 otherwise. Return the total number of provinces.
| Example Input | Output |
|---|---|
| isConnected = [[1,1,0],[1,1,0],[0,0,1]] | 2 |
| isConnected = [[1,0,0],[0,1,0],[0,0,1]] | 3 |
Constraints:
1 ≤ n ≤ 200
n == isConnected.length
n == isConnected[i].length
isConnected[i][j] is 1 or 0
isConnected[i][i] == 1
isConnected[i][j] == isConnected[j][i]
Abstraction
Return the number of unique connected components, Given an Adjacency Matrix representation of a graph. DFS, BFS, and Union Find can operate directly on the Adjacency Matrix.
Pseudocode
text will go here
Solution 1: [DFS] DFS Track Visited Nodes To Count Connected Components - Graph/DFS Adjacency Matrix
def findCircleNum(self, isConnected: List[List[int]]) -> int:
# Note:
# Counting the number of connected components
# using a Adjacency Matrix
# Adjacency Matrix:
# 0 1 2 3
# matrix = [
# [0, 1, 1, 0], 0
# [1, 0, 1, 0], 1
# [1, 1, 0, 1], 2
# [0, 0, 1, 0], 3
# ]
# By representing the cities as nodes in a connected graph,
# we can use DFS to explore all connected cities starting from any city
# in the province.
# Provinces then become a representation of connected components,
# so we are counting the number of connected components in the graph.
n = len(isConnected)
# Tracking visited cities to avoid revisiting
# and to check if we have found a new province/connected component
visited = set()
# Number of connected components
provinces = 0
# For current province connect all cities,
# meaning explore all cities and their connections
def dfs(city):
# Early Pruning:
if city in visited:
return
# Process root
visited.add(city)
# Explore neighbors
for nei in range(n):
if isConnected[city][nei] == 1:
dfs(nei)
# Run DFS for each province,
# which will mark all connected cities by adding them to visited set
for city in range(n):
# New province has been found
if city not in visited:
provinces += 1
dfs(city)
# overall: tc O(n^2)
# overall: sc O(n)
return provinces| Aspect | Time Complexity | Space Complexity | Time Remarks | Space Remarks |
|---|---|---|---|---|
Solution 2: [BFS] BFS Iterative Track Visited Nodes To Count Connected Components - Graph/BFS Adjacency Matrix
def findCircleNum(self, isConnected: List[List[int]]) -> int:
# Note:
# Counting the number of connected components
# using a Adjacency Matrix
# Adjacency Matrix:
# 0 1 2 3
# matrix = [
# [0, 1, 1, 0], 0
# [1, 0, 1, 0], 1
# [1, 1, 0, 1], 2
# [0, 0, 1, 0], 3
# ]
# By representing the cities as nodes in a connected graph,
# we can use BFS to explore all connected cities starting from any city
# in the province.
# Provinces then become a representation of connected components,
# so we are counting the number of connected components in the graph.
n = len(isConnected)
visited = set()
provinces = 0
def bfs(start):
queue = deque([start])
visited.add(start)
while queue:
# Run BFS for each province,
# which will mark all connected cities by adding them to visited set
city = queue.popleft()
# Explore neighbors
for nei in range(n):
if (isConnected[city][nei] == 1 and
nei not in visited):
# Mark as visited and enqueue for further exploration
visited.add(nei)
queue.append(nei)
# Run BFS for each province,
# which will mark all connected cities by adding them to visited set
for city in range(n):
# New province has been found
if city not in visited:
provinces += 1
bfs(city)
# overall: tc O(n^2)
# overall: sc O(n)
return provinces| Aspect | Time Complexity | Space Complexity | Time Remarks | Space Remarks |
|---|---|---|---|---|
Solution 3: [Union Find] Union Find Early Pruning - Graph/Union Find
def findCircleNum(self, isConnected: List[List[int]]) -> int:
# Note:
# Counting the number of connected components
# using a Adjacency Matrix
# Adjacency Matrix:
# 0 1 2 3
# matrix = [
# [0, 1, 1, 0], 0
# [1, 0, 1, 0], 1
# [1, 1, 0, 1], 2
# [0, 0, 1, 0], 3
# ]
# By representing the cities as nodes in a connected graph,
# we can use DFS to explore all connected cities starting from any city
# in the province.
# Provinces then become a representation of connected components,
# so we are counting the number of connected components in the graph.
n = len(isConnected)
# Union Find Data Structure:
# sc: O(n)
parent = list(range(n))
rank = [0] * n
# Find():
# with path compression
def find(x):
if parent[x] != x:
# Path Compression:
parent[x] = find(parent[x])
return parent[x]
# Union():
# adding by rank
def union(x, y):
rootX, rootY = find(x), find(y)
# Early pruning:
if rootX == rootY:
return
# Smaller rank tree becomes a subtree of the larger rank tree
if rank[rootX] > rank[rootY]:
parent[rootY] = rootX
elif rank[rootX] < rank[rootY]:
parent[rootX] = rootY
else:
parent[rootY] = rootX
# On a tie, add to rank to mark X the higher rank tree
rank[rootX] += 1
# Only check upper triangle (optimization)
# by only looking at a cell if its column number is bigger than its row number
for i in range(n):
for j in range(i + 1, n):
if isConnected[i][j] == 1:
union(i, j)
# Count the number of unique roots,
# which corresponds to the number of provinces
roots = set()
for city in range(n):
roots.add(find(city))
# overall: tc O(n^2 * α(n))
# overall: sc O(n)
return len(roots)| Aspect | Time Complexity | Space Complexity | Time Remarks | Space Remarks |
|---|---|---|---|---|
323. Number of Connected Components in an Undirected Graph ::3:: - Medium
Topics: Connected Components, Depth First Search, Breadth First Search, Graph Theory, Union Find, Edge List, Adjacency List
Intro
There is an undirected graph with n nodes. There is also an edges array, where edges[i] = [a, b] means that there is an edge between node a and node b in the graph. The nodes are numbered from 0 to n - 1. Return the total number of connected components in that graph.
| Example Input | Output |
|---|---|
| n=3 edges=[[0,1], [0,2]] | 1 |
| n=6 edges=[[0,1], [1,2], [2,3], [4,5]] | 2 |
Constraints:
1 ≤ n ≤ 100
0 ≤ edges.length ≤ n * (n-1) / 2
Abstraction
Return the total number of unique connected components, Given an Edge List representation of a graph composed of undirected edges. Union find can operate directly on the Edge List, but DFS and BFS which need to build an Adjacency List
Pseudocode
text will go here
Solution 1: [DFS] DFS - Graph/something
def countComponents(self, n: int, edges: List[List[int]]) -> int:
# Note:
# Counting the number of connected components
# using an Edge List
# Edge List:
# edges = [
# [0, 1],
# [0, 2],
# [1, 2],
# [2, 3],
# ]
# Adjacency List:
# graph = {
# 0: [1, 2],
# 1: [0, 2],
# 2: [0, 1, 3],
# 3: [2],
# }
# Connected Components In A Graph:
# Each node represents a vertex.
# Each edge connects two nodes bidirectionally.
# The problem asks for the number of connected components.
# DFS Approach:
# 1. Build adjacency list (graph representation).
# 2. Start DFS from every unvisited node.
# 3. DFS marks all nodes belonging to that component.
# 4. Each new DFS call = new connected component.
# Build adjacency list
# tc: O(E), sc: O(V + E)
graph = defaultdict(list)
for u, v in edges:
graph[u].append(v)
graph[v].append(u)
# Track visited nodes
# sc: O(V)
visited = set()
# Recursive DFS traversal
# sc: O(V) recursion stack worst case
def dfs(node):
# Explore:
# visit all neighbors
# tc: O(deg(node))
for nei in graph[node]:
# Early Pruning:
# skip already visited nodes
if nei not in visited:
# Process Root:
# mark neighbor as visited
# tc: O(!)
visited.add(nei)
# Explore:
# recursively explore neighbor
dfs(nei)
# Count connected components
# tc: O(V)
components = 0
# Iterate over all nodes
# tc: O(V)
for i in range(n):
# Initial Call:
# new component found
if i not in visited:
# mark root as visited
visited.add(i)
# recursively explore
dfs(i)
# add to component counter
components += 1
# overall: tc O(V + E)
# overall: sc O(V + E)
return componentsSolution 2: [BFS] BFS - Graph/something
def countComponents(self, n: int, edges: List[List[int]]) -> int:
# Connected Components In A Graph:
# BFS explores nodes level-by-level using a queue.
# Each BFS traversal fully visits one connected component.
# Build adjacency list
# tc: O(E), sc: O(V + E)
graph = defaultdict(list)
for u, v in edges:
graph[u].append(v)
graph[v].append(u)
# visited tracking
# sc: O(V)
visited = set()
# BFS traversal
# sc: O(V) queue worst-case
def bfs(start):
# Iterative Queue:
# sc: O(V)
queue = deque()
# Process Root:
# add to queue to process
# tc: O(V)
queue.append(start)
# While we still have nodes connected to root node
while queue:
# Grab original node
node = queue.popleft()
# Explore:
# recursively explore neighbors
# tc: O(V)
for nei in graph[node]:
# Early Prune:
# if neighbor has not been explore
# tc: O(1)
if nei not in visited:
# Process root:
# mark as visited
# tc: O(1)
visited.add(nei)
# Append to queue to process
# tc: O(1)
queue.append(nei)
# Global component counter
components = 0
# Iterate over all nodes
# tc: O(n)
for i in range(n):
# Early Prune:
# only process if node has not been visited
# tc: O(1)
if i not in visited:
# Process Root:
# mark as visited
visited.add(i)
# Explore:
# recursively explore neighbors
# tc: O(V)
bfs(i)
# Add to global component
components += 1
# overall: tc O(V + E)
# overall: sc O(V + E)
return componentsSolution 3: [Union Find] Union Find [SC Opt] - Graph/something
def countComponents(self, n: int, edges: List[List[int]]) -> int:
# Note:
# Counting the number of connected components
# using an Edge List
# Unlike DFS and BFS for an Edge List,
# Union Find does not require to create a new data structure to traverse,
# as it can operate directly on the Edge List.
# Edge List Graph Representation:
# Each node represents a vertex (0 to n-1)
# Each edge connects two nodes bidirectionally
# The problem asks for the number of connected components
# Parent and Rank Initialization:
# Each node starts as its own parent
# Rank is used to keep the trees shallow
parent = {}
rank = {}
# Initialize each node
# tc: O(n), sc: O(n)
for i in range(n):
parent[i] = i # initially, parent of node i is itself
rank[i] = 0 # initial rank (upper bound on tree height)
# Find with Path Compression
# Returns the root of a node's tree
# tc: O(α(n))
def find(x):
# parent is not self, recurse upwards
if parent[x] != x:
# recursively find root and compress path
parent[x] = find(parent[x])
# return original call's parent, after path compression
return parent[x]
# Union by Rank
# Connect two nodes together if they are not already connected
# tc: O(α(n))
def union(x, y):
# If they share the same root, they are already in the same component
rootX, rootY = find(x), find(y)
if rootX == rootY:
return 0
if rank[rootX] > rank[rootY]:
parent[rootY] = rootX
elif rank[rootX] < rank[rootY]:
parent[rootX] = rootY
else:
parent[rootY] = rootX
rank[rootX] += 1
return 1 # successful merge
# Assume all nodes are components
# sc: O(1)
components = n
# Iterate over all edges
# tc: O(E * α(n))
for u, v in edges:
# Every successful Union implies that we have 1 less assumed node component
if union(u, v):
components -= 1
# overall: tc O(V + E * α(n)) =~ O(V + E)
# overall: sc O(V)
return components695. Max Area of Island ::3:: - Medium
Topics: Connected Components, Array, Depth First Search, Breadth First Search, Union Find, Matrix, Grid
Intro
You are given an m x n binary matrix grid. An island is a group of 1's (representing land) connected 4-directionally
(horizontal or vertical.) You may assume all four edges of the grid are surrounded by water. The area of an island is the number of cells with a value 1 in the island. Return the maximum area of an island in grid. If there is no island, return 0.
| Example Input | Output |
|---|---|
| grid (see LeetCode) | 6 |
| grid (see LeetCode) | 0 |
Constraints:
m == grid.length
n == grid[i].length
1 ≤ m, n ≤ 50
grid[i][j] is either 0 or 1
Abstraction
Return the size of the largest connected component. Return the size of the largest island. Given a grid representation of a graph. DFS, BFS, and Union Find can operate directly on the grid representation.
Pseudocode
text will go here
Solution 1: [DFS] DFS Recursive Late Pruning Sink All Visited Land In Current Island - Graph/something
def maxAreaOfIsland(self, grid: List[List[int]]) -> int:
# Note:
# Find the largest connected component using an grid of land and water
# Adjacency Matrix:
# grid = [
# [0, 0, 1, 0, 0],
# [0, 1, 1, 1, 0],
# [0, 0, 1, 0, 0],
# [0, 0, 0, 0, 0],
# ]
# Backtracking vs Recursion for Graph Traversal:
# Recursion:
# - is a concept, backtracking is a technique using that concept.
# - does not imply backtracking
# Backtracking:
# - must use recursion.
# - requires reverting state (e.g., path.pop()) to explore alternatives.
# This problem uses recursion,
# but is not backtracking since once land is sunk (set to 0),
# it is never restored/we never backtracking via a pop()),
# since we are performing a flood fill traversal.
# Recursive Flood Fill (DFS Traversal):
# 1. Iterate over entire grid
# 2. When we hit a land cell, we have found a new connected component
# 3. Explore while changing land as water to mark as visited
# 4. Continue exploring entire grid
# Empty check:
# no grid exists, no connected components
if grid == None:
return 0
# Grid Traversal:
# sc: O(1)
m, n = len(grid), len(grid[0])
# global max connected Component
# sc: O(1)
maxArea = 0
def dfs(r: int, c: int) -> int:
# Late Pruning:
# skip if cell is out of bounds or water
# tc: O(1)
if (r < 0 or r >= m or
c < 0 or c >= n or
grid[r][c] == 0):
return 0
# Process Root:
# turn land into water, which marks the cell as visited
grid[r][c] = 0
# Track size for current island
area = 1
# Explore Neighbors:
# look for connected land to current island and sink
area += dfs(r + 1, c)
area += dfs(r - 1, c)
area += dfs(r, c + 1)
area += dfs(r, c - 1)
# Return area to root call
return area
# Iterate over grid
# tc: O(r*c)
for r in range(m):
for c in range(n):
# New Island Found
# explore new connected component
# tc: O(1)
if grid[r][c] == 1:
# grab area for connected component
islandArea = dfs(r, c)
# compare area to max
maxArea = max(maxArea, islandArea)
# overall: tc O(r*c)
# overall: sc O(r*c)
return maxArea| Aspect | Time Complexity | Space Complexity | Time Remarks | Space Remarks |
|---|---|---|---|---|
Solution 2: [BFS] BFS Iterative Early Pruning Sink All Visited Land In Current Island - Graph/something
def maxAreaOfIsland(self, grid: List[List[int]]) -> int:
# Recursive Flood Fill (BFS Traversal):
# 1. Iterate over entire grid
# 2. When we hit a land cell, we have found a new connected component
# 3. Explore while changing land as water to mark as visited and add to deque
# 4. Continue exploring entire grid
# Empty Check: no islands, grid is empty
# tc: O(1)
if not grid:
return 0
# Grid Traversal
m, n = len(grid), len(grid[0])
# Tracking global max island
maxArea = 0
def bfs(r, c):
# area count for local island
area = 0
# Iterative BFS Queue
# sc: O(r*c)
queue = deque()
# Add root land to queue execution and mark as visited
queue.append((r, c))
grid[r][c] = 0
# While we have land in current connected component
while queue:
# Pop a land cell
cr, cc = queue.popleft()
# Add to island size
area += 1
# Explore Neighbors:
for dr, dc in [(1,0), (-1,0), (0,1), (0,-1)]:
# next neighbor
nr, nc = cr + dr, cc + dc
# Early Pruning:
# only enqueue if in bounds and land
if 0 <= nr < m and 0 <= nc < n and grid[nr][nc] == 1:
# Add root land to queue execution and mark as visited
queue.append((nr, nc))
grid[nr][nc] = 0
# Return:
# pass area to root call
return area
# Iterate over grid
# tc: O(r*c)
for r in range(m):
for c in range(n):
# Found a new connected component
if grid[r][c] == 1:
# grab area for connected component
islandArea = bfs(r, c)
# compare area to max
maxArea = max(maxArea, islandArea)
# overall: tc O(m*n)
# overall: sc O(m*n)
return maxArea| Aspect | Time Complexity | Space Complexity | Time Remarks | Space Remarks |
|---|---|---|---|---|
Solution 3: [Union Find] Union Find Early Pruning Union By Size To Keep Track Of Island Sizes - Graph/something
def maxAreaOfIsland(self, grid: List[List[int]]) -> int:
# Note:
# Union-Find approach to track connected components by size
# Recursive Flood Fill Via Connected Components (Union Find Traversal):
# 1. Iterate over entire grid
# 2. When we hit a land cell, we have found a new connected component
# 3. Explore while changing land as water to mark as visited and add to deque
# 4. Continue exploring entire grid
# Empty Check: no islands, grid is empty
# tc: O(1)
if not grid:
return 0
# Grid Traversal
m, n = len(grid), len(grid[0])
# Union Find Data Structure
# sc: O(r*c)
parent = {}
size = {}
# Iterate all grid
# tc: O(r*c)
for r in range(m):
for c in range(n):
# Add land cell to union find data structure
if grid[r][c] == 1:
parent[(r, c)] = (r, c)
size[(r, c)] = 1
# Find():
# with Path Compression
# tc: O(α(n)) amortized per call
# sc: O(1) per call
def find(x):
# Path Compression
if parent[x] != x:
parent[x] = find(parent[x])
return parent[x]
# Union by Size
# tc: O(α(n)) amortized
# sc: O(1)
def union(x, y):
# Ignore if nodes share the same root (same connected component)
rootX, rootY = find(x), find(y)
if rootX == rootY:
return
# Smaller subtree will join the larger subtree
# tc: O(1)
if size[rootX] >= size[rootY]:
parent[rootY] = rootX
# Add to size
size[rootX] += size[rootY]
else:
parent[rootX] = rootY
size[rootY] += size[rootX]
# Iterate over grid
# tc: O(r*c)
for r in range(m):
for c in range(n):
# New connected component found
if grid[r][c] == 1:
# Explore Down and Right Neighbors:
# The rest will be explored as we iterate and Union
for dr, dc in [(1,0), (0,1)]:
nr, nc = r + dr, c + dc
# Early Pruning:
# only union if in bounds and land
if 0 <= nr < m and 0 <= nc < n and grid[nr][nc] == 1:
union((r, c), (nr, nc))
# Grab max island
largestIsland = max(size.values(), default=0)
# overall: tc O(r*c * α(r*c)) =~ O(r*c)
# overall: sc O(r*c)
return largestIsland| Aspect | Time Complexity | Space Complexity | Time Remarks | Space Remarks |
|---|---|---|---|---|
200. Number of Islands ::3:: - Medium
Topics: Connected Components, Array, Depth First Search, Breadth First Search, Union Find, Matrix, Grid
Intro
Given an m x n 2D binary grid grid 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.
| Example Input | Output |
|---|---|
| grid (see LeetCode) | 1 |
| grid (see LeetCode) | 3 |
Constraints:
m == grid.length
n == grid[i].length
1 ≤ m, n ≤ 300
grid[i][j] is 0 or 1
Abstraction
Return the total number of unique connected components.
Pseudocode
text will go here
Solution 1: [DFS] DFS Recursive Sink Visited Land To Count Islands - Graph/DFS Grid Traversal
def numIslands(self, grid: List[List[str]]) -> int:
# Note:
# Counting the number of connected components
# using a Grid
# Grid:
# grid = [
# ["1","1","0","0"],
# ["1","1","0","0"],
# ["0","0","1","0"],
# ["0","0","0","1"],
# ]
# Unlike Edge List / Adjacency List problems, the grid itself
# is the graph, a cell's neighbors are always just its 4
# grid-adjacent cells. No preprocessing is needed to build an
# adjacency structure, same as Nearest Exit and Max Area of Island.
# DFS Grid Traversal to Count Connected Components (Islands)
# By representing land cells as nodes in a graph, we can use DFS
# to explore every connected land cell starting from any unvisited
# land cell. Each fresh DFS call that starts from an unvisited
# land cell represents the discovery of one new island, since DFS
# will mark every cell connected to it as visited before returning.
if not grid or not grid[0]:
return 0
rows, cols = len(grid), len(grid[0])
# 4 cardinal directions
directions = [(1, 0), (-1, 0), (0, 1), (0, -1)]
# Track visited land cells to avoid revisiting
# sc: O(V)
visited = set()
def dfs(row, col):
# Explore Neighbors:
for dr, dc in directions:
nr, nc = row + dr, col + dc
# Bounds Check:
if 0 <= nr < rows and 0 <= nc < cols:
# Early Pruning:
# only recurse into cell if:
# - it's land ('1')
# - not already visited
if grid[nr][nc] == '1' and (nr, nc) not in visited:
visited.add((nr, nc))
dfs(nr, nc)
# Track number of connected components (islands)
islands = 0
# Run DFS for each unvisited land cell,
# which will mark all connected land cells by adding them to visited
# tc: O(V), each cell is visited and processed at most once overall
for row in range(rows):
for col in range(cols):
# New island has been found
if grid[row][col] == '1' and (row, col) not in visited:
visited.add((row, col))
dfs(row, col)
islands += 1
# overall: tc O(m * n), each cell is visited at most once
# overall: sc O(m * n), for the visited set and recursion stack
# in the worst case of one giant island
return islands| Aspect | Time Complexity | Space Complexity | Time Remarks | Space Remarks |
|---|---|---|---|---|
Solution 2: [BFS] BFS Iterative Sink Visited Land To Count Islands - Graph/BFS Grid Traversal
def numIslands(self, grid: List[List[str]]) -> int:
# Note:
# Counting the number of connected components
# using a Grid
# Grid:
# grid = [
# ["1","1","0","0"],
# ["1","1","0","0"],
# ["0","0","1","0"],
# ["0","0","0","1"],
# ]
# Unlike Edge List / Adjacency List problems, the grid itself
# is the graph, a cell's neighbors are always just its 4
# grid-adjacent cells. No preprocessing is needed to build an
# adjacency structure, same as Nearest Exit and Max Area of Island.
# BFS Grid Traversal to Count Connected Components (Islands)
# By representing land cells as nodes in a graph, we can use BFS
# to explore every connected land cell starting from any unvisited
# land cell. Each fresh BFS call that starts from an unvisited
# land cell represents the discovery of one new island, since BFS
# will mark every cell connected to it as visited before the
# queue empties.
if not grid or not grid[0]:
return 0
rows, cols = len(grid), len(grid[0])
# 4 cardinal directions
directions = [(1, 0), (-1, 0), (0, 1), (0, -1)]
# Track visited land cells to avoid revisiting
# sc: O(V)
visited = set()
def bfs(row, col):
# Iterative BFS Queue:
queue = deque([(row, col)])
while queue:
# Pop a cell from the queue
r, c = queue.popleft()
# Explore Neighbors:
for dr, dc in directions:
nr, nc = r + dr, c + dc
# Bounds Check:
if 0 <= nr < rows and 0 <= nc < cols:
# Early Pruning:
# only queue cell if:
# - it's land ('1')
# - not already visited
if grid[nr][nc] == '1' and (nr, nc) not in visited:
visited.add((nr, nc))
queue.append((nr, nc))
# Track number of connected components (islands)
islands = 0
# Run BFS for each unvisited land cell,
# which will mark all connected land cells by adding them to visited
# tc: O(V), each cell is visited and processed at most once overall
for row in range(rows):
for col in range(cols):
# New island has been found
if grid[row][col] == '1' and (row, col) not in visited:
visited.add((row, col))
bfs(row, col)
islands += 1
# overall: tc O(m * n), each cell is visited at most once
# overall: sc O(m * n), for the visited set and queue
# in the worst case of one giant island
return islands| Aspect | Time Complexity | Space Complexity | Time Remarks | Space Remarks |
|---|---|---|---|---|
Solution 3: [Union Find] Union Find Grid Neighbor Merging - Graph/Union Find
def numIslands(self, grid: List[List[str]]) -> int:
# Note:
# Counting the number of connected components
# using a Grid
# Grid:
# grid = [
# ["1","1","0","0"],
# ["1","1","0","0"],
# ["0","0","1","0"],
# ["0","0","0","1"],
# ]
# Unlike Edge List / Adjacency List problems, the grid itself
# is the graph, a cell's neighbors are always just its 4
# grid-adjacent cells. Union-Find operates directly on the grid,
# merging each land cell with its land neighbors as it scans.
# Union-Find (Disjoint Set) to Count Connected Components (Islands)
# Each land cell starts as its own disjoint set, flattened from
# its (row, col) position into a single index (row * cols + col).
# Scanning the grid and unioning each land cell with its land
# neighbors (only right and down needed, since left/up are
# covered by earlier cells scanning forward) merges every
# connected island into a single component. The final answer is
# the number of unique land roots remaining.
if not grid or not grid[0]:
return 0
rows, cols = len(grid), len(grid[0])
# Initialize Parent + Rank Arrays:
# flatten (row, col) into a single index: row * cols + col
# tc: O(V)
# sc: O(V)
parent = list(range(rows * cols))
rank = [0] * (rows * cols)
# Find():
# with path compression
def find(x):
if parent[x] != x:
# Path Compression:
parent[x] = find(parent[x])
return parent[x]
# Union():
# adding by rank
def union(x, y):
rootX, rootY = find(x), find(y)
# Early Pruning:
if rootX == rootY:
return
# Smaller rank tree becomes a subtree of the larger rank tree
if rank[rootX] > rank[rootY]:
parent[rootY] = rootX
elif rank[rootX] < rank[rootY]:
parent[rootX] = rootY
else:
parent[rootY] = rootX
rank[rootX] += 1
# Track total land cells found, needed since parent starts
# with every grid cell (including water) as its own set
landCells = set()
# Process Grid:
# only union land cells with their land neighbors (right, down)
# tc: O(V)
for row in range(rows):
for col in range(cols):
if grid[row][col] == '1':
landCells.add(row * cols + col)
# Explore Neighbors:
# only check right and down, left/up are handled
# when those earlier cells were processed
for dr, dc in [(1, 0), (0, 1)]:
nr, nc = row + dr, col + dc
if (0 <= nr < rows and 0 <= nc < cols and
grid[nr][nc] == '1'):
union(row * cols + col, nr * cols + nc)
# Count the number of unique roots among land cells only,
# which corresponds to the number of islands
roots = set()
for cell in landCells:
roots.add(find(cell))
# overall: tc O(V * α(V))
# overall: sc O(V)
return len(roots)| Aspect | Time Complexity | Space Complexity | Time Remarks | Space Remarks |
|---|---|---|---|---|
1254. Number of Closed Islands ::3:: - Medium
Topics: Connected Components, Array, Depth First Search, Breadth First Search, Union Find, Matrix, Grid
Intro
Given a 2D grid consists of 0s (land) and 1s (water).
An island is a maximal 4-directionally connected group of 0s and a closed island is an island totally (all left, top, right, bottom) surrounded by 1s. Return the number of closed islands.
| Example Input | Output |
|---|---|
| grid (see LeetCode) | 2 |
| grid (see LeetCode) | 1 |
Constraints:
1 ≤ grid.length, grid[0].length ≤ 100
0 ≤ grid[i][j] ≤ 1
Abstraction
Return the total number of unique connected components that are not touching the boarder.
Pseudocode
text will go here
Solution 1: [DFS] DFS Recursive Boarder Contact Tracking - Graph/DFS Grid Traversal
def closedIsland(self, grid: List[List[int]]) -> int:
# Note:
# Counting connected components that don't touch the border
# using a Grid
# Grid (0 = land, 1 = water):
# grid = [
# [1,1,1,1,1,1,1],
# [1,0,0,0,0,0,1],
# [1,0,1,0,1,0,1],
# [1,0,0,0,0,0,1],
# [1,1,1,1,1,1,1],
# ]
# Unlike Edge List / Adjacency List problems, the grid itself
# is the graph, a cell's neighbors are always just its 4
# grid-adjacent cells. No preprocessing is needed to build an
# adjacency structure, same as Number of Islands.
# Note the flipped values compared to Number of Islands:
# here 0 = land, 1 = water, so we're counting connected
# components of 0s instead of 1s.
# DFS Grid Traversal to Count Closed Islands
# An island is only "closed" if none of its land cells touch
# the grid's outer border, since any land cell on the border
# means the island leaks out to the edge of the map and is
# therefore not fully surrounded by water.
# DFS explores every connected land cell starting from an
# unvisited land cell, and tracks whether that exploration ever
# touches a border cell. If it never does, the entire connected
# component is safely enclosed and counts as one closed island.
if not grid or not grid[0]:
return 0
rows, cols = len(grid), len(grid[0])
# 4 cardinal directions
directions = [(1, 0), (-1, 0), (0, 1), (0, -1)]
# Track visited land cells to avoid revisiting
# sc: O(V)
visited = set()
def dfs(row, col):
# Track Border Contact:
# this cell touching the border means the island isn't closed
touchesBorder = (row == 0 or row == rows - 1 or
col == 0 or col == cols - 1)
# Explore Neighbors:
for dr, dc in directions:
nr, nc = row + dr, col + dc
# Bounds Check:
if 0 <= nr < rows and 0 <= nc < cols:
# Early Pruning:
# only recurse into cell if:
# - it's land (0)
# - not already visited
if grid[nr][nc] == 0 and (nr, nc) not in visited:
visited.add((nr, nc))
# Propagate Border Contact:
# if any neighbor touches the border, the whole
# island is disqualified regardless of this cell
if dfs(nr, nc):
touchesBorder = True
return touchesBorder
# Track number of closed islands
closedIslands = 0
# Run DFS for each unvisited land cell,
# which will mark all connected land cells by adding them to visited
# tc: O(V), each cell is visited and processed at most once overall
for row in range(rows):
for col in range(cols):
# New island has been found
if grid[row][col] == 0 and (row, col) not in visited:
visited.add((row, col))
# Only count as closed if DFS never touched the border
if not dfs(row, col):
closedIslands += 1
# overall: tc O(m * n), each cell is visited at most once
# overall: sc O(m * n), for the visited set and recursion stack
# in the worst case of one giant island
return closedIslands| Aspect | Time Complexity | Space Complexity | Time Remarks | Space Remarks |
|---|---|---|---|---|
Solution 2: [BFS] BFS Iterative Border Contact Tracking - Graph/BFS Grid Traversal
def closedIsland(self, grid: List[List[int]]) -> int:
# Note:
# Counting connected components that don't touch the border
# using a Grid
# Grid (0 = land, 1 = water):
# grid = [
# [1,1,1,1,1,1,1],
# [1,0,0,0,0,0,1],
# [1,0,1,0,1,0,1],
# [1,0,0,0,0,0,1],
# [1,1,1,1,1,1,1],
# ]
# Unlike Edge List / Adjacency List problems, the grid itself
# is the graph, a cell's neighbors are always just its 4
# grid-adjacent cells. No preprocessing is needed to build an
# adjacency structure, same as Number of Islands.
# Note the flipped values compared to Number of Islands:
# here 0 = land, 1 = water, so we're counting connected
# components of 0s instead of 1s.
# BFS Grid Traversal to Count Closed Islands
# An island is only "closed" if none of its land cells touch
# the grid's outer border, since any land cell on the border
# means the island leaks out to the edge of the map and is
# therefore not fully surrounded by water.
# BFS explores every connected land cell starting from an
# unvisited land cell, and tracks whether that exploration ever
# touches a border cell. If it never does, the entire connected
# component is safely enclosed and counts as one closed island.
if not grid or not grid[0]:
return 0
rows, cols = len(grid), len(grid[0])
# 4 cardinal directions
directions = [(1, 0), (-1, 0), (0, 1), (0, -1)]
# Track visited land cells to avoid revisiting
# sc: O(V)
visited = set()
def bfs(row, col):
# Track Border Contact:
# starting cell touching the border means the island isn't closed
touchesBorder = (row == 0 or row == rows - 1 or
col == 0 or col == cols - 1)
# Iterative BFS Queue:
queue = deque([(row, col)])
while queue:
# Pop a cell from the queue
r, c = queue.popleft()
# Explore Neighbors:
for dr, dc in directions:
nr, nc = r + dr, c + dc
# Bounds Check:
if 0 <= nr < rows and 0 <= nc < cols:
# Early Pruning:
# only queue cell if:
# - it's land (0)
# - not already visited
if grid[nr][nc] == 0 and (nr, nc) not in visited:
visited.add((nr, nc))
# Track Border Contact:
if (nr == 0 or nr == rows - 1 or
nc == 0 or nc == cols - 1):
touchesBorder = True
queue.append((nr, nc))
return touchesBorder
# Track number of closed islands
closedIslands = 0
# Run BFS for each unvisited land cell,
# which will mark all connected land cells by adding them to visited
# tc: O(V), each cell is visited and processed at most once overall
for row in range(rows):
for col in range(cols):
# New island has been found
if grid[row][col] == 0 and (row, col) not in visited:
visited.add((row, col))
# Only count as closed if BFS never touched the border
if not bfs(row, col):
closedIslands += 1
# overall: tc O(m * n), each cell is visited at most once
# overall: sc O(m * n), for the visited set and queue
# in the worst case of one giant island
return closedIslands| Aspect | Time Complexity | Space Complexity | Time Remarks | Space Remarks |
|---|---|---|---|---|
Solution 3: [Union Find] Union Find Grid Neighbor Merging - Graph/Union Find
def closedIsland(self, grid: List[List[int]]) -> int:
# Note:
# Counting connected components that don't touch the border
# using a Grid
# Grid (0 = land, 1 = water):
# grid = [
# [1,1,1,1,1,1,1],
# [1,0,0,0,0,0,1],
# [1,0,1,0,1,0,1],
# [1,0,0,0,0,0,1],
# [1,1,1,1,1,1,1],
# ]
# Unlike Edge List / Adjacency List problems, the grid itself
# is the graph, a cell's neighbors are always just its 4
# grid-adjacent cells. Union-Find operates directly on the grid,
# merging each land cell with its land neighbors as it scans.
# Union-Find (Disjoint Set) to Count Closed Islands
# Every land cell starts as its own disjoint set, flattened from
# its (row, col) position into a single index (row * cols + col).
# A single extra sentinel index represents "the border" itself.
# Any land cell sitting on the border is unioned directly into
# this sentinel, so every island that leaks out to the edge
# collapses into the same shared border component.
# After processing every cell, the final answer is the number
# of unique land roots that are NOT the border's root.
if not grid or not grid[0]:
return 0
rows, cols = len(grid), len(grid[0])
# Initialize Parent + Rank Arrays:
# flatten (row, col) into a single index: row * cols + col
# one extra slot at the end reserved as the border sentinel
# tc: O(V)
# sc: O(V)
border = rows * cols
parent = list(range(rows * cols + 1))
rank = [0] * (rows * cols + 1)
# Find():
# with path compression
def find(x):
if parent[x] != x:
# Path Compression:
parent[x] = find(parent[x])
return parent[x]
# Union():
# adding by rank
def union(x, y):
rootX, rootY = find(x), find(y)
# Early Pruning:
if rootX == rootY:
return
# Smaller rank tree becomes a subtree of the larger rank tree
if rank[rootX] > rank[rootY]:
parent[rootY] = rootX
elif rank[rootX] < rank[rootY]:
parent[rootX] = rootY
else:
parent[rootY] = rootX
rank[rootX] += 1
# Process Grid:
# union land cells with their land neighbors (right, down),
# and union any border-touching land cell with the sentinel
# tc: O(V)
for row in range(rows):
for col in range(cols):
if grid[row][col] == 0:
cell = row * cols + col
# Border Sentinel:
# merge this cell into the shared border component
if row == 0 or row == rows - 1 or col == 0 or col == cols - 1:
union(cell, border)
# Explore Neighbors:
# only check right and down, left/up are handled
# when those earlier cells were processed
for dr, dc in [(1, 0), (0, 1)]:
nr, nc = row + dr, col + dc
if (0 <= nr < rows and 0 <= nc < cols and
grid[nr][nc] == 0):
union(cell, nr * cols + nc)
# Count Closed Islands:
# unique land roots that are NOT part of the border's component
# tc: O(V * α(V))
roots = set()
for row in range(rows):
for col in range(cols):
if grid[row][col] == 0:
root = find(row * cols + col)
if root != find(border):
roots.add(root)
# overall: tc O(V * α(V))
# overall: sc O(V)
return len(roots)| Aspect | Time Complexity | Space Complexity | Time Remarks | Space Remarks |
|---|---|---|---|---|
130. Surrounded Regions ::3:: - Medium
Topics: Connected Components, Array, Depth First Search, Breadth First Search, Union Find, Matrix, Grid
Intro
You are given an m x n matrix board containing letters 'X' and 'O', capture regions that are surrounded: Connect: A cell is connected to adjacent cells horizontally or vertically. Region: To form a region connect every 'O' cell. Surround: The region is surrounded with 'X' cells if you can connect the region with 'X' cells and none of the region cells are on the edge of the board. To capture a surrounded region, replace all 'O's with 'X's in-place within the original board. You do not need to return anything.
| Example Input | Output |
|---|---|
| grid height (see LeetCode) | res grid |
| grid height (see LeetCode) | res grid |
Constraints:
m == heights.length
n == heights[r].length
1 ≤ m, n ≤ 200
board[i][j] is 'X' or 'O'.
Abstraction
Find the total number of unique connected components that are not touching the boarder. Then flip those connected components to water.
Pseudocode
text will go here
Solution 1: [DFS] Recursive DFS with Flood Fill from Borders - Graph/something
def solve(self, board: List[List[str]]) -> None:
# Identify Safe 'O's
# 1. Any 'O' that touches a border cannot be flipped
# 2. Any 'O' connected (directly or indirectly) to a border 'O' is safe
# - An 'O' connected to another 'O' cannot be fully surrounded by 'X'
# 3. So we can mark islands starting from the outer 'O' and fill them,
# marking them as cannot be captured
# 4. Any other 'O' is thus able to be captured
# Starting DFS/BFS From Border 'O's:
# Any 'O' that is connected to the border cannot be captured
# Note:
# 1. Any 'O' connected to the border cannot be captured.
# 2. Mark all border-connected 'O's with DFS (temporary marker 'T').
# 3. After traversal:
# Flip all remaining 'O' to 'X' (they are surrounded).
# Flip all 'T' back to 'O'.
# 4. Mutates board in-place, no return required.
# Empty Check
# tc: O(1), sc: O(1)
if not board:
return
# Grid dimensions
# sc: O(1)
m, n = len(board), len(board[0])
# Recursive DFS traversal: mark border-connected 'O's
# sc: O(m*n) recursion stack worst-case
def dfs(r, c):
# Early Pruning:
# skip if out of bounds or not an 'O'
# tc: O(1)
if (r < 0 or r >= m or
c < 0 or c >= n or
board[r][c] != 'O'):
return
# Process Root:
# mark current cell as safe and not able to be captured
# tc: O(1), sc: O(1)
board[r][c] = 'T'
# Explore:
# recursively visit all neighbors, to see if we find safe 'O's unable to be captured
# tc: O(r*c)
dfs(r + 1, c)
dfs(r - 1, c)
dfs(r, c + 1)
dfs(r, c - 1)
# Process Roots:
# Start DFS from border 'O's
# tc: O(r+c)
for i in range(m):
# left column
dfs(i, 0)
# right column
dfs(i, n - 1)
for j in range(n):
# top row
dfs(0, j)
# bottom row
dfs(m - 1, j)
# Late Prune:
# Flip surrounded 'O' -> 'X', revert 'T' -> 'O'
# tc: O(r*c)
for i in range(m):
for j in range(n):
# Any 'O' not marked as safe will be captured
if board[i][j] == 'O':
board[i][j] = 'X'
# Any 'T' is safe and will be reverted to 'O'
elif board[i][j] == 'T':
board[i][j] = 'O'
# overall: tc O(m * n)
# overall: sc O(m * n)Solution 2: [BFS] Iterative BFS with Flood Fill from Borders - Graph/something
def solve(self, board: List[List[str]]) -> None:
# Identify Safe 'O's
# 1. Any 'O' that touches a border cannot be flipped
# 2. Any 'O' connected (directly or indirectly) to a border 'O' is safe
# - An 'O' connected to another 'O' cannot be fully surrounded by 'X'
# 3. So we can mark islands starting from the outer 'O' and fill them,
# marking them as cannot be captured
# 4. Any other 'O' is thus able to be captured
# Starting DFS/BFS From Border 'O's:
# Any 'O' that is connected to the border cannot be captured
# Note:
# 1. Any 'O' connected to the border cannot be captured.
# 2. Mark all border-connected 'O's with DFS (temporary marker 'T').
# 3. After traversal:
# Flip all remaining 'O' to 'X' (they are surrounded).
# Flip all 'T' back to 'O'.
# 4. Mutates board in-place, no return required.
# Empty Check
# tc: O(1), sc: O(1)
if not board:
return
# Grid dimensions
# sc: O(1)
m, n = len(board), len(board[0])
# Recursive DFS traversal: mark border-connected 'O's
# sc: O(m*n) recursion stack worst-case
def bfs(r, c):
# Iterative Queue
# sc: O(r*c)
queue = deque([])
# Process Root:
# initialize BFS from this cell
# tc: O(1)
queue.append((r, c))
board[r][c] = 'T'
# While we still have 'O' connected to the root 'O'
# tc: O(r*c)
while queue:
# Grab the root 'O' (original edge 'O')
cr, cc = queue.popleft()
# Process Candidates:
# recursively explore neighbors
# tc: O(r*c)
for dr, dc in [(1,0), (-1,0), (0,1), (0,-1)]:
nr, nc = cr + dr, cc + dc
# Early Prune:
# only recurse if valid bounds and 'O'
if (0 <= nr < m and
0 <= nc < n and
board[nr][nc] == 'O'):
# Mark '0' as safe
# tc: O(1)
board[nr][nc] = 'T'
# Append 'O' neighbor to stack for processing
# tc: O(1)
queue.append((nr, nc))
# Process Roots:
# Start DFS from border 'O's
# tc: O(r+c)
for i in range(m):
# left column
if board[i][0] == 'O':
bfs(i, 0)
# right column
if board[i][n - 1] == 'O':
bfs(i, n - 1)
for j in range(n):
# top row
if board[0][j] == 'O':
bfs(0, j)
# bottom row
if board[m - 1][j] == 'O':
bfs(m - 1, j)
# Late Prune:
# Flip surrounded 'O' -> 'X', revert 'T' -> 'O'
# tc: O(r*c)
for i in range(m):
for j in range(n):
# Any 'O' not marked as safe will be captured
if board[i][j] == 'O':
board[i][j] = 'X'
# Any 'T' is safe and will be reverted to 'O'
elif board[i][j] == 'T':
board[i][j] = 'O'
# overall: tc O(m * n)
# overall: sc O(m * n)Solution 3: [Union Find] Union Find Disjoint Set Union - Graph/something
def solve(self, board: List[List[str]]) -> None:
# Identify Safe 'O's
# 1. Any 'O' that touches a border cannot be flipped
# 2. Any 'O' connected (directly or indirectly) to a border 'O' is safe
# - An 'O' connected to another 'O' cannot be fully surrounded by 'X'
# 3. So we can mark islands starting from the outer 'O' and fill them,
# marking them as cannot be captured
# 4. Any other 'O' is thus able to be captured
# Starting DFS/BFS From Border 'O's:
# Any 'O' that is connected to the border cannot be captured
# Connected Components / Union Find
# Treat each 'O' as a node
# Create a dummy node representing the border
# 1. Union all border 'O's with dummy node (safe)
# 2. Union all adjacent 'O's (up/down/left/right)
# 3. Any 'O' connected to dummy is afe
# 4. Flip all other 'O' to 'X'
# Empty Check
# tc: O(1), sc: O(1)
if not board:
return
# Grid dimensions
# sc: O(1)
m, n = len(board), len(board[0])
# Parent dictionary for Union-Find
# sc: O(m*n)
parent = {}
# Find with Path Compression
# tc: O(α(N)) amortized
def find(x: int) -> int:
parent.setdefault(x, x)
if parent[x] != x:
parent[x] = find(parent[x])
return parent[x]
# Union two sets
# tc: O(α(N)) amortized, sc: O(1)
def union(x: int, y: int) -> None:
parent[find(x)] = find(y)
# Special integer representing 'border'
# all border nodes will be Union() with it
# sc: O(1)
dummy = m * n
# Process Roots and Candidates:
# iterate all cells, union border 'O's and neighbor 'O's
# tc: O(m*n)
for r in range(m):
for c in range(n):
if board[r][c] == 'O':
# Grid coordinate converted into an integer
# (0, 0) => 0
# (0, 1) => 1
# (1, 0) => 4
# fn() =
idx = r * n + c
# Check: if 'O' has a coordinate on either edge of the grid
# Implies: this 'O' is a border 'O'
if r in (0, m - 1) or c in (0, n - 1):
# Union border 'O's with dummy 'border'
union(idx, dummy)
# Union adjacent 'O's
for dr, dc in [(1,0), (-1,0), (0,1), (0,-1)]:
nr, nc = r + dr, c + dc
# Early Exit:
if (0 <= nr < m and
0 <= nc < n and
board[nr][nc] == 'O'):
# Calculate neighbor index
neighborIndex = nr * n + nc
union(idx, neighborIndex)
# Late Prune:
# flip 'O' not connected to dummy -> 'X'
# tc: O(m*n * α(N)) amortized, sc: O(1) extra
for r in range(m):
for c in range(n):
if board[r][c] == 'O' and find(r * n + c) != find(dummy):
board[r][c] = 'X'
# overall: tc O(m*n * α(m*n)) =~ O(m*n)
# overall: sc (m*n)2492. Minimum Score of a Path Between Two Cities ::3:: - Medium
Topics: Connected Components, Depth First Search, Breadth First Search, Union Find, Graph Theory
Intro
You are given a positive integer n representing n cities numbered from 1 to n. You are also given a 2D array roads where roads[i] = [ai, bi, distancei] indicates that there is a bidirectional road between cities ai and bi with a distance equal to distancei. The cities graph is not necessarily connected. The score of a path between two cities is defined as the minimum distance of a road in this path. Return the minimum possible score of a path between cities 1 and n. Note: A path is a sequence of roads between two cities. It is allowed for a path to contain the same road multiple times, and you can visit cities 1 and n multiple times along the path. The test cases are generated such that there is at least one path between 1 and n.
| Example Input | Output |
|---|---|
| n = 4, roads = [[1,2,9],[2,3,6],[2,4,5],[1,4,7]] | 5 |
| n = 4, roads = [[1,2,2],[1,3,4],[3,4,7]] | 2 |
Constraints:
2 ≤ n ≤ 10^5
1 ≤ roads.length ≤ 10^5
roads[i].length == 3
1 ≤ ai, bi ≤ n
ai != bi
1 ≤ distancei ≤ 10^4
There is at least one path between 1 and n.
Abstraction
Find the smallest edge in the connected component that cities 1 and n both belong to.
Pseudocode
text will go here
Solution 1: [DFS] DFS Connected Component Min Edge Tracking - Graph/DFS Weighted Adjacency List
def minScore(self, n: int, roads: List[List[int]]) -> int:
# Note:
# Finding the minimum edge weight within a connected component
# using a Weighted Edge List
# Weighted Edge List:
# roads = [
# [1, 2, 9],
# [2, 3, 6],
# [2, 4, 5],
# [1, 4, 7],
# ]
# Weighted Adjacency List:
# graph = {
# 1: [(2, 9), (4, 7)],
# 2: [(1, 9), (3, 6), (4, 5)],
# 3: [(2, 6)],
# 4: [(2, 5), (1, 7)],
# }
# DFS Connected Component Traversal (Undirected, Weighted Graph)
# Since a path is allowed to reuse roads and revisit cities any
# number of times, the "score" of a path isn't limited to a
# simple/shortest path at all, it can wander freely through the
# entire connected component containing city 1. That means the
# minimum possible score is just the minimum edge weight found
# anywhere within that connected component, since any edge in
# the component can always be reached and walked across
# eventually by some sufficiently long path.
# DFS explores every city reachable from city 1, tracking the
# smallest edge weight seen along the way. Since the problem
# guarantees at least one path exists between 1 and n, n is
# guaranteed to already be part of this same connected component.
# Build Weighted Adjacency List:
# graph[u] = list of (neighbor, weight) pairs
# tc: O(V + E)
# sc: O(V + E)
graph = defaultdict(list)
for u, v, dist in roads:
graph[u].append((v, dist))
graph[v].append((u, dist))
# Track visited cities
# sc: O(V)
visited = set()
# Track minimum edge weight seen in the component
# using a mutable container since Python closures can't
# reassign an outer int directly
minScore = [float('inf')]
def dfs(city):
# Early Pruning:
if city in visited:
return
# Mark node as visited
visited.add(city)
# Explore Neighbors:
for nei, weight in graph[city]:
# Track Minimum:
# update global minimum edge weight seen so far
minScore[0] = min(minScore[0], weight)
# Recursively explore neighbor
dfs(nei)
# Start DFS from city 1
dfs(1)
# overall: tc O(V + E)
# overall: sc O(V + E)
return minScore[0]| Aspect | Time Complexity | Space Complexity | Time Remarks | Space Remarks |
|---|---|---|---|---|
Solution 2: [BFS] BFS Iterative Connected Component Min Edge Tracking - Graph/BFS Weighted Adjacency List
def minScore(self, n: int, roads: List[List[int]]) -> int:
# Note:
# Finding the minimum edge weight within a connected component
# using a Weighted Edge List
# Weighted Edge List:
# roads = [
# [1, 2, 9],
# [2, 3, 6],
# [2, 4, 5],
# [1, 4, 7],
# ]
# Weighted Adjacency List:
# graph = {
# 1: [(2, 9), (4, 7)],
# 2: [(1, 9), (3, 6), (4, 5)],
# 3: [(2, 6)],
# 4: [(2, 5), (1, 7)],
# }
# BFS Connected Component Traversal (Undirected, Weighted Graph)
# Since a path is allowed to reuse roads and revisit cities any
# number of times, the "score" of a path isn't limited to a
# simple/shortest path at all, it can wander freely through the
# entire connected component containing city 1. That means the
# minimum possible score is just the minimum edge weight found
# anywhere within that connected component, since any edge in
# the component can always be reached and walked across
# eventually by some sufficiently long path.
# BFS explores every city reachable from city 1, tracking the
# smallest edge weight seen along the way. Since the problem
# guarantees at least one path exists between 1 and n, n is
# guaranteed to already be part of this same connected component.
# Build Weighted Adjacency List:
# graph[u] = list of (neighbor, weight) pairs
# tc: O(V + E)
# sc: O(V + E)
graph = defaultdict(list)
for u, v, dist in roads:
graph[u].append((v, dist))
graph[v].append((u, dist))
# Iterative BFS Queue:
# start from city 1
# sc: O(V)
queue = deque([1])
visited = {1}
# Track minimum edge weight seen in the component
minScore = float('inf')
while queue:
# Pop a city from the queue
city = queue.popleft()
# Explore Neighbors:
for nei, weight in graph[city]:
# Track Minimum:
# update minimum edge weight seen so far
minScore = min(minScore, weight)
# Early Pruning:
# only queue city if not already visited
if nei not in visited:
visited.add(nei)
queue.append(nei)
# overall: tc O(V + E)
# overall: sc O(V + E)
return minScore| Aspect | Time Complexity | Space Complexity | Time Remarks | Space Remarks |
|---|---|---|---|---|
Solution 3: [Union Find] Union Find Component Min Edge Tracking - Graph/something
def minScore(self, n: int, roads: List[List[int]]) -> int:
# Note:
# Finding the minimum edge weight within a connected component
# using a Weighted Edge List
# Weighted Edge List:
# roads = [
# [1, 2, 9],
# [2, 3, 6],
# [2, 4, 5],
# [1, 4, 7],
# ]
# Unlike DFS/BFS, Union-Find operates directly on the Edge List —
# there's no need to build an Adjacency List first, since we only
# ever process one edge (pair of nodes plus weight) at a time.
# Union-Find (Disjoint Set) for Component Min Edge Tracking
# Since a path is allowed to reuse roads and revisit cities any
# number of times, the minimum possible score between 1 and n
# is simply the minimum edge weight anywhere in their shared
# connected component. Union-Find naturally groups cities into
# components as edges are processed, so we can track the
# minimum weight per component alongside the union operations
# themselves, then read off the answer for whichever component
# city 1 (and therefore city n) ends up in.
# Initialize Parent + Rank + Min Weight Arrays:
# parent[i] = representative of the set containing city i
# rank[i] = size/depth heuristic for union by rank
# minWeight[i] = minimum edge weight seen in the set rooted at i
# tc: O(V)
# sc: O(V)
parent = list(range(n + 1))
rank = [0] * (n + 1)
minWeight = [float('inf')] * (n + 1)
# Find():
# with path compression
def find(x):
if parent[x] != x:
# Path Compression:
parent[x] = find(parent[x])
return parent[x]
# Union():
# merges sets containing x and y, tracking min weight along the way
def union(x, y, weight):
rootX, rootY = find(x), find(y)
# Merge sets (skip if already unioned) and fold in the min weight
if rootX != rootY:
# Smaller rank tree becomes a subtree of the larger rank tree
if rank[rootX] > rank[rootY]:
parent[rootY] = rootX
rootY = rootX
elif rank[rootX] < rank[rootY]:
parent[rootX] = rootY
rootX = rootY
else:
parent[rootY] = rootX
rank[rootX] += 1
rootY = rootX
# Track Minimum:
# fold this edge's weight into the merged component's minimum
root = find(x)
minWeight[root] = min(minWeight[root], weight)
# Process All Edges:
# tc: O(E * α(V))
for u, v, dist in roads:
union(u, v, dist)
# Read off the minimum weight tracked for city 1's component
# tc: O(α(V))
# overall: tc O((V + E) * α(V))
# overall: sc O(V)
return minWeight[find(1)]| Aspect | Time Complexity | Space Complexity | Time Remarks | Space Remarks |
|---|---|---|---|---|
1319. Number of Operations to Make Network Connected ::3:: - Medium
Topics: Connected Components, Depth First Search, Breadth First Search, Union Find, Graph Theory, Edge List, Adjacency List
Intro
There are n computers numbered from 0 to n - 1 connected by ethernet cables connections forming a network where connections[i] = [ai, bi] represents a connection between computers ai and bi. Any computer can reach any other computer directly or indirectly through the network. You are given an initial computer network connections. You can extract certain cables between two directly connected computers, and place them between any pair of disconnected computers to make them directly connected. Return the minimum number of times you need to do this in order to make all the computers connected. If it is not possible, return -1.
| Example Input | Output |
|---|---|
| n = 4, connections = [[0,1],[0,2],[1,2]] | 1 |
| n = 6, connections = [[0,1],[0,2],[0,3],[1,2],[1,3]] | 2 |
| n = 6, connections = [[0,1],[0,2],[0,3],[1,2]] | -1 |
Constraints:
1 ≤ n ≤ 10^5
1 ≤ connections.length ≤ min(n * (n-1) / 2, 10^5)
connections[i].length == 2
0 ≤ ai, bi < n
ai != n
There are no repeated connections.
No two computers are connected by more than one cable.
Abstraction
Get total count of unique components. Then calculate the minimum edges needed to connect them all. The minimum edges needed to connected is: total count of unique components - 1.
Pseudocode
text will go here
Solution 1: [DFS] DFS Connected Component Counting Then Calculate - Graph/DFS Adjacency List
def makeConnected(self, n: int, connections: List[List[int]]) -> int:
# Note:
# Counting connected components
# using an Edge List
# Edge List:
# connections = [
# [0, 1],
# [0, 2],
# [1, 2],
# ]
# Adjacency List:
# graph = {
# 0: [1, 2],
# 1: [0, 2],
# 2: [0, 1],
# }
# DFS Connected Component Counting (Undirected Graph)
# Every extra cable within an already-connected group of
# computers is redundant, it doesn't help connect any new
# computer. Every one of these redundant cables can instead be
# unplugged and used to bridge two separate components together.
# This means the answer only depends on how many separate
# connected components exist: connecting k components into one
# always takes exactly k - 1 operations (bridge one at a time),
# regardless of how the redundant cables are actually
# distributed among the components.
# We only have n - 1 minimum required cables to connect n
# computers, so if there aren't at least that many cables total,
# there's no way to have enough spares to bridge every gap.
# Edge Case:
# not enough cables exist to possibly connect all computers
if len(connections) < n - 1:
return -1
# Build Adjacency List:
# graph[u] = neighbors of u
# tc: O(V + E)
# sc: O(V + E)
graph = defaultdict(list)
for u, v in connections:
graph[u].append(v)
graph[v].append(u)
# Track visited computers
# sc: O(V)
visited = set()
def dfs(computer):
# Early Pruning:
if computer in visited:
return
# Mark node as visited
visited.add(computer)
# Explore Neighbors:
for nei in graph[computer]:
dfs(nei)
# Count Connected Components:
# tc: O(V)
components = 0
for computer in range(n):
# New component found
if computer not in visited:
dfs(computer)
components += 1
# Connecting k components into one always takes k - 1 operations
# overall: tc O(V + E)
# overall: sc O(V + E)
return components - 1| Aspect | Time Complexity | Space Complexity | Time Remarks | Space Remarks |
|---|---|---|---|---|
Solution 2: [BFS] BFS Iterative Connected Component Counting - Graph/BFS Adjacency List
def makeConnected(self, n: int, connections: List[List[int]]) -> int:
# Note:
# Counting connected components
# using an Edge List
# Edge List:
# connections = [
# [0, 1],
# [0, 2],
# [1, 2],
# ]
# Adjacency List:
# graph = {
# 0: [1, 2],
# 1: [0, 2],
# 2: [0, 1],
# }
# BFS Connected Component Counting (Undirected Graph)
# Every extra cable within an already-connected group of
# computers is redundant, it doesn't help connect any new
# computer. Every one of these redundant cables can instead be
# unplugged and used to bridge two separate components together.
# This means the answer only depends on how many separate
# connected components exist: connecting k components into one
# always takes exactly k - 1 operations (bridge one at a time),
# regardless of how the redundant cables are actually
# distributed among the components.
# We only have n - 1 minimum required cables to connect n
# computers, so if there aren't at least that many cables total,
# there's no way to have enough spares to bridge every gap.
# Edge Case:
# not enough cables exist to possibly connect all computers
if len(connections) < n - 1:
return -1
# Build Adjacency List:
# graph[u] = neighbors of u
# tc: O(V + E)
# sc: O(V + E)
graph = defaultdict(list)
for u, v in connections:
graph[u].append(v)
graph[v].append(u)
# Track visited computers
# sc: O(V)
visited = set()
def bfs(start):
# Iterative BFS Queue:
queue = deque([start])
visited.add(start)
while queue:
# Pop a node from the queue
computer = queue.popleft()
# Explore Neighbors:
for nei in graph[computer]:
# Early Pruning:
# only queue node if not already visited
if nei not in visited:
visited.add(nei)
queue.append(nei)
# Count Connected Components:
# tc: O(V)
components = 0
for computer in range(n):
# New component found
if computer not in visited:
bfs(computer)
components += 1
# Connecting k components into one always takes k - 1 operations
# overall: tc O(V + E)
# overall: sc O(V + E)
return components - 1| Aspect | Time Complexity | Space Complexity | Time Remarks | Space Remarks |
|---|---|---|---|---|
Solution 3: [Union Find] Union Find Component Counting - Graph/Union Find
def makeConnected(self, n: int, connections: List[List[int]]) -> int:
# Note:
# Counting connected components
# using an Edge List
# Edge List:
# connections = [
# [0, 1],
# [0, 2],
# [1, 2],
# ]
# Unlike DFS/BFS, Union-Find operates directly on the Edge List —
# there's no need to build an Adjacency List first, since we only
# ever process one edge (pair of nodes) at a time.
# Union-Find (Disjoint Set) Component Counting
# Every extra cable within an already-connected group of
# computers is redundant, it doesn't help connect any new
# computer. Every one of these redundant cables can instead be
# unplugged and used to bridge two separate components together.
# This means the answer only depends on how many separate
# connected components exist: connecting k components into one
# always takes exactly k - 1 operations, regardless of how the
# redundant cables are actually distributed among the components.
# Processing every connection with Union-Find naturally merges
# computers into components. The final number of unique roots
# remaining is the number of separate components left.
# Edge Case:
# not enough cables exist to possibly connect all computers
if len(connections) < n - 1:
return -1
# Initialize Parent + Rank Arrays:
# parent[i] = representative of the set containing computer i
# rank[i] = size/depth heuristic for union by rank
# tc: O(V)
# sc: O(V)
parent = list(range(n))
rank = [0] * n
# Find():
# with path compression
def find(x):
if parent[x] != x:
# Path Compression:
parent[x] = find(parent[x])
return parent[x]
# Union():
# adding by rank
def union(x, y):
rootX, rootY = find(x), find(y)
# Early Pruning:
if rootX == rootY:
return
# Smaller rank tree becomes a subtree of the larger rank tree
if rank[rootX] > rank[rootY]:
parent[rootY] = rootX
elif rank[rootX] < rank[rootY]:
parent[rootX] = rootY
else:
parent[rootY] = rootX
rank[rootX] += 1
# Process All Edges:
# tc: O(E * α(V))
for u, v in connections:
union(u, v)
# Count Unique Roots:
# each unique root represents one separate component
# tc: O(V * α(V))
components = len(set(find(computer) for computer in range(n)))
# Connecting k components into one always takes k - 1 operations
# overall: tc O((V + E) * α(V))
# overall: sc O(V)
return components - 1| Aspect | Time Complexity | Space Complexity | Time Remarks | Space Remarks |
|---|---|---|---|---|
2316. Count Unreachable Pairs of Nodes in an Undirected Graph ::3:: - Medium
Topics: Connected Components, Depth First Search, Breadth First Search, Union Find, Graph Theory, Edge List, Adjacency List
Intro
You are given an integer n. There is an undirected graph with n nodes, numbered from 0 to n - 1. You are given a 2D integer array edges where edges[i] = [ai, bi] denotes that there exists an undirected edge connecting nodes ai and bi. Return the number of pairs of different nodes that are unreachable from each other.
| Example Input | Output |
|---|---|
| n = 3, edges = [[0,1],[0,2],[1,2]] | 0 |
| n = 7, edges = [[0,2],[0,5],[2,4],[1,6],[5,4]] | 14 |
Constraints:
1 ≤ n ≤ 10^5
1 ≤ edges.length ≤ 2 * 10^5
edges[i].length == 2
0 ≤ ai, bi < n
ai != n
There are no repeated edges.
Abstraction
Get number of unique connected components. Then calculate how many nodes are unreachable from each other.
Pseudocode
text will go here
Solution 1: [DFS] DFS Component Size Pairwise Counting - Graph/DFS Adjacency List
def countPairs(self, n: int, edges: List[List[int]]) -> int:
# Note:
# Counting unreachable node pairs
# using an Edge List
# Edge List:
# edges = [
# [0, 1],
# [0, 2],
# [3, 4],
# ]
# Adjacency List:
# graph = {
# 0: [1, 2],
# 1: [0],
# 2: [0],
# 3: [4],
# 4: [3],
# }
# DFS Component Size Tracking (Undirected Graph)
# Two nodes are unreachable from each other if and only if they
# belong to different connected components. Rather than checking
# every pair directly (which would be O(V^2)), we can compute
# the SIZE of each connected component, then use those sizes to
# derive the total unreachable pair count in one pass.
# For each component processed, every node inside it is
# unreachable from every node in every component processed so
# far. So we accumulate a running total of "nodes seen in
# earlier components", and for each new component of size s,
# add s * (nodes seen so far) to the answer, then fold s into
# the running total.
# Build Adjacency List:
# graph[u] = neighbors of u
# tc: O(V + E)
# sc: O(V + E)
graph = defaultdict(list)
for u, v in edges:
graph[u].append(v)
graph[v].append(u)
# Track visited nodes
# sc: O(V)
visited = set()
def dfs(node):
# Early Pruning:
if node in visited:
return 0
# Mark node as visited
visited.add(node)
# Count current node plus every node reachable from it
size = 1
for nei in graph[node]:
size += dfs(nei)
return size
# Accumulate Unreachable Pairs:
# nodesSeen = total nodes across all previously processed components
# tc: O(V)
unreachablePairs = 0
nodesSeen = 0
for node in range(n):
# New component found
if node not in visited:
size = dfs(node)
# Every node in this new component is unreachable from
# every node seen in all earlier components
unreachablePairs += size * nodesSeen
nodesSeen += size
# overall: tc O(V + E)
# overall: sc O(V + E)
return unreachablePairs| Aspect | Time Complexity | Space Complexity | Time Remarks | Space Remarks |
|---|---|---|---|---|
Solution 2: [BFS] BFS Iterative Component Size Pairwise Counting - Graph/BFS Weighted Adjacency List
def countPairs(self, n: int, edges: List[List[int]]) -> int:
# Note:
# Counting unreachable node pairs
# using an Edge List
# Edge List:
# edges = [
# [0, 1],
# [0, 2],
# [3, 4],
# ]
# Adjacency List:
# graph = {
# 0: [1, 2],
# 1: [0],
# 2: [0],
# 3: [4],
# 4: [3],
# }
# BFS Component Size Tracking (Undirected Graph)
# Two nodes are unreachable from each other if and only if they
# belong to different connected components. Rather than checking
# every pair directly (which would be O(V^2)), we can compute
# the SIZE of each connected component, then use those sizes to
# derive the total unreachable pair count in one pass.
# For each component processed, every node inside it is
# unreachable from every node in every component processed so
# far. So we accumulate a running total of "nodes seen in
# earlier components", and for each new component of size s,
# add s * (nodes seen so far) to the answer, then fold s into
# the running total.
# Build Adjacency List:
# graph[u] = neighbors of u
# tc: O(V + E)
# sc: O(V + E)
graph = defaultdict(list)
for u, v in edges:
graph[u].append(v)
graph[v].append(u)
# Track visited nodes
# sc: O(V)
visited = set()
def bfs(start):
# Iterative BFS Queue:
queue = deque([start])
visited.add(start)
# Count current node plus every node reachable from it
size = 0
while queue:
# Pop a node from the queue
node = queue.popleft()
size += 1
# Explore Neighbors:
for nei in graph[node]:
# Early Pruning:
# only queue node if not already visited
if nei not in visited:
visited.add(nei)
queue.append(nei)
return size
# Accumulate Unreachable Pairs:
# nodesSeen = total nodes across all previously processed components
# tc: O(V)
unreachablePairs = 0
nodesSeen = 0
for node in range(n):
# New component found
if node not in visited:
size = bfs(node)
# Every node in this new component is unreachable from
# every node seen in all earlier components
unreachablePairs += size * nodesSeen
nodesSeen += size
# overall: tc O(V + E)
# overall: sc O(V + E)
return unreachablePairs| Aspect | Time Complexity | Space Complexity | Time Remarks | Space Remarks |
|---|---|---|---|---|
Solution 3: [Union Find] Union Find Component Size Pairwise Counting - Graph/Union Find
def countPairs(self, n: int, edges: List[List[int]]) -> int:
# Note:
# Counting unreachable node pairs
# using an Edge List
# Edge List:
# edges = [
# [0, 1],
# [0, 2],
# [3, 4],
# ]
# Unlike DFS/BFS, Union-Find operates directly on the Edge List —
# there's no need to build an Adjacency List first, since we only
# ever process one edge (pair of nodes) at a time.
# Union-Find (Disjoint Set) Component Size Tracking
# Two nodes are unreachable from each other if and only if they
# end up with different roots after processing every edge.
# Union-Find can track the SIZE of each component directly as
# part of the union operation (always merging the smaller tree
# into the larger one), so after processing all edges, we can
# read off every component's final size in one pass.
# Once we have every component's size, the same running-total
# trick applies: for each component of size s, every node in it
# is unreachable from every node in every other component,
# so total unreachable pairs = sum over components of
# size_i * (nodes in all other components).
# Initialize Parent + Rank + Size Arrays:
# parent[i] = representative of the set containing node i
# rank[i] = size/depth heuristic for union by rank
# size[i] = number of nodes in the set rooted at i
# tc: O(V)
# sc: O(V)
parent = list(range(n))
rank = [0] * n
size = [1] * n
# Find():
# with path compression
def find(x):
if parent[x] != x:
# Path Compression:
parent[x] = find(parent[x])
return parent[x]
# Union():
# adding by rank, folding size into the surviving root
def union(x, y):
rootX, rootY = find(x), find(y)
# Early Pruning:
if rootX == rootY:
return
# Smaller rank tree becomes a subtree of the larger rank tree
if rank[rootX] > rank[rootY]:
parent[rootY] = rootX
size[rootX] += size[rootY]
elif rank[rootX] < rank[rootY]:
parent[rootX] = rootY
size[rootY] += size[rootX]
else:
parent[rootY] = rootX
size[rootX] += size[rootY]
rank[rootX] += 1
# Process All Edges:
# tc: O(E * α(V))
for u, v in edges:
union(u, v)
# Accumulate Unreachable Pairs:
# nodesSeen = total nodes across all previously processed components
# tc: O(V * α(V))
unreachablePairs = 0
nodesSeen = 0
for node in range(n):
# Only process each component once, when we hit its root
if find(node) == node:
# Every node in this component is unreachable from
# every node seen in all earlier components
unreachablePairs += size[node] * nodesSeen
nodesSeen += size[node]
# overall: tc O((V + E) * α(V))
# overall: sc O(V)
return unreachablePairs| Aspect | Time Complexity | Space Complexity | Time Remarks | Space Remarks |
|---|---|---|---|---|
684. Redundant Connection ::3:: - Medium
Topics: Connected Components, Depth First Search, Breadth First Search, Union Find, Graph Theory, Edge List
Intro
In this problem, a tree is an undirected graph that is connected and has no cycles. You are given a graph that started as a tree with n nodes labeled from 1 to n, with one additional edge added. The added edge has two different vertices chosen from 1 to n, and was not an edge that already existed. The graph is represented as an array edges of length n where edges[i] = [ai, bi] indicates that there is an edge between nodes ai and bi in the graph. Return an edge that can be removed so that the resulting graph is a tree of n nodes. If there are multiple answers, return the answer that occurs last in the input.
| Example Input | Output |
|---|---|
| edges = [[1,2],[1,3],[2,3]] | [2,3] |
| edges = [[1,2],[2,3],[3,4],[1,4],[1,5]] | [1,4] |
Constraints:
n == edges.length
3 ≤ n ≤ 1000
edges[i].length == 2
1 ≤ ai, < bi ≤ edges.length
ai != bi
There are no repeated edges.
The given graph is connected.
Abstraction
Find the extra edge in a connected component that when removed will remove the cycle from the component and restore it to a valid tree.
Pseudocode
text will go here
Solution 1: [DFS] DFS - Graph/something
def findRedundantConnection(self, edges: List[List[int]]) -> List[int]:
# Connected Components in a Graph:
# Each edge adds a connection between two nodes.
# A redundant connection is the first edge that forms a cycle.
# We can detect this by checking if two nodes are already connected before adding the edge.
# Graph representation using adjacency list
# sc: O(V + E)
graph = defaultdict(list)
# tc: O(V + E)
# sc: O(V)
def dfs(u, target, visited):
# path exists => adding edge would form a cycle
if u == target:
return True
# mark as visited
visited.add(u)
for v in graph[u]:
if v not in visited and dfs(v, target, visited):
return True
return False
# Process edges one by one
# tc: O(E * V) worst-case (each DFS may traverse all nodes)
# sc: O(V + E)
for u, v in edges:
visited = set()
# Check if u and v are already connected
# If yes, adding this edge forms a cycle -> redundant
if u in graph and v in graph and dfs(u, v, visited):
return [u, v]
# Otherwise, add edge to graph
graph[u].append(v)
graph[v].append(u)
# overall: tc O(E * V)
# overall: sc O(V + E) Solution 2: [BFS] BFS - Graph/something
def findRedundantConnection(self, edges: List[List[int]]) -> List[int]:
# Graph representation
# sc: O(V + E)
graph = defaultdict(list)
# BFS Helper Function
# tc: O(V + E) worst-case per call
# sc: O(V) for queue + visited
def bfs(u, target):
# tracking visited per BFS
visited = set([u])
# Iterative queue
queue = deque([u])
# While we still have connected nodes
while queue:
# Grab root node
node = queue.popleft()
# Process Root:
# path exists, adding edge would form a cycle
if node == target:
return True
# Explore:
# recursively explore neighbors
for nei in graph[node]:
# Early Prune:
# explore if not visited before
if nei not in visited:
# Process root:
# mark as visited
visited.add(nei)
# Append to queue to process
queue.append(nei)
return False
# Process each edge
# tc: O(E * V)
# sc: O(V + E)
for u, v in edges:
if u in graph and v in graph and bfs(u, v):
return [u, v]
# Add edge to graph
graph[u].append(v)
graph[v].append(u)
# overall tc: O(E * V)
# overall sc: O(V + E)Solution 3: [Union Find] Union Find Disjoint Set Union - Graph/something
def findRedundantConnection(self, edges: List[List[int]]) -> List[int]:
# Union-Find Approach:
# Each node starts as its own parent.
# If two nodes of an edge already share the same root, adding this edge forms a cycle.
n = len(edges)
parent = [0] * (n + 1)
rank = [0] * (n + 1)
for i in range(n + 1):
parent[i] = i
rank[i] = 1
# Find with Path Compression
# tc: O(α(n)
def find(x):
# if parent isn't self, recurse upwards
if parent[x] != x:
# path compression
parent[x] = find(parent[x])
# return parent of original, after path compression
return parent[x]
# Union by Rank
# tc: O(α(n)) amortized per call, sc: O(1)
def union(x, y):
# cycle detected, no union performed
rootX, rootY = find(x), find(y)
if rootX == rootY:
return False
# Union by rank to keep tree shallow
if rank[rootX] > rank[rootY]:
parent[rootY] = rootX
elif rank[rootX] < rank[rootY]:
parent[rootX] = rootY
else:
parent[rootY] = rootX
rank[rootX] += 1
# Union successful
return True
# Process all edges
# tc: O(E * α(n))
# sc: O(n) for parent and rank
for u, v in edges:
if not union(u, v):
# first edge forming a cycle is redundant
return [u, v]
# overall tc: O(E * α(n))
# overall sc: O(n)721. Accounts Merge ::3:: - Medium
Topics: Connected Components, Array, Hash Table, String, Depth First Search, Breadth First Search, Union Find, Sorting, Adjacency List, Adjacency Set, Rule Based Graph, Graph Theory
Intro
Given a list of accounts where each element accounts[i] is a list of strings, where the first element accounts[i][0] is a name, and the rest of the elements are emails representing emails of the account. Now, we would like to merge these accounts. Two accounts definitely belong to the same person if there is some common email to both accounts. Note that even if two accounts have the same name, they may belong to different people as people could have the same name. A person can have any number of accounts initially, but all of their accounts definitely have the same name. After merging the accounts, return the accounts in the following format: the first element of each account is the name, and the rest of the elements are emails in sorted order. The accounts themselves can be returned in any order.
| Example Input | Output |
|---|---|
| look at question! | ? |
| look at question! | ? |
Constraints:
1 ≤ accounts.length ≤ 1000
2 ≤ accounts[i].length ≤ 10
1 ≤ accounts[i][j].length ≤ 30
accounts[i][0] consists of English letters
accounts[i][j] (for j > 0) is a valid email
Abstraction
A single connected component represents one person, not a name,
since name alone doesn't determine identity (two different people can share the same name,
but they'd form two separate connected components since their email sets never overlap,
no edge connects them).
Each unique connected component/person contains multiple nodes representing
that person's emails (even if scattered across multiple original accounts,
again, possibly sharing a name with an unrelated person).
We will join all of the nodes/emails of each unique connected component/person
and group them under the person represented by that component.
Pseudocode
text will go here
Solution 1: [DFS] DFS - Graph/something
def accountsMerge(self, accounts: List[List[str]]) -> List[List[str]]:
# Problem:
# Emails that belong to the same person are connected via accounts.
# Build a graph where nodes are emails, edges connect emails in the same account.
# Then traverse connected components using DFS to collect all emails for each person.
# Graph Representation: adjacency list for emails
# sc: O(E) where E = total number of emails
graph = defaultdict(set)
# Map email -> name
email_to_name = {}
# Build graph
# tc: O(A * L^2) worst-case where A = number of accounts, L = emails per account
for account in accounts:
name = account[0]
first_email = account[1]
for email in account[1:]:
graph[first_email].add(email)
graph[email].add(first_email)
email_to_name[email] = name
visited = set()
res = []
# DFS Helper
# tc: O(E)
# sc: O(E) recursion stack
def dfs(email, component):
visited.add(email)
component.append(email)
for nei in graph[email]:
if nei not in visited:
dfs(nei, component)
# Traverse all emails
# tc: O(E)
for email in graph:
if email not in visited:
component = []
dfs(email, component)
# sort emails and prepend name
res.append([email_to_name[email]] + sorted(component))
# overall tc: O(A * L^2 + E log E) for sorting
# overall sc: O(E + A * L) for graph and recursion
return resSolution 2: [BFS] BFS - Graph/something
def accountsMerge(self, accounts: List[List[str]]) -> List[List[str]]:
# BFS Approach:
# Build the same graph of emails connected by accounts.
# Instead of DFS, explore each connected component level by level.
# Graph Representation
# sc: O(E)
graph = defaultdict(set)
email_to_name = {}
# Build graph
# tc: O(A * L^2)
for account in accounts:
name = account[0]
first_email = account[1]
for email in account[1:]:
graph[first_email].add(email)
graph[email].add(first_email)
email_to_name[email] = name
visited = set()
res = []
# BFS Helper
# tc: O(E)
# sc: O(E)
def bfs(start):
queue = deque([start])
component = []
visited.add(start)
while queue:
email = queue.popleft()
component.append(email)
for nei in graph[email]:
if nei not in visited:
visited.add(nei)
queue.append(nei)
return component
# Process all emails
# tc: O(E)
for email in graph:
if email not in visited:
component = bfs(email)
res.append([email_to_name[email]] + sorted(component))
# overall tc: O(A * L^2 + E log E) for sorting
# overall sc: O(E + A * L) for graph + queue
return resSolution 3: [Union Find] Union Find Disjoint Set Union - Graph/something
def accountsMerge(self, accounts: List[List[str]]) -> List[List[str]]:
# Union-Find Approach:
# Treat each email as a node.
# Union all emails in the same account.
# After all unions, emails in the same connected component belong to the same person.
parent = {}
email_to_name = {}
# Initialize parent mapping
for account in accounts:
name = account[0]
first_email = account[1]
for email in account[1:]:
parent[email] = email
email_to_name[email] = name
# Find with Path Compression
# tc: O(α(E))
def find(x):
if parent[x] != x:
parent[x] = find(parent[x])
return parent[x]
# Union by parent
# tc: O(α(E))
def union(x, y):
parent[find(x)] = find(y)
# Process accounts to union emails
# tc: O(A * L * α(E))
for account in accounts:
first_email = account[1]
for email in account[1:]:
union(first_email, email)
# Group emails by root parent
# tc: O(E)
groups = defaultdict(list)
for email in parent:
root = find(email)
groups[root].append(email)
# Build result
res = []
# tc: O(E log E) for sorting
for root, emails in groups.items():
res.append([email_to_name[root]] + sorted(emails))
# overall tc: O(A * L * α(E) + E log E)
# overall sc: O(E + A * L) for parent map and groups
return res