Algorithms & Sorting

Sorting, searching, graph algorithms, DP patterns & complexity analysis β€” explained with code

Interview + Core CS
Contents
πŸ“Š

Time & Space Complexity

Big-O describes the upper bound of growth rate. Focus on the dominant term and ignore constants.

NotationNameExample
O(1)ConstantHash table lookup, array index access
O(log n)LogarithmicBinary search, balanced BST lookup
O(n)LinearSingle loop, linear search
O(n log n)LinearithmicMerge sort, heap sort, Tim sort
O(nΒ²)QuadraticBubble sort, nested loops
O(2ⁿ)ExponentialRecursive fibonacci, subset generation
O(n!)FactorialPermutations, brute force TSP
πŸ’‘ Rules of Thumb
  • Drop constants: O(2n) β†’ O(n)
  • Drop lower-order terms: O(nΒ² + n) β†’ O(nΒ²)
  • Sequential loops β†’ add: O(n + m)
  • Nested loops β†’ multiply: O(n Γ— m)
  • Recursive calls β†’ draw the recursion tree
  • Hash map/set operations are O(1) average
🫧

Bubble Sort

Repeatedly swaps adjacent elements if they are in the wrong order. Simple but slow. Stable sort.

Time: O(nΒ²) worst/avg, O(n) best (already sorted) Β· Space: O(1)

def bubble_sort(arr):
    n = len(arr)
    for i in range(n):
        swapped = False
        for j in range(n - 1 - i):  # last i elements already sorted
            if arr[j] > arr[j + 1]:
                arr[j], arr[j + 1] = arr[j + 1], arr[j]
                swapped = True
        if not swapped:            # optimization: stop if no swaps
            break
    return arr

# [5, 3, 8, 1] β†’ [3, 5, 1, 8] β†’ [3, 1, 5, 8] β†’ [1, 3, 5, 8]
πŸ‘†

Selection Sort

Find the minimum element in unsorted portion and swap it to the front. Not stable.

Time: O(nΒ²) all cases Β· Space: O(1)

def selection_sort(arr):
    n = len(arr)
    for i in range(n):
        min_idx = i
        for j in range(i + 1, n):
            if arr[j] < arr[min_idx]:
                min_idx = j
        arr[i], arr[min_idx] = arr[min_idx], arr[i]  # swap minimum to front
    return arr
πŸ“₯

Insertion Sort

Build sorted array by inserting one element at a time into its correct position. Best for small/nearly-sorted arrays. Stable.

Time: O(nΒ²) worst, O(n) best Β· Space: O(1)

def insertion_sort(arr):
    for i in range(1, len(arr)):
        key = arr[i]
        j = i - 1
        while j >= 0 and arr[j] > key:
            arr[j + 1] = arr[j]       # shift elements right
            j -= 1
        arr[j + 1] = key              # insert at correct position
    return arr

# Why it's fast for nearly-sorted: inner while loop barely runs
πŸ”€

Merge Sort

Divide-and-conquer: split array in half, sort each half, merge them back. Guaranteed O(n log n). Stable.

Time: O(n log n) all cases Β· Space: O(n)

def merge_sort(arr):
    if len(arr) <= 1:
        return arr

    mid = len(arr) // 2
    left = merge_sort(arr[:mid])
    right = merge_sort(arr[mid:])
    return merge(left, right)

def merge(left, right):
    result = []
    i = j = 0
    while i < len(left) and j < len(right):
        if left[i] <= right[j]:  # <= makes it stable
            result.append(left[i])
            i += 1
        else:
            result.append(right[j])
            j += 1
    result.extend(left[i:])
    result.extend(right[j:])
    return result

# Recursion tree depth: log n levels, each level does O(n) work β†’ O(n log n)
⚑

Quick Sort

Pick a pivot, partition array into elements < pivot and > pivot, recurse on each side. In-place. Not stable.

Time: O(n log n) avg, O(nΒ²) worst (bad pivot) Β· Space: O(log n) stack

def quick_sort(arr, low=0, high=None):
    if high is None:
        high = len(arr) - 1
    if low < high:
        pi = partition(arr, low, high)
        quick_sort(arr, low, pi - 1)
        quick_sort(arr, pi + 1, high)
    return arr

def partition(arr, low, high):
    pivot = arr[high]          # choose last element as pivot
    i = low - 1                # index of smaller element boundary
    for j in range(low, high):
        if arr[j] <= pivot:
            i += 1
            arr[i], arr[j] = arr[j], arr[i]
    arr[i + 1], arr[high] = arr[high], arr[i + 1]
    return i + 1

# Tip: randomize pivot choice to avoid O(nΒ²) on sorted input
import random
def partition_random(arr, low, high):
    rand = random.randint(low, high)
    arr[rand], arr[high] = arr[high], arr[rand]
    return partition(arr, low, high)
πŸ”οΈ

Heap Sort

Build a max-heap, then repeatedly extract the max to sort. In-place, not stable.

Time: O(n log n) all cases Β· Space: O(1)

def heap_sort(arr):
    n = len(arr)

    # Build max heap (start from last non-leaf node)
    for i in range(n // 2 - 1, -1, -1):
        heapify(arr, n, i)

    # Extract elements one by one
    for i in range(n - 1, 0, -1):
        arr[0], arr[i] = arr[i], arr[0]  # move max to end
        heapify(arr, i, 0)               # heapify reduced heap

def heapify(arr, n, i):
    largest = i
    left = 2 * i + 1
    right = 2 * i + 2

    if left < n and arr[left] > arr[largest]:
        largest = left
    if right < n and arr[right] > arr[largest]:
        largest = right

    if largest != i:
        arr[i], arr[largest] = arr[largest], arr[i]
        heapify(arr, n, largest)  # recursively fix subtree
πŸ”’

Counting & Radix Sort

Non-comparison sorts that can beat O(n log n) when values are bounded.

Counting Sort β€” O(n + k)

Count occurrences of each value. Works when range k is small.

def counting_sort(arr):
    if not arr: return arr
    max_val = max(arr)
    count = [0] * (max_val + 1)

    for x in arr:
        count[x] += 1

    result = []
    for i, c in enumerate(count):
        result.extend([i] * c)
    return result

Radix Sort β€” O(d Γ— (n + k))

Sort digit by digit from least significant to most significant using counting sort as a subroutine.

def radix_sort(arr):
    if not arr: return arr
    max_val = max(arr)
    exp = 1
    while max_val // exp > 0:
        counting_sort_by_digit(arr, exp)
        exp *= 10
    return arr

def counting_sort_by_digit(arr, exp):
    n = len(arr)
    output = [0] * n
    count = [0] * 10

    for x in arr:
        idx = (x // exp) % 10
        count[idx] += 1
    for i in range(1, 10):
        count[i] += count[i - 1]
    for x in reversed(arr):
        idx = (x // exp) % 10
        output[count[idx] - 1] = x
        count[idx] -= 1
    arr[:] = output
βš–οΈ

Sorting Comparison

AlgorithmBestAverageWorstSpaceStable?
Bubble SortO(n)O(nΒ²)O(nΒ²)O(1)βœ…
Selection SortO(n²)O(n²)O(n²)O(1)❌
Insertion SortO(n)O(nΒ²)O(nΒ²)O(1)βœ…
Merge SortO(n log n)O(n log n)O(n log n)O(n)βœ…
Quick SortO(n log n)O(n log n)O(n²)O(log n)❌
Heap SortO(n log n)O(n log n)O(n log n)O(1)❌
Counting SortO(n+k)O(n+k)O(n+k)O(k)βœ…
Radix SortO(dΒ·n)O(dΒ·n)O(dΒ·n)O(n+k)βœ…
πŸ’‘ When to Use What
  • Small arrays (n < 20): Insertion sort (low overhead)
  • Nearly sorted: Insertion sort β€” O(n) best case
  • General purpose: Quick sort (fastest in practice) or Merge sort (stable, guaranteed)
  • External sorting (disk): Merge sort (sequential access pattern)
  • Integers in known range: Counting/Radix sort
  • Python built-in: sorted() uses Timsort (hybrid merge + insertion, O(n log n), stable)
πŸ‘‰πŸ‘ˆ

Two Pointers

# Two Sum (sorted array)
def two_sum_sorted(arr, target):
    l, r = 0, len(arr) - 1
    while l < r:
        s = arr[l] + arr[r]
        if s == target:    return [l, r]
        elif s < target:   l += 1
        else:              r -= 1

# Container With Most Water
def max_area(height):
    l, r = 0, len(height) - 1
    best = 0
    while l < r:
        area = min(height[l], height[r]) * (r - l)
        best = max(best, area)
        if height[l] < height[r]:
            l += 1
        else:
            r -= 1
    return best

# 3Sum β†’ sort + two pointers
def three_sum(nums):
    nums.sort()
    result = []
    for i in range(len(nums) - 2):
        if i > 0 and nums[i] == nums[i - 1]: continue  # skip duplicates
        l, r = i + 1, len(nums) - 1
        while l < r:
            s = nums[i] + nums[l] + nums[r]
            if s == 0:
                result.append([nums[i], nums[l], nums[r]])
                while l < r and nums[l] == nums[l + 1]: l += 1
                l += 1; r -= 1
            elif s < 0: l += 1
            else: r -= 1
    return result
πŸͺŸ

Sliding Window

# Max sum of subarray of size k (fixed window)
def max_sum_k(arr, k):
    window = sum(arr[:k])
    best = window
    for i in range(k, len(arr)):
        window += arr[i] - arr[i - k]  # slide: add right, remove left
        best = max(best, window)
    return best

# Longest substring without repeating characters (variable window)
def length_of_longest(s):
    seen = {}
    l = best = 0
    for r, ch in enumerate(s):
        if ch in seen and seen[ch] >= l:
            l = seen[ch] + 1          # shrink window
        seen[ch] = r
        best = max(best, r - l + 1)
    return best

# Minimum window substring
from collections import Counter
def min_window(s, t):
    need = Counter(t)
    missing = len(t)
    l = start = 0
    best = len(s) + 1
    for r, ch in enumerate(s):
        if need[ch] > 0: missing -= 1
        need[ch] -= 1
        while missing == 0:              # all chars found β†’ shrink
            if r - l + 1 < best:
                best = r - l + 1
                start = l
            need[s[l]] += 1
            if need[s[l]] > 0: missing += 1
            l += 1
    return s[start:start + best] if best <= len(s) else ""
🌳

BFS & DFS

from collections import deque

# BFS β€” Level-order, shortest path in unweighted graph
def bfs(graph, start):
    visited = {start}
    queue = deque([start])
    while queue:
        node = queue.popleft()
        print(node)
        for neighbor in graph[node]:
            if neighbor not in visited:
                visited.add(neighbor)
                queue.append(neighbor)

# DFS β€” Recursive
def dfs(graph, node, visited=None):
    if visited is None: visited = set()
    visited.add(node)
    print(node)
    for neighbor in graph[node]:
        if neighbor not in visited:
            dfs(graph, neighbor, visited)

# DFS β€” Iterative (stack)
def dfs_iterative(graph, start):
    visited = set()
    stack = [start]
    while stack:
        node = stack.pop()
        if node in visited: continue
        visited.add(node)
        print(node)
        for neighbor in graph[node]:
            stack.append(neighbor)

# Number of Islands (BFS on grid)
def num_islands(grid):
    rows, cols = len(grid), len(grid[0])
    count = 0
    for r in range(rows):
        for c in range(cols):
            if grid[r][c] == "1":
                count += 1
                queue = deque([(r, c)])
                grid[r][c] = "0"
                while queue:
                    cr, cc = queue.popleft()
                    for dr, dc in [(1,0),(-1,0),(0,1),(0,-1)]:
                        nr, nc = cr+dr, cc+dc
                        if 0 <= nr < rows and 0 <= nc < cols and grid[nr][nc] == "1":
                            grid[nr][nc] = "0"
                            queue.append((nr, nc))
    return count
πŸ—ΊοΈ

Dijkstra & Shortest Path

Find shortest paths in a weighted graph with non-negative edges. Uses a min-heap.

Time: O((V + E) log V) Β· Space: O(V)

import heapq

def dijkstra(graph, start):
    # graph: { node: [(cost, neighbor), ...] }
    dist = {start: 0}
    heap = [(0, start)]

    while heap:
        d, u = heapq.heappop(heap)
        if d > dist.get(u, float('inf')):
            continue                  # skip stale entries
        for cost, v in graph[u]:
            new_dist = d + cost
            if new_dist < dist.get(v, float('inf')):
                dist[v] = new_dist
                heapq.heappush(heap, (new_dist, v))
    return dist

# Bellman-Ford: handles negative edges, detects negative cycles β€” O(V Γ— E)
def bellman_ford(edges, n, start):
    dist = [float('inf')] * n
    dist[start] = 0
    for _ in range(n - 1):
        for u, v, w in edges:
            if dist[u] + w < dist[v]:
                dist[v] = dist[u] + w
    # Check for negative cycle
    for u, v, w in edges:
        if dist[u] + w < dist[v]:
            return None  # negative cycle exists
    return dist
πŸ“‹

Topological Sort

Linear ordering of vertices in a DAG where for every edge u→v, u comes before v. Used for dependency resolution, build systems, course scheduling.

# Kahn's Algorithm (BFS-based, also detects cycles)
from collections import deque

def topo_sort_bfs(graph, n):
    # graph: adjacency list, n: number of nodes
    in_degree = [0] * n
    for u in graph:
        for v in graph[u]:
            in_degree[v] += 1

    queue = deque(i for i in range(n) if in_degree[i] == 0)
    order = []

    while queue:
        u = queue.popleft()
        order.append(u)
        for v in graph.get(u, []):
            in_degree[v] -= 1
            if in_degree[v] == 0:
                queue.append(v)

    if len(order) != n:
        return None  # cycle detected!
    return order
πŸ”—

Union-Find (Disjoint Set)

Tracks connected components. Path compression + union by rank gives nearly O(1) per operation.

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

    def find(self, x):
        if self.parent[x] != x:
            self.parent[x] = self.find(self.parent[x])  # path compression
        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     # union by rank
        if self.rank[px] == self.rank[py]:
            self.rank[px] += 1
        self.components -= 1
        return True

    def connected(self, x, y):
        return self.find(x) == self.find(y)

# Use case: Kruskal's MST, connected components, cycle detection
🧠

Dynamic Programming

Break a problem into overlapping subproblems with optimal substructure. Cache results.

Framework

πŸ’‘ 5 Steps to Solve Any DP Problem
  • 1. Define state β€” What does dp[i] (or dp[i][j]) represent?
  • 2. Recurrence β€” How does dp[i] relate to smaller subproblems?
  • 3. Base case β€” What are the trivial cases?
  • 4. Order β€” In what order should you fill dp?
  • 5. Answer β€” Where is the final answer? dp[n]? max(dp)?

Classic Problems

# Fibonacci (bottom-up, O(n) time, O(1) space)
def fib(n):
    a, b = 0, 1
    for _ in range(n):
        a, b = b, a + b
    return a

# Climbing Stairs: dp[i] = dp[i-1] + dp[i-2]
def climb_stairs(n):
    a, b = 1, 1
    for _ in range(n - 1):
        a, b = b, a + b
    return b

# 0/1 Knapsack
def knapsack(weights, values, capacity):
    n = len(weights)
    dp = [[0] * (capacity + 1) for _ in range(n + 1)]
    for i in range(1, n + 1):
        for w in range(capacity + 1):
            dp[i][w] = dp[i-1][w]                           # skip item
            if weights[i-1] <= w:
                dp[i][w] = max(dp[i][w], dp[i-1][w - weights[i-1]] + values[i-1])
    return dp[n][capacity]

# Longest Common Subsequence
def lcs(s1, s2):
    m, n = len(s1), len(s2)
    dp = [[0] * (n + 1) for _ in range(m + 1)]
    for i in range(1, m + 1):
        for j in range(1, n + 1):
            if s1[i-1] == s2[j-1]:
                dp[i][j] = dp[i-1][j-1] + 1
            else:
                dp[i][j] = max(dp[i-1][j], dp[i][j-1])
    return dp[m][n]

# Coin Change (minimum coins)
def coin_change(coins, amount):
    dp = [float('inf')] * (amount + 1)
    dp[0] = 0
    for coin in coins:
        for a in range(coin, amount + 1):
            dp[a] = min(dp[a], dp[a - coin] + 1)
    return dp[amount] if dp[amount] != float('inf') else -1

# Longest Increasing Subsequence β€” O(n log n)
from bisect import bisect_left
def lis(nums):
    tails = []
    for x in nums:
        pos = bisect_left(tails, x)
        if pos == len(tails):
            tails.append(x)
        else:
            tails[pos] = x
    return len(tails)
πŸƒ

Greedy Algorithms

Make the locally optimal choice at each step. Works when greedy choice leads to global optimum.

# Activity Selection (max non-overlapping intervals)
def activity_selection(intervals):
    intervals.sort(key=lambda x: x[1])  # sort by end time
    result = [intervals[0]]
    for start, end in intervals[1:]:
        if start >= result[-1][1]:         # no overlap
            result.append((start, end))
    return result

# Jump Game (can you reach the end?)
def can_jump(nums):
    farthest = 0
    for i, jump in enumerate(nums):
        if i > farthest: return False
        farthest = max(farthest, i + jump)
    return True

# Huffman Encoding (use heap for optimal prefix codes)
import heapq
def huffman(freq):
    heap = [[f, [ch, ""]] for ch, f in freq.items()]
    heapq.heapify(heap)
    while len(heap) > 1:
        lo = heapq.heappop(heap)
        hi = heapq.heappop(heap)
        for pair in lo[1:]: pair[1] = '0' + pair[1]
        for pair in hi[1:]: pair[1] = '1' + pair[1]
        heapq.heappush(heap, [lo[0] + hi[0]] + lo[1:] + hi[1:])
    return heap[0][1:]
πŸ”„

Backtracking

Explore all possible solutions by building candidates and abandoning paths that can't lead to a solution.

# Subsets (power set)
def subsets(nums):
    result = []
    def backtrack(start, current):
        result.append(current[:])
        for i in range(start, len(nums)):
            current.append(nums[i])
            backtrack(i + 1, current)
            current.pop()              # undo choice (backtrack)
    backtrack(0, [])
    return result

# Permutations
def permutations(nums):
    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

# N-Queens
def solve_n_queens(n):
    result = []
    def backtrack(row, cols, diag1, diag2, 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
            board[row][col] = "Q"
            backtrack(row+1, cols|{col}, diag1|{row-col}, diag2|{row+col}, board)
            board[row][col] = "."
    backtrack(0, set(), set(), set(), [["."]*n for _ in range(n)])
    return result
πŸ”’

Bit Manipulation

OperationCodeWhat it does
ANDa & bBoth bits 1
ORa | bEither bit 1
XORa ^ bBits differ
NOT~aFlip all bits
Left shifta << nMultiply by 2ⁿ
Right shifta >> nDivide by 2ⁿ
Check bit(a >> i) & 1Is i-th bit set?
Set bita | (1 << i)Turn on i-th bit
Clear bita & ~(1 << i)Turn off i-th bit
Toggle bita ^ (1 << i)Flip i-th bit
Lowest set bita & (-a)Isolate rightmost 1
Clear lowesta & (a-1)Turn off rightmost 1
Power of 2?a & (a-1) == 0Only one bit set
Count bitsbin(a).count('1')Number of 1-bits
# Single Number: every element appears twice except one β†’ XOR all
def single_number(nums):
    result = 0
    for n in nums:
        result ^= n    # x ^ x = 0, x ^ 0 = x
    return result