Hash Table Data Structure in Python – Complete Tutorial with Examples

A hash table is one of the most useful data structures in computer science for storing and retrieving data by a unique key. Instead of searching through every item one at a time, a hash table uses a hash function to calculate where a key belongs, making lookup, insertion, and deletion O(1) on average. In this tutorial, you'll learn how hash tables work in Python, understand collisions and load factors, build a custom hash table from scratch, and explore real-world uses such as caching, frequency counting, duplicate detection, and fast lookups.

Hash table data structure in Python

Designed for beginners, Python developers, and candidates preparing for coding interviews, this tutorial covers:

  • Core Concepts & Architecture: Learn how keys, values, hash functions, buckets, collisions, chaining, and load factors work together.
  • Python Implementations: Use Python dictionaries, sets, defaultdict, and Counter, then create a custom hash table from scratch.
  • Real-World Applications: Build practical solutions for word-frequency counting, caching, event deduplication, database-style indexing, and fast key-value lookups.
  • Interview Prep & Patterns: Practice common hash table problems and patterns including Two Sum, Group Anagrams, sliding windows, LRU Cache, and collision handling.

Table of Contents

  1. What is a hash table?
  2. Core Operations & Complexity
  3. Core Operations Explained (With Examples)
  4. Implementing a Stack in Python (3 Ways)
  5. Real-World Practical Problems
  6. Common Patterns to Remember
  7. Practice Roadmap

Introduction

1. What Is hash table?

A hash table is a key–value data structure that uses a hash function to convert a key into an array index, enabling very fast average-case lookup, insertion, and deletion typically O(1). It is different from a queue: a queue preserves arrival order (FIFO), while a hash table preserves no meaningful order and is optimized for finding values by key.

A hash table stores pairs such as:

"user123" -> {name: "Asha", role: "admin"}
"order456" -> {status: "shipped"}

To store or find a value, it computes an index from the key:

index = hash(key) mod table size

Real-World Analogy: Think of a school locker room. Every student has a name, and the locker number is calculated from that name using a simple rule for example, count the letters in the name and use the last digit. When a student wants their bag, they do not search every locker; they apply the same rule, get the locker number, and go directly to it.

How It Relates to What You Already Know

  • Singly Linked List: In a hash table, each bucket can hold a small chain of entries. When two keys map to the same bucket, the new entry is simply added to that chain. The O(1) insert-at-front and O(n) search behavior you learned for singly linked lists applies directly to what happens inside one bucket.
  • Stack / Queue: A stack or queue decides where to add or remove an item using a fixed position rule one end for a stack, both ends for a queue. A hash table solves a different problem: it finds the exact location of a particular key by calculating it from the key itself.
  • Doubly Linked List: A hash table and doubly linked list often work together in problems such as an LRU Cache. The hash table provides fast lookup by key, while the doubly linked list makes it easy to move items around and remove the least recently used entry in constant time.

Where it's actually used

  • Python's dict and set - both are hash tables under the hood; this tutorial is effectively "what's happening when you write my_dict[key] = value."
  • Database indexing - hash indexes let a database jump straight to a row instead of scanning the table.
  • Caching layers (Redis, Memcached) - key-value lookups at massive scale are hash tables distributed across machines.
  • Compilers and interpreters - symbol tables that map variable names to their values/types are hash tables.
  • Deduplication - checking "have I seen this before?" against a large dataset is a hash table lookup, because checking a list would be O(n) per item.

2. Core Operations & Complexity

A Hash Table's operations look different from a linked list's there's no "beginning," "end," or "middle," because position doesn't matter; only the key does. The variants that do matter are: operating by key (the normal case) vs. operating by node reference (when you're already inside a bucket's chain, e.g., mid-iteration), plus the structure-specific mechanics of collision handling and resizing.

Operation Variant Average Case Worst Case Notes
Insert (new key) by key O(1) O(n) Worst case only if every key collides into the same bucket
Insert-or-update (existing key) by key O(1) O(n) Same lookup cost as insert — if the key exists, overwrite; if not, insert
Insert into a bucket's chain by node O(1) O(1) Prepending to a bucket's Singly Linked List — same O(1) as any linked list prepend
Delete by key O(1) O(n) Must locate the bucket (O(1)) then walk its chain (worst case O(n))
Delete by node (within a chain, reference already held) O(1)* O(n) O(1) only if the chain is doubly linked, or you already hold the previous node; otherwise still O(n) to find the predecessor, same limitation as a Singly Linked List
Search / contains by key O(1) O(n) Same shape as delete — bucket lookup is O(1), chain walk is worst case
Update value by key O(1) O(n) Find-then-overwrite; overwrite itself is O(1) once found

How It Relates to What You Already Know

  • Singly Linked List / Doubly Linked List: searching either is always O(n) you have no way to skip ahead. A Hash Table turns that same "find by value" operation into O(1) average case, because the hash function tells you exactly which (small) chain to search, instead of the whole structure.
  • Stack / Queue: both are O(1), but only because they restrict where you can operate (one end, or both). A Hash Table gets O(1) too, but for operations anywhere, keyed by content rather than position a fundamentally different trade-off.
  • Circular Singly/Doubly Linked List: those structures are still O(n) to search, same as their non-circular counterparts — circularity helps with traversal patterns, not lookup speed. A Hash Table is the first structure in this series where search genuinely stops being linear.

3. Core Operations Explained (With Examples)

Every example below is a plain standalone function, built on top of a hash table represented as a Python list of buckets, where each bucket is a chain (a small Singly Linked List).

We'll reuse the same Node idea from your Linked List tutorials, just storing a key-value pair instead of a single value:

Python

                            
class Node:
    """A single entry in a bucket's chain — stores a key/value pair."""
    def __init__(self, key, value):
        self.key = key
        self.value = value
        self.next = None  # points to the next entry in this bucket's chain
                        

Each Node stores:

  • key the lookup name, such as "Alice"
  • value the data attached to that key, such as 100
  • next a pointer to the next node in the same bucket

And a simple hash function to turn any key into a bucket index:

Python

                            
def hash_key(key, num_buckets):
    """Convert a key into a bucket index in range [0, num_buckets)."""
    # Python's built-in hash() gives a large integer; modulo compresses it
    # down to a valid index — this compression step is called "hash + compress."
    return hash(key) % num_buckets
                            

3.1 Insert a new key

Conceptually: compute the bucket index, then prepend a new node to that bucket's chain (if the key isn't already there).

Before (inserting "apple": 3, with 5 buckets, "apple" hashes to bucket 2):

bucket 0: empty
bucket 1: empty
bucket 2: empty
bucket 3: empty
bucket 4: empty

After:

bucket 0: empty
bucket 1: empty
bucket 2: [apple:3] -> None
bucket 3: empty
bucket 4: empty

Isolated code:

Python

                            
def insert(buckets, key, value):
    index = hash_key(key, len(buckets))
    new_node = Node(key, value)
    new_node.next = buckets[index]  # Point the new_node next into a buckets
    buckets[index] = new_node

# Create 5 empty buckets
buckets = [None] * 5

# Insert some key-value pairs
insert(buckets, "Alice", 100)
insert(buckets, "Bob", 200)
insert(buckets, "John", 300)
insert(buckets, "Kevin", 400)
insert(buckets, "Kennedy", 500)

# Display the has table
for idx, bucket in enumerate(buckets):
    print(f"Bucket {idx}")

    current = bucket

    while current is not None:
        print(f"    {current.key} -> {current.value}")
        current = current.next
                            

Step-by-Step Explanation

What This Program Does

This program implements a small hash table using separate chaining. Each bucket is the head of a singly linked list, and new entries are inserted at the front of that list.

1. The insert function

Python

def insert(buckets, key, value):
    index = hash_key(key, len(buckets))
    new_node = Node(key, value)
    new_node.next = buckets[index]
    buckets[index] = new_node

Step-by-step:

  • hash_key(key, len(buckets)) calculates the bucket index for the given key. The number of buckets is passed so that the result stays within valid indexes.
  • Node(key, value) creates a new linked-list node holding the key and its value.
  • new_node.next = buckets[index] links the new node to the current first node in that bucket. If the bucket is empty, buckets[index] is None, so new_node.next becomes None.
  • buckets[index] = new_node makes the new node the new head of that bucket’s linked list.

So the function always inserts the newest entry at the front of the bucket list. This is a common separate-chaining approach: when multiple keys hash to the same bucket, they are stored in the same linked list.

2. Creating empty buckets

Python

buckets = [None] * 5

This creates an array of five empty buckets:

Bucket 0: None
Bucket 1: None
Bucket 2: None
Bucket 3: None
Bucket 4: None

Each None means that bucket currently has no linked list.

3. Inserting values

Python

insert(buckets, "Alice", 100)
insert(buckets, "Bob", 200)
insert(buckets, "John", 300)
insert(buckets, "Kevin", 400)
insert(buckets, "Kennedy", 500)

For every call:

  • The key is hashed to find a bucket index.
  • A node is created for the key-value pair.
  • The node is linked to the existing bucket head.
  • The bucket now points to the new node.

For example, if "Alice" hashes to bucket 2:

Before:

Bucket 2: None

After:

Bucket 2: Alice -> 100 -> None

If "Kevin" later hashes to the same bucket 2:

Bucket 2: Kevin -> 400 -> Alice -> 100 -> None

Kevin appears first because it was inserted at the front.

4. Displaying the hash table

Python

for idx, bucket in enumerate(buckets):
    print(f"Bucket {idx}")

    current = bucket

    while current is not None:
        print(f"    {current.key} -> {current.value}")
        current = current.next

Step-by-step:

  • enumerate(buckets) gives both the bucket index and the head node of that bucket.
  • print(f"Bucket {idx}") prints the bucket number.
  • current = bucket starts traversal at the head of that bucket’s linked list.
  • The while loop runs until current becomes None.
  • Each node’s key and value are printed.
  • current = current.next moves to the next node in the chain.

5. Example output format

The exact bucket positions depend on hash_key(), but the output will look like this:

Bucket 0
    Kennedy -> 500
Bucket 1
Bucket 2
    Kevin -> 400
    Alice -> 100
Bucket 3
    Bob -> 200
Bucket 4
    John -> 300

Buckets that contain None print only their bucket number because their linked list is empty.

Important note

This insert function does not check whether the same key already exists. If you insert "Alice" twice, the program will store two separate Alice nodes in the same bucket instead of updating the original value. A complete hash table usually searches the bucket first and updates the value when the key already exists.

3.2 Insert-or-update an existing key

Conceptually: walk the target bucket's chain if the key is already there, overwrite its value; otherwise, prepend a new node.

Before (updating "apple" from 3 to 7):

bucket 2: [apple:3] -> None

After:

bucket 2: [apple:7] -> None

Isolated code:

Python

                            
def insert_or_update(buckets, key, value):
    index = hash_key(key, len(buckets))
    current = buckets[index]
    while current is not None:
        if current.key == key:
            current.value = value  # key found - overwrite
            return
        current = current.next

    # key is not found
    new_node = Node(key, value)
    new_node.next = buckets[index]
    buckets[index] = new_node


buckets = [None] * 5
insert_or_update(buckets,"Alice", 100)
insert_or_update(buckets,"Bob", 200)
insert_or_update(buckets,"Alice", 300)
insert_or_update(buckets,"Kennedy", 400)


for idx, bucket in enumerate(buckets):
    print(f"Buckest {idx}")

    current = bucket
    while current is not None:
        print(f"    {current.key} -> {current.value}")
        current = current.next
                            

Step-by-Step Explanation

What This Program Does

This program implements an insert-or-update operation for a hash table that uses separate chaining. If the key already exists in its bucket, the function changes its value; otherwise, it creates a new node and inserts it at the front of that bucket’s linked list.

1. insert_or_update Explanation

Python

def insert_or_update(buckets, key, value):
    index = hash_key(key, len(buckets))
    current = buckets[index]
  • hash_key(key, len(buckets)) finds the bucket index for the key.
  • current = buckets[index] starts at the first node in that bucket’s linked list.
  • If the bucket is empty, current is None.

Python

    while current is not None:
        if current.key == key:
            current.value = value
            return
        current = current.next
  • The loop checks every node in the bucket.
  • If it finds a node whose key matches the input key, it updates that node’s value.
  • return stops the function immediately, so no duplicate node is created.
  • If the key is not found, current eventually becomes None and the loop ends.

Python

    new_node = Node(key, value)
    new_node.next = buckets[index]
    buckets[index] = new_node
  • A new node is created for the key and value.
  • new_node.next = buckets[index] links the new node to the previous head of the bucket.
  • buckets[index] = new_node makes the new node the new head of the bucket.

This is called insert at front. It is efficient because it does not need to walk to the end of the linked list.

2. Example Execution

Python

buckets = [None] * 5

insert_or_update(buckets, "Alice", 100)
insert_or_update(buckets, "Bob", 200)
insert_or_update(buckets, "Alice", 300)
insert_or_update(buckets, "Kennedy", 400)

Assume "Alice" and "Kennedy" hash to the same bucket, while "Bob" hashes to another bucket.

First call

Python

insert_or_update(buckets, "Alice", 100)
  • The bucket is empty.
  • A node Alice -> 100 is created.
  • It becomes the head of its bucket.
Bucket: Alice -> 100 -> None

Second call

Python

insert_or_update(buckets, "Bob", 200)
  • "Bob" hashes to its own bucket.
  • The bucket is empty.
  • A node Bob -> 200 is created.
Bucket: Bob -> 200 -> None

Third call

Python

insert_or_update(buckets, "Alice", 300)
  • "Alice" hashes to the same bucket as before.
  • The loop finds the existing Alice node.
  • Its value changes from 100 to 300.
  • No new node is added.
Bucket: Alice -> 300 -> None

Fourth call

Python

insert_or_update(buckets, "Kennedy", 400)
  • "Kennedy" hashes to Alice’s bucket.
  • The loop checks Alice, but the key does not match.
  • A new node is created and placed at the front.
Bucket: Kennedy -> 400 -> Alice -> 300 -> None

3. Display Loop

Python

for idx, bucket in enumerate(buckets):
    print(f"Buckest {idx}")

    current = bucket
    while current is not None:
        print(f"    {current.key} -> {current.value}")
        current = current.next
  • enumerate(buckets) gives the bucket number and its head node.
  • current = bucket starts traversal from the bucket head.
  • The while loop prints every node in the chain.
  • current = current.next moves to the next node.
  • When current becomes None, the loop stops.

The output will look like this, depending on the bucket indexes produced by hash_key():

Buckest 0
Buckest 1
    Bob -> 200
Buckest 2
    Kennedy -> 400
    Alice -> 300
Buckest 3
Buckest 4

Key Difference from Plain Insert

A plain insert always adds a new node, even if the key already exists. This function first searches the bucket, so repeated keys are updated rather than duplicated. That behavior matches the usual put operation in a hash map.

3.3 Search / contains by key

Conceptually: compute the bucket index, then walk that one chain looking for a matching key.

Isolated code:

Python

                            
def get(buckets, key):
    index = hash_key(key, len(buckets))
    current = buckets[index]

    while current is not None:
        if current.key == key:
            return current.value
        current = current.next

    return None


def contains_key(buckets, key):
    return get(buckets, key) is not None


def insert(buckets, key, value):
    index = hash_key(key, len(buckets))
    new_node = Node(key, value)
    new_node.next = buckets[index]  # Point the new_node next into a buckets
    buckets[index] = new_node

# Create 5 empty buckets
buckets = [None] * 5

# Insert some key-value pairs
insert(buckets, "Alice", 100)
insert(buckets, "Bob", 200)
insert(buckets, "John", 300)
insert(buckets, "Kevin", 400)
insert(buckets, "Kennedy", 500)

print("Alice exists:", contains_key(buckets, "Alice"))
print("David exists:", contains_key(buckets, "David"))
                            

Step-by-Step Explanation

What This Program Does

This program shows how to search for a key in a hash table built with separate chaining. get() returns the stored value for a key, while contains_key() uses get() to return True or False.

1. get() explanation

Python

def get(buckets, key):
    index = hash_key(key, len(buckets))
    current = buckets[index]
  • hash_key(key, len(buckets)) calculates the bucket index where the key should be stored.
  • current = buckets[index] starts at the first node in that bucket’s linked list.
  • If the bucket is empty, current is None.

Python

    while current is not None:
        if current.key == key:
            return current.value
        current = current.next
  • The loop checks each node in the bucket.
  • If current.key == key, the matching node has been found.
  • The function immediately returns current.value.
  • If the keys do not match, current = current.next moves to the next node.
  • If the loop ends without finding the key, the function returns None.

In a separate-chaining hash table, each bucket holds a linked list, and a lookup scans only that bucket’s chain rather than every entry in the table.

2. contains_key() explanation

Python

def contains_key(buckets, key):
    return get(buckets, key) is not None
  • It calls get(buckets, key).
  • If get() returns a value, the key exists, so the result is True.
  • If get() returns None, the key does not exist, so the result is False.

For example:

Python

contains_key(buckets, "Alice")

returns:

True

because "Alice" was inserted earlier.

Python

contains_key(buckets, "David")

returns:

False

because "David" was never inserted.

3. insert() explanation

Python

def insert(buckets, key, value):
    index = hash_key(key, len(buckets))
    new_node = Node(key, value)
    new_node.next = buckets[index]
    buckets[index] = new_node
  • The key is hashed to find its bucket index.
  • A new node is created with the key and value.
  • new_node.next = buckets[index] links the new node to the current head of that bucket.
  • buckets[index] = new_node makes the new node the new head of the bucket.

So every insertion places the newest node at the front of its bucket’s linked list.

4. Creating and filling the table

Python

buckets = [None] * 5

This creates five empty buckets:

Bucket 0: None
Bucket 1: None
Bucket 2: None
Bucket 3: None
Bucket 4: None

Python

insert(buckets, "Alice", 100)
insert(buckets, "Bob", 200)
insert(buckets, "John", 300)
insert(buckets, "Kevin", 400)
insert(buckets, "Kennedy", 500)

Each key is hashed to one of the five buckets. If two keys hash to the same bucket, both remain in that bucket as a linked list.

For example, if "Kevin" and "Kennedy" hash to bucket 2, the bucket may look like this:

Bucket 2: Kennedy -> 500 -> Kevin -> 400 -> None

Kennedy appears first because it was inserted after Kevin.

5. Expected output

Python

print("Alice exists:", contains_key(buckets, "Alice"))
print("David exists:", contains_key(buckets, "David"))

The output is:

Alice exists: True
David exists: False

Important limitation

This get() returns None both when the key is missing and when a stored value is actually None. In this program, all stored values are numbers, so that is not a problem. For a more general hash table, it is better to use a separate contains_key() check before calling get().

3.4 Delete by key

Conceptually: walk the target bucket's chain, tracking the previous node, and unlink the matching node when found.

Before (deleting "apple" from bucket 2):

Before (deleting "Lemon" from bucket 2):
bucket 0: [Guava:60] -> [Grape:20] -> None
bucket 1: [Peach:40] -> None
bucket 2: [Melon:50] -> [Lemon:30] -> None
bucket 3: [Mango:10] -> None
bucket 4: empty

After:

bucket 0: [Guava:60] -> [Grape:20] -> None
bucket 1: [Peach:40] -> None
bucket 2: [Melon:50] -> None
bucket 3: [Mango:10] -> None
bucket 4: empty

Isolated code:

Python

                            
def delete_by_key(buckets, key):
    index = hash_key(key, len(buckets))
    current = buckets[index]   # Point to the first node in the bucket's linked list
    previous = None

    while current is not None:
        if current.key == key:
            if previous is None:
                buckets[index] = current.next
            else:
                previous.next = current.next
            return True
        previous = current
        current = current.next

    return False


def insert(buckets, key, value):
    index = hash_key(key, len(buckets))
    new_node = Node(key, value)
    new_node.next = buckets[index]  # Point the new_node next into a bucket
    buckets[index] = new_node


def print_buckets(buckets):
    for idx, bucket in enumerate(buckets):
        print(f"Bucket {idx}")
        current = bucket
        while current is not None:
            print(f"    {current.key} -> {current.value}")
            current = current.next


# Create 5 empty buckets
buckets = [None] * 5

# Insert some key-value pairs
insert(buckets, "Mango", 10)
insert(buckets, "Grape", 20)
insert(buckets, "Lemon", 30)
insert(buckets, "Peach", 40)
insert(buckets, "Melon", 50)
insert(buckets, "Guava", 60)

print("Before deleting 'Lemon':")
print_buckets(buckets)

delete_by_key(buckets, "Lemon")

print("\nAfter deleting 'Lemon':")
print_buckets(buckets)
                            

Step-by-Step Explanation

What This Program Does

This program implements deletion in a hash table that uses separate chaining. It finds the bucket for a key, removes the matching node from that bucket’s linked list, and prints the table before and after deletion.

1. Program structure (delete_by_key)

Python

def delete_by_key(buckets, key):
    index = hash_key(key, len(buckets))
    current = buckets[index]
    previous = None
  • hash_key(key, len(buckets)) finds the bucket index for the key.
  • current = buckets[index] points to the first node in that bucket’s linked list.
  • previous = None means no node has been visited yet.

Python

    while current is not None:
        if current.key == key:
  • The loop checks each node in the bucket.
  • If current.key == key, the node to delete has been found.

Python

            if previous is None:
                buckets[index] = current.next

If the matching node is the first node in the bucket, the bucket head is changed to the next node. This removes the first node from the chain.

Python

            else:
                previous.next = current.next
            return True

If the matching node is not the first node, the previous node is linked directly to the node after the deleted node. This removes the current node from the middle or end of the chain. The function returns True because deletion succeeded.

Python

        previous = current
        current = current.next
    return False
  • If the current node is not a match, previous becomes the current node.
  • current moves to the next node.
  • If the loop ends without finding the key, the function returns False.

In separate chaining, each bucket contains a linked list, and deletion removes the matching entry from that chain.

2. insert() explanation

Python

def insert(buckets, key, value):
    index = hash_key(key, len(buckets))
    new_node = Node(key, value)
    new_node.next = buckets[index]
    buckets[index] = new_node
  • The key is hashed to find its bucket.
  • A new node is created with the key and value.
  • new_node.next = buckets[index] links the new node to the current bucket head.
  • buckets[index] = new_node makes the new node the new head of that bucket.

So new entries are inserted at the front of the bucket’s linked list.

3. print_buckets() explanation

Python

def print_buckets(buckets):
    for idx, bucket in enumerate(buckets):
        print(f"Bucket {idx}")
        current = bucket
        while current is not None:
            print(f"    {current.key} -> {current.value}")
            current = current.next
  • enumerate(buckets) gives each bucket index and its head node.
  • current = bucket starts at the first node in that bucket.
  • The while loop prints every key-value pair in the bucket.
  • current = current.next moves to the next node.
  • When current becomes None, the loop stops.

4. Example execution

Python

buckets = [None] * 5

insert(buckets, "Mango", 10)
insert(buckets, "Grape", 20)
insert(buckets, "Lemon", 30)
insert(buckets, "Peach", 40)
insert(buckets, "Melon", 50)
insert(buckets, "Guava", 60)

This creates five empty buckets and inserts six fruit entries. The exact bucket positions depend on hash_key().

Assume "Lemon" hashes to bucket 2, and bucket 2 contains:

Before deleting "Lemon":

Bucket 2: [30] -> [10] -> None

Deleting "Lemon"

Python

delete_by_key(buckets, "Lemon")

Step-by-step:

  • "Lemon" hashes to bucket 2.
  • current points to the first node, Lemon -> 30.
  • previous is None.
  • The first comparison finds current.key == "Lemon".
  • Since previous is None, the program runs: buckets[index] = current.next.
  • Bucket 2 now points to Mango -> 10.
  • The Lemon node is removed from the chain.
  • The function returns True.

After deleting "Lemon":

Bucket 2: [10] -> None

5. Expected output format

The exact output depends on the hash function, but it will look like this:

Before deleting 'Lemon':
Bucket 0
Bucket 1
    Peach -> 40
Bucket 2
    Lemon -> 30
    Mango -> 10
Bucket 3
    Grape -> 20
    Melon -> 50
Bucket 4
    Guava -> 60

After deleting 'Lemon':
Bucket 0
Bucket 1
    Peach -> 40
Bucket 2
    Mango -> 10
Bucket 3
    Grape -> 20
    Melon -> 50
Bucket 4
    Guava -> 60

6. Deletion cases

Case What happens
Key is the first node buckets[index] is updated to current.next
Key is in the middle previous.next is updated to current.next
Key is the last node previous.next becomes None
Key is not found The loop reaches None and returns False

The time taken depends on the number of nodes in the target bucket. With a good hash function and reasonably short chains, deletion is efficient on average.

3.5 Delete by node reference (already inside a chain)

Conceptually: sometimes you're already iterating a bucket's chain (for example, during a resize) and hold a direct reference to the node you want removed but a Singly Linked chain still has no prev pointer, so you can't unlink it in O(1) without also tracking the predecessor yourself.

Before (deleting "Koala" from bucket 3):
bucket 0: [Zebra:22] -> None
bucket 1: [Otter:66] -> None
bucket 2: [Whale:77] -> [Tiger:11] -> None
bucket 3: [Eagle:55] -> [Koala:44] -> None
bucket 4: [Panda:33] -> None

After:

bucket 0: [Zebra:22] -> None
bucket 1: [Otter:66] -> None
bucket 2: [Whale:77] -> [Tiger:11] -> None
bucket 3: [Eagle:55] -> None
bucket 4: [Panda:33] -> None

Isolated code:

Python

                            
def delete_node_with_predecessor(buckets, index, target_node, previous_node):
    if previous_node is None:
        buckets[index] = target_node.next      # target is the head of the chain
    else:
        previous_node.next = target_node.next  # bypass the target node


def insert(buckets, key, value):
    index = hash_key(key, len(buckets))
    new_node = Node(key, value)
    new_node.next = buckets[index]  # Point the new_node next into a bucket
    buckets[index] = new_node


def print_buckets(buckets):
    for idx, bucket in enumerate(buckets):
        print(f"Bucket {idx}")
        current = bucket
        while current is not None:
            print(f"    {current.key} -> {current.value}")
            current = current.next


# Create 5 empty buckets
buckets = [None] * 5

# Insert some key-value pairs
insert(buckets, "Tiger", 11)
insert(buckets, "Zebra", 22)
insert(buckets, "Panda", 33)
insert(buckets, "Koala", 44)
insert(buckets, "Eagle", 55)
insert(buckets, "Otter", 66)
insert(buckets, "Whale", 77)

print("Before deleting 'Koala':")
print_buckets(buckets)

# Delete Koala
key = "Koala"
index = hash_key(key, len(buckets))
current = buckets[index]
previous = None
while current is not None:
    if current.key == key:
        delete_node_with_predecessor(buckets, index, current, previous)
        break
    previous = current
    current = current.next

print("\nAfter deleting 'Koala':")
print_buckets(buckets)
                            

Step-by-Step Explanation

What This Program Does

This program separates the search for a key from the actual deletion. Once the target node and its predecessor are found, delete_node_with_predecessor() removes the node by changing only the necessary links.

1. delete_node_with_predecessor() explanation

Python

def delete_node_with_predecessor(buckets, index, target_node, previous_node):
    if previous_node is None:
        buckets[index] = target_node.next
  • target_node is the node to delete.
  • previous_node is the node immediately before it in the same bucket.
  • If previous_node is None, the target node is the first node in the bucket.
  • Therefore, the bucket itself must point to the node after the target: buckets[index] = target_node.next.
  • This removes the head node from the bucket’s linked list.

Python

    else:
        previous_node.next = target_node.next
  • If the target node is not the first node, the previous node must skip over it.
  • previous_node.next = target_node.next links the previous node directly to the node after the target.
  • The target node is no longer part of the chain.

For a singly linked list, deletion needs the predecessor because the predecessor’s next pointer must be updated.

2. insert() explanation

Python

def insert(buckets, key, value):
    index = hash_key(key, len(buckets))
    new_node = Node(key, value)
    new_node.next = buckets[index]
    buckets[index] = new_node
  • The key is hashed to find its bucket index.
  • A new node is created with the key and value.
  • new_node.next = buckets[index] links the new node to the current first node in the bucket.
  • buckets[index] = new_node makes the new node the new head of that bucket.

So every new entry is inserted at the front of its bucket’s linked list.

3. print_buckets() explanation

Python

def print_buckets(buckets):
    for idx, bucket in enumerate(buckets):
        print(f"Bucket {idx}")
        current = bucket
        while current is not None:
            print(f"    {current.key} -> {current.value}")
            current = current.next
  • enumerate(buckets) provides the bucket number and the head node.
  • current = bucket starts at the first node in that bucket.
  • The while loop prints each key-value pair.
  • current = current.next moves to the next node.
  • The loop stops when current becomes None.

4. Creating and filling the table

Python

buckets = [None] * 5

insert(buckets, "Tiger", 11)
insert(buckets, "Zebra", 22)
insert(buckets, "Panda", 33)
insert(buckets, "Koala", 44)
insert(buckets, "Eagle", 55)
insert(buckets, "Otter", 66)
insert(buckets, "Whale", 77)

This creates five empty buckets. Each animal key is hashed to one of the five buckets. If several keys hash to the same bucket, they are stored as a linked list in that bucket.

5. Deleting "Koala"

Python

key = "Koala"
index = hash_key(key, len(buckets))
current = buckets[index]
previous = None
  • "Koala" is hashed to find its bucket.
  • current starts at the first node in that bucket.
  • previous starts as None.

Python

while current is not None:
    if current.key == key:
        delete_node_with_predecessor(buckets, index, current, previous)
        break
    previous = current
    current = current.next
  • The loop searches for the node containing "Koala".
  • When it is found, current is the target node and previous is its predecessor.
  • delete_node_with_predecessor() removes the target node.
  • break stops the loop because the deletion is complete.
  • If the current node is not "Koala", previous moves to current, and current moves to the next node.

Example

Assume bucket 2 contains:

Before deleting "Koala":

Bucket 2: [44] -> [33] -> None

Here:

  • target_node = Koala node
  • previous_node = None

Because previous_node is None, the program runs:

So bucket 2 now points to the Panda node:

After deleting "Koala":

Bucket 2: [33] -> None

If "Koala" were in the middle of the chain, the previous node would instead be updated:

That bypasses the Koala node and keeps the rest of the chain connected.

6. Expected output format

The exact bucket positions depend on hash_key(), but the output will look like this:

Before deleting 'Koala':
Bucket 0
Bucket 1
    Zebra -> 22
Bucket 2
    Koala -> 44
    Panda -> 33
Bucket 3
    Tiger -> 11
    Eagle -> 55
Bucket 4
    Otter -> 66
    Whale -> 77

After deleting 'Koala':
Bucket 0
Bucket 1
    Zebra -> 22
Bucket 2
    Panda -> 33
Bucket 3
    Tiger -> 11
    Eagle -> 55
Bucket 4
    Otter -> 66
    Whale -> 77

7. Why the predecessor matters

Target position previous_node Link changed
First node None buckets[index] = target_node.next
Middle node Node before target previous_node.next = target_node.next
Last node Node before target previous_node.next = None

Once the target and predecessor are known, deletion itself takes constant time because only one pointer needs to change.

3.6 Update value by key

Before (updating "Tiger" in bucket 2):
bucket 0: [Zebra:22] -> None
bucket 1: [Otter:66] -> None
bucket 2: [Whale:77] -> [Tiger:11] -> None
bucket 3: [Eagle:55] -> [Koala:44] -> None
bucket 4: [Panda:33] -> None

After:

bucket 0: [Zebra:22] -> None
bucket 1: [Otter:66] -> None
bucket 2: [Whale:77] -> [Tiger:99] -> None
bucket 3: [Eagle:55] -> [Koala:44] -> None
bucket 4: [Panda:33] -> None

Isolated code:

Python

                            
def update_value(buckets, key, new_value):
    index = hash_key(key, len(buckets))
    current = buckets[index]

    while current is not None:
        if current.key == key:
            current.value = new_value
            return True
        current = current.next

    return False


def insert(buckets, key, value):
    index = hash_key(key, len(buckets))
    new_node = Node(key, value)
    new_node.next = buckets[index]  # Point the new_node next into a bucket
    buckets[index] = new_node


def print_buckets(buckets):
    for idx, bucket in enumerate(buckets):
        print(f"Bucket {idx}")
        current = bucket
        while current is not None:
            print(f"    {current.key} -> {current.value}")
            current = current.next


# Create 5 empty buckets
buckets = [None] * 5

# Insert some key-value pairs
insert(buckets, "Tiger", 11)
insert(buckets, "Zebra", 22)
insert(buckets, "Panda", 33)
insert(buckets, "Koala", 44)
insert(buckets, "Eagle", 55)
insert(buckets, "Otter", 66)
insert(buckets, "Whale", 77)

print("Before updating 'Tiger':")
print_buckets(buckets)

print("\nupdate_value('Tiger', 99):", update_value(buckets, "Tiger", 99))
print("update_value('Lion', 1):  ", update_value(buckets, "Lion", 1))

print("\nAfter updating 'Tiger':")
print_buckets(buckets)
                            

Step-by-Step Explanation

What This Program Does

This program updates the value of an existing key in a hash table that uses separate chaining. It searches only the bucket where the key should be stored, changes the value if the key is found, and returns True; otherwise, it returns False.

1. update_value() explanation

Python

def update_value(buckets, key, new_value):
    index = hash_key(key, len(buckets))
    current = buckets[index]
  • hash_key(key, len(buckets)) calculates the bucket index for the key.
  • current = buckets[index] starts at the first node in that bucket’s linked list.
  • If the bucket is empty, current is None.

Python

    while current is not None:
        if current.key == key:
            current.value = new_value
            return True
        current = current.next
    return False
  • The loop checks each node in the bucket.
  • If current.key == key, the matching node has been found.
  • current.value = new_value replaces the old value with the new value.
  • return True immediately ends the function because the update succeeded.
  • If the key does not match, current = current.next moves to the next node.
  • If the loop reaches the end without finding the key, the function returns False.

In separate chaining, a lookup first hashes the key to locate its bucket and then walks that bucket’s linked list to compare keys.

2. insert() explanation

Python

def insert(buckets, key, value):
    index = hash_key(key, len(buckets))
    new_node = Node(key, value)
    new_node.next = buckets[index]
    buckets[index] = new_node
  • The key is hashed to find its bucket index.
  • A new node is created with the key and value.
  • new_node.next = buckets[index] links the new node to the current head of the bucket.
  • buckets[index] = new_node makes the new node the new head of that bucket.

So each new entry is inserted at the front of its bucket’s linked list.

3. print_buckets() explanation

Python

def print_buckets(buckets):
    for idx, bucket in enumerate(buckets):
        print(f"Bucket {idx}")
        current = bucket
        while current is not None:
            print(f"    {current.key} -> {current.value}")
            current = current.next
  • enumerate(buckets) gives each bucket index and its head node.
  • current = bucket starts at the first node in that bucket.
  • The while loop prints every key-value pair in the bucket.
  • current = current.next moves to the next node.
  • When current becomes None, the loop stops.

4. Creating and filling the table

Python

buckets = [None] * 5

insert(buckets, "Tiger", 11)
insert(buckets, "Zebra", 22)
insert(buckets, "Panda", 33)
insert(buckets, "Koala", 44)
insert(buckets, "Eagle", 55)
insert(buckets, "Otter", 66)
insert(buckets, "Whale", 77)

Each animal key is hashed to one of the five buckets. If multiple keys hash to the same bucket, they are stored in the same linked list.

5. Updating "Tiger"

Python

print("\nupdate_value('Tiger', 99):", update_value(buckets, "Tiger", 99))

Step-by-step:

  • "Tiger" is hashed to find its bucket.
  • The loop searches that bucket’s linked list.
  • When the node with key "Tiger" is found, its value changes from 11 to 99.
  • The function returns True.

Before:

Tiger -> 11

After:

Tiger -> 99

6. Trying to update "Lion"

Python

print("update_value('Lion', 1): ", update_value(buckets, "Lion", 1))
  • "Lion" is hashed to find its bucket.
  • The loop checks every node in that bucket.
  • No node has the key "Lion".
  • The loop ends, and the function returns False.
  • No value is changed and no new node is inserted.

7. Expected output format

The exact bucket positions depend on hash_key(), but the output will look like this:

Before updating 'Tiger':
Bucket 0
Bucket 1
    Zebra -> 22
Bucket 2
    Tiger -> 11
    Panda -> 33
Bucket 3
    Eagle -> 55
Bucket 4
    Koala -> 44
    Otter -> 66
    Whale -> 77

update_value('Tiger', 99): True
update_value('Lion', 1):  False

After updating 'Tiger':
Bucket 0
Bucket 1
    Zebra -> 22
Bucket 2
    Tiger -> 99
    Panda -> 33
Bucket 3
    Eagle -> 55
Bucket 4
    Koala -> 44
    Otter -> 66
    Whale -> 77

8. Result cases

Key status Function result Action
Key exists True Existing value is replaced
Key does not exist False No change is made
Bucket is empty False No node is available to update

The update operation follows the same two-step pattern as lookup: hash the key to find the bucket, then search that bucket’s chain for the matching key.


4. Implementation

Method A: Using the built-in structure

Python's dict is a production-grade hash table using it directly is almost always the right call in real code:

Python

                            
phonebook = {}
phonebook["Alice"] = "555-0100"
phonebook["Bob"] = "555=0142"

print(phonebook.get("Alice"))
print("Charlie" in phonebook)
del phonebook["Bob"]
print(phonebook.get("Bob"))

                        

Step-by-Step Explanation

What This Program Does

This program uses a Python dictionary as a simple phonebook. It adds contacts, looks up a number, checks whether a contact exists, deletes a contact, and safely checks for a deleted contact.

1. Create an empty dictionary

Python

phonebook = {}
  • {} creates an empty dictionary.
  • A dictionary stores data as key: value pairs.
  • Here, each person’s name is the key, and their phone number is the value.

At this point:

phonebook = {}

2. Add Alice

Python

phonebook["Alice"] = "555-0100"
  • "Alice" is the key.
  • "555-0100" is the value.
  • This adds a new entry to the dictionary.

Now:

phonebook = {
    "Alice": "555-0100"
}

3. Add Bob

Python

phonebook["Bob"] = "555=0142"
  • "Bob" is the key.
  • "555=0142" is the value.
  • Note: this value contains = instead of the usual - in a phone number, but Python treats it as an ordinary string.

Now:

phonebook = {
    "Alice": "555-0100",
    "Bob": "555=0142"
}

Dictionaries store unique keys; assigning a value to an existing key replaces its old value.

4. Get Alice’s phone number

Python

print(phonebook.get("Alice"))
  • phonebook.get("Alice") looks up the key "Alice".
  • The key exists, so it returns "555-0100".
  • print() displays that value.

Output:

555-0100

.get() is safer than phonebook["Alice"] because it returns None instead of raising a KeyError when a key is missing.

5. Check whether Charlie exists

Python

print("Charlie" in phonebook)
  • The in operator checks whether "Charlie" is a key in the dictionary.
  • "Charlie" was never added, so the result is False.

Output:

False

The in keyword checks for the presence of a key in a dictionary.

6. Delete Bob

Python

del phonebook["Bob"]
  • del removes the key-value pair for "Bob".
  • After this line, Bob is no longer in the phonebook.

Now:

phonebook = {
    "Alice": "555-0100"
}

del dictionary[key] deletes the entire key-value pair.

7. Try to get Bob’s number

Python

print(phonebook.get("Bob"))
  • "Bob" was deleted in the previous step.
  • phonebook.get("Bob") does not find the key.
  • Because .get() is used, it returns None instead of causing an error.

Output:

None

Final output

555-0100
False
None

Important note

If you used square brackets instead of .get():

Python

print(phonebook["Bob"])

Python would raise a KeyError because "Bob" no longer exists. Using .get() avoids that error and returns None by default.

Method B: Using the collections module

collections.defaultdict removes the "check if key exists before inserting" boilerplate handy for the frequency-counting and grouping patterns you'll see in Section 5:

Python

                            
from collections import defaultdict

word_counts = defaultdict(int)
for word in ["apple", "banana", "apple", "cherry", "apple"]:
    word_counts[word] += 1

print(dict(word_counts))
                            

Step-by-Step Explanation

What this Program Does

This program counts how many times each word appears in a list using defaultdict.

Python

from collections import defaultdict

word_counts = defaultdict(int)
for word in ["apple", "banana", "apple", "cherry", "apple"]:
    word_counts[word] += 1

print(dict(word_counts))

Output:

{'apple': 3, 'banana': 1, 'cherry': 1}

1. Import defaultdict

Python

from collections import defaultdict

defaultdict is a special type of dictionary. When you access a key that does not exist, it automatically creates a default value instead of raising a KeyError.

2. Create an empty counting dictionary

Python

word_counts = defaultdict(int)

Here, int is the default factory. When a missing key is accessed, defaultdict(int) creates the default value:

Python

int()

which is:

0

So every new word starts with a count of 0.

3. Loop through the words

Python

for word in ["apple", "banana", "apple", "cherry", "apple"]:

The loop visits each word one by one:

  • apple
  • banana
  • apple
  • cherry
  • apple

4. Increase the count

Python

word_counts[word] += 1

This line does two things:

  • If the word is not in word_counts, create it with value 0
  • Add 1 to its count

For example:

Python

word_counts["apple"] += 1

first becomes:

Python

word_counts["apple"] = 0

then becomes:

Python

word_counts["apple"] = 1

How the Dictionary Changes

Word Action word_counts after update
apple New key created, then increased to 1 {'apple': 1}
banana New key created, then increased to 1 {'apple': 1, 'banana': 1}
apple Existing count increased {'apple': 2, 'banana': 1}
cherry New key created, then increased to 1 {'apple': 2, 'banana': 1, 'cherry': 1}
apple Existing count increased {'apple': 3, 'banana': 1, 'cherry': 1}

Convert to a Normal Dictionary

Python

print(dict(word_counts))

dict(word_counts) converts the defaultdict into a regular dictionary before printing.

Final result:

{'apple': 3, 'banana': 1, 'cherry': 1}

Why Use defaultdict?

Without defaultdict, you would need to check whether the word already exists:

Python

word_counts = {}

for word in ["apple", "banana", "apple", "cherry", "apple"]:
    if word not in word_counts:
        word_counts[word] = 0

    word_counts[word] += 1

With defaultdict, this check is handled automatically:

Python

word_counts[word] += 1

This makes frequency counting shorter and easier to read.

Method 3: From scratch - the full custom class

This is where the class-based version lives, with every operation from Section 2/3 available as a method, using chaining with Singly Linked Lists as buckets:

Python

                            
class HashTable:
    """A from-scratch Hash Table using chaining (array of Singly Linked Lists)."""

    class _Node:
        def __init__(self, key, value):
            self.key = key
            self.value = value
            self.next = None

    def __init__(self, initial_buckets=8):
        self.buckets = [None] * initial_buckets
        self.size = 0  # number of key-value pairs currently stored

    def _hash(self, key):
        return hash(key) % len(self.buckets)

    def put(self, key, value):
        """Insert a new key, or update the value if the key already exists."""
        index = self._hash(key)
        current = self.buckets[index]

        while current is not None:
            if current.key == key:
                current.value = value
                return
            current = current.next

        new_node = self._Node(key, value)
        new_node.next = self.buckets[index]
        self.buckets[index] = new_node
        self.size += 1

        # Resize when the table gets too full (average chain length > 0.75)
        if self.size / len(self.buckets) > 0.75:
            self._resize()

    def get(self, key):
        index = self._hash(key)
        current = self.buckets[index]
        while current is not None:
            if current.key == key:
                return current.value
            current = current.next
        return None

    def contains(self, key):
        return self.get(key) is not None

    def delete(self, key):
        index = self._hash(key)
        current = self.buckets[index]
        previous = None

        while current is not None:
            if current.key == key:
                if previous is None:
                    self.buckets[index] = current.next
                else:
                    previous.next = current.next
                self.size -= 1
                return True
            previous = current
            current = current.next

        return False

    def _resize(self):
        """Double the bucket count and reinsert every key — every key's bucket
        index can change because the modulo divisor (len(self.buckets)) changed."""
        old_buckets = self.buckets
        self.buckets = [None] * (len(old_buckets) * 2)
        self.size = 0

        for head in old_buckets:
            current = head
            while current is not None:
                self.put(current.key, current.value)  # rehash into the new table
                current = current.next

    def __len__(self):
        return self.size

    def keys(self):
        """Helper for iteration/testing — collects all keys."""
        result = []
        for head in self.buckets:
            current = head
            while current is not None:
                result.append(current.key)
                current = current.next
        return result

                            

Step-by-Step Explanation

What This Program Does

This program creates a custom hash table from scratch using separate chaining. Each bucket stores a singly linked list of key-value pairs, and the table automatically grows when it becomes too full.

Python

class HashTable:
    """A from-scratch Hash Table using chaining (array of Singly Linked Lists)."""

1. Node Class

Python

class _Node:
    def __init__(self, key, value):
        self.key = key
        self.value = value
        self.next = None

_Node represents one entry inside a bucket. It stores:

  • key: the unique identifier, such as "Alice"
  • value: the data linked to that key, such as 100
  • next: a reference to the next node in the same bucket

Example: Node("Alice", 100) is stored as:

key: "Alice"
value: 100
next: None

2. Create the Hash Table

Python

def __init__(self, initial_buckets=8):
    self.buckets = [None] * initial_buckets
    self.size = 0

When a new HashTable is created, it starts with 8 empty buckets:

Python

table = HashTable()

Internally:

buckets: [None, None, None, None, None, None, None, None]
size: 0
  • self.buckets holds the bucket array
  • self.size counts how many key-value pairs are stored

3. Convert a Key to a Bucket Index

Python

def _hash(self, key):
    return hash(key) % len(self.buckets)

This method decides which bucket a key belongs to. For example, hash("Alice") % 8 might return 3, so "Alice" is stored in buckets[3]. The % operator keeps the index within the valid bucket range.

4. Insert or Update a Key

Python

def put(self, key, value):

put() adds a new key-value pair or updates the value if the key already exists.

Find the bucket

Python

index = self._hash(key)
current = self.buckets[index]

The key is converted into a bucket index, then current starts at the first node in that bucket.

Search the bucket chain

If the key already exists, its old value is replaced:

Python

table.put("Alice", 100)
table.put("Alice", 500)

After both calls:

Alice -> 500

No new node is added.

Insert a new node

Python

new_node = self._Node(key, value)
new_node.next = self.buckets[index]
self.buckets[index] = new_node
self.size += 1

If the key is not found, a new node is created and placed at the front of the bucket. For example, if bucket 3 already contains John -> 300 and you insert table.put("Alice", 100), the bucket becomes:

Alice -> 100 -> John -> 300

This is called prepending, and it takes constant time.

5. Resize When Needed

Python

if self.size / len(self.buckets) > 0.75:
    self._resize()

The load factor is:

load factor = number of stored items / number of buckets

If more than 75% of the bucket capacity is used, the table resizes.

Example: 6 items / 8 buckets = 0.75. At 7 items: 7 / 8 = 0.875, so _resize() runs.

6. Get a Value by Key

Python

def get(self, key):
    index = self._hash(key)
    current = self.buckets[index]

    while current is not None:
        if current.key == key:
            return current.value
        current = current.next
    return None

get() finds the value linked to a key. It first finds the correct bucket, then walks through the chain.

Example:

Python

table.put("Alice", 100)
print(table.get("Alice"))

Output:

100

If the key does not exist, it returns None.

7. Check Whether a Key Exists

Python

def contains(self, key):
    return self.get(key) is not None

This returns True if the key exists and False if it does not.

Example:

Python

table.put("Bob", 200)

print(table.contains("Bob"))    # True
print(table.contains("David"))  # False

8. Delete a Key

Python

def delete(self, key):

delete() removes a key-value pair from the hash table.

Find the bucket

Python

index = self._hash(key)
current = self.buckets[index]
previous = None
  • current points to the node being checked
  • previous remembers the node before current

Search for the key

When the key is found, the node is removed from the chain.

Remove the first node

Python

if previous is None:
    self.buckets[index] = current.next

If the matching node is the first node in the bucket, the bucket head moves to the next node.

Before:

Alice -> John

After deleting Alice:

John

Remove a middle or last node

Python

else:
    previous.next = current.next

If the node is not first, the previous node skips over it.

Before:

Alice -> John -> Kevin

After deleting John:

Alice -> Kevin

Update the size

Python

    self.size -= 1
    return True
previous = current
current = current.next

return False

If the key was deleted, True is returned. If the loop finishes without finding the key, return False is executed.

9. Resize the Hash Table

Python

def _resize(self):
    old_buckets = self.buckets
    self.buckets = [None] * (len(old_buckets) * 2)
    self.size = 0

    for head in old_buckets:
        current = head
        while current is not None:
            self.put(current.key, current.value)
            current = current.next

_resize() doubles the number of buckets and moves every existing entry into the new table.

  • Save old buckets: old_buckets = self.buckets
  • Create larger bucket array: self.buckets = [None] * (len(old_buckets) * 2). If the old table had 8 buckets, the new table has 16.
  • Reinsert every entry: Every key is hashed again because the number of buckets changed. A key stored at bucket 3 in an 8-bucket table may move to a different bucket in a 16-bucket table.

10. Return the Number of Items

Python

def __len__(self):
    return self.size

This lets you use Python’s built-in len() function:

Python

table.put("Alice", 100)
table.put("Bob", 200)

print(len(table))

Output:

2

11. Collect All Keys

Python

def keys(self):
    result = []
    for head in self.buckets:
        current = head
        while current is not None:
            result.append(current.key)
            current = current.next
    return result

keys() visits every bucket, walks through each chain, and adds every key to a list.

Example:

Python

table.put("Alice", 100)
table.put("Bob", 200)

print(table.keys())

Possible output:

['Bob', 'Alice']

The order can vary because keys are placed into buckets based on their hash values.

Example Usage

Python

table = HashTable()

table.put("Alice", 100)
table.put("Bob", 200)
table.put("John", 300)

print(table.get("Alice"))       # 100
print(table.contains("Bob"))    # True

table.put("Alice", 500)
print(table.get("Alice"))       # 500

table.delete("Bob")
print(table.contains("Bob"))    # False

print(len(table))               # 2
print(table.keys())             # ['John', 'Alice'] or another order

Time Complexity

Operation Average Case Worst Case
Insert / update O(1) O(n)
Search O(1) O(n)
Delete O(1) O(n)
Resize O(n) O(n)

The worst case happens when many keys collide and land in the same bucket, making one linked list very long.


5. Real-World Practical Problems

Problem 1: Word frequency counter (log analysis)

Use case: counting how often each error code appears in a server log, to spot the most common failure.

Python

                                
def count_occurrences(items):
    """Count how many times each item appears. O(n) total, O(1) average per lookup."""
    counts = {}
    for item in items:
        counts[item] = counts.get(item, 0) + 1  # get() with a default avoids a KeyError check
    return counts


log_codes = ["404", "500", "404", "200", "500", "500"]
print(count_occurrences(log_codes))  # {'404': 2, '500': 3, '200': 1}

                                

Step-by-Step Explanation

What This Program Does

This program counts how many times each item appears in a list using a dictionary as a hash table.

Output:

{'404': 2, '500': 3, '200': 1}

1. Define the Function

Python

def count_occurrences(items):

count_occurrences() accepts a list called items. In this example, the list contains HTTP status codes:

["404", "500", "404", "200", "500", "500"]

2. Create an Empty Dictionary

Python

counts = {}

The dictionary stores each unique item as a key and its occurrence count as the value. At the beginning:

counts = {}

3. Loop Through Each Item

Python

for item in items:

The loop visits each item one by one:

  • 404
  • 500
  • 404
  • 200
  • 500
  • 500

4. Get the Current Count

Python

counts.get(item, 0)

get() looks for item in the dictionary:

  • If the item exists, it returns its current count
  • If the item does not exist, it returns the default value 0

For example, counts.get("404", 0) returns 0 the first time because "404" has not been seen yet.

5. Increase the Count

Python

counts[item] = counts.get(item, 0) + 1

This line adds 1 to the item’s count.

If the item is new:

counts["404"] = 0 + 1
counts["404"] = 1

If the item already exists:

counts["404"] = 1 + 1
counts["404"] = 2

How the Dictionary Changes

Item Before Update Calculation After Update
"404" Not present 0 + 1 {'404': 1}
"500" Not present 0 + 1 {'404': 1, '500': 1}
"404" 1 1 + 1 {'404': 2, '500': 1}
"200" Not present 0 + 1 {'404': 2, '500': 1, '200': 1}
"500" 1 1 + 1 {'404': 2, '500': 2, '200': 1}
"500" 2 2 + 1 {'404': 2, '500': 3, '200': 1}

Final Result

Python

print(count_occurrences(log_codes))

prints:

{'404': 2, '500': 3, '200': 1}

This means:

  • Status code 404 appeared 2 times
  • Status code 500 appeared 3 times
  • Status code 200 appeared 1 time

Why Use get()?

Without get(), you would need to check whether the key exists first:

Python

if item not in counts:
    counts[item] = 0

counts[item] += 1

Using get() combines both steps into one line:

Python

counts[item] = counts.get(item, 0) + 1

Time Complexity

Operation Time Complexity
Visit each item once O(n)
Dictionary lookup O(1) average
Dictionary insert or update O(1) average
Total counting process O(n)

The program is efficient because it makes only one pass through the list and uses constant-time dictionary operations for each item.

Problem 2: Deduplicating a stream of events

Use case: an event pipeline receiving the same event twice (due to network retries) you need to process each event exactly once.

Python

                                
def filter_duplicates(event_ids):
    """Return only the first occurrence of each event ID, preserving order."""
    seen = set()  # a set is a hash table that only stores keys, no values
    unique_events = []
    for event_id in event_ids:
        if event_id not in seen:
            seen.add(event_id)
            unique_events.append(event_id)
    return unique_events
    
                                

Step-by-Step Explanation

What This Program Does

This program removes duplicate event IDs while keeping only the first occurrence of each one, in the original order.

Example Usage

Python

event_ids = ["E101", "E102", "E101", "E103", "E102", "E104"]

print(filter_duplicates(event_ids))

Output:

['E101', 'E102', 'E103', 'E104']

1. Define the Function

Python

def filter_duplicates(event_ids):

The function accepts a list of event IDs. Example input:

["E101", "E102", "E101", "E103", "E102", "E104"]

2. Create a Set for Seen IDs

Python

seen = set()

seen stores event IDs that have already appeared. A set is useful here because checking whether an item exists in it takes O(1) time on average.

At the start:

seen = set()

3. Create a List for Unique IDs

Python

unique_events = []

This list stores the final result: one copy of each event ID, in the order they first appeared. At the start:

unique_events = []

4. Check Each Event ID

Python

for event_id in event_ids:

The loop processes each event ID one at a time:

  • E101
  • E102
  • E101
  • E103
  • E102
  • E104

5. Check Whether the ID Was Seen Before

Python

if event_id not in seen:

This condition checks whether the current event ID is already stored in the seen set:

  • If it is not in seen, it is new
  • If it is already in seen, it is a duplicate

6. Store New IDs

Python

seen.add(event_id)
unique_events.append(event_id)

When a new event ID is found:

  • It is added to seen, so future duplicates can be detected
  • It is added to unique_events, so it appears in the final result

How the Program Works

Event ID Already in seen? Action seen After unique_events After
E101 No Add it {E101} ['E101']
E102 No Add it {E101, E102} ['E101', 'E102']
E101 Yes Skip it {E101, E102} ['E101', 'E102']
E103 No Add it {E101, E102, E103} ['E101', 'E102', 'E103']
E102 Yes Skip it {E101, E102, E103} ['E101', 'E102', 'E103']
E104 No Add it {E101, E102, E103, E104} ['E101', 'E102', 'E103', 'E104']

Final Result

Python

return unique_events

returns:

['E101', 'E102', 'E103', 'E104']

The duplicate IDs E101 and E102 appear only once because only their first occurrences were added.

Why Use a Set?

A set provides fast membership checking:

Python

event_id not in seen

If you used a list instead:

Python

if event_id not in unique_events:

Python would search the entire list for every new event ID, making the operation slower for large inputs.

Time Complexity

Operation Time Complexity
Check membership in a set O(1) average
Add an item to a set O(1) average
Append an item to a list O(1) average
Process all event IDs O(n)

The function makes one pass through the input list, so its overall average time complexity is O(n).

Problem 3: Undo history with instant lookup - combining Hash Table with Stack

Use case: a text editor's undo stack, where you also want to instantly check "has this exact document state been seen before?" without scanning the whole undo history.

Python

                                        
class UndoHistory:
    """A Stack for undo order, paired with a Hash Table (set) for instant duplicate checks."""

    def __init__(self):
        self.stack = []          # same Stack behavior from the Stack tutorial
        self.seen_states = set()  # O(1) average "have we seen this state?" check

    def push_state(self, state):
        if state in self.seen_states:
            return False  # don't push a state we've already recorded
        self.stack.append(state)
        self.seen_states.add(state)
        return True

    def undo(self):
        if not self.stack:
            return None
        state = self.stack.pop()
        self.seen_states.discard(state)
        return state
    
                                

Step-by-Step Explanation

What This Program Does

This program combines a stack and a set to create an undo history that prevents the same state from being saved twice.

How It Works

The class uses two data structures:

Data Structure Variable Purpose
Stack self.stack Stores states in undo order
Set self.seen_states Quickly checks whether a state already exists

The stack follows the LIFO rule: Last In, First Out. The most recently saved state is removed first during undo.

1. Initialize the Undo History

Python

def __init__(self):
    self.stack = []
    self.seen_states = set()

When a new UndoHistory object is created:

Python

history = UndoHistory()

both structures start empty:

self.stack = []
self.seen_states = set()

2. Save a New State

Python

def push_state(self, state):

push_state() saves a document state, editor position, or any other value onto the stack.

Check for duplicates

Python

if state in self.seen_states:
    return False

If the state already exists in seen_states, the method returns False and does not save it again.

Example:

Python

history.push_state("Draft 1")
history.push_state("Draft 1")

The second call returns:

False

Add a new state

Python

self.stack.append(state)
self.seen_states.add(state)
return True

If the state is new:

  • append() adds it to the end of the stack
  • add() records it in the set
  • The method returns True

Example:

Python

history.push_state("Draft 1")
history.push_state("Draft 2")

Internally:

stack = ["Draft 1", "Draft 2"]
seen_states = {"Draft 1", "Draft 2"}

3. Undo the Last State

Python

def undo(self):

undo() removes and returns the most recently saved state.

Check whether the stack is empty

Python

if not self.stack:
    return None

If there is nothing to undo, the method returns None.

Python

history.undo()

returns:

None

Remove the latest state

Python

state = self.stack.pop()

pop() removes the last item from the stack. If the stack is ["Draft 1", "Draft 2"], then self.stack.pop() removes "Draft 2".

Remove it from the seen set

Python

self.seen_states.discard(state)

discard() removes the state from seen_states. This is important because the state may be saved again later.

Return the removed state

Python

return state

The removed state is returned so the program can restore it.

Example Walkthrough

Python

history = UndoHistory()

print(history.push_state("Draft 1"))  # True
print(history.push_state("Draft 2"))  # True
print(history.push_state("Draft 1"))  # False

print(history.undo())                 # Draft 2
print(history.undo())                 # Draft 1
print(history.undo())                 # None

Step-by-Step Result

Action Stack Seen States Return Value
push_state("Draft 1") ["Draft 1"] {"Draft 1"} True
push_state("Draft 2") ["Draft 1", "Draft 2"] {"Draft 1", "Draft 2"} True
push_state("Draft 1") ["Draft 1", "Draft 2"] {"Draft 1", "Draft 2"} False
undo() ["Draft 1"] {"Draft 1"} "Draft 2"
undo() [] set() "Draft 1"
undo() [] set() None

Why Use a Set?

Checking a set is fast:

Python

state in self.seen_states

This takes O(1) time on average. Without the set, the program would need to search the entire stack:

Python

if state in self.stack:

That would take O(n) time in the worst case.

Time Complexity

Operation Time Complexity
Check for duplicate state O(1) average
Push a new state O(1)
Undo the latest state O(1)
Remove from set O(1) average

The combination of a stack and set makes both saving and undoing states fast, while also preventing duplicate entries.

Problem 4: LRU Cache - combining Hash Table with Doubly Linked List

Use case: caching database query results, evicting the least recently used entry once the cache is full, the canonical real-world pairing of everything you've learned so far. The hash table gives O(1) lookup by key; the doubly linked list gives O(1) reordering to track recency.

Python

                                        
class DoublyLinkedNode:
    def __init__(self, key, value):
        self.key = key
        self.value = value
        self.prev = None
        self.next = None


class LRUCache:
    def __init__(self, capacity):
        self.capacity = capacity
        self.map = {}  # key -> DoublyLinkedNode, O(1) lookup

        # Dummy head/tail nodes simplify edge cases at the ends of the list
        self.head = DoublyLinkedNode(None, None)
        self.tail = DoublyLinkedNode(None, None)
        self.head.next = self.tail
        self.tail.prev = self.head

    def _remove(self, node):
        node.prev.next = node.next
        node.next.prev = node.prev

    def _add_to_front(self, node):
        node.next = self.head.next
        node.prev = self.head
        self.head.next.prev = node
        self.head.next = node

    def get(self, key):
        if key not in self.map:
            return -1
        node = self.map[key]
        self._remove(node)
        self._add_to_front(node)  # mark as most recently used
        return node.value

    def put(self, key, value):
        if key in self.map:
            self._remove(self.map[key])

        node = DoublyLinkedNode(key, value)
        self._add_to_front(node)
        self.map[key] = node

        if len(self.map) > self.capacity:
            lru_node = self.tail.prev  # least recently used is right before the dummy tail
            self._remove(lru_node)
            del self.map[lru_node.key]
    
                                

Step-by-Step Explanation

What This Program Does

This program implements an LRU (Least Recently Used) Cache using two structures together: a Python dictionary for fast key lookup and a doubly linked list for tracking usage order. The node nearest the dummy head is the most recently used, while the node nearest the dummy tail is the least recently used.

1. DoublyLinkedNode explanation

Python

class DoublyLinkedNode:
    def __init__(self, key, value):
        self.key = key
        self.value = value
        self.prev = None
        self.next = None

Each cache entry is stored in a node:

  • key identifies the cache entry.
  • value stores the data associated with that key.
  • prev points to the node before it in the linked list.
  • next points to the node after it in the linked list.

The node stores the key as well as the value because, when a node is evicted, the program needs its key to delete the matching entry from self.map.

2. LRUCache.__init__() explanation

Python

def __init__(self, capacity):
    self.capacity = capacity
    self.map = {}

    self.head = DoublyLinkedNode(None, None)
    self.tail = DoublyLinkedNode(None, None)
    self.head.next = self.tail
    self.tail.prev = self.head
  • capacity is the maximum number of entries the cache can hold.
  • self.map maps each key directly to its linked-list node: key -> DoublyLinkedNode.
  • self.head and self.tail are dummy, or sentinel, nodes. They do not store real cache data.

Initially, the list is empty, so head.next = tail and tail.prev = head.

The structure is:

head ⇄ tail

After adding entries:

head ⇄ most recent ⇄ ... ⇄ least recent ⇄ tail

Dummy head and tail nodes remove special cases for empty lists, first-node removal, and last-node removal.

3. _remove() explanation

Python

def _remove(self, node):
    node.prev.next = node.next
    node.next.prev = node.prev

This removes a node from anywhere in the doubly linked list.

  • node.prev.next = node.next makes the previous node skip over the removed node.
  • node.next.prev = node.prev makes the next node point backward to the previous node.

The removed node is no longer connected to the list.

Example:

Before:
A ⇄ B ⇄ C

Remove B:
A.next = C
C.prev = A

After:
A ⇄ C

Because the node stores both prev and next, removal takes constant time once the node is known.

4. _add_to_front() explanation

Python

def _add_to_front(self, node):
    node.next = self.head.next
    node.prev = self.head
    self.head.next.prev = node
    self.head.next = node

This inserts a node immediately after the dummy head, making it the most recently used entry.

  • node.next = self.head.next points the new node to the current first real node.
  • node.prev = self.head points the new node backward to the dummy head.
  • self.head.next.prev = node makes the old first real node point backward to the new node.
  • self.head.next = node makes the new node the first real node.

Example:

Before:
head ⇄ A ⇄ tail

Add B to front:
head ⇄ B ⇄ A ⇄ tail

The most recently used item is always just after head.

5. get() explanation

Python

def get(self, key):
    if key not in self.map:
        return -1
    node = self.map[key]
    self._remove(node)
    self._add_to_front(node)
    return node.value
  • If the key is not in self.map, the cache does not contain it, so the function returns -1.
  • If the key exists, self.map[key] gives the node directly.
  • _remove(node) removes the node from its current position.
  • _add_to_front(node) places it at the front.
  • Moving it to the front marks it as the most recently used item.
  • The function returns the node’s value.

For example, if the cache contains:

head ⇄ A ⇄ B ⇄ C ⇄ tail

and you call cache.get("B"), the list becomes:

head ⇄ B ⇄ A ⇄ C ⇄ tail

A get() operation both returns the value and updates the usage order.

6. put() explanation

Python

def put(self, key, value):
    if key in self.map:
        self._remove(self.map[key])

If the key already exists, its old node is removed from the linked list. This prevents duplicate nodes for the same key.

Python

    node = DoublyLinkedNode(key, value)
    self._add_to_front(node)
    self.map[key] = node
  • A new node is created with the key and value.
  • The node is inserted at the front as the most recently used entry.
  • The dictionary is updated so the key points to the new node.

Python

    if len(self.map) > self.capacity:
        lru_node = self.tail.prev
        self._remove(lru_node)
        del self.map[lru_node.key]
  • If the cache now has more entries than its capacity, eviction is needed.
  • self.tail.prev is the least recently used real node.
  • _remove(lru_node) removes that node from the linked list.
  • del self.map[lru_node.key] removes its key from the dictionary.

For example, with capacity 3:

Before:
head ⇄ A ⇄ B ⇄ C ⇄ tail

put("D", 4):
head ⇄ D ⇄ A ⇄ B ⇄ C ⇄ tail

Capacity exceeded:
Remove C

After:
head ⇄ D ⇄ A ⇄ B ⇄ tail

The least recently used entry is always immediately before the dummy tail.

7. Example usage

Python

cache = LRUCache(2)

cache.put("a", 1)
cache.put("b", 2)

cache.get("a")       # a becomes most recently used
cache.put("c", 3)    # cache is full, so "b" is removed

print(cache.get("a"))  # 1
print(cache.get("b"))  # -1
print(cache.get("c"))  # 3

Step-by-step:

  • put("a", 1) adds a at the front.
  • put("b", 2) adds b at the front.
  • get("a") moves a back to the front.
  • put("c", 3) adds c at the front.
  • The cache exceeds capacity 2, so the least recently used key, "b", is removed.
  • get("a") returns 1.
  • get("b") returns -1 because "b" was evicted.
  • get("c") returns 3.

Time complexity

Operation Time Reason
get(key) O(1) Dictionary lookup plus constant-time list removal and insertion
put(key, value) O(1) Dictionary lookup, constant-time list operations, and constant-time eviction
Space O(capacity) The cache stores at most capacity nodes

The dictionary answers “where is this key’s node?” while the doubly linked list answers “which item is oldest and should be removed?” Together, they make both get() and put() constant-time on average.


6. Common Patterns Table

Not sure which type of queue problem you're looking at? This section breaks down the most common queue patterns you'll run into - both in real coding interviews and in everyday programming. Each pattern comes with the keywords that usually give it away in a problem statement, plus example problems so you can see it in action.

Pattern Signal / Keywords Notes
Complement lookup "two sum," "pair that adds up to," "find a matching pair" Store seen values in a hash table; check for the complement in the same pass
Frequency counting "count occurrences," "most common," "majority element" dict.get(key, 0) + 1, or collections.Counter directly
Grouping by computed key "group anagrams," "bucket by property" Use a defaultdict(list); the key is derived (sorted string, tuple of properties, etc.), not the raw value
Sliding window + hash set/map "longest substring without repeating," "at most k distinct characters" Hash table tracks what's currently in the window; grow/shrink the window based on lookups
Deduplication "remove duplicates," "have I seen this before," "unique items only" A set (a hash table storing only keys) gives O(1) average membership checks
Hash Table + Doubly Linked List "O(1) get and put," "LRU," "eviction," "most/least recently used" Hash table for lookup, linked list for O(1) reordering — see Section 5.4
Hash Table + Stack "undo with duplicate detection," "have I visited this state" Stack preserves order; hash table/set gives instant membership checks alongside it
Chaining vs. open addressing "design a hash map," "handle collisions" Chaining reuses a Singly Linked List per bucket; open addressing probes for the next free slot

7. Practice Roadmap

Easy

These problems reinforce basic hash table usage, frequency counting, and set-based membership checks.

  • Two Sum — LeetCode 1
    Store values and their indices in a hash table, then look up each value’s required complement.
  • Contains Duplicate — LeetCode 217
    Use a set or hash table to detect whether a value has already appeared.
  • Valid Anagram — LeetCode 242
    Count the frequency of characters in both strings and compare the counts.

Medium

These problems combine hash tables with sorting, sliding windows, linked lists, stacks, and design patterns.

  • Group Anagrams — LeetCode 49
    Use a hash table to group words by a common anagram key.
  • Longest Substring Without Repeating Characters — LeetCode 3
    Combine a hash table with a sliding window to track the most recent position of each character.
  • Top K Frequent Elements — LeetCode 347
    Count element frequencies, then select the most frequent elements.
  • Design HashMap — LeetCode 706
    Implement a hash table from scratch, often using an array of linked lists for collision handling.
  • Design a Stack with Duplicate-Aware Push
    Combine a stack with a hash table to track duplicate values or support efficient duplicate checks.

Medium-Hard

These problems use hash tables for fast membership checks, prefix sums, and complement lookups.

  • Longest Consecutive Sequence — LeetCode 128
    Use a hash set to check whether each sequence start exists and extend sequences efficiently.
  • Subarray Sum Equals K — LeetCode 560
    Store running sums in a hash table and use complement lookups to count matching subarrays.

Hard

These problems combine hash tables with ordered structures, linked lists, heaps, and cache design.

  • LRU Cache — LeetCode 146
    Use a hash table for O(1) key lookup and a doubly linked list to maintain usage order.
  • LFU Cache — LeetCode 460
    Extend cache design by grouping keys according to frequency and maintaining ordered frequency buckets.
  • Design Twitter — LeetCode 355
    Use hash tables to map users to their tweets, followers, and feeds, often combined with heaps or ordered lists for efficient retrieval.

Suggested Practice Order

Start with the easy problems to build confidence with key lookup and frequency counting. Move to the medium problems once you are comfortable combining hash tables with other data structures. Finish with the medium-hard and hard problems to practice more advanced design patterns, prefix sums, and cache implementations.