基础数据结构深度解析 — 从零精通算法与数据结构——Google 面试系统备战 第4篇
第4章:基础数据结构深度解析
本章目标
读完本章你会:
- 解释数组 vs 链表在缓存层面的性能差异(不只是理论复杂度)
- 用单调栈解决"下一个更大元素"类问题,理解为什么它是 O(n)
- 理解哈希表的三种冲突解决策略及其时间/空间代价
- 阐述全域哈希的概念及其为什么能保证期望 O(1)
- 入门摊还分析——理解"偶尔贵一次,但平均很便宜"
知识讲解
从一个生活例子开始
想象你在图书馆找书。
数组 = 所有书放在连续的书架上,编号 1-1000。
→ 要拿第 500 本书,直接走到第 500 格——O(1)。但要插入一本新书在第 500 格位置——后面的 501-1000 要全部往后挪一格——O(n)。
链表 = 每本书里有张纸条写着"下一本在哪"。
→ 要插入一本新书,只需要改前后两本书的纸条——O(1)。但要找第 500 本,需要从第一本开始跟着纸条翻了 499 本——O(n)。
哈希表 = 每本书的类型(历史/科学/小说)决定它放在哪个书架。
→ 找"三国演义"→ 历史类 → 在历史书架二分/顺序找——接近 O(1)。但所有书都是同一类型时会退化。
这就是三种基础数据结构的本质差异:数组赢在随机访问,链表赢在动态插入,哈希表赢在任意查找——但都需要特定条件。
工作原理
4.1 数组 vs 链表:缓存的真相
大多数教程告诉你"数组 O(1) 访问,链表 O(n) 访问"。但这只是 RAM 模型下的回答。在现代计算机上,还有一个巨大的隐藏因素:
CPU 缓存行 = 64 字节。 访问一个元素时,CPU 会把相邻的 64 字节一起加载到缓存。
- 数组: 连续内存,访问
A[0]时A[1..15](假设 4 字节 int)已经在你 CPU 的 L1 缓存里了 - 链表: 每个节点在堆上随机分配,访问下一个节点几乎一定缓存未命中,要走走内存 → 慢 50-200 倍
// 实验:遍历 100 万个 int
// 数组版本:~1ms(全部在缓存中)
// 链表版本:~10-20ms(每次都缓存未命中)
面试提示: 当面试官问"为什么用 vector 而不是 list",除了复杂度分析,提一嘴"cache locality"是加分项。
4.2 栈:不只是 push/pop
栈的最重要应用是保持顺序和消除回溯。三个经典面试场景:
1. 括号匹配: 遇到左括号 push,右括号 pop 并验证。O(n)。
2. 表达式求值: 中缀转后缀(Dijkstra 双栈算法)——操作数栈 + 运算符栈。
3. 单调栈 —— 本章重点: 维护一个始终保持单调(递增或递减)的栈。
单调栈的核心思想: 遍历数组时,当新元素 "破坏" 了栈的单调性,就不断弹出栈顶——每个元素恰好进栈一次、出栈一次,因此总时间是 O(n) 而不是 O(n²)。这是摊还分析的经典案例。
场景:找每个元素右边第一个比它大的元素(Next Greater Element)
// 输入:[2, 1, 2, 4, 3]
// 输出:[4, 2, 4, -1, -1] (-1 表示没有更大元素)
vector<int> NextGreaterElement(const vector<int>& nums) {
int n = nums.size();
vector<int> result(n, -1);
stack<int> st; // 存的是索引(不是值),方便回写结果
for (int i = 0; i < n; ++i) {
// 当前元素比栈顶大 → 当前元素就是栈顶元素的"下一个更大元素"
while (!st.empty() && nums[i] > nums[st.top()]) {
result[st.top()] = nums[i];
st.pop();
}
st.push(i);
}
// 留在栈里的元素:右边没有比它大的
return result;
}
为什么是 O(n)? 每个元素最多进栈一次、出栈一次。while 循环总的执行次数不超过 n。
4.3 哈希表:从碰撞到完美
哈希表的目标:用一个函数 h(key) 把任意 key 映射到区间 [0, m-1],实现 O(1) 操作。
核心问题是冲突——两个不同的 key 映射到同一个槽。
三种冲突解决策略:
| 策略 | 思想 | 查找时间 | 空间 |
|---|---|---|---|
| 链地址法 | 每个槽是一个链表 | O(1 + α) 期望 | O(n + m) |
| 开放寻址-线性探测 | 冲突时往后找空位 | 退化时 O(n) | O(m) |
| 开放寻址-二次/双重哈希 | 探测序列分散避免聚集 | 更接近 O(1) | O(m) |
其中 α = n/m 称为负载因子。当 α > 0.7 时通常需要扩容(rehash)。
为什么期望 O(1)? —— 全域哈希
如果攻击者知道你的 h(key) = key % m,可以精心构造一组 key 全部映射到同一个槽,退化到 O(n)。
全域哈希的解决方案:从一族哈希函数中随机选一个。数学上可证明:对于任意两个不同的 key,它们发生冲突的概率 ≤ 1/m。这意味着:
- 期望链长度 = α
- 当 α ≤ 1 时,期望 O(1)
C++ 中的哈希表: std::unordered_map 使用链地址法 + 默认负载因子 1.0。提供 reserve() 预分配桶数,避免 rehash 开销。面试中如果需要自定义哈希,重载 std::hash<T> 或提供自定义 Hasher。
代码实战
在 algo_toolkit 中添加 structures.h——实现单调栈辅助类和简化版哈希表。
include/algo/structures.h(部分新内容)
#ifndef ALGO_STRUCTURES_H_
#define ALGO_STRUCTURES_H_
#include <cstddef>
#include <cstdint>
#include <functional>
#include <list>
#include <optional>
#include <utility>
#include <vector>
namespace algo {
// ========== 简易哈希表(链地址法) ==========
// 教学用途——清晰地展示哈希表的内部结构
// 生产中请使用 std::unordered_map
template <typename Key, typename Value>
class SimpleHashMap {
public:
explicit SimpleHashMap(std::size_t bucket_count = 16)
: buckets_(bucket_count), size_(0) {}
// 插入或更新
void Put(const Key& key, const Value& value) {
if (size_ >= buckets_.size() * kLoadFactor) {
Rehash(buckets_.size() * 2);
}
auto& bucket = GetBucket(key);
for (auto& [k, v] : bucket) {
if (k == key) {
v = value;
return;
}
}
bucket.emplace_back(key, value);
++size_;
}
// 查找
std::optional<Value> Get(const Key& key) const {
const auto& bucket = GetBucket(key);
for (const auto& [k, v] : bucket) {
if (k == key) return v;
}
return std::nullopt;
}
// 删除
bool Remove(const Key& key) {
auto& bucket = GetBucket(key);
for (auto it = bucket.begin(); it != bucket.end(); ++it) {
if (it->first == key) {
bucket.erase(it);
--size_;
return true;
}
}
return false;
}
std::size_t size() const { return size_; }
double load_factor() const {
return static_cast<double>(size_) / buckets_.size();
}
private:
static constexpr double kLoadFactor = 0.75;
std::size_t Hash(const Key& key) const {
return std::hash<Key>{}(key);
}
std::size_t BucketIndex(const Key& key) const {
return Hash(key) % buckets_.size();
}
auto& GetBucket(const Key& key) { return buckets_[BucketIndex(key)]; }
const auto& GetBucket(const Key& key) const { return buckets_[BucketIndex(key)]; }
void Rehash(std::size_t new_size) {
std::vector<std::list<std::pair<Key, Value>>> new_buckets(new_size);
for (auto& bucket : buckets_) {
for (auto& pair : bucket) {
auto new_idx = std::hash<Key>{}(pair.first) % new_size;
new_buckets[new_idx].emplace_back(std::move(pair));
}
}
buckets_ = std::move(new_buckets);
}
std::vector<std::list<std::pair<Key, Value>>> buckets_;
std::size_t size_;
};
} // namespace algo
#endif // ALGO_STRUCTURES_H_
代码要点:
- 链地址法用
std::list实现(虽然生产代码可能用std::vector更好——缓存局部性)- rehash 在负载因子 > 0.75 时触发——这是 Java HashMap 的默认值,经历大量工程验证
std::optional用于可能不存在的返回值,干净地处理"未找到"的情况
本章小结
- 数组 O(1) 随机访问,缓存友好;链表 O(1) 插入删除但缓存不友好——实际性能差 20-50 倍
- 单调栈在 O(n) 时间内解决"下一个更大/更小元素"类问题——因为每个元素最多进栈一次、出栈一次
- 哈希表的核心是冲突处理:链地址法(最常用)、开放寻址(缓存友好)
- 负载因子 α = n/m,通常在 α > 0.7-1.0 时扩容 rehash
- 全域哈希通过随机化选哈希函数保证了期望 O(1)
关键术语
| 术语 | 释义 |
|---|---|
| 缓存局部性 | 相邻内存地址的数据倾向于一起被访问的性质,数组天然利用这一性质 |
| 单调栈 | 始终保持单调递增/递减的栈,用于解决"下一个更大/更小"问题 |
| 负载因子 | α = n/m,哈希表中元素数与槽数之比,决定冲突概率和扩容时机 |
| 链地址法 | 每个哈希槽维护一个链表存储冲突元素 |
| 全域哈希 | 从一族函数中随机选取哈希函数,保证任意两 key 冲突概率 ≤ 1/m |
| 摊还分析 | 分析操作序列的平均代价,而非单次最坏代价 |

浙公网安备 33010602011771号