课程概览 · 第 11 章

上一篇:泛型与 trait:复用行为,同时保持接口清晰

下一篇:迭代器与闭包:让数据转换可组合

任务列表按顺序展示、按 ID 查找、统计标签、按优先级调度,是四种不同的访问模式。先确定访问方式、顺序要求与修改成本,再比较容器;不要只凭一行理论复杂度选型。本章示例各自独立,不沿用前面章节的完整状态模型。

按顺序、按键、去重和优先级访问时的集合选择,以及 entry、迭代所有权和容量管理
图:先匹配访问模式,再决定元素借用、修改或移出的方式。

前置知识与学习目标

需要理解所有权、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)
LIFOVec<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());
}

自测

  1. 稳定列出标签计数时,HashMap 与 BTreeMap 各如何处理?
  2. remove、swap_remove 对双索引有什么影响?
  3. 未排序的 [1, 2, 1] 调用 dedup 会发生什么?
  4. entry 能否代替线程同步?自定义键要满足什么契约?
  5. 为什么不能断言 shrink_to_fit 后 capacity == len?

参考答案

  1. HashMap 收集后排序;BTreeMap 按键遍历即可。
  2. 前者移动后续位置,后者移动尾元素到被删位置;两者都须维护索引。
  3. 没有相邻重复,内容不变;若要全局去重可先排序或用 Set。
  4. 不能。HashMap 要求相等键同哈希,BTreeMap 要求一致全序。
  5. 收缩是尽力请求,分配器可能保留富余容量;内容与 capacity >= len 才是应检查的契约。