Skip to content
Published on

FAANG Coding Interview Mastery: Backtracking, Heap, and Advanced Patterns

Authors

FAANG Coding Interview Mastery: Backtracking, Heap, and Advanced Patterns

This guide covers backtracking, heap/priority queue, monotonic stack, Trie, and bit manipulation patterns that appear frequently in FAANG interviews.

1. Backtracking Patterns

The Backtracking Template

Backtracking explores a decision tree to find all solutions satisfying a constraint.

def backtrack(state, choices):
    # Base case: found a valid solution
    if is_solution(state):
        result.append(state[:])  # Save a copy, not a reference
        return

    for choice in choices:
        # Choose
        make_choice(state, choice)

        # Explore (recurse)
        backtrack(state, remaining_choices)

        # Unchoose (undo — this is the key!)
        undo_choice(state, choice)

Core Principles:

  • Always save a copy of state to results (not a reference)
  • State must be identical before and after each recursive call
  • Use pruning to eliminate unnecessary branches early

Subsets (LeetCode 78)

def subsets(nums: list) -> list:
    result = []

    def backtrack(start, current):
        result.append(current[:])  # Save current subset
        for i in range(start, len(nums)):
            current.append(nums[i])    # Choose
            backtrack(i + 1, current)  # Explore
            current.pop()              # Unchoose

    backtrack(0, [])
    return result
  • Time: O(n * 2^n)
  • Space: O(n) (call stack)

Subsets II — Handling Duplicates (LeetCode 90)

def subsetsWithDup(nums: list) -> list:
    nums.sort()  # Sort for duplicate detection
    result = []

    def backtrack(start, current):
        result.append(current[:])
        for i in range(start, len(nums)):
            # Skip duplicates at the same recursion level
            if i > start and nums[i] == nums[i-1]:
                continue
            current.append(nums[i])
            backtrack(i + 1, current)
            current.pop()

    backtrack(0, [])
    return result

Permutations (LeetCode 46)

def permute(nums: list) -> list:
    result = []

    def backtrack(current, remaining):
        if not remaining:
            result.append(current[:])
            return
        for i in range(len(remaining)):
            current.append(remaining[i])
            backtrack(current, remaining[:i] + remaining[i+1:])
            current.pop()

    backtrack([], nums)
    return result

# Visited array approach (more efficient)
def permute_v2(nums: list) -> list:
    result = []
    visited = [False] * len(nums)

    def backtrack(current):
        if len(current) == len(nums):
            result.append(current[:])
            return
        for i in range(len(nums)):
            if visited[i]:
                continue
            visited[i] = True
            current.append(nums[i])
            backtrack(current)
            current.pop()
            visited[i] = False

    backtrack([])
    return result

Permutations II — Handling Duplicates (LeetCode 47)

def permuteUnique(nums: list) -> list:
    nums.sort()
    result = []
    visited = [False] * len(nums)

    def backtrack(current):
        if len(current) == len(nums):
            result.append(current[:])
            return
        for i in range(len(nums)):
            if visited[i]:
                continue
            # Skip if same value and previous element not yet visited
            if i > 0 and nums[i] == nums[i-1] and not visited[i-1]:
                continue
            visited[i] = True
            current.append(nums[i])
            backtrack(current)
            current.pop()
            visited[i] = False

    backtrack([])
    return result

Combination Sum (LeetCode 39)

def combinationSum(candidates: list, target: int) -> list:
    result = []

    def backtrack(start, current, remaining):
        if remaining == 0:
            result.append(current[:])
            return
        if remaining < 0:
            return
        for i in range(start, len(candidates)):
            current.append(candidates[i])
            backtrack(i, current, remaining - candidates[i])  # Can reuse same element
            current.pop()

    backtrack(0, [], target)
    return result

Combination Sum II (LeetCode 40)

def combinationSum2(candidates: list, target: int) -> list:
    candidates.sort()
    result = []

    def backtrack(start, current, remaining):
        if remaining == 0:
            result.append(current[:])
            return
        for i in range(start, len(candidates)):
            if candidates[i] > remaining:
                break
            if i > start and candidates[i] == candidates[i-1]:
                continue
            current.append(candidates[i])
            backtrack(i + 1, current, remaining - candidates[i])
            current.pop()

    backtrack(0, [], target)
    return result

N-Queens (LeetCode 51)

def solveNQueens(n: int) -> list:
    result = []
    cols = set()
    diag1 = set()  # row - col (top-right to bottom-left)
    diag2 = set()  # row + col (top-left to bottom-right)

    def backtrack(row, board):
        if row == n:
            result.append([''.join(r) for r in board])
            return
        for col in range(n):
            if col in cols or (row - col) in diag1 or (row + col) in diag2:
                continue
            cols.add(col)
            diag1.add(row - col)
            diag2.add(row + col)
            board[row][col] = 'Q'
            backtrack(row + 1, board)
            cols.remove(col)
            diag1.remove(row - col)
            diag2.remove(row + col)
            board[row][col] = '.'

    board = [['.' for _ in range(n)] for _ in range(n)]
    backtrack(0, board)
    return result

Word Search (LeetCode 79)

def exist(board: list, word: str) -> bool:
    rows, cols = len(board), len(board[0])

    def backtrack(r, c, idx):
        if idx == len(word):
            return True
        if r < 0 or r >= rows or c < 0 or c >= cols:
            return False
        if board[r][c] != word[idx]:
            return False

        temp = board[r][c]
        board[r][c] = '#'  # Mark as visited

        found = (
            backtrack(r+1, c, idx+1) or
            backtrack(r-1, c, idx+1) or
            backtrack(r, c+1, idx+1) or
            backtrack(r, c-1, idx+1)
        )

        board[r][c] = temp  # Restore
        return found

    for r in range(rows):
        for c in range(cols):
            if backtrack(r, c, 0):
                return True
    return False

2. Heap / Priority Queue Patterns

Python heapq Complete Guide

import heapq

# Min-heap (default)
min_heap = []
heapq.heappush(min_heap, 3)
heapq.heappush(min_heap, 1)
heapq.heappush(min_heap, 2)
print(heapq.heappop(min_heap))  # 1 (minimum)

# Max-heap (negate values)
max_heap = []
heapq.heappush(max_heap, -3)
heapq.heappush(max_heap, -1)
heapq.heappush(max_heap, -2)
print(-heapq.heappop(max_heap))  # 3 (maximum)

# Build heap from list
nums = [3, 1, 4, 1, 5, 9]
heapq.heapify(nums)  # O(n)

# n largest / n smallest
heapq.nlargest(3, nums)   # Top 3 largest
heapq.nsmallest(3, nums)  # Top 3 smallest

# Tuple heap: (priority, value)
heap = [(1, 'task_a'), (3, 'task_b'), (2, 'task_c')]
heapq.heapify(heap)
priority, task = heapq.heappop(heap)  # (1, 'task_a')

Kth Largest Element (LeetCode 215)

def findKthLargest(nums: list, k: int) -> int:
    # Maintain a min-heap of size k
    min_heap = []
    for num in nums:
        heapq.heappush(min_heap, num)
        if len(min_heap) > k:
            heapq.heappop(min_heap)
    return min_heap[0]  # kth largest

# QuickSelect O(n) average
import random

def findKthLargest_quickselect(nums: list, k: int) -> int:
    def quickselect(left, right, k_smallest):
        pivot = nums[random.randint(left, right)]
        lo, hi, mid = left, right, left
        while mid <= hi:
            if nums[mid] < pivot:
                nums[lo], nums[mid] = nums[mid], nums[lo]
                lo += 1
                mid += 1
            elif nums[mid] > pivot:
                nums[hi], nums[mid] = nums[mid], nums[hi]
                hi -= 1
            else:
                mid += 1
        if k_smallest < lo:
            return quickselect(left, lo - 1, k_smallest)
        elif k_smallest >= mid:
            return quickselect(mid, right, k_smallest)
        else:
            return nums[lo]

    return quickselect(0, len(nums) - 1, len(nums) - k)

Top K Frequent Elements (LeetCode 347)

from collections import Counter

def topKFrequent(nums: list, k: int) -> list:
    count = Counter(nums)
    # Maintain a min-heap of size k using (freq, num) pairs
    heap = []
    for num, freq in count.items():
        heapq.heappush(heap, (freq, num))
        if len(heap) > k:
            heapq.heappop(heap)
    return [num for freq, num in heap]

# Bucket sort O(n)
def topKFrequent_bucket(nums: list, k: int) -> list:
    count = Counter(nums)
    buckets = [[] for _ in range(len(nums) + 1)]
    for num, freq in count.items():
        buckets[freq].append(num)
    result = []
    for i in range(len(buckets) - 1, -1, -1):
        result.extend(buckets[i])
        if len(result) >= k:
            return result[:k]
    return result

Merge K Sorted Lists (LeetCode 23)

class ListNode:
    def __init__(self, val=0, next=None):
        self.val = val
        self.next = next

def mergeKLists(lists: list) -> ListNode:
    heap = []
    # Push the first node from each list
    for i, node in enumerate(lists):
        if node:
            heapq.heappush(heap, (node.val, i, node))

    dummy = ListNode(0)
    curr = dummy

    while heap:
        val, i, node = heapq.heappop(heap)
        curr.next = node
        curr = curr.next
        if node.next:
            heapq.heappush(heap, (node.next.val, i, node.next))

    return dummy.next
  • Time: O(N log k), where N = total nodes, k = number of lists

Find Median from Data Stream (LeetCode 295)

The two-heap pattern is a key interview technique.

class MedianFinder:
    def __init__(self):
        self.small = []  # Max-heap (lower half, stored as negatives)
        self.large = []  # Min-heap (upper half)

    def addNum(self, num: int) -> None:
        # Add to small heap, then rebalance
        heapq.heappush(self.small, -num)
        # If max of small > min of large, move element
        if self.small and self.large and (-self.small[0] > self.large[0]):
            heapq.heappush(self.large, -heapq.heappop(self.small))
        # Balance sizes
        if len(self.small) > len(self.large) + 1:
            heapq.heappush(self.large, -heapq.heappop(self.small))
        if len(self.large) > len(self.small):
            heapq.heappush(self.small, -heapq.heappop(self.large))

    def findMedian(self) -> float:
        if len(self.small) > len(self.large):
            return -self.small[0]
        return (-self.small[0] + self.large[0]) / 2

Task Scheduler (LeetCode 621)

from collections import Counter

def leastInterval(tasks: list, n: int) -> int:
    count = Counter(tasks)
    max_heap = [-c for c in count.values()]
    heapq.heapify(max_heap)

    time = 0
    queue = []  # (count, available_time)

    while max_heap or queue:
        time += 1
        if max_heap:
            cnt = 1 + heapq.heappop(max_heap)
            if cnt:
                queue.append((cnt, time + n))
        if queue and queue[0][1] == time:
            heapq.heappush(max_heap, queue.pop(0)[0])

    return time

3. Monotonic Stack Patterns

The Monotonic Stack Concept

A monotonic stack maintains elements in monotonically increasing or decreasing order.

  • Monotonically increasing stack: pop when a smaller or equal element arrives (finds next smaller element)
  • Monotonically decreasing stack: pop when a larger or equal element arrives (finds next greater element)

Daily Temperatures (LeetCode 739)

def dailyTemperatures(temperatures: list) -> list:
    n = len(temperatures)
    result = [0] * n
    stack = []  # stores indices

    for i, temp in enumerate(temperatures):
        # Pop while current temperature is warmer
        while stack and temperatures[stack[-1]] < temp:
            idx = stack.pop()
            result[idx] = i - idx
        stack.append(i)

    return result

Next Greater Element (LeetCode 496)

def nextGreaterElement(nums1: list, nums2: list) -> list:
    next_greater = {}
    stack = []

    for num in nums2:
        while stack and stack[-1] < num:
            next_greater[stack.pop()] = num
        stack.append(num)

    # Elements remaining in stack have no next greater element
    while stack:
        next_greater[stack.pop()] = -1

    return [next_greater[n] for n in nums1]

Largest Rectangle in Histogram (LeetCode 84)

def largestRectangleArea(heights: list) -> int:
    stack = []  # Monotonically increasing stack (indices)
    max_area = 0
    heights = heights + [0]  # Sentinel to flush the stack at the end

    for i, h in enumerate(heights):
        while stack and heights[stack[-1]] > h:
            height = heights[stack.pop()]
            width = i if not stack else i - stack[-1] - 1
            max_area = max(max_area, height * width)
        stack.append(i)

    return max_area

Trapping Rain Water — Stack Approach (LeetCode 42)

def trap(height: list) -> int:
    stack = []  # Monotonically decreasing stack
    water = 0

    for i, h in enumerate(height):
        while stack and height[stack[-1]] < h:
            bottom = stack.pop()
            if not stack:
                break
            bounded_height = min(height[stack[-1]], h) - height[bottom]
            width = i - stack[-1] - 1
            water += bounded_height * width
        stack.append(i)

    return water

4. Trie (Prefix Tree) Patterns

Trie Implementation

class TrieNode:
    def __init__(self):
        self.children = {}  # or [None] * 26 for lowercase letters
        self.is_end = False

class Trie:
    def __init__(self):
        self.root = TrieNode()

    def insert(self, word: str) -> None:
        node = self.root
        for char in word:
            if char not in node.children:
                node.children[char] = TrieNode()
            node = node.children[char]
        node.is_end = True

    def search(self, word: str) -> bool:
        node = self.root
        for char in word:
            if char not in node.children:
                return False
            node = node.children[char]
        return node.is_end

    def startsWith(self, prefix: str) -> bool:
        node = self.root
        for char in prefix:
            if char not in node.children:
                return False
            node = node.children[char]
        return True

Design Add and Search Words (LeetCode 211)

class WordDictionary:
    def __init__(self):
        self.root = TrieNode()

    def addWord(self, word: str) -> None:
        node = self.root
        for char in word:
            if char not in node.children:
                node.children[char] = TrieNode()
            node = node.children[char]
        node.is_end = True

    def search(self, word: str) -> bool:
        def dfs(node, i):
            if i == len(word):
                return node.is_end
            char = word[i]
            if char == '.':
                # Wildcard: explore all children
                for child in node.children.values():
                    if dfs(child, i + 1):
                        return True
                return False
            if char not in node.children:
                return False
            return dfs(node.children[char], i + 1)

        return dfs(self.root, 0)

Word Search II (LeetCode 212)

def findWords(board: list, words: list) -> list:
    # Build Trie from word list
    root = TrieNode()
    for word in words:
        node = root
        for char in word:
            if char not in node.children:
                node.children[char] = TrieNode()
            node = node.children[char]
        node.is_end = True

    rows, cols = len(board), len(board[0])
    result = set()

    def dfs(r, c, node, path):
        if node.is_end:
            result.add(path)
            # Continue searching for longer words

        if r < 0 or r >= rows or c < 0 or c >= cols:
            return
        char = board[r][c]
        if char not in node.children or char == '#':
            return

        board[r][c] = '#'  # Mark visited
        for dr, dc in [(0,1), (0,-1), (1,0), (-1,0)]:
            dfs(r+dr, c+dc, node.children[char], path+char)
        board[r][c] = char  # Restore

    for r in range(rows):
        for c in range(cols):
            dfs(r, c, root, '')

    return list(result)

5. Bit Manipulation Patterns

XOR Tricks and Bit Operation Fundamentals

# Basic bit operations
a = 0b1010  # 10
b = 0b1100  # 12

print(a & b)    # AND: 0b1000 = 8
print(a | b)    # OR:  0b1110 = 14
print(a ^ b)    # XOR: 0b0110 = 6
print(~a)       # NOT: -11 (two's complement)
print(a << 1)   # Left shift:  0b10100 = 20
print(a >> 1)   # Right shift: 0b0101 = 5

# Useful bit tricks
n = 12
print(n & (n-1))    # Remove lowest set bit: 8
print(n & (-n))     # Isolate lowest set bit: 4
print(n ^ n)        # XOR with itself = 0
print(0 ^ n)        # XOR with 0 = n

Single Number (LeetCode 136)

def singleNumber(nums: list) -> int:
    # XOR all numbers: pairs cancel out to 0
    result = 0
    for num in nums:
        result ^= num
    return result

Counting Bits (LeetCode 338)

def countBits(n: int) -> list:
    dp = [0] * (n + 1)
    for i in range(1, n + 1):
        # Number of 1s in i = number of 1s in (i >> 1) + lowest bit
        dp[i] = dp[i >> 1] + (i & 1)
    return dp

Reverse Bits (LeetCode 190)

def reverseBits(n: int) -> int:
    result = 0
    for _ in range(32):
        result = (result << 1) | (n & 1)
        n >>= 1
    return result

Number of 1 Bits — Hamming Weight (LeetCode 191)

def hammingWeight(n: int) -> int:
    count = 0
    while n:
        n &= (n - 1)  # Remove the lowest set bit
        count += 1
    return count

# Python built-in
def hammingWeight_v2(n: int) -> int:
    return bin(n).count('1')

6. Real Interview Strategies

How to Ask for Hints When Stuck

Staying silent when stuck is the worst thing you can do. Try phrases like these:

"I'm running into [specific issue] with my current approach.
Am I missing something obvious here?"

"I'm thinking of using [specific data structure / algorithm].
Does that seem like a reasonable direction?"

Hidden Rule: Even when a hint is given, how you incorporate it matters just as much as the answer.

How to Discuss Code Optimization

When asked to optimize:

"My current solution runs in O(n^2) time and O(n) space.
I think using [specific data structure / algorithm]
could bring this down to O(n log n). Should I implement that?"

How to Explain Time and Space Complexity

Always state the reasoning, not just the answer:

"The time complexity is O(n log n).
The outer loop runs n times, and each iteration performs
a heap operation costing O(log n).

The space complexity is O(k) because
we maintain a heap of at most k elements."

The Interview Execution Framework

1. Understand the Problem (5 min)
   - Create your own examples
   - Ask about edge cases

2. Explain Your Approach (5 min)
   - Brute force first
   - Then discuss optimizations

3. Code (15-20 min)
   - Talk through your logic as you code
   - Use meaningful variable names

4. Test (5 min)
   - Trace through your own example
   - Verify edge cases

7. Practice Quiz

Quiz 1: Why does Subsets II need the condition "i > start" instead of "i > 0"?

Answer: To skip duplicates only at the same level of recursion, not across different levels.

Explanation: Using "i > 0" would skip elements that were already used in a parent call, causing valid combinations to be missed. "i > start" only skips when the same value appears twice at the current recursion depth, which is exactly the condition that produces duplicate subsets.

Quiz 2: Why do we use two heaps in Find Median from Data Stream?

Answer: To achieve O(1) median retrieval and O(log n) insertion.

Explanation: Maintaining a sorted array gives O(n) insertion. Using only one heap gives O(n) retrieval. Two balanced heaps (a max-heap for the lower half and a min-heap for the upper half) let us read the median from the tops of the heaps in O(1), while heap push and pop are O(log n).

Quiz 3: Why do we append 0 to heights in Largest Rectangle in Histogram?

Answer: To force all remaining elements to be popped from the stack after the loop ends.

Explanation: The sentinel value 0 is smaller than any valid height, so it triggers the while-loop to pop every remaining index from the stack. This eliminates the need for a separate post-loop cleanup step. You could also handle the remaining elements manually after the loop — both approaches are correct.

Quiz 4: What mathematical property of XOR makes Single Number work?

Answer: Two key XOR properties: a XOR a = 0 (any number XORed with itself is 0), and a XOR 0 = a (any number XORed with 0 is itself).

Explanation: All numbers appearing twice cancel out to 0 when XORed together. The number appearing once is XORed with 0, leaving itself. XOR is commutative and associative, so the order does not matter.

Quiz 5: Why do we use a Trie instead of checking each word individually in Word Search II?

Answer: To share common prefix traversal across multiple words and prune branches that cannot lead to any word.

Explanation: Searching for each word individually costs O(words _ cells _ 4^L) where L is word length. A Trie allows multiple words sharing a common prefix to share the same DFS path. When the current board path is not a prefix of any word in the Trie, we can prune immediately, dramatically reducing the total number of DFS calls.


Summary

Complexity Reference:

PatternTimeSpace
Backtracking SubsetsO(n * 2^n)O(n)
Backtracking PermutationsO(n * n!)O(n)
Heap — Kth elementO(n log k)O(k)
Merge K ListsO(N log k)O(k)
Monotonic StackO(n)O(n)
Trie Insert/SearchO(L)O(ALPHABET _ N _ L)
Bit XOR trickO(n)O(1)

Interview Preparation Roadmap:

  1. Backtracking: Subsets → Permutations → Combination Sum → N-Queens
  2. Heap: Kth Largest → Top K → Merge K Lists → Median Finder
  3. Monotonic Stack: Daily Temperatures → Largest Rectangle
  4. Trie: Implement Trie → Word Search II
  5. Bit Manipulation: Single Number → Counting Bits → Power of Two