Hash Tables: Data Structures and Collision Resolution

Master hash tables and collision resolution techniques like chaining and open addressing. Understand their performance and how they optimize data storage and retrieval for students. Learn more!

Podcast

Hash Tables: The Ultimate Guide0:00 / 22:52
0:001:00 remaining

Hash Tables are a fundamental data structure in computer science, offering an incredibly efficient way to store and retrieve data. Many applications, like compilers maintaining symbol tables, rely on dynamic sets that support dictionary operations: INSERT, SEARCH, and DELETE. While a worst-case search can take O(n) time, in practice, hash tables achieve an average search time of O(1) under reasonable assumptions, making them extremely powerful. The core idea is to compute an array index directly from a key, but this can lead to collisions where different keys map to the same index. This article will break down how hash tables work and how they handle these conflicts.

Understanding Direct Addressing: The Foundation of Hash Tables

Before diving into hash tables, it's helpful to understand a simpler, related concept: direct addressing. This technique works well when the universe of possible keys (U) is relatively small. Imagine you have keys from 0 to m-1. A direct-address table, denoted as T[0...m-1], uses an array where each position, or slot, directly corresponds to a key. Slot k either points to an element with key k or contains NIL if no such element exists.

Operations like DIRECT-ADDRESS-SEARCH(T, k), DIRECT-ADDRESS-INSERT(T, x), and DIRECT-ADDRESS-DELETE(T, x) are incredibly fast, each taking only O(1) time. This efficiency stems from the ability to access any array position directly. Sometimes, the table itself can hold the elements, saving space by not needing external objects and pointers.

Limitations of Direct Addressing

The main drawback of direct addressing becomes apparent when the universe U of possible keys is very large. Allocating an array of size |U| might be impractical or impossible due to memory constraints. Furthermore, if the set of actual keys (K) being stored is tiny compared to U, most of the allocated space in T would be wasted. This is where hash tables provide a more memory-efficient solution.

How Hash Tables Work: Reducing Storage and Handling Collisions

Hash tables overcome the storage limitations of direct addressing by using an array (the hash table T) whose size m is typically much smaller than |U|. Instead of directly using the key k as an array index, a hash function h computes the slot from the key: h: U → {0, 1,..., m-1}. An element with key k hashes to slot h(k), and h(k) is called the hash value of key k.

This reduction in the range of array indices allows the hash table to be sized proportionally to the number of keys actually stored (Θ(|K|)). However, a significant challenge arises because |U| > m. This means there must be at least two different keys that map to the same slot, a situation called a collision. Effective collision resolution techniques are crucial for hash tables to perform well.

Collision Resolution by Chaining

Chaining is the simplest and most common technique for resolving collisions. When multiple elements hash to the same slot j, they are all placed into a linked list associated with that slot T[j]. Each slot T[j] contains a pointer to the head of its list. If no elements hash to j, the slot contains NIL.

Dictionary operations with chaining are straightforward:

  • CHAINED-HASH-INSERT(T, x): Inserts x at the head of the list T[h(x.key)]. This takes O(1) time (assuming x isn't already present). For faster deletion, lists should be doubly linked.
  • CHAINED-HASH-SEARCH(T, k): Searches for an element with key k within the list T[h(k)].
  • CHAINED-HASH-DELETE(T, x): Deletes x from its list T[h(x.key)]. With doubly linked lists, this is O(1).

Performance Analysis of Chaining

The performance of hashing with chaining is analyzed using the load factor α = n/m, where n is the number of elements and m is the number of slots. α represents the average number of elements per chain and can be less than, equal to, or greater than 1.

The worst-case performance is poor: if all n keys hash to the same slot, searching takes Θ(n) time, similar to a single linked list. However, hashing's strength lies in its average-case performance, which relies on a good hash function distributing keys well (the simple uniform hashing assumption).

  • Unsuccessful Search: Under simple uniform hashing, an unsuccessful search takes Θ(1 + α) average-case time. This includes computing the hash function and searching the expected length of the list, which is α.
  • Successful Search: A successful search also takes Θ(1 + α) average-case time. This is because the expected number of elements examined is 1 + (α/2) - (α/2n), which simplifies to Θ(1 + α).

If n = O(m) (meaning α = O(1)), then searching takes constant time on average. Combined with O(1) insertion and deletion, all dictionary operations can be supported in O(1) average time.

Designing Effective Hash Functions

A good hash function is crucial for distributing keys evenly across the hash table, closely approximating simple uniform hashing. This minimizes collisions and improves performance. Hash functions typically interpret keys as natural numbers. For instance, a character string can be converted to an integer using radix notation (e.g., "pt" as 112 * 128 + 116 = 14452 in ASCII radix-128).

The Division Method

The division method is straightforward: h(k) = k mod m. It computes the hash value as the remainder of k divided by m. It's very fast, requiring only a single division operation.

  • Choosing m: Avoid m being a power of 2 (e.g., m = 2^p), as h(k) would only use the p lowest-order bits of k. A prime number not close to an exact power of 2 is often a good choice. For example, for 2000 strings, a prime m = 701 near 2000/3 is suitable.

The Multiplication Method

The multiplication method involves two steps: h(k) = floor(m * (kA mod 1)). Here, kA mod 1 refers to the fractional part of kA.

  • Choosing m: The value of m is less critical here; it's often chosen as a power of 2 (m = 2^p) for efficient implementation. Knuth suggests A ≈ (sqrt(5) - 1)/2 ≈ 0.618 for A.

Universal Hashing: Guaranteeing Average-Case Performance

To prevent a malicious adversary from choosing keys that always result in worst-case collision behavior for a fixed hash function, universal hashing is employed. Here, the hash function h is chosen randomly from a carefully designed family of functions H at the beginning of execution.

A family H is universal if, for any pair of distinct keys k, l ∈ U, the number of hash functions h ∈ H for which h(k) = h(l) is at most |H|/m. This means the probability of a collision between k and l is no more than 1/m.

One common universal family H_pm is defined as h_ab(k) = ((ak + b) mod p) mod m, where p is a prime greater than any key, a ∈ Z_p* (1 to p-1), and b ∈ Z_p (0 to p-1). This guarantees average-case O(1) performance for dictionary operations using chaining, regardless of the input key distribution.

Flashcards

1 / 9

What is the form of the probe function used in linear probing (given an auxiliary hash h0) and how is the probe sequence generated for a key k?

h(k,i) = (h0(k) + i) mod m for i = 0,1,...,m-1. Start at T[h0(k)], then T[h0(k)+1], ... up to T[m-1], wrap to T[0], continuing until T[h0(k)-1]. The i

Tap to flip · Swipe to navigate

Open Addressing: Storing Elements Directly in the Table

Open addressing is an alternative collision resolution technique where all elements are stored directly within the hash table itself, without using linked lists. Each table entry contains either an element or NIL (empty). This implies that the load factor α can never exceed 1 (n ≤ m). The advantage is saving memory by avoiding pointers.

To insert a key k, we systematically probe table slots until an empty one is found. The probe sequence ⟨h(k, 0), h(k, 1),..., h(k, m-1)⟩ is computed by extending the hash function to include a probe number i. This sequence must be a permutation of ⟨0, 1,..., m-1⟩, ensuring every slot is eventually considered.

  • HASH-INSERT(T, k): Probes slots j = h(k, i) for i = 0, 1,... until T[j] is NIL, then inserts k and returns j. If i reaches m, the table is full.
  • HASH-SEARCH(T, k): Follows the same probe sequence as HASH-INSERT. It returns j if T[j] == k, or NIL if an empty slot is found (meaning k isn't in the table) or m probes are exhausted.

Deletion Challenges in Open Addressing

Deleting from an open-address hash table is complex. Simply marking a slot as NIL can break subsequent search paths for keys that probed through that slot. To fix this, a special DELETED value is used instead of NIL. HASH-INSERT treats DELETED slots as empty, while HASH-SEARCH passes over them. However, using DELETED values can degrade performance over time, as search times no longer depend solely on α. Because of this, chaining is often preferred when deletions are frequent.

Open Addressing Probe Techniques

Practical approximations to uniform hashing (where each key's probe sequence is equally likely to be any permutation of ⟨0,..., m-1⟩) are used:

  1. Linear Probing: Uses h(k, i) = (h_0(k) + i) mod m, where h_0 is an auxiliary hash function. This probes T[h_0(k)], then T[h_0(k)+1], and so on. It's easy to implement but suffers from primary clustering, where long runs of occupied slots form, increasing average search time.
  2. Quadratic Probing: Uses h(k, i) = (h_0(k) + c_1*i + c_2*i^2) mod m. This method reduces primary clustering but can lead to secondary clustering, where keys with the same initial hash value (h_0(k)) follow identical probe sequences. The constants c1, c2, and m must be chosen carefully for full table utilization.
  3. Double Hashing: Offers one of the best methods: h(k, i) = (h_1(k) + i*h_2(k)) mod m. Both h_1 and h_2 are auxiliary hash functions. The probe sequence depends on two aspects of the key, resulting in Θ(m^2) distinct probe sequences (compared to Θ(m) for linear/quadratic). For the entire table to be searched, h_2(k) must be relatively prime to m. This can be achieved by making m prime and h_2(k) return a positive integer less than m (e.g., h_2(k) = 1 + (k mod (m-1))). Double hashing closely approximates uniform hashing performance.

Performance Analysis of Open Addressing

Under the assumption of uniform hashing:

  • Unsuccessful Search: The expected number of probes is at most 1/(1 - α). For α = 0.5, it's ≤ 2 probes; for α = 0.9, it's ≤ 10 probes.
  • Insertion: Since insertion is an unsuccessful search followed by placing the key, it also takes at most 1/(1 - α) probes on average.
  • Successful Search: The expected number of probes is at most (1/α) * ln(1 / (1 - α)). For α = 0.5, it's < 1.387 probes; for α = 0.9, it's < 2.559 probes.

Perfect Hashing: Worst-Case O(1) Search for Static Sets

For static sets of keys (where keys never change once stored, like reserved words in a programming language), perfect hashing guarantees O(1) memory accesses in the worst case for searches. This is achieved using a two-level hashing scheme with universal hashing at each level.

  1. First Level: n keys are hashed into m slots (typically m = n) using a hash function h chosen from a universal family H_pm.
  2. Secondary Level: Instead of a linked list, each slot j gets its own smaller, secondary hash table S_j. If n_j keys hash to slot j, S_j has size m_j = n_j^2 and uses its own hash function h_j chosen from a universal family H_p,m_j.

The quadratic sizing m_j = n_j^2 guarantees no collisions within S_j, ensuring O(1) lookup. Despite this, by choosing the first-level hash function appropriately, the expected total storage for all secondary tables is O(n). This means that with a few random trials, a suitable first-level hash function can be found that minimizes overall storage and ensures perfect hashing.

Frequently Asked Questions about Hash Tables

What is the main purpose of a hash table?

A hash table's main purpose is to implement dynamic sets or dictionaries, providing highly efficient (average O(1) time) operations for inserting, searching, and deleting elements based on their keys. It's ideal for scenarios where quick lookup is essential.

Why are collisions unavoidable in hash tables?

Collisions are unavoidable because the universe of possible keys (U) is typically much larger than the number of available slots (m) in the hash table. By the Pigeonhole Principle, if you have more items than containers, at least one container must hold more than one item. Similarly, more keys than slots means some keys must map to the same slot.

What's the difference between chaining and open addressing?

Chaining resolves collisions by storing all elements that hash to the same slot in a linked list attached to that slot. Open addressing, conversely, stores all elements directly within the hash table itself. When a collision occurs in open addressing, the system probes for the next available empty slot based on a predefined sequence.

How does a

Sign up to access full content

Create a free account to unlock all study materials, take interactive tests, listen to podcasts and more.

Create free account

Related topics