C++ 数据结构复习笔记
目录
- 复杂度分析
- 数组与字符串
- 链表
- 栈与队列
- 哈希表
- 树与二叉树
- [二叉搜索树 BST](#二叉搜索树 BST)
- [平衡二叉树 AVL](#平衡二叉树 AVL)
- 红黑树
- 堆与优先队列
- [图与遍历 BFS / DFS](#图与遍历 BFS / DFS)
- 排序算法
- 动态规划基础
- 数据结构面试概念考点
1. 复杂度分析
1.1 时间复杂度
| 复杂度 | 含义 | 常见算法 |
|---|---|---|
| O(1) | 常数 | 数组下标访问、哈希查找 |
| O(log n) | 对数 | 二分查找、平衡树查找 |
| O(n) | 线性 | 遍历数组、链表 |
| O(n log n) | 线性对数 | 归并排序、堆排序、快速排序(平均) |
| O(n²) | 平方 | 冒泡排序、选择排序、插入排序 |
| O(2ⁿ) | 指数 | 递归求解斐波那契(无记忆化) |
1.2 递归复杂度 --- 主定理
对于递归式 T(n) = a·T(n/b) + f(n):
| 情况 | 条件 | 复杂度 |
|---|---|---|
| 1 | f(n) = O(n^(log_b(a) - ε)) | T(n) = Θ(n^(log_b(a))) |
| 2 | f(n) = Θ(n^(log_b(a))) | T(n) = Θ(n^(log_b(a)) · log n) |
| 3 | f(n) = Ω(n^(log_b(a) + ε)) | T(n) = Θ(f(n)) |
常考例子:
cpp
// 二分查找: T(n) = T(n/2) + O(1) => O(log n)
// 归并排序: T(n) = 2T(n/2) + O(n) => O(n log n)
// 遍历二叉树: T(n) = 2T(n/2) + O(1) => O(n)
1.3 空间复杂度
指算法运行所需的额外存储空间,不包括输入数据本身。重点关注递归调用栈深度和临时数据结构的大小。
2. 数组与字符串
2.1 数据结构基本概念
数组 是一段连续 的内存空间,存储相同类型的元素,支持通过下标**O(1)**随机访问。
核心性质:
- 连续存储 → 缓存友好,遍历效率高
- 固定大小(C 风格数组)或动态扩容(
std::vector) - 中间插入/删除需要移动元素 → O(n)
2.2 STL array / vector 对比
| 特性 | std::array<T, N> |
std::vector<T> |
|---|---|---|
| 大小 | 编译期固定 | 动态可变 |
| 内存位置 | 栈上(或作为对象成员) | 堆上分配 |
| 随机访问 | O(1) | O(1) |
| 尾插/尾删 | --- | 均摊 O(1) |
| 中间插入/删除 | --- | O(n) |
cpp
int a[5] = {1, 2, 3, 4, 5};
std::array<int, 5> arr = {1, 2, 3, 4, 5};
std::vector<int> vec = {1, 2, 3};
vec.push_back(4); // 均摊 O(1)
vec.pop_back(); // O(1)
vec.insert(vec.begin() + 1, 10); // O(n) --- 后续元素全部后移
2.3 vector 扩容机制(面试重点)
cpp
// vs/g++ 扩容因子通常为 1.5x 或 2x
// size() = 当前元素个数 capacity() = 已分配空间能容纳的元素个数
// 扩容步骤: 分配新内存 → 拷贝/移动旧元素 → 释放旧内存
// 均摊复杂度 O(1) 的证明(聚合分析法 / 记账法)
std::vector<int> v;
v.reserve(100); // 预分配,避免多次扩容
为什么扩容因子选 1.5 或 2? --- 选 2 是经典的翻倍策略,选 1.5 可以让之前释放的内存块被复用,减少内存碎片。
2.4 基础操作模板
cpp
// ---------- 反转数组 ----------
void reverseArray(std::vector<int>& arr) {
int l = 0, r = arr.size() - 1;
while (l < r) std::swap(arr[l++], arr[r--]);
}
// ---------- 二分查找(有序数组)----------
int binarySearch(std::vector<int>& arr, int target) {
int l = 0, r = arr.size() - 1;
while (l <= r) {
int mid = l + (r - l) / 2; // 防溢出
if (arr[mid] == target) return mid;
else if (arr[mid] < target) l = mid + 1;
else r = mid - 1;
}
return -1;
}
3. 链表
3.1 数据结构基本概念
链表 由一系列节点 组成,每个节点包含数据域和指向下一节点的指针 。节点在内存中不必连续。
核心性质:
- 动态大小,不需要预分配
- 插入/删除节点只需修改指针 → O(1)(前提是已定位到插入位置)
- 不支持随机访问,查找必须从头遍历 → O(n)
- 额外指针开销
3.2 数组 vs 链表(概念对比,高频)
| 维度 | 数组 | 链表 |
|---|---|---|
| 内存布局 | 连续 | 分散,通过指针链接 |
| 随机访问 | O(1) | O(n) |
| 插入/删除 | O(n)(需移动元素) | O(1)(修改指针,已定位时) |
| 缓存友好 | 好(连续内存) | 差(内存跳跃) |
| 空间开销 | 无额外开销 | 每个节点额外存指针 |
| 扩容 | 需整体重新分配 | 按需逐个分配 |
| 大小 | 固定或动态扩容 | 完全动态 |
3.3 单链表节点定义
cpp
struct ListNode {
int val;
ListNode* next;
ListNode(int x) : val(x), next(nullptr) {}
ListNode(int x, ListNode* n) : val(x), next(n) {}
};
3.4 核心操作
cpp
// ---------- 反转链表(迭代)O(n), O(1) ----------
ListNode* reverseList(ListNode* head) {
ListNode *prev = nullptr, *cur = head;
while (cur) {
ListNode* nxt = cur->next; // 保存后继
cur->next = prev; // 反转指针
prev = cur;
cur = nxt;
}
return prev;
}
// ---------- 反转链表(递归)----------
ListNode* reverseListRecursive(ListNode* head) {
if (!head || !head->next) return head;
ListNode* newHead = reverseListRecursive(head->next);
head->next->next = head;
head->next = nullptr;
return newHead;
}
// ---------- 快慢指针找中点 ----------
ListNode* findMiddle(ListNode* head) {
ListNode *slow = head, *fast = head;
while (fast && fast->next) {
slow = slow->next;
fast = fast->next->next;
}
return slow;
}
// ---------- 判断环 ----------
bool hasCycle(ListNode* head) {
ListNode *slow = head, *fast = head;
while (fast && fast->next) {
slow = slow->next;
fast = fast->next->next;
if (slow == fast) return true;
}
return false;
}
// ---------- 找环的入口 ----------
ListNode* detectCycle(ListNode* head) {
ListNode *slow = head, *fast = head;
while (fast && fast->next) {
slow = slow->next;
fast = fast->next->next;
if (slow == fast) {
ListNode* ptr = head;
while (ptr != slow) { ptr = ptr->next; slow = slow->next; }
return ptr;
}
}
return nullptr;
}
// ---------- 合并两个有序链表 ----------
ListNode* mergeTwoLists(ListNode* l1, ListNode* l2) {
ListNode dummy(0);
ListNode* tail = &dummy;
while (l1 && l2) {
if (l1->val < l2->val) { tail->next = l1; l1 = l1->next; }
else { tail->next = l2; l2 = l2->next; }
tail = tail->next;
}
tail->next = l1 ? l1 : l2;
return dummy.next;
}
// ---------- 删除倒数第 N 个节点 ----------
ListNode* removeNthFromEnd(ListNode* head, int n) {
ListNode dummy(0, head);
ListNode *fast = &dummy, *slow = &dummy;
for (int i = 0; i <= n; ++i) fast = fast->next;
while (fast) { slow = slow->next; fast = fast->next; }
slow->next = slow->next->next;
return dummy.next;
}
3.5 链表常用技巧
| 技巧 | 原理 | 用途 |
|---|---|---|
| 快慢指针 | fast 每次走两步,slow 走一步 | 找中点、判环、找环入口 |
| 哑节点 (dummy) | 额外节点指向 head | 统一头节点处理,避免特殊判断 |
| 双指针(前后指针) | 一个指针固定落后另一个 n 步 | 倒数第 k 个、相交链表 |
4. 栈与队列
4.1 数据结构基本概念
栈 (Stack):**后进先出(LIFO)**的线性结构。只允许在一端(栈顶)进行插入和删除。类比:一摞盘子,后放的先取。
队列 (Queue):**先进先出(FIFO)**的线性结构。在一端(队尾)插入,在另一端(队头)删除。类比:排队,先到的先服务。
| 维度 | 栈 | 队列 |
|---|---|---|
| 操作原则 | LIFO(后进先出) | FIFO(先进先出) |
| 插入端 | 栈顶 (top) | 队尾 (back/rear) |
| 删除端 | 栈顶 (top) | 队头 (front) |
| 典型应用 | 函数调用栈、括号匹配、撤销操作 | BFS、消息队列、打印队列 |
4.2 STL 封装
cpp
std::stack<int> stk; // 默认 deque 底层
stk.push(1); stk.pop(); stk.top();
std::queue<int> q; // 默认 deque 底层
q.push(1); q.pop(); q.front(); q.back();
std::deque<int> dq; // 双端队列,头尾均可插入删除
dq.push_front(1); dq.push_back(2);
dq.pop_front(); dq.pop_back();
4.3 用栈实现队列 / 用队列实现栈
cpp
// ---------- 双栈实现队列 ----------
// 核心思想:in栈负责入队,out栈负责出队
// out为空时,将in的所有元素弹出并压入out,顺序反转,实现FIFO
class MyQueue {
std::stack<int> in, out;
void flush() {
if (out.empty())
while (!in.empty()) { out.push(in.top()); in.pop(); }
}
public:
void push(int x) { in.push(x); }
int pop() { flush(); int v = out.top(); out.pop(); return v; }
int peek() { flush(); return out.top(); }
bool empty() { return in.empty() && out.empty(); }
};
4.4 单调栈与单调队列
cpp
// ---------- 单调栈: 找每个元素右侧第一个比它大的元素 ----------
// 核心思想: 维护栈内元素值单调递减,遇到新元素时弹栈
std::vector<int> nextGreaterElement(std::vector<int>& nums) {
int n = nums.size();
std::vector<int> ans(n, -1);
std::stack<int> stk;
for (int i = 0; i < n; ++i) {
while (!stk.empty() && nums[stk.top()] < nums[i]) {
ans[stk.top()] = nums[i];
stk.pop();
}
stk.push(i);
}
return ans;
}
// ---------- 单调队列: 滑动窗口最大值 ----------
std::vector<int> maxSlidingWindow(std::vector<int>& nums, int k) {
std::deque<int> dq; // 存下标,队头=窗口最大值下标
std::vector<int> ans;
for (int i = 0; i < nums.size(); ++i) {
if (!dq.empty() && dq.front() <= i - k) dq.pop_front(); // 出窗口
while (!dq.empty() && nums[dq.back()] <= nums[i]) dq.pop_back(); // 维护递减
dq.push_back(i);
if (i >= k - 1) ans.push_back(nums[dq.front()]);
}
return ans;
}
5. 哈希表
5.1 数据结构基本概念
哈希表 (Hash Table) 通过哈希函数 将键 (key) 映射到存储位置(桶/bucket),实现均摊 O(1) 的查找、插入和删除。
核心组成:
- 哈希函数:将 key 转换为整数索引
- 桶数组:实际存储数据的数组
- 冲突解决机制:两个不同 key 映射到同一位置时的处理方法
5.2 STL 容器对比
| 容器 | 底层实现 | 查找 | 插入 | 有序性 |
|---|---|---|---|---|
std::unordered_map |
哈希表(拉链法) | O(1) 均摊 | O(1) 均摊 | 无序 |
std::unordered_set |
哈希表 | O(1) 均摊 | O(1) 均摊 | 无序 |
std::map |
红黑树 | O(log n) | O(log n) | 有序(按 key 排序) |
std::set |
红黑树 | O(log n) | O(log n) | 有序 |
5.3 哈希冲突解决方法
| 方法 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 拉链法 (Separate Chaining) | 每个桶指向一个链表,冲突的元素链到后面 | 装载因子可 >1,实现简单 | 额外指针开销,缓存不友好 |
| 开放寻址法 (Open Addressing) | 冲突时按一定规则找下一个空位(线性探测/平方探测/双重哈希) | 无指针开销,缓存友好 | 装载因子必须 <1,删除麻烦 |
5.4 装载因子 (Load Factor)
定义 :λ = n / m(n = 已存元素数,m = 桶数量)
- λ 过大 → 冲突增加,性能下降
- λ 过小 → 浪费空间
- unordered_map 默认
max_load_factor = 1.0 - 当 λ 超过阈值时触发 rehash → 扩容桶数组,重新映射所有元素
5.5 一个好的哈希函数的要求
- 确定性:相同 key 始终产生相同哈希值
- 均匀性:哈希值尽量均匀分布到所有桶
- 高效性:计算速度快
- 雪崩效应:key 的微小变化导致哈希值巨大变化
6. 树与二叉树
6.1 基本概念
① ← 根节点 (root)
/ \
② ③ ← ② 是 ① 的左孩子,③ 是右孩子
/ \ \
④ ⑤ ⑥ ← ④⑤⑥ 是叶子节点 (leaf),度为 0
深度 (depth): 从根到该节点的路径长度
高度 (height): 从该节点到最远叶子的路径长度
度 (degree): 一个节点拥有的子树个数
满二叉树: 每层节点数都达到最大值(每层都满)
完全二叉树: 除最后一层外全满,且最后一层节点从左到右连续排列
→ 堆的底层结构
6.2 二叉树重要性质(选择题高频)
- 第 i 层最多有
2^(i-1)个节点(i ≥ 1) - 深度为 k 的二叉树最多有
2^k - 1个节点 - 对任意非空二叉树,若叶子节点数为 n₀,度为 2 的节点数为 n₂,则 n₀ = n₂ + 1
- n 个节点的完全二叉树深度为
⌊log₂(n)⌋ + 1 - n 个节点能构成的不同二叉树个数 = 卡特兰数
C(2n, n) / (n+1)
6.3 二叉树节点定义
cpp
struct TreeNode {
int val;
TreeNode* left;
TreeNode* right;
TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
TreeNode(int x, TreeNode* l, TreeNode* r) : val(x), left(l), right(r) {}
};
6.4 四种遍历(必背)
遍历是二叉树最核心的操作,需要能口头描述遍历过程,以及知道每种遍历的典型用途。
1
/ \
2 3
/ \
4 5
前序 (根左右): 1 2 4 5 3
中序 (左根右): 4 2 5 1 3
后序 (左右根): 4 5 2 3 1
层序 (按层): 1 2 3 4 5
| 遍历方式 | 顺序 | 典型用途 |
|---|---|---|
| 前序 | 根→左→右 | 序列化、复制树、前缀表达式 |
| 中序 | 左→根→右 | BST 得到有序序列 |
| 后序 | 左→右→根 | 删除树(先删子节点)、后缀表达式、计算子树属性 |
| 层序 (BFS) | 逐层从左到右 | 最短路径、树宽、层级操作 |
cpp
// ---------- 1. 前序遍历 ----------
// 递归
void preorder(TreeNode* root) {
if (!root) return;
std::cout << root->val << " ";
preorder(root->left);
preorder(root->right);
}
// 迭代(栈:根入栈 → 弹出 → 先压右再压左)
void preorderIter(TreeNode* root) {
if (!root) return;
std::stack<TreeNode*> stk;
stk.push(root);
while (!stk.empty()) {
TreeNode* node = stk.top(); stk.pop();
std::cout << node->val << " ";
if (node->right) stk.push(node->right);
if (node->left) stk.push(node->left);
}
}
// ---------- 2. 中序遍历 ----------
// 递归
void inorder(TreeNode* root) {
if (!root) return;
inorder(root->left);
std::cout << root->val << " ";
inorder(root->right);
}
// 迭代(一路向左)
void inorderIter(TreeNode* root) {
std::stack<TreeNode*> stk;
TreeNode* cur = root;
while (cur || !stk.empty()) {
while (cur) { stk.push(cur); cur = cur->left; }
cur = stk.top(); stk.pop();
std::cout << cur->val << " ";
cur = cur->right;
}
}
// ---------- 3. 后序遍历 ----------
// 后序迭代:前序"根右左"再反转 → "左右根"
void postorderIter(TreeNode* root) {
if (!root) return;
std::stack<TreeNode*> stk;
std::vector<int> ans;
stk.push(root);
while (!stk.empty()) {
TreeNode* node = stk.top(); stk.pop();
ans.push_back(node->val);
if (node->left) stk.push(node->left);
if (node->right) stk.push(node->right);
}
std::reverse(ans.begin(), ans.end());
for (int v : ans) std::cout << v << " ";
}
// ---------- 4. 层序遍历 (BFS) ----------
void levelOrder(TreeNode* root) {
if (!root) return;
std::queue<TreeNode*> q;
q.push(root);
while (!q.empty()) {
TreeNode* node = q.front(); q.pop();
std::cout << node->val << " ";
if (node->left) q.push(node->left);
if (node->right) q.push(node->right);
}
}
// 层序按层输出
std::vector<std::vector<int>> levelOrderByLevel(TreeNode* root) {
std::vector<std::vector<int>> ans;
if (!root) return ans;
std::queue<TreeNode*> q;
q.push(root);
while (!q.empty()) {
int sz = q.size();
std::vector<int> level;
for (int i = 0; i < sz; ++i) {
TreeNode* node = q.front(); q.pop();
level.push_back(node->val);
if (node->left) q.push(node->left);
if (node->right) q.push(node->right);
}
ans.push_back(level);
}
return ans;
}
6.5 常见操作
cpp
// 最大深度
int maxDepth(TreeNode* root) {
if (!root) return 0;
return 1 + std::max(maxDepth(root->left), maxDepth(root->right));
}
// 判断对称二叉树(递归比较镜像位置)
bool isSymmetric(TreeNode* root) {
if (!root) return true;
std::function<bool(TreeNode*,TreeNode*)> dfs = [&](TreeNode* l, TreeNode* r) {
if (!l && !r) return true;
if (!l || !r) return false;
return l->val == r->val && dfs(l->left, r->right) && dfs(l->right, r->left);
};
return dfs(root->left, root->right);
}
// 翻转二叉树
TreeNode* invertTree(TreeNode* root) {
if (!root) return nullptr;
std::swap(root->left, root->right);
invertTree(root->left);
invertTree(root->right);
return root;
}
// 从前序+中序构造二叉树
TreeNode* buildTree(std::vector<int>& preorder, std::vector<int>& inorder) {
std::unordered_map<int, int> inMap;
for (int i = 0; i < inorder.size(); ++i) inMap[inorder[i]] = i;
std::function<TreeNode*(int,int,int,int)> build = [&](int pL, int pR, int iL, int iR) -> TreeNode* {
if (pL > pR) return nullptr;
int rootVal = preorder[pL];
int iMid = inMap[rootVal];
int leftCnt = iMid - iL;
TreeNode* root = new TreeNode(rootVal);
root->left = build(pL + 1, pL + leftCnt, iL, iMid - 1);
root->right = build(pL + leftCnt + 1, pR, iMid + 1, iR);
return root;
};
return build(0, preorder.size() - 1, 0, inorder.size() - 1);
}
7. 二叉搜索树 BST
7.1 数据结构基本概念
二叉搜索树 (Binary Search Tree) 是一棵二叉树,满足:
- 左子树所有节点的值 < 根节点的值
- 右子树所有节点的值 > 根节点的值
- 左右子树也分别是 BST
核心性质:中序遍历 BST 得到有序递增序列 ← 这是 BST 最重要的性质。
7.2 核心操作
cpp
// ---------- 查找 ----------
TreeNode* searchBST(TreeNode* root, int val) {
if (!root || root->val == val) return root;
return val < root->val ? searchBST(root->left, val)
: searchBST(root->right, val);
}
// ---------- 插入 ----------
TreeNode* insertBST(TreeNode* root, int val) {
if (!root) return new TreeNode(val);
if (val < root->val) root->left = insertBST(root->left, val);
else root->right = insertBST(root->right, val);
return root;
}
// ---------- 删除(三种情况)----------
TreeNode* deleteBST(TreeNode* root, int val) {
if (!root) return nullptr;
if (val < root->val) {
root->left = deleteBST(root->left, val);
} else if (val > root->val) {
root->right = deleteBST(root->right, val);
} else {
// 情况1/2: 只有一个孩子或没有孩子
if (!root->left) { TreeNode* r = root->right; delete root; return r; }
if (!root->right) { TreeNode* l = root->left; delete root; return l; }
// 情况3: 有两个孩子 → 找后继(右子树最小节点)替换
TreeNode* succ = root->right;
while (succ->left) succ = succ->left;
root->val = succ->val;
root->right = deleteBST(root->right, succ->val);
}
return root;
}
// ---------- 验证是否为合法 BST ----------
bool isValidBST(TreeNode* root, long long minV = LLONG_MIN, long long maxV = LLONG_MAX) {
if (!root) return true;
if (root->val <= minV || root->val >= maxV) return false;
return isValidBST(root->left, minV, root->val)
&& isValidBST(root->right, root->val, maxV);
}
7.3 BST 退化问题
| 操作 | 平均 | 最坏(退化成链表) |
|---|---|---|
| 查找 | O(log n) | O(n) |
| 插入 | O(log n) | O(n) |
| 删除 | O(log n) | O(n) |
退化原因:插入顺序不当(如插入 1,2,3,4,5 → 退化为右斜链表)。
解决方案:保证树保持平衡 → 这就是 AVL 树和红黑树的动机。
8. 平衡二叉树 AVL
8.1 数据结构基本概念
AVL 树 (以发明者 Adelson-Velsky 和 Landis 命名)是最早的自平衡二叉搜索树。
核心定义:任意节点的左右子树高度差不超过 1 。这个高度差称为平衡因子 (Balance Factor)。
平衡因子 = 左子树高度 - 右子树高度
合法范围: -1, 0, 1
8.2 旋转(自旋)--- AVL 的核心
当插入或删除导致某个节点的平衡因子超出 -1, 1 范围时,需要通过旋转 恢复平衡。旋转是理解平衡树的关键概念。
LL 型(左子树的左子树过高)→ 单次右旋 (Right Rotate)
z (BF=2) y (BF=0)
/ \ / \
(BF=1)y T4 x z
/ \ ======> / \ / \
x T3 T1 T2 T3 T4
/ \
T1 T2
RR 型(右子树的右子树过高)→ 单次左旋 (Left Rotate)
z (BF=-2) y (BF=0)
/ \ / \
T1 y (BF=-1) z x
/ \ ======> / \ / \
T2 x T1 T2 T3 T4
/ \
T3 T4
LR 型(左子树的右子树过高)→ 先左旋左子树,再右旋根
RL 型(右子树的左子树过高)→ 先右旋右子树,再左旋根
8.3 四种情况总结
| 类型 | 失衡模式 | 旋转操作 | 旋转次数 |
|---|---|---|---|
| LL | 在左子树的左子树插入 | 右旋根节点 | 1 |
| RR | 在右子树的右子树插入 | 左旋根节点 | 1 |
| LR | 在左子树的右子树插入 | 先左旋左孩子,再右旋根 | 2 |
| RL | 在右子树的左子树插入 | 先右旋右孩子,再左旋根 | 2 |
8.4 旋转实现
cpp
struct AVLNode {
int val, height;
AVLNode *left, *right;
AVLNode(int v) : val(v), height(1), left(nullptr), right(nullptr) {}
};
int getHeight(AVLNode* node) { return node ? node->height : 0; }
int getBalance(AVLNode* node) { return getHeight(node->left) - getHeight(node->right); }
// 右旋
AVLNode* rightRotate(AVLNode* y) {
AVLNode* x = y->left;
AVLNode* T2 = x->right;
x->right = y;
y->left = T2;
y->height = 1 + std::max(getHeight(y->left), getHeight(y->right));
x->height = 1 + std::max(getHeight(x->left), getHeight(x->right));
return x;
}
// 左旋
AVLNode* leftRotate(AVLNode* x) {
AVLNode* y = x->right;
AVLNode* T2 = y->left;
y->left = x;
x->right = T2;
x->height = 1 + std::max(getHeight(x->left), getHeight(x->right));
y->height = 1 + std::max(getHeight(y->left), getHeight(y->right));
return y;
}
8.5 带自平衡的插入
cpp
AVLNode* insertAVL(AVLNode* root, int val) {
// 1. 普通 BST 插入
if (!root) return new AVLNode(val);
if (val < root->val) root->left = insertAVL(root->left, val);
else root->right = insertAVL(root->right, val);
// 2. 更新高度
root->height = 1 + std::max(getHeight(root->left), getHeight(root->right));
// 3. 检查平衡并旋转
int balance = getBalance(root);
if (balance > 1 && val < root->left->val) return rightRotate(root); // LL
if (balance < -1 && val > root->right->val) return leftRotate(root); // RR
if (balance > 1 && val > root->left->val) { // LR
root->left = leftRotate(root->left);
return rightRotate(root);
}
if (balance < -1 && val < root->right->val) { // RL
root->right = rightRotate(root->right);
return leftRotate(root);
}
return root;
}
8.6 AVL vs 红黑树
| 特性 | AVL 树 | 红黑树 |
|---|---|---|
| 平衡条件 | 高度差 ≤ 1(严格) | 最长路径 ≤ 2×最短路径(宽松) |
| 树高 | 更低 | 略高(最多 2log(n+1)) |
| 查找性能 | 更快(更严格平衡) | 略慢 |
| 插入旋转 | 最多 2 次 | 最多 2 次(需染色) |
| 删除旋转 | 最多 O(log n) | 最多 3 次 |
| 适用场景 | 查多写少 | 写多 --- C++ std::map/std::set |
9. 红黑树
9.1 数据结构基本概念
红黑树 是一种自平衡二叉搜索树,通过给节点染色(红/黑)和一组规则来保证树的近似平衡。
9.2 五条性质(必背)
- 每个节点是红色 或黑色
- 根节点 始终是黑色
- 叶节点(NIL / 空节点)视为黑色
- 红色节点的两个直接孩子必须是黑色 → 不能出现连续的红节点
- 从任意节点到其每个后代叶节点的所有路径上,黑色节点数量相同
性质 4 和 5 共同保证:最长路径(红黑交替)≤ 最短路径(全黑)的 2 倍 → 树的近似平衡。
9.3 为什么不需要像 AVL 那么严格地旋转
红黑树放弃严格的高度平衡(AVL 的要求),改为维护颜色约束。这使得:
- 插入/删除时需要的结构调整更少
- 虽然树比 AVL 略高,但写操作平均更快
- 这是典型的以读换写的权衡
9.4 插入调平
新插入节点默认为红色(插入红色不违反性质 5,但可能违反性质 4)。
| 情况 | 叔节点颜色 | 处理方式 |
|---|---|---|
| case1 | 红色 | 父、叔变黑,祖父变红,问题节点上移到祖父继续处理 |
| case2(LR) | 黑色 + 当前节点是右孩子 | 对父节点左旋 → 转为 case3(LL) |
| case3(LL) | 黑色 + 当前节点是左孩子 | 父变黑、祖父变红,对祖父右旋 |
9.5 为什么 std::map 用红黑树而不是 AVL
- 写操作更少旋转:红黑树插入最多旋转 2 次,删除最多旋转 3 次;AVL 删除可能 O(log n) 次旋转
- STL 容器修改频繁,红黑树的颜色调整比 AVL 的高度调整开销更小
- 工程上已充分验证(Linux 内核、Java TreeMap 等都使用红黑树)
9.6 B 树 / B+ 树速查
| 特性 | B 树 | B+ 树 |
|---|---|---|
| 数据存放 | 所有节点都存数据 | 只有叶子节点存数据 |
| 叶子间链接 | 无 | 叶子节点用指针串成有序链表 |
| 内节点 | 存数据和索引 | 只存索引(充当路由) |
| 范围查询 | 需中序遍历 | 叶子链表 → O(log n + k) 极快 |
| 典型用途 | 文件系统 | 数据库索引(MySQL InnoDB) |
10. 堆与优先队列
10.1 数据结构基本概念
堆 (Heap) 是一种完全二叉树,分为两种:
- 大顶堆 (Max-Heap):每个节点的值 ≥ 其孩子节点的值 → 根是最大值
- 小顶堆 (Min-Heap):每个节点的值 ≤ 其孩子节点的值 → 根是最小值
核心性质:
- 堆只保证父子之间的大小关系,不保证兄弟节点之间的大小关系
- 堆用数组存储(利用完全二叉树的性质),索引 i 节点的左孩子为 2i+1,右孩子为 2i+2
- 插入/删除的时间复杂度为 O(log n)(上浮/下沉),建堆为 O(n)
10.2 priority_queue
cpp
// 默认大顶堆
std::priority_queue<int> pq;
pq.push(3); pq.push(1); pq.push(5);
pq.top(); // 5
pq.pop();
// 小顶堆
std::priority_queue<int, std::vector<int>, std::greater<int>> minPq;
10.3 手写堆
cpp
class MinHeap {
std::vector<int> h;
void siftUp(int i) {
while (i > 0) {
int p = (i - 1) / 2;
if (h[p] <= h[i]) break;
std::swap(h[p], h[i]); i = p;
}
}
void siftDown(int i) {
int n = h.size();
while (true) {
int smallest = i, l = 2 * i + 1, r = 2 * i + 2;
if (l < n && h[l] < h[smallest]) smallest = l;
if (r < n && h[r] < h[smallest]) smallest = r;
if (smallest == i) break;
std::swap(h[i], h[smallest]); i = smallest;
}
}
public:
void push(int v) { h.push_back(v); siftUp(h.size() - 1); }
void pop() { h[0] = h.back(); h.pop_back(); siftDown(0); }
int top() { return h[0]; }
bool empty() { return h.empty(); }
};
10.4 堆与二叉搜索树的区别
| 维度 | 堆 | 二叉搜索树 |
|---|---|---|
| 结构要求 | 完全二叉树 | 无形状要求 |
| 节点关系 | 父子有序,兄弟无序 | 左 < 根 < 右 |
| 查找任意元素 | O(n) --- 需遍历 | O(log n) 平均 |
| 获取最值 | O(1) | O(log n)(最左/最右) |
| 主要用途 | 优先队列、Top-K、堆排序 | 查找、有序遍历 |
11. 图与遍历 BFS / DFS
11.1 数据结构基本概念
图 (Graph) 由顶点集合 V 和边集合 E 组成,记作 G = (V, E)。
| 分类 | 定义 |
|---|---|
| 无向图 | 边没有方向,(u, v) 和 (v, u) 是同一条边 |
| 有向图 | 边有方向,u→v 和 v→u 是不同的边 |
| 带权图 | 每条边有权重 |
| 连通图 | 无向图中任意两个顶点之间都有路径 |
| 强连通图 | 有向图中任意两个顶点之间都有路径(双向可达) |
11.2 图的存储方式
| 方式 | 空间复杂度 | 适用场景 |
|---|---|---|
| 邻接矩阵 | O(V²) | 稠密图(边数接近 V²)、需要快速判断两点是否相连 |
| 邻接表 | O(V + E) | 稀疏图(边数远小于 V²)、遍历邻接点(最常用) |
cpp
int n = 5;
// 邻接矩阵
int graph[100][100] = {0};
graph[0][1] = graph[1][0] = 1;
// 邻接表(最常用)
std::vector<std::vector<int>> adj(n);
adj[0].push_back(1);
adj[1].push_back(0);
// 带权图
std::vector<std::vector<std::pair<int,int>>> wAdj(n);
wAdj[0].push_back({1, 5}); // 边 0→1, 权重 5
11.3 广度优先搜索 BFS
核心思想 :从起点出发,逐层向外扩展访问。使用队列实现。
关键性质 :BFS 在无权图中找到的一定是最短路径(边数最少)。
cpp
void bfs(int start, std::vector<std::vector<int>>& adj) {
std::vector<bool> visited(adj.size(), false);
std::queue<int> q;
q.push(start);
visited[start] = true;
while (!q.empty()) {
int u = q.front(); q.pop();
std::cout << u << " ";
for (int v : adj[u]) {
if (!visited[v]) {
visited[v] = true;
q.push(v);
}
}
}
}
// BFS 求无权图最短路径
std::vector<int> bfsShortestPath(int start, std::vector<std::vector<int>>& adj) {
int n = adj.size();
std::vector<int> dist(n, -1);
std::queue<int> q;
q.push(start);
dist[start] = 0;
while (!q.empty()) {
int u = q.front(); q.pop();
for (int v : adj[u]) {
if (dist[v] == -1) {
dist[v] = dist[u] + 1;
q.push(v);
}
}
}
return dist;
}
11.4 深度优先搜索 DFS
核心思想 :一条路走到底,走不通再回溯。使用栈(或递归)实现。
cpp
// 递归 DFS
void dfs(int u, std::vector<std::vector<int>>& adj, std::vector<bool>& visited) {
visited[u] = true;
std::cout << u << " ";
for (int v : adj[u])
if (!visited[v]) dfs(v, adj, visited);
}
// 迭代 DFS(显式栈)
void dfsIter(int start, std::vector<std::vector<int>>& adj) {
std::vector<bool> visited(adj.size(), false);
std::stack<int> stk;
stk.push(start);
while (!stk.empty()) {
int u = stk.top(); stk.pop();
if (visited[u]) continue;
visited[u] = true;
std::cout << u << " ";
for (int v : adj[u])
if (!visited[v]) stk.push(v);
}
}
11.5 BFS vs DFS(概念对比,高频)
| 维度 | BFS | DFS |
|---|---|---|
| 数据结构 | 队列 (queue) | 栈 或 递归调用栈 |
| 遍历顺序 | 按层(宽度优先) | 按深度(一条路到底) |
| 路径性质 | 无权图中找到最短路径 | 不保证最短 |
| 空间复杂度 | O(w),w = 图的最大宽度 | O(h),h = 图的深度 |
| 使用场景 | 最短路径、层级遍历、社交网络"几度好友" | 拓扑排序、连通分量、回溯、迷宫所有路径 |
| 递归能否实现 | 一般不递归(用队列迭代) | 递归实现自然简洁 |
11.6 经典图算法
cpp
// ---------- 拓扑排序 (Kahn 算法 / BFS 实现) ----------
// 概念: 对有向无环图(DAG),将所有顶点排成线性序列,
// 使对每条边 u→v,u 在序列中都出现在 v 之前
std::vector<int> topoSort(int n, std::vector<std::vector<int>>& adj) {
std::vector<int> inDeg(n, 0), ans;
for (int u = 0; u < n; ++u)
for (int v : adj[u]) inDeg[v]++;
std::queue<int> q;
for (int i = 0; i < n; ++i) if (inDeg[i] == 0) q.push(i);
while (!q.empty()) {
int u = q.front(); q.pop();
ans.push_back(u);
for (int v : adj[u])
if (--inDeg[v] == 0) q.push(v);
}
return ans.size() == n ? ans : std::vector<int>{}; // 返回空 = 图中有环
}
// ---------- Dijkstra 最短路径 ----------
// 限制: 边权必须非负
std::vector<int> dijkstra(int start, std::vector<std::vector<std::pair<int,int>>>& adj) {
int n = adj.size();
std::vector<int> dist(n, INT_MAX);
using P = std::pair<int,int>;
std::priority_queue<P, std::vector<P>, std::greater<P>> pq;
dist[start] = 0;
pq.push({0, start});
while (!pq.empty()) {
auto [d, u] = pq.top(); pq.pop();
if (d > dist[u]) continue;
for (auto& [v, w] : adj[u]) {
if (dist[u] + w < dist[v]) {
dist[v] = dist[u] + w;
pq.push({dist[v], v});
}
}
}
return dist;
}
12. 排序算法
12.1 各排序算法对比
| 算法 | 最佳 | 平均 | 最坏 | 空间 | 稳定 | 核心思想 |
|---|---|---|---|---|---|---|
| 冒泡 | O(n) | O(n²) | O(n²) | O(1) | 是 | 相邻比较,大的后移 |
| 选择 | O(n²) | O(n²) | O(n²) | O(1) | 否 | 每轮选最小放到前面 |
| 插入 | O(n) | O(n²) | O(n²) | O(1) | 是 | 将当前元素插入已排序部分 |
| 归并 | O(n log n) | O(n log n) | O(n log n) | O(n) | 是 | 分治,合并两个有序数组 |
| 快速 | O(n log n) | O(n log n) | O(n²) | O(log n) | 否 | 分治,选基准分区 |
| 堆 | O(n log n) | O(n log n) | O(n log n) | O(1) | 否 | 利用堆性质取最值 |
12.2 稳定性的含义
稳定排序:值相等的元素在排序后保持原来的相对顺序。
- 稳定:冒泡、插入、归并
- 不稳定:选择、快速、堆
例如按成绩排序时,稳定排序能让同分的人保持原来学号顺序。
12.3 三大排序实现
cpp
// ---------- 快速排序 ----------
int partition(std::vector<int>& arr, int l, int r) {
int pivot = arr[r];
int i = l;
for (int j = l; j < r; ++j)
if (arr[j] <= pivot) std::swap(arr[i++], arr[j]);
std::swap(arr[i], arr[r]);
return i;
}
void quickSort(std::vector<int>& arr, int l, int r) {
if (l >= r) return;
int p = partition(arr, l, r);
quickSort(arr, l, p - 1);
quickSort(arr, p + 1, r);
}
// ---------- 归并排序 ----------
void merge(std::vector<int>& arr, int l, int mid, int r) {
std::vector<int> tmp(r - l + 1);
int i = l, j = mid + 1, k = 0;
while (i <= mid && j <= r)
tmp[k++] = arr[i] <= arr[j] ? arr[i++] : arr[j++];
while (i <= mid) tmp[k++] = arr[i++];
while (j <= r) tmp[k++] = arr[j++];
for (int p = 0; p < k; ++p) arr[l + p] = tmp[p];
}
void mergeSort(std::vector<int>& arr, int l, int r) {
if (l >= r) return;
int mid = l + (r - l) / 2;
mergeSort(arr, l, mid);
mergeSort(arr, mid + 1, r);
merge(arr, l, mid, r);
}
// ---------- 堆排序 ----------
void heapSort(std::vector<int>& arr) {
int n = arr.size();
auto siftDown = [&](int root, int sz) {
while (true) {
int largest = root, l = 2 * root + 1, r = 2 * root + 2;
if (l < sz && arr[l] > arr[largest]) largest = l;
if (r < sz && arr[r] > arr[largest]) largest = r;
if (largest == root) break;
std::swap(arr[root], arr[largest]);
root = largest;
}
};
for (int i = n / 2 - 1; i >= 0; --i) siftDown(i, n); // 建堆 O(n)
for (int i = n - 1; i > 0; --i) { // 排序 O(n log n)
std::swap(arr[0], arr[i]);
siftDown(0, i);
}
}
13. 动态规划基础
13.1 概念与解题框架
动态规划 (DP) 的核心:将大问题分解为重叠的子问题,记录子问题的解避免重复计算。
1. 定义状态 dp[i] 的含义(最关键的一步)
2. 推导状态转移方程
3. 确定初始条件和边界
4. 确定遍历顺序
5. 举例验证
13.2 DP 的两大要素
| 要素 | 含义 |
|---|---|
| 最优子结构 | 问题的最优解包含子问题的最优解 |
| 重叠子问题 | 递归过程中反复出现相同的子问题(这是DP区别于分治的关键) |
13.3 经典例题
cpp
// ---------- 斐波那契(理解重叠子问题)----------
int fib(int n) {
if (n <= 1) return n;
int a = 0, b = 1;
for (int i = 2; i <= n; ++i) { int c = a + b; a = b; b = c; }
return b;
}
// ---------- 爬楼梯(每次 1 或 2 级)----------
int climbStairs(int n) {
if (n <= 2) return n;
int a = 1, b = 2;
for (int i = 3; i <= n; ++i) { int c = a + b; a = b; b = c; }
return b;
}
// ---------- 最长递增子序列 LIS ----------
// O(n²) DP 版本
int lengthOfLIS(std::vector<int>& nums) {
int n = nums.size(), ans = 1;
std::vector<int> dp(n, 1); // dp[i] = 以 nums[i] 结尾的 LIS 长度
for (int i = 1; i < n; ++i) {
for (int j = 0; j < i; ++j)
if (nums[j] < nums[i]) dp[i] = std::max(dp[i], dp[j] + 1);
ans = std::max(ans, dp[i]);
}
return ans;
}
// O(n log n) 贪心+二分
int lengthOfLIS_optimized(std::vector<int>& nums) {
std::vector<int> tails; // tails[i] = 长度为 i+1 的 LIS 的最小末尾值
for (int x : nums) {
auto it = std::lower_bound(tails.begin(), tails.end(), x);
if (it == tails.end()) tails.push_back(x);
else *it = x;
}
return tails.size();
}
// ---------- 最长公共子序列 LCS ----------
int longestCommonSubsequence(std::string a, std::string b) {
int m = a.size(), n = b.size();
std::vector<std::vector<int>> dp(m + 1, std::vector<int>(n + 1, 0));
for (int i = 1; i <= m; ++i)
for (int j = 1; j <= n; ++j)
if (a[i-1] == b[j-1]) dp[i][j] = dp[i-1][j-1] + 1;
else dp[i][j] = std::max(dp[i-1][j], dp[i][j-1]);
return dp[m][n];
}
// ---------- 0/1 背包 ----------
int knapsack01(std::vector<int>& wt, std::vector<int>& val, int W) {
int n = wt.size();
std::vector<int> dp(W + 1, 0);
for (int i = 0; i < n; ++i)
for (int w = W; w >= wt[i]; --w) // 倒序:保证每个物品只用一次
dp[w] = std::max(dp[w], dp[w - wt[i]] + val[i]);
return dp[W];
}
14. 数据结构面试概念考点
以下按照数据结构本身的面试考查角度组织,每题从概念、原理、对比层面回答。
14.1 基础概念
Q1: 什么是数据结构?逻辑结构和物理结构的区别?
- 数据结构 = 数据元素之间存在一种或多种特定关系的集合
- 逻辑结构(面向问题):线性结构(数组、链表)、树形结构、图形结构
- 物理结构/存储结构(面向计算机):顺序存储(连续内存)、链式存储(指针链接)
Q2: 什么是抽象数据类型(ADT)?
ADT 定义了一组数据及在其上的操作,但不指定具体实现。例如栈 ADT 定义了 push/pop/top,实现可以是数组也可以是链表。ADT 把"做什么"和"怎么做"分离开。
Q3: 顺序存储和链式存储的本质区别?
| 维度 | 顺序存储 | 链式存储 |
|---|---|---|
| 内存 | 连续的一块 | 分散,通过指针链接 |
| 寻址 | 下标 O(1) | 遍历 O(n) |
| 插入删除 | 需移动元素 | 修改指针 |
| 空间利用率 | 无指针开销 | 有指针开销 |
| 缓存 | 友好 | 不友好 |
| 扩容 | 整体重新分配 | 逐个按需 |
14.2 线性结构
Q4: 数组和链表的区别?各适合什么场景?
- 数组:连续内存,O(1) 随机访问,插入删除 O(n)。适合读多写少、需要随机访问的场景
- 链表:非连续,O(n) 查找,但插入删除 O(1)。适合频繁增删、不需要随机访问的场景
- 内存方面:数组需预分配(或动态扩容),链表完全按需分配但有指针开销
Q5: vector 扩容为什么用 1.5 倍或 2 倍?
翻倍扩容实现简单,均摊插入复杂度为 O(1)。设扩容因子为 k:
- k = 2:每次扩容翻倍,之前释放的内存块总和一定小于新分配的大小,无法复用
- k 选择 1.5:多次扩容后,之前释放的内存块总大小可能够下一次扩容用,减少内存碎片
Q6: 栈和队列的根本区别?各自的实际应用?
- 栈 LIFO:函数调用栈(递归)、表达式求值、括号匹配、撤销操作、浏览器的后退
- 队列 FIFO:消息队列、BFS、打印队列、任务调度
- 本质区别:数据离开结构的顺序不同
Q7: 什么是循环队列?为什么需要它?
队尾指针到达数组末尾时绕回到开头,形成一个逻辑上的环。普通队列在多次出队后,队头空出的空间无法复用(假溢出),循环队列通过取模运算复用数组前端的空间。
14.3 哈希表
Q8: 哈希表为什么能做到 O(1) 查找?会一直 O(1) 吗?
哈希函数将 key 直接映射为数组下标,理论上一次定位。但:
- 装载因子过高时冲突增多,退化到 O(n)(拉链法链表很长 / 开放寻址探测次数多)
- 哈希函数设计不好导致聚类,也会降低性能
- 需要通过 rehash 保持 O(1) 均摊
Q9: 拉链法 vs 开放寻址法,各自适用场景?
- 拉链法:装载因子可 >1,删除简单。适合元素大小变化大、不确定容量的场景(C++ unordered_map)
- 开放寻址法:无指针、缓存友好。适合元素小、装载因子可控的场景(Java ThreadLocal)
Q10: 哈希表 vs map(红黑树),什么时候用哪个?
- 需要 O(1) 查找,不关心顺序 →
unordered_map(哈希表) - 需要按 key 有序遍历、范围查询 →
map(红黑树) - 内存敏感 → 哈希表空间开销可能更大(桶数组 + 链表指针)
- key 不支持哈希 → 只能用 map(需支持 < 比较)
14.4 树
Q11: 什么是树?树和图的核心区别?
- 树是 n 个节点的有限集合,n=0 为空树;n>0 时有且仅有一个根节点,其余节点分为 m 个互不相交的子集,每个子集本身又是一棵树(递归定义)
- 树 vs 图 :树是连通无环的无向图,任意两个节点间有且仅有一条路径。树有明确的层级 和根概念,图没有
Q12: 完全二叉树和满二叉树的区别?
- 满二叉树:每层节点数都达到最大值(第 i 层有 2^(i-1) 个节点)
- 完全二叉树 :除最后一层外全满,并且最后一层节点从左到右连续排列
Q13: 二叉树为什么有 n₀ = n₂ + 1 这个性质?
设度为 0、1、2 的节点数分别 n₀、n₁、n₂,总边数 E = n₁ + 2n₂。又因为除根外每个节点有一条入边,E = n₀ + n₁ + n₂ - 1。联立得 n₀ = n₂ + 1。
Q14: 四种遍历方式分别有什么实际用途?
- 前序:复制树结构、序列化/反序列化(先根后子)
- 中序:BST 输出有序序列(最核心的用途)
- 后序:删除树(先删孩子再删父亲)、计算子树属性(汇总子节点信息后更新父节点)
- 层序:求树的宽度、求最短路径(BFS)、判断完全二叉树
Q15: 为什么二叉树通常用链式存储而不用数组?
- 一般二叉树可能很不"满",用数组存储会导致大量空间浪费
- 但完全二叉树(如堆)用数组存储效率很高,可通过下标计算父子关系
- 一般二叉树用链式存储更灵活,按需分配节点
14.5 二叉搜索树 & 平衡树
Q16: 什么是二叉搜索树?它的核心价值是什么?
BST 满足左子树 < 根 < 右子树。核心价值是利用二分思想将查找复杂度降到 O(log n),同时中序遍历即可获得有序序列。
Q17: BST 为什么可能退化成 O(n)?
插入顺序是关键:如果数据本身有序(如 1,2,3,4,5),每次都在最右侧插入 → 树变成一条右斜链 → 查找从 O(log n) 退化为 O(n)。这是裸 BST 的根本缺陷。
Q18: 平衡二叉树解决了什么问题?怎么解决的?
解决 BST 在极端插入顺序下退化成链表的问题。通过旋转操作 维护树的高度平衡,保证查找/插入/删除始终保持在 O(log n)。
Q19: 什么是旋转(自旋)?旋转为什么不会破坏 BST 性质?
旋转是通过改变少数父子关系的指针来调整树的结构。以右旋为例:将左孩子提为新的根,原根变为左孩子的右子树,左孩子的原右子树移给原根的左指针。旋转前后中序遍历结果不变 → BST 性质得以保持。
Q20: AVL 树和红黑树的平衡策略有什么不同?为什么 C++ STL 选红黑树?
- AVL 追求严格高度差 ≤ 1 → 树更矮、查找更快
- 红黑树追求近似平衡(最长路径 ≤ 最短路径 2 倍)→ 插入删除代价更小
- STL 选红黑树因为:容器场景下插入/删除频繁,红黑树旋转更少,整体性能更优
Q21: B+ 树为什么适合做数据库索引?
- 数据只在叶子节点,内节点只存索引 → 每个内节点能存更多索引项 → 树更矮,磁盘 I/O 更少
- 叶子节点串成链表 → 范围查询极快,顺着链表扫即可,不需要中序遍历
- 查询任何数据都在固定高度(根→叶子),时间稳定
14.6 堆
Q22: 什么是堆?堆和二叉搜索树的区别?
- 堆:完全二叉树,只保证父子间的大小关系(大顶堆/小顶堆),兄弟之间无序
- BST:保证 左 < 根 < 右,兄弟之间有序
- 堆获取最值 O(1),但查找任意元素需要遍历 O(n);BST 查找 O(log n),获取最值也需 O(log n)
- 堆用数组存储效率高,BST 一般链式存储
Q23: 建堆为什么是 O(n) 而不是 O(n log n)?
虽然每次 siftDown 最坏 O(log n),但越靠近底层的节点越多,且它们下沉的距离越短。从 n/2 到 n-1 的叶子节点下沉距离为 0。精确求和得 O(n)。粗略理解:大部分节点只需很少的比较次数。
14.7 图
Q24: 什么是图?有向图和无向图的区别?
图 G = (V, E) 由顶点和边组成。无向图的边没有方向,有向图的边有方向。连通性定义也不同:无向图连通指任意两点可互达,有向图强连通指任意两点双向可互达。
Q25: 邻接矩阵 vs 邻接表?各自适用场景?
- 邻接矩阵 O(V²):判断任意两点是否直接相连 O(1),适合稠密图或需要快速判断边的存在
- 邻接表 O(V+E):遍历邻接点快速,适合稀疏图,是实际中最常用的图的存储方式
Q26: BFS 和 DFS 的本质区别?为什么 BFS 能求最短路径?
- BFS 按层扩展,从起点向外一圈圈遍历。当第一次访问到某节点时,一定经过了最少边数 → 在无权图中即最短路径
- DFS 一条路走到底再回溯,先访问的路径不一定最短,因为可能绕了一大圈
- BFS 用队列(先进先出 → 按层),DFS 用栈(后进先出 → 按深度)
Q27: BFS/DFS 为什么需要 visited 标记?
图可以存在环路,没有 visited 会陷入无限循环。同时避免对同一节点的重复访问造成指数级膨胀。
Q28: 什么是拓扑排序?有哪些应用?
将有向无环图 (DAG) 的所有顶点排成线性序列,使每条边 u→v 中 u 出现在 v 之前。应用:编译器的依赖解析、任务调度(先修课程安排)、构建系统 (Makefile)。
14.8 排序
Q29: 什么是排序的稳定性?哪些排序是稳定的?
稳定 = 相等元素的相对顺序在排序后保持不变。稳定的有:冒泡、插入、归并。不稳定的有:选择、快速、堆。实际场景中多关键字排序时稳定性很重要(先按姓名排序,再按成绩排序时,同分的姓名有序)。
Q30: 快速排序 vs 归并排序?为什么快排通常更快?
- 都是分治,平均 O(n log n)
- 快排在原数组上交换(原地排序),缓存友好,常数因子小
- 归并需要临时数组和来回拷贝
- 但快排不稳定、最坏 O(n²);归并稳定、最坏 O(n log n)
- C++
std::sort使用内省排序(快排 + 堆排序兜底)
14.9 STL 综合
Q31: 各 STL 容器的底层数据结构是什么?
| 容器 | 底层数据结构 | 特点 |
|---|---|---|
vector |
动态数组 | 尾插快,随机访问 |
deque |
分段连续数组 | 头尾都快 |
list |
双向链表 | 任意位置插入 O(1) |
stack / queue |
默认基于 deque | 适配器 |
priority_queue |
堆(vector 实现) | 最值 O(1) |
set / map |
红黑树 | 有序,O(log n) |
unordered_set / unordered_map |
哈希表 | 无序,均摊 O(1) |
Q32: stack / queue 为什么是"容器适配器"?
它们自己不直接管理存储,而是对底层容器(默认 deque)的接口进行了"适配":stack 只暴露 push/pop/top,屏蔽了底层容器的其他操作。可以指定底层容器为 vector 或 list。
14.10 面试答题模板
面试中回答数据结构概念问题时,按这个框架组织:
- 先给定义 --- 一句话说清楚这是什么
- 核心性质 --- 最重要的 2-3 个特点
- 时间复杂度 --- 主要操作的时间/空间
- 对比替代方案 --- 与类似数据结构的区别、各自适用场景
- 实际应用举例 --- 在哪里用到(STL、数据库、操作系统等)
示例:"请你说说红黑树"
红黑树是一种自平衡二叉搜索树,通过给节点涂色和五条性质来保持树的近似平衡。插入删除最多需要 O(log n),但实际旋转次数很少(插入最多 2 次旋转)。相比 AVL,红黑树放弃了严格高度平衡,换来更少的写操作开销。C++ 的
std::map和std::set底层就是红黑树,这也是它被广泛使用的工程验证。
持续更新中:后续补充并查集、字典树 (Trie)、跳表、线段树、LRU/LFU 缓存等内容。