Jc-alt logo
jc
data structures and algorithms

LeetCode: Graphs I DFS BFS Union Find

LeetCode: Graphs I DFS BFS Union Find
61 min read
#data structures and algorithms
Table Of Contents

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

  1. Vertices (n): entities (e.g. nodes)
  2. Edges (m): connections between entities
  3. Direction: Edges can be directed (nodes pointing to nodes), or undirected (no one pointing)
  4. Weight: Edges can have weight (e.g. cost, time) or unweighted
  5. Unordered: unlike tree, heaps, etc, graphs allow cycles, multiple paths, and varying connectivity
  6. 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 fill

DFS 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 traversal

Post 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 area

BFS

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)

463. Island Perimeter ::1:: - Easy

Topics: Array, Depth First Search, Breadth First Search, Matrix

Intro

You are given row x col grid representing a map where grid[i][j] = 1 represents land and grid[i][j] = 0 represents water. Grid cells are connected horizontally/vertically (not diagonally). The grid is completely surrounded by water, and there is exactly one island (i.e., one or more connected land cells). The island doesn't have "lakes", meaning the water inside isn't connected to the water around the island. One cell is a square with side length 1. The grid is rectangular, width and height don't exceed 100. Determine the perimeter of the island.

Example InputOutput
grid = [[0,1,0,0],[1,1,1,0],[0,1,0,0],[1,1,0,0]]16
grid = [[1]]4
grid = [[1,0]]4

Constraints:

row == grid.length

col == grid[i].length

1 ≤ row, col ≤ 100

grid[i][j] is 0 or 1

There is exactly one island in grid

Abstraction

There

Space & Time Complexity

SolutionTime ComplexitySpace ComplexityTime RemarkSpace Remark
BugError

Brute Force:

AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

Find the Bug:

Solution 1: [DFS] DFS Recursive Late Pruning Track All Visited Land In Current Island - Graph/something

    def islandPerimeter(self, grid: List[List[int]]) -> int:
        
        # Problem:
        # Each land cell contributes 4 edges.
        #
        # But:
        # If two land cells are adjacent,
        # they share one edge.
        # That shared edge removes 2 perimeter edges
        # (one from each cell).
        #
        # Therefore:
        # For each land cell:
        #   Start with 4 sides
        #   Subtract 1 for each adjacent land neighbor
        #
        # Since each shared edge is counted twice
        # (once per cell), subtracting per neighbor
        # naturally handles correct perimeter.
        
        
        rows = len(grid)
        cols = len(grid[0])
        
        perimeter = 0
        
        # Iterate through entire grid
        # tc: O(rows * cols)
        for r in range(rows):
            for c in range(cols):
                
                # Only process land cells
                if grid[r][c] == 1:
                    
                    # Each land cell starts with 4 sides
                    perimeter += 4
                    
                    # Check top neighbor
                    if r > 0 and grid[r - 1][c] == 1:
                        perimeter -= 1
                    
                    # Check bottom neighbor
                    if r < rows - 1 and grid[r + 1][c] == 1:
                        perimeter -= 1
                    
                    # Check left neighbor
                    if c > 0 and grid[r][c - 1] == 1:
                        perimeter -= 1
                    
                    # Check right neighbor
                    if c < cols - 1 and grid[r][c + 1] == 1:
                        perimeter -= 1
        
        # overall: tc O(rows * cols)
        # overall: sc O(1)
        return perimeter

953. Verifying an Alien Dictionary ::1:: - Easy

Topics: Array, Hash Table, String

Intro

You are given row x col grid representing a map where grid[i][j] = 1 represents land and grid[i][j] = 0 represents water. Grid cells are connected horizontally/vertically (not diagonally). The grid is completely surrounded by water, and there is exactly one island (i.e., one or more connected land cells). The island doesn't have "lakes", meaning the water inside isn't connected to the water around the island. One cell is a square with side length 1. The grid is rectangular, width and height don't exceed 100. Determine the perimeter of the island.

Example InputOutput
words = ["hello","leetcode"], order = "hlabcdefgijkmnopqrstuvwxyz"true
words = ["word","world","row"], order = "worldabcefghijkmnpqstuvxyz"false
words = ["apple","app"], order = "abcdefghijklmnopqrstuvwxyz"false

Constraints:

1 ≤ words.length ≤ 100

1 ≤ words[i].length ≤ 20

order.length == 26

All characters in words[i] and order and English lowercase letters.

Abstraction

There

Space & Time Complexity

SolutionTime ComplexitySpace ComplexityTime RemarkSpace Remark
BugError

Brute Force:

AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

Find the Bug:

Solution 1: [DFS] DFS Recursive Late Pruning Track All Visited Land In Current Island - Graph/something

    def isAlienSorted(self, words: List[str], order: str) -> bool:
        
        # Problem:
        # We are given a custom alphabet ordering.
        # We must verify that words are sorted lexicographically
        # according to this alien order.
        #
        # Strategy:
        # 1. Convert 'order' into a rank map:
        #       char -> index (its position in alien alphabet)
        #
        # 2. Compare each adjacent pair of words.
        #    If every adjacent pair is valid,
        #    then the whole list is sorted.
        
        
        # Step 1: Build ranking map
        # tc O(26), sc O(26)
        rank = {char: i for i, char in enumerate(order)}
        
        
        # Step 2: Compare adjacent word pairs
        # tc O(total characters across words)
        for i in range(len(words) - 1):
            
            word1 = words[i]
            word2 = words[i + 1]
            
            # Compare characters lexicographically
            min_length = min(len(word1), len(word2))
            
            valid_order = False
            
            for j in range(min_length):
                
                if word1[j] != word2[j]:
                    
                    # If alien rank of word1 char is greater,
                    # ordering is invalid
                    if rank[word1[j]] > rank[word2[j]]:
                        return False
                    
                    # Correct order found, stop comparing
                    valid_order = True
                    break
            
            # If all compared chars are same,
            # then shorter word must come first
            if not valid_order:
                if len(word1) > len(word2):
                    return False
        
        # All pairs valid
        # overall tc O(N * L)
        # overall sc O(1)
        return True

997. Find the Town Judge ::1:: - Medium

Topics: Array, Hash Table, Graph Theory

Intro

In a town, there are n people labeled from 1 to n. There is a rumor that one of these people is secretly the town judge. If the town judge exists, then:

  1. The town judge trusts nobody.
  2. Everybody (except for the town judge) trusts the town judge.
  3. There is exactly one person that satisfies properties 1 and 2. You are given an array trust where trust[i] = [ai, bi] representing that the person labeled ai trusts the person labeled bi. If a trust relationship does not exist in trust array, then such a trust relationship does not exist. Return the label of the town judge if the town judge exists and can be identified, or return -1 otherwise.
Example InputOutput
n = 2, trust = [[1,2]]2
n = 3, trust = [[1,3],[2,3]]3
n = 3, trust = [[1,3],[2,3],[3,1]]-1

Constraints:

1 ≤ n ≤ 1000

0 ≤ trust.length ≤ 10^4

trust[i].length == 2

All the pairs of trust are unique

ai != bi

1 ≤ ai, bi ≤ n

Abstraction

Judge judy!

Space & Time Complexity

SolutionTime ComplexitySpace ComplexityTime RemarkSpace Remark
BugError

Brute Force:

AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

Find the Bug:

Solution 1: [DFS] Recursive Late Pruning Track All Visited Land In Current Island - Graph/something

    def findJudge(self, n: int, trust: List[List[int]]) -> int:
        
            # Edge Case
            if n == 1 and not trust:
                return 1
            
            # trust_score[i] =
            #   +1 for every person who trusts i
            #   -1 for every person i trusts
            trust_score = [0] * (n + 1)
            
            # tc O(m)
            for a, b in trust:
                trust_score[a] -= 1
                trust_score[b] += 1
            
            # Judge must have score n - 1
            # tc O(n)
            for person in range(1, n + 1):
                if trust_score[person] == n - 1:
                    return person
            
            return -1

Solution 2: [BFS] Recursive Late Pruning Track All Visited Land In Current Island - Graph/something

    def findJudge(self, n: int, trust: List[List[int]]) -> int:
        
        # Edge Case:
        # If n == 1 and no trust relationships,
        # that single person is the judge.
        if n == 1 and not trust:
            return 1
        
        # indegree[i]  = how many people trust i
        # outdegree[i] = how many people i trusts
        indegree = [0] * (n + 1)
        outdegree = [0] * (n + 1)
        
        # Process trust relationships
        # tc O(len(trust))
        for a, b in trust:
            outdegree[a] += 1
            indegree[b] += 1
        
        # Find candidate
        # Judge must have:
        #   indegree == n - 1
        #   outdegree == 0
        for person in range(1, n + 1):
            if indegree[person] == n - 1 and outdegree[person] == 0:
                return person
        
        return -1

547. Number of Provinces ::4:: - Medium

Topics: Depth First Search, Breadth First Search, Union Find, Graph Theory

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 InputOutput
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

Given a graph, return the number of islands, where an islands is a cell that is surrounded by water, and islands can connect horizontally or vertically. This is the same as Number of Islands but in adjacency matrix.

Space & Time Complexity

SolutionTime ComplexitySpace ComplexityTime RemarkSpace Remark
BugError

Brute Force:

AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

Find the Bug:

Solution 1: [DFS] DFS Recursive Early Pruning Sink All Visited Land In Current Island - Graph/something

    def findCircleNum(self, isConnected: List[List[int]]) -> int:
        
        # DFS - Connected Components
         
        # Idea:
        #   - Each city = node
        #   - isConnected[i][j] = edge
        #   - Count connected components

        n = len(isConnected)
        visited = set()
        provinces = 0

        # visit all cities connected to current city
        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)

        # iterate through all cities
        for city in range(n):
            if city not in visited:
                provinces += 1
                dfs(city)

        # overall: tc O(n^2)
        # overall: sc O(n)
        return provinces

Solution 2: [BFS] BFS Iterative Early Pruning Sink All Visited Land In Current Island - Graph/something

    def findCircleNum(self, isConnected: List[List[int]]) -> int:
        
        # BFS - Connected Components

        n = len(isConnected)
        visited = set()
        provinces = 0

        def bfs(start):

            queue = deque([start])
            visited.add(start)

            while queue:
                city = queue.popleft()

                # Explore neighbors
                for nei in range(n):
                    if (isConnected[city][nei] == 1 and
                        nei not in visited):

                        visited.add(nei)   # Early marking
                        queue.append(nei)

        # scan all cities
        for city in range(n):
            if city not in visited:
                provinces += 1
                bfs(city)

        # overall: tc O(n^2)
        # overall: sc O(n)
        return provinces

Solution 3: [Union Find] Union Find Early Pruning - Graph/something

    def findCircleNum(self, isConnected: List[List[int]]) -> int:
        
        # Union Find - Connected Components

        n = len(isConnected)

        parent = list(range(n))
        rank = [0] * n

        # Find with path compression
        def find(x):
            if parent[x] != x:
                parent[x] = find(parent[x])
            return parent[x]

        # Union by rank
        def union(x, y):
            rootX, rootY = find(x), find(y)

            # Early pruning:
            if rootX == rootY:
                return

            if rank[rootX] > rank[rootY]:
                parent[rootY] = rootX
            elif rank[rootX] < rank[rootY]:
                parent[rootX] = rootY
            else:
                parent[rootY] = rootX
                rank[rootX] += 1

        # Only check upper triangle (optimization)
        for i in range(n):
            for j in range(i + 1, n):
                if isConnected[i][j] == 1:
                    union(i, j)

        # count unique roots
        roots = set(find(i) for i in range(n))

        # overall: tc O(n^2 * α(n))
        # overall: sc O(n)
        return len(roots)

695. Max Area of Island ::3:: - Medium

Topics: Array, Depth First Search, Breadth First Search, Union Find, Matrix

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 InputOutput
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

Given a graph, return the largest island. Same as Number of Islands except we just keep track of the size of the current island as we sink it.

Space & Time Complexity

SolutionTime ComplexitySpace ComplexityTime RemarkSpace Remark
BugError

Brute Force:

AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

Find the Bug:

Solution 1: [DFS] DFS Recursive Late Pruning Sink All Visited Land In Current Island - Graph/something

    def maxAreaOfIsland(self, grid: List[List[int]]) -> int:

        # Connected Components In A Graph:
        # This problem is equivalent to finding out the number of connected components in a graph
        # Each land cell ('1') is a node, and edges exist between two nodes if they
        # are adjacent vertically or horizontally

        # Connected Components In A Grid Graph:
        # This problem is equivalent to finding the largest connected component
        # in a graph where:
        #   - Nodes: land cells (value = 1)
        #   - Edges: adjacency in 4 directions (up, down, left, right)

        # Backtracking vs Recursive Graph Traversal:
        # Recursion is a concept, backtracking is a technique using it.
        # Recursion does not imply backtracking.
        # Backtracking 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 are performing a flood fill traversal.

        # Recursive Flood Fill (DFS Traversal):
        # 0. Late Pruning: skip if out of bounds or water
        # 1. Process Root: sink current land cell and count it
        # 2. Explore: recursively visit all 4 neighbors
        # 3. Return: return total area of the connected component
        # Result: track and return the maximum island area

        # Empty check
        # tc: O(1)
        if not grid:
            return 0

        # sc: setting up search space rows [0, n-1] and columns [0 n-1] O(1)
        m, n = len(grid), len(grid[0])

        # Tracking Global Max Island
        # sc: O(1)
        max_area = 0

        def dfs_SinkVisitedIsland(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, 'marking' current cell as visited
            # tc: O(1), sc: O(1)
            grid[r][c] = 0

            # Process Root:
            # Track size for current island
            area = 1

            # Process Choices:
            # Explore neighbors, see if there is land to add for current island
            # and sink said neighbors
            area += dfs(r + 1, c)
            area += dfs(r - 1, c)
            area += dfs(r, c + 1)
            area += dfs(r, c - 1)

            # Return:
            # return land count for current land to parent land 
            # (and eventually return to the original root land we hit)
            return area
    
        # iterate over all cells
        # tc: O(r*c)
        for r in range(m):
            for c in range(n):

                # Initial Call:
                # land that has not been visited found, start dfs exploration at cell
                # tc: O(1)
                if grid[r][c] == 1:

                    # found new island, get island area
                    island_area = dfs_SinkVisitedIsland(r, c)

                    # compare local island area to global max island area
                    max_area = max(max_area, island_area)

        # overall: tc O(r*c)
        # overall: sc O(r*c)
        return max_area

Solution 2: [BFS] BFS Iterative Early Pruning Sink All Visited Land In Current Island - Graph/something

    def maxAreaOfIsland(self, grid: List[List[int]]) -> int:
        
        
        # Note:
        # 1. Treat grid as a graph: 1 = land, edges = 4-direction adjacency
        # 2. BFS explores each island iteratively using a queue
        # 3. Sink land cells in-place to mark visited
        # 4. Track area of each island, return maximum

        # Empty Check: no islands, grid is empty
        # tc: O(1)
        if not grid:
            return 0

        # sc: setting up search space rows [0, n-1] and columns [0 n-1] O(1)
        m, n = len(grid), len(grid[0])

        # Tracking Global Max Island
        # sc: O(1)
        max_area = 0


        def bfs_SinkVisitedLand(r, c):

            # area count for local island
            area = 0

            # Iterative queue
            # sc: O(r*c)
            queue = deque()

            # Add root land to queue execution
            queue.append((r, c))

            # Build:
            # Sink root island
            grid[r][c] = 0  # mark visited

            # While we have land in current island
            while queue:

                # Get Bottom Of Queue Cell (original root):
                # tc O(1)
                cr, cc = queue.popleft()

                # Add to local island land counter
                area += 1

                # Explore:
                # iteratively visit all neighbors (up, down, left, right)
                # tc: O(1) per call, but overall bounded by number of cells
                # sc: O(r*c) recursion stack grows with connected component size
                for dr, dc in [(1,0), (-1,0), (0,1), (0,-1)]:
                    nr, nc = cr + dr, cc + dc

                    # Early Pruning:
                    # only enqueue if in bounds and land
                    # tc: O(1)
                    if 0 <= nr < m and 0 <= nc < n and grid[nr][nc] == 1:

                        # Process Root:
                        # mark current cell as visited
                        # tc: O(1), sc: O(1)
                        grid[nr][nc] = 0

                        # Add land to bfs queue for execution
                        queue.append((nr, nc))

            # Return:
            # pass local island area to root island
            return area


        # iterate over all cells
        # tc: O(r*c)
        for r in range(m):
            for c in range(n):

                # Initial Call:
                # land that has not been visited found, start bfs exploration at cell
                # tc: O(1)
                if grid[r][c] == 1:

                    # found new island
                    island_area = bfs_SinkVisitedLand(r, c)

                    # compare local island area to global island area
                    max_area = max(max_area, island_area)


        # overall: tc O(m*n)
        # overall: sc O(m*n)
        return max_area

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
        # 1. Process Root -> initialize parent and size for each land cell
        # 2. Process Candidates -> union adjacent land cells (down/right)
        # 3. Early Prune -> skip out-of-bounds
        # 4. Late Prune -> skip water cells
        # Result: max size among all connected components = max island area

        # Empty Check: no islands, grid is empty
        # tc: O(1)
        if not grid:
            return 0

        # sc: setting up search space rows [0, n-1] and columns [0 n-1] O(1)
        m, n = len(grid), len(grid[0])

        # Parent and size for each land cell
        # sc: O(r*c) 
        parent = {}
        size = {}

        # Initialize Union Find:
        # tc: iterate over all cells O(r*c)
        # sc: add all cells O(r*c)
        for r in range(m):
            for c in range(n):
                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):

            # if parent of cell isn't self, go up to parent
            if parent[x] != x:
                parent[x] = find(parent[x])

            # returns parent of original, after path compression
            return parent[x]

        # Union by Rank
        # tc: O(α(n)) amortized
        # sc: O(1)
        def union(x, y):
            
            # Ignore if nodes share the same root (part of the same island)
            # This will act as:
            #   - tracking visited nodes: marks all land in a island
            #   - will not connect land already marked as being in the same island
            rootX, rootY = find(x), find(y)
            if rootX == rootY:
                return
            
            # Union By Rank (upper bound on tree height)
            # tc: O(1)
            if size[rootX] >= size[rootY]:
                parent[rootY] = rootX
                size[rootX] += size[rootY]
            else:
                parent[rootX] = rootY
                size[rootY] += size[rootX]


        # iterate over all cells
        # tc: O(r*c)
        for r in range(m):
            for c in range(n):

                # Initial Call:
                # land has been found, start dfs exploration at cell
                # tc: O(1), sc: O(1)
                if grid[r][c] == 1:

                    # Explore:
                    # Union the adjacent lands, 
                    # only down and right as we will finish the rest 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))

        # Return tree with most nodes
        largestIsland = max(size.values(), default=0)

        # overall: tc O(r*c * α(r*c)) =~ O(r*c)
        # overall: sc O(r*c)
        return largestIsland

133. Clone Graph ::2:: - Medium

Topics: Hash Table, Depth First Search, Breadth First Search, Graph

Intro

Given a reference of a node in a connected undirected graph. Return a deep copy (clone) of the graph. Each node in the graph contains a value (int) and a list
(List[Node]) of its neighbors. class Node ( public int val; public List[Node] neighbors; ) Test case format: For simplicity, each node's value is the same as the node's index (1-indexed). For example, the first node with val == 1, the second node with val == 2, and so on. The graph is represented in the test case using an adjacency list. An adjacency list is a collection of unordered lists used to represent a finite graph. Each list describes the set of neighbors of a node in the graph. The given node will always be the first node with val = 1. You must return the copy of the given node as a reference to the cloned graph.

Example InputOutput
adjList = [[2,4],[1,3],[2,4],[1,3]][[2,4],[1,3],[2,4],[1,3]]
adjList = [[]][[]]
adjList = [][]

Constraints:

The number of nodes in the graph is in the range [0, 100].

1 ≤ Node.val ≤ 100

Node.val is unique for each node.

There are no repeated edges and no self-loops in the graph.

The Graph is connected and all nodes can be visited starting from the given node.

Abstraction

Given a graph represented by an adjacency matrix, return a deep copy.

Space & Time Complexity

SolutionTime ComplexitySpace ComplexityTime RemarkSpace Remark
BugError

Brute Force:

AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

Find the Bug:

Solution 1: [DFS] Recursive DFS - Graph/something

    def cloneGraph(self, node: Optional['Node']) -> Optional['Node']:
        
        # Note:
        # 1. Use DFS to traverse the graph
        # 2. Use a hashmap to store already cloned nodes
        # 3. For each node, recursively clone its neighbors
        # 4. Return the cloned node corresponding to the input
        
        # Empty Check: no islands, grid is empty
        # tc: O(1)
        if not node:
            return None

        # Deep copy for new list
        # sc: O(n)
        cloned = {}

        # tc O(V + E) visit each node and edge once
        # sc O(V), for hashmap + recursion stack
        def dfs(n) -> Node:

            # Process Root:
            # clone current node
            copy = Node(n.val)

            # Add clone to hashmap
            cloned[n] = copy

            # Process Candidates:
            # recursively clone all neighbors
            for neighbor in n.neighbors:
                
                # Early Prune: only recurse if neighbor not yet cloned
                if neighbor not in cloned:
                    copy.neighbors.append(dfs(neighbor))
                # else just grab neighbor from clone hashmap
                else:
                    copy.neighbors.append(cloned[neighbor])

            # Return:
            # pass back the cloned node
            return copy

        # Initial Call:
        # return new root of cloned graph
        cloneRoot = dfs(node)

        # overall: tc O(V + E) 
        # overall: sc O(V)
        return cloneRoot

Solution 2: [BFS] Iterative BFS - Graph/something

    def cloneGraph(self, node: Optional['Node']) -> Optional['Node']:
        
        # Note:
        # 1. Iterative BFS traversal to clone graph nodes
        # 2. Use hashmap to track original -> cloned node
        # 3. Process Root -> start with input node in queue
        # 4. Process Candidates -> clone neighbors iteratively
        # 5. Early Prune -> skip already cloned neighbors
        # 6. Backtrack -> queue ensures all reachable nodes are processed

        # Empty Check: no islands, grid is empty
        # tc: O(1)
        if not node:
            return None

        # Deep copy of root for new list
        # sc: O(n)
        cloned = {node: Node(node.val)}

        # Iterative queue
        # sc: O(V)
        queue = deque()

        # Append Original Root Node
        # tc: O(1)
        queue.append(node)

        # While we still have nodes in original graph
        # tc O(V + E), each node and edge visited once
        # sc O(V), for hashmap + queue
        while queue:

            # Get Bottom Of Queue Node (original root):
            # tc O(1)
            current = queue.popleft()

            # Iterate over node's neighbors
            for neighbor in current.neighbors:

                # Early Prune: only recurse if neighbor not yet cloned
                if neighbor not in cloned:
                    cloned[neighbor] = Node(neighbor.val)
                    queue.append(neighbor)

                # Grab neighbor from clone hashmap
                cloned[current].neighbors.append(cloned[neighbor])

        cloneRoot = cloned[node]

        # overall: tc O(V + E)
        # overall: sc O(V)
        return cloneRoot

417. Pacific Atlantic Water Flow ::2:: - Medium

Topics: Array, Depth First Search, Breadth First Search, Matrix

Intro

There is an m x n rectangular island that borders both the Pacific Ocean and Atlantic Ocean. The Pacific Ocean touches the island's left and top edges, and the Atlantic Ocean touches the island's right and bottom edges. The island is partitioned into a grid of square cells. You are given an m x n integer matrix heights where heights[r][c] represents the height above sea level of the cell at coordinate (r, c). The island receives a lot of rain, and the rain water can flow to neighboring cells directly north, south, east, and west if the neighboring cell's height is less than or equal to the current cell's height. Water can flow from any cell adjacent to an ocean into the ocean. Return a 2D list of grid coordinates result where result[i] = [ri, ci] denotes that rain water can flow from cell (ri, ci) to both the Pacific and Atlantic oceans.

Example InputOutput
grid height (see LeetCode)res grid
grid height (see LeetCode)res grid

Constraints:

m == heights.length

n == heights[r].length

1 ≤ m, n ≤ 200

0 ≤ heights[r][c] ≤ 105

Abstraction

Given a grid of heights, return which cells can flow to the ocean.

Space & Time Complexity

SolutionTime ComplexitySpace ComplexityTime RemarkSpace Remark
BugError

Brute Force:

AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

Find the Bug:

Solution 1: [DFS] DFS Recursive Reverse Flow - Graph/something

    def pacificAtlantic(self, heights: List[List[int]]) -> List[List[int]]:
        
        # Note:
        # 1. Instead of simulating water flow forward (downhill), reverse the process,
        #    start from the oceans and "climb uphill" to neighbors with height >= current.
        # 2. DFS is used for traversal; recursion handles the exploration
        # 3. Each ocean has its own visited set
        # 4. Cells visited in both traversals can reach both oceans
        # 5. Intersection of visited sets gives the answer


        # Empty check
        if not heights:
            return []

        # boundaries
        m, n = len(heights), len(heights[0])

        # seen
        pacific = set()
        atlantic = set()


        def dfs_TravelUphill(r: int, c: int, ocean_visited: set, prev_height: int) -> None:
            
            # Late Prune:
            # ignore cells if they are out of bounds or visited
            # but specifically if they are downhill, as this means they cannot reach the island
            if (r < 0 or r >= m or 
                c < 0 or c >= n or
                (r, c) in ocean_visited or 
                heights[r][c] < prev_height):
                return

            # Process Root:
            # mark cell as ocean_visited
            ocean_visited.add((r, c))

            # Process Candidates:
            # recursively explore neighbors explore 4 directions
            dfs_TravelUphill(r + 1, c, ocean_visited, heights[r][c])
            dfs_TravelUphill(r - 1, c, ocean_visited, heights[r][c])
            dfs_TravelUphill(r, c + 1, ocean_visited, heights[r][c])
            dfs_TravelUphill(r, c - 1, ocean_visited, heights[r][c])

        # Process Roots:
        # start from Pacific and Atlantic edges
        # These are the edge cells adjacent to each ocean from which water can "flow uphill"
        # just collecting outermost edge calls

        for i in range(m):
            # left column
            dfs_TravelUphill(i, 0, pacific, heights[i][0])
            # right column
            dfs_TravelUphill(i, n - 1, atlantic, heights[i][n - 1])
        for j in range(n):
            # top row
            dfs_TravelUphill(0, j, pacific, heights[0][j])
            # bottom row
            dfs_TravelUphill(m - 1, j, atlantic, heights[m - 1][j])

        # Cells reachable to both oceans
        # tc: O(V)
        intersection = list(pacific & atlantic)

        # overall: tc O(m*n)
        # overall: sc O(m*n)
        return intersection

Solution 2: [BFS] Iterative BFS Reverse Flow - Graph/something

    def pacificAtlantic(self, heights: List[List[int]]) -> List[List[int]]:
        
        # Note:
        # 1. Same reverse flow idea, but BFS is used instead of DFS.
        # 2. BFS avoids recursion depth issues and may be easier to reason about.
        # 3. Initialize queues with Pacific and Atlantic edges separately.
        # 4. Traverse "uphill" from oceans, track visited cells for each.
        # 5. Answer = intersection of both visited sets.

        # Empty check
        if not heights:
            return []

        # boundaries
        m, n = len(heights), len(heights[0])

        # 
        def bfs(starts: List[Tuple[int,int]]) -> set:

            # Visited set for BFS traversal
            ocean_visited = set(starts)

            # Iterative queue
            queue = deque(starts)

            while queue:
                # Process Root:
                # Grab leftmost cell
                r, c = queue.popleft()

                # Process Choices:
                # Recursively explore neighbors
                for dr, dc in [(1,0), (-1,0), (0,1), (0,-1)]:
                    nr, nc = r + dr, c + dc

                    # Early Prune:
                    # skip if cell is out of bounds or visited
                    # but most importantly uphill as that means
                    if (0 <= nr < m and 
                        0 <= nc < n and 
                        (nr, nc) not in ocean_visited and 
                        heights[nr][nc] >= heights[r][c]):

                        # mark as visited
                        ocean_visited.add((nr, nc))

                        # add neighbor for processing
                        queue.append((nr, nc))
                        
            # Return all visited cells for this ocean
            return ocean_visited

        # Process Roots -> edge cells for each ocean
        # These are the edge cells adjacent to each ocean from which water can "flow uphill"
        # just collecting outermost edge calls

        # pacific_starts = all cells on the top row (0, j) and all cells on the left column (i, 0)
        pacific_starts = []

        # Top row (first row) for Pacific
        for j in range(n):
            pacific_starts.append((0, j))

        # Left column (first column) for Pacific
        for i in range(m):
            pacific_starts.append((i, 0))

        # Atlantic Ocean edge cells
        atlantic_starts = []

        # Bottom row (last row) for Atlantic
        for j in range(n):
            atlantic_starts.append((m - 1, j))

        # Right column (last column) for Atlantic
        for i in range(m):
            atlantic_starts.append((i, n - 1))

        pacific = bfs(pacific_starts)
        atlantic = bfs(atlantic_starts)

        # Cells reachable to both oceans
        # tc: O(V)
        intersection = list(pacific & atlantic)

        # overall: tc O(m*n)
        # overall: sc O(m*n)
        return intersection

130. Surrounded Regions ::3:: - Medium

Topics: Array, Depth First Search, Breadth First Search, Union Find, Matrix

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 InputOutput
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

Given a board, find all surrounded regions, capture and return them.

Space & Time Complexity

SolutionTime ComplexitySpace ComplexityTime RemarkSpace Remark
BugError

Brute Force:

AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

Find the Bug:

Solution 1: 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: 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 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)

752. Open the Lock ::1:: - Medium

Topics: Array, Hash Table, String, Breadth First Search

Intro

You have a lock in front of you with 4 circular wheels. Each wheel has 10 slots: '0', '1', '2', '3', '4', '5', '6', '7', '8', '9'. The wheels can rotate freely and wrap around: for example we can turn '9' to be '0', or '0' to be '9'. Each move consists of turning one wheel one slot. The lock initially starts at '0000', a string representing the state of the 4 wheels. You are given a list of deadends dead ends, meaning if the lock displays any of these codes, the wheels of the lock will stop turning and you will be unable to open it. Given a target representing the value of the wheels that will unlock the lock, return the minimum total number of turns required to open the lock, or -1 if it is impossible.

Example InputOutput
deadends = ["0201","0101","0102","1212","2002"], target = "0202"6
deadends = ["8888"], target = "0009"1
deadends = ["8887","8889","8878","8898","8788","8988","7888","9888"], target = "8888"-1

Constraints:

1 ≤ deadends.length ≤ 500

deadends[i].length == 4

target.length == 4

target will not be in the list deadends

target and deadends[i] consist of digits only

Abstraction

robber!

Space & Time Complexity

SolutionTime ComplexitySpace ComplexityTime RemarkSpace Remark
BugError

Brute Force:

AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

Find the Bug:

Solution 1: [BFS] Optimized BFS - Graph/something

    def openLock(self, deadends: List[str], target: str) -> int:
        
        # Convert deadends to a set for O(1) lookup
        dead = set(deadends)
        
        # Edge Case:
        # If starting position is a deadend,
        # we cannot move.
        if "0000" in dead:
            return -1
        
        # Standard BFS setup
        queue = deque([("0000", 0)])  # (state, moves)
        visited = set(["0000"])
        
        while queue:
            state, moves = queue.popleft()
            
            # If target reached
            if state == target:
                return moves
            
            # Generate all possible next states
            for i in range(4):
                
                digit = int(state[i])
                
                # Two possible rotations:
                # +1 and -1 (with wraparound)
                for change in (-1, 1):
                    
                    new_digit = (digit + change) % 10
                    
                    # Build new state string
                    new_state = (
                        state[:i] +
                        str(new_digit) +
                        state[i+1:]
                    )
                    
                    # Only proceed if:
                    # - not visited
                    # - not a deadend
                    if new_state not in visited and new_state not in dead:
                        
                        visited.add(new_state)
                        queue.append((new_state, moves + 1))
        
        # If BFS exhausts without finding target
        return -1

323. Number of Connected Components in an Undirected Graph ::3:: - Medium

Topics: Depth First Search, Breadth First Search, Graph, Union Find

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 InputOutput
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

Given a list of undirected edges, return number of connected components.

Space & Time Complexity

SolutionTime ComplexitySpace ComplexityTime RemarkSpace Remark
BugError

Brute Force:

AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

Find the Bug:

Solution 1: [DFS] DFS - Graph/something

    def countComponents(self, n: int, edges: List[List[int]]) -> int:
        
        # 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 components

Solution 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 components

Solution 3: [Union Find] Union Find - Graph/something

    def countComponents(self, n: int, edges: List[List[int]]) -> int:

        # Connected Components In A Graph:
        # Each node represents a vertex (0 to n-1)
        # Each edge connects two nodes bidirectionally
        # The problem asks for the number of connected components

        # Union-Find Approach:
        # Instead of traversing each component like DFS/BFS,
        # we "union" nodes as we process edges.
        # Two nodes sharing the same root are in the same component.

        # 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 components

684. Redundant Connection ::3:: - Medium

Topics: Depth First Search, Breadth First Search, Union Find, Graph

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 InputOutput
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

Given a list of undirected edges, determine if there are redundant connections.

Space & Time Complexity

SolutionTime ComplexitySpace ComplexityTime RemarkSpace Remark
BugError

Brute Force:

AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

Find the Bug:

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: Array, Hash Table, String, Depth First Search, Breadth First Search, Union Find, Sorting

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 InputOutput
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

spam!

Space & Time Complexity

SolutionTime ComplexitySpace ComplexityTime RemarkSpace Remark
BugError

Brute Force:

AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

Find the Bug:

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 res

Solution 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 res

Solution 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

934. Shortest Bridge ::2:: - Medium

Topics: Array, Depth First Search, Breadth First Search, Matrix

Intro

You are given an n x n binary matrix grid where 1 represents land and 0 represents water. An island is a 4-directionally connected group of 1's not connected to any other 1's. There are exactly two islands in grid. You may change 0's to 1's to connect the two islands to form one island. Return the smallest number of 0's you must flip to connect the two islands.

Example InputOutput
grid = [[0,1],[1,0]]1
grid = [[0,1,0],[0,0,0],[0,0,1]]2
grid = [[1,1,1,1,1],[1,0,0,0,1],[1,0,1,0,1],[1,0,0,0,1],[1,1,1,1,1]]1

Constraints:

n == grid.length == grid[i].length

2 ≤ n ≤ 100

grid[i][j] is either 0 or 1

There are exactly two islands in grid

Abstraction

Given a list of undirected edges, determine if there are redundant connections.

Space & Time Complexity

SolutionTime ComplexitySpace ComplexityTime RemarkSpace Remark
BugError

Brute Force:

AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

Find the Bug:

Solution 1: [DFS + BFS] Using DFS To Mark Islands And BFS To Expand Layer By Layer To Reach Second Island - Graph/something

    def shortestBridge(self, grid: List[List[int]]) -> int:
        # Dimensions
        n = len(grid)
        
        # Directions for 4-way movement
        directions = [(1,0), (-1,0), (0,1), (0,-1)]
        
        # Step 1: DFS to mark the first island
        def dfs(x, y, q):
            grid[x][y] = 2  # mark as visited
            q.append((x, y))  # add to BFS queue
            for dx, dy in directions:
                nx, ny = x + dx, y + dy
                if 0 <= nx < n and 0 <= ny < n and grid[nx][ny] == 1:
                    dfs(nx, ny, q)
        
        # Step 1a: Find the first island and mark it
        queue = deque()
        found = False
        for i in range(n):
            if found:
                break
            for j in range(n):
                if grid[i][j] == 1:
                    dfs(i, j, queue)
                    found = True
                    break
        
        # Step 2: BFS to expand from first island to reach second island
        steps = 0
        while queue:
            for _ in range(len(queue)):
                x, y = queue.popleft()
                for dx, dy in directions:
                    nx, ny = x + dx, y + dy
                    if 0 <= nx < n and 0 <= ny < n:
                        if grid[nx][ny] == 1:
                            # Reached second island
                            return steps
                        elif grid[nx][ny] == 0:
                            # Expand water cell
                            grid[nx][ny] = 2
                            queue.append((nx, ny))
            steps += 1
        
        return -1  # just in case, though problem guarantees two islands