Sorting, searching, graph algorithms, DP patterns & complexity analysis β explained with code
Interview + Core CSBig-O describes the upper bound of growth rate. Focus on the dominant term and ignore constants.
| Notation | Name | Example |
|---|---|---|
O(1) | Constant | Hash table lookup, array index access |
O(log n) | Logarithmic | Binary search, balanced BST lookup |
O(n) | Linear | Single loop, linear search |
O(n log n) | Linearithmic | Merge sort, heap sort, Tim sort |
O(nΒ²) | Quadratic | Bubble sort, nested loops |
O(2βΏ) | Exponential | Recursive fibonacci, subset generation |
O(n!) | Factorial | Permutations, brute force TSP |
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]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 arrBuild 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 runsDivide-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)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)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 subtreeNon-comparison sorts that can beat O(n log n) when values are bounded.
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 resultSort 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| Algorithm | Best | Average | Worst | Space | Stable? |
|---|---|---|---|---|---|
| Bubble Sort | O(n) | O(nΒ²) | O(nΒ²) | O(1) | β |
| Selection Sort | O(nΒ²) | O(nΒ²) | O(nΒ²) | O(1) | β |
| Insertion Sort | O(n) | O(nΒ²) | O(nΒ²) | O(1) | β |
| Merge Sort | O(n log n) | O(n log n) | O(n log n) | O(n) | β |
| Quick Sort | O(n log n) | O(n log n) | O(nΒ²) | O(log n) | β |
| Heap Sort | O(n log n) | O(n log n) | O(n log n) | O(1) | β |
| Counting Sort | O(n+k) | O(n+k) | O(n+k) | O(k) | β |
| Radix Sort | O(dΒ·n) | O(dΒ·n) | O(dΒ·n) | O(n+k) | β |
sorted() uses Timsort (hybrid merge + insertion, O(n log n), stable)Prerequisite: Sorted array. Halves the search space each step. O(log n).
# Standard binary search
def binary_search(arr, target):
lo, hi = 0, len(arr) - 1
while lo <= hi:
mid = (lo + hi) // 2
if arr[mid] == target:
return mid
elif arr[mid] < target:
lo = mid + 1
else:
hi = mid - 1
return -1
# Find leftmost (first occurrence)
def bisect_left(arr, target):
lo, hi = 0, len(arr)
while lo < hi:
mid = (lo + hi) // 2
if arr[mid] < target:
lo = mid + 1
else:
hi = mid
return lo
# Find rightmost (insertion point after target)
def bisect_right(arr, target):
lo, hi = 0, len(arr)
while lo < hi:
mid = (lo + hi) // 2
if arr[mid] <= target:
lo = mid + 1
else:
hi = mid
return lo
# Search on answer (monotonic function)
def min_capacity(weights, days):
lo, hi = max(weights), sum(weights)
while lo < hi:
mid = (lo + hi) // 2
if can_ship(weights, days, mid):
hi = mid
else:
lo = mid + 1
return lo# 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# 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 ""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 countFind 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 distLinear 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 orderTracks 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 detectionBreak a problem into overlapping subproblems with optimal substructure. Cache results.
# 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)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:]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| Operation | Code | What it does |
|---|---|---|
| AND | a & b | Both bits 1 |
| OR | a | b | Either bit 1 |
| XOR | a ^ b | Bits differ |
| NOT | ~a | Flip all bits |
| Left shift | a << n | Multiply by 2βΏ |
| Right shift | a >> n | Divide by 2βΏ |
| Check bit | (a >> i) & 1 | Is i-th bit set? |
| Set bit | a | (1 << i) | Turn on i-th bit |
| Clear bit | a & ~(1 << i) | Turn off i-th bit |
| Toggle bit | a ^ (1 << i) | Flip i-th bit |
| Lowest set bit | a & (-a) | Isolate rightmost 1 |
| Clear lowest | a & (a-1) | Turn off rightmost 1 |
| Power of 2? | a & (a-1) == 0 | Only one bit set |
| Count bits | bin(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