Frequency maps, sets and hashing techniques

Count, deduplicate and group with hash containers, use the two-sum and prefix-sum patterns, and know when sorting beats hashing.

Frequency maps and sets

from collections import Counter, defaultdict
from typing import Iterable, Sequence

def frequencies(xs: Iterable[str]) -> dict[str, int]:
    """Counter does the bookkeeping; most_common sorts for you."""
    return dict(Counter(xs).most_common())

def first_duplicate(xs: Sequence[int]) -> int | None:
    """A set turns a nested loop into a single pass."""
    seen: set[int] = set()
    for x in xs:
        if x in seen:
            return x
        seen.add(x)
    return None

def group_by_length(words: Iterable[str]) -> dict[int, list[str]]:
    groups: dict[int, list[str]] = defaultdict(list)
    for w in words:
        groups[len(w)].append(w)      # defaultdict removes the existence test
    return dict(groups)

def is_anagram(a: str, b: str) -> bool:
    """Counter equality is the clean form; a sort is also O(n log n)."""
    if len(a) != len(b):
        return False
    return Counter(a) == Counter(b)

def top_k_frequent(xs: Sequence[str], k: int) -> list[str]:
    """Counter plus heapq: O(n log k), better than sorting all n when k is small."""
    import heapq
    counts = Counter(xs)
    return [word for word, _ in heapq.nlargest(k, counts.items(), key=lambda kv: kv[1])]


if __name__ == "__main__":
    print(frequencies("mississippi"))
    print(first_duplicate([1, 2, 3, 2]))
    print(group_by_length(["a", "bb", "cc", "ddd"]))
    print(is_anagram("listen", "silent"))
    print(top_k_frequent(["a", "b", "a", "c", "a", "b"], 2))
  • A hash container gives average O(1) per operation and O(n) worst case, which an adversary can trigger if the hash function is predictable and the keys come from user input.
  • defaultdict(list) and setdefault both remove the pattern of testing for a key and then creating the container. Prefer the one that reads more clearly for the situation.
  • A set of keys uses memory proportional to the input, which is the real cost. An in-place sort uses O(log n) extra space and needs no additional structure.
  • Iteration order of a hash container is unspecified. Sort the output if the result is compared or displayed.

Two-sum and complement maps

from typing import Sequence

def two_sum(nums: Sequence[int], target: int) -> tuple[int, int] | None:
    """One pass, O(n) time and O(n) space, using the complement as the key."""
    seen: dict[int, int] = {}                 # value -> index
    for i, x in enumerate(nums):
        need = target - x
        if need in seen:
            return seen[need], i
        seen[x] = i
    return None

def two_sum_sorted(nums: Sequence[int], target: int) -> tuple[int, int] | None:
    """If the input is already sorted, two pointers use O(1) extra space."""
    lo, hi = 0, len(nums) - 1
    while lo < hi:
        s = nums[lo] + nums[hi]
        if s == target:
            return lo, hi
        if s < target:
            lo += 1
        else:
            hi -= 1
    return None

def subarray_sum_zero(nums: Sequence[int]) -> tuple[int, int] | None:
    """A repeated prefix sum means the elements in between sum to zero."""
    first: dict[int, int] = {0: -1}
    running = 0
    for i, x in enumerate(nums):
        running += x
        if running in first:
            return first[running] + 1, i
        first[running] = i
    return None

def longest_unique_window(s: str) -> int:
    """A map from character to last index gives the sliding-window form."""
    last: dict[str, int] = {}
    best = start = 0
    for i, ch in enumerate(s):
        if ch in last and last[ch] >= start:
            start = last[ch] + 1
        last[ch] = i
        best = max(best, i - start + 1)
    return best


if __name__ == "__main__":
    print(two_sum([2, 7, 11, 15], 9))          # (0, 1)
    print(two_sum_sorted([1, 2, 3, 4, 6], 9))  # (2, 4)
    print(subarray_sum_zero([1, 2, -3, 4]))    # (0, 2)
    print(longest_unique_window("abcabcbb"))   # 3
QuestionHash approachSort approach
Is there a pair summing to a target?Complement map, one pass, O(n)Sort then two pointers, O(n log n)
How many distinct values?len(set(xs)), O(n)Sort and count runs, O(n log n)
Which values repeat?Counter with a filter, O(n)Compare neighbours after sorting
Group equal items togetherdefaultdictSort then emit runs, in place
Nearest value to a targetNo natural fitSorted array plus bisect
Top k by frequencyCounter plus a heapSort the counts, O(n log n)

Choose hashing when you need membership or a complement lookup and the input order does not matter. Choose sorting when you also need order, neighbours, or a lower memory footprint, and when the extra log n factor is affordable.

Hazards and guarantees

# 1. Mutable and unhashable keys
#    key = [1, 2]        -> TypeError: unhashable type 'list'
key = (1, 2)             # a tuple is hashable if its elements are
# For a dict inside a key, freeze it: tuple(sorted(d.items()))

# 2. A custom __hash__ must agree with __eq__
class Point:
    __slots__ = ("x", "y")
    def __init__(self, x: int, y: int) -> None:
        self.x, self.y = x, y
    def __eq__(self, other: object) -> bool:
        return isinstance(other, Point) and (self.x, self.y) == (other.x, other.y)
    def __hash__(self) -> int:
        return hash((self.x, self.y))    # same fields as __eq__, or lookups fail

# 3. Floating point keys do not behave the way you expect
d = {0.1 + 0.2: "a"}
print(0.3 in d)          # False: 0.1 + 0.2 is not exactly 0.3
# Round or scale to integers for money and measurements.

# 4. Adversarial collisions
#    A hash function an attacker can predict lets them collide every key into
#    one bucket, degrading every operation to O(n). Python randomises string
#    hashing per process by default; do not disable it.

# 5. Memory: a dict of 1,000,000 small keys costs far more than a sorted list
#    of the same keys. Hashing buys time with space.
⚠️
A hash container's O(1) is an average over a good hash and a reasonable load factor. Say "average O(1)" in an interview and in a design document, and note the O(n) worst case: it is the difference between a service that degrades and one that falls over under a crafted request.

FAQ

Is a set or a sorted list better for deduplication?
A set is simpler and faster. A sorted list needs no extra memory beyond the sort and gives you ordered output for free. If the values are integers in a small known range, a bit array or a boolean list beats both.
Why is my dict lookup slower than expected?
Likely many collisions: keys that are equal by __eq__ but hash differently, or a hash function that is constant for many keys. Check that __hash__ uses the same fields as __eq__ and that it mixes the bits.

Two pointers, sliding window and prefix sums Complexity and Big-O in practice

Last refreshed 2026-09-18.