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