#003. 数据结构 II:哈希表与二叉搜索树
#学习目标
「平均 O(1) 的查找」有两条技术路线:哈希表用散列函数把键直接映射到桶;搜索树用有序性把查找变成沿高度下降的决策。两条路线的失败方式完全不同——哈希怕碰撞,树怕长歪——而面试官恰恰最爱问失败方式:你的 unordered_map 什么时候退化成 O(n)?你的 BST 插入什么序列会变成链表?
本章把两条路线的机制、代价与反例都推到底,然后解决一个所有树题的地基问题:四种遍历(前序、中序、后序、层序)的递归与迭代实现。遍历是「把树的二维结构压成一维序列」的标准接口,后续 BST 判合法、按前序中序重建、图章节的 DFS/BFS 全都建立在这套基本功上。
桶数组 + 散列函数把查找降到平均 \(\Theta(1)\),代价是无序、最坏 \(\Theta(n)\)、以及对哈希函数质量的依赖。
中序遍历有序是 BST 的灵魂;查找、插入、删除都是 \(O(h)\),平衡时 \(h = \Theta(\log n)\),退化为链时 \(h = \Theta(n)\)。
三种深度优先序(前/中/后)加一种广度优先序(层序),递归版三行、迭代版考栈功力,层序版考队列功力。
#哈希表:装载因子、冲突与复杂度反例
哈希表(hash table)的核心是一个数组,外加一个哈希函数 \(h\) 把键映射到下标:键 \(k\) 存在第 \(h(k) \bmod m\) 个桶里(\(m\) 为桶数)。理想情况下不同的键落进不同的桶,查找就是「算下标 + 访问数组」,\(\Theta(1)\)。问题在于键的可能性远多于桶:两个不同键可能得到同一个下标,这就是冲突(collision),哈希表的所有工程细节都是冲突处理。
装载因子:\(\alpha = n / m\),即每桶平均元素数。链地址法下期望链长就是 \(\alpha\),查找期望 \(\Theta(1 + \alpha)\)——想要 O(1),就要让 \(\alpha\) 是常数(比如控制在 1 以内)。
链地址法(separate chaining):每个桶挂一条链表,冲突元素追加到链上。删除简单,装载因子可以超过 1。
开放寻址(open addressing):冲突后按探测序列找下一个空槽:线性探测(加 1,易聚集)、二次探测、双重哈希。装载因子必须 \(\lt 1\),且越大性能越差。
再哈希(rehash):\(\alpha\) 超过阈值时把桶数翻倍,所有元素按新 \(m\) 重新分布。与002 章动态数组完全同构:偶发 \(\Theta(n)\),均摊 \(\Theta(1)\)。
「平均 \(\Theta(1)\)」的前提值得单独立一段:它假设键在桶间简单均匀散布。一旦哈希函数质量差(或被恶意构造——hash flooding 攻击),所有键挤进同一个桶,链地址法退化为单链表,每次操作 \(\Theta(n)\)。这是一个必须能随口给出的复杂度反例。工程上的三条防线:换更好的哈希函数(乘大质数、混合位)、对每次运行引入随机种子(让攻击者无法预构造)、以及超长链转平衡树(Java 8 起桶内链表超过 8 个节点转红黑树,最坏保底 \(\Theta(\log n)\))。
C++ 的 std::unordered_map 是哈希表:平均 O(1)、无序;std::map 是红黑树:\(\Theta(\log n)\)、按键有序、支持 lower_bound 等范围查询。选择口径一句话:要「点查」用 unordered_map,要「有序遍历或前驱后继查询」用 map。把两者答反或混说,是哈希题里最常见的扣分点。
#二叉搜索树:中序有序与退化风险
二叉搜索树(binary search tree,BST)对每个节点施加递归约束:左子树所有键 < 根的键 < 右子树所有键(注意是与整棵子树比较,不是只与孩子比较——例题 2 的经典错解就错在这里)。这一约束直接给出两个果实:
怎么读:一次比较淘汰一半候选(平衡时),与二分查找同构,代价 \(O(h)\)。第二个果实是中序遍历有序:中序是「左、根、右」,而 BST 里左 ≤ 根 ≤ 右,对子树递归归纳即得整树中序升序。这个性质把一大批问题转成线性扫描:验证 BST、找第 k 小、找众数、合并两棵 BST,套路都是「中序遍历 + 指针维护前驱」。
复杂度的全部悬念在高度 \(h\)。随机顺序插入时期望 \(h = \Theta(\log n)\)(与快排的期望分析同构);但按有序序列插入时,每个新键都比之前所有键大,一路向右,树退化成链表,\(h = \Theta(n)\),查找退化 O(n)——这是 BST 的标准反例,也是平衡树(AVL、红黑树)存在的理由:旋转操作把 \(h\) 强行压在 \(\Theta(\log n)\),std::map 红黑树即此类。
删除操作分三种情况,面试要求能口述:叶子直接摘;只有一个孩子让孩子顶替;两个孩子用中序后继(右子树最左节点,即右子树最小键)或中序前驱顶替再删除那个后继——后继至多一个右孩子,问题被降级成前两种情况。
节点值 5,右子树根是 6、6 的左孩子是 3:对 6 而言 3 合法(小于 6),但 3 位于 5 的右子树里,违反「右子树所有键大于 5」。所以验证 BST 不能只比较节点和孩子,必须把祖先传下来的上下界一路收窄,或干脆中序遍历查严格递增。
#四种遍历:递归与迭代的双实现
三种深度优先序的区别只是「访问根的时机」:前序(根左右)、中序(左根右)、后序(左右根)。递归实现是同一个模板换一行位置:
struct TreeNode {
int val;
TreeNode *left, *right;
TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
};
void traverse(TreeNode* node) { // 递归三序统一模板
if (node == nullptr) return;
// preOrder.push_back(node->val); // 放这里 = 前序
traverse(node->left);
// inOrder.push_back(node->val); // 放这里 = 中序
traverse(node->right);
// postOrder.push_back(node->val);// 放这里 = 后序
}
迭代版考的是对栈的理解。前序最直观:访问节点后先压右孩子再压左孩子,出栈顺序就是左先右后;中序要「先沉到最左再回溯」;后序可以用「根右左」反转得到。层序遍历(level-order)不用栈用队列,天然按层分组,是005 章BFS 在树上的版本。
#include <vector>
#include <stack>
// 前序迭代版:根右左压栈,出栈即左先
std::vector<int> preorder(TreeNode* root) {
std::vector<int> out;
std::stack<TreeNode*> st;
if (root) st.push(root);
while (!st.empty()) {
TreeNode* u = st.top(); st.pop();
out.push_back(u->val); // 出栈即访问
if (u->right) st.push(u->right); // 右先压
if (u->left) st.push(u->left); // 左后压
}
return out;
}
复制树、序列化树、构造答案自顶向下时用。迭代:栈,先压右后压左。
BST 上得到升序序列。迭代:沿左链入栈,弹出访问后转向右子树。
自底向上汇总信息(树高、子树和、销毁树)时用。迭代:按「根右左」遍历再整体反转。
按深度分层处理。队列实现;每层开始时记录队列长度即为该层节点数。
口诀化的记忆点:递归版「换个位置放访问」,迭代版「栈记住回来的路」,层序版「队列按层排队」。例题 3 会把中序迭代版完整写出来并解释它为什么对。
#例题详解
例题 1:手写一个链地址法哈希表(int 键到 int 值),支持 put/get,并在装载因子超过 1 时扩容。
建模。桶数组 buckets_,每桶一条 pair 链;哈希函数用 Knuth 乘法哈希(键乘大奇数再截断)把整数打散;size_ 记元素数。不变量:\(\alpha = \text{size\_} / \text{buckets\_}.size() \le 1\),即将越界时翻倍再哈希。
代码。
#include <cstddef>
#include <vector>
#include <list>
#include <utility>
class IntHashMap {
public:
IntHashMap() : buckets_(8), size_(0) {}
void put(int key, int value) {
if (size_ + 1 > buckets_.size()) rehash(); // 维持装载因子 <= 1
auto& chain = buckets_[index(key)];
for (auto& kv : chain)
if (kv.first == key) { kv.second = value; return; } // 覆盖旧值
chain.push_back({key, value});
++size_;
}
bool get(int key, int& out) const {
for (const auto& kv : buckets_[index(key)])
if (kv.first == key) { out = kv.second; return true; }
return false;
}
private:
static std::size_t hashInt(int key) {
return (std::size_t)((unsigned long long)(unsigned)key * 2654435761u);
}
std::size_t index(int key) const { // 桶下标 = 哈希对桶数取模
return hashInt(key) % buckets_.size();
}
void rehash() { // 桶数翻倍,全部重新分布
std::vector<std::list<std::pair<int,int>>> fresh(buckets_.size() * 2);
for (auto& chain : buckets_)
for (auto& kv : chain)
fresh[hashInt(kv.first) % fresh.size()].push_back(kv);
buckets_.swap(fresh);
}
std::vector<std::list<std::pair<int,int>>> buckets_;
std::size_t size_;
};
数值演示。8 个桶、乘法哈希下依次 put(1,10)、put(9,90):1 与 9 模 8 不同大概率不同桶,各自 O(1) 命中;若某键碰撞(如 1 与 9 恰同余),后者挂进同一条链,get 沿链线性比对。
复杂度。哈希均匀时期望 \(\Theta(1)\):定位桶是取模,链长期望 \(\alpha \le 1\)。rehash 单次 \(\Theta(n)\)、均摊 \(\Theta(1)\)(论证与002 章倍增完全相同)。最坏 \(\Theta(n)\):所有键同桶。
边界。负键:先转无符号再乘,避免有符号溢出的未定义行为;键覆盖(同键 put 更新而非重复插入);开放寻址若改为本解必须处理删除(墓碑标记或周期性重建),链地址法无此负担——这也是教学实现选链地址的原因。
面试怎么讲。先写 \(\alpha = n/m\) 与「期望链长 \(\alpha\)」,再讲链地址与开放寻址的取舍,最后主动给最坏反例(同桶退化 O(n))与工程对策(随机种子、链转红黑树)。这一套讲完,哈希表部分基本无可追问。
例题 2:验证二叉搜索树。给定二叉树根节点,判断它是否是合法 BST(键互异)。
建模。错误做法是「每个节点与左右孩子比较」——它只验证局部,放过深层违规(见知识点反例:5 的右子树里出现 3)。正确做法有二:(a) 中序遍历必须严格递增;(b) 递归传递祖先上下界,每个节点的键必须落在开区间 \((lo, hi)\) 内。
代码(解法一:中序 + 前驱指针)。
bool inorder(TreeNode* node, TreeNode*& prev) {
if (node == nullptr) return true;
if (!inorder(node->left, prev)) return false;
if (prev != nullptr && prev->val >= node->val)
return false; // 中序必须严格递增
prev = node;
return inorder(node->right, prev);
}
bool isValidBST(TreeNode* root) {
TreeNode* prev = nullptr; // 中序里的前一个访问值
return inorder(root, prev);
}
代码(解法二:上下界递归)。
#include <climits>
#include <cstdlib>
bool check(TreeNode* node, long long lo, long long hi) {
if (node == nullptr) return true;
if (node->val <= lo || node->val >= hi)
return false; // 键必须落在开区间 (lo, hi)
return check(node->left, lo, node->val) &&
check(node->right, node->val, hi);
}
// 调用:check(root, LLONG_MIN, LLONG_MAX)
数值演示。树 [5,1,4,null,null,3,6]:节点 4 的左孩子 3 与 4 局部合法,但 3 在 5 的右子树中,违反「右子树全部大于 5」。解法一里中序序列 1,5,3,4,6 在 5 → 3 处递减,立即判负;解法二里 3 落在区间 \((5, 4)\) 外同样判负。两法结论一致:不是合法 BST。
复杂度。两解均为时间 \(O(n)\)(每节点访问一次)、空间 \(O(h)\) 递归栈,最坏链状 \(\Theta(n)\)。
边界。键取到 INT_MIN/INT_MAX 时解法二用 int 边界会误判,必须用 long long(或改传「前驱指针」从根上规避);空树与单节点是合法 BST;相等键(重复值)按本题口径非法,比较必须用严格大于/小于。
面试怎么讲。先主动给出「只比孩子」的反例,展示你知道陷阱在哪,再写任一解法;说一句「中序有序是 BST 的充要条件」,把性质与算法连起来。
例题 3:二叉树的中序遍历,迭代实现(不使用递归)。
建模。递归中序隐式使用了调用栈;迭代化就是把「回来的路」显式放进一个栈。不变量:栈中从底到顶是「当前节点的一串祖先 + 各祖先的左链」;外层循环条件「curr 非空或栈非空」覆盖「正在下沉」与「正在回溯」两种状态。
代码。
#include <vector>
#include <stack>
std::vector<int> inorderTraversal(TreeNode* root) {
std::vector<int> order;
std::stack<TreeNode*> st;
TreeNode* curr = root;
while (curr != nullptr || !st.empty()) {
while (curr != nullptr) { // 沿左链一路下沉入栈
st.push(curr);
curr = curr->left;
}
curr = st.top(); st.pop(); // 左链尽头,弹出即最左未访问节点
order.push_back(curr->val); // 访问(左已尽,轮到根)
curr = curr->right; // 转向右子树,重复上述过程
}
return order;
}
数值演示。树 [1,null,2,3](1 的右孩子 2,2 的左孩子 3):下沉阶段只有 1 入栈;弹出访问 1;转向右子树 2,下沉 2 → 3 入栈;弹出访问 3,再弹出访问 2。输出 [1,3,2],正是中序。
复杂度。时间 \(O(n)\):每个节点恰好入栈、出栈各一次。空间 \(O(h)\):栈深等于树高,平衡树 \(\Theta(\log n)\),链状树 \(\Theta(n)\)。
边界。空树直接返回空;只有右链(一直不下沉直接弹出)与只有左链(一直下沉)是两个极端对拍用例。后序的迭代版可由「根右左再反转」得到:把本解的 left/right 交换得到根右左序,最后 reverse 一次。
面试怎么讲。讲不变量:「内层 while 负责下沉到最左,弹出即访问,然后去右子树」——三句话讲完循环结构再写代码,写错率最低。若被追问 Morris 遍历(线索化,\(O(1)\) 空间),能说出「用空闲右指针回指后继」的思想即可。
例题 4:已知一棵键互异二叉树的前序遍历与中序遍历,重建这棵树。
建模。前序第一个元素必是根;在中序里找到根的位置,其左边全是左子树的中序、右边全是右子树的中序;再由左子树的元素个数切出左子树的前序。递归下 go,子问题结构不变。用哈希表把「键 → 中序下标」预处理成 \(O(1)\) 查询。
代码。
#include <vector>
#include <unordered_map>
TreeNode* build(const std::vector<int>& pre, int pl, int pr, int il, int ir,
std::unordered_map<int,int>& pos) {
if (pl > pr) return nullptr; // 空区间
TreeNode* root = new TreeNode(pre[pl]); // 前序首元素是根
int k = pos[pre[pl]]; // 根在中序中的切分点
int leftLen = k - il; // 左子树元素个数
root->left = build(pre, pl + 1, pl + leftLen, il, k - 1, pos);
root->right = build(pre, pl + leftLen + 1, pr, k + 1, ir, pos);
return root;
}
TreeNode* buildTree(const std::vector<int>& pre, const std::vector<int>& in) {
std::unordered_map<int,int> pos; // 键 -> 中序下标
for (int i = 0; i < (int)in.size(); ++i) pos[in[i]] = i;
return build(pre, 0, (int)pre.size() - 1, 0, (int)in.size() - 1, pos);
}
数值演示。pre = [3,9,20,15,7],in = [9,3,15,20,7]:根 3 在中序下标 1,左子树长度 1。左子树:pre[9]、in[9] → 叶子 9;右子树 pre[20,15,7]、in[15,20,7]:根 20,切分出左 15、右 7。重建结果与原树一致。
复杂度。时间 \(O(n)\):每个节点建一次,切分点查询是哈希 \(O(1)\)(若不用哈希而在中序里线性找根,总代价退化到 \(O(n^2)\) 链状最坏)。空间 \(O(n)\) 哈希 + \(O(h)\) 递归栈。
边界。两数组必须同长且为同一棵树的有效遍历对(面试可声明此为前置条件,fail-fast 校验长度);键必须互异,否则哈希映射歧义;只有左链或只有右链的树对应「左长度为 0」或「右长度为 0」的极端切分。顺带的知识点:后序 + 中序同样可重建(后序末元素是根),但前序 + 后序不能唯一重建——只有一个孩子的节点无法区分左右,这是高频追问。
面试怎么讲。先讲「前序定根、中序切左右」这一句核心,再讲用哈希把找根降到 O(1),最后主动提「前序+后序不唯一」的反例,题目就完整了。
#误区与边界
正确口径:简单均匀假设下平均 \(\Theta(1)\),最坏 \(\Theta(n)\)(同桶/聚集),再哈希单次 \(\Theta(n)\) 均摊 \(\Theta(1)\)。另外哈希表无序——「输出按序」「找最接近键」这类需求哈希天然做不了,要用 std::map 的 lower_bound。
一是只比孩子不比祖先(例题 2 反例);二是用 INT_MIN/INT_MAX 当边界遇上取满值的键,溢出比较失效——用 long long 或前驱指针方案。BST 允许重复与否要和面试官确认,口径不同代码不同(相等去左还是右必须一致)。
中序的外层条件是「curr 非空或栈非空」,漏掉前半段就无法进入初始下沉、漏掉后半段就提前退出;前序要先压右后压左(出栈才是左右序);层序想分层要在每轮开始快照队列长度,否则层信息丢失。
高频追问清单:std::unordered_map 与 std::map 的选型(点查 vs 有序/范围查询);开放寻址删除为什么麻烦(墓碑,探测链被打断);字符串哈希怎么做(多项式滚动哈希,配合取模);为什么红黑树而不是 AVL(红黑旋转少、写密集更稳);BST 怎么找第 k 小(中序遍历数到第 k 个)。层序的队列实现在005 章BFS 一节还会再正式出现一次。
#检查清单
- 我能写出装载因子 \(\alpha = n/m\),说明链地址法期望链长为 \(\alpha\)、查找期望 \(\Theta(1+\alpha)\)。
- 我能比较链地址法与开放寻址(线性/二次/双重哈希)的取舍,并解释开放寻址要求 \(\alpha \lt 1\)。
- 我能给出哈希表最坏 \(\Theta(n)\) 的反例与三条工程对策(好哈希、随机种子、链转红黑树)。
- 我能证明(归纳)BST 中序遍历严格升序,并说出有序插入退化为链表的反例。
- 我能口述 BST 删除的三种情况与「中序后继顶替」的降级策略。
- 我能默写中序遍历的迭代版(左链下沉、弹出访问、转向右子树)并说明循环条件为何缺一不可。
- 我能用前序 + 中序重建二叉树,并解释为什么前序 + 后序不能唯一重建。
- 我能在 unordered_map 与 map 之间按「点查 vs 有序范围查询」正确选型。