16.6. Open addressing (closed hashing)¶
In open addressing, also called closed hashing, the table stores entries directly in its array of slots rather than storing a collection at each bucket. When a key collides with an occupied home slot, the table searches for another slot according to a probe sequence.
Each slot contains a hash_entry with one data element and a status field.
The status distinguishes an OCCUPIED slot from an EMPTY slot that has
never been used and a DELETED slot that contains a tombstone.
Note
The complete example on this page is intentionally simplified. It omits rehashing, iterators, allocator support, and many other features of the standard unordered containers. It is meant to make probe sequences and tombstones visible.
The hash_entry type uses a live T data member even when its slot is
empty. As a result, this particular implementation requires T to
be default-constructible. Standard containers do not impose that requirement
on every key merely because a slot is empty.
hash_set
enum class hash_status { OCCUPIED, EMPTY, DELETED };
template <class T>
struct hash_entry {
T data;
hash_status status = hash_status::EMPTY;
};
The table stores an array of entries. The hash policy and equality predicate are separate template parameters. As with every unordered container, equivalent keys must compare equal and must produce the same hash value.
template <class Key,
std::size_t N,
class Hash = std::hash<Key>,
class KeyEqual = std::equal_to<Key>>
class hash_set {
std::pair<std::size_t, bool> insert(const Key& value);
std::size_t find(const Key& value) const;
bool contains(const Key& value) const;
std::size_t count(const Key& value) const;
std::size_t erase(const Key& value);
};
find
The table first computes the home slot:
The probe sequence then computes each candidate position from that home slot:
Searching examines at most N positions:
If the position is
OCCUPIEDand contains an equivalent key, the search succeeds.If the position is
EMPTY, the search fails. No later insertion can have placed the key beyond a slot that has never been used.If the position is
DELETEDor contains a different key, probing continues.
find Pseudocode¶home <- hash(value) % bucket_count
for probe from 0 through bucket_count - 1:
position <- (home + offset(value, probe)) % bucket_count
if table[position] is EMPTY:
return not found
if table[position] is OCCUPIED and equal(table[position], value):
return position
return not found
A tombstone cannot terminate a search. The requested key might have been inserted farther along the probe sequence before an earlier key was erased.
contains
Once find is available, membership operations are straightforward.
This interface returns a slot index, with N meaning that
the key was not found.
bool contains(const Key& value) const {
return find(value) != N;
}
std::size_t count(const Key& value) const {
return contains(value) ? 1 : 0;
}
A set enforces uniqueness, so count can only return 0 or 1.
erase
Erasing an entry marks its slot DELETED instead of changing it to
EMPTY. This tombstone preserves the probe path for keys stored later
in the sequence.
position <- find(value)
if position == not found:
return 0
table[position].status <- DELETED
decrease size
return 1
Tombstones can accumulate and make searches longer. A practical table can rebuild or rehash its storage when tombstones or the load factor become too numerous.
insert
Insertion must continue past a tombstone so it can detect a duplicate key later in the probe sequence. It remembers the first tombstone and uses it only after the search confirms that the key is not already in the table.
first_deleted <- not found
home <- hash(value) % bucket_count
for probe from 0 through bucket_count - 1:
position <- (home + offset(value, probe)) % bucket_count
if table[position] is OCCUPIED:
if equal(table[position], value):
return {position, false}
else if table[position] is DELETED:
if first_deleted == not found:
first_deleted <- position
else:
if first_deleted != not found:
position <- first_deleted
store value at position
mark position OCCUPIED
increase size
return {position, true}
if first_deleted != not found:
store value at first_deleted
mark first_deleted OCCUPIED
increase size
return {first_deleted, true}
return {not found, false}
The second parameter returns true when a new key is inserted,
false otherwise.
A duplicate does not replace the existing key.
Run it
The preprocessor symbol selects the probe strategy. Leave the default definition in place for linear probing, or define one of the other symbols before compiling to compare the strategies.
1#include <array>
2#include <cstddef>
3#include <functional>
4#include <iostream>
5#include <ostream>
6#include <utility>
7
8#if !defined(USE_LINEAR_PROBING) \
9 && !defined(USE_QUADRATIC_PROBING) \
10 && !defined(USE_DOUBLE_HASHING)
11#define USE_LINEAR_PROBING
12#endif
13
14enum class hash_status { OCCUPIED, EMPTY, DELETED };
15
16struct identity_hash {
17 std::size_t operator()(int value) const noexcept {
18 return static_cast<std::size_t>(value);
19 }
20};
21
22template <class T>
23struct hash_entry {
24 T data;
25 hash_status status = hash_status::EMPTY;
26};
27
28template <class Key,
29 std::size_t N,
30 class Hash = std::hash<Key>,
31 class KeyEqual = std::equal_to<Key>>
32class hash_set {
33 static_assert(N > 0, "hash_set needs at least one slot");
34
35public:
36 using size_type = std::size_t;
37 using insert_result = std::pair<size_type, bool>;
38
39 size_type find(const Key& value) const {
40 const size_type home = home_slot(value);
41 for (size_type probe = 0; probe < N; ++probe) {
42 const size_type position = probe_position(value, home, probe);
43 const auto& entry = table_[position];
44 if (entry.status == hash_status::EMPTY) {
45 return N;
46 }
47 if (entry.status == hash_status::OCCUPIED
48 && equal_(entry.data, value)) {
49 return position;
50 }
51 }
52 return N;
53 }
54
55 bool contains(const Key& value) const {
56 return find(value) != N;
57 }
58
59 size_type count(const Key& value) const {
60 return contains(value) ? 1 : 0;
61 }
62
63 size_type erase(const Key& value) {
64 const size_type position = find(value);
65 if (position == N) {
66 return 0;
67 }
68 table_[position].status = hash_status::DELETED;
69 --size_;
70 return 1;
71 }
72
73 insert_result insert(const Key& value) {
74 const size_type home = home_slot(value);
75 size_type first_deleted = N;
76
77 for (size_type probe = 0; probe < N; ++probe) {
78 const size_type position = probe_position(value, home, probe);
79 auto& entry = table_[position];
80
81 if (entry.status == hash_status::OCCUPIED) {
82 if (equal_(entry.data, value)) {
83 return {position, false};
84 }
85 } else if (entry.status == hash_status::DELETED) {
86 if (first_deleted == N) {
87 first_deleted = position;
88 }
89 } else {
90 const size_type target =
91 first_deleted == N ? position : first_deleted;
92 table_[target].data = value;
93 table_[target].status = hash_status::OCCUPIED;
94 ++size_;
95 return {target, true};
96 }
97 }
98
99 if (first_deleted != N) {
100 table_[first_deleted].data = value;
101 table_[first_deleted].status = hash_status::OCCUPIED;
102 ++size_;
103 return {first_deleted, true};
104 }
105 return {N, false};
106 }
107
108 size_type size() const noexcept {
109 return size_;
110 }
111
112 bool empty() const noexcept {
113 return size_ == 0;
114 }
115
116 friend std::ostream& operator<<(std::ostream& os,
117 const hash_set& set) {
118 os << '[';
119 for (size_type position = 0; position < N; ++position) {
120 const auto& entry = set.table_[position];
121 if (entry.status == hash_status::OCCUPIED) {
122 os << position << ':' << entry.data << ' ';
123 } else if (entry.status == hash_status::DELETED) {
124 os << position << ":D ";
125 } else {
126 os << position << ":E ";
127 }
128 }
129 return os << ']';
130 }
131
132private:
133 size_type home_slot(const Key& value) const {
134 return hasher_(value) % N;
135 }
136
137 size_type probe_position(const Key& value,
138 size_type home,
139 size_type probe) const {
140 return (home + probe_offset(value, probe)) % N;
141 }
142
143 size_type probe_offset(const Key& value,
144 size_type probe) const {
145#if !defined(USE_DOUBLE_HASHING)
146 (void)value;
147#endif
148#if defined(USE_QUADRATIC_PROBING)
149 return probe * probe;
150#elif defined(USE_DOUBLE_HASHING)
151 return probe * secondary_step(value);
152#else
153 return probe;
154#endif
155 }
156
157 size_type secondary_step(const Key& value) const {
158 if constexpr (N == 1) {
159 return 1;
160 } else {
161 return 1 + (hasher_(value) % (N - 1));
162 }
163 }
164
165 std::array<hash_entry<Key>, N> table_;
166 Hash hasher_;
167 KeyEqual equal_;
168 size_type size_ = 0;
169};
170
171template <class T>
172std::ostream& operator<<(std::ostream& os,
173 const hash_entry<T>& entry) {
174 if (entry.status == hash_status::OCCUPIED) {
175 return os << entry.data;
176 }
177 return os << (entry.status == hash_status::DELETED ? 'D' : 'E');
178}
179
180int main() {
181 hash_set<int, 11, identity_hash> values;
182
183 std::cout << "size: " << values.size() << '\n'
184 << std::boolalpha
185 << "empty: " << values.empty() << '\n';
186
187 const auto first = values.insert(72);
188 const auto duplicate = values.insert(72);
189 std::cout << "first insertion: " << first.second << '\n'
190 << "duplicate insertion: " << duplicate.second << '\n'
191 << "count(72): " << values.count(72) << '\n';
192
193 values.erase(72);
194 std::cout << "after erase, count(72): " << values.count(72)
195 << '\n';
196
197 values.insert(34);
198 values.insert(45);
199 values.insert(21);
200 std::cout << "values: " << values << '\n'
201 << "contains(45): " << values.contains(45) << '\n';
202}
The default linear-probing strategy checks the home slot, then consecutive slots, wrapping at the end of the array. It is simple, but entries tend to form contiguous clusters. This is called primary clustering because a cluster makes later probe sequences longer.
offset(value, i) <- i
position_i <- (home(value) + i) % N
Quadratic probing uses increasing offsets:
offset(value, i) <- i * i
position_i <- (home(value) + i * i) % N
Quadratic probing reduces primary clustering, but different keys with the same
home slot still follow the same sequence. That is called secondary clustering.
The simple i * i sequence is not guaranteed to visit every slot for every
table size. A common analysis uses a prime table size and keeps the load factor
below one half, but these conditions describe a particular probing scheme,
not a universal guarantee.
Double hashing uses a second hash function to choose the step size:
The step must be nonzero and relatively prime to N. When N is prime,
choosing h_2(value) in the range 1 through N - 1 guarantees this
property. For a non-prime table size, the secondary hash must be designed so
that the step and N are relatively prime; otherwise the sequence can skip
slots and fail even when an empty slot exists.
The three strategies have different clustering behavior and probe costs. The table must also keep enough empty capacity for a probe sequence to terminate, and tombstones effectively increase the amount of occupied search history.
16.6.1. Analysis of open addressing¶
Let \(N\) be the number of occupied entries and \(M\) be the number of slots in the table. The load factor is:
For open addressing, \(0 \leq \lambda \leq 1\), and insertion cannot succeed when every slot is occupied. Under an idealized uniform-probing model, the expected number of probes for an unsuccessful search or insertion is:
This formula does not describe every probe strategy exactly. Successful searches have a different expected cost, and linear probing can perform worse because of primary clustering. The formula is useful for showing why open addressing becomes increasingly sensitive to load factor.
The graph shows the expected number of extra probes beyond the first under the idealized model:
If the table is less than half full, then the idealized unsuccessful-search
estimate is less than two probes on average. As \(\lambda\) approaches
one, the estimate grows without bound, although a real table can examine no
more than M slots before declaring failure. In practice, clustering and
tombstones make the actual cost dependent on the selected strategy.
Keeping the table comfortably below full is therefore necessary, but there is no universal half-full rule. A practical implementation chooses a threshold based on its probe strategy and workload, then rehashes before the table or its tombstones make searches too long.