Anthony's Blog Site

Cracking LeetCode 75 (Part 4)

April 28, 2025

Graph

Photo Credit: ChatGPT 4o


This blog post aims to walk through classic coding problems listed in LeetCode 75.

Topics covered in this post: Hashmap / Set, and Graphs (DFS and BFS). I grouped these topics together because graphs are often represented in the form of hashmaps (or dictionaries), where the key is a node and the value a list of its neighbouring nodes.

All solutions are written in Python and are optimized for performance and readability.

Hashmap / Set

2215. Find the Difference of Two Arrays

class Solution:
    def findDifference(self, nums1: List[int], nums2: List[int]) -> List[List[int]]:
        # We first convert lists to sets for faster lookups
        set1 = set(nums1)
        set2 = set(nums2)

        diff1 = list(set1 - set2)
        diff2 = list(set2 - set1)

        return [diff1, diff2]

1207. Unique Number of Occurrences

class Solution:
    def uniqueOccurrences(self, arr: List[int]) -> bool:

        # Intuition:
        # We first count the frequency for each integer using a hash map, then we can use a set that collects the frequency values for faster lookups.

        # Build a hash map:
        hashmap = {}  # key: the integer, value: the frequency
        for num in arr:
            if num in hashmap:
                hashmap[num] += 1
            else:
                hashmap[num] = 1
        
        # Check for duplicate frequencies:
        frequencies = set()
        for freq in hashmap.values():
            # Check:
            if freq in frequencies:
                return False
            # Add:
            frequencies.add(freq)

        return True

1657. Determine if Two Strings Are Close

class Solution:
    def closeStrings(self, word1: str, word2: str) -> bool:
        
        # Intuition:
        # For each word, we build a hash map that maps a letter to number of occurrences.
        # e.g. word1 = "aabbbc", word2 = "ccaaab", then by applying Operation 2 twice on word1, we would be able to get word2 - since the "frequency distributions" of the letters are the same.

        if len(word1) != len(word2) or set(word1) != set(word2):
            return False

        # Compute character frequencies manually
        freq1 = {}
        freq2 = {}
        for char in word1:
            freq1[char] = freq1.get(char, 0) + 1
            # `freq1.get(char, 0)` returns 0 if `char` is not found.
        for char in word2:
            freq2[char] = freq2.get(char, 0) + 1
        
        if set(freq1.keys()) != set(freq2.keys()):
            return False
        
        # The "frequency distributions" must be the same:
        if sorted(freq1.values()) != sorted(freq2.values()):
            return False
        
        return True

2352. Equal Row and Column Pairs

class Solution:
    def equalPairs(self, grid: List[List[int]]) -> int:

        # Intuition:
        # Since we're asked to count the number of matches, our intuition is to build a set or a dictionary.
        # Note that lists are not hashable (i.e. cannot be used as a dictionary key or stored in a set), so we have to first convert them into tuples.

        row_count = {}  # maps a row to its num of occurrences

        # Count the rows' occurrences:
        for row in grid:
            row_tuple = tuple(row)  # converts list to tuple (hashable)
            row_count[row_tuple] = row_count.get(row_tuple, 0) + 1

        # Loop through the columns to find matches:
        count = 0
        for col in zip(*grid):  # `zip(*grid)` transposes the matrix
            col_tuple = tuple(col)
            count += row_count.get(col_tuple, 0)

        return count

Graphs - DFS

841. Keys and Rooms

class Solution:
    def canVisitAllRooms(self, rooms: List[List[int]]) -> bool:

        # Intuition:
        # This problem reminds me of the "number of provinces" problem.
        # We could use DFS to unlock rooms sequentially.

        visited = [False] * len(rooms)

        def dfs(i_room, visited):
            visited[i_room] = True
            for room in rooms[i_room]:
                if not visited[room]:  # avoids getting stuck in an infinite loop
                    dfs(room, visited)
        
        dfs(0, visited)

        return all(visited)  # evaluates to True when all items in `visited` are True

547. Number of Provinces

class Solution:
    def findCircleNum(self, isConnected: List[List[int]]) -> int:

        # Intuition: We run depth-first search to explore what cities are
        # indirectly connected to current city.
        # We should also use `visited` to avoid visiting two connected cities back and forth (infinite loop).

        # We use dfs() to do in-depth visits starting from i_city.
        def dfs(i_city, visited):
            visited[i_city] = True
            
            # Run dfs() on directly connected cities:
            for i_neighbour in range(len(isConnected)):
                if isConnected[i_city][i_neighbour] == 1 and not visited[i_neighbour]:
                    dfs(i_neighbour, visited)

        n = len(isConnected)
        visited = [False] * n  # a list of booleans
        num_provinces = 0

        for i_city in range(n):
            # Run depth-first search on unvisited cities:
            if not visited[i_city]:
                dfs(i_city, visited)
                num_provinces += 1
                # Each city that is not visited by depth-first search
                # will form a new province.

        return num_provinces

1466. Reorder Routes to Make All Paths Lead to the City Zero

from collections import defaultdict

class Solution:
    def minReorder(self, n: int, connections: List[List[int]]) -> int:

        # Key:
        # - Use DFS for graph search. For `dfs(city)`, all the neighbours should point to `city`, and we start the recursion by calling `dfs(0)`.

        # Store neighbours for each city:
        neighbours = defaultdict(list)
        for source, dest in connections:
            neighbours[source].append((dest, 1))
            neighbours[dest].append((source, -1))
        
        num_changed = 0
        visited = set()
        def dfs(city):
            nonlocal num_changed
            visited.add(city)
            # All its neighbours should point to this city
            for neighbour, direction in neighbours[city]:
                if neighbour not in visited:
                    if direction == 1:
                        num_changed += 1
                    dfs(neighbour)

        dfs(0)
        return num_changed

399. Evaluate Division

class Solution:
    def calcEquation(self, equations: List[List[str]], values: List[float], queries: List[List[str]]) -> List[float]:
        
        # Keys:
        # - Observation: Given the values of a/b and b/c ,then we could compute a/c by using the 'path' a -> b -> c. At the same time, queries like b/a, c/b, and c/a could also be computed.
        # - Graph: We use graph to store the info in `equations` and `values` and process each query in `queries`. Given a/b = 3, we will have graph[a][b] = 3 and graph[b][a] = 1/3.
        # - DFS: To find a/c, we check if c is a neighbour of a. If it is, the result is found immediately; If not, we continue with each neighbour of a (e.g. b) and use recursion to check if c is a neighbour of b.
        # - For each recursive path, we use a `visited` set to avoid cycles (especially in a graph). Each recursive path has its _own_ `visited` blacklist.

        # STEP 1: Store `equations` and `values` in a graph
        graph = defaultdict(lambda: defaultdict(float))  # nested dict
        for (numerator, denominator), value in zip(equations, values):
            graph[numerator][denominator] = value
            graph[denominator][numerator] = 1 / value
        
        # STEP 2: DFS in the graph
        def dfs(curr_node, target_node, acc_product, visited):
            visited.add(curr_node)
            result = -1.0
            neighbours = graph[curr_node]
            # target found --> result obtained
            if target_node in neighbours:
                result = acc_product * neighbours[target_node]
            # target not found --> run recursion on neighbour nodes
            else:
                for neighbour, quotient in neighbours.items():
                    if neighbour not in visited:
                        result = dfs(neighbour, target_node, acc_product * quotient, visited)
                        if result != -1.0:
                            break
            visited.remove(curr_node)
            return result

        results = []
        for numerator, denominator in queries:
            result = -1.0  # base case #1: variables not found
            if numerator in graph and denominator in graph:
                if numerator == denominator:
                    result = 1.0  # base case #2: e.g. 'a'/'a'
                else:
                    # recursive case
                    visited = set()
                    result = dfs(numerator, denominator, 1, visited)
            results.append(result)

        return results

Graphs - BFS

1926. Nearest Exit from Entrance in Maze

class Solution:
    def nearestExit(self, maze: List[List[str]], entrance: List[int]) -> int:

        # Intuition:
        #   1. BFS is the intuitive choice for finding the shortest paths. It explores all neighbors at the current level before moving deeper, ensuring that we find the nearest exit first.
        #   2. Visited cells should be marked as '+' to prevent revisiting (infinite loop).

        m, n = len(maze), len(maze[0])
        directions = [(-1, 0), (1, 0), (0, -1), (0, 1)]  # Up, Down, Left, Right
        
        queue = deque([(entrance[0], entrance[1], 0)])  # (row, col, steps)
        maze[entrance[0]][entrance[1]] = '+'  # Mark entrance as visited
        while queue:
            r, c, steps = queue.popleft()
            # Take a further step to each direction:
            for dr, dc in directions:
                nr, nc = r + dr, c + dc  # new row & new col
                if 0 <= nr < m and 0 <= nc < n and maze[nr][nc] == '.':
                    if nr == 0 or nr == m-1 or nc == 0 or nc == n-1:
                        return steps + 1  # exit found
                    queue.append((nr, nc, steps + 1))
                    maze[nr][nc] = '+'  # marked as visited

        return -1  # no exit found

994. Rotting Oranges

class Solution:
    def orangesRotting(self, grid: List[List[int]]) -> int:
        
        # Keys:
        # - Oranges are rotten in a BFS manner
        # - In the queue used for BFS, we use (row, col, timestamp) to track elapsed time as we visit batches.

        # STEP 1: Find all rotten and fresh oranges
        rows, cols = len(grid), len(grid[0])
        queue = deque()
        num_fresh = 0
        for r in range(rows):
            for c in range(cols):
                if grid[r][c] == 2:
                    queue.append((r, c, 0))  # (row, col, timestamp)
                elif grid[r][c] == 1:
                    num_fresh += 1

        # STEP 2: Let oranges rot using BFS
        directions = [(-1,0), (1,0), (0,-1), (0,1)]
        num_minutes = 0
        while queue:
            r, c, timestamp = queue.popleft()
            num_minutes = max(num_minutes, timestamp)  # updates time elapsed
            for dr, dc in directions:
                nr, nc = r + dr, c + dc
                # Let this orange rot if it's fresh:
                if (0 <= nr < rows and 0 <= nc < cols) and grid[nr][nc] == 1:
                    grid[nr][nc] = 2
                    queue.append((nr, nc, timestamp + 1))
                    num_fresh -= 1

        # STEP 3: Determine
        return num_minutes if num_fresh == 0 else -1