C++ • Collections and STL

std::set & unordered_set — Use std::set/unordered_set for membership testing (intro)

Efficiently determine whether an element belongs to a collection using the STL's associative containers.

Programming braces with a command terminal and binary digits.
Inside this lesson 10 sections 5 questions 8 figures

SECTION 1

Historical Context & Motivation

The problem of membership testing — asking "does this element belong to a collection?" — is one of the most fundamental operations in computer science. Long before C++ formalized its Standard Template Library, programmers routinely reached for sorted arrays and binary search, hand-rolled hash tables, or balanced binary search trees from textbooks. Each approach demanded bespoke code for insertion, deletion, rebalancing, or collision resolution, and bugs in these low-level routines were a constant source of production failures. The STL's associative containers were designed to abstract away these details, providing a clean, iterator-compatible interface backed by well-understood data structures.

1979 • Bjarne Stroustrup begins "C with Classes"

Stroustrup starts work at Bell Labs on what will become C++. Early class libraries include rudimentary container abstractions, but no standard collection vocabulary exists.

1994 • Alexander Stepanov's STL proposal

Stepanov and Meng Lee present the Standard Template Library to the C++ Standards Committee. The proposal includes std::set and std::multiset as ordered associative containers backed by balanced trees.

1998 • C++98 standardizes std::set

The first ISO C++ standard (ISO/IEC 14882:1998) ships with std::set and std::map as part of the ordered associative container family, guaranteeing O(log n) lookup.

2011 • C++11 adds unordered containers

C++11 introduces std::unordered_set and std::unordered_map, hash-table-backed containers offering average O(1) membership testing. Move semantics and initializer lists further modernize container usage.

2020 • C++20 contains() member function

C++20 adds the contains() member function to all associative containers, replacing the idiomatic but cumbersome find() != end() pattern with a clean boolean predicate.

The central question this lesson addresses is straightforward yet pervasive: given a large, dynamic collection of elements, how can we test membership efficiently in C++ without writing and maintaining custom data structures? The STL answers this with two complementary containers — std::set for ordered, tree-based lookup and std::unordered_set for hash-based lookup — each with distinct performance profiles and trade-offs.

SECTION 2

Core Principles & Definitions

Both std::set and std::unordered_set model the mathematical concept of a set: an unordered collection of unique elements. What distinguishes them in C++ are the invariants they maintain, the data structures that back them, and the resulting complexity guarantees. Understanding these principles will let you choose the right container for a given task without second-guessing.

1

Uniqueness Invariant

Both containers reject duplicate keys. An insert() of an already-present element is a no-op and returns an iterator to the existing element along with false. This guarantees each element appears exactly once.
2

Ordering vs. Hashing

std::set stores elements in a self-balancing binary search tree (typically a red-black tree), requiring operator< or a custom comparator. std::unordered_set uses a hash table, requiring a hash function and operator==.
3

Iterator Stability

Iterators to elements of both containers remain valid after insertions (for std::set, always; for std::unordered_set, unless a rehash is triggered). This property is critical for algorithms that hold iterators across mutations.
4

Complexity Contracts

std::set guarantees O(log n) for find, insert, and erase in the worst case. std::unordered_set provides O(1) amortized average but O(n) worst case due to hash collisions.
5

The contains() Predicate (C++20)

Prior to C++20, membership testing required s.find(x) != s.end() or s.count(x) > 0. The contains() member function returns a simple bool, eliminating boilerplate and improving readability.
✦

KEY TAKEAWAY

Think of std::set as a neatly alphabetized filing cabinet — you can quickly find any file via binary search through the sorted tabs, and you can also iterate in order. std::unordered_set is more like a hash-coded locker system where each item's name maps directly to a locker number. Looking up a specific locker is nearly instant, but walking through every locker in alphabetical order isn't possible without sorting first. Choose the cabinet when you need ordered traversal; choose the lockers when you only care about membership speed.

SECTION 3

Visual Explanation — Internal Structure

The diagram below contrasts the two internal data structures side by side. On the left, std::set maintains a balanced binary search tree (shown here as a simplified red-black tree). On the right, std::unordered_set organizes elements into an array of buckets, each containing a linked list (or similar chain) of colliding entries. The structural difference explains why ordered iteration is natural for the tree but requires a sort pass for the hash table.

std::set (Red-Black Tree)std::unordered_set (Hash Table)30155010204070In-order traversal: 10 → 15 → 20 → 30 → 40 → 50 → 70find(40): root→right→left ✓O(log n) — 3 comparisons for n=7Red nodeBlack node[0]70[1]∅[2]30[3]1050[4]∅[5]40[6]2015hash(x) = x % 7 → bucket indexfind(40): hash(40)=5 → bucket[5] → 40 ✓O(1) average — 1 hash + 1 comparison
Left: a red-black tree used by std::set — traversal follows the BST property (left < parent < right). Right: a hash table used by std::unordered_set — elements are placed into buckets via a hash function. Collisions (e.g., bucket [3] holding both 10 and 50) are resolved by chaining.

Notice how the tree on the left naturally yields a sorted in-order traversal (10 → 15 → 20 → 30 → 40 → 50 → 70), whereas the hash table stores elements in an order determined solely by the hash function. This structural distinction directly affects whether you can use range queries, lower_bound/upper_bound lookups, or ordered iteration — operations that are efficient on std::set but meaningless on std::unordered_set.

SECTION 4

Complexity Analysis & Performance

Choosing between the two containers ultimately comes down to understanding their asymptotic complexity guarantees and how those translate to real-world wall-clock performance. The mathematical framework below formalizes the costs of the three core membership-related operations: insertion, lookup, and deletion.

std::set — BALANCED BST LOOKUP

Tfind(n)=O(log2n)T_find(n) = O(log₂ n)

Each comparison eliminates half the remaining tree. For n = 10⁶ elements, at most ⌈log₂(10⁶)⌉ = 20 comparisons are needed — this is a worst-case guarantee.

std::unordered_set — HASH TABLE LOOKUP

Tfind(n)=O(1)amortized,O(n)worstcaseT_find(n) = O(1) amortized, O(n) worst case

Average-case performance depends on the load factor α = n / B, where n is the element count and B is the bucket count. When α is low and the hash function distributes well, expected chain length ≈ α ≈ 1. Worst case arises when all elements hash to the same bucket.

LOAD FACTOR & REHASHING

α=n/B,rehashwhenα>maxloadfactor(default1.0)α = n / B, rehash when α > max_load_factor (default 1.0)

When α exceeds max_load_factor(), the container doubles B and re-inserts all elements — an O(n) operation. This is amortized across many inserts, preserving average O(1) per operation.

Operationstd::setstd::unordered_set
insert(x)O(log n) worstO(1) avg, O(n) worst
find(x) / contains(x)O(log n) worstO(1) avg, O(n) worst
erase(x)O(log n) worstO(1) avg, O(n) worst
Ordered iterationO(n) — naturalO(n log n) — sort needed
lower_bound(x)O(log n)N/A

Complexity comparison of key operations

💡

Practical note

In practice, std::unordered_set often outperforms std::set by 3–10× for pure membership queries on integer keys because hash computation is fast and the flat memory layout exploits CPU cache lines. However, the tree's pointer-heavy structure can cause many cache misses on large sets. Always benchmark with your actual workload when performance is critical.

SECTION 5

API Surface & Common Patterns

Both containers share a remarkably similar public interface, which is by design — the STL's concept of AssociativeContainer and UnorderedAssociativeContainer ensure that switching between the two requires changing little more than the type name and, possibly, supplying a hash function. The diagram below maps the most commonly used member functions onto the membership testing workflow.

Membership Testing Workflow1. Constructset<int> s{1,2,3};2. Inserts.insert(42);3. Test Memberships.contains(42); // truePre-C++20 Patternss.find(x) != s.end() // iterators.count(x) > 0 // 0 or 1C++20 Patterns.contains(x) // bool ✓Clean, readable, no iterator boilerplateAdditional std::set Powerss.lower_bound(x) // ≥ xs.upper_bound(x) // > xfor (auto& v : s) // sortedOrdered traversal is inherentAdditional unordered_set Controlss.bucket_count() // Bs.load_factor() // αs.reserve(n) // pre-allocTune for performance via hashing
The membership testing workflow proceeds from construction through insertion to querying. Both containers support find(), count(), and (since C++20) contains(). The lower boxes show container-specific capabilities: ordered queries for std::set and hash-tuning controls for std::unordered_set.

A pattern that deserves special attention is the insert-and-test idiom. The insert() member function returns a std::pair<iterator, bool> where the boolean indicates whether the insertion actually occurred. This is useful in scenarios like building a set of unique tokens from a stream — you insert each token and simultaneously learn whether it was new. Calling find() before insert() would double the lookup cost, so leveraging the return value is both idiomatic and efficient.

ℹ

Custom Types

To store a user-defined type in std::set<T>, provide operator< or a comparator. For std::unordered_set<T>, you need a specialization of std::hash<T> and operator==. Forgetting either requirement yields a compile-time error — the STL enforces these contracts through template constraints.

SECTION 6

Worked Example — Distinct Word Counter

Suppose we want to read words from a text file and count the number of distinct words it contains. We also want to quickly answer whether a given query word appears in the file. This is a classic membership testing scenario.

Building a Distinct-Word Set and Querying Membership

  1. 1

    Step 1 — Choose the Container

    Since we only need membership testing and do not need words in sorted order, we pick std::unordered_set<std::string> for O(1) average lookups. If we also needed alphabetical iteration, we would use std::set<std::string>.
    std::unordered_set<std::string> words;
  2. 2

    Step 2 — Populate the Set

    Read the file token by token using std::istream_iterator or a simple while (ifs >> word) loop. Each call to words.insert(word) either adds the word (if new) or is silently ignored (if duplicate). No explicit duplicate check is needed.
    std::ifstream ifs("input.txt"); std::string word; while (ifs >> word) { words.insert(word); }
  3. 3

    Step 3 — Query the Distinct Count

    The number of distinct words is simply words.size(). Because the set guarantees uniqueness, this value is accurate without any post-processing.
    std::cout << "Distinct words: " << words.size() << '\n';
  4. 4

    Step 4 — Test Membership (C++20)

    To check if a specific word like "algorithm" appears, use the C++20 contains() predicate. Pre-C++20, the equivalent would be words.find("algorithm") != words.end(). Both compile to essentially the same machine code.
    if (words.contains("algorithm")) { std::cout << "Found!\n"; }
  5. 5

    Step 5 — Full Program

    The complete program combines the above steps. Compiling with -std=c++20 enables the contains() function.
    #include <iostream> #include <fstream> #include <string> #include <unordered_set> int main() { std::unordered_set<std::string> words; std::ifstream ifs("input.txt"); std::string word; while (ifs >> word) words.insert(word); std::cout << "Distinct: " << words.size() << '\n'; std::cout << std::boolalpha << words.contains("algorithm") << '\n'; }

SECTION 7

Trade-offs: std::set vs. std::unordered_set

Neither container dominates the other in all scenarios. The choice depends on a combination of factors: whether you need ordering guarantees, the cost of hashing versus comparison, memory constraints, and worst-case latency requirements. The table below summarizes the key trade-offs to guide your decision.

Criterionstd::setstd::unordered_set
Lookup speed (average)O(log n) — slower for large nO(1) — faster
Worst-case guaranteeO(log n) — deterministicO(n) — hash collision degeneration
Ordered iterationYes — sorted by comparatorNo — arbitrary bucket order
Range queries (lower/upper_bound)O(log n) — efficientNot supported
Memory overhead~3 pointers per node + dataBucket array + per-element pointer + data
Cache friendlinessPoor — pointer chasing in treeModerate — bucket array is contiguous
Requirements on element typeoperator< (or comparator)std::hash<T> + operator==

Decision matrix for choosing between std::set and std::unordered_set

🎯

RULE OF THUMB

Default to std::unordered_set when you only need membership testing and insertion — it's typically faster. Reach for std::set when you need sorted traversal, predecessor/successor queries, or guaranteed worst-case logarithmic performance (e.g., in real-time systems). Think of it like choosing between a dictionary's alphabetical index (set) and a phone's contact search by typing the first few letters (unordered_set): the alphabetical index lets you browse neighbors, while the search bar is faster for exact matches.

SECTION 8

Connection to Advanced Containers & Techniques

The set containers introduced here form the foundation for a family of more specialized tools. Understanding their design prepares you for advanced scenarios such as multi-key collections, concurrent access patterns, and external policy-based containers in production systems.

This LessonAdvanced TopicKey Difference
std::set<T>std::multiset<T>Allows duplicate keys — useful for counting sorted occurrences
std::unordered_set<T>std::unordered_multiset<T>Allows duplicate keys in a hash table
Set (keys only)std::map / unordered_mapAssociates a value with each key — key-value pairs instead of lone keys
Single-thread usageConcurrent hash sets (e.g., TBB, libcuckoo)Thread-safe concurrent reads and writes without external locking
Exact membershipBloom filtersProbabilistic membership with false positives but O(1) and very low memory

From introductory sets to advanced data structures

C++23 introduces flat_set and flat_unordered_set (via <flat_set>), which store elements in a sorted contiguous array instead of a node-based tree. These provide better cache locality at the cost of O(n) insertion (due to shifting), making them ideal for read-heavy, write-infrequent workloads. As you deepen your understanding of performance-critical C++ code, profiling will reveal which container topology best matches your access patterns.

SECTION 9

Practice Problems

Question 1: CONCEPTUAL

Explain why std::unordered_set does not support lower_bound() or upper_bound(), even though std::set does. What underlying structural property makes range queries efficient on a set but not on an unordered_set?
Show answer
The lower_bound() and upper_bound() functions rely on elements being stored in a sorted order. std::set uses a balanced BST where an in-order traversal visits elements in ascending order, so finding the first element ≥ x is a matter of following the tree structure in O(log n). std::unordered_set distributes elements into buckets based on hash values, which bear no relationship to the element ordering. To find all elements ≥ x, you would need to scan every bucket — an O(n) operation — defeating the purpose of the data structure.

Question 2: BASIC CALCULATION

A std::set<int> contains 1,000,000 elements. What is the maximum number of comparisons required by a single call to find()? (Assume a perfectly balanced red-black tree.)
Show answer
A red-black tree of n nodes has height at most 2 × log₂(n + 1). For n = 1,000,000, log₂(1,000,001) ≈ 19.93, so the height bound is approximately 2 × 20 = 40. In practice, the height of a red-black tree with 10⁶ elements is typically around 20–25, and each level visited requires one comparison. Thus the maximum is roughly 40 comparisons by the theoretical bound, though realistic implementations usually need about 20. The key insight is that this is O(log n) regardless — logarithmic growth makes even millions of elements manageable.

Question 3: INTERMEDIATE

Write a C++20 function std::vector<int> find_duplicates(const std::vector<int>& v) that returns a sorted vector of all values appearing more than once in v. Use set-based containers, and analyze the time complexity.
Show answer
Use an unordered_set<int> seen to track first-time elements and a set<int> duplicates (ordered, to get sorted output) to record values seen a second time. For each element x in v, attempt seen.insert(x); if the returned bool is false (meaning x was already seen), insert x into duplicates. Finally, construct the result vector from duplicates' iterators. Time complexity: O(n) average for the unordered_set operations plus O(d × log d) for the ordered set (where d ≤ n is the duplicate count), giving O(n) overall when d is small. Returning std::vector<int>(duplicates.begin(), duplicates.end()) leverages the sorted property of std::set for a clean result.

Question 4: APPLIED

You are building a network intrusion detection system that must check every incoming IP address (represented as a 32-bit unsigned int) against a blacklist of 50,000 known malicious IPs. Lookups happen on the critical path at 100,000 packets per second. Which container would you choose and why? What tuning parameters might you adjust?
Show answer
Choose std::unordered_set<uint32_t> because raw membership testing speed is paramount, ordered traversal is unnecessary, and integer hashing is extremely fast. At 100,000 lookups/sec, each lookup has a budget of ~10μs; O(1) hash lookups typically complete in under 100ns. To tune: call reserve(50000) before loading the blacklist to avoid rehashing. Consider setting max_load_factor(0.5) to reduce collisions at the cost of extra memory. Since the blacklist is static (write-once, read-many), you could also consider a std::flat_set (C++23) or even a minimal perfect hash for the absolute best cache performance.

Question 5: CRITICAL THINKING

An adversary learns the hash function your std::unordered_set<std::string> uses and deliberately crafts input strings that all hash to the same bucket. Analyze the asymptotic complexity of n lookups under this attack. Then propose at least two mitigations, discussing the trade-offs of each.
Show answer
When all n elements land in a single bucket, each lookup degrades to a linear scan of that bucket's chain: O(n) per query, making n queries O(n²). Mitigation 1: Use a cryptographically seeded hash (e.g., SipHash) that randomizes output per process launch, preventing the adversary from predicting collisions. Trade-off: SipHash is ~2× slower than std::hash<string> for short strings. Mitigation 2: Switch to std::set<std::string>, which guarantees O(log n) per lookup regardless of input distribution, eliminating the hash collision attack vector. Trade-off: average-case throughput drops from O(1) to O(log n). A third option is Swiss-table-style open addressing (as in Abseil's flat_hash_set), which limits probe sequences and uses SIMD to check multiple slots in parallel, bounding worst-case probing per lookup even under adversarial conditions.

SUMMARY

Lesson Summary

Summary

C++ provides two primary containers for membership testing: std::set, backed by a balanced binary search tree offering O(log n) worst-case operations and sorted iteration, and std::unordered_set, backed by a hash table offering O(1) amortized average lookups but O(n) worst case. Both enforce element uniqueness and expose find(), count(), and (since C++20) contains() for membership queries.

Choose std::set when you need ordered traversal, range queries (lower_bound/upper_bound), or deterministic worst-case guarantees. Choose std::unordered_set when pure lookup speed dominates and ordering is irrelevant. Remember that custom types require operator< for sets and std::hash<T> plus operator== for unordered sets. This foundational understanding prepares you for maps, multisets, and advanced containers like flat_set (C++23).

Varsity Tutors • C++ • std::set & unordered_set — Use std::set/unordered_set for membership testing (intro)