Command Palette

Search for a command to run...

MTH 4300

Lecture 15

What Is a Hash Table?

A hash table stores key-value pairs and supports insert, search, and delete in O(1) amortized time. The central idea: apply a hash function to the key to compute a bucket index, then store the pair in that bucket.

The idea
bucket_index = hash(key) % capacity;

When two keys map to the same bucket — a collision — the table needs a strategy to handle it. This lecture covers two: separate chaining and open addressing.

Hash Functions

A hash function maps a key to a non-negative integer. For integer keys, the simplest hash is modulo division:

Integer hash
int hash(int key, int capacity) {
    return key % capacity;
}

Good hash functions distribute keys uniformly across buckets. Clustering keys into a few buckets turns O(1) lookups into O(n) list traversals.

Choose a prime number for the capacity. Prime capacities reduce clustering when keys share common factors with the capacity.

In C++, the % operator on negative integers produces a negative result. Guard against this: ((key % capacity) + capacity) % capacity.

Chaining

Separate chaining resolves collisions by keeping a linked list at each bucket. Every key that hashes to the same bucket is appended to that list.

Node and HashTable — chaining
struct Node {
    int   key;
    int   value;
    Node* next;
    Node(int k, int v) : key(k), value(v), next(nullptr) {}
};

class HashTable {
public:
    static const int CAPACITY = 11;

    HashTable() {
        for (int i = 0; i < CAPACITY; i++) buckets_[i] = nullptr;
    }

    ~HashTable() {
        for (int i = 0; i < CAPACITY; i++) {
            Node* curr = buckets_[i];
            while (curr) {
                Node* next = curr->next;
                delete curr;
                curr = next;
            }
        }
    }

    void insert(int key, int value) {
        int   idx  = key % CAPACITY;
        Node* curr = buckets_[idx];
        while (curr) {
            if (curr->key == key) { curr->value = value; return; } // update
            curr = curr->next;
        }
        Node* node    = new Node(key, value);
        node->next    = buckets_[idx];
        buckets_[idx] = node; // prepend
    }

    int* find(int key) const {
        int   idx  = key % CAPACITY;
        Node* curr = buckets_[idx];
        while (curr) {
            if (curr->key == key) return &curr->value;
            curr = curr->next;
        }
        return nullptr;
    }

    void remove(int key) {
        int   idx  = key % CAPACITY;
        Node* curr = buckets_[idx];
        Node* prev = nullptr;
        while (curr) {
            if (curr->key == key) {
                if (prev) prev->next = curr->next;
                else       buckets_[idx] = curr->next;
                delete curr;
                return;
            }
            prev = curr;
            curr = curr->next;
        }
    }

private:
    Node* buckets_[CAPACITY];
};

insert walks the chain to check for an existing key. If found, it updates the value in place; if not, it prepends a new Node in O(1). find and remove likewise walk only the chain for that bucket — not the entire table.

Load Factor

The load factor α = n / m, where n is the number of stored keys and m is the number of buckets. At low load (α ≪ 1) chains are short and lookups are fast. As α grows, chains lengthen and performance approaches O(n).

A common rule: when α exceeds 0.75, resize to roughly double the capacity and rehash all existing keys. This keeps amortized O(1) performance.

Open Addressing

Open addressing stores all entries directly in the bucket array — no linked lists. When the home bucket is occupied, the table probes for the next available slot.

Linear probing tries successive indices:

Probe sequence
index = (hash(key) + i) % capacity;  // i = 0, 1, 2, ...
HashTableLP — linear probing
class HashTableLP {
public:
    static const int CAPACITY = 11;
    static const int EMPTY    = -1;
    static const int DELETED  = -2; // tombstone

    HashTableLP() {
        for (int i = 0; i < CAPACITY; i++) {
            keys_[i]   = EMPTY;
            values_[i] = 0;
        }
    }

    void insert(int key, int value) {
        int idx = key % CAPACITY;
        for (int i = 0; i < CAPACITY; i++) {
            int probe = (idx + i) % CAPACITY;
            if (keys_[probe] == EMPTY || keys_[probe] == DELETED) {
                keys_[probe]   = key;
                values_[probe] = value;
                return;
            }
            if (keys_[probe] == key) { values_[probe] = value; return; }
        }
    }

    int* find(int key) {
        int idx = key % CAPACITY;
        for (int i = 0; i < CAPACITY; i++) {
            int probe = (idx + i) % CAPACITY;
            if (keys_[probe] == EMPTY)   return nullptr; // probe chain broken
            if (keys_[probe] == DELETED) continue;       // skip tombstone
            if (keys_[probe] == key)     return &values_[probe];
        }
        return nullptr;
    }

    void remove(int key) {
        int idx = key % CAPACITY;
        for (int i = 0; i < CAPACITY; i++) {
            int probe = (idx + i) % CAPACITY;
            if (keys_[probe] == EMPTY) return;
            if (keys_[probe] == key) {
                keys_[probe] = DELETED; // tombstone — preserves probe chain
                return;
            }
        }
    }

private:
    int keys_[CAPACITY];
    int values_[CAPACITY];
};

The Deletion Problem

Deleting a slot by writing EMPTY breaks the probe chain: a later find stops at the gap and misses any keys inserted beyond it. Instead, mark deleted slots with a tombstone (DELETED). find skips tombstones; insert reuses them.

Never write EMPTY into a deleted slot in an open-addressing table. Future lookups will terminate early and report keys as missing even when they are present.

Chaining vs. Open Addressing

ChainingOpen Addressing
StorageNodes on heapFlat array — cache-friendly
High load (α > 1)Works — chains just grow longerFails — no room once array is full
DeletionSplice the nodeTombstone required
Memory per entryExtra pointer per nodeKeys and values stored in-place

Chaining handles high load gracefully. Open addressing is faster at low load due to cache locality.

Exercises

Q1.

Without running the code, predict what this program prints.

#include <iostream>

int main() {
    HashTable ht; // chaining, CAPACITY = 11

    ht.insert(5,  100);
    ht.insert(16, 200); // 16 % 11 = 5 — same bucket as key 5
    ht.insert(5,  300); // update existing key 5

    int* a = ht.find(5);
    int* b = ht.find(16);
    int* c = ht.find(27);

    std::cout << (a ? *a : -1) << "\n"; // line A
    std::cout << (b ? *b : -1) << "\n"; // line B
    std::cout << (c ? *c : -1) << "\n"; // line C

    return 0;
}

Q2.

Without running the code, trace the keys array after each operation and predict the output. The table uses linear probing with CAPACITY = 7.

#include <iostream>

int main() {
    HashTableLP ht; // CAPACITY = 7, EMPTY = -1, DELETED = -2

    ht.insert(0,  10); // 0  % 7 = 0 — slot 0
    ht.insert(7,  20); // 7  % 7 = 0 — occupied, probe to slot 1
    ht.insert(14, 30); // 14 % 7 = 0 — occupied, probe to slot 2
    ht.remove(7);       // slot 1 becomes DELETED

    int* a = ht.find(14);
    int* b = ht.find(7);

    std::cout << (a ? *a : -1) << "\n"; // line A
    std::cout << (b ? *b : -1) << "\n"; // line B

    return 0;
}

Q3.

Each snippet has a bug related to hashing. Identify what's wrong.

// Snippet A — deletion in open addressing
void remove(int key) {
    int idx = key % CAPACITY;
    for (int i = 0; i < CAPACITY; i++) {
        int probe = (idx + i) % CAPACITY;
        if (keys_[probe] == key) {
            keys_[probe] = EMPTY; // marks slot empty
            return;
        }
    }
}

// Snippet B — hash function with negative keys
int hash(int key, int capacity) {
    return key % capacity;
}

// Snippet C — find stops at DELETED
int* find(int key) {
    int idx = key % CAPACITY;
    for (int i = 0; i < CAPACITY; i++) {
        int probe = (idx + i) % CAPACITY;
        if (keys_[probe] == EMPTY || keys_[probe] == DELETED) return nullptr;
        if (keys_[probe] == key) return &values_[probe];
    }
    return nullptr;
}

// Snippet D — chaining table with CAPACITY = 1
static const int CAPACITY = 1;

Q4.

Implement insert and find for the chaining hash table below.

#include <iostream>

struct Node {
    int   key;
    int   value;
    Node* next;
    Node(int k, int v) : key(k), value(v), next(nullptr) {}
};

class HashTable {
public:
    static const int CAPACITY = 11;

    HashTable() {
        for (int i = 0; i < CAPACITY; i++) buckets_[i] = nullptr;
    }

    ~HashTable() {
        for (int i = 0; i < CAPACITY; i++) {
            Node* curr = buckets_[i];
            while (curr) {
                Node* next = curr->next;
                delete curr;
                curr = next;
            }
        }
    }

    void insert(int key, int value) {
        // TODO: update value if key exists; prepend new Node otherwise
    }

    int* find(int key) const {
        // TODO: return pointer to value, or nullptr if not found
    }

private:
    Node* buckets_[CAPACITY];
};

int main() {
    HashTable ht;
    ht.insert(3,  30);
    ht.insert(14, 140); // 14 % 11 = 3 — same bucket as key 3
    ht.insert(3,  99);  // update key 3

    std::cout << *ht.find(3)  << "\n"; // 99
    std::cout << *ht.find(14) << "\n"; // 140
    std::cout << (ht.find(99) ? "found" : "not found") << "\n"; // not found
    return 0;
}

Q5.

Implement insert and find for the linear-probing hash table below.

#include <iostream>

class HashTableLP {
public:
    static const int CAPACITY = 7;
    static const int EMPTY    = -1;
    static const int DELETED  = -2;

    HashTableLP() {
        for (int i = 0; i < CAPACITY; i++) {
            keys_[i]   = EMPTY;
            values_[i] = 0;
        }
    }

    void insert(int key, int value) {
        // TODO: probe from key % CAPACITY;
        //       update if key exists, insert at first EMPTY or DELETED slot
    }

    int* find(int key) {
        // TODO: probe from key % CAPACITY; skip DELETED; stop at EMPTY
    }

private:
    int keys_[CAPACITY];
    int values_[CAPACITY];
};

int main() {
    HashTableLP ht;
    ht.insert(0,  10);
    ht.insert(7,  20);  // 7  % 7 = 0 — probes to slot 1
    ht.insert(14, 30);  // 14 % 7 = 0 — probes to slot 2

    std::cout << *ht.find(0)  << "\n"; // 10
    std::cout << *ht.find(7)  << "\n"; // 20
    std::cout << *ht.find(14) << "\n"; // 30
    std::cout << (ht.find(21) ? "found" : "not found") << "\n"; // not found
    return 0;
}

Practice Problems