Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Arrays and hash tables: every variation

A hash table answers “have I seen this value” in instead of , so it turns a scan inside a loop into a lookup inside a loop. That single substitution collapses the obvious double loop to , and the price is memory. Almost every array problem that looks quadratic is really a question about membership, about pairing, or about counting, and all three are lookups.

The reason the pattern is hard has nothing to do with the hash table. A dictionary is three lines and nobody fails on the syntax. The hard part is deciding what to key on, and that decision is different in every problem. Two Sum keys on the complement target - x, not on the value. Group Anagrams keys on a canonical form of the word, so "eat" and "tea" collapse to the same key. Subarray Sum Equals K keys on a running prefix sum, not on any element. Longest Consecutive Sequence keys on nothing at all beyond set membership, and the whole solution is one in test in the right place. The interview tests whether you can invent the key. So the work is not learning the data structure; it is learning to look at a question and name the one value that makes the answer a single lookup.

Recognising it from the phrasing

The interviewer saysKey onStructureRecord where
“a pair that sums to a target”the complement target - xdict value → indexon the lookup, before inserting x
“have I seen this”, “any duplicate”the value itselfa seton the lookup, before adding
“group these together”a canonical form: sorted letters, or a 26-count tupledict key → listafter the whole pass
“count of subarrays summing to k”the prefix sumdict sum → how many timesinside the loop, on each lookup
“top k frequent / most common”the value, counteddict, then buckets or a heapafter counting, walking buckets down
“the missing number”, “the duplicate number, in place”the index: value v belongs at slot v-1the array itselfin a second pass over the slots
“product of all elements except self”nothing — prefix and suffix productstwo passes, one output arrayas you build the second pass
“does the order of the answer matter”insertion order of a dictdict, Python 3.7+never depend on it for correctness

Before writing any code, ask one question: what single value, computed from the current element or from the prefix ending at it, would answer the question in one lookup? If you can name that value, the problem is a hash-map problem and the rest writes itself — you build the map of that value as you scan, and at each element you look up the one thing that would complete an answer. If you cannot name it, the problem is probably not a hash-map problem. Two Sum on a sorted array has no useful key, because sorting already gives you order and two pointers do it in space. Range queries have no useful key, because a hash table cannot answer “how many values lie between 10 and 20” at all. Those are the cases where sorting, binary search or two pointers wins, and reaching for a dict there costs you memory for nothing.

The templates

Four skeletons cover the whole family. The first two are the same loop with a different lookup, and the third is the same loop again with the key changed from the element to the running prefix. Learn the shape once: scan, look up what would complete the answer, then insert the current thing. The order of those last two steps is the bug that appears most often, so it is marked in every template.

Template 1 — the seen-set. Use when the question is existence, membership or duplication.

def has_duplicate(nums):
    seen = set()                                     ## every value met so far
    for x in nums:
        if x in seen:                                ## O(1) lookup replaces an O(n) scan
            return True
        seen.add(x)                                  ## add AFTER the test, never before
    return False

## tests

assert has_duplicate([1, 2, 3, 1]) is True
assert has_duplicate([1, 2, 3]) is False
assert has_duplicate([]) is False
print(has_duplicate([1, 2, 3, 1]), has_duplicate([1, 2, 3]))
True False

Template 2 — the complement map. Use when the question asks for a pair with a fixed relation.

def two_sum(nums, target):
    index_of = {}                                    ## value -> the index where it was seen
    for i, x in enumerate(nums):
        complement = target - x                      ## the ONE value that completes the pair
        if complement in index_of:
            return [index_of[complement], i]         ## record on the LOOKUP, not on the insert
        index_of[x] = i                              ## insert after, so x cannot pair with itself
    return []

## tests

assert two_sum([2, 7, 11, 15], 9) == [0, 1]
assert two_sum([3, 3], 6) == [0, 1]
assert two_sum([3, 2, 4], 6) == [1, 2]
assert two_sum([1, 2], 100) == []
print(two_sum([2, 7, 11, 15], 9))
[0, 1]

Template 3 — the prefix-sum counter map. Use when the question counts subarrays by their sum. It is template 2 with the key changed from the element to the running prefix sum.

def count_subarrays_summing_to(nums, k):
    counts = {0: 1}                                  ## the empty prefix has sum 0 and exists once
    running, total = 0, 0
    for x in nums:
        running += x                                 ## running = prefix sum up to and including x
        total += counts.get(running - k, 0)          ## earlier prefixes that close a match here
        counts[running] = counts.get(running, 0) + 1  ## record THIS prefix for later positions
    return total

## tests

assert count_subarrays_summing_to([1, 1, 1], 2) == 2
assert count_subarrays_summing_to([1, 2, 3], 3) == 2
assert count_subarrays_summing_to([3, 4, 7, 2, -3, 1, 4, 2], 7) == 4
print(count_subarrays_summing_to([1, 2, 3], 3))
2

The seed counts = {0: 1} is the single most misunderstood line in this family, and the next section explains it properly. Do not write this template without it.

Template 4 — frequency map plus bucket sort. Use for “top k” when you want and not .

def top_k_frequent(nums, k):
    freq = {}
    for x in nums:
        freq[x] = freq.get(x, 0) + 1
    buckets = [[] for _ in range(len(nums) + 1)]      ## bucket i holds values seen i times
    for value, count in freq.items():
        buckets[count].append(value)
    out = []
    for count in range(len(nums), 0, -1):             ## walk from the highest count downwards
        for value in buckets[count]:
            out.append(value)
            if len(out) == k:                         ## record as soon as k are collected
                return out
    return out

## tests

assert sorted(top_k_frequent([1, 1, 1, 2, 2, 3], 2)) == [1, 2]
assert top_k_frequent([7], 1) == [7]
assert sorted(top_k_frequent([4, 4, 5, 5, 6], 3)) == [4, 5, 6]
print(top_k_frequent([1, 1, 1, 2, 2, 3], 2))
[1, 2]

Bucket sort is linear because a count can never exceed n, so the counts themselves are array indices. That bound is what removes the factor a heap would cost.

The trick that unlocks a whole family: prefix sums in a hash map

Write prefix[i] for the sum of the first i elements, with prefix[0] = 0. The sum of the subarray covering indices i to j-1 is then prefix[j] - prefix[i]. Asking for that sum to equal k gives

and the rearranged form is the whole trick. At each position j you already know prefix[j], so the question “how many subarrays end here and sum to k” becomes “how many earlier prefixes equalled prefix[j] - k”. That is one lookup in a map from prefix value to how many times it has occurred. One pass, .

Worked example. Take nums = [3, 4, 7, 2, -3, 1, 4, 2] and k = 7. Scan left to right, and at each step look up running - 7 in the map before inserting running.

indexvaluerunninglook upfoundtotalmap after inserting
start00{0: 1}
033-400{0: 1, 3: 1}
147011{0: 1, 3: 1, 7: 1}
2714712{0: 1, 3: 1, 7: 1, 14: 1}
3216902..., 16: 1
4-313602..., 13: 1
5114713..., 14: 2
64181103..., 18: 1
72201314..., 20: 1

The four subarrays are [3, 4], [7], [7, 2, -3, 1] and [-3, 1, 4, 2]. Note the hit at index 5: the map held 7 from index 1, so the earlier prefix and the current one differ by exactly 7. Note also that 14 reaches a count of 2, and that count is what lets a later position claim both matches at once with a single lookup.

Now the seed. counts = {0: 1} says that the empty prefix — the prefix of length zero, whose sum is 0 — has been seen once, before the scan starts. It matters at index 1 in the table above: running is 7, so the lookup is for 0, and the only prefix with sum 0 is the empty one. That empty prefix is what represents the subarray [3, 4], which starts at index 0. Without the seed, every subarray that starts at index 0 is missed, and the tests that hide the bug are the ones whose answer happens to lie in the middle of the array. Seed it with {0: 1}, not {0: 0} and not {}.

def subarray_sum_equals_k(nums, k):
    counts = {0: 1}
    running, total = 0, 0
    for x in nums:
        running += x
        total += counts.get(running - k, 0)          ## look up BEFORE inserting
        counts[running] = counts.get(running, 0) + 1
    return total

## tests

assert subarray_sum_equals_k([3, 4, 7, 2, -3, 1, 4, 2], 7) == 4
assert subarray_sum_equals_k([1, 1, 1], 2) == 2
assert subarray_sum_equals_k([1], 0) == 0
assert subarray_sum_equals_k([0, 0, 0], 0) == 6
print(subarray_sum_equals_k([3, 4, 7, 2, -3, 1, 4, 2], 7))
4

This is the pattern that takes over the moment a sliding window fails. A window shrinks from the left as soon as the sum overshoots the target, which assumes an overshoot is permanent. With negative numbers it is not: in the array above, [7, 2] already exceeds 7, yet [7, 2, -3, 1] hits it exactly. So left would have to move backwards and the linear guarantee is gone. See 21_sliding_window.md, problem P21, for the same array worked from the window side. The rule is short: positive values only, use a window; negatives possible, use prefix sums in a hash map.

The problems

P1. Two sum — return the indices of the two numbers that add to target

Which template. Template 2, the complement map, and it is the canonical instance of it. The trick. Do not key on the value you are holding. Key on target - x, which is the one value that would complete a pair with x. Then a single pass suffices, because by the time you reach the second member of the pair the first is already in the map. Insert x after the lookup, so an element cannot pair with itself when target == 2 * x.

def two_sum(nums, target):
    index_of = {}
    for i, x in enumerate(nums):
        if target - x in index_of:                   ## the complement is the key, not x
            return [index_of[target - x], i]
        index_of[x] = i                              ## insert after the test
    return []

## tests

assert two_sum([2, 7, 11, 15], 9) == [0, 1]
assert two_sum([3, 2, 4], 6) == [1, 2]
assert two_sum([3, 3], 6) == [0, 1]
assert two_sum([1, 5, 9], 3) == []
print(two_sum([3, 2, 4], 6))
[1, 2]

Complexity. time, space.

P2. Two sum on a sorted array — same question, but the input is sorted, and indices are 1-based

Which template. None of the four. Sorting has already done the work a hash map would pay memory for, so two pointers win. The trick. Compare numbers[left] + numbers[right] to the target. If the sum is too small, only a larger left value can help, so left += 1. If it is too large, only a smaller right value can help, so right -= 1. Every step eliminates one candidate permanently, so the scan is linear. Put P1 and P2 side by side: same question, same time, but space against space. When the input arrives sorted, or when the interviewer asks for constant space, the hash map is the wrong answer.

def two_sum_sorted(numbers, target):
    left, right = 0, len(numbers) - 1
    while left < right:
        total = numbers[left] + numbers[right]
        if total == target:
            return [left + 1, right + 1]             ## the problem asks for 1-based indices
        if total < target:
            left += 1                                ## only a larger left value can help
        else:
            right -= 1                               ## only a smaller right value can help
    return []

## tests

assert two_sum_sorted([2, 7, 11, 15], 9) == [1, 2]
assert two_sum_sorted([2, 3, 4], 6) == [1, 3]
assert two_sum_sorted([-1, 0], -1) == [1, 2]
assert two_sum_sorted([1, 2, 3], 100) == []
print(two_sum_sorted([2, 7, 11, 15], 9), two_sum_sorted([2, 3, 4], 6))
[1, 2] [1, 3]

Complexity. time, space. Sorting an unsorted input first costs , so on unsorted input P1 is faster and P2 is smaller.

P3. Contains duplicate — does the array hold any value twice

Which template. Template 1, the seen-set. The trick. The one-liner len(set(nums)) < len(nums) is correct and fine to say out loud, but the explicit loop returns as soon as it finds the first repeat, so it is faster on inputs whose duplicate is early and it uses less memory in that case. Say which one you are writing and why.

def contains_duplicate(nums):
    seen = set()
    for x in nums:
        if x in seen:
            return True
        seen.add(x)
    return False

def contains_duplicate_short(nums):
    return len(set(nums)) < len(nums)                ## same answer, but always scans everything

## tests

assert contains_duplicate([1, 2, 3, 1]) is True
assert contains_duplicate([1, 2, 3, 4]) is False
assert contains_duplicate([1, 1, 1, 3, 3, 4, 3, 2, 4, 2]) is True
assert contains_duplicate_short([1, 2, 3, 1]) is True
print(contains_duplicate([1, 2, 3, 1]), contains_duplicate([1, 2, 3, 4]))
True False

Complexity. time, space.

P4. Contains duplicate within distance k — is there a repeated value whose two indices differ by at most k

Which template. Template 1, but the set becomes a map to the most recent index. The trick. Store only the latest index of each value and overwrite it every time. An earlier copy is never more useful than a later one, because a later copy is closer to whatever comes next. That overwrite is what keeps the map at one entry per distinct value. A sliding window of size k holding a set is the other correct solution; the map is shorter to write and harder to get wrong.

def contains_nearby_duplicate(nums, k):
    last_index = {}                                  ## value -> the most recent index of it
    for i, x in enumerate(nums):
        if x in last_index and i - last_index[x] <= k:
            return True                              ## the nearest earlier copy is the best chance
        last_index[x] = i                            ## overwrite: only the latest index matters
    return False

## tests

assert contains_nearby_duplicate([1, 2, 3, 1], 3) is True
assert contains_nearby_duplicate([1, 0, 1, 1], 1) is True
assert contains_nearby_duplicate([1, 2, 3, 1, 2, 3], 2) is False
assert contains_nearby_duplicate([1], 0) is False
print(contains_nearby_duplicate([1, 2, 3, 1], 3), contains_nearby_duplicate([1, 2, 3, 1, 2, 3], 2))
True False

Complexity. time, space for d distinct values.

P5. Valid anagram — do two strings contain the same letters with the same multiplicities

Which template. A frequency map, and for a lowercase alphabet a 26-slot list beats a dict. The trick. Count up on the first string and down on the second in one shared array, and bail out the moment a count goes negative. That single array replaces the two maps most people write, and the negative test means you do not need a final comparison pass. The length check at the top is what makes the negative test sufficient: with equal lengths, no count can be left positive if none went negative.

def is_anagram(s, t):
    if len(s) != len(t):
        return False                                 ## a length check kills most inputs at once
    counts = [0] * 26
    for ch in s:
        counts[ord(ch) - 97] += 1
    for ch in t:
        counts[ord(ch) - 97] -= 1
        if counts[ord(ch) - 97] < 0:                 ## t has a letter s does not have enough of
            return False
    return True

## tests

assert is_anagram("anagram", "nagaram") is True
assert is_anagram("rat", "car") is False
assert is_anagram("a", "ab") is False
assert is_anagram("", "") is True
print(is_anagram("anagram", "nagaram"), is_anagram("rat", "car"))
True False

Complexity. time, space, which is .

P6. Group anagrams — partition a word list into groups that are anagrams of each other

Which template. A map from a canonical key to a list, and the key is the whole problem. The trick. Two words belong together when they share a canonical form. Sorting the letters gives one, "".join(sorted(word)), and costs per word. A tuple of 26 counts gives another and costs . Use the count tuple: it must be a tuple, because a list is not hashable and a dict key must be hashable. Say the sorted-string version first, then offer the count tuple as the improvement.

def group_anagrams(words):
    groups = {}
    for word in words:
        counts = [0] * 26
        for ch in word:
            counts[ord(ch) - 97] += 1
        key = tuple(counts)                          ## canonical form, built in O(len(word))
        if key not in groups:
            groups[key] = []
        groups[key].append(word)
    return list(groups.values())

## tests

out = group_anagrams(["eat", "tea", "tan", "ate", "nat", "bat"])
assert sorted(sorted(g) for g in out) == [["ate", "eat", "tea"], ["bat"], ["nat", "tan"]]
assert group_anagrams([""]) == [[""]]
assert group_anagrams(["a"]) == [["a"]]
print(out)
[['eat', 'tea', 'ate'], ['tan', 'nat'], ['bat']]

Complexity. time for N words of length up to L, and space.

P7. Top k frequent elements — the k values that appear most often

Which template. Template 4, frequency map plus bucket sort. The trick. A count can never exceed n, so counts are valid indices into an array of length n + 1. Put each value into the bucket named by its count and then walk the buckets downwards, taking values until you have k. There is no sort and no heap, so the whole thing is . The heap alternative, heapq.nlargest(k, freq, key=freq.get), is and is the right answer to give when k is small and memory matters.

def top_k_frequent(nums, k):
    freq = {}
    for x in nums:
        freq[x] = freq.get(x, 0) + 1
    buckets = [[] for _ in range(len(nums) + 1)]     ## index = count, so index <= n
    for value, count in freq.items():
        buckets[count].append(value)
    out = []
    for count in range(len(nums), 0, -1):
        for value in buckets[count]:
            out.append(value)
            if len(out) == k:
                return out
    return out

## tests

assert sorted(top_k_frequent([1, 1, 1, 2, 2, 3], 2)) == [1, 2]
assert top_k_frequent([1], 1) == [1]
assert sorted(top_k_frequent([5, 5, 4, 4, 3, 3, 2], 3)) == [3, 4, 5]
print(top_k_frequent([1, 1, 1, 2, 2, 3], 2))
[1, 2]

Complexity. time, space.

P8. Product of array except self — out[i] is the product of every element other than nums[i], without division

Which template. None. This is the problem that looks like it needs a map and does not. The trick. The answer at i is (everything to the left of i) times (everything to the right of i). Build the left products in a forward pass, writing each into out[i] before multiplying nums[i] in, then run a backward pass doing the same with a suffix accumulator. Two passes, one output array, no division, so a zero anywhere in the input is handled with no special case. The division solution breaks on a single zero and gives the wrong answer on two zeros.

def product_except_self(nums):
    n = len(nums)
    out = [1] * n
    prefix = 1
    for i in range(n):
        out[i] = prefix                              ## product of everything strictly left of i
        prefix *= nums[i]
    suffix = 1
    for i in range(n - 1, -1, -1):
        out[i] *= suffix                             ## multiply in everything strictly right of i
        suffix *= nums[i]
    return out

## tests

assert product_except_self([1, 2, 3, 4]) == [24, 12, 8, 6]
assert product_except_self([-1, 1, 0, -3, 3]) == [0, 0, 9, 0, 0]
assert product_except_self([2, 3]) == [3, 2]
assert product_except_self([0, 0]) == [0, 0]
print(product_except_self([1, 2, 3, 4]), product_except_self([-1, 1, 0, -3, 3]))
[24, 12, 8, 6] [0, 0, 9, 0, 0]

Complexity. time, extra space if the output array is not counted.

P9. Longest consecutive sequence — the length of the longest run of consecutive integers, in any order

Which template. Template 1, a set, and one extra test that is the entire problem. The trick. Put every value in a set, then for each value walk upwards while x + 1 is present. On its own that is , because a run of length L gets walked from all L of its members. The fix is one line: only start walking when x - 1 is not in the set, so each run is walked exactly once, from its first element. The total work over all runs is then the sum of the run lengths, which is at most n. That single continue is what makes the solution linear, and it is the thing the interviewer is checking.

def longest_consecutive(nums):
    values = set(nums)
    best = 0
    for x in values:
        if x - 1 in values:                          ## x is not the start of a run, so skip it
            continue
        length = 1
        while x + length in values:                  ## walk the run upwards from its first element
            length += 1
        best = max(best, length)
    return best

## tests

assert longest_consecutive([100, 4, 200, 1, 3, 2]) == 4
assert longest_consecutive([0, 3, 7, 2, 5, 8, 4, 6, 0, 1]) == 9
assert longest_consecutive([]) == 0
assert longest_consecutive([1, 1, 1]) == 1
print(longest_consecutive([100, 4, 200, 1, 3, 2]), longest_consecutive([0, 3, 7, 2, 5, 8, 4, 6, 0, 1]))
4 9

Complexity. time and space. Sorting also solves it in and extra space, so name that trade if the interviewer asks for less memory.

P10. Subarray sum equals k — count them, then find the longest one

Which template. Template 3, the prefix-sum map. The counting version is in the section above; this is the same idea with the map’s value changed. The trick. Change what the map stores and you change the question it answers. Store how many times each prefix sum occurred and you count subarrays. Store the first index at which each prefix sum occurred and you get the longest subarray, because the earliest match gives the widest span. So never overwrite an existing key in this version — an earlier index is always at least as good. Seed with {0: -1} here, not {0: 1}: the empty prefix ends just before index 0, so its index is -1 and the length arithmetic i - (-1) comes out right.

def longest_subarray_sum_k(nums, k):
    first_index = {0: -1}                            ## prefix sum 0 ends just before index 0
    running, best = 0, 0
    for i, x in enumerate(nums):
        running += x
        if running - k in first_index:               ## a match: keep the EARLIEST such prefix
            best = max(best, i - first_index[running - k])
        if running not in first_index:               ## do not overwrite: earlier means longer
            first_index[running] = i
    return best

## tests

assert longest_subarray_sum_k([1, -1, 5, -2, 3], 3) == 4
assert longest_subarray_sum_k([-2, -1, 2, 1], 1) == 2
assert longest_subarray_sum_k([1, 2, 3], 100) == 0
assert longest_subarray_sum_k([0, 0, 0], 0) == 3
print(longest_subarray_sum_k([1, -1, 5, -2, 3], 3), longest_subarray_sum_k([-2, -1, 2, 1], 1))
4 2

Complexity. time, space.

P11. Continuous subarray sum divisible by k — is there a subarray of length at least 2 whose sum is a multiple of k

Which template. Template 3, with the key changed from the prefix sum to the prefix sum modulo k. The trick. A subarray sums to a multiple of k exactly when its two bounding prefix sums leave the same remainder modulo k, because prefix[j] - prefix[i] is divisible by k if and only if prefix[j] % k == prefix[i] % k. So the key is the remainder, and the map holds at most k entries rather than n. Store the first index of each remainder and require the gap to be at least 2. Python’s % returns a non-negative result for a positive k, so negative values need no special handling.

def check_subarray_sum(nums, k):
    first_index = {0: -1}                            ## remainder 0 before the array starts
    running = 0
    for i, x in enumerate(nums):
        running = (running + x) % k                  ## key on the REMAINDER, not on the sum
        if running in first_index:
            if i - first_index[running] >= 2:        ## the problem needs length at least 2
                return True
        else:
            first_index[running] = i                 ## keep the earliest index of each remainder
    return False

## tests

assert check_subarray_sum([23, 2, 4, 6, 7], 6) is True
assert check_subarray_sum([23, 2, 6, 4, 7], 13) is False
assert check_subarray_sum([1, 0], 2) is False
assert check_subarray_sum([0, 0], 7) is True
print(check_subarray_sum([23, 2, 4, 6, 7], 6), check_subarray_sum([23, 2, 6, 4, 7], 13))
True False

Complexity. time, space.

P12. Find all duplicates in an array — values are in 1..n and each appears once or twice; return the repeats, in space

Which template. Index-as-hash. The array is its own hash table, because the values and the indices have the same range. The trick. Value v belongs at index v - 1. Mark a slot as visited by negating what is stored there. When you reach a value whose home slot is already negative, that value has been seen before, so it is a duplicate. Always read abs(x), because an earlier mark may have flipped the sign of the very cell you are reading. Restore the signs at the end unless the interviewer says the input may be destroyed.

def find_duplicates(nums):
    out = []
    for x in nums:
        slot = abs(x) - 1                            ## value v belongs at index v-1
        if nums[slot] < 0:                           ## already marked, so v was seen before
            out.append(abs(x))
        else:
            nums[slot] = -nums[slot]                 ## mark slot v-1 as visited
    for i in range(len(nums)):
        nums[i] = abs(nums[i])                       ## restore the array before returning
    return out

## tests

assert sorted(find_duplicates([4, 3, 2, 7, 8, 2, 3, 1])) == [2, 3]
assert find_duplicates([1, 1, 2]) == [1]
assert find_duplicates([1]) == []
data = [4, 3, 2, 7, 8, 2, 3, 1]
print(find_duplicates(data), data)
[2, 3] [4, 3, 2, 7, 8, 2, 3, 1]

Complexity. time, extra space. Sign marking needs strictly positive values, so it fails if zero is allowed; use cyclic sort then, as in P13.

P13. First missing positive — the smallest positive integer absent from the array, in time and space

Which template. Cyclic sort, the strongest form of index-as-hash. The trick. The answer must lie in 1..n+1, because n values cannot cover more than n of the positives. So put every value v in range at index v - 1 by swapping, then scan once for the first index whose content is wrong. The swap loop is a while, not an if, because the value that arrives at position i may itself need to move. The guard nums[nums[i] - 1] != nums[i] compares values, not indices, and it is what stops the loop spinning forever on duplicates — a swap that would change nothing is refused.

def first_missing_positive(nums):
    n = len(nums)
    for i in range(n):
        while 1 <= nums[i] <= n and nums[nums[i] - 1] != nums[i]:
            target = nums[i] - 1                     ## send nums[i] home to index nums[i]-1
            nums[i], nums[target] = nums[target], nums[i]
    for i in range(n):
        if nums[i] != i + 1:                         ## the first slot holding the wrong value
            return i + 1
    return n + 1

## tests

assert first_missing_positive([1, 2, 0]) == 3
assert first_missing_positive([3, 4, -1, 1]) == 2
assert first_missing_positive([7, 8, 9, 11, 12]) == 1
assert first_missing_positive([]) == 1
assert first_missing_positive([1, 1]) == 2
print(first_missing_positive([3, 4, -1, 1]), first_missing_positive([7, 8, 9, 11, 12]))
2 1

Complexity. time — each swap puts one value permanently home, so there are at most n of them — and space.

P14. Single number — every value appears twice except one; find it

Which template. None. XOR replaces the hash table entirely. The trick. XOR has three properties that together solve the problem: x ^ x == 0, x ^ 0 == x, and it is commutative and associative, so the order of the array does not matter. XOR the whole array and every pair cancels, leaving the lonely value. A set gives the same answer in space; XOR gives it in , and that is the reason the question is asked.

def single_number(nums):
    result = 0
    for x in nums:
        result ^= x                                  ## equal values cancel, order does not matter
    return result

## tests

assert single_number([2, 2, 1]) == 1
assert single_number([4, 1, 2, 1, 2]) == 4
assert single_number([1]) == 1
assert single_number([0, 0, -5]) == -5
print(single_number([4, 1, 2, 1, 2]))
4

Complexity. time, space.

P15. Majority element — the value that appears more than n/2 times

Which template. Boyer-Moore voting. A frequency map solves it in space; voting solves it in . The trick. Hold one candidate and a counter. Every matching element is a vote for the candidate, every other element is a vote against, and when the counter reaches zero the candidate is replaced. The reason it works: each cancellation removes one majority element and one non-majority element, and because the majority strictly exceeds n/2 it cannot be exhausted before the others are. Whatever survives is the answer. If the problem only promises a plurality rather than a majority, this is wrong — add a second pass that counts the candidate and verifies it.

def majority_element(nums):
    candidate, count = None, 0
    for x in nums:
        if count == 0:
            candidate = x                            ## no survivor left, so start a new candidate
        count += 1 if x == candidate else -1         ## a vote for, or a cancelling vote against
    return candidate

## tests

assert majority_element([3, 2, 3]) == 3
assert majority_element([2, 2, 1, 1, 1, 2, 2]) == 2
assert majority_element([1]) == 1
assert majority_element([6, 5, 5]) == 5
print(majority_element([2, 2, 1, 1, 1, 2, 2]))
2

Complexity. time, space.

P16. Intersection of two arrays — the values present in both

Which template. Template 1, with the set built from one side and the lookup done from the other. The trick. Build the set from the smaller array, so the space is rather than . Then ask what the answer means. If the result must be distinct values, collect into a set. If the result must respect multiplicity — each value appears as often as it occurs in both inputs — count instead of setting, and decrement as you spend each copy. Ask the interviewer which one they want, because the two answers differ on the very first example.

def intersection(nums1, nums2):
    smaller, larger = (nums1, nums2) if len(nums1) <= len(nums2) else (nums2, nums1)
    lookup = set(smaller)                            ## build from the SMALLER side
    out = set()
    for x in larger:
        if x in lookup:
            out.add(x)
    return list(out)

def intersection_with_counts(nums1, nums2):
    counts = {}
    for x in nums1:
        counts[x] = counts.get(x, 0) + 1
    out = []
    for x in nums2:
        if counts.get(x, 0) > 0:                     ## multiplicity version: spend one copy
            out.append(x)
            counts[x] -= 1
    return out

## tests

assert sorted(intersection([1, 2, 2, 1], [2, 2])) == [2]
assert sorted(intersection([4, 9, 5], [9, 4, 9, 8, 4])) == [4, 9]
assert sorted(intersection_with_counts([1, 2, 2, 1], [2, 2])) == [2, 2]
assert intersection_with_counts([1], [2]) == []
print(sorted(intersection([4, 9, 5], [9, 4, 9, 8, 4])), intersection_with_counts([1, 2, 2, 1], [2, 2]))
[4, 9] [2, 2]

Complexity. time, space.

P17. Isomorphic strings — can the letters of s be renamed consistently to give t

Which template. Two maps, and the second one is the point of the question. The trick. One map from s to t is not enough. It accepts "badc" against "baba", because each letter of s maps to exactly one letter of t there — but d and b both map onto b, so the renaming is not reversible. A renaming must be a one-to-one correspondence, therefore you need the map in both directions and a conflict in either one is a rejection. Write both checks before either assignment, or the second check reads a value you have just written.

def is_isomorphic(s, t):
    if len(s) != len(t):
        return False
    forward, backward = {}, {}                       ## s->t and t->s, both needed
    for a, b in zip(s, t):
        if a in forward and forward[a] != b:
            return False                             ## a already maps somewhere else
        if b in backward and backward[b] != a:
            return False                             ## b is already claimed by another letter
        forward[a] = b
        backward[b] = a
    return True

## tests

assert is_isomorphic("egg", "add") is True
assert is_isomorphic("foo", "bar") is False
assert is_isomorphic("badc", "baba") is False
assert is_isomorphic("paper", "title") is True
print(is_isomorphic("egg", "add"), is_isomorphic("badc", "baba"))
True False

Complexity. time, space.

P18. Word pattern — does a sentence follow a letter pattern, with one word per letter

Which template. P17 exactly, with characters on one side and words on the other. The trick. It is the same two-map bijection, so recognise it and reuse the code. Two things are new. First, zip stops at the shorter sequence, so a pattern and a sentence of different lengths would silently pass; the explicit length check is a real test case, not defensive noise. Second, split on whitespace with sentence.split() and not split(" "), because the latter produces empty strings from repeated spaces.

def word_pattern(pattern, sentence):
    words = sentence.split()
    if len(pattern) != len(words):
        return False                                 ## the length check is a real test case
    forward, backward = {}, {}
    for ch, word in zip(pattern, words):
        if ch in forward and forward[ch] != word:
            return False
        if word in backward and backward[word] != ch:
            return False
        forward[ch] = word
        backward[word] = ch
    return True

## tests

assert word_pattern("abba", "dog cat cat dog") is True
assert word_pattern("abba", "dog cat cat fish") is False
assert word_pattern("aaaa", "dog cat cat dog") is False
assert word_pattern("abba", "dog dog dog dog") is False
assert word_pattern("a", "dog dog") is False
print(word_pattern("abba", "dog cat cat dog"), word_pattern("abba", "dog dog dog dog"))
True False

Complexity. time, space.

P19. Ransom note — can the note be built from the letters of the magazine, each used once

Which template. A frequency map spent down, which is the counting form of template 1. The trick. Count the supply, then walk the demand and spend one copy per character. A missing key and an exhausted key are the same failure, so available.get(ch, 0) == 0 covers both in one test and there is no KeyError to guard. The length check at the top is free and rejects a whole class of inputs before any counting.

def can_construct(note, magazine):
    if len(note) > len(magazine):
        return False
    available = {}
    for ch in magazine:
        available[ch] = available.get(ch, 0) + 1
    for ch in note:
        if available.get(ch, 0) == 0:                ## missing, or already spent
            return False
        available[ch] -= 1                           ## spend one copy
    return True

## tests

assert can_construct("a", "b") is False
assert can_construct("aa", "aab") is True
assert can_construct("aa", "ab") is False
assert can_construct("", "anything") is True
print(can_construct("aa", "aab"), can_construct("aa", "ab"))
True False

Complexity. time, space.

P20. Substring with concatenated words — every start index where s holds all of words joined in some order, each once

Which template. A frequency map compared against a window, but the window moves in whole words. The trick. All words have the same length k, so treat s as a sequence of k-character chunks. There are k different ways to align the chunk grid, so run k independent scans, one per starting offset, and inside each scan slide a window measured in words rather than characters. Shrink from the left while any word is over its quota, and reset the whole window when a chunk is not a word at all. Every character is visited once per offset, so the cost is and not .

def find_substring(s, words):
    k, m = len(words[0]), len(words)
    need = {}
    for w in words:
        need[w] = need.get(w, 0) + 1
    out = []
    for offset in range(k):                          ## k independent chunk alignments
        window, count, left = {}, 0, offset
        for right in range(offset, len(s) - k + 1, k):
            word = s[right:right + k]
            if word not in need:
                window.clear()                       ## an unusable word resets everything
                count, left = 0, right + k
                continue
            window[word] = window.get(word, 0) + 1
            count += 1
            while window[word] > need[word]:         ## too many copies: drop from the left
                gone = s[left:left + k]
                window[gone] -= 1
                count -= 1
                left += k
            if count == m:
                out.append(left)
    return out

## tests

assert sorted(find_substring("barfoothefoobarman", ["foo", "bar"])) == [0, 9]
assert find_substring("wordgoodgoodgoodbestword", ["word", "good", "best", "word"]) == []
assert sorted(find_substring("barfoofoobarthefoobarman", ["bar", "foo", "the"])) == [6, 9, 12]
print(sorted(find_substring("barfoothefoobarman", ["foo", "bar"])))
[0, 9]

Complexity. time, space.

P21. Four sum count — how many tuples (i, j, p, q) from four lists satisfy a[i] + b[j] + c[p] + d[q] == 0

Which template. Template 2, the complement map, applied to pairs instead of elements. The trick. Four nested loops cost . Split the four lists into two halves. Build a map from every sum a[i] + b[j] to how many pairs produce it — that is entries. Then for every pair from the other two lists, look up the complement -(c[p] + d[q]) and add its count. The key is a pair sum, not an element, and that generalisation is what takes the problem from to . The same meet-in-the-middle idea appears whenever a problem splits cleanly into two independent halves.

def four_sum_count(a, b, c, d):
    pair_sums = {}
    for x in a:
        for y in b:
            pair_sums[x + y] = pair_sums.get(x + y, 0) + 1   ## all n^2 sums of the first two lists
    total = 0
    for z in c:
        for w in d:
            total += pair_sums.get(-(z + w), 0)              ## look up the complement
    return total

## tests

assert four_sum_count([1, 2], [-2, -1], [-1, 2], [0, 2]) == 2
assert four_sum_count([0], [0], [0], [0]) == 1
assert four_sum_count([1], [1], [1], [1]) == 0
print(four_sum_count([1, 2], [-2, -1], [-1, 2], [0, 2]))
2

Complexity. time, space.

P22. Counting values in a range — how many elements lie between low and high inclusive

Which template. None, and that is the lesson. This is the problem where a hash map is the wrong answer. The trick. A hash table answers equality and nothing else. It has no order, so it cannot answer “which values are less than 7” without visiting every key. The map version below is correct but its cost grows with the width of the range, so a query over 1..10**9 is hopeless even on three elements. Sorting once and using binary search answers any range in , and the sort is paid back after the second query. The general rule: equality and membership go to a hash map, order and ranges go to a sorted array with bisect, or to a balanced tree. If the interviewer says “many queries”, they are asking for the sorted version.

from bisect import bisect_left, bisect_right

def count_in_range(nums, low, high):
    ordered = sorted(nums)                           ## pay O(n log n) once, then O(log n) per query
    return bisect_right(ordered, high) - bisect_left(ordered, low)

def count_in_range_with_a_map(nums, low, high):
    seen = {}
    for x in nums:
        seen[x] = seen.get(x, 0) + 1
    total = 0
    for value in range(low, high + 1):               ## the map forces a scan of the whole range
        total += seen.get(value, 0)
    return total

## tests

data = [5, 1, 9, 3, 7, 3]
assert count_in_range(data, 3, 7) == 4
assert count_in_range(data, 0, 100) == 6
assert count_in_range(data, 4, 4) == 0
assert count_in_range_with_a_map(data, 3, 7) == 4
print(count_in_range(data, 3, 7), count_in_range(data, 0, 100))
4 6

Complexity. to sort, then per query and space. The map version is per query, which is unusable for a wide range.

Tricks and tips

Say the key out loud before you write anything. “I will key on the complement.” “I will key on the sorted letters.” “I will key on the running prefix sum modulo k.” That sentence is the solution, and the code after it is mechanical. If you cannot finish the sentence, you have the wrong pattern, and the usual replacements are sorting, two pointers or binary search.

Look up before you insert. In every template the current element is inserted into the map only after the lookup that uses the map. Insert first and an element pairs with itself, so Two Sum returns [0, 0] on [3, 2, 4] with target 6. This is one line in the wrong order and it is the most common single-character bug in the whole family.

Use collections.Counter and collections.defaultdict when they help, but know what they cost. Counter(s) == Counter(t) is a complete Valid Anagram in one line, and it is a fine answer. However, defaultdict(int) inserts a key on every read, so if x in d after a read of d[x] is always true, and that turns a membership test into a silent yes. If you use a defaultdict, never test membership on it. dict.get(key, 0) avoids the whole issue and works everywhere.

Delete a key when its count reaches zero, or compare counts instead of maps. {'a': 0} and {} are not equal, so a map that keeps zero entries breaks any == comparison and any len() used as a distinct-value count. Either delete on reaching zero, or switch to a fixed-size list when the alphabet is small: [0] * 26 compares with a single == and has no keys to delete.

Make the key hashable and canonical. A list cannot be a dict key; a tuple can. tuple(counts) and "".join(sorted(word)) are both canonical, and the tuple is faster because it avoids the sort. For a pair of coordinates use (row, col), never a string built with a separator, because the separator can appear in the data.

When the values are a permutation of 1..n, the array is already a hash table. Index v - 1 belongs to value v, so you can mark by negating or place by swapping, and the space drops to . Sign marking needs strictly positive values; cyclic sort tolerates anything, because out-of-range values are simply left where they are.

Two directions, not one, for any correspondence. Isomorphic strings and Word Pattern both need a map each way, because a renaming must be reversible. One map accepts a collapse of two symbols onto one.

Insertion order is a Python detail, not a guarantee of the problem. A dict preserves insertion order in Python 3.7 and later, so list(groups.values()) comes out in first-seen order. That is convenient for reading the output, and it is never a correctness argument. If the problem needs a specific order, sort explicitly and say so.

The bugs that cost the round

Inserting before looking up. Covered above and worth repeating, because it fails only on the inputs where an element could pair with itself, which is rarely the first example.

Forgetting the {0: 1} seed on a prefix-sum map. Without it every subarray that starts at index 0 is missed. The classic symptom is that [1, 2, 3] with k = 3 returns 1 instead of 2 — the subarray [3] is found, [1, 2] is not. If a prefix-sum answer is exactly one too small, this is why.

Seeding with the wrong value for the wrong variant. The counting form seeds {0: 1} because it stores occurrence counts. The longest-subarray form seeds {0: -1} because it stores indices, and the empty prefix ends just before index 0. Mixing the two gives lengths that are off by one, or a TypeError when a count is subtracted from an index.

Overwriting a first index that should be kept. When the map stores indices for a longest-span answer, an existing key must never be updated, because an earlier index gives a wider span. When the map stores the most recent index, as in P4, it must always be updated. Decide which one the question needs and write the if x not in map guard, or its absence, deliberately.

Mutating the input and then reading it as if it were unchanged. Sign marking flips values in place, so every later read must be abs(x). Cyclic sort permutes the array. Restore or warn.

Using a hash map where order is the question. Ranges, nearest values, “the smallest greater than x”, and top-k by value rather than by count are all order questions, and a hash map cannot answer any of them without a full scan. Reach for sorted plus bisect, or for a heap.

Assuming the alphabet. ord(ch) - 97 is wrong the moment uppercase, digits or Unicode appear. Ask what the input alphabet is, and use a dict if the answer is anything but lowercase ASCII.

Done when

  • Given a problem statement you have not seen, you can name the key — complement, canonical form, prefix sum, remainder, or index — in under 30 seconds, and say why the other four are wrong.
  • You can write the prefix-sum counter map from a blank file in two minutes, explain the {0: 1} seed by naming the empty prefix, and convert it to the {0: -1} longest-subarray form without hesitating.
  • You can write Two Sum, Group Anagrams and Top K Frequent from memory in five minutes total, with the lookup before the insert in the first and a hashable canonical key in the second.
  • You can state, for any array problem, whether a hash map beats sorting, and give the memory cost of choosing it — including the cases in P2, P13 and P22 where sorting or the array itself wins.