16.7. Analysis of hash tables

The table below shows the average case complexity of some basic std::unordered_map operations. These estimates assume a good hash function, a reasonable load factor, and fixed-size keys whose hash and equality operations take constant time. For lookup, insertion, and erasure, the worst-case complexity can be \(O(N)\) when many keys occupy the same bucket. clear is linear because it visits every stored element.

Average complexity of C++ unordered map operations

Operation

Complexity

operator[]

O(1)

insert()

O(1)

find()

O(1)

contains()

O(1)

erase()

O(1)

clear()

O(n)

C++20 Feature

C++20 added unordered_map::contains for membership checks when the mapped value is not needed. Its average complexity is constant under the same assumptions as find.

The reason these operations may have \(O(n)\) complexity is because the performance of the container is ultimately controlled by the quality of the hash function for the key type in the container. If the hash function performs poorly (many collisions), then the benefits of hash tables are lost and we decay into list performance. When the hash function quality is high, then the performance is good.

When we discussed the messy and neat closets in Tree ADT concepts, we mentioned that search is a primary motivation for non-sequential containers. A hash table can usually select the correct bucket in expected constant time, and then searches the bucket for an equal key. The hash computation and key comparison costs are part of the operation's total cost.

The following code shows what happens when searching in an unordered map vs a vector.

 1#include <algorithm>
 2#include <chrono>
 3#include <iomanip>
 4#include <iostream>
 5#include <numeric>
 6#include <unordered_map>
 7#include <vector>
 8
 9int main() {
10    using clock = std::chrono::steady_clock;
11    std::cout << std::setw(6) << "size"
12              << std::setw(10) << "vector"
13              << std::setw(20) << "hash table\n";
14    // for(int size = 10'000; size < 100'001; size += 20'000) {
15    int size = 35000;
16        // fill vector
17        std::vector<int> sequence (size);
18        std::iota(sequence.begin(), sequence.end(), 0);
19        // search vector
20        auto begin = clock::now();
21        for(const auto& it: sequence){
22            if(std::find(sequence.begin(), sequence.end(), it) == sequence.end()) {
23                std::cerr << "Failed to find an expected value in vector! Halting.\n";
24                return -2;
25            }
26        }
27        auto end = clock::now();
28        std::chrono::duration<double> elapsed_secs = end - begin;
29        // fill hash table
30        std::unordered_map<int, int> table;
31        for(int item = 0; item < size; ++item){
32            table[item] = item;
33        }
34        begin = clock::now();
35        // search hash table
36        for(const auto& it: table){
37            if(table.find(it.first) == table.end()) {
38                std::cerr << "Failed to find an expected value in map! Halting.\n";
39                return -2;
40            }
41        }
42        end = clock::now();
43        std::chrono::duration<double> elapsed_secs_ht = end - begin;
44
45        // Printing final output
46        std::cout << std::fixed   << std::setprecision(4)
47                  << std::setw(6) << size << '\t'
48                  << std::setw(8) << elapsed_secs.count() << '\t'
49                  << std::setw(8) << elapsed_secs_ht.count() << '\n';
50    // }
51    return 0;
52}

Try This!

The online compiler is limited in both memory and time allowed.

Run this example on your own computer with the loop enabled and with larger values and compare.

The vector is linear in std::distance(begin, end) and as expected, the hash table is constant time. Running the previous code should produce results similar to this:

Comparison of vector and hash table search times

So what about the tree ADT? The standard does not require a particular implementation for std::set, but it does guarantee logarithmic complexity in the size of the container for search. This is a guaranteed bound, unlike the average constant-time bound for an unordered container.

How does std::set find compare to std::unordered_map find?

 1#include <algorithm>
 2#include <chrono>
 3#include <iomanip>
 4#include <iostream>
 5#include <numeric>
 6#include <set>
 7#include <unordered_map>
 8
 9int main() {
10    using clock = std::chrono::steady_clock;
11    std::cout << std::setw(6) << "size"
12              << std::setw(10) << "set"
13              << std::setw(20) << "hash table\n";
14    for(int size = 5'000; size < 100'001; size += 5'000) {
15        // fill set
16        std::set<int> tree;
17        for(int item = 0; item < size; ++item){
18            tree.insert(item);
19        }
20        // search set
21        auto begin = clock::now();
22        for(const auto& it: tree){
23            if(tree.find(it) == tree.end()) {
24                std::cerr << "Failed to find an expected value in set! Halting.\n";
25                return -2;
26            }
27        }
28        auto end = clock::now();
29        std::chrono::duration<double> elapsed_secs = end - begin;
30        // fill hash table
31        std::unordered_map<int, int> table;
32        for(int item = 0; item < size; ++item){
33            table[item] = item;
34        }
35        begin = clock::now();
36        // search hash table
37        for(const auto& it: table){
38            if(table.find(it.first) == table.end()) {
39                std::cerr << "Failed to find an expected value in map! Halting.\n";
40                return -2;
41            }
42        }
43        end = clock::now();
44        std::chrono::duration<double> elapsed_secs_ht = end - begin;
45
46        // Printing final output
47        std::cout << std::fixed   << std::setprecision(4)
48                  << std::setw(6) << size << '\t'
49                  << std::setw(8) << elapsed_secs.count() << '\t'
50                  << std::setw(8) << elapsed_secs_ht.count() << '\n';
51    }
52    return 0;
53}

Although the std::set find is logarithmic complexity, from a practical sense, it compares favorably with the hash table. The graph below shows example output for values up to 1,000,000.

Comparison of set and hash table find times

Try This!

The online compiler is limited in both memory and time allowed.

Run this example on your own computer with larger values and compare.