Bit Manipulation Patterns for Interviews
Set, test and clear bits, exploit XOR properties, and count set bits with the Brian Kernighan trick.
The coding-round fundamentals: complexity, arrays and hashing, linked lists, stacks and queues, trees, graphs, sorting, and the common problem-solving patterns.
Official reference: MIT 6.006 Introduction to Algorithms
Set, test and clear bits, exploit XOR properties, and count set bits with the Brian Kernighan trick.
Backtracking as choose, explore, unchoose, and the proof each prune needs: sorted breaks, duplicate skips and the negative-number trap.
Read Big-O as a growth rate: count steps as n doubles, name separate inputs n and m, and spot the hidden O(n) inside one-line operations.
Binary search a numeric answer instead of an array: monotonic feasibility checks, tight bounds and min-max versus max-min variants.
Write binary search from an invariant: half-open [lo, hi), lower_bound traced step by step, and the loop and overflow bugs it prevents.
Define the DP state as one sentence before any table: House Robber from 4.3 million calls to 32, coin loop order and an LCS walkthrough.
Why directed cycle detection needs three DFS states, how to return the actual cycle, and an iterative version that avoids RecursionError.
Use hash maps to count and look up in O(1): Two Sum's lookup order, anagram keys, the JavaScript constructor trap and consecutive runs.
Keep the k largest items with a size-k min-heap: a traced stream, timings against sorting, heapq tie-breaking and K Closest Points.
Floyd's tortoise and hare in O(1) space: why fast meets slow, the null check order, finding the cycle's entry and the duplicate-number twist.
Reverse a linked list by tracking who keeps each node reachable: a traced JavaScript loop, the lost-tail bug, sublists and palindrome checks.
Find the next warmer day, next greater element or widest rectangle in O(n) with a monotonic stack, traced on Daily Temperatures.
Answer range-sum queries in O(1) after an O(n) pass: the prefix[0] = 0 convention, counting subarrays that sum to k, 2D grids and products.
Why BFS finds shortest paths when every edge costs the same: a maze solved with parent pointers, mark-on-enqueue and multi-source BFS.
Solve contiguous subarray and substring problems in O(n): state what makes a window valid, then expand right and shrink left to keep it so.
Count the memory an algorithm adds: input vs output vs auxiliary, recursion depth as space, hidden slice copies, all measured with tracemalloc.
Use a stack wherever the last thing opened must close first: bracket checks with error positions, RPN division and decoding nested strings.
Order build steps or courses so dependencies run first: Kahn's algorithm traced, cycle reporting, isolated nodes and the minimum-semesters twist.
Pick preorder, inorder or postorder by asking which way information flows: bounds passed down, heights returned up, sorted order from BSTs.
Two pointers work when each move rules out answers you can prove are worse: sorted pair sums, Container With Most Water, 3Sum and palindromes.
Track merging groups with union-find: parent pointers, union by size and path compression measured, cycle detection and Number of Islands.
Big-O describes how an algorithm's running time (or memory) grows as the input size n grows. It is an upper bound on the growth rate, so we drop constants and lower-order terms: 3n² + 5n + 2 is O(n²).
To analyze code, I count how many times the dominant operation runs as a function of n:
O(n) + O(n²) is O(n²)n inside a loop over n is O(n²)O(log n)O(a + b) or O(a · b), not O(n²)I also watch for hidden linear costs inside a single line, such as x in some_list, slicing, or copying a list.
The common classes, fastest to slowest: O(1), O(log n), O(n), O(n log n), O(n²), O(2ⁿ), O(n!).
O(log n)in on a listLikely follow-up: What is the complexity of a loop where j doubles each step, nested inside a loop over n? · How do you analyze the complexity of a recursive function?
The brute force checks every pair: O(n²) time, O(1) space. That is my baseline, but the bottleneck is the inner search for the complement, so I replace it with a hash map.
I walk the array once. For each x at index i, the number I need is target - x. If it is already in the map, I have my pair; otherwise I store x -> i and move on. Checking before inserting means an element is never paired with itself, and duplicates like [3, 3] with target 6 still work.
That is O(n) time and O(n) extra space, relying on average-case O(1) hash lookups.
If the array were already sorted, I would use two pointers from both ends instead: O(n) time and O(1) space. Sorting first costs O(n log n) and loses the original indices unless I sort (value, index) pairs.
def two_sum(nums, target):
seen = {} # value -> index
for i, x in enumerate(nums):
if target - x in seen:
return [seen[target - x], i]
seen[x] = i # insert after checking
return []O(n²)O(n) time, O(n) space on averageO(1) spaceLikely follow-up: How would you solve 3Sum? · What changes if you must return all unique pairs?
An array stores elements in one contiguous block, so index access is O(1) by address arithmetic and iteration is very cache-friendly. The cost is that inserting or deleting in the middle shifts elements, O(n), and a fixed-size array must be reallocated to grow (dynamic arrays hide this behind amortized O(1) appends).
A linked list stores nodes scattered in memory, each pointing to the next (and to the previous, if doubly linked). Accessing the i-th element or searching is O(n), but once you hold a reference to a node, inserting or removing next to it is O(1) with no shifting. Each node carries pointer overhead, and traversal has poor cache locality.
In practice I default to a dynamic array (Python list, Java ArrayList) because locality usually wins. I pick a linked list when I need O(1) insert and remove at positions I already hold, like the doubly linked list inside an LRU cache, or when splicing lists together.
O(1) index access, cache-friendlyO(n) from shiftingO(n) access, O(1) insert at a known nodeLikely follow-up: Why is linked-list insertion "O(1)" often misleading in practice? · How would you find the middle of a linked list in one pass?
O(1) on average?easyA hash table stores key-value pairs in an array of buckets. To insert or find a key, it runs the key through a hash function to get an integer, then maps that to a bucket index, typically hash(key) % capacity. Jumping straight to the bucket instead of scanning is what makes it fast.
Two keys can land in the same bucket, a collision, handled by chaining (each bucket holds a small list) or open addressing (probe for another free slot). The table tracks its load factor, entries divided by buckets, and past a threshold it allocates a bigger array and rehashes everything. That resize is O(n) but rare, so inserts stay amortized O(1).
With a good hash function and a bounded load factor, buckets stay small on average, so get, put and delete are O(1) average. The worst case is O(n) when many keys collide.
Keys must be hashable and stable: equal keys need equal hashes, and mutating a key after inserting it breaks lookups.
O(1) average, O(n) worst caseLikely follow-up: What is the difference between chaining and open addressing? · Why shouldn't you use a mutable object as a dictionary key?
I follow the same loop every time and talk through each step:
n around 10⁵ usually needs O(n log n) or better, while n ≤ 20 allows exponential search.Likely follow-up: What do you do if you get stuck on the optimization step? · How do you choose which edge cases to test?
Iteratively, I walk the list with three references: prev (the already-reversed part, starting at None), curr, and a saved nxt. For each node I save curr.next, point curr.next back at prev, then advance both. When curr is None, prev is the new head. That is O(n) time and O(1) extra space. The classic bug is overwriting curr.next before saving it, which loses the rest of the list.
Recursively, I first reverse everything after the head, which returns the new head (the old tail). Now head.next is the last node of the reversed part, so head.next.next = head appends the current node, and head.next = None cuts the old link. It is also O(n) time but uses O(n) call-stack space, so a long list can hit Python's recursion limit. I prefer the iterative version in real code.
def reverse_list(head):
prev, curr = None, head
while curr:
nxt = curr.next # save the rest first
curr.next = prev # flip the pointer
prev, curr = curr, nxt
return prev # new head
def reverse_rec(head):
if head is None or head.next is None: return head
new_head = reverse_rec(head.next)
head.next.next = head # hang head after its old successor
head.next = None
return new_headprev and curr; save next before relinkingO(n) time, O(1) spacehead.next.next = head, then head.next = NoneO(n) stack spaceLikely follow-up: How would you reverse only the nodes between positions m and n? · How would you reverse a list in groups of k?
"([]{})", how do you check whether it is valid?easyBrackets must close in the reverse order they opened, which is exactly last-in, first-out, so I use a stack.
I scan left to right and push every opening bracket. For a closing bracket, the stack must be non-empty and its top must be the matching opener; if so I pop it, otherwise the string is invalid right away. At the end the string is valid only if the stack is empty, because any leftover openers were never closed.
A map from each closer to its opener keeps the code short. It is O(n) time and O(n) space in the worst case, such as a string of only opening brackets.
Edge cases I test: the empty string (valid), a lone closer ")", interleaving like "([)]" (invalid) and "((" (invalid). With only one bracket type, a counter that never goes negative and ends at zero is enough, in O(1) space.
def is_valid(s):
pairs = {')': '(', ']': '[', '}': '{'}
stack = []
for ch in s:
if ch in pairs: # closing bracket
if not stack or stack.pop() != pairs[ch]:
return False
else:
stack.append(ch) # opening bracket
return not stack # nothing left unclosedO(n) time, O(n) spaceLikely follow-up: How would you find the longest valid parentheses substring? · How would you generate all valid combinations of n pairs?
A stack is last-in, first-out: you push and pop at the same end. It models function calls, undo, DFS and bracket matching. A Python list works well, since append and pop() at the end are amortized O(1).
A queue is first-in, first-out: you add at the back and remove from the front. It models scheduling, buffering and BFS. I use collections.deque with append and popleft, both O(1). I avoid list.pop(0) because it shifts every remaining element, which is O(n).
A deque (double-ended queue) supports O(1) insert and remove at both ends, so it can act as either. It is the tool behind sliding-window maximum and 0-1 BFS.
For thread-safe producer-consumer queues Python has queue.Queue. A priority queue is a different structure again, usually a binary heap via heapq.
list.append and list.pop()deque.append and deque.popleft()list.pop(0) is O(n); avoid it for queuesO(1) operations at both endsLikely follow-up: How would you implement a queue using two stacks? · How would you design a stack that returns its minimum in O(1)?
Both visit every reachable vertex and edge once, so both are O(V + E) time with an adjacency list. They differ in the order they explore.
BFS (breadth-first search) uses a queue and explores level by level: every node at distance 1, then distance 2, and so on. That makes it the right choice for the shortest path in an unweighted graph, the minimum number of moves, and level-order tree traversal. Its memory grows with the widest frontier.
DFS (depth-first search) uses a stack or recursion and goes as deep as possible before backtracking. It suits exhaustive exploration: connected components, cycle detection, topological sort, path finding and backtracking. Its memory grows with the depth, and deep recursion can overflow the call stack, so for very deep graphs I use an explicit stack.
In both, I mark a node visited when I first discover it (for BFS, when I enqueue it) so it is never processed twice.
O(V + E) with an adjacency listLikely follow-up: Why does BFS give shortest paths only when edges are unweighted? · How would you run BFS on a grid with obstacles?
O(1) extra space?easyThe simple approach stores every visited node in a hash set and reports a cycle when it sees a node twice: O(n) time but also O(n) space.
For O(1) space I use Floyd's tortoise and hare. Two pointers start at the head; slow moves one step and fast moves two. Without a cycle, fast reaches the end. With a cycle, both end up circling it, and each step the gap between them shrinks by one, so fast must land on slow within about one lap. It is O(n) time.
The loop condition fast and fast.next protects both hops of the fast pointer. I compare nodes by identity (is), not by value, since values can repeat.
To find where the cycle starts, after they meet I move one pointer back to the head and advance both one step at a time; they meet again at the cycle's entry node.
def has_cycle(head):
slow = fast = head
while fast and fast.next:
slow = slow.next
fast = fast.next.next
if slow is fast: # compare nodes, not values
return True
return FalseO(n) spacefast and fast.nextLikely follow-up: Why does resetting one pointer to the head find the cycle start? · How would you compute the length of the cycle?
Binary search finds a target in a sorted array by repeatedly halving the search range. I keep lo and hi bounds, look at the middle element, and discard the half that cannot contain the target. Each step halves the range, so it runs in O(log n) time and O(1) space when written iteratively.
The common bugs:
hi is inclusive (len - 1, loop while lo <= hi) or exclusive (len, loop while lo < hi), and keep every update consistent with that choice.lo = mid where mid + 1 is needed can stop the range from shrinking.(lo + hi) / 2 can overflow for large indices; lo + (hi - lo) / 2 avoids it. Python integers never overflow.In Python I would also mention bisect_left and bisect_right from the standard library.
def binary_search(a, target):
lo, hi = 0, len(a) - 1 # inclusive bounds
while lo <= hi:
mid = lo + (hi - lo) // 2
if a[mid] == target:
return mid
if a[mid] < target:
lo = mid + 1 # discard the left half
else:
hi = mid - 1 # discard the right half
return -1O(log n)hi and stay consistentmid + 1 / mid - 1 so the range shrinkslo + (hi - lo) / 2 avoids overflow in Java/C++Likely follow-up: How would you find the first occurrence of a duplicated value? · How would you search a rotated sorted array?
Both are divide-and-conquer O(n log n) sorts, but they split the work differently.
Quicksort picks a pivot, partitions the array in place into smaller and larger elements, then recurses on each side. It averages O(n log n), but consistently bad pivots (for example, always the first element on already-sorted input) give O(n²). Random or median-of-three pivots make that very unlikely. It needs only O(log n) expected stack space, has excellent cache behavior, and is not stable.
Mergesort splits in half, sorts both halves, and merges them. It is O(n log n) in every case and stable, but merging arrays needs O(n) extra space. It is the natural choice for linked lists (no random access needed) and for external sorting of data that does not fit in memory.
Real libraries blend ideas: Python's sorted and Java's object sorts use Timsort, a stable merge/insertion hybrid, while C++ std::sort is typically introsort, a quicksort that falls back to heapsort.
O(n log n) on averageO(n²); random pivots mitigate itO(n log n), stable, O(n) extraLikely follow-up: How does pivot choice affect quicksort? · How would you sort a linked list in O(n log n)?
There are three standard options:
O(n log n) time. Simple, and fine as a first answer.k. The heap always holds the k largest values seen so far, so its root is the kth largest. That is O(n log k) time and O(k) space, and it also works on a stream.O(n), with an O(n²) worst case made unlikely by a random pivot. It works in place but reorders the input.In an interview I present the heap as the robust default, mention quickselect for the best average time, and note that heapq.nlargest(k, nums)[-1] does the heap approach in one line.
I also clarify that duplicates count: in [3, 3, 2] the 2nd largest is 3.
import heapq
def kth_largest(nums, k):
heap = [] # min-heap of the k largest so far
for x in nums:
heapq.heappush(heap, x)
if len(heap) > k:
heapq.heappop(heap) # drop the smallest of them
return heap[0] # root = kth largestO(n log n)O(n log k) time, O(k) spaceO(n), worst O(n²)Likely follow-up: How does quickselect differ from quicksort? · How would you track the kth largest value in a stream?
O(n)?midBrute force checks every substring for duplicates, which is O(n²) substrings with extra work each. The optimization is a sliding window: keep a window [start, i] that never contains a repeat, and extend it one character at a time.
I store the last index where each character was seen. When the current character already appears inside the window (its last index is at least start), I move start to just past that previous copy. Then I record the current index and update the best length, i - start + 1.
The >= start check matters: a character last seen before the window began is not a conflict. In "abba", when the final a arrives, its old index 0 is already outside the window, so start must not move backwards.
Each character is handled once: O(n) time, and O(min(n, alphabet size)) space for the map. Quick tests: "" gives 0, "bbbb" gives 1, "pwwkew" gives 3.
def length_of_longest_substring(s):
last = {} # char -> last index seen
start = best = 0
for i, ch in enumerate(s):
if last.get(ch, -1) >= start: # repeat inside the window
start = last[ch] + 1 # jump past the old copy
last[ch] = i
best = max(best, i - start + 1)
return beststart only if the repeat is inside the windowO(n) time, O(min(n, alphabet)) space"abba" to catch the backwards-jump bugLikely follow-up: How would you solve it if up to k distinct characters were allowed? · How would you return the substring itself?
The key step is to sort by start. Once sorted, any interval that overlaps the current merged block must come right after it, so one pass is enough.
I keep a result list. For each interval, if the result is empty or the interval starts after the last merged block ends, it opens a new block. Otherwise it overlaps, so I extend the last block's end to max(last_end, end). The max matters because an interval can sit entirely inside the previous one, like [1, 10] followed by [2, 3].
I clarify whether touching intervals such as [1, 2] and [2, 3] count as overlapping; using <= merges them, < keeps them apart.
Sorting dominates: O(n log n) time, plus O(n) space for the output and the sorted copy. I use sorted() rather than intervals.sort() so the caller's list is not mutated.
def merge(intervals):
merged = []
for start, end in sorted(intervals, key=lambda iv: iv[0]):
if merged and start <= merged[-1][1]: # overlaps last block
merged[-1][1] = max(merged[-1][1], end)
else:
merged.append([start, end])
return mergedmax to handle containmentO(n log n) time, O(n) spaceLikely follow-up: How would you insert a new interval into a sorted, non-overlapping list? · What is the minimum number of meeting rooms these intervals need?
Dynamic programming solves a problem by combining answers to smaller subproblems. It applies when two properties hold: optimal substructure (the best answer is built from best answers to subproblems) and overlapping subproblems (the same subproblems recur, so caching them pays off). Naive recursive Fibonacci is the classic sign: fib(n - 2) is recomputed again and again, giving exponential time.
functools.cache. It computes only the states actually reached and is easy to derive, but it pays call overhead and can hit the recursion limit.Both have the same time complexity: number of states times work per state. My recipe: define the state, write the recurrence, set base cases, choose the evaluation order, then optimize space.
Likely follow-up: How do you recognize that a problem needs DP? · When would you prefer memoization over tabulation?
get and put in O(1) time.midAn LRU cache evicts the least recently used entry when it is full. To make both operations O(1) I combine two structures:
O(1) lookup.O(1).get looks up the key, moves its node to the most-recent end, and returns the value (or -1). put updates or inserts the node at the most-recent end; if the size now exceeds capacity, it removes the node at the least-recent end and deletes its key from the map, which is why each node also stores its key. Sentinel head and tail nodes remove empty-list edge cases.
In Python, OrderedDict already is this combination, as shown. Interviewers often ask for the hand-built version too. In Java, a LinkedHashMap in access order that overrides removeEldestEntry does the same.
from collections import OrderedDict
class LRUCache:
def __init__(self, capacity):
self.cap, self.data = capacity, OrderedDict()
def get(self, key):
if key not in self.data:
return -1
self.data.move_to_end(key) # now most recent
return self.data[key]
def put(self, key, value):
self.data[key] = value
self.data.move_to_end(key)
if len(self.data) > self.cap:
self.data.popitem(last=False) # evict least recentO(1) key lookupLikely follow-up: How would you make the cache thread-safe? · How would you design an LFU cache instead?
The three depth-first orders differ only in when the node is visited relative to its subtrees:
All four are O(n) time. The depth-first orders use O(h) stack space, where h is the height: O(log n) for a balanced tree but O(n) for a skewed one. Level-order uses space for the widest level, which can be about n / 2 in a complete tree. Any recursive version can be rewritten with an explicit stack.
from collections import deque
def inorder(node, out):
if node:
inorder(node.left, out) # pre-order: append before this line
out.append(node.val)
inorder(node.right, out) # post-order: append after this line
def level_order(root):
out, q = [], deque([root] if root else [])
while q:
node = q.popleft()
out.append(node.val)
q.extend(c for c in (node.left, node.right) if c)
return outO(n) timeO(h); BFS space is the widest levelLikely follow-up: Write an iterative in-order traversal with an explicit stack. · How would you rebuild a tree from its pre-order and in-order traversals?
A BST keeps every value in a node's left subtree smaller than the node and every value in its right subtree larger. That ordering makes search, insert and delete O(h) for height h: O(log n) when balanced, but O(n) if it degenerates into a chain, for example after inserting keys in sorted order.
The common wrong answer compares each node only with its direct children. That misses a root 5 whose left child 3 has a right child 6: every parent-child pair looks fine, but 6 sits in the left subtree of 5.
The fix is to pass down the valid range. The root may hold anything; going left tightens the upper bound to the node's value, going right tightens the lower bound, and every node must fall strictly inside its range. That is O(n) time and O(h) stack space.
An alternative is an in-order traversal that checks the values are strictly increasing. I also clarify how duplicates should be treated.
def is_valid_bst(node, lo=float('-inf'), hi=float('inf')):
if node is None:
return True
if not (lo < node.val < hi): # must fit the inherited range
return False
return (is_valid_bst(node.left, lo, node.val) and
is_valid_bst(node.right, node.val, hi))O(h); a degenerate tree is O(n)Likely follow-up: How would you find the kth smallest element in a BST? · How would you find the lowest common ancestor of two nodes in a BST?
Space complexity measures the memory an algorithm needs as a function of input size. I usually report auxiliary space, the memory beyond the input itself, and I say so explicitly, because some people count the input too.
I add up:
n entries is O(n), while a fixed 26-letter count array is O(1).O(log n); on a skewed tree it is O(n). Memoized Fibonacci uses O(n) for the cache plus O(n) of stack.sorted(), string building and list comprehensions all allocate.The output is usually excluded unless the question says otherwise. Replacing recursion with a loop, or keeping only the previous DP row, are the usual ways to cut space.
O(log n); skewed O(n)sorted()Likely follow-up: What is the space complexity of merge sort on an array? · How would you reduce a 2D DP table to a single row?
A binary heap is a complete binary tree with the heap property: in a min-heap every parent is less than or equal to its children, so the minimum sits at the root. A max-heap is the mirror image. Beyond that it is not sorted; siblings have no order.
Because the tree is complete, it lives compactly in an array: the children of index i are at 2i + 1 and 2i + 2, and its parent is at (i - 1) // 2.
O(1), the root is a[0].O(log n).O(log n).n items: O(n) with bottom-up heapify.A priority queue is the abstract "give me the highest-priority item" interface, and a heap is its usual implementation. Python's heapq is a min-heap over a list; for a max-heap, push negated keys. Heaps power Dijkstra, top-k, merging sorted lists and schedulers.
2i+1 and 2i+2O(1); push and pop O(log n)O(n)heapq is a min-heap; negate keys for maxLikely follow-up: Why is building a heap O(n) rather than O(n log n)? · How would you merge k sorted lists with a heap?
k most frequent elements. Can you beat O(n log n)?midFirst I count frequencies with a hash map (Counter), which is O(n). Then I need the k keys with the highest counts, among m distinct values:
O(m log m), the O(n log n) baseline.O(m log k). heapq.nlargest(k, count, key=count.get) does it in one line.1 and n, so I create n + 1 buckets where bucket c holds the values seen exactly c times, then walk from the highest bucket down until I have k values. That is O(n) time and O(n) space.I mention Counter.most_common(k) as the idiomatic library call, then write the bucket version when asked to beat O(n log n). I also clarify ties: when several values share the cut-off frequency, any of them is usually acceptable.
from collections import Counter
def top_k_frequent(nums, k):
count = Counter(nums)
buckets = [[] for _ in range(len(nums) + 1)] # index = frequency
for x, c in count.items():
buckets[c].append(x)
out = []
for c in range(len(nums), 0, -1): # highest frequency first
for x in buckets[c]:
out.append(x)
if len(out) == k:
return out
return outO(n)O(m log k)n, so bucket themO(n) time and spaceLikely follow-up: How would you handle a stream too large to fit in memory? · How would you return the k most frequent words, alphabetical on ties?
Two pointers means walking an array or string with two indices instead of nested loops, moving them by a rule so the total work is O(n) rather than O(n²). There are two main shapes:
a[lo] + a[hi] is too small, move lo right; if too large, move hi left. Each move safely discards candidates, giving O(n) time and O(1) space. Palindrome checks, in-place reversal and container-with-most-water fit here too.3Sum combines both: sort, fix one element, then run the opposite-ends search on the rest, for O(n²) overall.
Signals to reach for it: sorted input, pairs or triples, in-place rewriting, or a symmetric check.
O(n), O(1) spaceO(n²)Likely follow-up: Why is it safe to move the shorter side in container-with-most-water? · How do you skip duplicate triplets in 3Sum?
n?easyTo land on step n, the last move was either a 1-step from n - 1 or a 2-step from n - 2. Those groups do not overlap and together cover every path, so ways(n) = ways(n - 1) + ways(n - 2), with ways(0) = ways(1) = 1. That is the Fibonacci recurrence.
Plain recursion is exponential, roughly O(1.6ⁿ), because the same values are recomputed many times. That repeated work is the overlapping subproblems signal for DP.
n is computed once: O(n) time, O(n) space for the cache and the stack.O(n) time, O(1) space.I present the bottom-up version as the final answer. Variations interviewers like: steps of 1, 2 or 3 (sum the last three), an arbitrary set of step sizes (a coin-change style loop), or a cost per step (min-cost climbing stairs).
from functools import cache
@cache
def climb_memo(n):
return 1 if n <= 1 else climb_memo(n - 1) + climb_memo(n - 2)
def climb(n): # bottom-up, O(1) space
a, b = 1, 1 # ways(0), ways(1)
for _ in range(n - 1):
a, b = b, a + b
return bf(n) = f(n-1) + f(n-2)f(0) = f(1) = 1O(n) time and spaceO(1) spaceLikely follow-up: What if you could take 1, 2 or 3 steps? · How would you handle a cost for landing on each step?
Greedy (always take the largest coin that fits) works for coin systems like 1, 5, 10, 25, but not in general. With coins [1, 3, 4] and amount 6, greedy picks 4 + 1 + 1 (3 coins), while 3 + 3 needs only 2.
So I use DP. Let dp[a] be the fewest coins that make amount a. The last coin used is some c, leaving a - c, so dp[a] = 1 + min(dp[a - c]) over coins c <= a, with dp[0] = 0. Amounts that cannot be made stay at infinity, and I return -1 for them.
It is O(amount × number of coins) time and O(amount) space. That is pseudo-polynomial: polynomial in the numeric value of amount, not in the number of digits needed to write it.
The sibling problem, counting the number of ways to make the amount, puts coins in the outer loop so each combination is counted once rather than once per ordering.
def coin_change(coins, amount):
INF = float('inf')
dp = [0] + [INF] * amount # dp[a] = fewest coins for a
for a in range(1, amount + 1):
for c in coins:
if c <= a and dp[a - c] + 1 < dp[a]:
dp[a] = dp[a - c] + 1
return dp[amount] if dp[amount] != INF else -1[1, 3, 4], amount 6dp[a] = 1 + min(dp[a - c]), with dp[0] = 0-1O(amount × coins) time, O(amount) spaceLikely follow-up: How would you count the number of combinations instead? · Could you solve it with BFS instead of DP?
Recursion expresses a problem in terms of smaller copies of itself, with a base case that stops it. It is the natural fit for trees, divide and conquer, backtracking and DFS, and the code often mirrors the recurrence directly.
The costs: each call pushes a stack frame, so space is O(depth), and deep recursion can overflow the stack. Python's default recursion limit is 1000 and it has no tail-call optimization, so recursing down a 100,000-node linked list fails. Calls also add constant-factor overhead.
Iteration uses loops and explicit state. It avoids stack overflow, is usually faster, and makes memory use obvious.
Yes, every recursion can be converted: the call stack is just a stack, so you can manage one yourself, as in iterative DFS or iterative in-order traversal. Tail-recursive and simple linear recursions become plain loops.
My rule: use recursion when it is clearer and the depth is bounded, such as O(log n) on a balanced tree, and switch to iteration when depth can grow with n.
O(depth) spacenLikely follow-up: Convert a recursive pre-order traversal into an iterative one. · What is tail recursion, and why does it matter?
O(1) amortized.midAmortized analysis bounds the total cost of a sequence of operations and divides by the number of operations. It is a worst-case guarantee on the average over the sequence, which is different from average-case analysis: no probability or random inputs are involved.
A dynamic array (Python list, Java ArrayList) keeps spare capacity. Most appends just write into the next free slot, O(1). When it is full, it allocates a bigger block and copies every element, O(n) for that one append.
If capacity doubles, the copies across n appends total 1 + 2 + 4 + ... up to n, a geometric series below 2n. So n appends cost O(n) overall, O(1) amortized each. What matters is geometric growth: any constant factor above 1 works (Java's ArrayList grows by about 1.5x, and CPython over-allocates by a smaller fraction). Growing by a fixed number of slots instead makes the sequence O(n²).
The formal tools are the aggregate method (used above), the accounting method and the potential method.
O(n) resize, otherwise O(1) writes2nO(n²) totalLikely follow-up: Why do some implementations grow by 1.5x rather than 2x? · What goes wrong if you halve the array whenever it becomes half empty?
All three bound how a function grows, for large enough n and up to a constant factor:
f(n) = O(g(n)) if f(n) ≤ c · g(n) for some constant c and every n past some n₀.f(n) ≥ c · g(n).f grows like g.So 3n² + n is Θ(n²). It is also technically O(n³), true but uninformative. In interviews people say Big-O but usually mean the tight bound.
And no, Big-O is not "the worst case". Best, average and worst case choose which inputs you analyze; O, Ω and Θ describe what kind of bound you state about that cost. Insertion sort is Θ(n²) in the worst case and Θ(n) in the best case, on already-sorted input. The comparison-sort lower bound is an Ω statement: any comparison sort needs Ω(n log n) comparisons in the worst case.
nΘ(n²), best Θ(n)Likely follow-up: Is 2ⁿ⁺¹ in O(2ⁿ)? What about 2²ⁿ? · What is the average-case complexity of quicksort, and why?
I use an inbox stack for pushes and an outbox stack for pops.
O(1).The key detail is to transfer only when the outbox is empty. Transferring while it still holds items would put newer elements above older ones and break FIFO order.
A single pop can cost O(n) when it triggers a transfer, but each element is pushed onto the inbox once, moved once, and popped from the outbox once. So n operations cost O(n) in total, which makes every operation amortized O(1), with O(n) space.
The reverse puzzle, a stack built from queues, also works, but then push or pop costs O(n) every time rather than amortized.
class MyQueue:
def __init__(self):
self.inbox, self.outbox = [], []
def push(self, x):
self.inbox.append(x)
def pop(self):
if not self.outbox: # refill only when empty
while self.inbox:
self.outbox.append(self.inbox.pop())
return self.outbox.pop()O(1) per operationLikely follow-up: What is the worst case for a single pop? · How would you implement a stack using queues?
The two standard representations, for V vertices and E edges:
O(V + E) and listing a vertex's neighbors costs O(degree), so BFS and DFS run in O(V + E). Checking one specific edge is O(degree), or O(1) on average if neighbors are kept in sets.V × V grid where m[u][v] holds 1 or the edge weight. Edge lookup is O(1), but space is O(V²) however few edges there are, and listing neighbors costs O(V), so BFS becomes O(V²).Most real graphs are sparse, with E far below V², so I default to adjacency lists. A matrix makes sense for small or dense graphs, frequent edge queries, or matrix-shaped algorithms like Floyd-Warshall.
An edge list of (u, v, weight) tuples is a third option, handy for Kruskal's algorithm, and grids are implicit graphs whose neighbors are computed on the fly.
O(V + E) space, neighbors in O(degree)O(V²) space, O(1) edge checkO(V + E) with lists, O(V²) with a matrixLikely follow-up: How would you store a weighted, directed graph? · When would you choose Floyd-Warshall over running Dijkstra from every node?
A sliding window maintains a contiguous range [left, right] over an array or string and updates an aggregate incrementally as the window moves, instead of recomputing it for every subarray. That turns O(n²) or worse into O(n).
k consecutive elements", add the element entering on the right and subtract the one leaving on the left, O(1) per step.right each step; while the window violates the condition, shrink from left. Each index enters and leaves at most once, so it is O(n) overall despite the inner while. Longest substring without repeats and shortest subarray with sum at least target work this way.It applies when validity is monotonic in the window: shrinking a valid window keeps it valid, or growing an invalid one keeps it invalid. That is why "shortest subarray with sum at least target" needs non-negative numbers. With negatives, adding an element can lower the sum, so I switch to prefix sums with a hash map, or a monotonic deque.
O(n)Likely follow-up: How would you find the minimum window substring containing all characters of t? · How would you count subarrays with exactly k distinct values?
k?midA prefix sum array stores running totals: P[0] = 0 and P[i + 1] = P[i] + a[i]. After O(n) preprocessing, the sum of any range a[i..j] is P[j + 1] - P[i] in O(1), so repeated range-sum queries become cheap. The idea extends to 2D grids, and its inverse, a difference array, applies range updates in O(1) each.
For "count subarrays summing to k": a subarray ending at the current index sums to k exactly when some earlier prefix equals prefix - k. So I sweep once, keep a hash map from each prefix sum to how many times it has occurred, and add seen[prefix - k] at every step. The map starts as {0: 1} so subarrays that begin at index 0 are counted.
That is O(n) time and O(n) space, and unlike a sliding window it works with negative numbers. The brute force, summing every subarray, is O(n²).
def subarray_sum(nums, k):
count = prefix = 0
seen = {0: 1} # prefix sum -> times seen
for x in nums:
prefix += x
count += seen.get(prefix - k, 0)
seen[prefix] = seen.get(prefix, 0) + 1
return countP[i+1] = P[i] + a[i]; range sum is P[j+1] - P[i]O(n) preprocessing, O(1) per queryprefix - k{0: 1}Likely follow-up: How would you find the longest subarray with sum k? · How do 2D prefix sums answer rectangle-sum queries?
A topological order of a directed graph lists the vertices so that every edge u -> v has u before v. It exists only for a DAG (directed acyclic graph); a cycle means no valid order. Typical uses are build systems, task scheduling and course prerequisites.
Kahn's algorithm is BFS-based: compute every in-degree and queue the vertices with in-degree 0, since nothing must precede them. Repeatedly pop a vertex, append it to the order, and decrement its neighbors' in-degrees, queuing any that reach 0. If the order ends up shorter than V, the leftover vertices sit on a cycle, so this doubles as cycle detection.
The DFS alternative appends each vertex after all its descendants finish (post-order), then reverses the list; cycle detection there needs an "on the current path" marker.
Both are O(V + E) time and space. The order is usually not unique; using a min-heap instead of a queue yields the lexicographically smallest one.
from collections import deque
def topo_sort(n, edges): # (u, v): u must come before v
graph, indeg = [[] for _ in range(n)], [0] * n
for u, v in edges:
graph[u].append(v)
indeg[v] += 1
q, order = deque(i for i in range(n) if indeg[i] == 0), []
while q:
u = q.popleft()
order.append(u)
for v in graph[u]:
indeg[v] -= 1
if indeg[v] == 0: q.append(v)
return order if len(order) == n else None # None: there is a cycleu -> v puts u before vV means a cycleO(V + E); reversed DFS post-order also worksLikely follow-up: How would you report which courses form the cycle? · How would you find the minimum number of semesters needed to finish all courses?
Dijkstra finds shortest paths from one source in a graph with non-negative edge weights. It is BFS with a priority queue: always expand the vertex with the smallest known distance, because with no negative edges nothing found later can make that distance shorter.
I keep a dist map and a min-heap of (distance, vertex) pairs. Pop the smallest; if it is stale (larger than the recorded distance), skip it. Otherwise relax each outgoing edge: if d + w beats the neighbor's best distance, record it and push the new pair. This lazy deletion avoids needing a decrease-key operation.
With a binary heap it is O((V + E) log V) time and O(V + E) space. A Fibonacci heap improves the bound to O(E + V log V), and a plain array scan gives O(V²), which suits dense graphs.
It fails with negative edges: a vertex can be finalized before a cheaper path through a negative edge is discovered. Then I use Bellman-Ford, O(V · E), which also detects negative cycles. For unweighted graphs, plain BFS is enough.
import heapq
def dijkstra(graph, src): # graph: {u: [(v, weight), ...]}
dist = {src: 0}
pq = [(0, src)]
while pq:
d, u = heapq.heappop(pq)
if d > dist[u]:
continue # stale entry: u was already improved
for v, w in graph.get(u, []):
nd = d + w
if nd < dist.get(v, float('inf')):
dist[v] = nd
heapq.heappush(pq, (nd, v))
return distO((V + E) log V)Likely follow-up: How would you reconstruct the actual path, not just the distance? · How does A* search improve on Dijkstra?
With separate chaining, each bucket holds a list (or tree) of the entries that hash there. It is simple, deletion is easy, and the load factor can exceed 1, with performance degrading gradually as chains grow. The cost is a pointer per entry and weaker cache locality. Java's HashMap chains, resizes past a default load factor of 0.75, and converts very long buckets into balanced trees.
With open addressing, entries live in the array itself, and a collision probes for another slot: linear probing, quadratic probing or double hashing. It is compact and cache-friendly, which is why CPython's dict uses it, but the load factor must stay well below 1 because probe sequences lengthen sharply as the table fills. Linear probing also suffers primary clustering, and deletion needs tombstones so later searches do not stop early.
Either way, O(1) average needs a well-spreading hash and geometric resizing. The worst case is O(n) when keys collide, which attackers can force (hash flooding); keyed, randomized hashing like Python's SipHash-based string hash, and Java's tree bins, limit the damage.
O(n); keyed hashing resists floodingLikely follow-up: Why does deleting from a linear-probing table need tombstones? · What happens to a Java HashMap bucket that collects many collisions?
A greedy algorithm builds a solution by making the locally best choice at each step and never revisiting it. It is correct when the problem has the greedy-choice property (some optimal solution starts with the greedy choice) and optimal substructure (what remains is a smaller instance of the same problem).
Classic cases where greedy is provably optimal:
Where it fails: 0/1 knapsack, where the best-ratio item can waste capacity; coin change with arbitrary coins, like [1, 3, 4] for amount 6; and longest-path problems. Those need DP or search.
To justify greedy in an interview I use an exchange argument: take any optimal solution, swap in the greedy choice, and show it gets no worse. If I cannot make that argument, I hunt for a small counterexample, and if I find one I move to DP.
Likely follow-up: Why does picking the earliest end time work, but earliest start time does not? · Can you give a counterexample for greedy 0/1 knapsack?
Backtracking is DFS over a tree of partial solutions. At each step I choose an option, explore further recursively, then un-choose it to restore state before trying the next option. It fits problems that ask for all solutions or any valid configuration: subsets, permutations, combination sum, N-queens, Sudoku and word search.
For subsets, each call records the current path as one subset, then tries adding each remaining element from start onward. Recursing with i + 1 keeps elements in increasing index order, so [1, 2] and [2, 1] are not both produced. I append a copy of the path, because the list itself keeps changing.
There are 2ⁿ subsets and copying each costs up to O(n), so it is O(n · 2ⁿ) time, with O(n) recursion depth beyond the output. Permutations cost O(n · n!).
The main optimization is pruning: abandon a branch as soon as it cannot lead to a valid answer, for example when a running sum passes the target, or skip duplicates after sorting.
def subsets(nums):
out, path = [], []
def backtrack(start):
out.append(path[:]) # record a copy of the current subset
for i in range(start, len(nums)):
path.append(nums[i]) # choose
backtrack(i + 1) # explore
path.pop() # un-choose
backtrack(0)
return outO(n · 2ⁿ); permutations O(n · n!)Likely follow-up: How would you generate subsets when the input contains duplicates? · How would you solve N-queens with backtracking?
Fast and slow pointers move through a sequence at different speeds, usually one and two steps. Besides detecting a cycle, the pattern solves:
fast reaches the end, slow is in the middle. Starting both at the head with while fast and fast.next, an even-length list gives the second middle node.a steps from the entry and they met b steps into a cycle of length c, then a + b is a multiple of c, so a more steps from the meeting point reach the entry.x -> f(x) as a linked list with a cycle.All of these run in O(n) time and O(1) space.
Likely follow-up: Why is a + b a multiple of the cycle length at the meeting point? · How would you find the duplicate in an array of n + 1 integers in O(1) space?
O(n)?hardA monotonic stack keeps its contents in sorted order (increasing or decreasing) by popping anything that would break the order before pushing. It answers "nearest greater or smaller element to the left or right" for every position in one pass.
For next greater element, I scan left to right with a stack of indices whose answer is still unknown; their values decrease from bottom to top. When a new value x arrives, every index on top with a smaller value has just found its next greater element, x, so I pop it and record the answer. Then I push the current index. Whatever remains at the end has no greater element, so it keeps -1.
It looks nested, but each index is pushed once and popped at most once, so the total is O(n) time (amortized O(1) per element) and O(n) space.
The same pattern solves daily temperatures (store the index distance instead), stock span, largest rectangle in a histogram and trapping rain water. For a circular array, loop over 2n positions using i % n.
def next_greater(nums):
res = [-1] * len(nums)
stack = [] # indices; their values decrease upward
for i, x in enumerate(nums):
while stack and nums[stack[-1]] < x:
res[stack.pop()] = x # x is the next greater for that index
stack.append(i)
return resO(n)Likely follow-up: How would you find the largest rectangle in a histogram? · How would you handle a circular array?
A sort is stable if elements that compare equal keep their original relative order. Sorting [("bob", 25), ("amy", 30), ("cat", 25)] by age, a stable sort guarantees bob still comes before cat.
It matters mostly for multi-key sorting: sort by the secondary key first, then stable-sort by the primary key, and ties on the primary key stay ordered by the secondary one. Tables where you click one column after another rely on this, and so does LSD radix sort.
sorted and list.sort are guaranteed stable, and so is Java's object sort.Any sort can be made stable by using the original index as a tie-breaker, at the cost of extra space. For plain integers stability is invisible, since equal values are indistinguishable, which is why Java can sort an int[] with a non-stable quicksort.
Likely follow-up: How would you sort people by last name, then first name, using a stable sort? · Why must the per-digit sort inside LSD radix sort be stable?
Ω(n log n) comparisons in the worst case?hardModel any comparison sort as a decision tree. Each internal node is one comparison, like "is a[i] < a[j]?", with a branch per outcome, and each leaf is a final ordering. To be correct on every input of n distinct values, the algorithm must be able to produce all n! permutations, so the tree needs at least n! leaves.
A binary tree of height h has at most 2ʰ leaves, so 2ʰ ≥ n!, which gives h ≥ log₂(n!). The height is the worst-case number of comparisons. By Stirling's approximation, log₂(n!) is Θ(n log n); a quick argument is that the larger half of the factors are each at least n/2, so n! ≥ (n/2)^(n/2).
So merge sort and heapsort are asymptotically optimal comparison sorts, and a similar argument bounds the average case too.
The bound only covers algorithms that learn about the input by comparing elements. Counting sort and radix sort read key values or digits directly, so they can run in linear time when the key range or key length is bounded.
n! permutationsh ≥ log₂(n!), which is Θ(n log n)Likely follow-up: How many comparisons do you need to find both the minimum and maximum of n items? · Does the bound apply to sorting integers from a small known range?
A trie (prefix tree) stores strings character by character. Each node represents a prefix and has children keyed by the next character, plus an end-of-word flag, because a word like "car" can also be a prefix of "card". In Python each node is often just a dict of children.
Insert and exact search walk one node per character, O(L) for a word of length L, no matter how many words are stored. The real advantage is prefix queries: "does any word start with pre?" is also O(L), and collecting every completion is a DFS from that node. A hash set cannot answer prefix questions without scanning every key.
Use cases: autocomplete, spell checking, word games like Word Search II (prune the grid DFS as soon as the path is not a prefix), and longest-prefix matching as in IP routing tables.
The downside is memory: many small nodes with pointers, especially with fixed 26-slot child arrays. Compressed tries (radix trees) merge single-child chains to save space. For plain membership checks, a hash set is simpler and usually faster.
O(L) for word length LLikely follow-up: How would you build autocomplete that returns the top 3 suggestions? · How does a trie speed up Word Search II?
Union-find tracks a partition of elements into disjoint sets with two operations: find(x) returns the representative (root) of x's set, and union(a, b) merges two sets. Each set is a tree stored in a parent array, where a root is its own parent.
Naively, trees can turn into long chains and find becomes O(n). Two optimizations fix that:
O(log n).find, point every visited node straight at the root, flattening the tree for next time.Either one alone gives roughly O(log n) per operation. With both, operations are amortized O(α(n)), where α is the inverse Ackermann function, at most 4 for any realistic n, so effectively constant.
Uses: counting connected components, detecting a cycle in an undirected graph (an edge whose endpoints already share a root closes a cycle), Kruskal's minimum spanning tree, and grouping problems like accounts merge. It handles edges arriving online, but it cannot split sets apart.
class DSU:
def __init__(self, n):
self.parent, self.size = list(range(n)), [1] * 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, a, b):
ra, rb = self.find(a), self.find(b)
if ra == rb: return False # already connected
if self.size[ra] < self.size[rb]: ra, rb = rb, ra
self.parent[rb] = ra # union by size
self.size[ra] += self.size[rb]
return Truefind returns the root; union merges two setsfindO(α(n)), effectively constantLikely follow-up: How does Kruskal's algorithm use union-find? · Why can union-find not detect cycles in a directed graph?
The two cases need different rules, and mixing them up is a classic mistake.
Undirected: run DFS or BFS and remember each vertex's parent. Every edge appears in both directions, so seeing the parent again is not a cycle; reaching any other already-visited vertex is. Union-find also works: if an edge's endpoints already share a root, that edge closes a cycle. A handy sanity check is that a connected undirected graph is acyclic exactly when it has V - 1 edges.
Directed: "already visited" is not enough, because two paths can reach the same vertex without any cycle, as in a diamond. I use DFS with three colors: white (unvisited), gray (on the current recursion path) and black (finished). An edge to a gray vertex is a back edge, which means a cycle; an edge to a black vertex is fine. Alternatively, run Kahn's topological sort: if it cannot output all V vertices, there is a cycle.
All of these are O(V + E) time, and I start a search from every unvisited vertex so disconnected components are covered.
VO(V + E) timeLikely follow-up: How would you handle parallel edges in the undirected case? · How would you return the vertices that form the cycle?
A plain BST's operations cost O(h), and inserting keys in sorted order produces a chain with h = n, so everything degrades to O(n). Self-balancing trees restore balance with rotations, local pointer rearrangements that preserve BST order, and guarantee O(log n) search, insert and delete.
1.44 log₂ n at worst, so lookups are very fast. The price is more rebalancing work on inserts and deletes.2 log₂ n, looser than AVL, but an insert needs at most two rotations and a delete at most three, so writes are cheaper.Rule of thumb: AVL for read-heavy workloads, red-black for mixed ones. Red-black trees back Java's TreeMap and are the usual implementation of C++ std::map.
TreeMap is red-blackLikely follow-up: Walk me through a left rotation. · Why would a B-tree beat both for a database index?
n elements take O(n) rather than O(n log n)?hardPushing elements one at a time costs O(log n) each, O(n log n) in total. Bottom-up heapify (Floyd's method) instead treats the array as a complete tree already and sifts down every non-leaf node, from the last one at index n // 2 - 1 back to the root.
The key is that sifting down costs at most the node's height, not log n, and most nodes are near the bottom. About half the nodes are leaves and do no work, a quarter have height 1, an eighth height 2, and so on. The total is about n/4 · 1 + n/8 · 2 + n/16 · 3 + ..., which is n times the series Σ h / 2^(h+1), and that series converges to 1. So building is O(n).
Building by sift-up gets no such benefit, because the many bottom nodes are the ones that may travel farthest.
So heapq.heapify(list) is O(n), and top-k code often heapifies once instead of pushing in a loop. Heapsort is still O(n log n), since its n pops cost O(log n) each.
n//2 - 1 to 0log nΣ h / 2^(h+1) converges, so the total is O(n)heapq.heapify is O(n); heapsort is still O(n log n)Likely follow-up: Why is building a heap by repeated sift-up O(n log n)? · How does heapsort use an in-place max-heap?
O(log n)?midPlain binary search stops at any match, so I change it to find a boundary instead of a value. The cleanest tool is a lower-bound search: the first index whose value is >= target. I use a half-open range [lo, hi) with hi = len(a). If a[mid] < target, the answer lies to the right, so lo = mid + 1; otherwise mid might be the answer, so hi = mid. The loop ends when lo == hi, which is the boundary.
> target (the upper bound), minus one.Each search is O(log n), so the whole thing stays O(log n) even when every element equals the target. Finding one match and scanning outward would degrade to O(n).
These are exactly Python's bisect_left and bisect_right, and the same template counts occurrences (upper - lower) or finds the insert position.
def bound(a, target, upper=False):
# first index with a[i] >= target (or a[i] > target when upper=True)
lo, hi = 0, len(a)
while lo < hi:
mid = (lo + hi) // 2
if a[mid] < target or (upper and a[mid] == target):
lo = mid + 1
else:
hi = mid
return lo
def search_range(a, target):
first, last = bound(a, target), bound(a, target, upper=True) - 1
return [first, last] if first <= last else [-1, -1][lo, hi); hi = mid keeps the candidateO(log n) even if all values are duplicatesbisect_left and bisect_rightLikely follow-up: How would you count the occurrences of a target? · How would you find the insert position that keeps the array sorted?
Sometimes you do not search the array; you search the range of possible answers. It works when feasibility is monotonic: if answer x works, every larger (or every smaller) answer works too. The answers then look like no, no, no, yes, yes, and binary search finds the first yes.
Example, Koko eating bananas: given piles and h hours (at least one hour per pile), eating at speed k per hour, one pile at a time, find the minimum k that finishes in time. Pile p takes ceil(p / k) hours, and if speed k finishes in time, any faster speed does too. So I binary search k over [1, max(piles)], and each check sums the hours in O(n). Total: O(n log M), where M is the largest pile.
The same template solves shipping packages within D days, splitting an array into k parts to minimize the largest sum, and integer square root.
The recipe: bound the answer space, write a feasible(x) check, confirm it is monotonic, then find the boundary with the lower-bound template.
def min_eating_speed(piles, h):
def hours(k): # total hours at speed k
return sum((p + k - 1) // k for p in piles)
lo, hi = 1, max(piles)
while lo < hi: # smallest feasible k
mid = (lo + hi) // 2
if hours(mid) <= h:
hi = mid # feasible: try slower
else:
lo = mid + 1 # too slow: go faster
return lofeasible(x) check, usually O(n)[1, max(piles)]O(n log M) for answer range MLikely follow-up: How would you split an array into k subarrays minimizing the largest sum? · How do you choose the bounds of the search range?
O(n²) and then in O(n log n)?hardA subsequence keeps order but may skip elements. The O(n²) DP: let dp[i] be the length of the longest increasing subsequence ending at index i. For every earlier j with a[j] < a[i], dp[i] = max(dp[i], dp[j] + 1). The answer is max(dp), and back-pointers can reconstruct the sequence.
The O(n log n) method keeps tails, where tails[k] is the smallest possible tail of an increasing subsequence of length k + 1 seen so far. tails stays sorted, so for each x I binary search for the first tail >= x. If none exists, x extends the longest subsequence and is appended; otherwise it replaces that tail, since a smaller tail leaves more room to grow. The answer is len(tails).
Two cautions: tails is not itself a valid subsequence, only its length is meaningful; and bisect_left gives strictly increasing, while bisect_right gives non-decreasing. For [10, 9, 2, 5, 3, 7, 101, 18], the LIS length is 4, for example [2, 3, 7, 18].
from bisect import bisect_left
def length_of_lis(nums):
tails = [] # tails[k] = smallest tail of an increasing run of length k+1
for x in nums:
i = bisect_left(tails, x)
if i == len(tails):
tails.append(x) # x extends the longest run
else:
tails[i] = x # x is a smaller tail for length i+1
return len(tails)dp[i] = LIS ending at i: O(n²)tails[k] = smallest tail for length k + 1tailstails is not the actual subsequencebisect_left strict; bisect_right non-decreasingLikely follow-up: How would you reconstruct the actual subsequence in O(n log n)? · How does LIS solve the Russian doll envelopes problem?
Let dp[i][j] be the LCS length of the first i characters of a and the first j characters of b, with row and column 0 set to 0 for empty prefixes. Then:
a[i-1] == b[j-1], that character extends a common subsequence: dp[i][j] = dp[i-1][j-1] + 1.dp[i][j] = max(dp[i-1][j], dp[i][j-1]).The answer is dp[m][n], in O(m · n) time and space. Each row depends only on the previous one, so the length alone needs just O(min(m, n)) space. To recover the subsequence itself, keep the full table and walk back from dp[m][n], moving diagonally on matches.
For "abcde" and "ace", the LCS is "ace", length 3.
I clarify subsequence versus substring: a longest common substring must be contiguous, so on a mismatch its cell resets to 0 instead of taking the max. LCS underlies diff tools, and edit distance is a close relative with insert, delete and replace costs.
dp[i][j] over prefixes; row and column 0 are 0O(m · n) time; rolling rows cut spaceLikely follow-up: How would you compute the edit distance between two strings? · How would you print the LCS, not just its length?
Given items with weights and values and a capacity W, choose the subset with maximum total value that fits. "0/1" means each item is taken whole or not at all. Greedy by value-to-weight ratio fails here; it is only correct for the fractional version.
Define dp[i][c] as the best value using the first i items within capacity c. For each item there are two choices: skip it (dp[i-1][c]) or, if it fits, take it (dp[i-1][c - w] + v), and I keep the max. That is O(n · W) time.
Each row reads only the previous one, so I compress to a 1D array and iterate capacity downwards. Going down guarantees dp[c - w] still holds the previous row's value, so each item is used at most once. Iterating upwards would let an item be reused, which is exactly the unbounded knapsack.
It is pseudo-polynomial because O(n · W) is polynomial in the numeric value W, which can be exponential in the number of bits needed to write it. The general problem is NP-hard.
def knapsack(weights, values, capacity):
dp = [0] * (capacity + 1) # dp[c] = best value within c
for w, v in zip(weights, values):
for c in range(capacity, w - 1, -1): # downwards: each item once
dp[c] = max(dp[c], dp[c - w] + v)
return dp[capacity]max(dp[c], dp[c - w] + v)O(n · W) is pseudo-polynomial; NP-hard in generalLikely follow-up: How would you recover which items were chosen? · How does partition equal subset sum reduce to knapsack?
Beyond &, |, ^ (xor), ~ and the shifts << and >>, the tricks that come up most:
i: (x >> i) & 1, x | (1 << i), x & ~(1 << i), x ^ (1 << i).x & (x - 1). Repeating it until zero counts set bits, and x > 0 and (x & (x - 1)) == 0 tests for a power of two.x & -x, the core of Fenwick trees.a ^ a = 0 and a ^ 0 = a, so XOR-ing every element finds the one value that appears once when all others appear twice, in O(1) space.Python specifics: integers never overflow, and negatives act like infinitely sign-extended two's complement, so mask with & 0xFFFFFFFF to emulate 32-bit behavior. int.bit_count() (Python 3.10+) counts set bits.
x & (x - 1) clears the lowest set bitx > 0 and (x & (x-1)) == 0x & -x isolates the lowest set bitLikely follow-up: How would you find the two numbers that appear once when all others appear twice? · How would you count the set bits of every number from 0 to n?
The answer is the maximum number of meetings in progress at the same moment. Two standard O(n log n) approaches:
Both cost O(n log n) for sorting and O(n) space.
I clarify the boundary rule: if one meeting ends at 10 and another starts at 10, they can usually share a room, so the reuse check is end <= start. The simpler variant, "can one person attend every meeting?", just sorts by start and checks that no meeting starts before the previous one ends.
O(n log n) time, O(n) spaceLikely follow-up: How would you report which meeting goes in which room? · How would you solve it without sorting if times are small integers?
Neither compares elements, so the Ω(n log n) comparison lower bound does not apply.
Counting sort handles integer keys in a known range of size k. Count the occurrences of each key, turn the counts into prefix sums so each key knows where its block starts, then copy elements into those positions in input order, which keeps the sort stable. That is O(n + k) time and space: excellent when k is about n or smaller, hopeless when keys span a huge range like all 64-bit integers.
Radix sort handles larger keys one digit at a time. LSD radix sort goes from the least significant digit to the most, using a stable counting sort per digit, so the order from earlier digits survives later passes. With d digits in base b, it is O(d · (n + b)), which is linear in n for fixed-width integers.
The trade-offs: extra memory, keys must be integers or fixed-length strings, and for small or general inputs a good comparison sort often wins on constants.
n log n bound does not applyO(n + k) for key range kO(d · (n + b))Likely follow-up: Why must each digit pass in LSD radix sort be stable? · How would you sort a million 32-bit integers as fast as possible?
k in O(n) time?hardBrute force takes the max of each window, O(n · k). A heap with lazy removal of expired entries gives O(n log n). The optimal approach is a monotonic deque of indices whose values decrease from front to back.
For each index i:
<= i - k).<= the new value; they can never be a window maximum again, because the new element is at least as large and stays in the window longer.i onto the back.i >= k - 1, the front of the deque holds the current window's maximum.Each index is pushed and popped at most once, so the total is O(n) time and O(k) space for the deque. Storing indices rather than values is what makes the expiry check possible.
The same structure helps DP problems where each state needs the maximum of the previous k states.
O(n)Likely follow-up: How would you adapt it to the sliding window minimum? · Why store indices rather than values in the deque?
Heapsort runs in two phases on the array itself:
O(n).O(log n) each. After n - 1 rounds the array is sorted ascending.That is O(n log n) in the best, average and worst case with O(1) extra space, a stronger guarantee than quicksort's O(n²) worst case. It is not stable.
Quicksort usually wins in practice because of cache behavior: partitioning scans memory sequentially, while sift-down jumps from index i to 2i + 1, touching memory far apart in a large heap. Heapsort also tends to do more comparisons and swaps.
That is why introsort, the usual C++ std::sort, runs quicksort and switches to heapsort only when recursion gets too deep, combining quicksort's speed with a guaranteed O(n log n). Heapsort alone suits cases where memory is tight and worst-case time matters.
O(n)O(n log n) in all cases, O(1) extra spaceLikely follow-up: Why does heapsort use a max-heap to sort in ascending order? · What triggers introsort to switch to heapsort?
Divide and conquer splits a problem into independent subproblems, solves them recursively, and combines the results. Merge sort, quicksort, binary search and fast multiplication algorithms all follow this shape. Unlike DP, the subproblems do not overlap, so there is nothing to cache.
The running time usually looks like T(n) = a · T(n / b) + f(n): a subproblems of size n / b, plus f(n) to split and combine. The master theorem compares f(n) with n^(log_b a), the total work at the leaves:
f(n) grows polynomially slower, the leaves dominate: Θ(n^(log_b a)).Θ(n^(log_b a) · log n).f(n) grows polynomially faster (and meets a regularity condition), the root dominates: Θ(f(n)).Examples: merge sort, 2T(n/2) + n, is Θ(n log n); binary search, T(n/2) + 1, is Θ(log n); Karatsuba, 3T(n/2) + n, is about Θ(n^1.585). Recurrences like T(n - 1) + n do not fit the form, so I sum them directly or draw a recursion tree.
T(n) = a · T(n/b) + f(n)f(n) with n^(log_b a)Θ(n log n); binary search Θ(log n)Likely follow-up: What is the complexity of T(n) = 2T(n/2) + n²? · How does the recurrence explain quicksort's O(n²) worst case?
No questions match that filter.
Prefer multiple choice? All 27 Data Structures & Algorithms MCQs with answers →