The 60 algorithms questions interviewers actually ask, with direct answers, runnable code, Big-O reasoning, and what the key signal is. Grouped for freshers, intermediate, and experienced rounds.
60 questions with answersKey Takeaways
An algorithm is a finite sequence of well-defined, unambiguous instructions that takes an input and produces a correct output in a bounded number of steps. Wikipedia's Algorithm article traces the word to the ninth-century mathematician al-Khwarizmi and captures the properties every interviewer expects you to respect: definiteness, finiteness, and effectiveness. In coding interviews, algorithm questions rarely ask you to invent something new. They check whether you can map a problem to a known pattern (sorting, binary search, two pointers, hashing, recursion, graph traversal, dynamic programming), reason about its time and space cost in Big-O terms, and write correct code while explaining your thinking. This page collects the 60 questions that come up most, each with a direct answer and, where it helps, runnable code. Increasingly the first round runs as a live AI coding interview, so pair this bank with our AI coding interview prep for the format side.
Watch: Intro to Algorithms: Crash Course Computer Science #13
Video: Intro to Algorithms: Crash Course Computer Science #13 (CrashCourse, YouTube)
Test yourself and earn a certificate
10 quick questions. Score 70%+ to download your Algorithms certificate.
The fundamentals every entry-level round checks. If any answer here surprises you, that's your study list.
An algorithm is a finite, ordered set of unambiguous steps that transforms an input into a correct output in a bounded amount of work. It's a recipe a computer can follow with no room for interpretation, where each instruction is precise enough to execute the same way every time you run it.
Every valid algorithm has a few properties: it's finite (it ends), definite (each step is precise), it takes zero or more inputs and produces at least one output, and each step is effective (basic enough to actually perform). the question needs the definition plus these properties, not just a vague 'set of steps'.
Key point: Leading with 'finite, unambiguous steps' and naming one or two properties beats a hand-wavy answer. This is the warm-up that sets the tone.
Big-O describes how an algorithm's running time or memory grows as the input size grows, ignoring constants and lower-order terms so you can compare approaches at scale. O(n) means the work grows linearly with input size; O(n squared) means it grows with the square, so ten times the input is a hundred times the work.
It matters because it predicts behavior at scale. An O(n squared) solution that's fine for 100 items crawls at a million. Interviewers ask for Big-O to check that you can reason about scalability instead of only testing on small inputs.
# O(n): one pass
for x in items:
process(x)
# O(n squared): a pass inside a pass
for a in items:
for b in items:
compare(a, b)Key point: Say what n is before you give the complexity. 'O(n) where n is the number of elements' shows you know what you're counting.
Watch a deeper explanation
Video: Big-O notation in 5 minutes (Michael Sambol, YouTube)
From best to worst for large inputs: O(1) constant, O(log n) logarithmic, O(n) linear, O(n log n) linearithmic, O(n squared) quadratic, and O(2 to the n) exponential. Each class grows faster than the one before it, and the practical goal in most interviews is to move a solution down this list, from quadratic toward linear or log-linear.
The gap widens fast. At a thousand elements, an O(log n) algorithm takes about ten steps while O(n squared) takes a million. That's why choosing the right class matters far more than shaving constants off a slow one.
How common complexities scale at n = 1000
Approximate step counts for an input of 1000 elements (log scale). This is why complexity class beats micro-optimization.
| Class | Name | Example |
|---|---|---|
| O(1) | Constant | Array index lookup |
| O(log n) | Logarithmic | Binary search |
| O(n) | Linear | Scanning a list |
| O(n log n) | Linearithmic | Merge sort, quicksort average |
| O(n squared) | Quadratic | Nested-loop comparison |
Time complexity measures how the number of operations grows with input size; space complexity measures how the extra memory grows beyond the input itself. Both are expressed in Big-O, and they often trade against each other, so a faster algorithm frequently costs more memory and a memory-lean one runs slower.
A hash-set solution to 'find duplicates' is O(n) time but O(n) space, while a sort-first approach is O(n log n) time but O(1) extra space. Naming both, and the trade between them, is what a strong answer sounds like.
An array stores elements in contiguous memory, giving O(1) random access by index but O(n) insertion or deletion in the middle because elements must shift. A linked list stores nodes with pointers, giving O(1) insertion or deletion once you're at a node but O(n) access because you must walk from the head.
Arrays win when you index a lot and rarely insert in the middle; linked lists win when you insert and delete often and access sequentially. This is a classic trade-off question.
| Operation | Array | Linked list |
|---|---|---|
| Access by index | O(1) | O(n) |
| Insert/delete at front | O(n) | O(1) |
| Insert/delete in middle | O(n) | O(n) to find, O(1) to splice |
| Memory layout | Contiguous | Scattered nodes with pointers |
Key point: The follow-up is 'when would you pick each?'. Have the 'lots of random access vs lots of middle inserts' answer ready.
A stack is last in, first out (LIFO): you push and pop from the same end, so the most recent item comes out first. A queue is first in, first out (FIFO): you add at the back and remove from the front, so items come out in arrival order.
Stacks model undo history, function call frames, and expression parsing. Queues model task scheduling and breadth-first traversal. Both offer O(1) push and pop with the right underlying structure.
stack = []
stack.append(1); stack.append(2)
stack.pop() # 2 (LIFO)
from collections import deque
queue = deque()
queue.append(1); queue.append(2)
queue.popleft() # 1 (FIFO)Linear search checks every element until it finds the target, running in O(n) and working on any list. Binary search repeatedly halves a sorted range by comparing the middle element to the target, running in O(log n) but requiring the data to be sorted first.
The catch the key signal is: binary search only works on sorted data, so if you have to sort first, the O(n log n) sort can dominate for a single lookup. It pays off when you search the same sorted data many times.
def binary_search(arr, target):
lo, hi = 0, len(arr) - 1
while lo <= hi:
mid = (lo + hi) // 2
if arr[mid] == target:
return mid
if arr[mid] < target:
lo = mid + 1
else:
hi = mid - 1
return -1Key point: Watch the boundary conditions: mid computation and the lo <= hi loop guard are the two places candidates introduce off-by-one bugs.
Watch a deeper explanation
Video: Binary Search Algorithm in 100 Seconds (Fireship, YouTube)
Recursion is a function calling itself to solve a smaller version of the same problem until it reaches a case simple enough to answer directly. It needs two parts: a base case that stops the recursion and returns without another call, and a recursive case that reduces the problem toward the base case.
Without a base case, recursion never stops and overflows the call stack. Every recursive solution has an iterative equivalent, and the trade is readability (recursion often reads cleaner) against the call-stack space that iteration avoids.
def factorial(n):
if n <= 1: # base case
return 1
return n * factorial(n - 1) # recursive case
factorial(5) # 120Key point: Always The base case first. Interviewers watch whether you handle termination before writing the recursive call.
Watch a deeper explanation
Video: Algorithms: Recursion (HackerRank, YouTube)
Choose iteration when the recursion depth could be large enough to overflow the stack, when performance matters (function-call overhead adds up), or when the loop version is just as readable. Choose recursion when the problem is naturally recursive (trees, divide and conquer) and the depth is bounded.
Some languages optimize tail recursion into a loop, but Python doesn't, so deep recursion there is a real risk. Converting recursion to iteration with an explicit stack is a common follow-up.
Bubble sort repeatedly steps through the list, comparing adjacent pairs and swapping them if they're out of order, so the largest unsorted element 'bubbles' to the end on each pass. After k passes the last k elements are in their final places. It's O(n squared) time and O(1) space because it sorts in place.
Nobody uses it in production, but interviewers ask it because it's the simplest sort to reason about. Mention that an early-exit flag (stop if a pass makes no swaps) makes it O(n) on already-sorted input.
def bubble_sort(arr):
n = len(arr)
for i in range(n):
swapped = False
for j in range(n - 1 - i):
if arr[j] > arr[j + 1]:
arr[j], arr[j + 1] = arr[j + 1], arr[j]
swapped = True
if not swapped: # already sorted
break
return arrInsertion sort builds a sorted section one element at a time, taking each new element and shifting the larger already-sorted elements to the right to make room before dropping it into place. It's O(n squared) in the worst case but O(n) on nearly-sorted data because few shifts are needed.
It shines on small or almost-sorted inputs, which is why real sorting libraries switch to insertion sort for tiny subarrays inside a larger merge or quicksort. It's also stable and sorts in place.
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]
j -= 1
arr[j + 1] = key
return arrHashing runs a key through a hash function that produces an index into an array of buckets, so you can store and retrieve a value in average O(1) time without scanning. A hash table (dict, map) is built on this idea.
Two keys can hash to the same bucket, a collision, handled by chaining (a list per bucket) or open addressing (probe for the next free slot). Good hash functions spread keys evenly; a bad one degrades lookups toward O(n).
Key point: The follow-up is almost always 'what happens on a collision?'. Naming chaining or open addressing is the difference between memorized and understood.
Two main strategies. Chaining stores a small list (or tree) at each bucket, so colliding keys share a bucket and lookup walks the short chain. Open addressing keeps one entry per bucket and, on collision, probes other slots (linear, quadratic, or double hashing) until it finds a free or matching one.
Both keep average lookups near O(1) as long as the load factor stays low, which is why hash tables resize (rehash into a bigger array) once they fill past a threshold.
| Strategy | Idea | Trade-off |
|---|---|---|
| Chaining | List per bucket | Simple, extra memory per node |
| Open addressing | Probe for a free slot | Cache-friendly, sensitive to load factor |
| Resize / rehash | Grow and re-insert | Amortized O(1), occasional pause |
Two pointers use two indices moving through a sequence to solve a problem in one pass instead of nested loops, which often turns an O(n squared) approach into O(n). The two pointers can start at both ends and move inward, or move at different speeds through the same sequence, depending on the problem.
The classic use is finding a pair that sums to a target in a sorted array: point at both ends, and move the left pointer right when the sum is too small or the right pointer left when it's too big. That turns an O(n squared) search into O(n).
def two_sum_sorted(arr, target):
lo, hi = 0, len(arr) - 1
while lo < hi:
s = arr[lo] + arr[hi]
if s == target:
return (lo, hi)
if s < target:
lo += 1
else:
hi -= 1
return NoneSwap characters from both ends moving inward, or use the language's slice or built-in reverse. It's O(n) time because you touch each character once. In-place swapping on a mutable array is O(1) extra space; building a new reversed string is O(n) space.
The interviewer usually wants the two-pointer version to see you handle indices, not just the one-liner. Volunteer both: 'slicing is text[::-1], and here's the manual two-pointer version.'
def reverse(chars):
lo, hi = 0, len(chars) - 1
while lo < hi:
chars[lo], chars[hi] = chars[hi], chars[lo]
lo += 1
hi -= 1
return chars
reverse(list('interview'))Scan the array once, tracking the largest value seen so far and updating it whenever you find something bigger. It's O(n) time and O(1) space, and you can't do better than O(n) here because you must look at every element at least once to be sure none of them is larger.
The edge case worth naming is the empty array: decide whether to return None, raise an error, or use negative infinity as the starting value. Interviewers plant this to see if you handle empty input.
def find_max(arr):
if not arr:
return None # handle empty input
best = arr[0]
for x in arr[1:]:
if x > best:
best = x
return bestCompare characters from both ends moving inward with two pointers; if any pair differs, it's not a palindrome, and if the pointers meet without a mismatch it is. It's O(n) time because you touch each character once, and O(1) extra space because you only track two indices.
Ask about the rules first: should it ignore case, spaces, and punctuation? 'Race car' is a palindrome only if you normalize it. Clarifying that before coding is exactly the habit the technical value is.
def is_palindrome(s):
lo, hi = 0, len(s) - 1
while lo < hi:
if s[lo] != s[hi]:
return False
lo += 1
hi -= 1
return TrueNaive recursion recomputes the same values exponentially, O(2 to the n): avoid it. Memoized recursion or a bottom-up loop both compute each value once, O(n) time. The iterative version keeps only the last two values, so it's O(n) time and O(1) space, which is the answer to give.
This question exists to expose the difference between exponential and linear solutions and to introduce dynamic programming, so name all three and explain why the naive one is bad.
def fib(n):
a, b = 0, 1
for _ in range(n):
a, b = b, a + b
return a # O(n) time, O(1) spaceKey point: Volunteering 'the naive recursion is O(2 to the n) because it recomputes subproblems' before writing the fast version signals real understanding.
Put elements into a set as you scan; if you ever try to add one already present, there's a duplicate. It's O(n) time and O(n) space. The alternative is sorting first, then checking adjacent pairs, which is O(n log n) time but O(1) extra space.
This is a clean time-versus-space question: the hash-set approach spends memory to save time, the sort approach does the reverse. Naming both trade-offs is the strong answer.
def has_duplicate(arr):
seen = set()
for x in arr:
if x in seen:
return True
seen.add(x)
return FalseLoop from 1 to n and, for each number, print 'Fizz' for multiples of 3, 'Buzz' for multiples of 5, 'FizzBuzz' for multiples of both, and the number itself otherwise. It's O(n) time and trivial once you order the checks correctly or build the output string by appending each word.
Interviewers ask it as a fast filter: it catches people who can't turn simple logic into working code. The trap is checking 3 and 5 before checking 15; test the combined case first or build the string.
for i in range(1, 16):
out = ''
if i % 3 == 0: out += 'Fizz'
if i % 5 == 0: out += 'Buzz'
print(out or i)A stable sort keeps equal elements in their original relative order after sorting. If two records have the same sort key, the one that appeared first in the input still appears first in the output, so ties are broken by original position rather than shuffled arbitrarily.
Stability matters for multi-level sorting: sort by a secondary key, then by the primary key, and a stable sort preserves the secondary order within ties. Merge sort and insertion sort are stable; plain quicksort and heapsort are not.
A data The technical sequence is a way to organize and store data (array, list, tree, hash table); an algorithm is a step-by-step procedure that operates on data to produce a result. They pair up: the right data structure makes an algorithm fast.
The connection is the point. Binary search needs a sorted array. Breadth-first search needs a queue. Choosing the data The technical sequence is often the real decision behind choosing the algorithm.
For candidates with working experience: sorting internals, graph traversal, and the pattern-recognition that separates coders from problem-solvers.
Merge sort splits the array in half repeatedly until each piece has one element, then merges the pieces back in sorted order. The splitting creates about log n levels, and merging every level touches all n elements, so total work is O(n log n).
It's stable and has a guaranteed O(n log n) worst case, but it needs O(n) extra space for the merge buffers. That predictable worst case is why it's used where quicksort's O(n squared) risk is unacceptable.
def merge_sort(arr):
if len(arr) <= 1:
return arr
mid = len(arr) // 2
left = merge_sort(arr[:mid])
right = merge_sort(arr[mid:])
out, i, j = [], 0, 0
while i < len(left) and j < len(right):
if left[i] <= right[j]:
out.append(left[i]); i += 1
else:
out.append(right[j]); j += 1
return out + left[i:] + right[j:]Key point: The '<=' in the merge comparison is what keeps it stable. Interviewers sometimes ask why '<' would break stability.
Quicksort picks a pivot, partitions the array so smaller elements go to its left and larger ones to its right, then recursively sorts each side. With balanced partitions it's O(n log n) on average and sorts in place using only O(log n) stack space for the recursion, which is why it's often the default array sort.
It degrades to O(n squared) when the pivot is consistently the smallest or largest element, which happens on sorted input with a naive first-element pivot. Randomizing the pivot or using median-of-three makes the bad case vanishingly unlikely.
def quicksort(arr):
if len(arr) <= 1:
return arr
pivot = arr[len(arr) // 2]
left = [x for x in arr if x < pivot]
mid = [x for x in arr if x == pivot]
right = [x for x in arr if x > pivot]
return quicksort(left) + mid + quicksort(right)Key point: The follow-up is 'how do you avoid the worst case?'. Random pivot or median-of-three is the answer they want.
Watch a deeper explanation
Video: Algorithms: Quicksort (HackerRank, YouTube)
Quicksort is usually faster in practice (better cache behavior, sorts in place) and is the default for arrays, but its worst case is O(n squared) and it isn't stable. Merge sort guarantees O(n log n), is stable, and works well on linked lists and external data, but needs O(n) extra space.
The rule of thumb: quicksort for in-memory arrays where average speed matters, merge sort when you need stability, a guaranteed worst case, or you're sorting linked lists or data too big for memory.
| Property | Quicksort | Merge sort |
|---|---|---|
| Average time | O(n log n) | O(n log n) |
| Worst time | O(n squared) | O(n log n) |
| Extra space | O(log n) | O(n) |
| Stable | No | Yes |
| Best for | In-memory arrays | Linked lists, stability, external sort |
BFS explores a graph level by level from a source, using a queue: dequeue a node, visit its unvisited neighbors, enqueue them, and repeat. A visited set prevents revisiting nodes. It runs in O(V + E) for V vertices and E edges.
Because it expands outward in rings, BFS finds the shortest path in an unweighted graph. Use it for shortest hops, level-order tree traversal, and finding all nodes within a distance.
from collections import deque
def bfs(graph, start):
seen = {start}
q = deque([start])
order = []
while q:
node = q.popleft()
order.append(node)
for nbr in graph[node]:
if nbr not in seen:
seen.add(nbr)
q.append(nbr)
return orderDFS goes as deep as possible along one path before backtracking, using recursion or an explicit stack. Like BFS it's O(V + E), but it uses a stack (or the call stack) instead of a queue, so it explores deep before wide.
Use DFS for cycle detection, topological sorting, connected components, and any exhaustive path exploration. Use BFS when you need the shortest path in an unweighted graph. The only structural difference is stack versus queue.
def dfs(graph, start, seen=None):
if seen is None:
seen = set()
seen.add(start)
for nbr in graph[start]:
if nbr not in seen:
dfs(graph, nbr, seen)
return seenKey point: Expect 'when BFS, when DFS?'. Shortest path in an unweighted graph is BFS; exhaustive search or cycle detection is DFS.
Both visit every node in O(V + E). BFS uses a queue and finds shortest unweighted paths, but its memory can balloon because a wide level holds many nodes at once. DFS uses a stack, has lower memory on wide graphs, but can recurse deep enough to overflow and doesn't find shortest paths.
Pick BFS when the technical answer is 'closest' or 'fewest steps'; pick DFS when you need to explore all paths, detect cycles, or order dependencies topologically.
| Aspect | BFS | DFS |
|---|---|---|
| Structure | Queue | Stack / recursion |
| Finds shortest path (unweighted) | Yes | No |
| Memory on wide graphs | High | Low |
| Typical uses | Shortest hops, levels | Cycles, topo sort, components |
Dynamic programming solves a problem by breaking it into overlapping subproblems, solving each once, and reusing the results. It applies when a problem has two properties: overlapping subproblems (the same computations recur) and optimal substructure (the best overall answer is built from best sub-answers).
You implement it top-down with memoized recursion or bottom-up by filling a table. The payoff is turning exponential brute force into polynomial time, as with Fibonacci, coin change, and longest common subsequence.
Key point: the question needs the two properties named explicitly. 'Overlapping subproblems and optimal substructure' is the phrase that signals you know when DP fits.
Watch a deeper explanation
Video: Dynamic Programming - Learn to Solve Algorithmic Problems and Coding Challenges (freeCodeCamp.org, YouTube)
Both are dynamic programming. Memoization is top-down: you write the natural recursion and cache each result so repeated subproblems return instantly. Tabulation is bottom-up: you fill a table starting from the smallest subproblems and build upward with a loop, using no recursion at all. They reach the same answer with the same time complexity.
Memoization is easier to write from a recursive definition and only computes subproblems you actually need. Tabulation avoids call-stack overhead and often allows space optimization by keeping only the last few rows. They have the same time complexity.
from functools import lru_cache
@lru_cache(maxsize=None)
def fib_memo(n): # top-down
if n < 2:
return n
return fib_memo(n-1) + fib_memo(n-2)
def fib_tab(n): # bottom-up
dp = [0, 1]
for i in range(2, n+1):
dp.append(dp[i-1] + dp[i-2])
return dp[n]A greedy algorithm makes the locally best choice at each step and never reconsiders, which is fast but only correct when local optimality leads to global optimality. Dynamic programming considers all relevant subproblems and combines them, which is slower but correct whenever the problem has optimal substructure.
Coin change with arbitrary denominations breaks greedy but works with DP; interval scheduling by earliest finish time works greedily. The interview skill is recognizing which one a problem allows.
| Aspect | Greedy | Dynamic programming |
|---|---|---|
| Decision | Local best, never revisits | Explores subproblems, combines |
| Speed | Usually faster | Usually slower |
| Correctness | Only if greedy-choice holds | Whenever optimal substructure holds |
| Example | Activity selection | Coin change, knapsack |
A sliding window keeps a contiguous range over an array or string and slides it instead of recomputing from scratch, turning many O(n squared) subarray problems into O(n). You expand the window's right edge and shrink from the left when a condition breaks.
It fits problems asking for the best or valid contiguous subarray or substring: longest substring without repeats, minimum window covering a set, maximum sum of size k. The key is updating the running state incrementally as the window moves.
def longest_unique(s):
seen = {}
left = best = 0
for right, ch in enumerate(s):
if ch in seen and seen[ch] >= left:
left = seen[ch] + 1
seen[ch] = right
best = max(best, right - left + 1)
return bestBinary search works on any monotonic predicate, not just sorted arrays. If you can ask 'is a value of x feasible?' and feasibility flips from false to true (or true to false) once, you can binary search over the range of x to find the boundary.
This 'binary search on the answer' pattern solves problems like the minimum capacity to ship packages in D days or the smallest divisor under a threshold. Recognizing the monotonic feasibility check is the whole trick.
def min_feasible(lo, hi, feasible):
while lo < hi:
mid = (lo + hi) // 2
if feasible(mid):
hi = mid # mid works, try smaller
else:
lo = mid + 1 # mid too small
return loKey point: Say 'the feasibility function is monotonic, so I can binary search the answer space.' That sentence tells the interviewer you see the pattern.
Use Floyd's tortoise and hare: two pointers, one moving one step and the other two steps per iteration. If there's a cycle, the fast pointer laps the slow one and they meet; if the fast pointer reaches the end, there's no cycle. It's O(n) time and O(1) space.
The O(1) space is why interviewers like it over the obvious hash-set-of-seen-nodes approach. You can extend it to find the cycle's start by resetting one pointer to the head after they meet.
def has_cycle(head):
slow = fast = head
while fast and fast.next:
slow = slow.next
fast = fast.next.next
if slow is fast:
return True
return FalseDepth-first traversals visit nodes in three orders: preorder (node, left, right) copies or serializes a tree; inorder (left, node, right) yields a binary search tree's values in sorted order; postorder (left, right, node) frees or evaluates children before the parent. Breadth-first (level-order) visits by depth using a queue.
Inorder on a BST giving sorted output is the fact interviewers probe most, because it connects traversal order to the BST property.
def inorder(node, out):
if not node:
return
inorder(node.left, out)
out.append(node.val) # sorted order for a BST
inorder(node.right, out)
return outA binary search tree keeps smaller values in the left subtree and larger values in the right, so search, insert, and delete follow a single path down and run in O(h) where h is the height. A balanced tree has h around log n, giving O(log n) operations.
The catch: inserting sorted data into a plain BST makes it a linked list with O(n) operations. Self-balancing variants (AVL, red-black) rotate to keep height logarithmic, which is why production maps use them.
Key point: The expected follow-up is 'what if the tree becomes unbalanced?'. Naming AVL or red-black trees shows you know the fix.
A heap is a complete binary tree where each parent is smaller than its children (min-heap) or larger (max-heap), stored compactly in an array. It gives O(1) access to the min or max and O(log n) insert and remove, which makes it the backbone of a priority queue.
Use a heap for repeatedly pulling the smallest or largest item: Dijkstra's shortest path, merging k sorted lists, and the top-k pattern (keep a heap of size k). Building a heap from n items is O(n).
import heapq
def k_largest(nums, k):
heap = nums[:k]
heapq.heapify(heap) # min-heap of size k
for x in nums[k:]:
if x > heap[0]:
heapq.heapreplace(heap, x)
return sorted(heap, reverse=True)Keep a min-heap of size k while scanning: push each element, and once the heap exceeds k, pop the smallest. The heap ends holding the k largest, in O(n log k) time and O(k) space. That beats sorting everything (O(n log n)) when k is small.
If k is close to n, just sort. If you only need the single kth element, quickselect averages O(n). Naming the size-k heap and its O(n log k) cost is the answer the question needs.
Quickselect is quicksort that recurses into only one side. Partition around a pivot, and if the pivot lands at position k you're done; otherwise recurse into just the side that contains k. Because you discard half the work each time on average, it's O(n) average instead of O(n log n).
The worst case is O(n squared) with bad pivots, fixed by randomizing the pivot. It's the go-to for 'find the kth smallest or largest' when you don't need the whole thing sorted.
import random
def quickselect(arr, k): # kth smallest, 0-indexed
pivot = random.choice(arr)
lows = [x for x in arr if x < pivot]
highs = [x for x in arr if x > pivot]
eq = len(arr) - len(lows) - len(highs)
if k < len(lows):
return quickselect(lows, k)
if k < len(lows) + eq:
return pivot
return quickselect(highs, k - len(lows) - eq)Count character frequencies in both strings and compare the two counts; if every character appears the same number of times, the strings are anagrams. It's O(n) time and O(1) space for a fixed alphabet. Sorting both strings and comparing them works too, but that's slower at O(n log n) because of the sort.
Clarify the rules first: does case matter, do spaces count? The frequency-count approach is the one to show because it's linear and generalizes to a running comparison.
from collections import Counter
def is_anagram(a, b):
return Counter(a) == Counter(b)Backtracking builds a solution incrementally and abandons a partial path (it backtracks) the moment it can tell that path can't lead to a valid answer. It's a refined brute force that prunes dead branches early instead of exploring them fully, which cuts the search space by a large factor on most problems.
Use it for combinatorial problems: permutations, combinations, subsets, N-queens, Sudoku, and word search on a grid. The template is: choose, explore recursively, then undo the choice before trying the next.
def permutations(nums):
out = []
def backtrack(path, remaining):
if not remaining:
out.append(path[:])
return
for i in range(len(remaining)):
path.append(remaining[i])
backtrack(path, remaining[:i] + remaining[i+1:])
path.pop() # undo the choice
backtrack([], nums)
return outKey point: The 'choose, explore, undo' The technical sequence is what interviewers look for. Forgetting the undo (path.pop) is the classic backtracking bug.
Clarify first: restate the problem, ask about input size and constraints, and check edge cases like empty or single-element input. Then work an example by hand to find the pattern, name a brute-force approach and its complexity, and only then look for a better one.
State your approach before coding, write it while narrating, dry-run one small input, and finish by confirming time and space complexity. The structure matters more than speed: it shows the interviewer how you think.
A repeatable problem-solving sequence
this process even when you don't reach the optimal solution is the technical point. Narrate every step out loud.
Key point: the key point is the sequence, not a memorized answer to a specific problem.
advanced rounds probe algorithmic depth, design judgment, and the ability to reason about correctness and scale. Expect every answer to draw a follow-up.
Dijkstra finds shortest paths from a source in a weighted graph with non-negative edges. It keeps a priority queue of nodes by tentative distance, repeatedly pops the closest unfinalized node, and relaxes its neighbors. With a binary heap it runs in O((V + E) log V).
The hard limit is negative edge weights: Dijkstra can finalize a node too early and get the wrong answer, so use Bellman-Ford (O(VE)) when negatives are possible. For all-pairs shortest paths, Floyd-Warshall is O(V cubed).
import heapq
def dijkstra(graph, src):
dist = {src: 0}
pq = [(0, src)]
while pq:
d, node = heapq.heappop(pq)
if d > dist.get(node, float('inf')):
continue
for nbr, w in graph[node]:
nd = d + w
if nd < dist.get(nbr, float('inf')):
dist[nbr] = nd
heapq.heappush(pq, (nd, nbr))
return distKey point: The follow-up is 'why not Dijkstra with negative edges?'. Explaining that a finalized node can still be improved later is the depth signal.
Dijkstra: single-source shortest paths with non-negative weights, fast at O((V + E) log V). Bellman-Ford: single-source that also handles negative edges and detects negative cycles, slower at O(VE). Floyd-Warshall: all-pairs shortest paths on a dense graph, simple to code at O(V cubed).
The decision comes down to whether weights can be negative and whether you need one source or all pairs. Naming that decision tree is what separates a memorized answer from real judgment.
| Algorithm | Scope | Negative edges | Time |
|---|---|---|---|
| Dijkstra | Single source | No | O((V+E) log V) |
| Bellman-Ford | Single source | Yes (detects neg cycles) | O(VE) |
| Floyd-Warshall | All pairs | Yes (no neg cycles) | O(V cubed) |
Topological sort orders the nodes of a directed acyclic graph so every edge points forward: if A depends on B, B comes first. It only exists for a DAG; a cycle makes ordering impossible, which is how you detect one.
Two implementations: Kahn's algorithm repeatedly removes nodes with in-degree zero using a queue, or a DFS pushes nodes onto a stack after visiting all descendants. Both are O(V + E). Use it for build systems, task scheduling, and dependency resolution.
from collections import deque
def topo_sort(graph, indeg):
q = deque([n for n in graph if indeg[n] == 0])
order = []
while q:
node = q.popleft()
order.append(node)
for nbr in graph[node]:
indeg[nbr] -= 1
if indeg[nbr] == 0:
q.append(nbr)
return order if len(order) == len(graph) else None # None means a cycleUnion-find tracks a collection of disjoint sets with two operations: find (which set does x belong to) and union (merge two sets). With path compression and union by rank, both run in near-constant amortized time, technically the inverse Ackermann function.
Use it for connected components, cycle detection in an undirected graph, and Kruskal's minimum spanning tree, where you union endpoints of the cheapest edges while skipping any edge that would connect an already-joined component.
class DSU:
def __init__(self, n):
self.parent = list(range(n))
def find(self, x):
while self.parent[x] != x:
self.parent[x] = self.parent[self.parent[x]] # path compression
x = self.parent[x]
return x
def union(self, a, b):
self.parent[self.find(a)] = self.find(b)A problem is in NP if a proposed solution can be verified in polynomial time. NP-complete problems are the hardest in NP: every NP problem reduces to them, and no known polynomial algorithm solves any of them. Traveling salesman, subset sum, and graph coloring are examples.
In practice you don't find an exact fast solution; you use approximation algorithms with provable bounds, heuristics, exact solvers on small inputs (branch and bound, dynamic programming over subsets), or accept 'good enough'. Recognizing a problem as NP-complete tells you to stop hunting for a polynomial algorithm.
Key point: The strong move is recognizing NP-hardness early and pivoting to approximation or heuristics instead of promising an exact fast solution.
Amortized analysis measures the average cost per operation across a whole sequence of operations, rather than the worst cost of a single one. It's the honest measure when occasional expensive operations are paid for by many cheap ones, giving a per-operation cost that reflects real usage instead of a rare spike.
The classic example is a dynamic array (Python list). Appending is usually O(1), but when it fills it doubles and copies everything, which is O(n). Because doublings are rare and each element is copied a bounded number of times overall, the amortized cost of append is O(1).
Replace the implicit call stack with an explicit stack (or a queue for BFS-style order). Push the initial state, then loop: pop a state, do its work, and push the sub-states you'd have recursed into. Tail-recursive functions convert to a simple loop.
Why bother: deep recursion overflows the call stack (Python's default limit is around a thousand frames), and an explicit stack lets you control memory and avoid that crash. It also removes per-call overhead on hot paths.
def dfs_iterative(graph, start):
stack, seen, order = [start], {start}, []
while stack:
node = stack.pop()
order.append(node)
for nbr in graph[node]:
if nbr not in seen:
seen.add(nbr)
stack.append(nbr)
return orderNaive substring search re-checks from scratch after each mismatch, which is O(n times m) for text length n and pattern length m. KMP precomputes a prefix table so, on a mismatch, it skips ahead without re-examining characters, giving O(n + m). Rabin-Karp uses a rolling hash to compare in O(1) per position, averaging O(n + m).
The insight KMP exploits is that a partial match already tells you how much of the pattern can still align, so you never move the text pointer backward.
Combine a hash map with a doubly linked list. The map gives O(1) lookup from key to node; the linked list keeps usage order, most-recent at the head. On access, move the node to the head; on insert past capacity, evict the tail.
Both get and put are O(1) because the map avoids scanning and the doubly linked list lets you unlink and relink a node in constant time. In Python, collections.OrderedDict (or functools.lru_cache) gives this for free, but interviewers usually want the hand-built version.
from collections import OrderedDict
class LRUCache:
def __init__(self, capacity):
self.cap = capacity
self.data = OrderedDict()
def get(self, key):
if key not in self.data:
return -1
self.data.move_to_end(key) # mark most-recent
return self.data[key]
def put(self, key, value):
if key in self.data:
self.data.move_to_end(key)
self.data[key] = value
if len(self.data) > self.cap:
self.data.popitem(last=False) # evict LRUKey point: the key point is 'hash map plus doubly linked list' before you reach for OrderedDict. That pairing is the core insight.
When each state depends only on a bounded number of previous rows or columns, you don't need the full table. Keep just those rows and roll them forward. A 2D DP where row i depends on row i minus one collapses to two 1D arrays, or often one that you update in place.
Fibonacci is the tiny example (two variables instead of an n-length array). The same trick takes edit distance and knapsack from O(n times m) space to O(min(n, m)). Interviewers ask this after you write the naive table.
def knapsack(weights, values, cap):
dp = [0] * (cap + 1) # one row instead of a full table
for w, v in zip(weights, values):
for c in range(cap, w - 1, -1): # iterate backward
dp[c] = max(dp[c], dp[c - w] + v)
return dp[cap]An adjacency list stores each node's neighbors, using O(V + E) space and letting you iterate a node's edges in proportion to its degree, which fits sparse graphs (most real graphs). An adjacency matrix uses a V by V grid, O(V squared) space, giving O(1) edge lookup but wasting memory when edges are few.
Use the list for sparse graphs and traversal-heavy work; use the matrix for dense graphs or when you frequently ask 'is there an edge between A and B?' and need constant-time answers.
| Aspect | Adjacency list | Adjacency matrix |
|---|---|---|
| Space | O(V + E) | O(V squared) |
| Edge exists? | O(degree) | O(1) |
| Iterate neighbors | O(degree) | O(V) |
| Best for | Sparse graphs | Dense graphs |
Common ones: x AND (x minus 1) clears the lowest set bit (count set bits by looping this), XOR finds the single unpaired number in a list because a XOR a is zero, x AND 1 tests parity, and left or right shifts multiply or divide by powers of two.
The single-number problem is the classic: XOR every element and the duplicates cancel, leaving the unique one in O(n) time and O(1) space. Interviewers use these to check comfort with the binary representation, not obscure trivia.
def single_number(nums):
result = 0
for x in nums:
result ^= x # pairs cancel, unique survives
return result
def count_bits(x):
count = 0
while x:
x &= x - 1 # clear lowest set bit
count += 1
return countRandomized algorithms make random choices during execution to get good expected performance or a simpler implementation. Randomized quicksort avoids the O(n squared) worst case on adversarial input by picking pivots at random, so no single fixed input is reliably bad and an attacker can't craft one that forces the slow path.
Two flavors: Las Vegas algorithms are always correct but have random running time (randomized quicksort), while Monte Carlo algorithms have bounded running time but a small failure probability you can shrink by repeating. Reservoir sampling and randomized primality tests are other examples.
External merge sort. Read the data in chunks that fit in memory, sort each chunk, and write it to a temporary sorted run on disk. Then merge the runs with a k-way merge, keeping only one small buffer per run in memory and streaming the output.
The cost is dominated by disk I/O, not comparisons, so you minimize passes over the data. This is how databases sort large tables and how command-line sort handles files bigger than RAM.
For iterative algorithms, use a loop invariant: state a property true before the loop, show each iteration preserves it, and show that when the loop ends the invariant plus the exit condition gives the answer. For recursive ones, use induction: prove the base case, then assume smaller cases are correct and show the recursive step follows.
For greedy algorithms you additionally argue the greedy choice is safe (an exchange argument). Interviewers ask this to check you can justify code, not just run it against test cases.
Key point: Naming 'loop invariant' or 'induction' explicitly, then applying it to your code, is what a senior correctness answer sounds like.
Clarify scale first, then map-reduce it: each machine counts frequencies for its shard and emits local top candidates, a shuffle groups counts by item, and a reduce sums per item and keeps a global heap of size k. For a streaming, memory-bounded version, use a Count-Min Sketch to approximate frequencies plus a heap of heavy hitters.
The senior points are naming the exact-versus-approximate trade, the network cost of the shuffle, and why a single-machine sort doesn't scale. Structuring the answer as clarify, partition, aggregate, and note the approximation option is what's really being evaluated.
from collections import Counter
import heapq
def top_k_local(shard, k):
counts = Counter(shard) # per-machine count
return heapq.nlargest(k, counts.items(), key=lambda kv: kv[1])
# a reducer merges local (item, count) pairs, sums per item,
# then takes a global nlargest(k) over the merged countsKadane's scans once, keeping a running sum of the best subarray ending at the current position: extend it if that helps, or restart from the current element if the running sum went negative. Track the best value seen along the way. It's O(n) time and O(1) space.
The insight is that a negative running prefix can only hurt what follows, so you drop it. This beats the O(n squared) approach of checking every subarray and is the model answer for the maximum-subarray problem.
def max_subarray(nums):
best = current = nums[0]
for x in nums[1:]:
current = max(x, current + x) # extend or restart
best = max(best, current)
return bestKey point: The follow-up is 'why restart when the running sum is negative?'. Answering that a negative prefix can only drag down later sums shows you get the logic, not just the code.
Start from the data, not instinct. If n is small and bounded (a config list of tens of items), the O(n squared) version is fine and simpler code wins. If n grows with users or data, or this runs in a hot path, profile to confirm it's actually the bottleneck before rewriting.
Only then optimize, and measure the result. Premature optimization adds complexity and bugs for cases that never arrive. The judgment the question needs is 'know your input size and measure', not 'always make it faster'.
Key point: The mature answer resists optimizing on reflex. the decision maps to real input size and profiling, and you sound like someone who's shipped code.
Almost every algorithm decision is a trade between time and space, and knowing which you're spending is the core interview signal. A hash set turns an O(n squared) nested-loop scan into an O(n) pass by spending O(n) extra memory. Sorting first buys you O(n log n) so later steps run in one linear sweep. Recursion is readable but pays for a call stack that iteration avoids. When you The trade out loud, you show you're choosing on merits, not habit. The table below pairs common approaches with what they cost and where each one fits.
| Approach | Time | Extra space | Fits when |
|---|---|---|---|
| Nested-loop scan | O(n squared) | O(1) | Tiny inputs, memory is tight |
| Hashing (set or map) | O(n) | O(n) | You can trade memory for speed |
| Sort then sweep | O(n log n) | O(1) to O(n) | Order helps the later steps |
| Binary search | O(log n) | O(1) | Data is already sorted |
| Recursion / memoization | Problem-dependent | O(depth) stack | Overlapping subproblems |
Prepare in layers, and practice out loud. Most algorithm rounds move from a concept check to a live coding problem to follow-up questions that push on complexity and edge cases, so rehearse each stage rather than only reading answers.
The typical algorithms interview flow
Earlier rounds increasingly run as AI coding interviews. The patterns on this page are what gets probed at every stage.
10 questions, about 6 minutes. Score 70% or higher to earn a shareable certificate.
Hyring builds the AI Coding Interviewer that runs live technical rounds in 20+ languages for 5,000+ hiring teams. These Algorithms questions and scoring notes reflect what actually gets asked and evaluated inside the interviews we host.
See how the AI Coding Interviewer works