课程概览 · 第 11 章
上一篇:泛型与 trait:复用行为,同时保持接口清晰
下一篇:迭代器与闭包:让数据转换可组合
任务列表按顺序展示、按 ID 查找、统计标签、按优先级调度,是四种不同的访问模式。先确定访问方式、顺序要求与修改成本,再比较容器;不要只凭一行理论复杂度选型。本章示例各自独立,不沿用前面章节的完整状态模型。
图:先匹配访问模式,再决定元素借用、修改或移出的方式。前置知识与学习目标
需要理解所有权、Option 与第 10 章的 trait 约束。学完本章应能:
- 根据顺序、按键、唯一性与 FIFO 需求选择容器;
- 预测
Vec 删除、移出与空集合访问的结果; - 使用
entry、集合运算与范围查询,并说明键的契约; - 区分内容、容量和派生索引的一致性要求。
从访问模式选型
| 访问需求 | 容器 | 顺序与主要成本 |
|---|
| 顺序展示、尾部追加、按下标读取 | Vec<T> | 连续存储;下标 O(1),push 摊销 O(1),中间增删 O(n) |
| FIFO 或两端进出 | VecDeque<T> | 两端进出通常为常数时间,扩容可能搬移 |
| 按键查找、无需排序 | HashMap<K, V> | 无稳定遍历顺序;平均查找 O(1),最坏可为 O(n) |
| 按键有序输出、范围查询 | BTreeMap<K, V> | 键升序;查找 O(log n) |
| 存在性与去重 | HashSet<T> / BTreeSet<T> | 前者无序,后者有序;键要求对应 Map |
| 反复取最大或最小 | BinaryHeap<T> | 仅堆顶有序;peek O(1),pop O(log n) |
| LIFO | Vec<T> | 尾部 push/pop |
不确定时先用 Vec,按键查询频繁时用 HashMap,要求确定顺序时用 BTreeMap 或显式排序。哈希表不承诺遍历顺序,随机种子与内部布局都可能影响它;测试应比较键值关系,而不是未排序的键列表。Set 可以理解为只关心键、不关心值的 Map,但应依赖各自公开 API,不依赖内部实现。
LinkedList 有节点分配和指针跳转成本,定位节点仍需遍历,通常不是列表或队列的默认选项。只有明确需要节点级操作并测量过实际工作负载时再考虑它;“插入 O(1)”并不包含查找插入位置的成本。
任务列表主例
只保留本例用到的 Todo 与 InProgress。remove 保序并移动后续元素;swap_remove 把尾元素放到被删位置,通常不保序。只读标题借用、修改标签和移出统计在这里仅展示使用方式,迭代器机制留给第 12 章。
独立示例(仅标准库;完整内容放 src/main.rs,Edition 2024;执行 cargo +stable run)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71
| use std::collections::{BTreeMap, HashMap};
#[derive(Debug, PartialEq)] struct TaskId(u64);
#[derive(Debug, PartialEq)] enum TaskState { Todo, InProgress { started_at: u64 }, }
struct Task { id: TaskId, title: String, state: TaskState, labels: Vec<String>, }
fn main() { let mut tasks = Vec::with_capacity(4); tasks.push(Task { id: TaskId(1), title: "write docs".into(), state: TaskState::Todo, labels: vec!["docs".into()], }); tasks.push(Task { id: TaskId(2), title: "fix build".into(), state: TaskState::InProgress { started_at: 100 }, labels: vec!["ci".into(), "urgent".into()], }); tasks.push(Task { id: TaskId(3), title: "review pr".into(), state: TaskState::Todo, labels: vec!["urgent".into()], }); let removed = tasks.remove(0); assert_eq!(removed.id, TaskId(1)); assert_eq!(tasks[0].title, "fix build"); let titles: Vec<&str> = tasks.iter().map(|t| t.title.as_str()).collect(); assert_eq!(titles, ["fix build", "review pr"]); for task in &mut tasks { if matches!(task.state, TaskState::Todo) { task.labels.push("unassigned".into()); } } assert_eq!(tasks[1].labels, ["urgent", "unassigned"]); let total: usize = tasks.into_iter().map(|t| t.labels.len()).sum(); assert_eq!(total, 4);
let mut counts = HashMap::new(); for label in ["urgent", "ci", "urgent", "docs", "urgent"] { *counts.entry(label).or_insert(0usize) += 1; } assert_eq!(counts.get("missing"), None); let mut pairs: Vec<_> = counts.iter().map(|(k, v)| (*k, *v)).collect(); pairs.sort(); assert_eq!(pairs, [("ci", 1), ("docs", 1), ("urgent", 3)]); let ordered: BTreeMap<_, _> = counts.into_iter().collect(); assert_eq!(ordered.keys().copied().collect::<Vec<_>>(), ["ci", "docs", "urgent"]);
let titles = ["fix build", "review pr"]; assert_eq!(titles.get(7), None); assert_eq!(titles.get(1), Some(&"review pr")); let mut ids = vec![1, 2, 3]; assert_eq!(ids.swap_remove(0), 1); assert_eq!(ids, [3, 2]); }
|
空集合、缺键与 FIFO
空 Vec 的 first/pop、空堆的 peek/pop 返回 None。map.get 的缺键也是 None;索引 map[&key] 则可能 panic。外部未验证下标优先 get,内部已证明边界的索引可以直接访问。重复键的 insert 返回旧值并替换该键的值,Set 会去重,Vec 保留重复。
独立示例(仅标准库;替换 src/main.rs;执行 cargo +stable run)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| use std::collections::{HashMap, HashSet, VecDeque};
fn main() { let mut queue = VecDeque::new(); assert_eq!(queue.pop_front(), None::<&str>); queue.push_back("t1"); queue.push_back("t2"); assert_eq!(queue.pop_front(), Some("t1")); assert_eq!(queue.pop_front(), Some("t2")); assert_eq!(queue.pop_front(), None); let mut map = HashMap::new(); assert_eq!(map.insert(1, "old"), None); assert_eq!(map.insert(1, "new"), Some("old")); assert_eq!(map.get(&1), Some(&"new")); assert_eq!(map.get(&2), None); let set: HashSet<_> = [1, 1, 2].into_iter().collect(); assert_eq!(set.len(), 2); }
|
不要反复用 Vec::remove(0) 实现 FIFO:每次都要移动后续元素。VecDeque 的环形存储适合两端进出,但不保证所有元素在一个连续切片里。
Vec 的修改与移出
sort 是稳定排序,可能使用辅助存储;sort_unstable 不保证相等元素的相对顺序。两者谁更快取决于数据与比较器。sort_by_key 可能对同一元素多次求键,求键昂贵时可考虑 sort_by_cached_key,同时计入其存储成本。
dedup 只消除相邻重复,并不要求排序;若目标是删除所有相等值,先排序可以让它们相邻,但会改变原顺序。binary_search 要求输入按一致规则排序。retain 原地筛选,drain 移出选定范围并在结束时整理原容器;后续 collect 到新 Vec 仍可能分配。chunks/windows 产出借用切片,不复制底层元素,收集这些切片也会创建新的目标容器。
独立示例(仅标准库;替换 src/main.rs;执行 cargo +stable run)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| use std::cmp::Reverse;
fn main() { let mut titles = vec!["review pr", "fix build", "write docs", "fix build"]; titles.sort(); titles.dedup(); assert_eq!(titles, ["fix build", "review pr", "write docs"]); titles.retain(|t| !t.contains("build")); assert_eq!(titles, ["review pr", "write docs"]); let mut tasks = vec![("docs", 2u8), ("build", 5), ("review", 3)]; tasks.sort_by_key(|(_, p)| Reverse(*p)); assert_eq!(tasks[0].0, "build"); let mut inbox = vec!["t1", "t2", "t3"]; let drained: Vec<_> = inbox.drain(..).collect(); assert_eq!(drained, ["t1", "t2", "t3"]); assert!(inbox.is_empty()); assert!(inbox.capacity() >= 3); let ids = [1u64, 3, 5, 7]; assert_eq!(ids.binary_search(&5), Ok(2)); assert_eq!(ids.binary_search(&4), Err(2)); let ids = [1, 2, 3, 4, 5]; let batches: Vec<_> = ids.chunks(2).collect(); assert_eq!(batches, [&[1, 2][..], &[3, 4][..], &[5][..]]); let sums: Vec<_> = ids.windows(2).map(|w| w[0] + w[1]).collect(); assert_eq!(sums, [3, 5, 7, 9]); }
|
Map、Set 与键的契约
HashMap 键要求 Eq + Hash,且相等键必须产生相同哈希;BTreeMap 键要求一致的 Ord 全序。派生通常适合字段本身就定义相等语义的值对象。手写实现时应让相等、哈希或比较覆盖相同的语义字段;不要在键留在集合期间通过内部可变性改变这些语义。
entry 用一个可变访问入口表达“存在则更新、缺失则插入”,避免手写查询后插入分支;它不提供线程同步。共享集合仍需要第 18 章的锁或消息边界。or_insert_with 仅在缺键时调用构造闭包。HashMap<String, _> 可按 &str 查询,因为 String: Borrow<str>,无需临时 String 分配。
独立示例(仅标准库;替换 src/main.rs;执行 cargo +stable run)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
| use std::collections::{BTreeMap, BTreeSet, HashMap};
#[derive(Debug, PartialEq, Eq, Hash)] struct TaskId(u64);
fn main() { let mut title_of = HashMap::new(); title_of.insert(TaskId(2), "fix build".to_owned()); assert_eq!(title_of.get(&TaskId(2)).map(String::as_str), Some("fix build")); let mut counts: HashMap<String, usize> = HashMap::new(); for label in ["urgent", "ci", "urgent"] { counts.entry(label.into()).and_modify(|n| *n += 1).or_insert(1); } assert_eq!(counts.get("urgent"), Some(&2)); let mut index: HashMap<&str, Vec<u64>> = HashMap::new(); index.entry("urgent").or_insert_with(Vec::new).push(1); index.entry("urgent").or_insert_with(Vec::new).push(2); assert_eq!(index["urgent"], [1, 2]);
let alice = BTreeSet::from(["docs", "ci", "urgent"]); let bob = BTreeSet::from(["ci", "review"]); let both: BTreeSet<_> = alice.intersection(&bob).copied().collect(); assert_eq!(both, BTreeSet::from(["ci"])); let either: BTreeSet<_> = alice.union(&bob).copied().collect(); assert_eq!(either, BTreeSet::from(["ci", "docs", "review", "urgent"])); let only: BTreeSet<_> = alice.difference(&bob).copied().collect(); assert_eq!(only, BTreeSet::from(["docs", "urgent"])); let exclusive: BTreeSet<_> = alice.symmetric_difference(&bob).copied().collect(); assert_eq!(exclusive, BTreeSet::from(["docs", "review", "urgent"])); assert!(BTreeSet::from(["ci"]).is_subset(&alice)); assert!(alice.is_disjoint(&BTreeSet::from(["review"]))); let by_priority = BTreeMap::from([(2, "docs"), (5, "build"), (3, "review")]); let mid: Vec<_> = by_priority.range(3..=5).collect(); assert_eq!(mid, [(&3, &"review"), (&5, &"build")]); }
|
这些集合运算返回迭代器,便于表达交、并、差;是否比特定手写循环更快,需要测量,不能只按方法名判断。频繁成员查询适合 Set;若数据很少或本来只需顺序扫描,Vec 也可能更合适。
堆:只维护下一个最值
BinaryHeap 默认是最大堆,只保证堆顶。Ord 按派生字段顺序比较,以下同优先级任务会继续比较标题;若要求先入先出,应在比较规则中加入合适的序号。Reverse 翻转比较方向,得到最小堆行为,而不是把整个堆排序。
独立示例(仅标准库;替换 src/main.rs;执行 cargo +stable run)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| use std::cmp::Reverse; use std::collections::BinaryHeap;
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)] struct Prioritized { priority: i32, title: String, }
fn main() { let mut heap = BinaryHeap::new(); for (priority, title) in [(2, "write docs"), (5, "fix build"), (3, "review pr")] { heap.push(Prioritized { priority, title: title.into() }); } assert_eq!(heap.pop().unwrap().title, "fix build"); assert_eq!(heap.peek().map(|t| t.priority), Some(3)); assert_eq!(heap.pop().unwrap().title, "review pr"); assert_eq!(heap.pop().unwrap().title, "write docs"); assert!(heap.pop().is_none()); let mut earliest = BinaryHeap::from([Reverse(5), Reverse(2), Reverse(8)]); assert_eq!(earliest.pop(), Some(Reverse(2))); assert_eq!(earliest.peek().map(|r| r.0), Some(5)); }
|
深入阅读:双索引与容量
主存 Vec,按 ID 建派生索引
需要同时按展示顺序遍历与按 ID 定位时,可以用 Vec<Task> 主存配 HashMap<Id, usize>。这只是在平均查找成本上提供另一入口,不承诺具体响应时间。删除、插入、排序和 swap_remove 都可能改变位置,必须同步维护或重建索引;否则下标仍合法也可能指向错误任务。
独立示例(仅标准库;替换 src/main.rs;执行 cargo +stable run)。
1 2 3 4 5 6 7 8 9
| use std::collections::HashMap;
fn main() { let tasks = [(10u64, "write docs"), (20, "fix build"), (30, "review pr")]; let index_of: HashMap<_, _> = tasks.iter() .enumerate().map(|(i, (id, _))| (*id, i)).collect(); let pos = index_of.get(&20).copied().expect("id 20 was indexed"); assert_eq!(tasks[pos].1, "fix build"); }
|
当修改频繁,改用 HashMap<Id, Task> 主存与 Vec<Id> 顺序目录也可行。无论哪种设计,都先确定真实数据源和派生视图,再承担同步成本。
len、capacity 与收缩
len 是元素个数,capacity 是不重新分配能容纳的元素数。Vec 不承诺特定扩容倍数;尾部 push 的摊销复杂度不等于每次 push 都是常数成本。reserve(n) 保证至少还能容纳 n 个,shrink_to_fit 尝试收缩,但分配器可能保留富余容量,不保证严格缩小或等于 len。零大小元素的容量行为另有特殊规则。
独立示例(仅标准库;替换 src/main.rs;执行 cargo +stable run)。
1 2 3 4 5 6 7 8 9 10 11
| fn main() { let mut ids: Vec<u64> = Vec::new(); assert_eq!(ids.capacity(), 0); ids.reserve(10); assert!(ids.capacity() >= 10); ids.extend(0..200); ids.truncate(10); ids.shrink_to_fit(); assert_eq!(ids, (0..10).collect::<Vec<_>>()); assert!(ids.capacity() >= ids.len()); }
|
自测
- 稳定列出标签计数时,HashMap 与 BTreeMap 各如何处理?
remove、swap_remove 对双索引有什么影响?- 未排序的
[1, 2, 1] 调用 dedup 会发生什么? entry 能否代替线程同步?自定义键要满足什么契约?- 为什么不能断言
shrink_to_fit 后 capacity == len?
参考答案
- HashMap 收集后排序;BTreeMap 按键遍历即可。
- 前者移动后续位置,后者移动尾元素到被删位置;两者都须维护索引。
- 没有相邻重复,内容不变;若要全局去重可先排序或用 Set。
- 不能。HashMap 要求相等键同哈希,BTreeMap 要求一致全序。
- 收缩是尽力请求,分配器可能保留富余容量;内容与
capacity >= len 才是应检查的契约。