Learn/DSA Patterns
DSA PatternsHashingmedium10 min read

Custom Hash Design

Some problems hand you a composite key — a pair, a tuple, a sorted string — and expect O(1) lookups. This chapter teaches you to design hash keys for any structure, avoid accidental collisions, and build full hash-map systems like TinyURL or a from-scratch HashSet.

#hashmap#custom-hash#design#encode-decode#tinyurl#hashset-design#composite-key#beginner#interview
Table of contents

The problem with complex keys

You have seen hash maps keyed by integers and strings. Now consider:

  • "Group anagrams" — the key is a sorted string or a 26-length frequency tuple.
  • "Two Sum with pair tracking" — the key is a (value, index) pair.
  • "Encode TinyURL" — the key is a random 6-character code, and you need two maps (code→url, url→code).
  • "Design HashSet from scratch" — you need to simulate the internal bucketing yourself.

Each case requires you to design what the key looks like. Getting this wrong leads to collisions (two different things mapping to the same key) or to O(n) lookups that defeat the purpose of hashing.


The passport control desk story

A real-life story

At international passport control, an officer must identify every traveller uniquely. A name alone isn't enough — two people can share a name. A passport number alone isn't enough in theory either — different countries have overlapping numbering. The correct unique key is (country code, passport number) — a composite key.

Custom hash design is exactly this: choosing and encoding the right composite identity for each domain object so that two different objects always get different keys, and two identical objects always get the same key.


The one idea to remember

The entire pattern in one sentence

A custom hash key is a canonical string or tuple that uniquely encodes the relevant structure of an object — same structure → same key, different structure → different key — enabling O(1) hash map operations on otherwise unhashable or ambiguous inputs.


Design rule 1 — Use a canonical form

A canonical form is a standard representation that all equivalent inputs map to. Two inputs are "equivalent" if your problem treats them as the same bucket.

Example: Anagram grouping (LC #49)

"eat", "tea", "ate" are all anagrams. Their canonical form: sort the characters → "aet". All three map to the same key.

from collections import defaultdict

def groupAnagrams(strs: list[str]) -> list[list[str]]:
    groups = defaultdict(list)
    for s in strs:
        key = "".join(sorted(s))    # canonical form: sorted characters
        groups[key].append(s)
    return list(groups.values())

Alternative canonical form: a 26-element tuple of character counts. Avoids sorting (O(k) vs O(k log k)):

def groupAnagrams_v2(strs: list[str]) -> list[list[str]]:
    groups = defaultdict(list)
    for s in strs:
        count = [0] * 26
        for c in s:
            count[ord(c) - ord('a')] += 1
        key = tuple(count)          # tuples are hashable; lists are not
        groups[key].append(s)
    return list(groups.values())

Pause & think

Why is tuple(count) hashable but list(count) is not? What Python property causes this?

Answer

Python requires hash map keys to be immutable (so their hash value can't change after insertion). Lists are mutable — you can change count[0] after inserting it, which would silently corrupt the map. Tuples are immutable — Python guarantees their hash stays constant. The rule: always convert mutable containers to tuples (or join them into strings) before using them as keys.


Design rule 2 — Add separators to avoid false merges

Consider encoding a 2D grid coordinate (row, col) as a string. A naive approach:

key = str(row) + str(col)    # WRONG
# (1, 23) and (12, 3) both become "123"

Fix: use a separator that can't appear in the values:

key = f"{row},{col}"         # (1,23) → "1,23"; (12,3) → "12,3"  ✅

General rule: when concatenating numeric or variable-length parts into a string key, always separate them with a character that cannot be part of the values.

Frame-by-frame: collision example

Without separator:
(1, 23)  → "123"
(12, 3)  → "123"   ← collision — two different grid cells map to the same key!

With separator:
(1, 23)  → "1,23"
(12, 3)  → "12,3"distinct

Design rule 3 — Use tuples for multi-field keys

When your key has multiple heterogeneous parts (different types or semantics), a tuple is cleaner and safer than string concatenation:

# Keying by (value, sign) pair
key = (num, "positive")      # tuple — always hashable, no separator needed

Tuples are compared element by element, so (1, "a") and (1, "b") are always distinct. No separator tricks needed.


Application: Encode and Decode TinyURL (LC #535)

Design a URL shortener with two operations:

  • encode(long_url) → short_code
  • decode(short_code) → long_url

Requirements: O(1) both ways, no two distinct URLs can get the same code, the same URL should always get the same code.

import random
import string

class Codec:
    def __init__(self):
        self.url_to_code = {}    # long_url → short_code
        self.code_to_url = {}    # short_code → long_url
        self.BASE = "http://tinyurl.com/"
        self.CHARS = string.ascii_letters + string.digits   # 62 chars

    def _generate_code(self) -> str:
        return "".join(random.choices(self.CHARS, k=6))    # 62^6 ≈ 56 billion codes

    def encode(self, long_url: str) -> str:
        if long_url in self.url_to_code:
            return self.BASE + self.url_to_code[long_url]  # idempotent: same URL → same code
        code = self._generate_code()
        while code in self.code_to_url:     # collision handling: regenerate on conflict
            code = self._generate_code()
        self.url_to_code[long_url] = code
        self.code_to_url[code] = long_url
        return self.BASE + code

    def decode(self, short_url: str) -> str:
        code = short_url.replace(self.BASE, "")
        return self.code_to_url[code]

Why two maps? encode needs to check if a URL is already stored (URL→code direction). decode needs to retrieve the original URL (code→URL direction). One map can't efficiently serve both directions.

Why check url_to_code first in encode? Idempotency — encoding the same URL twice should return the same short code, not two different codes pointing at the same destination.

Pause & think

What is the probability of a collision with 6-character alphanumeric codes (62 characters)? At what number of stored URLs does the collision probability become non-trivial?

Answer (Birthday Problem)

62^6 ≈ 56 billion possible codes. By the birthday problem, the expected number of URLs before the first collision is approximately √(56 × 10⁹) ≈ 237 000. At 237 000 stored URLs there is about a 50% chance of at least one collision. The while code in code_to_url loop handles this — it just regenerates until a fresh code is found. For typical interview use cases (much less than 237K URLs) this is essentially zero-collision.


Application: Design HashSet from scratch (LC #705)

Implement add(key), remove(key), contains(key) without using the built-in hash set.

class MyHashSet:
    def __init__(self):
        self.SIZE = 1000          # number of buckets
        self.buckets = [[] for _ in range(self.SIZE)]   # each bucket: a list (chaining)

    def _bucket(self, key: int) -> list:
        return self.buckets[key % self.SIZE]   # hash function: modulo

    def add(self, key: int) -> None:
        b = self._bucket(key)
        if key not in b:
            b.append(key)

    def remove(self, key: int) -> None:
        b = self._bucket(key)
        if key in b:
            b.remove(key)

    def contains(self, key: int) -> bool:
        return key in self._bucket(key)

Frame-by-frame: add(1000), add(2000), add(1)

SIZE = 1000

add(1000): bucket = 1000 % 1000 = 0   buckets[0] = [1000]
add(2000): bucket = 2000 % 1000 = 0   buckets[0] = [1000, 2000]  (chaining)
add(1):    bucket = 1    % 1000 = 1   buckets[1] = [1]

contains(2000): bucket = 0, scan [1000, 2000]  True
remove(1000):   bucket = 0, remove  [2000]
contains(1000): bucket = 0, scan [2000]  False

The key design insight: key % SIZE is the hash function. With good SIZE and chaining, each bucket stays short — O(1) average operations.


When to use each key design

SituationKey designExample
Two strings are equivalent if same charssorted string or frequency tupleAnagram grouping
Grid cell (row, col)f"{row},{col}" or (row, col) tupleIsland problems, BFS grid
Multi-field object identitytuple of fields(name, dob) for person
Sequence identity (order matters)tuple(sequence)DP memoisation, state machines
Subarray/range(left, right) tupleRange queries
Bidirectional mappingtwo dictsTinyURL, bimap

The 4 custom hash pitfalls

Watch out for these

  1. No separator in string concatenation. str(1) + str(23) == str(12) + str(3). Always add a delimiter.
  2. Using a list as a key. Lists are mutable → not hashable. Convert to tuple(lst) or ",".join(map(str, lst)).
  3. Forgetting idempotency in encode. If the same URL can generate different codes on repeated calls, decoding breaks for one of them.
  4. Too-small hash table SIZE. If SIZE is small and keys cluster, buckets grow long and operations degrade to O(n). Use a prime SIZE (e.g., 1009) to spread keys.

Say it like a pro (interview one-liner)

"I need a custom hash key that maps equivalent objects to the same string or tuple, and different objects to different keys. I'll use [sorted string / frequency tuple / separator-delimited string / tuple of fields] as the canonical form, store it in a hash map, and get O(1) lookup."


Remember this forever

Custom Hash Design — 4 rules

  1. Canonical form — all equivalent inputs map to the same key (sort, normalize, frequency tuple).
  2. Separator — when string-concatenating variable-length parts, add a delimiter that can't appear in values.
  3. Tuple over list — tuples are hashable; lists are not.
  4. Idempotency — encoding the same input twice must yield the same key.

TinyURL pattern: two maps (input→code, code→input). Check for existing code before generating a new one.

HashSet from scratch: buckets array + key % SIZE hash function + chaining for collisions.


Check yourself

Why does grouping anagrams with a 26-length frequency tuple work better than a sorted string for very long strings?

Sorting a string of length k costs O(k log k). Building the frequency array costs O(k) — one pass. For very long strings, O(k) vs O(k log k) is a meaningful improvement. The frequency tuple is also unambiguous — two strings that are anagrams will always produce the identical 26-element count array, regardless of character order.

In the custom HashSet implementation, why is a prime number a good choice for SIZE?

If SIZE shares common factors with many keys (e.g., SIZE=1000 with keys that are all multiples of 10), keys cluster into a small subset of buckets, making those buckets long and degrading operations toward O(n). A prime SIZE has no common factors with most key distributions, spreading keys more uniformly across all buckets. Common interview primes: 1009, 2003, 10007.

In TinyURL, why do you check `if long_url in url_to_code` at the start of encode?

Idempotency: if the same URL is encoded twice, it should return the same short code both times. Without the check, two different short codes would map to the same destination URL. Both would decode correctly, but the first code would become orphaned (url_to_code would overwrite it with the second code, so re-encoding the original URL would return the second code — inconsistent behaviour).


Practice problems

ProblemDifficultyWhat to noticeLink
Group AnagramsMediumCanonical key: sorted string or frequency tupleLC #49
Encode and Decode TinyURLMediumTwo maps; idempotent encode; collision handlingLC #535
Design HashSetEasyBuckets + modulo hash; chaining for collisionsLC #705
Design HashMapEasySame as HashSet but bucket stores (key, value) pairsLC #706
Word PatternEasyBijection: word→char and char→word, two mapsLC #290
Find Duplicate SubtreesMediumCanonical key: serialised subtree stringLC #652

This completes Chapter 2 — Hashing (7 patterns). Next: Chapter 3 — Strings, starting with the two-pointer technique applied to string problems.