2 — Array Algorithms
Two pointers, sliding window, prefix sums, and hashing each get their own chapter next, because each is a pattern — a shape of solution that recurs across dozens of otherwise-unrelated problems. This chapter is what’s left after subtracting the patterns: five specific, load-bearing techniques on the same contiguous, index-addressable layout that don’t generalize the same way, but show up often enough — and are elegant enough — that each is worth knowing cold. Reversal-based rotation, Kadane’s algorithm, three-way partitioning, the from-the-back merge, and the sum/XOR family for a missing or duplicate number all share one property: each trades an obvious O(n·k) or O(n²) approach for an O(n)-time, O(1)-space one, using a single structural insight instead of a second data structure.
In-Place Rotation: The Reversal Trick
“Rotate the array right by k” has an obvious naive solution: pop the last element, insert it at the front, repeat k times.
def rotate_naive(nums, k):
n = len(nums)
k %= n
for _ in range(k):
nums.insert(0, nums.pop())
return nums
Each pop()/insert(0, ...) shifts every remaining element by one slot — O(n) — and runs k times:
O(n·k) time. For k proportional to n that’s O(n²) — it looks like a cheap loop until you notice
what insert(0, ...) actually costs.
The fix reuses one O(1)-space primitive — reversing a subrange in place — three times instead of shifting elements one at a time:
def reverse(nums, lo, hi):
while lo < hi:
nums[lo], nums[hi] = nums[hi], nums[lo]
lo += 1
hi -= 1
def rotate_reversal(nums, k):
n = len(nums)
k %= n
reverse(nums, 0, n - 1) # whole array
reverse(nums, 0, k - 1) # first k elements
reverse(nums, k, n - 1) # remaining n - k elements
return nums
Trace on [1,2,3,4,5,6,7], k=3:
reverse(0, 6): [7,6,5,4,3,2,1]
reverse(0, 2): [5,6,7,4,3,2,1]
reverse(3, 6): [5,6,7,1,2,3,4]
Reversing the whole array puts every element into its final relative rotated order, but with both the front block and back block individually backwards. Re-reversing each block independently un-reverses only within that block, leaving the block-level order intact. Three linear passes sum to O(n) time, O(1) auxiliary space — no second array, no per-element shifting.
Kadane’s Algorithm: Maximum Subarray
The brute-force answer to “find the contiguous subarray with the largest sum” checks every
(start, end) pair — O(n²) pairs, O(n²) total with a running sum per start index. Kadane’s
algorithm gets there in one pass by treating it as dynamic programming in disguise: define
best_ending_here[i] as the max-sum subarray that must end exactly at index i. It has a two-choice
recurrence — extend the best subarray ending at i−1, or start fresh at i:
best_ending_here[i] = max(nums[i], best_ending_here[i-1] + nums[i])
The answer is max(best_ending_here[i] for all i). The DP table collapses to two scalars because
each state depends only on the one before it:
def max_subarray(nums):
best_ending_here = best_overall = nums[0]
for x in nums[1:]:
best_ending_here = max(x, best_ending_here + x)
best_overall = max(best_overall, best_ending_here)
return best_overall
Nothing here looks like a DP table — just two variables updated in a single scan — which is exactly
why it’s worth naming as DP explicitly: “best answer ending here, extend-or-restart” is the same
shape you’ll meet again in longest-increasing-subsequence and house-robber-style problems. O(n)
time, O(1) space, one pass — no pair of indices is ever compared, because best_ending_here
already encodes every ending-at-i answer.
A common follow-up — return the subarray itself, not just its sum — needs one more piece of state: a
start index that resets to the current position whenever the recurrence chooses to restart rather
than extend, plus tracking the (start, end) pair that produced best_overall.
Dutch National Flag Partitioning
Given an array of only 0s, 1s, and 2s, sort it in one pass without a general-purpose sort and
without a separate counting pass. Dijkstra’s Dutch National Flag algorithm partitions the array into
three regions with three pointers — low, mid, high — maintaining an invariant on each region:
[0, low) — all 0s, settled
[low, mid) — all 1s, settled
[mid, high] — unknown, not yet examined
(high, n) — all 2s, settled
def sort_colors(nums):
low, mid, high = 0, 0, len(nums) - 1
while mid <= high:
if nums[mid] == 0:
nums[low], nums[mid] = nums[mid], nums[low]
low += 1
mid += 1
elif nums[mid] == 1:
mid += 1
else: # nums[mid] == 2
nums[mid], nums[high] = nums[high], nums[mid]
high -= 1
# mid does NOT advance: the value just swapped in from `high`
# is unexamined and could be another 0, 1, or 2
return nums
The 0 and 1 branches both advance mid because the swap either brought in an already-classified
low value or the element was already correctly placed; the 2 branch swaps in an unexamined value
from the high end and must re-check it next iteration. Every element is looked at once and every
pointer only moves inward: O(n) time, O(1) space, one pass, no second array. This same three-way
partition is the subroutine inside quicksort variants that split “less than / equal to / greater
than” a pivot instead of the usual two-way split — which is what makes them resistant to the
all-duplicates worst case a naive two-way partition suffers.
Merging Sorted Arrays In Place
The classic framing: nums1 has length m + n, but only its first m slots hold real sorted
values — the trailing n slots are unused capacity. nums2 holds n sorted values. Merge nums2
into nums1 in place so all m + n values end up sorted.
Merging from the front, the way a textbook merge-sort merge does, doesn’t work here without a
temporary buffer — writing into nums1[0] would overwrite a value from nums1 not yet compared.
The trick is to merge from the back: the trailing empty slots are exactly enough room to place
the largest remaining values first, and every slot written from that point on has already been read.
def merge(nums1, m, nums2, n):
i, j, write = m - 1, n - 1, m + n - 1
while j >= 0: # nums2 fully placed is the stopping condition
if i >= 0 and nums1[i] > nums2[j]:
nums1[write] = nums1[i]
i -= 1
else:
nums1[write] = nums2[j]
j -= 1
write -= 1
return nums1
The loop condition is j >= 0 alone, deliberately. If nums2 empties first, whatever remains at
the front of nums1 is already in its final position. If nums1’s real values empty first, the
else branch keeps firing and drains the rest of nums2 in — correct, since those are the smallest
values left. One backward pass: O(m + n) time, O(1) auxiliary space — the naive alternative
(merge into a new list, copy back) is the same time but spends O(m + n) space, which is the whole
point this problem tests.
Missing and Duplicate Numbers
Given an array meant to hold each integer in [1, n] exactly once, but with one missing, the sum
trick finds it with no extra space: the sum of 1..n is n(n+1)/2; subtract the array’s actual sum
and what’s left is the number that never got added in.
def find_missing(nums, n):
"""nums holds n - 1 distinct values from 1..n; exactly one is missing."""
expected = n * (n + 1) // 2
return expected - sum(nums)
The harder variant — exactly n values from [1, n], one missing and another duplicated in its
place — needs a second equation, since one sum can’t separate two unknowns. Pairing the sum trick
with the sum-of-squares trick (Σi² has its own closed form) gives two equations and solves both:
def find_missing_and_duplicate(nums):
n = len(nums)
expected_sum = n * (n + 1) // 2
expected_sq_sum = n * (n + 1) * (2 * n + 1) // 6
sum_diff = expected_sum - sum(nums) # missing - duplicate
sq_diff = expected_sq_sum - sum(x * x for x in nums) # missing^2 - duplicate^2
total = sq_diff // sum_diff # missing + duplicate
missing = (sum_diff + total) // 2
duplicate = total - missing
return missing, duplicate
Both are O(n) time, O(1) space — but the squares version risks overflow in fixed-width languages
at large n. The bitwise equivalent — XOR every array value together with every value 1..n, so
each correctly-present number cancels itself and only the missing/duplicate pair survives — avoids
that entirely and generalizes to a whole family of XOR-based recovery problems; that mechanism gets
full treatment in XOR Problems (Part 11, Bit Manipulation) rather than repeated
here.
A third approach skips arithmetic altogether: cyclic sort repeatedly swaps each value to its
“home” index (value v belongs at index v - 1) in one in-place pass; whichever index doesn’t hold
its home value once the pass finishes points at the missing and duplicate numbers directly. That
technique, and the broader pattern of using an array’s own index space as a marker, is
Cyclic Sort (Part 14, Interview Problem Patterns, Chapter 6).
Metadata
| Author | Amit Singh |
| Scope | data-structures-algorithms |
Local graph
Related notes
5 — Prefix Sum & Difference Arrays
Precomputed running sums and difference arrays for O(1) range-sum queries and range-update problems.
6 — Hashing
How Python's dict/set turn an O(n) or O(n²) scan into O(1) average-case lookups, when that average case breaks down, and where hashing trades away information — order — that a problem still needs.
7 — Strings
Why treating a Python string as 'just an array of characters' is only half true — immutability turns naive concatenation quietly quadratic, and that one property shapes every string algorithm that follows.
8 — String Algorithms
Palindrome checks via two pointers and expand-around-center, anagram detection by counting vs. sorting, the naive O(n·m) substring search baseline, and why Python's string immutability turns 'reverse in place' into a trick question.