Hashing for Subarray Problems
A hash map that stores prefix aggregates (sums, XORs, remainders) turns any 'count subarrays satisfying a condition' problem into a single O(n) pass. This chapter deepens pattern 1.17 with the XOR, mod-k, and longest-subarray variants — and explains the design decision of *what exactly to hash*.
Table of contents
- Before we start
- The one design question
- The turning point — the hiker's balance story
- The one idea to remember
- Variant 1 — Sum equals k (the foundation)
- Variant 2 — XOR equals k
- Variant 3 — Sum divisible by k
- Variant 4 — Equal 0s and 1s (LC #525)
- Count mode vs Longest mode
- The decision table
- Common traps
- Say it like a pro (interview one-liner)
- Check yourself
- Practice problems
Before we start
Pattern 1.17 introduced the core mechanic: prefix sum + hash map = O(n) subarray counting. If you haven't read it, read it first — it has the full story, the frame-by-frame trace, and the correctness proof.
This chapter focuses on one skill that 1.17 didn't drill: deciding what to hash. The prefix sum is just one option. Depending on the problem's condition, you'll hash:
- A running sum → for sum equals k
- A running XOR → for XOR equals k
- A running remainder mod k → for divisibility
- A running balance counter → for equal 0s and 1s
By the end you will be able to:
- Choose the right aggregate to hash for any subarray condition.
- Write the correct
need = f(prefix)query for each variant. - Distinguish count mode (frequency map) from longest mode (first-seen index map).
The one design question
Every problem in this family reduces to one question:
Two positions
iandjdefine a valid subarray ifaggregate[i] ○ aggregate[j] = target.
What is○and what isaggregate?
Once you answer that, the code is always the same skeleton:
freq = {identity: 1} # identity element for the aggregate operation
agg = identity
count = 0
for x in arr:
agg = combine(agg, x) # extend the running aggregate
need = inverse(agg, target) # what earlier aggregate would complete the pair?
count += freq.get(need, 0) # how many times has that aggregate appeared?
freq[agg] = freq.get(agg, 0) + 1
The three variants below differ only in combine, inverse, and identity.
The turning point — the hiker's balance story
A real-life story
A hiker logs her altitude change at every checkpoint. Her running total (prefix sum) starts at 0. After each checkpoint, she records the new total.
She wants to know: in how many time windows did she gain exactly 200m?
Key insight: if the total at checkpoint 7 is 900m and the total at checkpoint 3 was 700m, then between checkpoints 3 and 7 she gained exactly 200m. She doesn't scan pairs — she just asks, for each page, "how many earlier pages showed a total that is 200 less than my current total?"
This story is about sums. But the same hiker logic applies if the notebook tracks XOR values, remainders, or character balances — just change what she writes on each page.
The one idea to remember
The entire pattern in one sentence
Store a running aggregate in a hash map as you scan; for each new value, query for the "complementary earlier aggregate" that would make the window between them satisfy the target condition — then record the current aggregate for future queries.
Variant 1 — Sum equals k (the foundation)
Condition: prefix[i] - prefix[j] = k → need: prefix[i] - k
from collections import defaultdict
def subarraySum(nums, k):
freq = defaultdict(int)
freq[0] = 1 # empty prefix
prefix = 0
count = 0
for num in nums:
prefix += num
count += freq[prefix - k]
freq[prefix] += 1
return count
combine = +, inverse = subtract k, identity = 0.
Variant 2 — XOR equals k
Condition: prefix_xor[i] ^ prefix_xor[j] = k
XOR is self-inverse: prefix_xor[j] = prefix_xor[i] ^ k → need: prefix_xor[i] ^ k
def subarrayXOR(nums, k):
freq = defaultdict(int)
freq[0] = 1 # empty prefix XOR = 0
prefix = 0
count = 0
for num in nums:
prefix ^= num # XOR instead of add
count += freq[prefix ^ k] # need = prefix ^ k (because x ^ k ^ k = x)
freq[prefix] += 1
return count
combine = ^, inverse = XOR with k, identity = 0.
Pause & think
Why is need = prefix ^ k for XOR, but need = prefix - k for sum? What mathematical property of XOR makes this work?
Answer
XOR is its own inverse: a ^ b ^ b = a. So if prefix_xor[i] ^ prefix_xor[j] = k, then prefix_xor[j] = prefix_xor[i] ^ k (XOR both sides by prefix_xor[i]). For sum, subtraction is the inverse of addition: prefix[i] - prefix[j] = k → prefix[j] = prefix[i] - k.
Variant 3 — Sum divisible by k
Condition: (prefix[i] - prefix[j]) % k = 0 → prefix[i] % k = prefix[j] % k
Need: the same remainder as the current prefix.
def subarraysDivByK(nums, k):
freq = defaultdict(int)
freq[0] = 1
prefix = 0
count = 0
for num in nums:
prefix = (prefix + num) % k
if prefix < 0:
prefix += k # Python's % is always ≥ 0, but explicit guard for clarity
count += freq[prefix] # need = same remainder (not prefix - k!)
freq[prefix] += 1
return count
combine = (sum) % k, inverse = same value, identity = 0.
The key shift: we no longer store the raw prefix sum — we store the remainder. Two positions with the same remainder define a subarray divisible by k.
Pause & think
For nums = [4, 5, 0, -2, -3, 1] with k = 5, what remainders do you see at each step? How many pairs share a remainder?
Check your trace
prefix sums: 4, 9, 9, 7, 4, 5
remainders: 4, 4, 4, 2, 4, 0
Remainder 4 appears 4 times → C(4,2) = 6 pairs (each pair of indices is a valid subarray).
Remainder 0 appears 1 time → 1 pair with the initial freq[0]=1.
Answer: 7 ✅ (matches LC #974)
Variant 4 — Equal 0s and 1s (LC #525)
Encoding trick: replace 0 with -1. Now "equal 0s and 1s" means "subarray sums to 0".
Run Variant 1 with k = 0.
def findMaxLength(nums):
# encode 0 → -1, then count subarrays with sum 0
freq = {0: 1}
prefix = 0
count = 0
for num in nums:
prefix += 1 if num == 1 else -1
count += freq.get(prefix, 0)
freq[prefix] = freq.get(prefix, 0) + 1
return count
The encoding is the entire insight. Once encoded, it's identical to Variant 1 with k=0.
Count mode vs Longest mode
Everything above is count mode — use a frequency map, accumulate count += freq[need].
For longest subarray, switch to a first-seen index map and take max(length):
def longestSubarraySum(nums, k):
first_seen = {0: -1} # prefix sum → first index it appeared
prefix = 0
max_len = 0
for i, num in enumerate(nums):
prefix += num
if prefix - k in first_seen:
max_len = max(max_len, i - first_seen[prefix - k])
if prefix not in first_seen:
first_seen[prefix] = i # only store FIRST occurrence
return max_len
Key differences:
| Count mode | Longest mode | |
|---|---|---|
| Map stores | frequency (int) | first-seen index (int) |
| On hit | count += freq[need] | max_len = max(max_len, i - first_seen[need]) |
| On store | always update | only if key not yet in map |
The "only if not yet in map" rule is critical for longest: you want the earliest occurrence to maximise the subarray length.
The decision table
| Problem condition | Aggregate | need query | Identity |
|---|---|---|---|
| Sum == k | running sum | prefix - k | 0 |
| XOR == k | running XOR | prefix ^ k | 0 |
| Sum % k == 0 | running sum % k | prefix % k (same value) | 0 |
| Equal 0s and 1s | running sum (0→-1) | prefix - 0 = prefix | 0 |
| Longest with sum k | running sum | prefix - k | 0, stored at index -1 |
All five share the same skeleton. The only changes are one line (combine) and one line (need).
Common traps
Watch out for these
- Query before storing. Always
count += freq[need]THENfreq[prefix] += 1. Reversing this lets the current position pair with itself. - Forgetting
freq[0] = 1(orfirst_seen = {0: -1}for longest). This handles subarrays starting at index 0. Without it you silently miss them. - Using count mode for longest. Frequency maps don't tell you where a prefix sum appeared — only how many times. For longest, you need the index, so switch to
first_seen. - For mod-k: negative remainders. If a number is negative,
prefix % kmay be negative in some languages (not Python). Addif prefix < 0: prefix += kfor safety. - Confusing "sum divisible by k" with "sum equals k". The need query changes from
prefix - ktoprefix % k(same value lookup, not subtraction).
Say it like a pro (interview one-liner)
"This is a prefix-aggregate + hash map problem. I maintain a running aggregate and store how often each value has appeared. For each new position, I query 'has the complementary aggregate appeared before?' — answering in O(1). The only design choice is the right aggregate and its inverse. O(n) time, O(n) space, one pass."
Remember this forever
Hashing for Subarray Problems
freq = {identity: 1}
agg = identity
for x in arr:
agg = combine(agg, x)
count += freq[need(agg, target)] # query FIRST
freq[agg] += 1 # store AFTER
| Condition | combine | need | identity |
|---|---|---|---|
| sum == k | + num | agg - k | 0 |
| XOR == k | ^ num | agg ^ k | 0 |
| sum % k == 0 | (+num)%k | agg (same) | 0 |
Count: frequency map · Longest: first-seen index map (store only first occurrence)
Check yourself
Why does the XOR need use `prefix ^ k` while the sum need uses `prefix - k`?
Because of each operation's inverse. For sum: a - b = k → b = a - k (subtraction is addition's inverse). For XOR: a ^ b = k → b = a ^ k (XOR is its own inverse — XOR both sides by a: a ^ a ^ b = a ^ k → b = a ^ k).
For "longest subarray with sum k," why must you only store the FIRST occurrence of each prefix sum?
The length of the subarray ending at index i with an earlier prefix at index j is i - j. To maximise this length, you want the smallest possible j — the earliest occurrence. If you overwrote with a later occurrence, you'd shorten all future answers.
What is the encoding trick for "equal number of 0s and 1s," and why does it work?
Replace every 0 with -1. Now the sum of any subarray with equal 0s and 1s becomes exactly 0 (each +1 is cancelled by a -1). The problem reduces to "count/find subarrays with sum 0" — the standard prefix-sum + hash map pattern with k = 0.
Practice problems
| Problem | Difficulty | What to notice | Link |
|---|---|---|---|
| Subarray Sum Equals K | Medium | Core sum variant; init freq[0]=1 | LC #560 |
| Contiguous Array | Medium | Encode 0→-1; find longest with sum 0 | LC #525 |
| Subarray Sums Divisible by K | Medium | Store remainder; need = same remainder | LC #974 |
| Count of Subarrays with XOR k | Medium | Replace + with ^; need = prefix ^ k | LC #1542 related |
| Maximum Size Subarray Sum Equals k | Medium | Longest mode: first-seen index, store only first | LC #325 |
Next up: Rolling Hash / Rabin-Karp — where a hash value slides across a string in O(1) per step, enabling pattern matching in O(n) time.