Algorithmic Problem-Solving and Data Structure Selection Questions
The higher-order meta-skill of attacking an unfamiliar problem: recognizing problem archetypes and mapping them to known techniques, decomposing under constraints, and choosing, composing, or designing the right data structures to meet specified operation costs (LRU cache, min-stack, ordered maps, disjoint-set/union-find). Covers reasoning about trade-offs between competing structures and approaches, working through medium-to-hard problems methodically, handling problem variations, and communicating an approach before coding. The connective-tissue topic that ties the individual structure and algorithm topics together, rather than any single structure or algorithm.
Explain the difference between a stack and a queue and give a concrete example where each is the right choice. Then show how you would implement a queue using only two stacks (or a stack using only queues), and give the amortized cost per operation.
Sample Answer
Direct answer
A stack is last-in-first-out (LIFO): the most recently added item comes out first. A queue is first-in-first-out (FIFO): items come out in the order they arrived. Use a stack when you need to undo or backtrack in reverse arrival order, such as a browser's back button or a function call stack; use a queue when arrival order must be preserved, such as a task scheduler or a print spooler. You can build a queue out of two stacks: push is O(1) worst case, and pop is amortized (averaged over a sequence of operations) O(1) because each element only ever moves between the two stacks once over its lifetime.
Structured elaboration
| Stack (LIFO) | Queue (FIFO) | |
|---|---|---|
| Order returned | Most recent first | Oldest first |
| Concrete example | Undo history, expression parsing, recursive call stack | Print queue, request processing, breadth-first search frontier |
Queue from two stacks. Keep an in_stack that absorbs pushes and an out_stack that serves pops. Pushing always goes to in_stack in O(1). When a pop or peek is requested and out_stack is empty, drain all of in_stack into out_stack; this reverses the order, so the oldest element (which was at the bottom of in_stack) ends up on top of out_stack, ready to be returned first.
Why the amortized argument holds. Use the aggregate method: over any sequence of n operations, each element is pushed onto in_stack exactly once (cost 1), moved from in_stack to out_stack at most once in its lifetime (cost 1), and popped from out_stack exactly once (cost 1). No element is ever moved more than that, so the total work across the whole sequence is bounded by a constant multiple of n, which is what "amortized O(1) per operation" means, even though any single pop that triggers the drain costs O(n) by itself.
Worked example
class QueueFromStacks:
def __init__(self):
self.in_stack: list[int] = []
self.out_stack: list[int] = []
def push(self, x: int) -> None:
self.in_stack.append(x)
def _transfer(self) -> None:
if not self.out_stack:
while self.in_stack:
self.out_stack.append(self.in_stack.pop())
def pop(self) -> int:
self._transfer()
return self.out_stack.pop()
def peek(self) -> int:
self._transfer()
return self.out_stack[-1]
if __name__ == "__main__":
q = QueueFromStacks()
q.push(1)
q.push(2)
q.push(3)
seq = [q.pop(), q.peek()]
q.push(4)
seq += [q.pop(), q.pop(), q.pop()]
print(seq)
Running this prints [1, 2, 2, 3, 4]. The first pop() triggers a drain (in_stack [1,2,3] becomes out_stack [3,2,1], top popped is 1); peek() then reads 2 for free from the already-drained out_stack; pushing 4 goes straight to in_stack without disturbing out_stack; the remaining pops (2, 3) come from out_stack, and the last pop (4) triggers a second drain since out_stack had emptied.
Complexity
| Push | Pop / peek | |
|---|---|---|
| Worst case (single call) | O(1) | O(n) |
| Amortized (over n calls) | O(1) | O(1) |
Space: O(n) total across the two internal stacks, since every pushed element lives in exactly one of them at any time (no extra space is used beyond storing the n elements themselves).
Edge cases
- Calling
pop()orpeek()on an empty two-stack queue: in the reference implementation,_transfer()leavesout_stackempty when both stacks are empty, sopop()'sself.out_stack.pop()andpeek()'sself.out_stack[-1]both raise an unhandledIndexErrorinstead of failing cleanly. Guard this explicitly, for exampleif not self.in_stack and not self.out_stack: raise IndexError("pop from empty queue")before touchingout_stack, so the caller gets a clear, intentional signal rather than an incidental one. - A single push followed immediately by a pop: the drain moves that one element from
in_stacktoout_stackand it is returned, leaving both stacks empty again, which is the state the empty-queue guard above must handle correctly on the next call.
Trade-offs & pitfalls
The most common confusion is treating "amortized" as "always fast": a single pop can still cost O(n) when it triggers the drain. Note the asymmetry with building a stack out of a single queue by rotating on every push (dequeue-then-requeue the previous elements so the newest sits at the front): that rotation happens on every single push, not just occasionally, so it is genuinely O(n) per push with no amortization to appeal to, unlike the two-stack construction above where the expensive transfer is rare and each element only ever pays for it once.
Rotate an array to the right by k steps in-place, using O(1) extra space (k may exceed the array's length). Explain your approach, and how the same in-place three-reversal trick generalizes: reversing a string in place, or rotating a 2D matrix in place.
Sample Answer
Direct answer
Reverse the whole array, then reverse the first k elements and the remaining n-k elements separately; three linear passes compose into the fully rotated result with no auxiliary array. The same reversal trick generalizes directly: reversing a string in place is the identical two-pointer, swap-from-both-ends routine, and rotating a square matrix 90 degrees in place is a transpose followed by reversing each row, both built on the same in-place-swap primitive as the array rotation.
Structured elaboration
Why three reversals produce a rotation. Reversing the entire array puts every element in fully reversed order. Reversing the first k elements of that reversed array un-reverses exactly the block that should now sit at the front, restoring its original relative order; reversing the remaining n-k elements does the same for the remainder. Normalizing with k %= n handles k values larger than the array's length or equal to zero.
Generalizing to a string. The same in-place two-pointer swap from both ends is exactly what reverses a string, provided the string is held in a mutable container (a list of characters, for example, since Python's own string type is immutable and cannot be reversed truly in place without first converting it).
Generalizing to a square matrix. Transposing swaps matrix[i][j] with matrix[j][i] for every i < j, turning rows into columns. Reversing each row afterward flips left to right. Combined, what was the first column read top to bottom becomes the first row read left to right, which is exactly a 90-degree clockwise turn.
Related in-place-preprocessing techniques (with an honest space caveat). Prefix-sum preprocessing builds an auxiliary array once, in O(n) time, so that any later range-sum query answers in O(1); this trades O(n) extra space for fast queries, so it is not itself an O(1)-extra-space technique, even though it shares this family's "one linear pass, reuse the result" character. Product-except-self, by contrast, genuinely can be done with O(1) extra space beyond the required output array: a first pass fills the output with the running product of everything to each index's left, and a second pass multiplies in the running product of everything to that index's right, needing no separate auxiliary array at all.
Worked example
def rotate_array(nums: list[int], k: int) -> None:
n = len(nums)
if n <= 1:
return
k %= n
if k == 0:
return
def reverse(i, j):
while i < j:
nums[i], nums[j] = nums[j], nums[i]
i += 1
j -= 1
reverse(0, n - 1)
reverse(0, k - 1)
reverse(k, n - 1)
def reverse_string_inplace(chars: list[str]) -> None:
i, j = 0, len(chars) - 1
while i < j:
chars[i], chars[j] = chars[j], chars[i]
i += 1
j -= 1
def rotate_matrix_90_cw_inplace(matrix: list[list[int]]) -> None:
n = len(matrix)
for i in range(n):
for j in range(i + 1, n):
matrix[i][j], matrix[j][i] = matrix[j][i], matrix[i][j]
for row in matrix:
row.reverse()
if __name__ == "__main__":
arr = [1, 2, 3, 4, 5, 6, 7]
rotate_array(arr, 3)
print(arr)
chars = list("hello")
reverse_string_inplace(chars)
print("".join(chars))
m = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
rotate_matrix_90_cw_inplace(m)
print(m)
Running this prints [5, 6, 7, 1, 2, 3, 4], then olleh, then [[7, 4, 1], [8, 5, 2], [9, 6, 3]].
Complexity
rotate_array: time O(n) for the three reversal passes, since they compose additively into a single linear scan rather than multiplying; space O(1) extra, using only the two index pointers inside each reversal call.
reverse_string_inplace: time O(n), one pass with two pointers closing in from both ends; space O(1) extra beyond the mutable character list itself.
rotate_matrix_90_cw_inplace: time O(n2) for an n-by-n matrix, since the transpose visits each of the n2 cells once; space O(1) extra, since both the transpose and the row reversals swap in place with no auxiliary matrix.
Edge cases
- k = 0, or k a multiple of the array's length once normalized via
k %= n:rotate_arraydetects this and returns immediately without performing any reversals, since the array is already in its correct rotated position. - Empty or single-element array or string: both
rotate_array(via itsn <= 1guard) andreverse_string_inplace(viawhile i < jnever firing) return immediately with nothing to do. - A non-square matrix passed to
rotate_matrix_90_cw_inplace: this implementation assumes a square matrix, and a non-square transpose changes the matrix's dimensions, so it cannot be rotated true in place this way.
Trade-offs & pitfalls
Forgetting k %= n for a k larger than the array's length either wastes work or, in a careless implementation, indexes out of range. The transpose-then-reverse-rows trick only works for a square matrix: transposing a non-square matrix changes its dimensions, so a genuinely non-square rotation needs a separate output buffer rather than a true in-place transform. Python's string immutability means a real in-place string reversal needs a mutable container (a list of characters, or a bytearray) first; there is no way to mutate a str object's characters directly.
Given the head of a singly linked list, determine whether it contains a cycle, and if so, return the node where the cycle begins, using O(1) extra space (no visited-set). Explain why moving one pointer twice as fast as the other guarantees they meet if and only if a cycle exists, and how that same meeting point lets you locate the cycle's start.
Sample Answer
Direct answer
Advance one pointer (slow) one step at a time and another (fast) two steps at a time. If the list has no cycle, fast reaches the end first and you can report there is none. If it does have a cycle, fast eventually laps slow and the two meet somewhere inside it. Once they meet, restarting one pointer at the head and advancing both pointers one step at a time makes them meet again exactly at the cycle's start. All of this uses O(1) extra space, no visited set required.
Structured elaboration
Why meeting implies a cycle, and why no meeting implies no cycle. With no cycle, fast strictly gains ground toward a null terminator every step and reaches it in at most n/2 steps; it can never occupy the same node as slow without a cycle to loop back through. Once both pointers are inside a cycle, fast closes the gap to slow by exactly one node per step, because fast gains two steps of distance while slow gains one, a net closing rate of one per step. Since the gap can never exceed the cycle's length, they are guaranteed to meet within one full lap of the cycle.
Why the meeting point locates the cycle's start. Let a be the distance from the head to the cycle's start, c be the cycle's length, and b be the distance from the cycle's start to the meeting point, with 0≤b<c. By the time they meet, slow has traveled a + b steps and fast has traveled exactly twice that, but fast has also gone around the cycle some whole number of extra laps n≥1:
So a and (c - b) differ by a whole number of cycle lengths, meaning a pointer walking one step at a time from the head, and a pointer walking one step at a time from the meeting point, land on the same node after exactly a steps: the cycle's start.
Related applications of the same fast/slow pattern. Removing the n-th node from the end of a list uses the same shape without any cycle involved: advance one pointer n steps first, then move both pointers together; when the front pointer reaches the end, the trailing pointer sits exactly at the node to remove. Finding a duplicate number hidden in an array reframes the array itself as an implicit linked list, where the value stored at each index tells you which index to visit next; a repeated value forces two different positions to point at the same next index, creating a cycle in that implicit list, which Floyd's tortoise-and-hare detects exactly the way it detects a cycle here.
Worked example
class Node:
def __init__(self, val, next=None):
self.val = val
self.next = next
def detect_cycle_start(head):
slow = fast = head
while fast and fast.next:
slow = slow.next
fast = fast.next.next
if slow is fast:
ptr = head
while ptr is not slow:
ptr = ptr.next
slow = slow.next
return ptr
return None
if __name__ == "__main__":
nodes = [Node(i) for i in range(5)] # values 0,1,2,3,4
for i in range(4):
nodes[i].next = nodes[i + 1]
nodes[4].next = nodes[2] # cycle back into node with value 2
start = detect_cycle_start(nodes[0])
print(start.val)
Running this prints 2. In the derivation's terms: a = 2 (two steps from the head, value 0, to the cycle's start, value 2), c = 3 (the cycle 2 to 3 to 4 back to 2 has three edges), and the two pointers first meet at the node with value 3, so b = 1 (one step from the cycle's start to that meeting point). Checking the identity: c - b = 3 - 1 = 2, which equals a, confirming the derivation against this concrete run.
Complexity
Time: O(n). The pointers meet within one full lap of the cycle once both are inside it, and finding the cycle's start afterward takes at most another full lap; both phases are bounded by a constant multiple of the list's length n.
Space: O(1), since only the slow and fast pointers (and later ptr) are held, with no visited set.
Edge cases
- Empty list (
headisNone):fastisNoneimmediately, so the loop never runs and the function correctly returns no cycle. - Single node with no self-link:
fast.nextisNoneon the first check, so the loop exits immediately with no cycle detected. - Single node that cycles to itself:
slowandfastboth land back on that same node on the first iteration, correctly reporting it as the cycle's start.
Trade-offs & pitfalls
A common bug is comparing pointer values instead of pointer identity; when list values can repeat, slow.val == fast.val can be true without slow and fast being the same node, so the check must be slow is fast.
What does it mean for a sorting algorithm to be stable, and why does that matter when you are sorting by a secondary key after already having sorted by a primary one? Name a stable and an unstable sort and say what would break if you used the unstable one in a multi-key sort.
Sample Answer
Direct answer
A sorting algorithm is stable if it preserves the relative order of elements
that compare equal on the sort key. If two records tie on the key you sorted
by, a stable algorithm guarantees the one that came first in the input still
comes first in the output. This matters for multi-key sorts: if you sort by a
secondary key after already sorting by a primary one, stability is what lets
the primary ordering survive as a tiebreaker inside each secondary-key group.
Merge sort and Python's Timsort (the algorithm behind sorted() and list.sort())
are stable; classic in-place selection sort and a typical quicksort are not.
Structured elaboration
Why stability matters for multi-key sorts. The standard trick for sorting
by (primary key, secondary key) without writing a composite comparator is:
sort once by the primary key, then sort the result by the secondary key. This
only produces the correct combined order if the second sort is stable: a
stable sort only reorders elements that actually differ on the secondary key,
so within any group of equal secondary-key values, the primary-key order from
step one is left untouched. An unstable sort makes no such promise: it may
reorder equal-secondary-key elements arbitrarily while grouping them, silently
destroying the primary ordering you already paid to establish.
A stable sort: merge sort, and Timsort (the hybrid merge/insertion sort
used by Python and Java's Collections.sort for objects). Both work by
merging or shifting elements without ever swapping two elements past an equal
one, so ties keep their input order.
An unstable sort: classic in-place selection sort (repeatedly swap the
current position with the position of the next-smallest remaining element).
The swap step moves elements across long distances in the array, including
past other elements with the same key, which is exactly what breaks ties.
Typical in-place quicksort implementations are unstable for the same reason
(the partition step swaps non-adjacent elements).
If you only have an unstable sort available, you can force stability by
attaching the original index to each record and sorting by (key, original_index)
instead of key alone. Ties on key are then broken by index, which exactly
reproduces stable behavior, at the cost of allocating one extra field per record.
Worked example
Take four employee records, already sorted by name (the primary key):
| dept | name |
|---|---|
| Sales | Alvarez |
| Eng | Chen |
| Sales | Diallo |
| Eng | Ito |
Now sort by department (the secondary key). A stable sort must produce:
Eng: Chen, Ito (name order preserved)
Sales: Alvarez, Diallo (name order preserved)
Running this in Python, where sorted() is stable, versus a hand-rolled
unstable selection sort, on the exact same input:
records = [
{"name": "Alvarez", "dept": "Sales"},
{"name": "Chen", "dept": "Eng"},
{"name": "Diallo", "dept": "Sales"},
{"name": "Ito", "dept": "Eng"},
]
stable_result = sorted(records, key=lambda r: r["dept"])
def unstable_selection_sort_by_dept(items):
items = list(items)
n = len(items)
for i in range(n):
min_idx = i
for j in range(i + 1, n):
if items[j]["dept"] < items[min_idx]["dept"]:
min_idx = j
items[i], items[min_idx] = items[min_idx], items[i]
return items
unstable_result = unstable_selection_sort_by_dept(records)
print([(r["dept"], r["name"]) for r in stable_result])
print([(r["dept"], r["name"]) for r in unstable_result])
Output (verified by running this exact code):
[('Eng', 'Chen'), ('Eng', 'Ito'), ('Sales', 'Alvarez'), ('Sales', 'Diallo')]
[('Eng', 'Chen'), ('Eng', 'Ito'), ('Sales', 'Diallo'), ('Sales', 'Alvarez')]
The Eng group comes out identical either way, but the Sales group is
reversed under the unstable sort: Diallo now precedes Alvarez, even though
Alvarez came first alphabetically. That reversal is the bug: any downstream
code relying on "same department, alphabetical order" is now silently wrong,
and the failure is data-dependent, so it will not show up on every input.
Trade-offs & pitfalls
- Stability is a property of the algorithm's specification, not just a given
implementation detail: "quicksort" is not stable by definition, but a
particular library's sort might document stability as a guarantee (check
the docs rather than assuming from the algorithm's name). - The index-as-tiebreaker trick works with any comparison sort, stable or not,
but costs O(n) extra memory for the index field and slightly more comparison
overhead per element; it is the right choice when the language's built-in
sort is unstable by contract (e.g. a raw quicksort library call) and you
cannot swap in a stable one. - A common wrong turn is assuming "the final order looks right on my test
data" is proof of stability; instability is a tie-breaking behavior that
only shows up when there are actual ties, so it hides in datasets with few
duplicate keys and surfaces later at a different data distribution. - Do not confuse stability with sort correctness on the primary sort key
itself: an unstable sort still produces a fully correct order on the key it
was told to sort by, it only fails to preserve unrelated prior ordering
among equal elements.
Compare a contiguous array and a singly linked list on random access, insertion/deletion at head/middle/tail, memory overhead, and cache locality. For a workload that is mostly random reads versus one that is mostly insertions and deletions in the middle, which would you pick and why?
Sample Answer
Direct answer
An array gives O(1) index-based random access and is cache-friendly, because its elements sit in one contiguous block of memory that the CPU can pull into cache together. A singly linked list gives O(1) insertion or deletion once you already hold a reference to the splice point, at the cost of O(n) traversal to reach any given position and extra per-node memory overhead. For a workload that is mostly random reads, pick an array (or dynamic array); for a workload that is mostly insertions and deletions in the middle where you already hold the relevant node reference, a linked list wins.
Structured elaboration
| Dimension | Array | Singly linked list |
|---|---|---|
| Random access by index | O(1) | O(n), must walk from the head |
| Insert/delete at head | O(n), shifts every remaining element | O(1), relink the head reference |
| Insert/delete at tail | O(1) amortized (averaged over a sequence of operations; dynamic array resize) | O(1) only if a tail reference is separately maintained, otherwise O(n) to reach it |
| Insert/delete in the middle | O(n), shifts elements | O(1) to relink, but only if you already hold a reference to the node just before the splice point; otherwise O(n) just to reach it |
| Memory overhead | None beyond the elements themselves, plus occasional unused resize slack | Each node carries at least one extra reference beyond its value, a larger overhead per element |
| Cache locality | Contiguous memory means sequential and even random access both benefit from data already sitting in cache | Each node is typically a separate heap allocation, so following references jumps around memory ("pointer chasing"), producing far more cache misses per traversal |
The contiguous-versus-non-contiguous memory allocation framing is exactly this same dimension stated differently: contiguous storage is what gives arrays both their cache locality and their index arithmetic; non-contiguous, per-node allocation is what gives linked lists their cheap local splicing at the cost of locality. In a garbage-collected (GC) language, this also affects collector pressure: many small linked-list node objects mean more individual objects for the garbage collector to track and scan, compared to one contiguous array allocation holding the same data.
Worked example
Consider inserting a new element in the middle of a ten-item collection, repeatedly, as items are typed into an editable list. With an array, each insertion must shift every element after the insertion point one slot over, an O(n) cost per insertion regardless of whether you know exactly where to insert. With a singly linked list, if the editing position is tracked by an existing reference to the node just before it (as a cursor would be in a text-editing context), each insertion is a pure O(1) relink; but if you only know the position as an index and must first walk from the head to reach it, the linked list gains nothing over the array for that access, since both now cost O(n) overall. This is why the deciding factor is not "array versus linked list" in the abstract, but whether the workload naturally hands you a reference to the splice point or only an index.
Trade-offs & pitfalls
Assuming "O(1) insertion" for a linked list means fast in absolute terms is a common mistake: reaching the splice point is usually the dominant cost unless a reference to it is already in hand from a prior traversal or an auxiliary index. Ignoring the per-element memory overhead ratio is another: a linked list of single integers can use several times the memory of the equivalent array, because the pointer overhead per node is fixed regardless of how small the stored value is. Modern hybrid structures such as a deque (double-ended queue) or a rope address parts of this trade-off by chunking data into contiguous blocks rather than choosing purely one extreme or the other.
Unlock Full Question Bank
Get access to all 27 Algorithmic Problem-Solving and Data Structure Selection interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.