Python Algorithms & Data Structures track

Lesson 06 · Part 2: Core Data Structures

Hashing

dict, set, Counter and the idea of hash collisions

Part 2: Core Data Structures4 min readFree lesson

Introduction

A hash table is a data structure that allows average O(1) insertion, deletion, and lookup. Called the "magic of O(1) lookup," it offers performance that is impossible with binary search (O(log n)) or linear search (O(n)).

In Python, dict, set, Counter, and defaultdict are implemented based on hashing.

Key "apple"  ->  Hash function  ->  Index 3
                                      |
Bucket:  [0][ ] [1][ ] [2][ ] [3]["apple":5] [4][ ] ...
                                      ^
Access in O(1)!

Reasons to use hash in competitive programming:

  • Frequency counting: Counting occurrences of characters/words O(n)
  • Duplicate checking: O(1) membership test
  • Grouping: Group elements with a common key
  • Two-Sum pattern: Improve from O(n^2) -> O(n)

Learning Objectives

  1. Understand and correctly use the average O(1) operations of Python dict and set.
  2. Efficiently solve frequency counting and grouping problems using Counter and defaultdict.
  3. Understand the concept of hash collisions and recognize situations where worst-case O(n) occurs.

Key Concepts

1. dict Operation Complexity

Operation Average Worst Description
d[key] O(1) O(n) Lookup
d[key] = val O(1) O(n) Insert/Modify
del d[key] O(1) O(n) Deletion
key in d O(1) O(n) Membership
len(d) O(1) -- Size
d.keys() O(1) -- Return view
list(d.keys()) O(n) -- Convert to list

Worst O(n): Occurs when hash collisions chain. Rare in practice.


2. Hash Collision

"apple" -> hash -> 3
"grape" -> hash -> 3 <- collision!

Solution:
1. Chaining: link a linked list in the same bucket
2. Open Addressing: search for another empty bucket

Python dict uses Open Addressing.

3. set operations

Operation Time Complexity Example
x in s O(1) average Membership test
s.add(x) O(1) average Add
s.remove(x) O(1) average Remove (KeyError if not present)
s.discard(x) O(1) average Discard (OK if not present)
`s t` (union) O(len(s)+len(t))
s & t (intersection) O(min(len(s),len(t))) Intersection
s - t (difference) O(len(s)) Difference
s ^ t (symmetric difference) O(len(s)+len(t)) Symmetric Difference

4. Counter

from collections import Counter

c = Counter("aabbbcccc")
# Counter({'c': 4, 'b': 3, 'a': 2})

c.most_common(2)   #[('c', 4), ('b', 3)]  -- Top 2

#Counter arithmetic
c1 = Counter(a=3, b=1)
c2 = Counter(a=1, b=2)
c1 + c2   # Counter({'a': 4, 'b': 3})
c1 - c2   #Counter({'a': 2})  -- Keep only positive numbers
c1 & c2   # Counter({'a': 1, 'b': 1})  -- min
c1 | c2   # Counter({'a': 3, 'b': 2})  -- max

5. defaultdict

from collections import defaultdict

#general dict: KeyError occurs
d = {}
d["missing"] += 1   # KeyError!

#defaultdict: default value automatically created
d = defaultdict(int)
d["missing"] += 1   # 0 + 1 = 1, OK

#frequently used pattern
graph  = defaultdict(list)   #adjacency list
cnt    = defaultdict(int)    #frequency calculation
groups = defaultdict(list)   #grouping

Examples

File Content Key Concept
src/01_dict_basics.py dict CRUD, get(), iteration Basic operations
src/02_set_operations.py set operations, frozenset Membership, sets
src/03_counter_usage.py Counter frequency analysis most_common, arithmetic
src/04_hash_applications.py Two-Sum, Anagram grouping Practical patterns

Common Mistakes

Mistake 1: Using a mutable object as a key

#Incorrect example -- list is not hashable
d = {}
d[[1, 2, 3]] = "value"   # TypeError: unhashable type: 'list'

#Correct example -- use tuple
d[(1, 2, 3)] = "value"   # OK

Mistake 2: Confusing defaultdict with a regular dict

from collections import defaultdict

#defaultdict provides a default value for missing keys
d = defaultdict(int)
print(d["missing key"])   #0 (new key created!)
print(list(d.keys()))   #['missing key'] <- caution!

#For simple lookup, using .get() is recommended
d2 = {}
print(d2.get("missing key", 0))   #0 (key not created)

Mistake 3: Modifying a dict while iterating

d = {"a": 1, "b": 2, "c": 3}
#Incorrect example -- RuntimeError!
for key in d:
    if d[key] == 2:
        del d[key]

#Correct example -- Iterate with a copy
for key in list(d.keys()):
    if d[key] == 2:
        del d[key]

Mistake 4: A set has no order

s = {3, 1, 2}
print(list(s))   #[1, 2, 3] or another order (non-deterministic)

#Use sorted() if sorting is needed
print(sorted(s))   #[1, 2, 3] always sorted

Mistake 5: Excluding negatives in Counter subtraction

from collections import Counter

c1 = Counter(a=2, b=3)
c2 = Counter(a=5, b=1)
result = c1 - c2
print(result)   #Counter({'b': 2}) -- Exclude negative results!

#Use subtract if you want to include negatives
c1.subtract(c2)
print(c1)   # Counter({'b': 2, 'a': -3})

Summary

Concept Key Point
dict Average O(1) CRUD, keys must be hashable
set Average O(1) membership, removes duplicates
Counter Count frequencies, most_common, arithmetic operations
defaultdict Automatically creates default values, useful for grouping
Hash collision Worst O(n), in practice almost average O(1)

Try it yourself

  1. Implement Two-Sum in src/04_hash_applications.py using brute force O(n^2) and compare the speed.
  2. Implement using a regular dictionary instead of Counter in src/03_counter_usage.py.
  3. Solve the assignment below.

Example files

The runnable files used in this lesson. Copy them into your own project and experiment.

src/01_dict_basics.pyPython
#Topic: dict CRUD, get() default value, iteration patterns
#Time: O(1) average -- insert/delete/access
#Space: O(n) -- number of stored key-value pairs


def demo_crud() -> None:
    """Basic CRUD operations for dict."""
    print("=" * 50)
    print("dict CRUD")
    print("=" * 50)

    #Create
    d: dict[str, int] = {}
    d["apple"] = 3
    d["banana"] = 5
    d["cherry"] = 2
    print("After creation:", d)

    #Access -- KeyError when accessing a non-existent key
    print("d['apple']:", d["apple"])
    try:
        _ = d["grape"]
    except KeyError as e:
        print(f"KeyError occurred: {e}")

    #Safe access -- get()
    print("d.get('grape', 0) :", d.get("grape", 0))   #Default value 0
    print("d.get('apple', 0) :", d.get("apple", 0))   # 3

    #Update
    d["apple"] = 10
    print("After update:", d)

    #Delete
    del d["cherry"]
    print("After deletion:", d)

    removed = d.pop("banana", -1)
    print(f"pop('banana') = {removed}, remaining dict: {d}")


def demo_iteration() -> None:
    """dict iteration pattern."""
    print()
    print("=" * 50)
    print("Dict iteration")
    print("=" * 50)

    scores = {"Alice": 95, "Bob": 87, "Carol": 92, "David": 78}

    #Keys only
    print("keys()  :", list(scores.keys()))

    #Values only
    print("values():", list(scores.values()))

    #Key-value pairs
    print("items():")
    for name, score in scores.items():
        print(f"  {name}: {score}")

    #Sorted iteration
    print("Scores in descending order:")
    for name, score in sorted(scores.items(), key=lambda x: -x[1]):
        print(f"  {name}: {score}")


def demo_advanced_patterns() -> None:
    """Advanced dict pattern."""
    print()
    print("=" * 50)
    print("Advanced patterns")
    print("=" * 50)

    #setdefault -- set only when key is missing
    d: dict[str, list[int]] = {}
    for num in [1, 2, 1, 3, 2, 1]:
        key = "odd" if num % 2 else "even"
        d.setdefault(key, []).append(num)
    print("setdefault grouping:", d)

    # dict comprehension
    squares = {x: x ** 2 for x in range(1, 6)}
    print("squares:", squares)

    #Filtering
    large = {k: v for k, v in squares.items() if v > 9}
    print("v > 9 filter:", large)

    #Merge (Python 3.9+)
    a = {"x": 1, "y": 2}
    b = {"y": 20, "z": 3}
    merged = a | b   #b takes priority
    print("a | b:", merged)

    #frequency calculation
    words = ["apple", "banana", "apple", "cherry", "banana", "apple"]
    freq: dict[str, int] = {}
    for w in words:
        freq[w] = freq.get(w, 0) + 1
    print("Frequency:", freq)
    print("Mode:", max(freq, key=freq.get))  # type: ignore[arg-type]


if __name__ == "__main__":
    demo_crud()
    demo_iteration()
    demo_advanced_patterns()
src/02_set_operations.pyPython
#Topic: set operations, frozenset, membership test
#Time: O(1) average -- add/remove/in
#Space: O(n) -- number of elements


def demo_set_basics() -> None:
    """set basic operation."""
    print("=" * 50)
    print("Basic set operations")
    print("=" * 50)

    s: set[int] = set()

    #Addition
    for v in [3, 1, 4, 1, 5, 9, 2, 6, 5]:
        s.add(v)
    print("After add (duplicates removed):", sorted(s))

    #Membership -- O(1)
    print("5 in s:", 5 in s)
    print("7 in s:", 7 in s)

    #Removal
    s.remove(1)           #KeyError if not present
    s.discard(100)        #OK even if not present
    print("After remove(1), discard(100):", sorted(s))

    #pop -- remove arbitrary element
    val = s.pop()
    print(f"pop() = {val}, remaining: {sorted(s)}")


def demo_set_operations() -> None:
    """Set operations -- union/intersection/difference/symmetric difference."""
    print()
    print("=" * 50)
    print("Set operations")
    print("=" * 50)

    A = {1, 2, 3, 4, 5}
    B = {3, 4, 5, 6, 7}
    print("A:", sorted(A))
    print("B:", sorted(B))

    print("A | B (union) :", sorted(A | B))
    print("A & B (intersection) :", sorted(A & B))
    print("A - B (difference) :", sorted(A - B))
    print("B - A (difference) :", sorted(B - A))
    print("A ^ B (symmetric difference) :", sorted(A ^ B))

    #Subset / Superset
    C = {1, 2, 3}
    print()
    print(f"{sorted(C)} <= {sorted(A)}: {C <= A}  (issubset)")
    print(f"{sorted(A)} >= {sorted(C)}: {A >= C}  (issuperset)")
    print(f"A.isdisjoint(B): {A.isdisjoint(B)}")  #Disjoint check

    D = {10, 20}
    print(f"A.isdisjoint(D={sorted(D)}): {A.isdisjoint(D)}")  # True


def demo_frozenset() -> None:
    """frozenset -- immutable set, can be used as a dict key."""
    print()
    print("=" * 50)
    print("frozenset")
    print("=" * 50)

    fs = frozenset([1, 2, 3, 2, 1])
    print("frozenset:", fs)
    print("1 in fs:", 1 in fs)

    #Use frozenset as dict key
    d: dict[frozenset[int], str] = {}
    d[frozenset([1, 2])] = "pair"
    d[frozenset([3, 4, 5])] = "triple"
    print("frozenset key dict:", d)

    #Supports set operations too
    fa = frozenset([1, 2, 3])
    fb = frozenset([2, 3, 4])
    print("fa & fb:", fa & fb)


def demo_performance() -> None:
    """Comparison of membership performance between set and list."""
    import time
    print()
    print("=" * 50)
    print("Membership performance: set vs list")
    print("=" * 50)

    n = 500_000
    data = list(range(n))
    target = n - 1  #Worst case

    # list -- O(n)
    t0 = time.perf_counter()
    _ = target in data
    list_time = time.perf_counter() - t0

    # set -- O(1)
    data_set = set(data)
    t0 = time.perf_counter()
    _ = target in data_set
    set_time = time.perf_counter() - t0

    print(f"list (O(n)): {list_time:.6f}s")
    print(f"set  (O(1)): {set_time:.6f}s")
    if set_time > 0 and set_time < list_time:
        print(f"set is about {list_time / set_time:.0f} times faster!")


if __name__ == "__main__":
    demo_set_basics()
    demo_set_operations()
    demo_frozenset()
    demo_performance()
src/03_counter_usage.pyPython
#Topic : Counter -- Frequency calculation, most_common, arithmetic operations
#Time  : O(n) -- Creating Counter
#Space : O(k) -- k = Number of unique elements

from collections import Counter


def demo_counter_creation() -> None:
    """Various ways to create a Counter."""
    print("=" * 50)
    print("Create Counter")
    print("=" * 50)

    #String
    c1 = Counter("aababcabcd")
    print("String:", c1)

    #List
    c2 = Counter([1, 2, 1, 3, 2, 1])
    print("List:", c2)

    #Keyword arguments
    c3 = Counter(red=4, blue=2, green=1)
    print("Keywords:", c3)

    #From dict
    c4 = Counter({"a": 3, "b": 1})
    print("dict  :", c4)


def demo_most_common() -> None:
    """most_common -- the top K elements."""
    print()
    print("=" * 50)
    print("most_common")
    print("=" * 50)

    text = "the quick brown fox jumps over the lazy dog"
    word_cnt = Counter(text.split())
    print("Total:", word_cnt)
    print("Top 3:", word_cnt.most_common(3))
    print("Bottom 3:", word_cnt.most_common()[:-4:-1])  #Reverse slicing

    char_cnt = Counter(text.replace(" ", ""))
    print("Top 5 characters:", char_cnt.most_common(5))


def demo_arithmetic() -> None:
    """Counter arithmetic operation."""
    print()
    print("=" * 50)
    print("Counter arithmetic")
    print("=" * 50)

    c1 = Counter(a=4, b=2, c=0, d=-2)
    c2 = Counter(a=1, b=2, c=3)
    print("c1:", c1)
    print("c2:", c2)

    print("c1 + c2 (sum)  :", c1 + c2)   #Only positives
    print("c1 - c2 (difference)  :", c1 - c2)   #Only positives
    print("c1 & c2 (min) :", c1 & c2)   #Minimum value per key
    print("c1 | c2 (max) :", c1 | c2)   #Maximum value per key

    #subtract -- Including negatives
    c3 = Counter(a=4, b=2)
    c3.subtract(Counter(a=1, b=5))
    print("After subtract:", c3)   # a:3, b:-3

    #update -- Addition
    c4 = Counter(a=1)
    c4.update({"a": 2, "b": 3})
    print("After update:", c4)


def demo_practical_uses() -> None:
    """Practical usage patterns of counters."""
    print()
    print("=" * 50)
    print("Practical use")
    print("=" * 50)

    #1. Find duplicate elements
    nums = [1, 2, 3, 2, 4, 1, 5, 1]
    cnt = Counter(nums)
    duplicates = [n for n, c in cnt.items() if c > 1]
    print("Duplicate elements:", sorted(duplicates))

    #2. Number of unique elements
    print("Number of unique elements:", len(cnt))

    #3. Total sum
    print("Total number of elements:", sum(cnt.values()))

    #4. Check anagram
    def is_anagram(s: str, t: str) -> bool:
        return Counter(s) == Counter(t)

    print("listen/silent anagram:", is_anagram("listen", "silent"))

    #5. Common elements of two lists (including duplicates)
    a = [1, 2, 2, 3, 4]
    b = [2, 2, 3, 3, 5]
    intersection = list((Counter(a) & Counter(b)).elements())
    print("Common elements (including duplicates):", sorted(intersection))


if __name__ == "__main__":
    demo_counter_creation()
    demo_most_common()
    demo_arithmetic()
    demo_practical_uses()
src/04_hash_applications.pyPython
#Topic: Hash Applications -- Two-Sum, Anagram Group, Consecutive Sequence, Subarray Sum
#Time: O(n) -- Hash-based
#Space: O(n) -- Hash table size

from collections import Counter, defaultdict


def two_sum(nums: list[int], target: int) -> tuple[int, int] | None:
    """Two-Sum: Return two indices whose sum is target -- O(n).

    Improve brute force O(n^2) to O(n) using a hash."""
    seen: dict[int, int] = {}   #{value: index}
    for i, num in enumerate(nums):
        complement = target - num
        if complement in seen:
            return (seen[complement], i)
        seen[num] = i
    return None


def group_anagrams(words: list[str]) -> list[list[str]]:
    """Group anagrams -- O(n * k log k), k = maximum word length.

    Use the sorted string as the key."""
    groups: dict[str, list[str]] = defaultdict(list)
    for word in words:
        key = "".join(sorted(word.lower()))
        groups[key].append(word)
    return list(groups.values())


def first_non_repeating(s: str) -> str:
    """Return the first unique character -- O(n)."""
    freq = Counter(s)
    for ch in s:
        if freq[ch] == 1:
            return ch
    return ""   #If none, empty string


def longest_consecutive_sequence(nums: list[int]) -> int:
    """Return the length of the longest consecutive sequence -- O(n).

    Achieve O(n) using a hash set without sorting."""
    num_set = set(nums)
    best = 0

    for num in num_set:
        if num - 1 not in num_set:    #Only search the starting point of the consecutive sequence
            cur = num
            length = 1
            while cur + 1 in num_set:
                cur += 1
                length += 1
            best = max(best, length)

    return best


def subarray_sum_equals_k(nums: list[int], k: int) -> int:
    """Return the number of subarrays with sum k -- O(n).

    Prefix sum + hash map pattern."""
    count = 0
    prefix_sum = 0
    prefix_cnt: dict[int, int] = defaultdict(int)
    prefix_cnt[0] = 1   #Cumulative sum of empty array = 0

    for num in nums:
        prefix_sum += num
        count += prefix_cnt[prefix_sum - k]
        prefix_cnt[prefix_sum] += 1

    return count


if __name__ == "__main__":
    print("=" * 55)
    print("Two-Sum")
    print("=" * 55)
    cases = [
        ([2, 7, 11, 15], 9,  (0, 1)),
        ([3, 2, 4],      6,  (1, 2)),
        ([3, 3],         6,  (0, 1)),
        ([1, 5, 3, 7],   8,  (1, 2)),
    ]
    for nums, target, expected in cases:
        result = two_sum(nums, target)
        ok = "OK" if result == expected else "NG"
        print(f"nums={nums}, target={target} -> {result}  {ok}")

    print()
    print("=" * 55)
    print("Grouping anagrams")
    print("=" * 55)
    words = ["eat", "tea", "tan", "ate", "nat", "bat"]
    groups = group_anagrams(words)
    print("Input:", words)
    for g in groups:
        print(" ", sorted(g))

    print()
    print("=" * 55)
    print("First unique character")
    print("=" * 55)
    for s in ["leetcode", "loveleetcode", "aabb"]:
        result = first_non_repeating(s)
        print(f"{s!r:15} -> {result!r}")

    print()
    print("=" * 55)
    print("Longest consecutive sequence")
    print("=" * 55)
    seq_cases = [
        ([100, 4, 200, 1, 3, 2], 4),
        ([0, 3, 7, 2, 5, 8, 4, 6, 0, 1], 9),
    ]
    for nums, expected in seq_cases:
        result = longest_consecutive_sequence(nums)
        ok = "OK" if result == expected else "NG"
        print(f"{nums} -> {result}  {ok}")

    print()
    print("=" * 55)
    print("Number of subarrays whose sum is k")
    print("=" * 55)
    sub_cases = [
        ([1, 1, 1], 2, 2),
        ([1, 2, 3], 3, 2),
    ]
    for nums, k, expected in sub_cases:
        result = subarray_sum_equals_k(nums, k)
        ok = "OK" if result == expected else "NG"
        print(f"nums={nums}, k={k} -> {result}  {ok}")

Assignment

Pre-Submission Checklist

  • Did you choose the appropriate data structure among dict/set/Counter?
  • Have you indicated the time complexity in comments?
  • Did you check if the value used as a key is hashable?

Problem 1: Baekjoon 1764 -- Never Heard of Them (Silver IV)

Link: https://www.acmicpc.net/problem/1764

Problem Description

Given the names of N people who have never been heard of and M people who have never been seen, print the list of people who have neither been heard nor seen in alphabetical order.

Input

3 4
ohhenrie
charlie
baesangwook
obama
baesangwook
ohhenrie
clinton

Output

2
baesangwook
ohhenrie

Hint

  • Store the first list in a set.
  • Traverse the second list, and if it exists in the set, add it to the result.
  • Time O(N+M), Space O(N)

Problem 2: Baekjoon 10816 -- Number Card 2 (Silver IV)

Link: https://www.acmicpc.net/problem/10816

Problem Description

Count the number of number cards Sanggeun has and print for M numbers how many of each number there are.

Input

10
6 3 2 10 10 10 -10 -10 7 3
8
10 9 -5 2 3 4 5 -10

Output

3 0 0 1 2 0 0 2

Hint

  • Use Counter or dict to store the occurrence count of each number.
  • Time O(N+M), Space O(N)

Problem 3: Baekjoon 1302 -- Bestseller (Silver V)

Link: https://www.acmicpc.net/problem/1302

Problem Description

The titles of books sold throughout the day are given. Print the title of the book that sold the most. If there are multiple, print the one that comes first in alphabetical order.

Input

5
top
top
top
kimchi
kimchi

Output

top

Hint

  • Use Counter to count the sales of each book.
  • Find the most sold book using max(key=...).
  • If there is a tie, choose the one that comes first in alphabetical order.
  • Time O(N log N), Space O(N)

Answer File

  • boj_1764.py
  • boj_10816.py
  • boj_1302.py
Show the reference solution

Try the assignment yourself first — comparing your attempt with a working solution is where most of the learning happens.

boj_10816.pyPython
#Baekjoon 10816 -- Number Card 2
#Time: O(N + M) -- Creating Counter O(N), Checking M times O(M)
#Space: O(N) -- Counter size
#
#Solution: Record the occurrences of Sang-geun's cards using a Counter,
#Then, print the count for each of the M numbers.

import sys
from collections import Counter

input = sys.stdin.readline


def solve() -> None:
    n = int(input())
    cards = Counter(map(int, input().split()))

    m = int(input())
    queries = map(int, input().split())

    print(*[cards[q] for q in queries])


solve()
boj_1302.pyPython
#Baekjoon 1302 -- Bestseller
#Time : O(N log N) -- Counter creation O(N), max O(N), sorting for handling ties
#Space: O(N) -- Counter size
#
#Solution: Count the number of sales for each book using Counter.
#After finding the maximum number of sales, print the first book in alphabetical order among the books with that number.

import sys
from collections import Counter

input = sys.stdin.readline


def solve() -> None:
    n = int(input())
    cnt: Counter[str] = Counter()

    for _ in range(n):
        book = input().strip()
        cnt[book] += 1

    max_count = max(cnt.values())
    #The alphabetically smallest among the books with the highest sales
    best = min(book for book, c in cnt.items() if c == max_count)
    print(best)


solve()
boj_1764.pyPython
#Baekjoon 1764 -- Unknown and Unheard
#Time: O(N + M + K log K) -- K = size of the intersection
#Space: O(N) -- store the first list in a set
#
#Solution: After storing the first list in a set,
#when traversing the second list, if it is in the set, add it to the result. Sort in alphabetical order.

import sys

input = sys.stdin.readline


def solve() -> None:
    n, m = map(int, input().split())
    not_heard: set[str] = set()

    for _ in range(n):
        not_heard.add(input().strip())

    result = []
    for _ in range(m):
        name = input().strip()
        if name in not_heard:
            result.append(name)

    result.sort()
    print(len(result))
    print("\n".join(result))


solve()

Learn this lesson with a mentor

Work through the Python Algorithms & Data Structures track on your own, or join a guided cohort at our Pitampura campus and online.

Call NowEnroll Now