- C++ 常用数据结构详解,含代码示例、底层机制与实际应用场景
- 对应面试问答见 03-Resources/interview/C++ 数据结构
- 完整系列:C++ 基础-初级、C++ 基础-中级、C++ 基础-高级、C++ STL、03-Resources/cpp/C++ 并发编程、03-Resources/cpp/C++ 网络编程、03-Resources/cpp/C++ 数据结构、03-Resources/cpp/C++ 设计模式、03-Resources/cpp/C++ 基本算法、03-Resources/cpp/C++ Linux 编程
- 当前覆盖:数组/链表、栈队列、哈希表、堆、树、图、跳表、并查集、Trie、布隆过滤器、线段树、树状数组、LRU
数组和链表有什么区别?各自适用场景?
数组与链表是最基础的两种线性结构,核心差异在于内存布局:数组是连续内存,链表通过指针把分散节点串起来。这一差异直接决定了它们的访问、插入、删除复杂度。
| 对比维度 | 数组(std::vector/std::array) | 链表(std::list/std::forward_list) |
|---|---|---|
| 内存布局 | 连续,支持 CPU 缓存预取 | 分散,指针跳跃,缓存不友好 |
| 随机访问 | O(1) | O(n) |
| 头部插入/删除 | O(n) | O(1) |
| 尾部插入/删除 | 均摊 O(1) | O(1)(双向)/O(n)(单向) |
| 中间插入/删除 | O(n)(移动元素) | O(1)(已知节点位置) |
| 空间开销 | 无额外指针,可能有预留容量 | 每个节点多存 1~2 个指针 |
| 迭代器失效 | 扩容/插入可能全部失效 | 只有被删除节点失效 |
#include <vector>
#include <list>
#include <iostream>
int main() {
// 数组:随机访问 O(1),适合频繁按下标读取
std::vector<int> arr{1, 2, 3, 4, 5};
std::cout << arr[2] << '\n'; // 3,O(1)
// 链表:中间插入 O(1),适合频繁插入删除
std::list<int> lst{1, 2, 4, 5};
auto it = lst.begin();
std::advance(it, 2); // 指向 4
lst.insert(it, 3); // 变成 1 2 3 4 5
return 0;
}
现代 CPU 缓存让数组在遍历时远比链表快:std::vector 连续内存能命中 cache line,链表指针跳跃会频繁 cache miss。所以「链表中点插入 O(1)」只是理论值,实际工程里除非数据量巨大且频繁在头部/中部增删,否则优先用 vector。
- 数组适用场景:需要按下标随机访问、元素数量相对稳定、遍历密集型(如矩阵、缓冲区、LRU 里的连续哈希表槽位)。
- 链表适用场景:频繁在头部/中部插入删除、元素数量动态变化且无法预估、需要稳定迭代器(如 LRU 的访问顺序链、内存分配器的空闲块管理)。
用 C++ 实现一个单链表(增删查改)?
手写单链表考察对指针操作的理解。核心是维护 head 指针,插入/删除时正确处理前驱指针,避免断链。
#include <iostream>
struct ListNode {
int val;
ListNode* next;
ListNode(int x) : val(x), next(nullptr) {}
};
class SinglyLinkedList {
public:
SinglyLinkedList() : head(nullptr) {}
// 尾部插入
void push_back(int val) {
ListNode* node = new ListNode(val);
if (!head) { head = node; return; }
ListNode* p = head;
while (p->next) p = p->next;
p->next = node;
}
// 按值查找,返回第一个匹配节点,找不到返回 nullptr
ListNode* find(int val) {
for (ListNode* p = head; p; p = p->next)
if (p->val == val) return p;
return nullptr;
}
// 删除第一个值为 val 的节点,返回是否删除成功
bool remove(int val) {
ListNode** cur = &head; // 二级指针,优雅处理头节点删除
while (*cur && (*cur)->val != val)
cur = &(*cur)->next;
if (!*cur) return false;
ListNode* tmp = *cur;
*cur = (*cur)->next; // 把前驱的 next 指向被删节点的 next
delete tmp;
return true;
}
// 打印
void print() {
for (ListNode* p = head; p; p = p->next)
std::cout << p->val << " ";
std::cout << '\n';
}
~SinglyLinkedList() { // 析构释放全部节点
while (head) {
ListNode* tmp = head;
head = head->next;
delete tmp;
}
}
private:
ListNode* head;
};
int main() {
SinglyLinkedList l;
l.push_back(1); l.push_back(2); l.push_back(3);
l.remove(2);
l.print(); // 1 3
return 0;
}
删除节点时用指向指针的指针(ListNode**)可统一处理「删除头节点」和「删除中间节点」两种情况,避免单独判断 head,是链表题的高分写法。
手写链表务必注意:插入前先把新节点 next 接上再断前驱(否则后半段丢失);删除后记得 delete 释放内存;析构函数要遍历释放,避免内存泄漏。
栈和队列有什么区别?如何用栈实现队列?
栈是后进先出(LIFO),队列是先进先出(FIFO),两者都限制「只能在特定端操作」。C++ 中 std::stack 默认用 std::deque 作底层容器,std::queue 同样默认 std::deque。
| 对比 | 栈 std::stack | 队列 std::queue |
|---|---|---|
| 出队顺序 | LIFO(后进先出) | FIFO(先进先出) |
| 操作 | push/pop/top | push/pop/front/back |
| 应用 | 函数调用、括号匹配、表达式求值、DFS | 任务调度、BFS、消息队列、缓存淘汰 |
用两个栈实现队列:一个栈 in 负责入队,一个栈 out 负责出队。出队时若 out 空,把 in 全部倒入 out,翻转顺序后栈顶即队首。每个元素最多被搬移两次,均摊 O(1)。
#include <stack>
class MyQueue {
std::stack<int> in, out;
void transfer() { // 只有 out 空时才搬
if (out.empty()) {
while (!in.empty()) {
out.push(in.top());
in.pop();
}
}
}
public:
void push(int x) { in.push(x); } // 入队 O(1)
int pop() { // 出队均摊 O(1)
transfer();
int v = out.top(); out.pop();
return v;
}
int peek() { // 取队首均摊 O(1)
transfer();
return out.top();
}
bool empty() { return in.empty() && out.empty(); }
};
用队列实现栈则相反:入队时先入队新元素,再把队列前 size-1 个元素依次出队再入队,让新元素始终在队首,出栈即队首出队 O(1)。
- 实际场景:栈用于函数调用栈、括号匹配、撤销/重做、DFS 迭代版;队列用于 BFS、线程池任务队列、消息队列、LRU 里的访问顺序(
std::list充当)。
哈希表的原理?如何解决哈希冲突?
哈希表通过哈希函数把 key 映射到数组下标,实现「平均 O(1)」的查找。理想情况下一次定位,但不同 key 可能映射到同一下标,即哈希冲突。
解决冲突的两大类方法:
| 方法 | 思路 | 代表实现 | 特点 |
|---|---|---|---|
| 链地址法(拉链) | 每个槽位挂一条链表/红黑树,冲突元素追加到链表 | unordered_map | 实现简单、负载因子可 >1,但多一次指针跳转 |
| 开放定址法 | 冲突时按探测序列找下一个空槽(线性/二次/双重哈希) | Java HashMap(8 后混合)、Google dense_hash | 缓存友好、无需指针,但删除需墓碑、负载因子低 |
链地址法示意(每个 bucket 一条链表):
// 简化版链地址哈希表:vector<list<pair<K,V>>>
#include <vector>
#include <list>
#include <utility>
#include <cstddef>
template <typename K, typename V>
class MyHashMap {
std::vector<std::list<std::pair<K, V>>> buckets;
std::size_t count = 0;
std::size_t hash(const K& key) const {
return std::hash<K>{}(key) % buckets.size();
}
public:
MyHashMap(std::size_t n = 16) : buckets(n) {}
void put(const K& key, const V& val) {
auto& list = buckets[hash(key)];
for (auto& kv : list) {
if (kv.first == key) { kv.second = val; return; } // 覆盖
}
list.emplace_back(key, val); // 拉链追加
++count;
}
bool get(const K& key, V& out) const {
const auto& list = buckets[hash(key)];
for (const auto& kv : list)
if (kv.first == key) { out = kv.second; return true; }
return false;
}
};
平均复杂度 O(1) 的前提是哈希函数分布均匀 + 负载因子受控;最坏情况(所有 key 撞同一槽)退化为 O(n)。工程上链长超阈值(如 8)会把链表转红黑树(Java)或触发 rehash(C++ unordered_map),保证最坏 O(log n) 或重新摊平。
- 实际场景:缓存(如 LRU 的 key→节点映射)、去重(集合)、词频统计、数据库索引的哈希索引、分布式一致性哈希(对象→节点)。
unordered_map 的底层实现原理?
std::unordered_map 采用链地址法:底层是一个桶数组(bucket array,即 vector),每个桶是一条单向链表,存 pair<const Key, T> 节点。插入/查找流程:先 hash(key) 得到哈希值,再对桶数取模定位桶,最后在该桶链表上顺序查找。
#include <unordered_map>
#include <string>
#include <iostream>
int main() {
std::unordered_map<std::string, int> m;
// 底层可观测接口
std::cout << "bucket_count = " << m.bucket_count() << '\n'; // 初始桶数
std::cout << "max_load_factor = " << m.max_load_factor() << '\n'; // 默认 1.0
m["apple"] = 1;
m["banana"] = 2;
std::cout << "load_factor = " << m.load_factor() << '\n'; // 元素数/桶数
// 查询 key 所在桶
std::cout << "apple 在桶 " << m.bucket("apple") << '\n';
return 0;
}核心机制:
| 概念 | 说明 |
|---|---|
| 桶数组(bucket) | 连续内存,下标由 hash(key) % bucket_count 决定 |
| 负载因子 | size / bucket_count,超过 max_load_factor 触发 rehash |
| rehash | 桶数翻倍(通常取质数),重新分配并重哈希所有元素,O(n) |
| 节点 | 单向链表节点,unordered_map 只支持前向迭代、迭代器不稳定 |
与 map(红黑树,有序,操作 O(log n))相比,unordered_map 平均 O(1) 但无序、占用内存更大(桶数组 + 链表指针)、迭代器在 rehash 后失效。需要有序遍历或范围查询时选 map,只做点查选 unordered_map。
可提前 reserve(n) 预留桶数,避免频繁 rehash;rehash(n) 直接设桶数。对已知规模的数据,合理设置桶数能显著减少重哈希开销。
- 实际场景:计数器(词频)、缓存映射、两数之和等哈希类题目、符号表、配置 key→value 存储。
哈希函数如何设计?什么是哈希碰撞?
哈希函数是把任意长度 key 映射为固定范围整数(桶下标)的函数;哈希碰撞(冲突)指不同 key 得到相同哈希值/桶下标,碰撞不可避免(鸽巢原理),目标是均匀分布 + 快速计算。
常用设计方法:
| 方法 | 公式/思路 | 说明 |
|---|---|---|
| 除留余数法 | key % m,m 取质数或不含小因子的数 | 最常用,m 取质数减少碰撞 |
| 乘法散列 | floor(m * (A*key mod 1)),A≈0.618 | 对 m 取值不敏感 |
| 字符串哈希 | 如 hash = hash*31 + c(多项式) | Java String 同款,31 是经典乘子 |
好的哈希函数三要素:① 分布均匀(雪崩效应,输入微小变化引起哈希值大幅变化);② 计算快;③ 确定性(同 key 同值)。
#include <string>
#include <cstddef>
// 多项式滚动哈希(类 Java String.hashCode 思路,用 31 乘子)
std::size_t poly_hash(const std::string& s) {
std::size_t h = 0;
for (char c : s)
h = h * 31 + static_cast<unsigned char>(c);
return h;
}
// 更稳健的字符串哈希:FNV-1a,分布更均匀
std::size_t fnv1a(const std::string& s) {
std::size_t h = 1469598103934665603ULL; // FNV offset basis
for (char c : s) {
h ^= static_cast<unsigned char>(c);
h *= 1099511628211ULL; // FNV prime
}
return h;
}
自定义类型作 unordered_map 的 key 时,必须同时提供 std::hash<T> 特化(哈希函数)和 operator==(判等,用于碰撞后在同桶内确认是同一 key)。二者缺一不可:哈希只缩小范围,== 才最终判定相等。
「雪崩效应」是加分词:好的哈希函数让一位输入变化引起约一半输出位变化,例如 murmurhash、xxhash、FNV-1a 都是工程常用实现;C++ 标准库 std::hash 对整数基本是恒等映射,对字符串实现由库决定。
- 实际场景:密码存储(加盐哈希)、负载均衡(按请求哈希路由到固定后端)、分布式缓存一致性哈希、去重(哈希指纹)。
堆(优先队列)的原理?如何用堆求 TopK?
堆是完全二叉树,满足堆序:大顶堆每个节点 ≥ 其子节点(堆顶最大),小顶堆反之(堆顶最小)。堆用数组紧凑存储,节点 i 的左子 2i+1、右子 2i+2、父 (i-1)/2。
核心操作(以数组实现的小顶堆为例):
push:追加到末尾,向上「上浮」(sift-up)O(log n)pop:用末尾元素替换堆顶,向下「下沉」(sift-down)O(log n)top:直接取a[0]O(1)
#include <vector>
#include <algorithm>
class MinHeap {
std::vector<int> a;
void sift_up(int i) {
while (i > 0 && a[i] < a[(i - 1) / 2]) {
std::swap(a[i], a[(i - 1) / 2]);
i = (i - 1) / 2;
}
}
void sift_down(int i) {
int n = a.size();
while (true) {
int l = 2 * i + 1, r = 2 * i + 2, small = i;
if (l < n && a[l] < a[small]) small = l;
if (r < n && a[r] < a[small]) small = r;
if (small == i) break;
std::swap(a[i], a[small]);
i = small;
}
}
public:
void push(int x) { a.push_back(x); sift_up(a.size() - 1); }
int top() const { return a[0]; }
void pop() { a[0] = a.back(); a.pop_back(); sift_down(0); }
bool empty() const { return a.empty(); }
int size() const { return a.size(); }
};用堆求 TopK:维护一个大小为 K 的小顶堆,遍历所有元素,若当前元素 > 堆顶则弹出堆顶、插入当前元素。遍历结束堆中即最大的 K 个,堆顶即第 K 大。
#include <queue>
#include <vector>
std::vector<int> topK(const std::vector<int>& nums, int k) {
std::priority_queue<int, std::vector<int>, std::greater<int>> minHeap;
for (int x : nums) {
minHeap.push(x);
if (minHeap.size() > k) minHeap.pop(); // 维持堆大小为 k
}
std::vector<int> res;
while (!minHeap.empty()) { res.push_back(minHeap.top()); minHeap.pop(); }
return res; // 从第 K 大到最大
}TopK 有两条路线:小顶堆(时间 O(n log k),空间 O(k),适合 k 远小于 n,可处理流式数据)和快速选择 quickselect(平均 O(n),但会修改原数组、非流式)。求「第 K 大」小顶堆堆顶就是答案;求「第 K 小」则用大顶堆。
- 实际场景:TopK 排行榜(微博热搜、电商销量前 N)、优先队列调度(操作系统就绪队列、Dijkstra 最短路径、定时器管理)、流式数据求中位数(双堆)。
priority_queue 的底层实现?
std::priority_queue 是容器适配器,底层默认用 std::vector 存储,并用堆算法(std::make_heap/push_heap/pop_heap)维持堆序。它只暴露 top/push/pop 三个核心接口,不支持遍历。
#include <queue>
#include <vector>
#include <iostream>
int main() {
// 默认大顶堆(less 比较,堆顶最大)
std::priority_queue<int> maxHeap;
maxHeap.push(3); maxHeap.push(1); maxHeap.push(5);
std::cout << maxHeap.top() << '\n'; // 5
// 小顶堆:greater 比较
std::priority_queue<int, std::vector<int>, std::greater<int>> minHeap;
minHeap.push(3); minHeap.push(1); minHeap.push(5);
std::cout << minHeap.top() << '\n'; // 1
return 0;
}模板参数:priority_queue<T, Container, Compare> —— Container 默认 vector<T>,Compare 默认 std::less<T>(大顶堆)。注意 Compare 的语义与直觉相反:std::greater 得到小顶堆。
复杂度:push/pop 都是 O(log n),top O(1)。由于底层是 vector,入堆可能触发扩容。
自定义比较器三种写法:① 传函数对象(如 std::greater<int>);② 传 lambda(C++11 起需配合 decltype);③ 自定义类型重载 operator<。若元素是自定义结构体,通常重载 operator< 并用 std::less 即可。
priority_queue 没有 iterator、不能遍历、不能直接修改堆中元素(改了不会自动维护堆序)。需要「删除任意元素 / 更新优先级」时,改用 std::set(有序)或手写堆 + 懒删除标记。
- 实际场景:任务优先级调度、TopK、Dijkstra/A* 优先队列、事件驱动框架的定时器堆。
二叉树的遍历(前/中/后/层序)如何实现?
二叉树遍历分深度优先(DFS)与广度优先(BFS)。DFS 按根节点访问顺序分前序(根左右)、中序(左根右)、后序(左右根);BFS 即层序遍历,按层从左到右。
#include <iostream>
struct TreeNode {
int val;
TreeNode *left, *right;
TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
};
// 前序:根 -> 左 -> 右
void preorder(TreeNode* root) {
if (!root) return;
std::cout << root->val << " ";
preorder(root->left);
preorder(root->right);
}
// 中序:左 -> 根 -> 右(BST 中序得到有序序列)
void inorder(TreeNode* root) {
if (!root) return;
inorder(root->left);
std::cout << root->val << " ";
inorder(root->right);
}
// 后序:左 -> 右 -> 根
void postorder(TreeNode* root) {
if (!root) return;
postorder(root->left);
postorder(root->right);
std::cout << root->val << " ";
}非递归(迭代) 需要显式用栈模拟递归的调用栈。以中序为例:
#include <stack>
#include <vector>
std::vector<int> inorder_iter(TreeNode* root) {
std::vector<int> res;
std::stack<TreeNode*> st;
TreeNode* cur = root;
while (cur || !st.empty()) {
while (cur) { // 一路向左压栈
st.push(cur);
cur = cur->left;
}
cur = st.top(); st.pop(); // 弹出访问
res.push_back(cur->val);
cur = cur->right; // 转向右子树
}
return res;
}层序遍历用队列,逐层处理:
#include <queue>
#include <vector>
std::vector<std::vector<int>> levelorder(TreeNode* root) {
std::vector<std::vector<int>> res;
if (!root) return res;
std::queue<TreeNode*> q;
q.push(root);
while (!q.empty()) {
int n = q.size();
std::vector<int> level;
for (int i = 0; i < n; ++i) { // 一次处理一层
TreeNode* cur = q.front(); q.pop();
level.push_back(cur->val);
if (cur->left) q.push(cur->left);
if (cur->right) q.push(cur->right);
}
res.push_back(level);
}
return res;
}| 遍历 | 顺序 | 迭代实现要点 | 典型用途 |
|---|---|---|---|
| 前序 | 根左右 | 栈,先压右再压左 | 序列化、复制树、前缀表达式 |
| 中序 | 左根右 | 栈,一路向左再回退 | BST 输出有序序列 |
| 后序 | 左右根 | 栈 + 标记,或双栈 | 释放树、后缀表达式、子树信息聚合 |
| 层序 | 逐层从左到右 | 队列,按层计数 | BFS、最短路径、树的宽度 |
后序遍历的非递归是三者中最难的,常用技巧:**「前序(根右左)反转」**得到后序(左右根),即用一个栈实现前序但先访问右子,最后 reverse。或给节点加访问标记(第二次弹出才输出)。
- 实际场景:表达式解析(前缀/中缀/后缀)、BST 有序遍历、树的序列化/反序列化、文件系统遍历、游戏 AI 决策树评估。
二叉搜索树 BST 的特点?为什么容易退化?
二叉搜索树(BST) 满足:左子树所有节点 < 根 < 右子树所有节点。中序遍历得到递增有序序列,查找/插入/删除平均 O(log n)。
为什么容易退化:BST 的形态完全取决于插入顺序。若按有序/近似有序顺序插入,树会退化成单链(每个节点只有右子或左子),此时高度从 O(log n) 变 O(n),查找/插入/删除全部退化为 O(n)。
#include <iostream>
struct TreeNode {
int val;
TreeNode *left, *right;
TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
};
// BST 查找:平均 O(log n),退化 O(n)
TreeNode* search(TreeNode* root, int key) {
while (root) {
if (key < root->val) root = root->left;
else if (key > root->val) root = root->right;
else return root;
}
return nullptr;
}
// BST 插入
TreeNode* insert(TreeNode* root, int key) {
if (!root) return new TreeNode(key);
if (key < root->val) root->left = insert(root->left, key);
else root->right = insert(root->right, key);
return root;
}
依次插入 1,2,3,4,5 会得到一条全右链,高度 5;查找 5 要比较 5 次。这是 BST 的致命弱点,也是「平衡树」诞生的原因。
解决退化的三条路径:AVL 树(严格平衡,旋转多)、红黑树(近似平衡,工程首选)、Treap/Splay(引入随机/伸展摊平)。C++ std::map 用红黑树正是为了避免 BST 退化。
- 实际场景:有序符号表、范围查询(找区间内所有 key)、数据库索引(B+ 树是 BST 的多路扩展)、有序容器
std::set/std::map。
平衡二叉树 AVL 的原理?
AVL 树是最早的自平衡二叉搜索树,要求任意节点左右子树高度差(平衡因子)≤ 1。插入/删除后若失衡,通过旋转恢复平衡,保证树高 O(log n)。
四种失衡与旋转:
| 失衡情况 | 形状 | 修复 |
|---|---|---|
| LL(左左) | 左子树的左子树过高 | 右旋(单旋) |
| RR(右右) | 右子树的右子树过高 | 左旋(单旋) |
| LR(左右) | 左子树的右子树过高 | 先左旋再右旋(双旋) |
| RL(右左) | 右子树的左子树过高 | 先右旋再左旋(双旋) |
#include <algorithm>
struct AVLNode {
int val, height;
AVLNode *left, *right;
AVLNode(int x) : val(x), height(1), left(nullptr), right(nullptr) {}
};
int h(AVLNode* n) { return n ? n->height : 0; }
int bf(AVLNode* n) { return n ? h(n->left) - h(n->right) : 0; } // 平衡因子
void update(AVLNode* n) { n->height = 1 + std::max(h(n->left), h(n->right)); }
AVLNode* rotateRight(AVLNode* y) { // 右旋
AVLNode* x = y->left;
y->left = x->right;
x->right = y;
update(y); update(x);
return x;
}
AVLNode* rotateLeft(AVLNode* x) { // 左旋
AVLNode* y = x->right;
x->right = y->left;
y->left = x;
update(x); update(y);
return y;
}
AVLNode* insert(AVLNode* n, int val) {
if (!n) return new AVLNode(val);
if (val < n->val) n->left = insert(n->left, val);
else n->right = insert(n->right, val);
update(n);
int b = bf(n);
if (b > 1 && val < n->left->val) return rotateRight(n); // LL
if (b > 1 && val > n->left->val) { n->left = rotateLeft(n->left); return rotateRight(n); } // LR
if (b < -1 && val > n->right->val) return rotateLeft(n); // RR
if (b < -1 && val < n->right->val) { n->right = rotateRight(n->right); return rotateLeft(n); }// RL
return n;
}AVL 是严格平衡(高度差 ≤1),查找性能最优;但插入/删除为维持严格平衡旋转更频繁。因此更适合读多写少的场景,写入密集时红黑树更有优势。
平衡因子 = 左子树高 - 右子树高,正常取 -1/0/1;插入后失衡节点的平衡因子必为 ±2,只需在其最近失衡祖先处旋转一次即可恢复,不影响整体树高。
- 实际场景:内存中的有序字典(读密集)、需要保证最坏 O(log n) 查找的场景、Windows 内核地址空间管理(AVL 树的变体)。
红黑树的特点?为什么 map 用红黑树不用 AVL?
红黑树是近似平衡的二叉搜索树,靠五条性质维持「最长路径 ≤ 2 × 最短路径」,保证树高 O(log n):
- 节点非红即黑
- 根是黑
- 叶子(NIL)是黑
- 红节点的子节点必须是黑(红不连续)
- 任一节点到其所有叶子的路径上黑节点数相同(黑高一致)
| 对比 | AVL 树 | 红黑树 |
|---|---|---|
| 平衡程度 | 严格(高度差 ≤1) | 近似(最长 ≤2×最短) |
| 查找 | 更快(树更矮) | 略慢(树略高) |
| 插入/删除 | 旋转多,可能 O(log n) 次旋转 | 最多 2 次旋转 + 重着色,更快 |
| 适用场景 | 读多写少 | 读写均衡、工程通用 |
#include <map>
#include <string>
#include <iostream>
int main() {
std::map<std::string, int> m; // 底层红黑树,key 有序
m["banana"] = 2;
m["apple"] = 1;
m["cherry"] = 3;
for (auto& [k, v] : m) // 按键有序输出:apple banana cherry
std::cout << k << ':' << v << '\n';
// 范围查询:找 key >= "b" 的区间,红黑树的中序有序性带来
auto it = m.lower_bound("b");
return 0;
}std::map 用红黑树而非 AVL 的核心原因:写入成本更低。红黑树插入/删除最多旋转 O(1)(常数次)+ O(log n) 重着色,而 AVL 可能沿路径旋转 O(log n) 次;在读写均衡的通用容器中,红黑树的整体吞吐更高。AVL 只在读远多于写时才更优。
红黑树的「有序」是 map/set 区别于 unordered_map/set 的关键:支持 lower_bound、范围遍历、按序迭代;代价是单点操作 O(log n),且内存占用小于哈希表。
- 实际场景:
std::map/std::set、Linux 内核完全公平调度器 CFS(用红黑树管理进程 vruntime)、epoll事件管理、JavaTreeMap、MySQL 内存索引。
图的存储方式:邻接矩阵 vs 邻接表?
图有两种经典存储:邻接矩阵(二维数组,g[i][j] 表示边)和邻接表(每个顶点一条链表/vector,存邻接点)。
| 对比 | 邻接矩阵 | 邻接表 |
|---|---|---|
| 空间复杂度 | O(V²) | O(V + E) |
| 判断边是否存在 | O(1) | O(deg(v)),需遍历邻接链表 |
| 遍历某点所有邻居 | O(V) | O(deg(v)) |
| 加边/删边 | O(1) | O(1)(尾部) |
| 适用场景 | 稠密图(E 接近 V²)、频繁查边、Floyd | 稀疏图(E 远小于 V²)、频繁遍历邻居 |
#include <vector>
#include <iostream>
// 邻接矩阵
std::vector<std::vector<int>> build_matrix(int n, const std::vector<std::pair<int,int>>& edges) {
std::vector<std::vector<int>> g(n, std::vector<int>(n, 0));
for (auto& [u, v] : edges) g[u][v] = 1; // 无向图再设 g[v][u] = 1
return g;
}
// 邻接表(C++ 首选,稀疏图高效)
std::vector<std::vector<int>> build_adj(int n, const std::vector<std::pair<int,int>>& edges) {
std::vector<std::vector<int>> g(n);
for (auto& [u, v] : edges) {
g[u].push_back(v);
g[v].push_back(u); // 无向图双向
}
return g;
}
带权图用邻接表时,节点存 (邻接点, 权重),即 vector<vector<pair<int,int>>>;邻接矩阵则直接把 g[i][j] 存权重。工程上绝大多数图是稀疏的,邻接表是默认选择;只有稠密图或需要 O(1) 查边时才用矩阵。
另有用边列表 vector<tuple<u,v,w>> 存边,适合 Kruskal 最小生成树等只关心边的算法。
- 实际场景:社交网络好友关系(邻接表)、地图导航(带权邻接表)、路由表、编译依赖图、网络拓扑。
图的遍历 BFS 和 DFS?
BFS(广度优先) 用队列,按「层」扩散,天然求无权图最短路径;DFS(深度优先) 用栈/递归,一路深入再回溯,适合连通性、拓扑排序、环检测。
#include <vector>
#include <queue>
#include <iostream>
// BFS:从 s 出发遍历,返回访问顺序(求无权图最短距离同理)
std::vector<int> bfs(const std::vector<std::vector<int>>& g, int s) {
std::vector<bool> visited(g.size(), false);
std::vector<int> order;
std::queue<int> q;
q.push(s); visited[s] = true;
while (!q.empty()) {
int u = q.front(); q.pop();
order.push_back(u);
for (int v : g[u])
if (!visited[v]) { visited[v] = true; q.push(v); }
}
return order;
}
// DFS:递归版
void dfs_rec(const std::vector<std::vector<int>>& g, int u,
std::vector<bool>& visited, std::vector<int>& order) {
visited[u] = true;
order.push_back(u);
for (int v : g[u])
if (!visited[v]) dfs_rec(g, v, visited, order);
}DFS 迭代版用显式栈:
#include <stack>
std::vector<int> dfs_iter(const std::vector<std::vector<int>>& g, int s) {
std::vector<bool> visited(g.size(), false);
std::vector<int> order;
std::stack<int> st;
st.push(s);
while (!st.empty()) {
int u = st.top(); st.pop();
if (visited[u]) continue;
visited[u] = true;
order.push_back(u);
for (int v : g[u]) if (!visited[v]) st.push(v);
}
return order;
}| 对比 | BFS | DFS |
|---|---|---|
| 数据结构 | 队列 | 栈(递归调用栈) |
| 特性 | 逐层扩散,先访问近的 | 一路深入到底再回溯 |
| 无权最短路 | 能求(首次到达即最短) | 不能 |
| 空间复杂度 | O(最宽一层宽度) | O(树高/递归深度) |
| 典型应用 | 最短路、层序、二分图、连通分量计数 | 拓扑排序、环检测、强连通分量、迷宫、回溯 |
求「无权图最短路径 / 最少步数」优先 BFS;「是否存在路径、拓扑序、环检测」DFS 更直观(配合三色标记:白未访问/灰在栈中/黑已结束,灰→灰即发现环)。递归 DFS 在深层图上可能爆栈,可用迭代版或改 BFS。
- 实际场景:BFS——最短路径导航、社交「N 度人脉」、爬虫按层抓取;DFS——编译器的依赖解析、游戏迷宫求解、有向图拓扑排序(任务调度)。
跳表的原理?为什么 Redis 用跳表?
跳表(Skip List) 在有序链表之上建立多层索引:第 0 层是完整有序链表,更高层是下一层的「抽样」快车道。查找时从最高层开始,能跳就跳、不能跳就下沉,把链表 O(n) 查找降到平均 O(log n)。
#include <cstdlib>
#include <climits>
#include <iostream>
// 简化跳表节点:多层 next 指针
struct SkipNode {
int val;
std::vector<SkipNode*> next;
SkipNode(int v, int level) : val(v), next(level, nullptr) {}
};
class SkipList {
static const int MAX_LEVEL = 16;
SkipNode* head;
int random_level() { // 随机层高:50% 概率升一层
int level = 1;
while (level < MAX_LEVEL && (rand() & 1)) ++level;
return level;
}
public:
SkipList() : head(new SkipNode(INT_MIN, MAX_LEVEL)) {}
SkipNode* find(int target) {
SkipNode* cur = head;
for (int i = MAX_LEVEL - 1; i >= 0; --i) {
while (cur->next[i] && cur->next[i]->val < target)
cur = cur->next[i]; // 当前层向右跳
}
return cur->next[0] && cur->next[0]->val == target ? cur->next[0] : nullptr;
}
void insert(int val) {
int level = random_level();
SkipNode* node = new SkipNode(val, level);
SkipNode* cur = head;
for (int i = MAX_LEVEL - 1; i >= 0; --i) {
while (cur->next[i] && cur->next[i]->val < val)
cur = cur->next[i];
if (i < level) { // 在低层插入新节点
node->next[i] = cur->next[i];
cur->next[i] = node;
}
}
}
};跳表本质是「用空间换时间 + 随机化」:通过多级索引把有序链表的顺序查找变成「跳着找」,且实现比红黑树简单、支持高效范围查询。与红黑树相比,跳表插入删除无需复杂旋转,只用局部指针调整,并发实现更容易(可以只锁相邻节点)。
为什么 Redis 用跳表(zskiplist):有序集合 ZSet 需要按 score 排序 + 范围查询 + 排名(rank):
- 跳表天然有序,范围查询(
ZRANGEBYSCORE)直接沿第 0 层顺序走,比红黑树中序遍历更直观高效; - 实现简单、易于调试和维护;
- 并发/持久化下,跳表的局部性修改比红黑树旋转更友好;
- 每个节点额外存
span跨度,可 O(log n) 算出排名(rank),满足ZRANK需求。
- 实际场景:Redis ZSet、LevelDB/RocksDB 的 MemTable、Lucene 的倒排索引 term 字典、并发有序容器(ConcurrentSkipListMap)。
并查集的原理和适用场景?
并查集(Union-Find / Disjoint Set) 维护若干不相交集合,每个集合用一棵树表示,树的根是该集合代表元。支持两个近乎 O(1) 的操作:find(x) 找 x 所属集合根,union(x,y) 合并两集合。核心优化是路径压缩 + 按秩合并。
#include <vector>
class UnionFind {
std::vector<int> parent, rank;
public:
UnionFind(int n) : parent(n), rank(n, 0) {
for (int i = 0; i < n; ++i) parent[i] = i; // 各自独立成集合
}
// 查找根 + 路径压缩:把沿途节点直接挂到根
int find(int x) {
if (parent[x] != x) parent[x] = find(parent[x]);
return parent[x];
}
// 按秩合并:把矮树挂到高树下,避免退化成链
void unite(int x, int y) {
int rx = find(x), ry = find(y);
if (rx == ry) return;
if (rank[rx] < rank[ry]) std::swap(rx, ry);
parent[ry] = rx;
if (rank[rx] == rank[ry]) ++rank[rx];
}
bool connected(int x, int y) { return find(x) == find(y); }
};同时使用路径压缩 + 按秩合并时,单次操作均摊复杂度为反阿克曼函数 α(n),几乎可视为常数 O(1)。只做路径压缩是 O(log n) 均摊,两者结合才是最优。
| 优化 | 作用 |
|---|---|
| 路径压缩 | find 时把节点直接指向根,摊平树高 |
| 按秩/按大小合并 | 合并时小树挂大树,控制树高增长 |
- 实际场景:
- 图连通性:判断两节点是否连通、连通分量计数、Kruskal 最小生成树判环;
- 朋友圈/岛屿数量类题目(「省份数量」);
- 等价关系判定(变量相等性)、网络连通性(两主机是否可达)。
Trie 树(前缀树)的原理和适用场景?
Trie(前缀树/字典树) 是多叉树,节点存「下一字符 → 子节点」映射,从根到某节点的路径即一个字符串前缀。共享公共前缀可大幅压缩空间,且前缀查询、按字典序遍历天然高效。
#include <unordered_map>
#include <string>
struct TrieNode {
std::unordered_map<char, TrieNode*> children; // 字符映射
bool is_end = false; // 是否为某单词结尾
};
class Trie {
TrieNode* root;
public:
Trie() : root(new TrieNode()) {}
void insert(const std::string& word) {
TrieNode* cur = root;
for (char c : word) {
if (!cur->children.count(c))
cur->children[c] = new TrieNode();
cur = cur->children[c];
}
cur->is_end = true;
}
bool search(const std::string& word) { // 完整单词是否存在
TrieNode* cur = root;
for (char c : word) {
if (!cur->children.count(c)) return false;
cur = cur->children[c];
}
return cur->is_end;
}
bool startsWith(const std::string& prefix) { // 是否存在该前缀
TrieNode* cur = root;
for (char c : prefix) {
if (!cur->children.count(c)) return false;
cur = cur->children[c];
}
return true;
}
};复杂度:插入/查询均为 O(m),m 为字符串长度,与词表规模无关。
内存敏感场景可用数组代替哈希表存子节点(TrieNode* children[26],小写字母时),速度快但空间可能浪费;或用「压缩 Trie(Patricia)」合并单分支路径进一步省空间。Trie 的核心价值是共享前缀与前缀匹配。
- 实际场景:
- 搜索引擎/输入法的自动补全、前缀联想;
- 敏感词过滤(AC 自动机基于 Trie);
- IP 路由最长前缀匹配、拼写检查;
- 词频统计、字符串集合的公共前缀查找。
布隆过滤器的原理?如何控制误判率?
布隆过滤器(Bloom Filter) 是空间高效的概率型结构,用长度为 m 的位数组 + k 个哈希函数表示集合。插入时把 key 经 k 个哈希函数映射到的 k 个位置全部置 1;查询时若 k 个位置全为 1 则「可能存在」,有任一为 0 则「一定不存在」。
#include <vector>
#include <string>
#include <functional>
class BloomFilter {
std::vector<bool> bits; // 位数组
std::vector<std::hash<std::string>> hashers; // 简化:k 个哈希用不同种子
int k;
std::size_t nth_hash(const std::string& s, int i) {
// 用双哈希技巧模拟 k 个哈希:h_i = h1 + i*h2
std::size_t h1 = hashers[0](s);
std::size_t h2 = hashers[1](s);
return (h1 + i * h2) % bits.size();
}
public:
BloomFilter(std::size_t m, int k) : bits(m), hashers(2), k(k) {}
void add(const std::string& s) {
for (int i = 0; i < k; ++i) bits[nth_hash(s, i)] = true;
}
bool might_contain(const std::string& s) {
for (int i = 0; i < k; ++i)
if (!bits[nth_hash(s, i)]) return false; // 一定不存在
return true; // 可能存在(可能误判)
}
};如何控制误判率:设 n 为元素数、m 为位数、k 为哈希函数个数,误判率近似:
- 最优哈希函数个数:,此时误判率约 。
- 位越多(m/n 越大)误判率越低:例如 m/n=10 时 p≈1%;m/n=20 时 p≈0.001%。
- 权衡:误判率越低需要越多的位和哈希计算。
布隆过滤器不支持删除(多个 key 可能共享同一位置,置 0 会误删他人);且只会有假阳性(误判存在),不会有假阴性(漏判不存在)——这是它的核心性质。需要删除时改用「计数布隆过滤器」。
典型应用是「缓存穿透防护」:查询前先过布隆过滤器,不在集合内的 key 直接返回,避免打穿到 DB。同理用于:垃圾邮件过滤、爬虫 URL 去重、LevelDB/RocksDB 用布隆过滤器快速判断 SSTable 中是否可能含某 key。
B 树和 B+ 树的区别?为什么数据库索引用 B+ 树?
B 树/B+ 树都是多路平衡搜索树,一个节点可存多个 key、多个子节点,高度极低(百万级数据仅 3~4 层),专为磁盘/外存设计——减少磁盘 I/O 次数。
| 对比 | B 树 | B+ 树 |
|---|---|---|
| 数据存储 | 每个节点(含内部节点)都存数据 | 只有叶子节点存全部数据,内部节点只存索引 |
| 叶子节点连接 | 叶子间无指针 | 叶子用双向链表串联,支持顺序遍历 |
| 查找 | 可能在内部节点命中即返回 | 必须一直走到叶子节点 |
| 范围查询 | 需中序遍历,可能跨层回溯 | 叶子链表顺序扫,高效 |
| 稳定性 | 查找到命中节点位置不定 | 每次查找路径长度固定(都到叶子) |
| 适用 | 部分文件系统、内存索引 | 数据库索引(MySQL InnoDB) |
// B+ 树节点示意(仅结构,非完整实现)
#include <vector>
template <typename K, typename V>
struct BPlusNode {
bool is_leaf;
std::vector<K> keys; // 内部节点存 key 索引
std::vector<BPlusNode*> children; // 内部节点的子指针
std::vector<V> values; // 叶子节点存数据
BPlusNode* next_leaf = nullptr; // 叶子节点串成链表
};数据库索引用 B+ 树而非 B 树/红黑树的核心原因:
- 磁盘 I/O 友好:多路、矮胖,一次磁盘页读入更多 key,树高 3
4 层即可容纳千万数据,查询只需 34 次 I/O;红黑树是二叉树,高度高、I/O 次数多。 - 范围查询/顺序扫描高效:叶子链表 + 全部数据在叶子,
ORDER BY/范围查询/全表扫描都顺着叶子链表走即可。 - 查询稳定:每次都走到叶子,路径等长,最坏性能可预期。
- 内部节点只存 key 不存数据,单页能容纳更多索引项,进一步降低树高。
B 树「内部节点也存数据」意味着命中内部节点可提前返回,个别点查略快,但范围查询要跨层回溯,不适合数据库主键排序扫描为主的负载。MySQL InnoDB 主键索引(聚簇索引)即 B+ 树,叶子存完整行;辅助索引叶子存主键值,回表再查主键索引。
- 实际场景:MySQL InnoDB 索引、大多数关系型数据库索引、文件系统目录结构(NTFS、ext4 用类 B+ 树/B 树变体)。
如何实现 LRU 缓存?
LRU(Least Recently Used,最近最少使用) 缓存淘汰策略:容量满时淘汰「最久未被访问」的项。经典实现是哈希表 + 双向链表:哈希表负责 O(1) 定位 key→节点,双向链表维护访问顺序(头部最新、尾部最旧)。
#include <unordered_map>
#include <list>
class LRUCache {
int cap;
std::list<std::pair<int, int>> lru; // 双向链表:头新尾旧
std::unordered_map<int, std::list<std::pair<int, int>>::iterator> mp;
public:
LRUCache(int capacity) : cap(capacity) {}
int get(int key) {
auto it = mp.find(key);
if (it == mp.end()) return -1;
lru.splice(lru.begin(), lru, it->second); // 移到头部(最新)
return it->second->second;
}
void put(int key, int value) {
auto it = mp.find(key);
if (it != mp.end()) { // 已存在:更新并移到头部
lru.splice(lru.begin(), lru, it->second);
it->second->second = value;
return;
}
if (lru.size() == cap) { // 满了:淘汰尾部(最旧)
mp.erase(lru.back().first);
lru.pop_back();
}
lru.emplace_front(key, value);
mp[key] = lru.begin();
}
};std::list::splice 是 O(1) 地把节点移动到头部的高效写法,避免「erase + push_front」两次操作。核心考点是为什么用双向链表而不是单向:删除尾节点时,双向链表能通过尾节点的 prev O(1) 定位到新的尾,单向链表则需 O(n) 遍历。
常见坑:① 用 std::list 存迭代器到 map 时,splice/erase 后要同步更新 map;② get 命中也算「访问」,必须刷新到头部,否则淘汰顺序错;③ 若用 std::map 代替 unordered_map 定位,整体会从 O(1) 退化 O(log n)。
- 实际场景:操作系统页面置换、CPU 缓存、浏览器历史/缓存、Redis 内存淘汰策略(
allkeys-lru)、数据库缓冲池(InnoDB Buffer Pool 的 LRU 链表)。
线段树的原理和适用场景?
线段树(Segment Tree) 是一棵平衡二叉树,把区间 [l, r] 递归二分:根节点代表整个区间,每个节点代表一个子区间,叶子节点代表单点。每个节点保存该区间的聚合信息(和、最值等),从而把「区间查询」和「区间更新」都降到 O(log n)。
原理示意:根节点 [0, n-1],节点 [l, r] 的左子是 [l, mid]、右子是 [mid+1, r](mid=(l+r)/2)。用数组紧凑存储,节点 i 的左子 2i、右子 2i+1(下标从 1 起),空间需开 4n。懒惰标记(lazy):区间更新时若当前节点区间被完全覆盖,只在该节点打标记、不递归到叶子,等下次访问其子区间时再「下传」(pushdown),从而把区间加法也优化到 O(log n)。
#include <vector>
// 线段树:维护区间和,支持「区间加法 + 区间查询」
class SegmentTree {
std::vector<long long> tree, lazy; // 各开 4n 空间
int n;
void build(const std::vector<int>& a, int node, int l, int r) {
if (l == r) { tree[node] = a[l]; return; }
int mid = (l + r) >> 1;
build(a, node * 2, l, mid);
build(a, node * 2 + 1, mid + 1, r);
tree[node] = tree[node * 2] + tree[node * 2 + 1]; // 自底向上合并
}
void pushdown(int node, int l, int r) { // 懒标记下传
if (lazy[node] == 0) return;
int mid = (l + r) >> 1;
tree[node * 2] += lazy[node] * (mid - l + 1);
tree[node * 2 + 1] += lazy[node] * (r - mid);
lazy[node * 2] += lazy[node];
lazy[node * 2 + 1] += lazy[node];
lazy[node] = 0;
}
void range_add(int node, int l, int r, int ql, int qr, long long val) {
if (ql <= l && r <= qr) { // 完全覆盖:直接打懒标记
tree[node] += val * (r - l + 1);
lazy[node] += val;
return;
}
pushdown(node, l, r); // 部分覆盖:先下传再递归
int mid = (l + r) >> 1;
if (ql <= mid) range_add(node * 2, l, mid, ql, qr, val);
if (qr > mid) range_add(node * 2 + 1, mid + 1, r, ql, qr, val);
tree[node] = tree[node * 2] + tree[node * 2 + 1];
}
long long query(int node, int l, int r, int ql, int qr) {
if (ql <= l && r <= qr) return tree[node];
pushdown(node, l, r);
int mid = (l + r) >> 1;
long long res = 0;
if (ql <= mid) res += query(node * 2, l, mid, ql, qr);
if (qr > mid) res += query(node * 2 + 1, mid + 1, r, ql, qr);
return res;
}
public:
SegmentTree(const std::vector<int>& a) : n(a.size()) {
tree.assign(4 * n, 0);
lazy.assign(4 * n, 0);
build(a, 1, 0, n - 1);
}
void range_add(int l, int r, long long val) { range_add(1, 0, n - 1, l, r, val); }
long long query(int l, int r) { return query(1, 0, n - 1, l, r); }
};| 操作 | 复杂度 | 说明 |
|---|---|---|
| 建树 | O(n) | 每个节点恰好访问一次 |
| 单点/区间更新 | O(log n) | 懒标记避免递归到叶子 |
| 区间查询 | O(log n) | 至多覆盖约 2·log n 个节点 |
| 空间 | O(4n) | 数组存储,实际约 4n |
线段树与「树」章节的递归分治一脉相承:本质是区间二分 + 懒标记。两个高频考点:① 懒标记必须在 pushdown 中下传并清零,否则区间更新会重复累加;② 空间要开 4n(不是 2n,最坏情况节点数接近 4n),避免数组越界。进阶还有「线段树合并」「动态开点(值域线段树/主席树)」「扫描线求矩形面积并」。
对比 树状数组:线段树功能更强,能维护区间最值、区间修改、区间最大子段和等任意可合并信息,但常数和代码量更大。若只需要「前缀和 + 单点更新」,树状数组更轻量。
- 实际场景:区间求和/最值(RMQ)、排行榜区间统计、LeetCode 区间类题目(区域和检索、区间加法)、计算几何扫描线(矩形面积并/周长并)、线段树维护区间最大公约数/最大子段和、持久化(主席树)求区间第 K 大。
树状数组(BIT)的原理和适用场景?
树状数组(BIT,Binary Indexed Tree / Fenwick Tree) 用一棵「前缀和」思想维护的数组,支持 O(log n) 单点更新 + O(log n) 前缀和查询,空间 O(n),常数极小、实现只有几行。核心是 lowbit:lowbit(x) = x & (-x),取 x 二进制最低位的 1。
原理:tree[i] 维护区间 [i - lowbit(i) + 1, i] 的和。更新时沿 i += lowbit(i) 向上「通知」所有覆盖它的节点;查询前缀和时沿 i -= lowbit(i) 向下累加。因 lowbit 把下标按 2 的幂分组,更新和查询都只经过 O(log n) 个节点。区间和 [l, r] = sum(r) - sum(l-1)。
#include <vector>
// 树状数组:单点更新 + 前缀和查询(下标从 1 开始)
class Fenwick {
std::vector<long long> tree;
int n;
static int lowbit(int x) { return x & (-x); }
public:
Fenwick(int n) : tree(n + 1, 0), n(n) {}
// 单点更新:位置 i 加 val,O(log n)
void add(int i, long long val) {
for (; i <= n; i += lowbit(i)) tree[i] += val;
}
// 前缀和 [1, i],O(log n)
long long sum(int i) {
long long res = 0;
for (; i > 0; i -= lowbit(i)) res += tree[i];
return res;
}
// 区间和 [l, r]
long long range_sum(int l, int r) {
return sum(r) - sum(l - 1);
}
};
树状数组经典变体——差分 + BIT 实现「区间更新 + 单点查询」:对原数组建差分数组 d[i] = a[i] - a[i-1],[l, r] 区间加 val 等价于 d[l] += val、d[r+1] -= val 两次单点更新,单点 a[i] 即 sum(d[1..i])。还可结合离散化求逆序对:倒序遍历,用 BIT 统计已出现且小于当前值的个数。
| 维度 | 树状数组 BIT | 线段树 |
|---|---|---|
| 代码量 | 极小(10 行内) | 较大 |
| 常数/内存 | 小(O(n)) | 大(O(4n)) |
| 能维护 | 前缀和/可差分信息、单点更新 | 区间和/最值/任意可合并信息、区间更新(懒标记) |
| 是否支持区间最值 | 不支持 | 支持 |
| 适用 | 动态前缀和、逆序对、排名 | 通用区间操作 |
树状数组本质是线段树的「压缩版」:它只保留了线段树中「前缀和」这条主链的信息,因此只能维护前缀和这类可相减(差分)的信息,不能像线段树那样维护区间最值、区间修改。能用 BIT 解决时优先 BIT(快、省内存),需要通用区间操作时用 线段树。
- 实际场景:动态维护前缀和(数据流式累加 + 查询)、求逆序对/顺序对(离散化)、排行榜的实时排名与分数更新、算法题「区域和检索 - 数组可修改」(LeetCode 307)、差分数组求区间更新后单点值。