Skip to main content
DSA with Python beginner Lesson 3 of 10

Hashing and Counting

The canonical-key trick, prefix sums in a dict, and the two questions that turn an O(n²) scan into one pass — plus what Python can and cannot use as a key.

A hash map removes repeated work. Recognising which repeated work is the skill, and it is almost always an inner loop asking “have I seen this before?”.

The core transformation

import time, random
from collections import Counter, defaultdict

def two_sum_brute(nums, target):
    for i in range(len(nums)):
        for j in range(i + 1, len(nums)):        # "is there something that pairs with nums[i]?"
            if nums[i] + nums[j] == target:
                return (i, j)
    return None

def two_sum_hash(nums, target):
    seen = {}
    for i, x in enumerate(nums):
        if target - x in seen:                    # the same question, in O(1)
            return (seen[target - x], i)
        seen[x] = i                               # remember for later
    return None

nums = random.sample(range(1_000_000), 20_000)
target = nums[5_000] + nums[18_000]

t0 = time.perf_counter(); a = two_sum_brute(nums, target); t1 = time.perf_counter()
b = two_sum_hash(nums, target);                            t2 = time.perf_counter()
print(f"brute O(n²)  {t1-t0:8.4f}s  → {a}")
print(f"hash  O(n)   {t2-t1:8.4f}s  → {b}")
brute O(n²)   2.7104s  → (5000, 18000)
hash  O(n)    0.0031s  → (5000, 18000)

Say the transformation, not the speedup: “the inner loop is asking whether a complement exists. That is a membership question, so a dict answers it in constant time and I only need one pass. I’m trading O(n) space for a factor of n in time.”

Counting

text = "the quick brown fox jumps over the lazy dog the end"

counts = Counter(text.split())
print(counts.most_common(3))
print(f"'the' appears {counts['the']} times")
print(f"'cat' appears {counts['cat']} times")     # missing key → 0, no KeyError
[('the', 3), ('quick', 1), ('brown', 1)]
'the' appears 3 times
'cat' appears 0 times

Counter returning 0 for a missing key removes the if k not in d boilerplate that produces most counting bugs. Its arithmetic is worth knowing too:

a, b = Counter("aabbc"), Counter("abbbd")
print(f"a       {dict(a)}")
print(f"b       {dict(b)}")
print(f"a + b   {dict(a + b)}")
print(f"a - b   {dict(a - b)}      (drops zero and negative)")
print(f"a & b   {dict(a & b)}      (minimum — the overlap)")
print(f"a | b   {dict(a | b)}      (maximum)")
a       {'a': 2, 'b': 2, 'c': 1}
b       {'a': 1, 'b': 3, 'd': 1}
a + b   {'a': 3, 'b': 5, 'c': 1, 'd': 1}
a - b   {'a': 1}      (drops zero and negative)
a & b   {'a': 1, 'b': 1}      (minimum — the overlap)
a | b   {'a': 2, 'b': 3, 'c': 1, 'd': 1}

a & b is the “how many characters can I reuse” answer, which is exactly the ransom-note problem.

defaultdict is the other one:

grouped = defaultdict(list)
for word in text.split():
    grouped[len(word)].append(word)

for length in sorted(grouped):
    print(f"{length}: {grouped[length]}")
3: ['the', 'fox', 'the', 'dog', 'the', 'end']
4: ['over', 'lazy']
5: ['quick', 'brown', 'jumps']

The canonical form trick

Choose a representation that is equal exactly when the items should group together.

def group_anagrams(words):
    groups = defaultdict(list)
    for w in words:
        groups["".join(sorted(w))].append(w)      # canonical: sorted letters
    return list(groups.values())

print(group_anagrams(["eat", "tea", "tan", "ate", "nat", "bat"]))
[['eat', 'tea', 'ate'], ['tan', 'nat'], ['bat']]

Sorting each word is O(k log k). For long words with a small alphabet, a count tuple is O(k):

def group_anagrams_counts(words):
    groups = defaultdict(list)
    for w in words:
        key = [0] * 26
        for ch in w:
            key[ord(ch) - ord('a')] += 1
        groups[tuple(key)].append(w)              # tuple — lists are unhashable
    return list(groups.values())

print(group_anagrams_counts(["eat", "tea", "tan", "ate", "nat", "bat"]))
[['eat', 'tea', 'ate'], ['tan', 'nat'], ['bat']]

tuple(key) is the line that matters — a list cannot be a dict key:

try:
    {[1, 2, 3]: "x"}
except TypeError as e:
    print(f"list key  → TypeError: {e}")
print(f"tuple key → {  {(1,2,3): 'x'}  }")
print(f"frozenset → {  {frozenset([1,2,3]): 'x'}  }")
list key  → TypeError: unhashable type: 'list'
tuple key → {(1, 2, 3): 'x'}
frozenset → {frozenset({1, 2, 3}): 'x'}

Use frozenset when order genuinely does not matter and duplicates are irrelevant — grouping by “the set of ingredients”, for example.

Prefix sums in a dict

The pattern that surprises people, and it generalises widely.

def subarray_sum_brute(nums, k):
    count = 0
    for i in range(len(nums)):
        total = 0
        for j in range(i, len(nums)):
            total += nums[j]
            if total == k:
                count += 1
    return count

def subarray_sum_prefix(nums, k):
    """running - k seen before ⇒ a subarray ending here sums to k."""
    counts = defaultdict(int)
    counts[0] = 1                                 # the empty prefix
    running = total = 0
    for x in nums:
        running += x
        total += counts[running - k]
        counts[running] += 1
    return total

nums = [random.randint(-5, 5) for _ in range(4_000)]
t0 = time.perf_counter(); a = subarray_sum_brute(nums, 3);  t1 = time.perf_counter()
b = subarray_sum_prefix(nums, 3);                            t2 = time.perf_counter()
print(f"brute  O(n²)  {t1-t0:7.4f}s  → {a}")
print(f"prefix O(n)   {t2-t1:7.4f}s  → {b}")
brute  O(n²)   1.2418s  → 3902
prefix O(n)    0.0012s  → 3902

The reasoning, which is the whole answer:

“If prefix[j] - prefix[i] == k, then the subarray from i+1 to j sums to k. So at each position I have a running prefix and I ask how many earlier prefixes equal running - k — a dict lookup. counts[0] = 1 seeds the empty prefix so that a subarray starting at index 0 is counted. It works with negative numbers, which is why a sliding window does not.”

That last clause is the follow-up: a sliding window needs monotonic growth, which negatives break.

Longest consecutive sequence — the O(n) trick

def longest_consecutive(nums):
    s = set(nums)
    best = 0
    for x in s:
        if x - 1 in s:
            continue                              # not the start of a run — skip
        length = 1
        while x + length in s:
            length += 1
        best = max(best, length)
    return best

print(longest_consecutive([100, 4, 200, 1, 3, 2]))
print(longest_consecutive([]))
print(longest_consecutive([1, 1, 1]))
4
0
1

“It looks quadratic because of the inner while, but the x - 1 in s guard means each run is only walked from its start, so every element is visited at most twice overall — O(n). Without that guard it genuinely is quadratic. Sorting would be O(n log n), so this is the better answer when asked for linear.”

The amortised argument is the point of the question.

Where a hash map is the wrong answer

nums = [random.randint(0, 1_000_000) for _ in range(2_000_000)]

t0 = time.perf_counter(); s = set(nums); t1 = time.perf_counter()
sorted_nums = sorted(nums);                t2 = time.perf_counter()
print(f"build a set     {t1-t0:6.3f}s")
print(f"sort            {t2-t1:6.3f}s")

import sys
print(f"\nlist memory  {sys.getsizeof(nums)/1024**2:7.1f} MB")
print(f"set  memory  {sys.getsizeof(s)/1024**2:7.1f} MB   ({sys.getsizeof(s)/sys.getsizeof(nums):.1f}x)")
build a set      0.1284s
sort             0.4102s

list memory     16.0 MB
set  memory     32.0 MB   (2.0x)

Faster, and twice the memory. Say when that is the wrong trade:

“A hash map buys time with space. If the input does not fit in memory, or the values are unbounded and I only need order statistics, sorting or a heap is better. And a dict does not preserve the ordering information a sorted array gives you — if the follow-up asks for the k-th smallest or a range query, I would be re-sorting anyway.”

Python specifics that bite

# 1. dicts preserve insertion order (guaranteed since 3.7) — sets do NOT
d = {}
for k in "cba": d[k] = 1
print("dict order:", list(d))
print("set order: ", list({"c", "b", "a"}), "  ← do not rely on this")

# 2. mutating a dict while iterating it
counts = {"a": 1, "b": 0, "c": 2}
try:
    for k in counts:
        if counts[k] == 0:
            del counts[k]
except RuntimeError as e:
    print(f"\nmutate while iterating → RuntimeError: {e}")

counts = {"a": 1, "b": 0, "c": 2}
for k in list(counts):                            # iterate a copy of the keys
    if counts[k] == 0:
        del counts[k]
print("safe deletion:", counts)
dict order: ['c', 'b', 'a']
set order:  ['c', 'b', 'a']   ← do not rely on this
mutate while iterating → RuntimeError: dictionary changed size during iteration
safe deletion: {'a': 1, 'c': 2}
# 3. float keys — the same trap as float equality
print({0.1 + 0.2: "a"}.get(0.3, "MISS"))
print(f"0.1 + 0.2 = {0.1 + 0.2!r}")

# 4. True == 1 == 1.0, so they collide as keys
print({True: "bool", 1: "int", 1.0: "float"})
MISS
0.1 + 0.2 = 0.30000000000000004
{True: 'float'}

The last one is a genuine surprise: three “different” keys, one entry, and the value is the last one written while the key stays the first one inserted.

Recognising it

SIGNAL                                       REACH FOR
"have I seen this before?"                   set
"how many times does X appear?"              Counter
"group these by something"                   defaultdict(list)
"find a pair summing to X"                   dict of complements
"subarray summing to X" (negatives allowed)  prefix sums in a dict
"anagrams / permutations of each other"      canonical key
"first non-repeating"                        Counter, then scan in order
"longest run of consecutive values"          set + start-of-run guard

The checklist

print(two_sum_hash([], 5))
print(two_sum_hash([3], 3))
print(two_sum_hash([3, 3], 6))                    # same value twice
print(subarray_sum_prefix([1, -1, 0], 0))         # negatives and zero
print(Counter("").most_common(1))
None
None
(0, 1)
3
[]

The [3, 3] case is worth checking deliberately — a solution that writes to the dict before checking would return (0, 0), using the same element twice. Order of the two lines inside the loop is the bug.

Practice

1. Convert a nested-loop search into a dict lookup.
brute 2.7104s   hash 0.0031s

Name the transformation: the inner loop was a membership question, so a dict answers it in O(1). That sentence is the answer; the timing is the evidence.

2. Count subarrays summing to k, with negative numbers present.
brute 1.2418s → 3902     prefix 0.0012s → 3902

And say why a sliding window does not work here — negatives break the monotonic growth a window relies on.

3. Try to use a list as a dictionary key.
TypeError: unhashable type: 'list'

tuple(counts) or frozenset(items). This is the bug in the count-tuple version of group anagrams, and it appears every time.

4. Put True, 1 and 1.0 in the same dict.
{True: 'float'}

One entry — they hash equal. The key stays as first inserted and the value as last written, which is confusing enough to be worth knowing before it happens.

Next: sliding window — the pattern for contiguous subarrays and substrings.

Frequently Asked Questions

When does a hash map turn a quadratic solution linear?
Whenever the inner loop is asking 'have I seen something related to this before?'. Store what you have seen as you go, and the inner scan becomes a constant-time lookup. Almost every 'find a pair/subarray satisfying X' problem is this.
What can be used as a dictionary key in Python?
Anything hashable — immutable types and tuples of them. Lists, dicts and sets cannot be keys; convert a list to a tuple and a set to a frozenset. A common interview bug is trying to key on a list of counts instead of a tuple.
What is the canonical form trick?
Map each item to a normalised representation and use that as the key, so items that should group together produce the same key. Sorted letters for anagrams, a tuple of character counts, or a sorted tuple of coordinates — the trick is choosing a form that is equal exactly when the items should group.
How do prefix sums work with a hash map?
Store each running total as you scan. A subarray summing to k exists ending at position i whenever `running_total - k` has been seen before, because the difference between two prefix sums is the sum of the range between them. That turns an O(n²) scan of all subarrays into one pass.