DSA

Common Patterns Summary

Interview Prep · 8 entries

Pattern: Two Pointers

syntax
When to use:
- Sorted array pair problems
- Removing duplicates in place
- Container with most water
- Palindrome checking

Variants:
- Opposite ends (converging)
- Same direction (fast/slow or lagging)
example
// JavaScript — Remove duplicates from sorted array
function removeDuplicates(arr) {
  if (arr.length === 0) return 0;
  let writeIdx = 1;
  for (let i = 1; i < arr.length; i++) {
    if (arr[i] !== arr[i - 1]) {
      arr[writeIdx] = arr[i];
      writeIdx++;
    }
  }
  return writeIdx;
}

# Python
def remove_duplicates(arr):
    if not arr:
        return 0
    write_idx = 1
    for i in range(1, len(arr)):
        if arr[i] != arr[i - 1]:
            arr[write_idx] = arr[i]
            write_idx += 1
    return write_idx
output
remove_duplicates([1,1,2,2,3]) → 3, arr becomes [1,2,3,...]

Note O(n) time, O(1) space. The 'read pointer / write pointer' variant is essential for in-place array problems. Always ask: is the input sorted? If yes, two pointers likely applies. If unsorted, consider hash set instead.

Pattern: Fast & Slow Pointer

syntax
When to use:
- Cycle detection (linked list, array)
- Finding middle of linked list
- Finding the start of a cycle
- Happy number problem

Slow moves 1 step, fast moves 2 steps.
example
// JavaScript — Happy number (sum of squared digits eventually reaches 1)
function isHappy(n) {
  function digitSquareSum(num) {
    let sum = 0;
    while (num > 0) {
      const digit = num % 10;
      sum += digit * digit;
      num = Math.floor(num / 10);
    }
    return sum;
  }
  let slow = n, fast = n;
  do {
    slow = digitSquareSum(slow);
    fast = digitSquareSum(digitSquareSum(fast));
  } while (slow !== fast);
  return slow === 1;
}

# Python
def is_happy(n):
    def digit_square_sum(num):
        total = 0
        while num > 0:
            digit = num % 10
            total += digit * digit
            num //= 10
        return total
    slow = fast = n
    while True:
        slow = digit_square_sum(slow)
        fast = digit_square_sum(digit_square_sum(fast))
        if slow == fast:
            break
    return slow == 1
output
is_happy(19) → True (19→82→68→100→1)
is_happy(2) → False (enters cycle)

Note The fast/slow pointer pattern detects cycles in any sequence where each value maps to the next. It uses O(1) space compared to a hash set. For linked lists, this is Floyd's algorithm. For number sequences (like happy number), the function that generates the next value plays the role of 'next pointer'.

Pattern: Sliding Window (Summary)

syntax
When to use:
- Subarray/substring with constraint (max sum, min length, distinct chars)
- Fixed-size window statistics
- Longest/shortest substring matching a condition

Template:
- Expand right pointer
- When window invalid, shrink left pointer
- Update answer at each valid state
example
// JavaScript — Longest substring without repeating characters
function lengthOfLongestSubstring(s) {
  const charIdx = new Map();
  let maxLen = 0, left = 0;
  for (let right = 0; right < s.length; right++) {
    if (charIdx.has(s[right]) && charIdx.get(s[right]) >= left) {
      left = charIdx.get(s[right]) + 1;
    }
    charIdx.set(s[right], right);
    maxLen = Math.max(maxLen, right - left + 1);
  }
  return maxLen;
}

# Python
def length_of_longest_substring(s):
    char_idx = {}
    max_len = left = 0
    for right, ch in enumerate(s):
        if ch in char_idx and char_idx[ch] >= left:
            left = char_idx[ch] + 1
        char_idx[ch] = right
        max_len = max(max_len, right - left + 1)
    return max_len
output
length_of_longest_substring('abcabcbb') → 3 ('abc')

Note O(n) time, O(k) space where k = alphabet/window contents. The trick: store the last index of each character so you can jump the left pointer forward without shrinking one step at a time. This is one of the most frequently asked medium problems.

Pattern: Merge Intervals

syntax
When to use:
- Overlapping intervals
- Meeting room scheduling
- Insert interval into sorted list

1. Sort intervals by start time
2. Iterate: if current overlaps with previous, merge them
3. Otherwise, add current as new interval
example
// JavaScript
function mergeIntervals(intervals) {
  intervals.sort((a, b) => a[0] - b[0]);
  const merged = [intervals[0]];
  for (let i = 1; i < intervals.length; i++) {
    const last = merged[merged.length - 1];
    if (intervals[i][0] <= last[1]) {
      last[1] = Math.max(last[1], intervals[i][1]);
    } else {
      merged.push(intervals[i]);
    }
  }
  return merged;
}

# Python
def merge_intervals(intervals):
    intervals.sort(key=lambda x: x[0])
    merged = [intervals[0]]
    for start, end in intervals[1:]:
        if start <= merged[-1][1]:
            merged[-1][1] = max(merged[-1][1], end)
        else:
            merged.append([start, end])
    return merged
output
merge_intervals([[1,3],[2,6],[8,10],[15,18]]) → [[1,6],[8,10],[15,18]]

Note Time O(n log n) for sorting. Space O(n) for result. The overlap condition: current.start <= prev.end. For 'meeting rooms needed' (minimum rooms), use a min-heap on end times or a sweep line approach. Always sort by start time first.

Pattern: Top-K Elements

syntax
When to use:
- K most frequent elements
- K closest points
- K largest/smallest values

Approaches:
1. SortO(n log n)
2. Min-heap of size kO(n log k)
3. QuickselectO(n) average
example
// JavaScript — Top K frequent elements using bucket sort
function topKFrequent(nums, k) {
  const freq = new Map();
  for (const n of nums) freq.set(n, (freq.get(n) || 0) + 1);
  // Bucket: index = frequency, value = list of elements
  const buckets = Array.from({ length: nums.length + 1 }, () => []);
  for (const [num, count] of freq) buckets[count].push(num);
  const result = [];
  for (let i = buckets.length - 1; i >= 0 && result.length < k; i--) {
    result.push(...buckets[i]);
  }
  return result.slice(0, k);
}

# Python
import heapq
from collections import Counter

def top_k_frequent(nums, k):
    freq = Counter(nums)
    # nlargest uses a heap internally
    return [item for item, count in freq.most_common(k)]
output
top_k_frequent([1,1,1,2,2,3], 2) → [1, 2]

Note Bucket sort approach: O(n) time and space. Heap approach: O(n log k). For 'top K', a min-heap of size k is optimal — you evict the smallest when the heap exceeds k, so only the K largest remain. Python's heapq.nlargest and Counter.most_common are powerful shortcuts.

Pattern: Prefix Sum (Applications)

syntax
When to use:
- Range sum queries
- Subarray sum equals K
- Subarray sum divisible by K
- Binary subarrays with sum
- Count subarrays with given XOR

Core idea: prefix[i] - prefix[j] = sum of arr[j..i-1]
example
// JavaScript — Count subarrays with sum divisible by k
function subarraysDivByK(arr, k) {
  const modCount = new Map([[0, 1]]);
  let prefix = 0, count = 0;
  for (const num of arr) {
    prefix = ((prefix + num) % k + k) % k; // handle negatives
    count += modCount.get(prefix) || 0;
    modCount.set(prefix, (modCount.get(prefix) || 0) + 1);
  }
  return count;
}

# Python
def subarrays_div_by_k(arr, k):
    mod_count = {0: 1}
    prefix = 0
    count = 0
    for num in arr:
        prefix = (prefix + num) % k
        count += mod_count.get(prefix, 0)
        mod_count[prefix] = mod_count.get(prefix, 0) + 1
    return count
output
subarrays_div_by_k([4,5,0,-2,-3,1], 5) → 7

Note Time O(n), Space O(k). The key insight: if two prefix sums have the same remainder mod k, the subarray between them is divisible by k. Handle negative modulo carefully: (x % k + k) % k ensures a non-negative remainder. This is a very common interview pattern.

Pattern: Union-Find (Disjoint Set)

syntax
Track connected components efficiently.
Two operations:
- find(x): return the root/representative of x's set
- union(x, y): merge the sets containing x and y

Optimizations: path compression + union by rank
example
// JavaScript
class UnionFind {
  constructor(n) {
    this.parent = Array.from({ length: n }, (_, i) => i);
    this.rank = new Array(n).fill(0);
  }
  find(x) {
    if (this.parent[x] !== x) {
      this.parent[x] = this.find(this.parent[x]); // path compression
    }
    return this.parent[x];
  }
  union(x, y) {
    const px = this.find(x), py = this.find(y);
    if (px === py) return false;
    if (this.rank[px] < this.rank[py]) this.parent[px] = py;
    else if (this.rank[px] > this.rank[py]) this.parent[py] = px;
    else { this.parent[py] = px; this.rank[px]++; }
    return true;
  }
}

# Python
class UnionFind:
    def __init__(self, n):
        self.parent = list(range(n))
        self.rank = [0] * n

    def find(self, x):
        if self.parent[x] != x:
            self.parent[x] = self.find(self.parent[x])
        return self.parent[x]

    def union(self, x, y):
        px, py = self.find(x), self.find(y)
        if px == py:
            return False
        if self.rank[px] < self.rank[py]:
            px, py = py, px
        self.parent[py] = px
        if self.rank[px] == self.rank[py]:
            self.rank[px] += 1
        return True
output
uf = UnionFind(5)
uf.union(0, 1); uf.union(2, 3); uf.union(1, 3)
uf.find(0) == uf.find(3) → True (same component)

Note With both optimizations, operations are nearly O(1) amortized (technically O(alpha(n)) inverse Ackermann). Perfect for: connected components, redundant connections, accounts merge, minimum spanning tree (Kruskal's). Union returns false when already connected — useful for cycle detection.

Pattern: When to Use BFS vs DFS

syntax
Use BFS when:
- Shortest path in unweighted graph
- Level-by-level processing
- Closest node / nearest distance

Use DFS when:
- Exploring all paths / all possibilities
- Cycle detection
- Topological sort
- Tree traversals (in/pre/post order)
- Backtracking problems
example
// Decision guide:
// 'Shortest' or 'minimum steps' → BFS
// 'All paths' or 'does a path exist' → DFS
// 'Level order' → BFS
// 'Permutations/combinations' → DFS (backtracking)

// JavaScript — Minimum steps to reach target in grid
function minSteps(grid, start, end) {
  const rows = grid.length, cols = grid[0].length;
  const queue = [[...start, 0]]; // [row, col, steps]
  const visited = new Set([start.toString()]);
  const dirs = [[0,1],[0,-1],[1,0],[-1,0]];
  while (queue.length) {
    const [r, c, steps] = queue.shift();
    if (r === end[0] && c === end[1]) return steps;
    for (const [dr, dc] of dirs) {
      const nr = r + dr, nc = c + dc;
      const key = `${nr},${nc}`;
      if (nr >= 0 && nr < rows && nc >= 0 && nc < cols
          && grid[nr][nc] === 0 && !visited.has(key)) {
        visited.add(key);
        queue.push([nr, nc, steps + 1]);
      }
    }
  }
  return -1;
}

# Python
from collections import deque

def min_steps(grid, start, end):
    rows, cols = len(grid), len(grid[0])
    queue = deque([(start[0], start[1], 0)])
    visited = {(start[0], start[1])}
    for dr, dc in [(0,1),(0,-1),(1,0),(-1,0)]:
        pass  # directions defined in loop below
    while queue:
        r, c, steps = queue.popleft()
        if (r, c) == tuple(end):
            return steps
        for dr, dc in [(0,1),(0,-1),(1,0),(-1,0)]:
            nr, nc = r + dr, c + dc
            if 0 <= nr < rows and 0 <= nc < cols and grid[nr][nc] == 0 and (nr,nc) not in visited:
                visited.add((nr, nc))
                queue.append((nr, nc, steps + 1))
    return -1
output
BFS guarantees shortest path in unweighted graphs.
DFS is better for exhaustive search and backtracking.

Note BFS time/space: O(V + E). DFS time: O(V + E), space: O(V) worst case. In grid problems, treat each cell as a node with 4 edges. For BFS on grids, always mark visited when enqueuing (not when dequeuing) to avoid processing a cell multiple times.