基础数据结构深度解析 — 从零精通算法与数据结构——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 用于可能不存在的返回值,干净地处理"未找到"的情况

本章小结

  1. 数组 O(1) 随机访问,缓存友好;链表 O(1) 插入删除但缓存不友好——实际性能差 20-50 倍
  2. 单调栈在 O(n) 时间内解决"下一个更大/更小元素"类问题——因为每个元素最多进栈一次、出栈一次
  3. 哈希表的核心是冲突处理:链地址法(最常用)、开放寻址(缓存友好)
  4. 负载因子 α = n/m,通常在 α > 0.7-1.0 时扩容 rehash
  5. 全域哈希通过随机化选哈希函数保证了期望 O(1)

关键术语

术语 释义
缓存局部性 相邻内存地址的数据倾向于一起被访问的性质,数组天然利用这一性质
单调栈 始终保持单调递增/递减的栈,用于解决"下一个更大/更小"问题
负载因子 α = n/m,哈希表中元素数与槽数之比,决定冲突概率和扩容时机
链地址法 每个哈希槽维护一个链表存储冲突元素
全域哈希 从一族函数中随机选取哈希函数,保证任意两 key 冲突概率 ≤ 1/m
摊还分析 分析操作序列的平均代价,而非单次最坏代价
posted @ 2026-06-22 01:09  Yobeeo  阅读(9)  评论(0)    收藏  举报