Guide contents

Lesson 43 of 94

HashSet

A HashSet<T> stores unique values with average O(1) insertion and lookup. It is a HashMap<T, ()> underneath, so everything from HashMap about hashing, ownership, and iteration order applies: elements must be Eq + Hash, inserting moves owned values in, and iteration order is unspecified.

Operation Rust
Create an empty set HashSet::new()
Add an element set.insert(x) returns bool
Membership test set.contains(&x)
Remove an element set.remove(&x)
Union (in place) a.extend(b) or &a | &b
Intersection &a & &b or a.intersection(&b)
Difference &a - &b or a.difference(&b)
Subset test b.is_subset(&a)
Sorted set BTreeSet: BTreeMap & BTreeSet

1. Creating a set and inserting

insert returns true if the value was new, and false if it was already present.

Rust
use std::collections::HashSet;

fn main() {
    let mut seen = HashSet::new();
    println!("{}", seen.insert("ana"));
    println!("{}", seen.insert("ben"));
    println!("{}", seen.insert("ana"));
    println!("{} unique", seen.len());
}
Output
true
true
false
2 unique

2. Membership: contains

Rust
use std::collections::HashSet;

fn main() {
    let banned_ips: HashSet<&str> = HashSet::from(["10.0.0.66", "192.168.1.99"]);
    for ip in ["10.0.0.1", "10.0.0.66"] {
        let verdict = if banned_ips.contains(ip) { "blocked" } else { "allowed" };
        println!("{ip}: {verdict}");
    }
}
Output
10.0.0.1: allowed
10.0.0.66: blocked

With a Vec, contains scans every element (O(n)). A HashSet answers in O(1) on average, which matters for large lists checked often.

3. Removing elements

Rust
use std::collections::HashSet;

fn main() {
    let mut online: HashSet<String> = ["ana", "ben", "cy"].iter().map(|s| s.to_string()).collect();
    println!("{}", online.remove("ben"));
    println!("{}", online.remove("zed"));
    let mut left: Vec<&String> = online.iter().collect();
    left.sort();
    println!("{:?}", left);
}
Output
true
false
["ana", "cy"]

Like HashMap, a HashSet<String> can be queried with a &str.

4. Deduplicating a list while keeping order

Because insert reports whether the value was new, it can be used directly as a filter.

Rust
use std::collections::HashSet;

fn main() {
    let events = ["login", "view", "login", "click", "view", "logout", "click"];
    let mut seen = HashSet::new();
    let unique_in_order: Vec<&str> = events.iter().copied().filter(|e| seen.insert(*e)).collect();
    println!("{:?}", unique_in_order);
}
Output
["login", "view", "click", "logout"]

5. Detecting whether a list has duplicates

all stops at the first false, so this stops at the first duplicate.

Rust
use std::collections::HashSet;

fn has_duplicates<T: std::hash::Hash + Eq>(items: &[T]) -> bool {
    let mut seen = HashSet::with_capacity(items.len());
    !items.iter().all(|x| seen.insert(x))
}

fn main() {
    println!("{}", has_duplicates(&["a@x.com", "b@x.com", "c@x.com"]));
    println!("{}", has_duplicates(&[3, 1, 4, 1, 5]));
}
Output
false
true

6. Building sets: from, collect, extend

Rust
use std::collections::HashSet;

fn main() {
    let a = HashSet::from([1, 2, 3]);
    let b: HashSet<char> = "hello".chars().collect();
    let mut c: HashSet<u32> = HashSet::new();
    c.extend([5, 6]);
    c.extend(vec![6, 7]);
    println!("{} {} {}", a.len(), b.len(), c.len());
}
Output
3 4 3

7. Union, intersection, difference

The set methods return lazy iterators of references. Collect or sort them to use the result.

Rust
use std::collections::HashSet;

fn sorted<'a>(it: impl Iterator<Item = &'a &'a str>) -> Vec<&'a str> {
    let mut v: Vec<&str> = it.copied().collect();
    v.sort();
    v
}

fn main() {
    let team_a: HashSet<&str> = HashSet::from(["rust", "go", "sql", "docker"]);
    let team_b: HashSet<&str> = HashSet::from(["java", "sql", "docker", "kotlin"]);
    println!("union:        {:?}", sorted(team_a.union(&team_b)));
    println!("intersection: {:?}", sorted(team_a.intersection(&team_b)));
    println!("a - b:        {:?}", sorted(team_a.difference(&team_b)));
    println!("symmetric:    {:?}", sorted(team_a.symmetric_difference(&team_b)));
}
Output
union:        ["docker", "go", "java", "kotlin", "rust", "sql"]
intersection: ["docker", "sql"]
a - b:        ["go", "rust"]
symmetric:    ["go", "java", "kotlin", "rust"]

8. Set operators: |, &, -, ^

On references to sets, operators produce new owned sets, which reads like math.

Rust
use std::collections::HashSet;

fn show(s: &HashSet<u32>) -> Vec<u32> {
    let mut v: Vec<u32> = s.iter().copied().collect();
    v.sort();
    v
}

fn main() {
    let evens: HashSet<u32> = (1..=10).filter(|n| n % 2 == 0).collect();
    let multiples_of_3: HashSet<u32> = (1..=10).filter(|n| n % 3 == 0).collect();
    println!("{:?}", show(&(&evens | &multiples_of_3)));
    println!("{:?}", show(&(&evens & &multiples_of_3)));
    println!("{:?}", show(&(&evens - &multiples_of_3)));
    println!("{:?}", show(&(&evens ^ &multiples_of_3)));
}
Output
[2, 3, 4, 6, 8, 9, 10]
[6]
[2, 4, 8, 10]
[2, 3, 4, 8, 9, 10]

9. Subsets, supersets, and disjoint sets

Rust
use std::collections::HashSet;

fn main() {
    let granted: HashSet<&str> = HashSet::from(["read", "write", "comment"]);
    let needed_to_edit: HashSet<&str> = HashSet::from(["read", "write"]);
    let needed_to_admin: HashSet<&str> = HashSet::from(["read", "write", "delete"]);
    let guest: HashSet<&str> = HashSet::from(["view_public"]);

    println!("can edit:  {}", needed_to_edit.is_subset(&granted));
    println!("can admin: {}", needed_to_admin.is_subset(&granted));
    println!("superset:  {}", granted.is_superset(&needed_to_edit));
    println!("disjoint:  {}", granted.is_disjoint(&guest));
}
Output
can edit:  true
can admin: false
superset:  true
disjoint:  true

10. Iteration order is not defined

Rust
use std::collections::HashSet;

fn main() {
    let tags: HashSet<&str> = HashSet::from(["zeta", "alpha", "mid"]);
    let mut ordered: Vec<&str> = tags.into_iter().collect();
    ordered.sort();
    println!("{:?}", ordered);
}
Output
["alpha", "mid", "zeta"]

11. Custom element types

Elements must implement Eq and Hash.

Rust
use std::collections::HashSet;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
struct Seat {
    row: char,
    number: u8,
}

fn main() {
    let mut booked: HashSet<Seat> = HashSet::new();
    let requests = [Seat { row: 'A', number: 1 }, Seat { row: 'A', number: 2 }, Seat { row: 'A', number: 1 }];
    for seat in requests {
        if booked.insert(seat) {
            println!("booked {}{}", seat.row, seat.number);
        } else {
            println!("{}{} already taken", seat.row, seat.number);
        }
    }
}
Output
booked A1
booked A2
A1 already taken

12. retain and drain

Rust
use std::collections::HashSet;

fn main() {
    let mut active_users: HashSet<u32> = (1..=10).collect();
    active_users.retain(|id| id % 3 != 0); // remove every third user
    let mut remaining: Vec<u32> = active_users.iter().copied().collect();
    remaining.sort();
    println!("{:?}", remaining);

    let mut batch: Vec<u32> = active_users.drain().collect();
    batch.sort();
    println!("drained {:?}, set empty: {}", batch, active_users.is_empty());
}
Output
[1, 2, 4, 5, 7, 8, 10]
drained [1, 2, 4, 5, 7, 8, 10], set empty: true

13. Getting the stored element: get, take, replace

get returns a reference to the element stored in the set that equals the argument. This is useful when elements compare by a key but carry more data, or for interning strings.

Rust
use std::collections::HashSet;

fn main() {
    let mut interned: HashSet<String> = HashSet::new();
    for word in ["rust", "java", "rust", "rust"] {
        if !interned.contains(word) {
            interned.insert(word.to_string());
        }
        let canonical: &String = interned.get(word).unwrap();
        println!("{word} -> stored at {:p}", canonical.as_ptr());
    }
    let taken = interned.take("java");
    println!("{:?}, {} left", taken, interned.len());
}

Run it and you'll see that the address printed for rust is the same all three times: every lookup returns the single stored copy. The actual addresses differ from run to run, so no output is shown here.

14. Unique characters and pangrams

Rust
use std::collections::HashSet;

fn is_pangram(sentence: &str) -> bool {
    let letters: HashSet<char> = sentence.to_lowercase().chars().filter(|c| c.is_ascii_lowercase()).collect();
    letters.len() == 26
}

fn main() {
    println!("{}", is_pangram("The quick brown fox jumps over the lazy dog"));
    println!("{}", is_pangram("Hello, world"));
    let unique: HashSet<char> = "mississippi".chars().collect();
    println!("mississippi uses {} distinct letters", unique.len());
}
Output
true
false
mississippi uses 4 distinct letters

15. Case-insensitive sets by normalizing

A set compares values exactly. Normalize values before inserting them to make it case-insensitive.

Rust
use std::collections::HashSet;

fn main() {
    let signups = ["Ana@Example.com", "ben@example.com", "ana@example.COM", "  Ben@Example.com "];
    let mut unique = HashSet::new();
    for email in signups {
        let normalized = email.trim().to_lowercase();
        if !unique.insert(normalized.clone()) {
            println!("duplicate signup: {email:?}");
        }
    }
    println!("{} unique emails", unique.len());
}
Output
duplicate signup: "ana@example.COM"
duplicate signup: "  Ben@Example.com "
2 unique emails

16. Realistic example: diffing two lists

What was added and removed between yesterday's and today's list of deployed services?

Rust
use std::collections::HashSet;

fn main() {
    let yesterday: HashSet<&str> = HashSet::from(["auth", "billing", "search", "email"]);
    let today: HashSet<&str> = HashSet::from(["auth", "billing", "recommendations", "email", "chat"]);

    let mut added: Vec<&&str> = today.difference(&yesterday).collect();
    let mut removed: Vec<&&str> = yesterday.difference(&today).collect();
    added.sort();
    removed.sort();
    println!("added:   {:?}", added);
    println!("removed: {:?}", removed);
    println!("unchanged: {}", today.intersection(&yesterday).count());
}
Output
added:   ["chat", "recommendations"]
removed: ["search"]
unchanged: 3

17. Realistic example: permission checks

Rust
use std::collections::{HashMap, HashSet};

fn main() {
    let roles: HashMap<&str, HashSet<&str>> = HashMap::from([
        ("viewer", HashSet::from(["read"])),
        ("editor", HashSet::from(["read", "write", "comment"])),
        ("admin", HashSet::from(["read", "write", "comment", "delete", "invite"])),
    ]);
    let endpoints: [(&str, HashSet<&str>); 3] = [
        ("GET /docs", HashSet::from(["read"])),
        ("PUT /docs", HashSet::from(["read", "write"])),
        ("DELETE /docs", HashSet::from(["delete"])),
    ];
    for role in ["viewer", "editor", "admin"] {
        let granted = &roles[role];
        let allowed: Vec<&str> = endpoints.iter().filter(|(_, needed)| needed.is_subset(granted)).map(|(e, _)| *e).collect();
        println!("{role:<7} {:?}", allowed);
    }
}
Output
viewer  ["GET /docs"]
editor  ["GET /docs", "PUT /docs"]
admin   ["GET /docs", "PUT /docs", "DELETE /docs"]

18. Realistic example: documents matching every tag

Rust
use std::collections::HashSet;

fn main() {
    let docs: Vec<(&str, HashSet<&str>)> = vec![
        ("Intro to Rust", HashSet::from(["rust", "beginner"])),
        ("Async Rust", HashSet::from(["rust", "async", "advanced"])),
        ("Tokio in production", HashSet::from(["rust", "async", "ops"])),
        ("Java streams", HashSet::from(["java", "beginner"])),
    ];
    let wanted: HashSet<&str> = HashSet::from(["rust", "async"]);
    let matches: Vec<&str> = docs.iter().filter(|(_, tags)| wanted.is_subset(tags)).map(|(t, _)| *t).collect();
    println!("{:?}", matches);

    let all_tags: HashSet<&str> = docs.iter().flat_map(|(_, tags)| tags.iter().copied()).collect();
    let mut all: Vec<&str> = all_tags.into_iter().collect();
    all.sort();
    println!("{} distinct tags: {:?}", all.len(), all);
}
Output
["Async Rust", "Tokio in production"]
6 distinct tags: ["advanced", "async", "beginner", "java", "ops", "rust"]

19. Realistic example: friends in common

Rust
use std::collections::{HashMap, HashSet};

fn main() {
    let friends: HashMap<&str, HashSet<&str>> = HashMap::from([
        ("ana", HashSet::from(["ben", "cy", "dee", "eve"])),
        ("ben", HashSet::from(["ana", "cy", "fay"])),
        ("cy", HashSet::from(["ana", "ben", "dee"])),
    ]);
    let mutual = |a: &str, b: &str| -> Vec<&str> {
        let mut v: Vec<&str> = friends[a].intersection(&friends[b]).copied().collect();
        v.sort();
        v
    };
    println!("ana & ben: {:?}", mutual("ana", "ben"));
    println!("ana & cy:  {:?}", mutual("ana", "cy"));

    let suggestions: HashSet<&str> = friends["ben"].iter().flat_map(|f| friends.get(f).into_iter().flatten()).copied()
        .filter(|s| *s != "ben" && !friends["ben"].contains(s))
        .collect();
    let mut s: Vec<&str> = suggestions.into_iter().collect();
    s.sort();
    println!("people ben may know: {:?}", s);
}
Output
ana & ben: ["cy"]
ana & cy:  ["ben", "dee"]
people ben may know: ["dee", "eve"]

Depth-first search through a dependency graph. The visited set stops the search from looping forever on cycles.

Rust
use std::collections::{HashMap, HashSet};

fn reachable<'a>(graph: &HashMap<&'a str, Vec<&'a str>>, start: &'a str) -> Vec<&'a str> {
    let mut visited: HashSet<&str> = HashSet::new();
    let mut stack = vec![start];
    let mut order = Vec::new();
    while let Some(node) = stack.pop() {
        if !visited.insert(node) {
            continue;
        }
        order.push(node);
        if let Some(deps) = graph.get(node) {
            for &d in deps.iter().rev() {
                stack.push(d);
            }
        }
    }
    order
}

fn main() {
    let graph: HashMap<&str, Vec<&str>> = HashMap::from([
        ("app", vec!["web", "db"]),
        ("web", vec!["http", "json"]),
        ("db", vec!["pool", "json"]),
        ("pool", vec!["db"]), // a cycle
        ("http", vec![]),
        ("json", vec![]),
    ]);
    println!("{:?}", reachable(&graph, "app"));
    println!("{:?}", reachable(&graph, "pool"));
}
Output
["app", "web", "http", "json", "db", "pool"]
["pool", "db", "json"]

21. Realistic example: detecting a dependency cycle

Keep two sets: nodes finished, and nodes on the current path. Reaching a node already on the path means a cycle.

Rust
use std::collections::{HashMap, HashSet};

fn find_cycle<'a>(
    graph: &HashMap<&'a str, Vec<&'a str>>,
    node: &'a str,
    on_path: &mut Vec<&'a str>,
    done: &mut HashSet<&'a str>,
) -> Option<Vec<&'a str>> {
    if let Some(pos) = on_path.iter().position(|&n| n == node) {
        let mut cycle = on_path[pos..].to_vec();
        cycle.push(node);
        return Some(cycle);
    }
    if done.contains(node) {
        return None;
    }
    on_path.push(node);
    for &dep in graph.get(node).map(|v| v.as_slice()).unwrap_or(&[]) {
        if let Some(c) = find_cycle(graph, dep, on_path, done) {
            return Some(c);
        }
    }
    on_path.pop();
    done.insert(node);
    None
}

fn main() {
    let modules = HashMap::from([
        ("api", vec!["service"]),
        ("service", vec!["repo", "cache"]),
        ("repo", vec!["db"]),
        ("cache", vec!["service"]),
        ("db", vec![]),
    ]);
    let cycle = find_cycle(&modules, "api", &mut Vec::new(), &mut HashSet::new());
    println!("{:?}", cycle.map(|c| c.join(" -> ")));
}
Output
Some("service -> cache -> service")

22. Realistic example: unique visitors per day

Rust
use std::collections::{HashMap, HashSet};

fn main() {
    let hits = [
        ("2026-09-25", "ip-1"), ("2026-09-25", "ip-2"), ("2026-09-25", "ip-1"),
        ("2026-09-26", "ip-3"), ("2026-09-26", "ip-1"), ("2026-09-26", "ip-3"),
        ("2026-09-27", "ip-4"),
    ];
    let mut per_day: HashMap<&str, HashSet<&str>> = HashMap::new();
    for (day, ip) in hits {
        per_day.entry(day).or_default().insert(ip);
    }
    let mut days: Vec<&&str> = per_day.keys().collect();
    days.sort();
    for day in days {
        println!("{day}: {} unique visitors", per_day[*day].len());
    }
    let all: HashSet<&str> = per_day.values().flatten().copied().collect();
    println!("total unique: {}", all.len());
}
Output
2026-09-25: 2 unique visitors
2026-09-26: 2 unique visitors
2026-09-27: 1 unique visitors
total unique: 4

23. Realistic example: a simple spell checker

Rust
use std::collections::HashSet;

fn main() {
    let dictionary: HashSet<&str> = "the a borrow checker keeps references valid and memory safe"
        .split_whitespace()
        .collect();
    let text = "The borrow chekcer keeps refrences valid";
    let misspelled: Vec<&str> = text
        .split_whitespace()
        .filter(|w| !dictionary.contains(w.to_lowercase().as_str()))
        .collect();
    println!("{:?}", misspelled);
}
Output
["chekcer", "refrences"]

24. Realistic example: Conway's Game of Life

A set of live cells, and counting neighbors with a map, makes the rules short. The board is infinite because only live cells are stored.

Rust
use std::collections::{HashMap, HashSet};

type Cell = (i32, i32);

fn step(live: &HashSet<Cell>) -> HashSet<Cell> {
    let mut neighbor_counts: HashMap<Cell, u32> = HashMap::new();
    for &(x, y) in live {
        for dx in -1..=1 {
            for dy in -1..=1 {
                if (dx, dy) != (0, 0) {
                    *neighbor_counts.entry((x + dx, y + dy)).or_insert(0) += 1;
                }
            }
        }
    }
    neighbor_counts
        .into_iter()
        .filter(|&(cell, n)| n == 3 || (n == 2 && live.contains(&cell)))
        .map(|(cell, _)| cell)
        .collect()
}

fn render(live: &HashSet<Cell>) {
    for y in 0..5 {
        let row: String = (0..5).map(|x| if live.contains(&(x, y)) { '#' } else { '.' }).collect();
        println!("{row}");
    }
}

fn main() {
    let mut glider: HashSet<Cell> = HashSet::from([(1, 0), (2, 1), (0, 2), (1, 2), (2, 2)]);
    for generation in 0..3 {
        println!("generation {generation}:");
        render(&glider);
        glider = step(&glider);
    }
}
Output
generation 0:
.#...
..#..
###..
.....
.....
generation 1:
.....
#.#..
.##..
.#...
.....
generation 2:
.....
..#..
#.#..
.##..
.....

25. Realistic example: finding the first repeated request ID

Idempotency checks in payment systems reject a request ID they have already seen.

Rust
use std::collections::HashSet;

struct IdempotencyGuard {
    seen: HashSet<String>,
}

impl IdempotencyGuard {
    fn check(&mut self, request_id: &str) -> Result<(), String> {
        if self.seen.insert(request_id.to_string()) {
            Ok(())
        } else {
            Err(format!("duplicate request {request_id}: ignoring"))
        }
    }
}

fn main() {
    let mut guard = IdempotencyGuard { seen: HashSet::new() };
    for (id, amount) in [("req-1", 500), ("req-2", 120), ("req-1", 500), ("req-3", 75)] {
        match guard.check(id) {
            Ok(()) => println!("charging {amount} for {id}"),
            Err(e) => println!("{e}"),
        }
    }
}
Output
charging 500 for req-1
charging 120 for req-2
duplicate request req-1: ignoring
charging 75 for req-3

26. Realistic example: which features are enabled for a user?

Global flags, minus disabled ones, plus user-specific opt-ins, all expressed as set operations.

Rust
use std::collections::HashSet;

fn main() {
    let global: HashSet<&str> = HashSet::from(["search_v2", "dark_mode", "new_checkout"]);
    let disabled_in_region: HashSet<&str> = HashSet::from(["new_checkout"]);
    let user_beta_optins: HashSet<&str> = HashSet::from(["ai_summary"]);

    let effective = &(&global - &disabled_in_region) | &user_beta_optins;
    let mut flags: Vec<&str> = effective.into_iter().collect();
    flags.sort();
    println!("{:?}", flags);
}
Output
["ai_summary", "dark_mode", "search_v2"]

Summary

  • HashSet<T> stores unique values; elements must be Eq + Hash. It's a HashMap<T, ()> underneath.
  • insert returns whether the value was new, which makes deduplication and "seen before?" checks one-liners.
  • contains is O(1) on average, unlike Vec::contains.
  • Set algebra: union, intersection, difference, symmetric_difference (lazy iterators), or the operators |, &, -, ^ on references (new sets).
  • is_subset, is_superset, and is_disjoint express permission and tag checks directly.
  • Iteration order is unspecified. Sort for display, or use BTreeSet.

Next: BTreeMap & BTreeSet.