Search Tech Journey

Find topics, journeys and posts

back to blog
algorithmsbeginner 60m read

L05 · Hashing — Your Default Weapon

The complement-lookup skeleton and the general move of trading memory for time, which is the first thing to try whenever a problem looks quadratic.

🧩DSAPhase 0 · Foundations· Session 005 of 130 60 min

🎯 Learn the complement-lookup skeleton and the general 'trade memory for time' move: remember what you have seen in a hash map so a future element can find its partner in O(1), collapsing quadratic scans to a single pass.

Series: LeetCode — From Basics to Interview-Ready · Session 5 / 65 · Phase 0 · Foundations

What this pattern IS — remembering what you have seen

A huge class of problems reduces to: for the current element, has some related element appeared before? "Is its partner here?" "Have I seen this value?" "How many times did this occur?"

The naive answer is a nested loop: for each element, scan the rest looking for the match — O(n^2). But scanning is wasteful because you re-examine the same elements over and over. The fix is to remember: as you pass each element, drop it into a hash map. Then when a later element needs a partner, it does not scan — it asks the map, which answers in O(1) average time.

You are spending memory (the map) to buy speed (the O(1) lookup replacing an O(n) scan). That single trade — memory for time — is the most common first optimisation in all of algorithms, and hashing is how you make it.

The analogy
🌍 Real world
Instead of asking every person at a party "are you my friend Sam?" one by one, you keep a guest list at the door. When someone new arrives you glance at the list — one look tells you if Sam is already inside. The list costs paper (memory) but saves you asking everyone (time).
💻 Code world
Instead of scanning the array for each element's partner (O(n) each), you keep a hash map of everyone seen. A lookup is one glance (O(1) average) instead of a full scan.

The naive version and its cost

def two_sum_brute(nums, target):
    for i in range(len(nums)):
        for j in range(i + 1, len(nums)):     # scan the rest for the partner
            if nums[i] + nums[j] == target:
                return [i, j]
    return []
# O(n^2): every element scans the remaining elements.

The inner scan is the waste. Each element's partner, if it exists, is a specific value (target - nums[i]). We should be able to look that value up directly instead of scanning for it.

From first principles
Start with the question
Why does storing seen elements in a hash map turn a quadratic scan into a single linear pass?
  1. 1
    For element x, the thing that completes it is a KNOWN value (e.g. target - x).
    forced by · The relationship is fixed, so we do not need to search — we need to check existence of one specific value.
  2. 2
    A hash map answers 'is value v present, and where?' in O(1) average.
    forced by · Hashing v jumps directly to its bucket instead of scanning all entries.
  3. 3
    If we insert each element as we pass it, then when we reach x its partner (if earlier) is already in the map.
    forced by · We only ever look BACK at elements we have already inserted.
  4. 4
    So each element does O(1) work (one lookup + one insert) and we make one pass.
    forced by · n elements times O(1) is O(n) total.
⇒ Therefore
Because the completing element is a specific value, existence-checking it in a hash map (O(1)) replaces scanning for it (O(n)), collapsing O(n^2) to O(n) at the cost of O(n) memory.

The templates — annotated

Template A — complement lookup (Two Sum shape)

def two_sum(nums, target):
    seen = {}                        # value -> index we saw it at
    for i, x in enumerate(nums):     # single pass, O(n)
        need = target - x            # the specific partner that completes x
        if need in seen:             # has the partner appeared already? O(1) avg
            return [seen[need], i]
        seen[x] = i                  # remember x so a FUTURE element can find it
    return []

The order matters: check for the partner before inserting x, so a value never pairs with itself.

Template B — seen-set for existence / dedup

def has_duplicate(nums):
    seen = set()
    for x in nums:
        if x in seen:                # O(1) membership
            return True
        seen.add(x)
    return False

Template C — frequency map for counting

from collections import Counter, defaultdict
 
counts = Counter(nums)               # {value: how many times}
groups = defaultdict(list)           # {key: list of items with that key}
for i, x in enumerate(nums):
    groups[compute_key(x)].append(i)
Mental modelThe guest list at the door
One pass down the array. Beside it sits a hash map — the guest list. Each element you pass, you (1) glance at the list to see if its partner already checked in, then (2) add yourself to the list. Look before you sign in, so you never match yourself.
  • Decide what the KEY is (the value, the complement, a computed signature).
  • Look up the partner/existence BEFORE inserting the current element.
  • Insert the current element so future ones can find it.
  • One pass, O(1) work per element -> O(n) time, O(n) space.
🔔 Fires when you see
Problem looks O(n^2) with a nested search for a related element, OR asks 'has this appeared', 'how many', 'find the pair/complement', 'group by'.

Memory technique — "look before you sign in"

The bug that eats beginners is pairing an element with itself, or inserting before checking. Peg the correct order as a doorway ritual: look before you sign in. You glance at the guest list (lookup) first, and only then write your own name (insert). If you sign in first, you might "find" yourself as your own partner.

Reinforce it with the code shape — the loop body is always the same two lines in the same order:

if partner_of(x) in seen:  ->  found it
seen.add / seen[x] = ...   ->  now record x

Lookup on top, insert on the bottom. If you ever write the insert first, the ritual is broken.

Common misconception
✗ What most people think
A hash map lookup is guaranteed O(1), so hashing is always the fastest option.
✓ What is actually true
Hash lookups are O(1) on AVERAGE. Worst case (pathological collisions) they degrade, and hashing always costs O(n) extra memory plus the overhead of computing hashes.
Why the myth is so sticky
A hash map trades memory for time. When the input is already sorted, two pointers achieve the same result in O(1) extra space; when you need order, a sort or heap may beat a map. 'Default weapon' means try it first, not that it always wins.
Prove it to yourself
If the problem says O(1) extra space AND the array is sorted, the hash map is the wrong tool — reach for two pointers (L07) instead.

What interviewers actually ask

Hashing underlies more interview problems than any other single idea. These are the ones where recognising 'this is a hash problem' is the whole battle:

  • Two Sum (1) · Amazon, Google, Meta (Easy) — probes: complement lookup in one pass; the canonical hashing problem. Follow-up: "sorted input, O(1) space?" → two pointers (167).
  • Contains Duplicate (217) · Amazon, Adobe (Easy) — probes: seen-set existence check. Follow-up: "within a window of k indices? (219)."
  • Valid Anagram (242) · Amazon, Bloomberg (Easy) — probes: frequency-map equality. Follow-up: "unicode / streaming input."
  • Group Anagrams (49) · Amazon, Uber (Medium) — probes: hashing by a computed signature (sorted string or count tuple). Follow-up: "remove the sort from the key."
  • Subarray Sum Equals K (560) · Meta, Amazon (Medium) — probes: prefix-sum COUNT map — the subtle case where sliding window fails because of negatives. Follow-up: "count vs existence; why not two pointers."
  • Longest Consecutive Sequence (128) · Amazon, Google (Medium) — probes: a set turns O(n log n) sort into O(n); start-of-run detection. Follow-up: "prove it is O(n), not O(n^2)."
  • Two Sum II — Input Array Is Sorted (167) · Amazon, Microsoft (Medium) — probes: recognising that sorting lets you DROP the map for two pointers. Follow-up: "why is O(1) space possible now?"

Worked example 1 — Two Sum (the archetype)

Problem in plain words: Return indices of the two numbers in nums that add to target. Exactly one answer exists.

Brute + cost. Nested loop over all pairs — O(n^2). Fine for tiny n, times out at 10^5.

Insight. The partner of x is exactly target - x. Remember each value's index in a map; when a later value's partner is already in the map, you have the pair.

def two_sum(nums, target):
    seen = {}                            # value -> index
    for i, x in enumerate(nums):
        need = target - x                # the one value that completes x
        if need in seen:                 # partner already seen? O(1) avg
            return [seen[need], i]        # return earlier index, then current
        seen[x] = i                      # record x for future partners
    return []
# Time O(n), space O(n).

Complexity. O(n) time, O(n) space. We paid memory (the map) to remove the inner scan.

Follow-up. "The array is sorted — do it in O(1) extra space." Drop the map; converge two pointers from the ends (L07):

def two_sum_sorted(nums, target):
    lo, hi = 0, len(nums) - 1
    while lo < hi:
        s = nums[lo] + nums[hi]
        if s == target:
            return [lo, hi]
        elif s < target:
            lo += 1                      # need a bigger sum -> move left pointer up
        else:
            hi -= 1                      # need a smaller sum -> move right pointer down
    return []
# O(n) time, O(1) space — no hash map needed once sorted.
Try itTurn a quadratic into a one-pass hash solution.
from collections import defaultdict
 
def count_pairs(nums, target):
    seen = defaultdict(int)              # value -> how many times seen so far
    pairs = 0
    for x in nums:
        pairs += seen[target - x]        # every earlier partner forms a new pair
        seen[x] += 1                     # record x AFTER counting (look before sign in)
    return pairs
 
print(count_pairs([1, 5, 3, 3, 3], 6))   # pairs summing to 6: (1,5),(3,3)x3 -> 4

Change it to count triples and notice hashing alone is not enough — you will need a pass over pairs plus a lookup, foreshadowing 3Sum in L07.

💡 Hint · Count pairs (i<j) with nums[i]+nums[j]==target using a frequency map.

Worked example 2 — Subarray Sum Equals K (the prefix-sum map)

Problem in plain words: Count the contiguous subarrays whose elements sum to exactly k. Values may be negative.

Brute + cost. Try every subarray, sum it — O(n^2) or O(n^3). Too slow, and sliding window fails here because negatives break monotonicity.

Insight. Let prefix[i] = sum of the first i elements. A subarray (j, i] sums to k exactly when prefix[i] - prefix[j] == k, i.e. prefix[j] == prefix[i] - k. So as you sweep, count how many earlier prefixes equal prefix - k — a hash count map, not a set.

from collections import defaultdict
 
def subarray_sum(nums, k):
    count = defaultdict(int)
    count[0] = 1                         # empty prefix: sum 0 seen once
    prefix = 0
    result = 0
    for x in nums:
        prefix += x                      # running prefix sum
        result += count[prefix - k]      # how many earlier prefixes complete a k-sum
        count[prefix] += 1               # record this prefix for the future
    return result
# Time O(n), space O(n).

Complexity. O(n) time, O(n) space. The count[0] = 1 seed handles subarrays that start at index 0.

Follow-up. "Why not a sliding window?" Because negatives mean growing the window can decrease the sum, so there is no monotonic expand/contract rule — the prefix-count map is required.

The tradeoff
Hash map vs sorting/two-pointers for pair-and-lookup problems.
Hash map
+ you gain O(n) time; works on unsorted input; preserves original indices.
− you pay O(n) extra memory; O(1) is average, not worst case; no ordering.
pick when Unsorted input, need indices, or need counts/grouping.
Sort + two pointers
+ you gain O(1) extra space; naturally handles 'closest' and range variants.
− you pay O(n log n) to sort; loses original indices; mutates or copies input.
pick when Input already sorted, memory is tight, or you need ordered scans (3Sum).
Brute nested loop
+ you gain No memory, trivial to write.
− you pay O(n^2) — times out beyond small n.
pick when Only when constraints guarantee tiny n.
What a senior engineer actually does
Try hashing first — it is the default weapon. Switch to sort + two pointers when O(1) space is required, the input is already sorted, or the problem asks for ordered/closest results.

Where this shows up in production

Hash maps are the backbone of real systems: caches (key → value in O(1)), database indexes and joins (hash-join builds a map of one table then probes it with the other — exactly the "build a guest list, then look each up" move), deduplication of events by id, and rate limiters keying on a client id. The Two Sum trick — "remember what you have seen so a later item finds its partner in one lookup" — is structurally identical to a streaming hash join. The interview loves it because it is a miniature of a technique you will use for the rest of your career.

You can now…
  • Recognise a nested search-for-a-partner loop as a hashing opportunity.
  • Write the complement-lookup skeleton with 'look before you sign in' ordering.
  • Use a set for existence, a Counter/defaultdict for frequency and grouping.
  • Apply a prefix-sum COUNT map for subarray-sum problems (including negatives).
  • Explain the memory-for-time trade and when it is NOT worth it.
  • Switch to sort + two pointers when O(1) space or sorted input calls for it.
Recall check · click to reveal
★ = stretch question

Practice queue

Solve, and for each say aloud what the KEY is and what you look up.