01 Β· DSA β€” File 02

Arrays, Strings & Hashing

Roughly 40% of interview problems are "here's an array or string; the fast solution uses a hashmap, two pointers, a sliding window, or prefix sums." Master these four moves and a huge slice of the interview surface becomes routine.

01Arrays: the physics of contiguous memory

An array is a promise: n elements, side by side, in one contiguous block of memory. Everything good and bad about arrays follows from that single fact.

address:   1000   1008   1016   1024   1032
          β”Œβ”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”
          β”‚ a[0] β”‚ a[1] β”‚ a[2] β”‚ a[3] β”‚ a[4] β”‚   (8 bytes each)
          β””β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”˜

a[i] lives at:  base_address + i Γ— element_size   ← one multiply + one add = O(1)
Index = O(1)

Reading a[i] is arithmetic, not search β€” compute an address, jump there.

Middle insert = O(n)

Contiguity must be preserved, so everything after the hole shifts by one.

Cache heaven

Sequential access pulls whole cache lines β€” arrays beat linked lists in practice even at the same Big-O (file 03 has the full grudge match).

Backend anchor: a Kafka partition is conceptually an append-only array β€” appends at the end are cheap, and readers seek by offset in O(1). Nobody inserts into the middle of a log, because that's the O(n) operation.

Dynamic arrays (Python list)

A Python list is a dynamic array of pointers to objects. It keeps spare capacity: append fills a slot in O(1), and when full it reallocates bigger and copies β€” amortized O(1) (the full doubling argument lives in file 01 Β§5). Consequences you should recite on demand:

OpCostWhy
a[i], a[i] = xO(1)address arithmetic
a.append(x), a.pop()O(1) amortizedtail has spare room
a.insert(0, x), a.pop(0)O(n)shift everything
a[i:j]O(jβˆ’i)copies into a new list
x in aO(n)linear scan

02Strings: immutable arrays of characters

A Python string behaves like a read-only array. Immutability is the interview-relevant property: every "modification" builds a brand-new string. That's how you fall into the classic accidental quadratic:

O(nΒ²) β€” the trap

s = ""
for ch in chunks:
    s += ch   # each += copies the WHOLE
              # accumulated string β†’ O(nΒ²)

O(n) β€” the fix

parts = []
for ch in chunks:
    parts.append(ch)  # O(1) amortized
s = "".join(parts)    # one O(n) pass

Other string moves that come up constantly:

s[::-1]                    # reverse (O(n), makes a copy)
s.split(), s.strip()       # tokenize / trim
ord('a'), chr(97)          # char ↔ codepoint, for count arrays
sorted(s)                  # list of chars, O(n log n) β€” canonical form for anagrams

For "compare letter counts" problems, a 26-slot count array is the zero-overhead hashmap:

counts = [0] * 26
for ch in s:
    counts[ord(ch) - ord('a')] += 1

03Hashmaps: how the magic actually works

This is the data structure interviewers most love to ask you to explain, because it separates "I use dicts" from "I know what a dict is."

The problem it solves

Arrays give O(1) access by integer index. But we want O(1) access by arbitrary key β€” a user ID string, a tuple, an email. The trick: convert the key into an index.

key "user:42" hash(key) 8734120987 % 8 (capacity) index 3 0 1 2 3 4 5 6 7 buckets ("user:42" β†’ v1) ("cart:7" β†’ v9) ↑ collision: same bucket, chained
The whole trick in one picture: hash the key to a big integer, modulo by capacity to pick a bucket, store (key, value) there. Two different keys ("user:42" and "cart:7") landed in bucket 3 β€” the chain resolves the collision.

The mechanism, step by step

  1. Hash the key β†’ a deterministic, well-scattered integer. Same key β‡’ same hash, always.
  2. Modulo by capacity β†’ a bucket index.
  3. Store (key, value) in that bucket. We keep the key too β€” because of the next point.

Collisions: the inevitable complication

Different keys can land in the same bucket (pigeonhole: infinite keys, finite buckets). Two classic strategies:

  • Chaining: each bucket holds a small list; on collision, append. Lookup = hash, then scan that short list, comparing stored keys β€” that's why we keep them.
  • Open addressing (what CPython uses): on collision, probe other slots in a deterministic sequence until a free one is found. Lookup follows the same probe sequence.
Chaining:                          Open addressing:
bucket[3] ──► (k1,v1) ─► (k9,v9)   bucket[3] full? try 4… full? try 6… (probe sequence)

Load factor: when to grow

load factor = entries / buckets. As it rises, buckets get crowded, chains/probes lengthen, and O(1) decays toward O(n). So hashmaps resize when the load factor crosses a threshold (CPython: 2/3): allocate a bigger table and re-insert everything β€” hashes must be re-moduloed by the new capacity. That resize is O(n), but rare, so inserts are amortized O(1). Same doubling logic as dynamic arrays.

The contract that makes it all work

  • Keys must be hashable β‡’ effectively immutable. This is why lists can't be dict keys but tuples can β€” if a key mutated after insertion, its hash would change and you'd never find it again (it's filed under the old hash).
  • a == b must imply hash(a) == hash(b).
  • Worst case is O(n) β€” adversarial keys all colliding. Real-world relevance: hash-flooding DoS attacks; Python randomizes string hashing per-process partly for this reason.
Backend anchor: Redis is this diagram as a service — GET key hashes to a bucket, O(1) average. Redis Cluster's key→slot mapping (CRC16(key) % 16384) is literally hash-mod-capacity applied to shard routing. Same idea, different altitude: consistent hashing exists because naive mod-N re-shuffles almost every key when N changes — the distributed version of "resizing is expensive."

Sets

A set is a hashmap that stores only keys. Same costs, same mechanics. Use it whenever the question is "have I seen this before?"

Try it: hash a key into 8 buckets

Type any key. A toy hash (h = hΒ·31 + charcode, like Java's String.hashCode) maps it to a big integer, then % 8 picks its bucket. Hit Insert a few times with different keys and watch collisions chain up inside one bucket.

type a key to see where it lands

04Pattern: Two Pointers

When: the array is sorted (or can be), or you're working from both ends, or comparing/merging two sequences.

Why it works: sortedness gives you monotonicity β€” moving a pointer changes the quantity you're tracking in a predictable direction, so you can discard candidates without checking them. That's how you delete an O(n) inner loop.

Two Sum on a sorted array β€” the canonical demo:

def two_sum_sorted(a, target):
    lo, hi = 0, len(a) - 1
    while lo < hi:
        s = a[lo] + a[hi]
        if s == target: return [lo, hi]
        if s < target:  lo += 1     # sum too small β†’ only a bigger left element can fix it
        else:           hi -= 1     # sum too big β†’ only a smaller right element can fix it
    return []
[1, 3, 4, 6, 8, 11]   target 10
 lo↑            ↑hi   1+11 = 12 too big   β†’ hi--
 lo↑         ↑hi      1+8  = 9  too small β†’ lo++
    lo↑      ↑hi      3+8  = 11 too big   β†’ hi--
    lo↑   ↑hi         3+6  = 9  too small β†’ lo++
       lo↑↑hi         4+6  = 10 βœ“
Each step permanently retires one element β‡’ O(n). The interview-worthy sentence: "When the sum is too small, no pair involving a[lo] can ever work β€” everything a[lo] could pair with is ≀ a[hi]. So I can discard it."

Same-direction variant (reader / writer)

In-place dedup, remove-element, move-zeroes β€” a slow pointer marks where the next kept element goes, a fast pointer scans:

def move_zeroes(a):
    write = 0
    for read in range(len(a)):
        if a[read] != 0:
            a[write], a[read] = a[read], a[write]
            write += 1

Other members of the family: valid palindrome (ends inward), container with most water, 3Sum (sort + fix one + two-pointer the rest), merging two sorted lists.

05Pattern: Sliding Window

When: "longest / shortest / count of contiguous subarray or substring satisfying a condition."

Why it works: instead of re-examining every one of the O(nΒ²) windows from scratch, you maintain one window and update its state incrementally as the edges move. The right edge grows the window; the left edge shrinks it when the constraint breaks. Each element enters once and leaves once β‡’ O(n) β€” amortized: the inner while is paid for by prior expansions (file 01 Β§5 energy).

step 3 Β· window "abc" β€” all unique βœ“ a b c a b b ↑left ↑right step 4 Β· right reads a duplicate 'a' β†’ left jumps past the old 'a' a b c a b b ↑left ↑right window "bca" βœ“ evicted β€” dropped from window
Sliding window on "abcabb": both pointers only ever move right, so each character is absorbed once and evicted once β€” 2n pointer moves total, O(n) despite the nested loop.

Longest substring without repeating characters β€” the archetype:

def longest_unique(s):
    last = {}                       # char β†’ most recent index
    left = best = 0
    for right, ch in enumerate(s):
        if ch in last and last[ch] >= left:
            left = last[ch] + 1     # jump past the previous occurrence
        last[ch] = right
        best = max(best, right - left + 1)
    return best

The general template β€” worth memorizing as a shape, not code:

left = 0
for right in range(n):
    # 1. absorb a[right] into window state
    while window_is_invalid:
        # 2. evict a[left] from window state
        left += 1
    # 3. window [left..right] is valid β†’ update answer
  • Longest valid window β†’ update the answer after the while (as above).
  • Shortest valid window (min-window-substring, min-size subarray sum β‰₯ target) β†’ shrink while valid, updating the answer inside the shrink loop.
  • Fixed-size window β†’ no while; slide both edges together (max-average subarray).
Backend anchor: a sliding-window rate limiter is exactly this β€” a window over a timestamp stream with "evict from the left when out of range." Windowed aggregations in Kafka Streams keep incremental per-window state instead of recomputing.
Caution: the classic window argument needs monotonicity ("expanding can only make the constraint tighter"). With negative numbers, "subarray sum" windows break β€” growing the window no longer monotonically grows the sum β€” and you reach for prefix sums instead. Recognizing that boundary is a strong signal.

06Pattern: Prefix Sums

When: many range-sum queries, or "count subarrays with sum/property X" β€” especially when negatives make sliding windows invalid.

The idea: precompute running totals once, then any range sum is a subtraction.

a        =  [ 2,  4,  1,  3,  5]
prefix   =  [0,  2,  6,  7, 10, 15]      (prefix[i] = sum of first i elements)

sum(a[i..j]) = prefix[j+1] - prefix[i]   ← O(1) per query after O(n) prep
e.g. sum(a[1..3]) = prefix[4] - prefix[1] = 10 - 2 = 8   (4+1+3 βœ“)

The power move β€” prefix sums + hashmap (Subarray Sum Equals K)

A subarray (i..j] sums to k ⟺ prefix[j] βˆ’ prefix[i] = k ⟺ prefix[i] = prefix[j] βˆ’ k. So walk left to right, and at each point ask: how many earlier prefixes equal (current prefix βˆ’ k)? A hashmap counts them:

def subarray_sum(a, k):
    from collections import defaultdict
    seen = defaultdict(int)
    seen[0] = 1                     # empty prefix β€” subarrays starting at index 0
    total = ans = 0
    for x in a:
        total += x
        ans += seen[total - k]      # each match = one subarray ending here
        seen[total] += 1
    return ans

O(n), handles negatives, and the seen[0] = 1 line is the detail interviewers probe ("why?" β€” because a prefix that itself equals k forms a valid subarray from the start).

Backend anchor: this is materialized cumulative metrics. Computing "requests between 2pm and 3pm" from cumulative counters is counter(3pm) βˆ’ counter(2pm) β€” Prometheus counters + increase() are prefix sums in production clothing.

07Classic problems, worked briskly

Try to state the approach and complexity before revealing each answer.

Two Sum (unsorted) β€” the hashmap hello-world. Given nums and target, return indices of two numbers summing to target.

Brute force is O(nΒ²): all pairs. Better: for each x, its partner is target βˆ’ x β€” a lookup, not a search. One pass, store valueβ†’index as you go. Checking before inserting handles duplicates and "don't use the same element twice" for free.

def two_sum(nums, target):
    seen = {}
    for i, x in enumerate(nums):
        if target - x in seen:
            return [seen[target - x], i]
        seen[x] = i

O(n) time, O(n) space. The generalizable lesson: "find a pair satisfying a relation" β†’ store what you've seen, look up the complement.

Valid Anagram β€” are s and t anagrams of each other?

Same letters, same counts. Either sorted(s) == sorted(t) (O(n log n), one line) or count-and-compare (O(n)):

from collections import Counter
def is_anagram(s, t):
    return Counter(s) == Counter(t)

O(n) time. For lowercase a–z only, a 26-slot count array works; for general Unicode you need the Counter/dict β€” mentioning the assumption is the point.

Group Anagrams β€” cluster a list of strings so anagrams end up together.

Anagrams share a canonical form β€” use it as a dict key:

def group_anagrams(strs):
    from collections import defaultdict
    groups = defaultdict(list)
    for s in strs:
        groups[tuple(sorted(s))].append(s)   # or a 26-count tuple for O(nΒ·k)
    return list(groups.values())

O(nΒ·k log k) with sorted-tuple keys, O(nΒ·k) with 26-count-tuple keys. Lesson: "group things by equivalence" β†’ design a canonical key, bucket into a hashmap. Same idea as choosing a partition key in Kafka: pick the key so equivalent things land together.

Longest Substring Without Repeating Characters.

Sliding window + last-seen map (Β§05 above): absorb s[right]; if it was already seen inside the window, jump left past the previous occurrence; track the best width. Each pointer only moves forward.

O(n) time, O(min(n, alphabet)) space.

Subarray Sum Equals K β€” count subarrays whose elements sum to k (negatives allowed).

Sliding window is unsound here (negatives break monotonicity). Use prefix sums + hashmap (Β§06): running total, and at each step add how many earlier prefixes equal total βˆ’ k. Seed seen[0] = 1 so subarrays starting at index 0 count.

O(n) time, O(n) space.

Honorable mentions to have in your pocket

Contains Duplicate

len(set(a)) < len(a) β€” a set collapses duplicates.

Product Except Self

Prefix products from the left Γ— suffix products from the right; no division.

Longest Consecutive Seq

Dump into a set; only start counting from numbers where xβˆ’1 is absent β‡’ O(n).

Top-K Frequent

Counter + heap (file 06), or bucket-by-frequency.

08Choosing the pattern β€” a mini decision tree

Contiguous subarray/substring, longest/shortest/count?
 β”œβ”€ all values non-negative / constraint monotone β†’ SLIDING WINDOW
 └─ negatives involved, sum-based                β†’ PREFIX SUM (+ hashmap)

Pairs/triples with a target relation?
 β”œβ”€ sorted (or sorting is free)  β†’ TWO POINTERS
 └─ unsorted, need indices       β†’ HASHMAP complement lookup

"Seen before?" / dedupe / grouping β†’ SET / HASHMAP with canonical key

Many range-sum queries β†’ PREFIX SUM
In-place compaction / partition β†’ READER-WRITER POINTERS

09Common interview questions

Q1. Why is dict lookup O(1) and not O(n)?

Hash the key straight to a bucket β€” arithmetic, not scanning. Collisions add a short chain/probe, kept short by resizing at a load-factor threshold. Average O(1), worst O(n).

Q2. Why can't a list be a dict key?

Mutable β‡’ its hash could change after insertion β‡’ the entry becomes unfindable β€” it's filed under the stale hash. Hashability requires stable equality + hash.

Q3. What happens when a hashmap resizes?

New, larger table; every entry re-inserted, because bucket = hash % new_capacity changes. O(n) once, amortized away over inserts.

Q4. Two Sum with duplicates like [3, 3], target 6?

One-pass check-then-insert handles it: the second 3 finds the first in the map before overwriting it.

Q5. Sliding window is O(n) despite nested loops β€” why?

left only moves forward; each element is added once and removed once. Total pointer movement ≀ 2n. (An amortized argument.)

Q6. When does sliding window fail for subarray sums?

Negative numbers: growing the window no longer monotonically grows the sum, so "shrink when too big" is unsound. Use prefix sums + hashmap.

Q7. Anagram check for Unicode vs a–z?

A 26-array works only for lowercase ASCII; use Counter/dict for general alphabets. Stating the assumption out loud is the point.

Q8. Why keep the key inside the hash bucket?

To disambiguate collisions: multiple keys can share a bucket, so lookup must compare actual keys, not just hashes.

10Self-test

Answer from memory, then open the spot-checks.

  1. Walk through what d["user:42"] = v does, mechanically, in ≀ 4 steps (hash β†’ ? β†’ ? β†’ ?).
  2. What is load factor, and what does the map do when it gets too high? What's the cost and why is it acceptable?
  3. Chaining vs open addressing β€” one sentence each.
  4. Write two-pointer Two Sum (sorted) from memory. Then explain why discarding a[lo] when the sum is too small is safe.
  5. Write the general sliding-window template (3 comment lines are enough). How does it change for "shortest valid window"?
  6. sum(a[i..j]) via prefix array β€” the exact formula, with the off-by-one right.
  7. In Subarray Sum Equals K, why initialize seen[0] = 1?
  8. Design a canonical key for grouping anagrams. Give two options and their costs.
  9. s += ch in a loop over n chars β€” total complexity and the fix?
  10. Longest Consecutive Sequence in O(n): what's the trick that avoids sorting?
Spot-checks
  1. hash key β†’ mod capacity β†’ find bucket (probe/chain) β†’ store (key, value).
  2. entries/buckets; resize + re-insert all, O(n), amortized O(1) per insert.
  3. Chaining: list per bucket. Open addressing: probe alternative slots in-table.
  4. Everything a[lo] could still pair with is ≀ a[hi], so all its sums are < target.
  5. absorb right / while invalid evict left / record; shortest: shrink while valid and record inside the shrink loop.
  6. prefix[j+1] βˆ’ prefix[i].
  7. A prefix equal to k means the subarray from index 0 counts.
  8. sorted-tuple key O(k log k); 26-count tuple O(k).
  9. O(nΒ²); build a list + join.
  10. Set membership; only start runs at x where xβˆ’1 is absent, so each run is walked once.