Hash Maps & Sets

Leverage constant-time lookups for frequency counting and duplicate detection

Last generated

Lesson 8 of 23 available20 practice questions

SPACED REPETITION Β· 20 practice questions

Make this lesson stick.

Try 3 questions now. No account needed. Sample answers aren't saved.

Stop searching the past. Look it up by name.

You have 100,000 transaction amounts, and you need two of them that add up to exactly 1,000. Checking every pair means n(nβˆ’1)/2 = 4,999,950,000 additions. A judge gives you a second or two.

Now watch what the slow version actually does. When it reaches an amount of 380, it scans everything before it for one specific number: 620. It isn't comparing anything interesting. It's searching for a value it can name in advance. A dictionary answers "is 620 here, and where?" in expected constant time, so the whole job becomes one pass and 100,000 lookups.

In the foundation lesson the thing you looked up was the value itself ("have I seen 7 before?"). This lesson generalizes that move. The key you look up can be the value's partner (target βˆ’ x), a signature shared by a whole group (the sorted letters of a word), or a running total (prefix βˆ’ k). Almost every hash problem comes down to one design decision:

Decide what question each element asks about the past. Then store the past keyed by the answer to that question.

# The current element asks… Store (key β†’ value) Signature problem
1 "How many times does this occur in total?" item β†’ count First Unique Character
2 "Has my partner target βˆ’ x appeared, and where?" value β†’ index (or count) Two Sum
3 "Which group do I belong to?" canonical key β†’ list of members Group Anagrams
4 "How many earlier prefix sums equal mine minus k?" prefix sum β†’ count (or first index) Subarray Sum Equals K
5 "Is my neighbour x βˆ’ 1 here?" set of values Longest Consecutive Sequence

Before you start: this lesson builds on Foundation: Arrays & Strings. You should already know the "seen set" for duplicates, 26-slot count vectors for anagrams, output contracts, and the prefix-sum formula sum(l..r) = prefix[r + 1] βˆ’ prefix[l]. Those ideas are used here without re-teaching them.

How a dictionary finds things (and when it can't)

You don't need to build a hash table in an interview. You do need an accurate picture of one, because that picture tells you what "O(1)" promises, which keys are legal, and how a correct-looking solution can quietly go quadratic.

Slots, collisions, probing

A Python dict keeps an array of slots, and a set works the same way without the values (CPython tunes the two differently, as you'll see). To store a key, Python computes hash(key), an integer, and turns it into a starting slot. For a table of 8 slots, think hash(key) % 8. Small integers hash to themselves, which makes them handy for a picture:

8 slots. Start slot = hash(key) % 8. Illustration uses "try the next slot".

insert 13 β†’ 13 % 8 = 5 β†’ slot 5 empty                      β†’ store in 5
insert 22 β†’ 22 % 8 = 6 β†’ slot 6 empty                      β†’ store in 6
insert 29 β†’ 29 % 8 = 5 β†’ 5 holds 13, 6 holds 22, 7 empty  β†’ store in 7

slot:   0     1     2     3     4     5     6     7
      [   ] [   ] [   ] [   ] [   ] [13 ] [22 ] [29 ]

lookup 29: slot 5 (13, no) β†’ 6 (22, no) β†’ 7 (29, yes)             found
lookup 37: slot 5 (13) β†’ 6 (22) β†’ 7 (29) β†’ 0 (empty)             not present

Two different keys wanting the same slot is a collision, and collisions are normal. Tables resolve them in one of two ways. Chaining hangs a small list off each slot; Java's HashMap works this way. Open addressing probes other slots by a fixed rule until it finds the key or an empty slot; CPython's dict and set work this way. CPython's real probe order isn't simply "next slot": a dict jumps around the table, and a set checks a few neighbouring slots before jumping. The picture is the same either way: a lookup is short as long as the probe sequences are short.

Each stored entry keeps its full hash. A probe calls == on an entry only when the full hashes match. (A dict also accepts an entry that is the very same object before looking at hashes; a set checks the hash first.) Hold on to that detail; it explains the rules for keys below.

Why "O(1)" is an expected cost

Probe sequences stay short for two reasons:

  • The table never gets crowded. CPython keeps a dict at most about two-thirds full. When an insert would pass that point, it allocates a table roughly twice as large and re-inserts every entry. (A set uses a lower fill limit and grows faster while small; the argument is the same.) That resize costs O(n), but the next one waits until the bigger table fills up, so the re-insert work over all resizes is a geometric series: n + n/2 + n/4 + … < 2n. Spread over n inserts, that is amortized O(1) per insert.
  • Good hashes scatter keys. Different keys usually start at different slots.

The second reason is an assumption, not a guarantee. You can break it on purpose and count the damage:

class Key:
    eq_calls = 0
    def __init__(self, v):
        self.v = v
    def __eq__(self, other):
        Key.eq_calls += 1
        return self.v == other.v
    def __hash__(self):
        return 42                        # every key collides

for n in (1000, 2000, 4000):
    Key.eq_calls = 0
    table = {}
    for i in range(n):
        table[Key(i)] = i
    print(n, Key.eq_calls)
1000 500300
2000 2000800
4000 8000531

Every new key has to be compared with every key already stored, so n inserts make roughly nΒ²/2 comparisons. Double n and the work quadruples: the dict has quietly become a list you search from the front. Change __hash__ to return hash(self.v) and the same run makes 0 calls to __eq__, because different full hashes never need an == check.

So the honest statement is: dict and set operations cost expected O(1) time, and O(n) in the worst case, when many keys collide. Built-in hashes make that worst case rare. Python also salts str hashes with a random value per process, so hash("abc") changes between runs and outsiders can't easily aim collisions at your string keys. Integers are not salted: hash(12345) == 12345. Complexity claims in this lesson therefore say "expected".

One more cost hides in the key itself. Hashing a string or tuple of length k takes O(k) time, and so does comparing two of them. (A str caches its hash after the first time, but a fresh string, such as a new slice, pays again, and a successful lookup still compares all k characters.) "O(1) per lookup" means O(1) for small keys such as integers. With 30-letter string keys, a lookup does work proportional to 30, not 1.

What can be a key

Predict first: this code stores a grid cell in a set and then moves the cell. What does the last line print?

class Cell:
    def __init__(self, r, c):
        self.r, self.c = r, c
    def __eq__(self, other):
        return (self.r, self.c) == (other.r, other.c)
    def __hash__(self):
        return hash((self.r, self.c))

cell = Cell(0, 0)
visited = {cell}
cell.r = 5
print(cell in visited, Cell(0, 0) in visited, Cell(5, 0) in visited, len(visited))
Check your answer

False False False 1. The object is still in the set, but no lookup can reach it.

  • cell in visited now hashes (5, 0). The stored entry carries the hash of (0, 0), so the full hashes never match.
  • Cell(0, 0) in visited starts in the right place and its hash matches the stored one. Then == compares it with the stored object, which now says (5, 0). No match.
  • Cell(5, 0) has the wrong hash, just like cell.

A key's hash must never change while the key is in a table. That rule is why Python refuses mutable built-ins as keys. (With a dict instead of a set, looking up the same object can occasionally still succeed, because a dict checks identity first. Don't build on that.)

The rules follow from the slot picture:

  1. A key must not change while it is stored. Its hash decided where it lives. int, float, str, bytes, frozenset and tuples of hashable things are hashable. list, set and dict are not, and using one as a key raises TypeError ("unhashable type"). That includes a tuple that contains a list: hash((1, [2])) fails.
  2. Equal keys must have equal hashes. The table compares hashes before it calls ==, so two "equal" objects with different hashes would be stored as different keys. Python keeps this promise for its own types, even across types: 1 == 1.0 == True, and {1: 'a', 1.0: 'b', True: 'c'} is {1: 'c'}, a single key.
  3. Your own classes: base __eq__ and __hash__ on the same fields, and don't change those fields afterwards. @dataclass(frozen=True) writes both methods for you and forbids the mutation. If you define __eq__ without __hash__, Python makes instances unhashable.

For LeetCode, the practical rule is: turn lists into tuples, sets into frozensets, and multi-part keys into tuples. A grid cell is (r, c), not str(r) + str(c). That string version gives the cells (1, 23) and (12, 3) the same key, "123".

Your turn: a grid solver with 20 columns stores visited cells under the integer key r * 10 + c. Find a cell that collides with (1, 13). Then give two key formulas that can never collide.

Check your answer

1 * 10 + 13 = 23, and (2, 3) also gives 2 * 10 + 3 = 23. The multiplier must be at least the number of columns: with c < 20, the key r * 20 + c is unique, because c can never spill into the next row's range. The tuple (r, c) is unique with no arithmetic at all, so it is the default choice.

The Python toolkit

You want to… Write If the key is missing
read a value that must exist d[k] raises KeyError
read with a fallback d.get(k, 0) returns 0; the dict is unchanged
test membership k in d False
keep only the first value stored d.setdefault(k, v) stores v, then returns the stored value
count things c = Counter(items), then c[k] returns 0 and does not insert k
group things g = defaultdict(list), then g[k].append(x) inserts [], even when you only read

Four behaviours cause a lot of hash-table bugs:

  • Reading a defaultdict inserts. if g[k]: on a missing k adds k. That changes len(g), and if you are looping over g at the time, the next step raises RuntimeError: dictionary changed size during iteration. Use k in g when you only want to ask.
  • Don't add or remove keys while iterating over a dict or set. Updating the values of existing keys is fine. To delete, loop over list(d), or build a new dict.
  • d.get(k) returns None for a missing key, and a stored 0 is falsy too. if d.get(k): cannot tell "absent" from "stored at index 0". Test with k in d or is not None.
  • Order: a dict remembers insertion order. The language has guaranteed that since Python 3.7, so list(dict.fromkeys(items)) is a legitimate order-preserving dedupe. A set has no guaranteed order: {10, 1, 5, 100, 3} may iterate as 1, 3, 100, 5, 10.

Set or dict? Use a set when the only question is "is x here?". Use a dict when you need something about x: where it was, how many times, which group. Choose for the question you're asking, not for memory. In CPython a set of n keys is not reliably smaller than a dict with the same keys.

Move 1: Count first, decide second

Smell: the answer depends on how often something appears in the whole input, so no single left-to-right glance can decide it.

Take LeetCode 387: First Unique Character in a String: return the index of the first character that appears exactly once, or βˆ’1.

Predict first: can you solve "loveleetcode" in a single left-to-right pass that keeps only a set of characters seen so far?

Check your answer

No. At index 0 you see 'l', and nothing in the past says whether another 'l' is coming (one is, at index 4). A seen-set answers "did this appear before?", but uniqueness also depends on the future. You need the totals first, then a second pass that makes the decision.

The slow version calls s.count(ch) for every position. Each call scans the whole string, so that's O(nΒ²). What it repeats: the same totals, recomputed for every position. Compute them once:

from collections import Counter

def first_uniq_char(s):
    counts = Counter(s)            # pass 1: totals
    for i, ch in enumerate(s):     # pass 2: first one with total 1
        if counts[ch] == 1:
            return i
    return -1

On "loveleetcode" the totals are e: 4, l: 2, o: 2, and 1 for each of v, t, c, d.

i ch counts[ch] decision
0 l 2 keep going
1 o 2 keep going
2 v 1 return 2

Two passes of O(n) each, so O(n) expected time. The extra space is O(Οƒ), where Οƒ is the number of distinct characters (at most 26 when the input is lowercase letters). With a known small alphabet, the 26-slot array from the foundation lesson does the same job. Reach for Counter when the keys are words, large integers or tuples.

Why it works. After pass 1, counts[ch] is the total number of occurrences of ch, which is exactly the fact a single pass lacked. Pass 2 walks positions in order, so the first position that passes the test is the first unique character.

Traps.

  • max(counts, key=counts.get) finds a most frequent item, but it raises ValueError on an empty dict. Decide what empty input should return.
  • counts.most_common(k) sorts by count, and ties keep first-seen order. Frequency ranking (top k) is a heap problem; see Heaps & Priority Queues.

Your turn: LeetCode 884: Uncommon Words from Two Sentences. A word is uncommon if it appears exactly once in one sentence and not at all in the other. For s1 = "this apple is sweet" and s2 = "this apple is sour" the answer is ["sweet", "sour"]. What single condition replaces "exactly once here and nowhere there"?

Check your answer

"Exactly once across both sentences combined." If a word appears once in s1 and not in s2, its combined count is 1. Any other situation gives a combined count of 0 or at least 2.

from collections import Counter

def uncommon_from_sentences(s1, s2):
    counts = Counter(s1.split()) + Counter(s2.split())
    return [w for w, c in counts.items() if c == 1]

For the example this returns ['sweet', 'sour']. Time and space are O(total characters), because hashing each word costs O(its length). Rephrasing a two-part condition as one count is a common counting move.

Move 2: Look up your partner

Smell: a nested loop where the inner loop searches for a specific value that the outer element determines. For a pair sum, that value is target βˆ’ x.

LeetCode 1: Two Sum: return the indices of the two numbers that add up to target. Exactly one answer exists, and you may not use the same element twice. The array can hold up to 10⁴ numbers, so checking all pairs means up to 49,995,000 of them.

Predict first: this one-pass attempt registers each number before looking for its partner. What does it return for nums = [3, 2, 4], target = 6?

def two_sum_eager(nums, target):
    seen = {}
    for i, x in enumerate(nums):
        seen[x] = i                        # register first...
        if target - x in seen:             # ...then look
            return [seen[target - x], i]
    return []
Check your answer

[0, 0]. At index 0, x = 3, so the partner it needs is also 3. The function has just stored 3, so it pairs the element with itself. The correct answer is [1, 2] (2 + 4). With [3, 3] it also returns [0, 0] instead of [0, 1]. Order matters: look first, then register.

The slow version and what it repeats

def two_sum_slow(nums, target):
    for j in range(len(nums)):
        for i in range(j):                 # search the past...
            if nums[i] == target - nums[j]:
                return [i, j]
    return []

For each j, the inner loop searches the past for one value it could name in advance, target βˆ’ nums[j]. A search by value is exactly what a dictionary replaces.

The fast version

def two_sum(nums, target):
    seen = {}                      # value -> index where it appeared
    for i, x in enumerate(nums):
        need = target - x
        if need in seen:           # 1. look for the partner
            return [seen[need], i]
        seen[x] = i                # 2. then register yourself
    return []

Trace nums = [4, 11, 7, 2, 15], target = 9:

i x need = 9 βˆ’ x need in seen? seen afterward
0 4 5 no {4: 0}
1 11 βˆ’2 no {4: 0, 11: 1}
2 7 2 no {4: 0, 11: 1, 7: 2}
3 2 7 yes, at index 2 β†’ return [2, 3]

Expected O(n) time and O(n) extra space, for n = len(nums). That trade (memory for time) is the usual price of hashing.

Why it works. The invariant: before index i is processed, seen holds every value among nums[0..iβˆ’1], each with an index where it occurs. Take any valid pair j < i. When the loop reaches i, nums[j] is already in seen, so the pair is found at its later element. Checking before registering means seen never contains the current element, so the partner is always a different index.

Store what you are, look up what you need. The dict's keys are values you have seen. The lookup asks for the value you need. (The mirror design, storing each element's need and checking whether x is in it, also works. Mixing the two does not.)

Traps and edge cases.

  • Duplicates. [3, 3] with target 6 works: the first 3 is stored before the second one looks. A later duplicate overwrites the stored index. That's harmless here, but if a variant wants the earliest index, use seen.setdefault(x, i).
  • "Is it there?" versus "what is stored?" j = seen.get(need) followed by if j: skips a partner at index 0, because 0 is falsy. Test with need in seen, or j is not None.
  • Sorted input and O(1) extra space. Then two pointers from both ends need no dict at all; see Two Pointer Techniques. Sorting an unsorted array just to use two pointers loses the original indices unless you carry them along.
  • Several possible partners. If a pair "works" when the sum is any power of two, or when the difference is exactly k, look up each candidate partner. That's a few lookups per element instead of one.
Two Sum in one pass: each number looks up the partner it needs, then stores itself

Plot twist: which pairs are you counting?

"Count the pairs that sum to 4" in [2, 2, 2, 2] has three different correct answers, depending on the contract:

Contract Answer What to store
index pairs i < j 6 value β†’ how many times seen
distinct value pairs {a, b} 1 value β†’ count; a value paired with itself needs 2 copies
disjoint pairs, each element used once (LeetCode 1679: Max Number of K-Sum Pairs) 2 value β†’ how many are still unmatched

A plain set of seen values returns 3 here, which answers none of the three. It knows whether a 2 appeared, not how many did.

For index pairs, add how many partners are already waiting, then register yourself:

def count_pairs(nums, target):
    counts = {}
    pairs = 0
    for x in nums:
        pairs += counts.get(target - x, 0)   # every earlier partner makes a pair
        counts[x] = counts.get(x, 0) + 1
    return pairs

Each pair is counted exactly once, at its later element: 0 + 1 + 2 + 3 = 6. To count pairs with equal keys instead (nums[i] == nums[j]), look up x itself rather than target βˆ’ x.

For disjoint pairs, a match consumes the waiting partner:

def max_operations(nums, k):
    waiting = {}                              # value -> unmatched copies
    ops = 0
    for x in nums:
        if waiting.get(k - x, 0) > 0:
            waiting[k - x] -= 1               # use up one partner
            ops += 1
        else:
            waiting[x] = waiting.get(x, 0) + 1
    return ops

Both run in expected O(n) time with O(n) extra space. 3Sum is Two Sum inside a loop: fix one element, then find a pair in the rest, which is O(nΒ²) overall. Removing duplicate triplets is much easier after sorting, so that problem lives in Two Pointer Techniques.

Your turn: LeetCode 2006: Count Number of Pairs With Absolute Difference K. Count pairs i < j with |nums[i] βˆ’ nums[j]| == k, where k β‰₯ 1. Write it in one pass. Predict the results for [1, 2, 2, 1] with k = 1 and for [3, 2, 1, 5, 4] with k = 2. Then: what would your function return for [1, 1] with k = 0, and why?

Check your answer

The partner of x can be x βˆ’ k or x + k, because the earlier element may be smaller or larger. Look up both:

def count_pairs_abs_diff(nums, k):
    counts = {}
    pairs = 0
    for x in nums:
        pairs += counts.get(x - k, 0) + counts.get(x + k, 0)
        counts[x] = counts.get(x, 0) + 1
    return pairs

The results are 4 and 3. With k = 0, x βˆ’ k and x + k are the same key, so every pair is counted twice: [1, 1] returns 2 instead of 1. LeetCode's k β‰₯ 1 keeps you safe here; if k = 0 were allowed, you would look up once. Expected O(n) time, O(n) space.

Move 3: Design the key

Smell: "group together the items that are equivalent", or "are these two the same up to X?". The slow code compares each item with every group formed so far.

LeetCode 49: Group Anagrams: given ["eat", "tea", "tan", "ate", "nat", "bat"], return the groups [["eat", "tea", "ate"], ["tan", "nat"], ["bat"]] in any order.

Predict first: which of these keys puts two words in the same group exactly when they are anagrams?

  1. frozenset(w)
  2. sum(ord(ch) for ch in w)
  3. sorted(w)
  4. "".join(sorted(w))
Check your answer

Only 4.

  1. frozenset forgets how many times each letter appears: "aab" and "abb" both become {a, b}.
  2. The letter sum merges words that aren't anagrams: "ad" and "bc" both sum to 197.
  3. sorted(w) returns a list, so using it as a key raises TypeError. (tuple(sorted(w)) works.)

Slow, then fast

The slow approach keeps a list of groups and checks each word against one representative per group with an anagram test. With g groups that is O(g Β· k) per word, where k is the word length. When most words are unrelated, g grows toward n, and the total approaches O(nΒ² Β· k).

What it repeats is the question "which group is mine?", asked by comparing. Replace the comparison with a canonical key: a value that every member of a group shares and no outsider has. Then the dict finds your group in one lookup:

from collections import defaultdict

def group_anagrams(words):
    groups = defaultdict(list)
    for w in words:
        groups["".join(sorted(w))].append(w)
    return list(groups.values())
word key groups afterward
eat aet {'aet': ['eat']}
tea aet {'aet': ['eat', 'tea']}
tan ant {'aet': ['eat', 'tea'], 'ant': ['tan']}
ate aet {'aet': ['eat', 'tea', 'ate'], 'ant': ['tan']}
nat ant {'aet': ['eat', 'tea', 'ate'], 'ant': ['tan', 'nat']}
bat abt {'aet': ['eat', 'tea', 'ate'], 'ant': ['tan', 'nat'], 'abt': ['bat']}

The groups come out in first-seen order because dicts keep insertion order. LeetCode accepts any order anyway. The empty string is a legal word: its key is "", and it forms its own group.

Why it works. A key function f groups correctly exactly when f(a) == f(b) if and only if a and b belong together. The key can fail in two directions:

Key failure Symptom Example
too coarse merges groups that should be separate frozenset(w), letter sums, len(w)
too fine splits a real group w itself, tuple(w) (keeps letter order)

Sorted letters pass both tests. Two words are anagrams exactly when they have the same letters with the same multiplicities, and sorting keeps the multiplicities while forgetting the order.

A second key: the count vector

The foundation lesson's 26-slot count vector is also an exact anagram signature. Turn it into a tuple to make it hashable:

def anagram_key(w):
    counts = [0] * 26
    for ch in w:
        counts[ord(ch) - ord('a')] += 1
    return tuple(counts)

For n words of length at most k, sorted keys cost O(n Β· k log k) time and count keys cost O(n Β· (k + 26)). Both use O(n Β· k) space for the groups. The asymptotic winner is not always the stopwatch winner. In one CPython 3.14 run on 2,000 random words, sorted keys were faster up to about 15 letters (sorting runs in C, while the count loop runs in Python), and count keys pulled ahead from about 25 letters. Either answer is fine in an interview if you state the costs correctly.

Your turn: LeetCode 205: Isomorphic Strings. Two strings are isomorphic if you can replace characters one-for-one (a bijection) to turn one into the other: "egg" and "add" are, "foo" and "bar" are not, "paper" and "title" are. Design a key shape(s) so that two strings are isomorphic exactly when their shapes are equal. What is shape("paper")?

Check your answer

Replace each character by the order in which it first appeared:

def shape(s):
    first = {}                                   # char -> order of first appearance
    return tuple(first.setdefault(ch, len(first)) for ch in s)

def is_isomorphic(s, t):
    return len(s) == len(t) and shape(s) == shape(t)

shape("paper") and shape("title") are both (0, 1, 0, 2, 3). "egg" and "add" are both (0, 1, 1), while "bar" is (0, 1, 2). Time is O(n) expected, and space is O(n) for the tuples.

The classic alternative keeps two maps, s→t and t→s, and rejects any conflict. One map is not enough. With s = "ab" and t = "aa", the map s→t is a→a, b→a, which never conflicts, yet two letters share one image. The reverse map catches it. The shape key has the bonus that it can group many strings by pattern, just like Group Anagrams.

Move 4: Prefix sums meet a dictionary

Smell: count (or find the longest) contiguous subarray with an exact sum property, and the numbers can be negative.

LeetCode 560: Subarray Sum Equals K: count the contiguous subarrays of nums whose sum is exactly k. There are up to 2 Γ— 10⁴ numbers, each between βˆ’1000 and 1000.

Predict first: how many subarrays of [1, βˆ’1, 1, βˆ’1] sum to 0?

Check your answer

4: [1, βˆ’1] at indices 0–1, [βˆ’1, 1] at 1–2, [1, βˆ’1] at 2–3, and the whole array. The prefix sums are 0, 1, 0, 1, 0. Every subarray summing to 0 sits between two equal prefix sums. The value 0 appears three times, which gives 3 pairs, and the value 1 appears twice, which gives 1 more. Hold on to that "pairs of prefix sums" picture.

Why a window doesn't work

With only positive numbers you could slide a window, growing it while the sum is below k and shrinking it while the sum is above k (see Sliding Window Patterns). Negatives break the rule the window depends on: adding an element can make the sum smaller. So "too big, shrink" and "too small, grow" stop being safe moves. The brute force checks every (start, end) pair, which is 200,010,000 subarrays when n = 2 Γ— 10⁴.

From the prefix formula to a lookup

From the foundation lesson: with prefix[0] = 0 and prefix[i + 1] = prefix[i] + nums[i],

sum(l..r) = prefix[r + 1] βˆ’ prefix[l]

Fix the right end r. The subarray l..r sums to k exactly when prefix[l] = prefix[r + 1] βˆ’ k. So as you sweep r to the right, the question is: how many earlier prefix sums equal current βˆ’ k? That's a counting lookup. Keep a dict from prefix value to how many times it has occurred:

def subarray_sum(nums, k):
    seen = {0: 1}              # the empty prefix: sum 0, seen once
    prefix = 0
    count = 0
    for x in nums:
        prefix += x
        count += seen.get(prefix - k, 0)     # subarrays ending here
        seen[prefix] = seen.get(prefix, 0) + 1
    return count

Trace nums = [1, 2, βˆ’2, 2, 2, 1], k = 3:

i x prefix looks for prefix βˆ’ k found count seen afterward
0 1 1 βˆ’2 0 0 {0: 1, 1: 1}
1 2 3 0 1 1 {0: 1, 1: 1, 3: 1}
2 βˆ’2 1 βˆ’2 0 1 {0: 1, 1: 2, 3: 1}
3 2 3 0 1 2 {0: 1, 1: 2, 3: 2}
4 2 5 2 0 2 {0: 1, 1: 2, 3: 2, 5: 1}
5 1 6 3 2 4 {0: 1, 1: 2, 3: 2, 5: 1, 6: 1}

The last row finds two subarrays at once. Prefix 3 occurred after index 1 and after index 3, so both [βˆ’2, 2, 2, 1] (indices 2–5) and [2, 1] (4–5) end here with sum 3. The other two are [1, 2] and [1, 2, βˆ’2, 2]. Total 4.

Why it works. The invariant: when index r is processed, seen[v] is the number of boundaries l in 0..r with prefix[l] = v. Every subarray has exactly one right end, and it is counted once, at that end. Expected O(n) time. The extra space is O(n), one entry per distinct prefix value.

Three traps, three wrong answers:

  • Forgetting {0: 1}. prefix[0] = 0 is a real boundary, the start of the array. Without it, subarrays that start at index 0 are missed: [1, 2, 3] with k = 3 gives 1 instead of 2.
  • Registering before looking. If you add the current prefix before the lookup, then when k = 0 every prefix matches itself, and you count an empty subarray at each step. [1, βˆ’1, 1, βˆ’1] with k = 0 gives 8 instead of 4.
  • A set instead of counts. Repeated prefix values are the whole point: the last row of the trace needed the number 2, not "yes".
Subarray Sum Equals K: counting earlier prefix sums finds the subarrays that end at each position

The longest version: keep the first index

To find the longest subarray with a property, store the earliest index at which each prefix value occurs. For a fixed right end, the longest subarray starts at the leftmost matching boundary.

LeetCode 525: Contiguous Array asks for the longest subarray of a binary array with equally many 0s and 1s. Count 1 as +1 and 0 as βˆ’1. Then "equal counts" means "sum 0", which means "two equal running balances":

def find_max_length(nums):
    first = {0: -1}                 # balance -> earliest index; empty prefix at -1
    balance = 0
    best = 0
    for i, x in enumerate(nums):
        balance += 1 if x == 1 else -1
        if balance in first:
            best = max(best, i - first[balance])
        else:
            first[balance] = i      # only the first occurrence is kept
    return best

Trace [1, 0, 1, 1, 1, 0, 0]:

i x balance seen before? length best
0 1 1 no β†’ store 1: 0 β€” 0
1 0 0 yes, first at βˆ’1 2 2
2 1 1 yes, first at 0 2 2
3 1 2 no β†’ store 2: 3 β€” 2
4 1 3 no β†’ store 3: 4 β€” 2
5 0 2 yes, first at 3 2 2
6 0 1 yes, first at 0 6 6

The answer is 6: indices 1–6, [0, 1, 1, 1, 0, 0]. Two details decide it. The base entry is {0: -1} (an index, not a count), so row 1 can measure from before the array. And the else keeps the first index. Overwrite it with every new index and row 6 measures from index 2, returning 4.

Question Dict value Base entry
how many subarrays? how many times each prefix was seen {0: 1}
longest subarray? earliest index of each prefix {0: -1}

Range-sum queries on a fixed array need no dict at all. They belong to Prefix, Suffix & Range Updates.

Your turn: LeetCode 974: Subarray Sums Divisible by K. Count the subarrays whose sum is divisible by k. nums = [4, 5, 0, βˆ’2, βˆ’3, 1], k = 5 should give 7. What should the key be, what is the base entry, and what changes if you port the solution to Java?

Check your answer

A subarray sum prefix[r + 1] βˆ’ prefix[l] is divisible by k exactly when the two prefixes leave the same remainder. So key the dict by prefix % k, and count pairs of equal remainders. The base entry is {0: 1}, because the empty prefix has remainder 0.

def subarrays_div_by_k(nums, k):
    seen = {0: 1}
    rem = 0
    count = 0
    for x in nums:
        rem = (rem + x) % k           # Python: always in 0..k-1 for k > 0
        count += seen.get(rem, 0)
        seen[rem] = seen.get(rem, 0) + 1
    return count

This returns 7 on the example. In Python, -2 % 5 is 3, so negative sums land in the right bucket. In Java, C++ or C#, -2 % 5 is -2, and the remainders βˆ’2 and 3 (which mean the same thing) would become different keys. Normalize with ((r % k) + k) % k. The dict never holds more than k keys, so the extra space is O(min(n, k)), and the time is expected O(n).

Move 5: Sets that ask "is my neighbour here?"

Smell: the question is about which values exist near each other, positions don't matter, and sorting is too slow or not allowed.

LeetCode 128: Longest Consecutive Sequence: return the length of the longest run of consecutive integers that appear in the array, in any order. The algorithm must run in O(n) time. n goes up to 10⁡ and values up to ±10⁹.

Predict first: what are the answers for [100, 4, 200, 1, 3, 2] and for [1, 2, 2, 3]? Would sorting meet the requirement?

Check your answer

4 (1, 2, 3, 4) and 3 (1, 2, 3; the duplicate 2 neither breaks nor extends the run). Sorting and then scanning is correct if you skip duplicates, but sorting costs O(n log n), and the problem demands O(n).

The other slow idea walks upward from every value: from 1 it walks 1, 2, 3, 4, then from 2 it walks 2, 3, 4 again, and so on. A run of length L costs about LΒ²/2 steps. What repeats is walking the same run from its middle. So only start walking where a run begins, at an x whose x βˆ’ 1 is absent:

def longest_consecutive(nums):
    values = set(nums)
    best = 0
    for x in values:              # iterate the set, not nums
        if x - 1 in values:       # not a run start; its run is walked elsewhere
            continue
        length = 1
        while x + length in values:
            length += 1
        best = max(best, length)
    return best

On [100, 4, 200, 1, 3, 2]:

x is x βˆ’ 1 in the set? action run length
1 0 is absent walk 1…4 4
2 1 is present skip β€”
3 2 is present skip β€”
4 3 is present skip β€”
100 99 is absent walk 100…100 1
200 199 is absent walk 200…200 1

The set may hand you the values in any order; the result is the same. The answer is 4.

Why it's O(n) despite the nested loop. A nested loop is not automatically O(nΒ²); count the work. Every distinct value belongs to exactly one run, and each run is walked exactly once, from its start. So the inner while makes at most u successful steps in total, where u is the number of distinct values. The outer loop runs u times. Building the set is O(n). That gives expected O(n) time and O(u) extra space.

⚠️ Iterate the set, not nums. With duplicates, looping over nums visits a run start once per copy and walks the whole run each time. Take m copies of 1 followed by 1, 2, …, m. Iterating the set takes m βˆ’ 1 walking steps. Iterating the list takes mΒ² βˆ’ 1. With m = 4,000 that was 3,999 steps against 15,999,999 in a real run, and at LeetCode's n = 10⁡ it would be about 2.5 billion.

Sets also do algebra. For LeetCode 349: Intersection of Two Arrays, list(set(a) & set(b)) is the whole solution: expected O(len(a) + len(b)) time. |, - and ^ give union, difference and symmetric difference.

Your turn: LeetCode 202: Happy Number. Replace n by the sum of the squares of its digits, and repeat. n is happy if this reaches 1. Otherwise it loops forever through a cycle that doesn't contain 1. Decide is_happy(19) and is_happy(2). How do you know when to stop?

Check your answer

The process is deterministic, so once a number repeats, the future repeats too. Keep a set of the states you've visited:

def is_happy(n):
    seen = set()
    while n != 1 and n not in seen:
        seen.add(n)
        n = sum(int(d) ** 2 for d in str(n))
    return n == 1

19 β†’ 82 β†’ 68 β†’ 100 β†’ 1, so it's happy (True). 2 β†’ 4 β†’ 16 β†’ 37 β†’ 58 β†’ 89 β†’ 145 β†’ 42 β†’ 20 β†’ 4 reaches 4 again without meeting 1, so it's not (False). A set of visited states is the general tool for "stop when something repeats". If extra space is forbidden, the fast/slow pointer cycle detection from Two Pointer Techniques finds the same cycle in O(1) space.

When a hash table is the wrong tool

A dict is the default answer to "search the past by value", but not the only one. Check the constraints before you reach for one.

Situation Better tool Why
sorted input, pair sum, O(1) extra space two pointers (Two Pointer Techniques) the order already tells you which way to move
keys are small integers (26 letters, remainders mod 60, values 1..100) a plain list indexed by the key no hashing; space fixed by the key range
you need the smallest, largest or k-th item a heap or sorting (Heaps & Priority Queues) a dict has no order by value
many range-sum queries on fixed data a prefix array (Prefix, Suffix & Range Updates) no lookup by value is needed
values are exactly 1..n and O(1) extra space is required the array as its own table see below

No dict needed: the array is its own hash table

LeetCode 442: Find All Duplicates in an Array: n integers, each in [1, n], each appearing once or twice. Return the ones that appear twice, in O(n) time and O(1) extra space besides the output.

A value v can be sent to index v βˆ’ 1. That is a perfect "hash function": no two values share a slot, and it covers exactly the array's own indices. Use the sign of nums[v βˆ’ 1] as the "seen v" bit:

def find_duplicates(nums):
    dups = []
    for x in nums:
        slot = abs(x) - 1          # abs: the value may already carry a mark
        if nums[slot] < 0:
            dups.append(abs(x))    # second visit to this slot
        else:
            nums[slot] = -nums[slot]
    return dups

[4, 3, 2, 7, 8, 2, 3, 1] returns [2, 3]. The contract matters here. This solution mutates the input (you can restore it afterwards with abs), and it depends on values being exactly 1..n: an array of length n that contained the value n + 1 would index past the end. LeetCode 287: Find the Duplicate Number forbids modifying the array, and its standard solution is fast/slow cycle detection instead.

A dict keyed by function arguments is also how memoization caches results. Dynamic Programming Mastery builds on that.

Final round: no label on the problem

Each problem below needs one of this lesson's moves, possibly with a twist. Name the question each element asks, then choose the key.

Challenge 1: Contains Duplicate II

LeetCode 219: Contains Duplicate II: return True if two different indices i and j have nums[i] == nums[j] and |i βˆ’ j| ≀ k. [1, 2, 3, 1] with k = 3 β†’ True. [1, 2, 3, 1, 2, 3] with k = 2 β†’ False.

  1. What does each element need to know about the past?
  2. If a value appeared several times before, which earlier index is the only one worth remembering?
  3. Predict [1, 0, 1, 1] with k = 1.
Check your answer

Each element asks: "where did my value last appear?" The most recent earlier occurrence is the closest one, so if that one is too far away, every older one is too. Map value β†’ last index:

def contains_nearby_duplicate(nums, k):
    last = {}
    for i, x in enumerate(nums):
        if x in last and i - last[x] <= k:
            return True
        last[x] = i                 # overwrite: the latest index is the useful one
    return False

[1, 0, 1, 1] with k = 1 is True (indices 2 and 3). This time overwriting the index is exactly right, the opposite of Contiguous Array, because the contract asks for the closest pair, not the longest span. Expected O(n) time, O(n) space. (A set holding only the last k values is an O(k)-space alternative.)

Challenge 2: Pairs of songs

LeetCode 1010: Pairs of Songs With Total Durations Divisible by 60: count pairs i < j with (time[i] + time[j]) % 60 == 0. [30, 20, 150, 100, 40] β†’ 3. [60, 60, 60] β†’ 3. n goes up to 6 Γ— 10⁴.

  1. Which move is this, and what replaces "the partner value"?
  2. What partner does a song whose duration is a multiple of 60 need?
  3. Why is a dict not even necessary?
Hint

Only the remainder mod 60 matters. The partner of remainder r is remainder (60 βˆ’ r) % 60.

Check your answer

It's pair counting from Move 2 on remainders instead of values. A song with remainder r needs a partner with remainder (60 βˆ’ r) % 60. The outer % 60 sends r = 0 to partner 0 instead of to 60, which is not a remainder. There are only 60 possible keys, so a list works as the table:

def num_pairs_divisible_by_60(time):
    counts = [0] * 60
    pairs = 0
    for t in time:
        r = t % 60
        pairs += counts[(60 - r) % 60]   # earlier songs that complete this one
        counts[r] += 1
    return pairs

The examples give 3 and 3. That's O(n) time and O(60) = O(1) extra space. Forgetting the outer % 60 sends remainder 0 to counts[60], which raises IndexError.

Challenge 3 (boss level): four arrays

LeetCode 454: 4Sum II: four arrays a, b, c, d, each of length n ≀ 200. Count the tuples (i, j, k, l) with a[i] + b[j] + c[k] + d[l] == 0. For a = [1, 2], b = [βˆ’2, βˆ’1], c = [βˆ’1, 2], d = [0, 2] the answer is 2.

Four nested loops make 200⁴ = 1.6 billion checks. Where can a lookup replace work? What does it cost in memory?

Check your answer

Split the four arrays into two halves and do Two Sum on pair sums. Count every a[i] + b[j] in a dict (nΒ² entries at most). Then for every c[k] + d[l], look up how many first-half sums equal its negation:

from collections import Counter

def four_sum_count(a, b, c, d):
    ab = Counter(x + y for x in a for y in b)       # sum -> number of (i, j) pairs
    return sum(ab[-(x + y)] for x in c for y in d)  # Counter returns 0 if missing

This returns 2 on the example. That's O(nΒ²) expected time (80,000 sums instead of 1.6 billion quadruples) and O(nΒ²) extra space. This split-in-the-middle idea is Two Sum's "look up what you need" applied one level up. It trades memory for a whole factor of nΒ².

Cheat sheet: what does the element ask?

The current element asks… Store Start with Classic trap
"how many of me in total?" Counter(items) β€” deciding in the counting pass
"is my partner here, and where?" value β†’ index {} registering before looking; if get(...) on index 0
"how many partners are waiting?" value β†’ count {} a set loses multiplicity
"which group am I in?" canonical key β†’ list defaultdict(list) key too coarse or too fine; unhashable list keys
"how many earlier prefixes equal mine βˆ’ k?" prefix β†’ count {0: 1} missing base; set instead of counts
"where did this prefix first appear?" prefix β†’ earliest index {0: -1} overwriting the first index
"is x βˆ’ 1 here?" set of values set(nums) iterating nums with duplicates
"has this state happened before?" set of states set() looping "until 1" with no repeat check

Every row costs expected O(1) per lookup for small keys, O(k) for keys of length k, and O(n) extra space unless the key range is bounded.

Before moving on, take one problem from this lesson, open a blank editor, and explain it out loud. Say what each element asks about the past. Say what the dict stores, and what it holds right before index i is processed. Say why it's a dict and not a set (or the other way round), and what the base entry means. Then name the input that breaks the most tempting wrong version. If you can do that for Two Sum, Group Anagrams and Subarray Sum Equals K, you have the core of this lesson.

Next: Prefix, Suffix & Range Updates takes prefix sums beyond lookups, to range queries and difference arrays. Later, Dynamic Programming Mastery caches results in dicts, Heaps & Priority Queues turns counts into top-k answers, and String Algorithms hashes whole substrings with rolling hashes. Revisit Sliding Window Patterns and Longest Substring Without Repeats to see a count table and a last-seen-index map drive a moving window.