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.

Simplified interface
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:

\[home(value) = hash(value) \mathbin{\%} N\]

The probe sequence then computes each candidate position from that home slot:

\[position_i = (home(value) + offset(value, i)) \mathbin{\%} N\]

Searching examines at most N positions:

  • If the position is OCCUPIED and 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 DELETED or 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.

Pseudocode
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.

Pseudocode
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.

Linear probing
offset(value, i) <- i
position_i <- (home(value) + i) % N

Quadratic probing uses increasing offsets:

Quadratic probing
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:

\[ \begin{align}\begin{aligned}offset(value, i) = i \mathbin{\times} h_2(value)\\position_i = (home(value) + i \mathbin{\times} h_2(value)) \mathbin{\%} N\end{aligned}\end{align} \]

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:

\[\lambda = \frac{N}{M}\]

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:

\[\frac{1}{1 - \lambda}\]

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:

../_images/closed_hashing-1.png

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.