数据结构详解与算法关联指南
理解数据结构的本质,掌握数据结构与算法的深层联系
目录
- 数据结构总览
- 数组:连续存储的基石
- 链表:离散存储的灵活结构
- 栈与队列:受限访问的有序结构
- 哈希表:极速查找的映射结构
- 树:层次组织的递归结构
- 堆:优先级管理的特殊树
- 图:复杂关系的网络结构
- 数据结构选择策略
- 数据结构与算法映射表
1. 数据结构总览
1.1 什么是数据结构?
本质理解:数据结构是数据的"组织方式",决定了数据的存储方式和访问方式。
数据 + 结构 = 数据如何存储 + 如何访问
类比理解:
- 数组 = 一排连续的柜子(编号访问)
- 链表 = 一串连接的盒子(顺序访问)
- 栈 = 只能从顶部取的盘子堆(后进先出)
- 队列 = 只能从一头进的排队(先进先出)
- 树 = 分层的管理结构(上下级关系)
- 图 = 复杂的人际网络(任意连接)
1.2 数据结构的分类
数据结构
├── 线性结构(一对一关系)
│ ├── 数组(连续存储)
│ ├── 链表(离散存储)
│ ├── 栈(受限访问:只能一端操作)
│ ├── 队列(受限访问:两端操作)
│ └── 哈希表(映射存储)
│
├── 树形结构(一对多关系)
│ ├── 二叉树
│ ├── 二叉搜索树(BST)
│ ├── 平衡树(AVL、红黑树)
│ ├── 堆(优先队列)
│ └── B树/B+树
│
└── 图结构(多对多关系)
├── 有向图
├── 无向图
├── 加权图
└── 稀疏图/稠密图
1.3 数据结构的三个维度
存储维度:数据如何在内存中存储
- 连续存储(数组):地址连续,索引访问快
- 离散存储(链表):地址不连续,指针连接
- 映射存储(哈希表):键值映射,散列存储
访问维度:数据如何被访问
- 随机访问(数组):O(1)索引访问
- 顺序访问(链表):O(n)顺序遍历
- 受限访问(栈/队列):只能特定位置访问
- 键值访问(哈希表):O(1)键查找
操作维度:支持哪些操作
- 插入:在哪里插入?O(?)复杂度?
- 删除:删除哪个元素?O(?)复杂度?
- 查找:查找特定元素?O(?)复杂度?
- 遍历:如何访问所有元素?
1.4 数据结构选择的核心问题
选择数据结构时,回答四个核心问题:
Q1: 数据规模多大?(决定空间效率)
Q2: 主要操作是什么?(插入/删除/查找/遍历?)
Q3: 操作频率如何?(频繁查找?频繁插入?)
Q4: 时间要求?(O(1)? O(n)? O(log n)?)
2. 数组:连续存储的基石
2.1 数组的基本原理
物理存储:数组在内存中连续存储,每个元素占用固定大小空间。
内存地址: 1000 1004 1008 1012 1016
数组元素: arr[0] arr[1] arr[2] arr[3] arr[4]
元素值: 1 2 3 4 5
地址计算公式:address(arr[i]) = base + i * sizeof(type)
为什么索引从0开始?
如果索引从1开始:
address(arr[1]) = base + 1 * sizeof(type)
address(arr[i]) = base + i * sizeof(type)
计算arr[1]的地址需要额外减1:
address(arr[1]) = base + (1-1) * sizeof(type) = base
从0开始可以直接计算,无需额外减法操作。
核心特性:
1. 连续存储 → 索引访问 O(1)
2. 固定大小 → 预分配空间
3. 元素类型相同 → sizeof固定
4. 内存连续 → 缓存友好
2.2 数组的时间复杂度
| 操作 | 时间复杂度 | 原因 |
|---|---|---|
| 索引访问 | O(1) | 直接计算地址 |
| 修改元素 | O(1) | 知道位置直接修改 |
| 尾部插入 | O(1) | 直接放后面(动态数组需考虑扩容) |
| 中间插入 | O(n) | 后面元素都要移动 |
| 中间删除 | O(n) | 后面元素都要移动 |
| 查找元素 | O(n) | 需要遍历比较 |
| 排序 | O(n log n) | 使用高效排序算法 |
2.3 数组与算法的关联
数组的连续性 → 算法的基石
连续存储的优势:
├── 索引访问快 → 支持随机访问算法
├── 内存连续 → 缓存命中率高 → 性能好
├── 地址可计算 → 支持二分查找(跳跃访问)
└── 局部性好 → 支持双指针、滑动窗口
适合数组的算法:
| 算法 | 利用数组的什么特性 | 为什么高效 |
|---|---|---|
| 二分查找 | 连续存储+索引访问 | 可以跳跃访问,每次排除一半 |
| 双指针 | 连续存储+索引访问 | 两个指针协同,避免嵌套循环 |
| 滑动窗口 | 连续存储+索引访问 | 动态调整边界,无需重建 |
| 前缀和 | 连续存储+索引访问 | 预处理区间信息,O(1)查询 |
| 快速排序 | 索引交换 | 原地排序,无需额外空间 |
| 堆排序 | 索引表示父子关系 | 数组模拟堆结构 |
2.4 二维数组(矩阵)
内存布局:
C++:行主序(一行接一行)
内存:[row0_col0][row0_col1]...[row0_colN][row1_col0][row1_col1]...
地址计算:address(arr[i][j]) = base + (i * cols + j) * sizeof(type)
Java:同样是行主序
遍历建议:外层遍历行,内层遍历列(缓存友好)
为什么行主序遍历更快?
行主序遍历:访问arr[0][0]→arr[0][1]→arr[0][2]...(连续地址)
缓存一次加载多个连续地址到缓存行,后续访问命中率高。
列主序遍历:访问arr[0][0]→arr[1][0]→arr[2][0]...(跳跃地址)
每次访问可能跨越缓存行,缓存命中率低。
2.5 数组的局限性
局限性:
├── 大小固定(静态数组) → 不灵活
├── 插入删除慢 → O(n)移动元素
├── 内存浪费 → 预分配可能过大
└── 连续内存要求 → 大数组可能分配失败
解决方案:
├── 动态数组(vector) → 按需扩容
├── 链表 → 频繁插入删除用链表
├── 预估大小 → 根据需求合理预分配
└── 分段数组 → 大数组分段存储
2.6 动态数组(vector)原理
扩容机制:
初始容量:capacity = n
已用大小:size = m
当 size == capacity 时:
1. 分配新内存:new_capacity = 2 * capacity(通常翻倍)
2. 复制旧元素:copy(old, new)
3. 释放旧内存:delete old
4. 更新指针:指向新内存
时间复杂度:
- 单次扩容:O(n)
- 均摊分析:O(1)(每次扩容后可以用很久)
为什么翻倍而不是固定增加?
翻倍扩容:
n次插入的总复制次数:1 + 2 + 4 + ... + n ≈ 2n
均摊每次插入:O(1)
固定增加k:
n次插入的总复制次数:k + 2k + 3k + ... = k*n²/k = n²
均摊每次插入:O(n)
翻倍扩容保证均摊O(1)。
3. 链表:离散存储的灵活结构
3.1 链表的基本原理
物理存储:链表节点离散存储,通过指针连接。
节点结构:
struct Node {
int data; // 数据域
Node* next; // 指针域(指向下一个节点)
};
内存布局:
节点A(data=1, next→B) 节点B(data=2, next→C) 节点C(data=3, next→NULL)
地址:1000 地址:2000 地址:3000
↓ ↓ ↓
[1|→B] → [2|→C] → [3|NULL]
核心特性:
1. 离散存储 → 无需连续内存
2. 动态大小 → 随时添加删除节点
3. 顺序访问 → 只能从头遍历
4. 插入删除快 → 只改指针,O(1)
5. 内存开销 → 每个节点额外存指针
3.2 链表的时间复杂度
| 操作 | 时间复杂度 | 原因 |
|---|---|---|
| 索引访问 | O(n) | 必须从头遍历到位置 |
| 头部插入 | O(1) | 直接改头指针 |
| 头部删除 | O(1) | 直接改头指针 |
| 中间插入 | O(n) | 先遍历到位置,再插入 O(1) |
| 中间删除 | O(n) | 先遍历到位置,再删除 O(1) |
| 查找元素 | O(n) | 需要遍历比较 |
| 反转链表 | O(n) | 需要遍历所有节点 |
3.3 链表与算法的关联
链表的离散性 → 算法的约束
离散存储的限制:
├── 无索引访问 → 不能直接跳到某个位置
├── 必须顺序访问 → 不能用二分查找
├── 无法随机访问 → 不能用滑动窗口
└── 无连续性 → 缓存效率低
离散存储的优势:
├── 插入删除快 → 链表操作算法
├── 大小灵活 → 动态管理
├── 无需连续内存 → 可处理大数据
└── 特殊指针操作 → 快慢指针算法
适合链表的算法:
| 算法 | 利用链表的什么特性 | 为什么适合 |
|---|---|---|
| 快慢指针 | 指针操作灵活 | 可以控制步数,找中点、判断环 |
| 虚拟头节点 | 指针操作灵活 | 统一头节点和非头节点的处理 |
| 反转链表 | 指针操作灵活 | 只改指针,不移动数据 |
| 合并链表 | 指针操作灵活 | 比较后改指针连接 |
| 删除节点 | 插入删除O(1) | 找到后直接改指针 |
3.4 快慢指针原理
为什么快慢指针能找中点?
慢指针:每次走1步
快指针:每次走2步
设链表长度为n:
慢指针走过的步数:s
快指针走过的步数:f = 2s
当快指针到达终点(f = n)时:
慢指针位置:s = n/2 → 正好在中点!
数学推导:
起点 → 1步 → 2步 → 3步 → ... → n步(终点)
慢指针:0 → 1 → 2 → ... → n/2
快指针:0 → 2 → 4 → ... → n
为什么快慢指针能判断环?
无环:快指针最终到达终点(NULL)
有环:快指针在环内绕圈,慢指针进入环后会被快指针追上
追上的原因:
快指针相对慢指针每次多走1步(相对速度为1)
环长度为k时,慢指针进入环后最多走k步就会被追上
数学证明:
慢指针在环内位置:s
快指针在环内位置:f = 2s mod k
相遇条件:f == s(mod k)
即:2s mod k == s mod k → s mod k == 0
最多k步后必定相遇。
3.5 虚拟头节点原理
为什么需要虚拟头节点?
问题:删除链表节点时,头节点和非头节点处理逻辑不同
删除非头节点:
prev->next = cur->next;
delete cur;
删除头节点:
head = head->next;
delete old_head;
两种逻辑不同,代码需要分支判断。
虚拟头节点解决方案:
创建dummy节点,dummy->next = head;
统一处理:所有节点都有"前驱节点"
删除逻辑统一:prev->next = cur->next;
代码统一,无需特殊判断。
3.6 链表 vs 数组对比
| 特性 | 数组 | 链表 |
|---|---|---|
| 内存存储 | 连续 | 离散 |
| 空间效率 | 高(无额外开销) | 低(每个节点存指针) |
| 索引访问 | O(1) | O(n) |
| 插入删除 | O(n)(移动元素) | O(1)(改指针) |
| 缓存效率 | 高(连续内存) | 低(跳跃访问) |
| 大小管理 | 固定或动态扩容 | 动态,随时增删 |
| 适用场景 | 查找多、修改少 | 插入删除频繁 |
选择建议:
选择数组:
- 需要频繁索引访问
- 数据大小相对固定
- 内存空间有限
- 查找操作频繁
选择链表:
- 需要频繁插入删除
- 数据大小变化大
- 无法预知数据量
- 不需要随机访问
4. 栈与队列:受限访问的有序结构
4.1 栈的基本原理
定义:栈是只能在一端(栈顶)进行插入和删除的线性结构。
特性:LIFO(Last In First Out)后进先出
类比:一摞盘子,只能从顶部取
栈结构:
栈顶
↓
[元素D] ← 只能从这里操作
[元素C]
[元素B]
[元素A]
↑
栈底
操作:
push:压栈(顶部插入)
pop:弹栈(顶部删除)
top:查看栈顶元素
核心特性:
1. 单端操作 → 只能操作栈顶
2. 后进先出 → 最新元素最先处理
3. 顺序有序 → 保持插入顺序
4. 操作简单 → 只有push/pop两种
4.2 栈的时间复杂度
| 操作 | 时间复杂度 | 原因 |
|---|---|---|
| push | O(1) | 直接添加到栈顶 |
| pop | O(1) | 直接删除栈顶 |
| top | O(1) | 直接访问栈顶 |
| 查找 | O(n) | 需要遍历所有元素 |
4.3 栈与算法的关联
栈的LIFO特性 → 算法应用
后进先出的应用:
├── 递归模拟 → 保存函数调用状态
├── 括号匹配 → 最近遇到的括号先匹配
├── 表达式计算 → 运算符优先级处理
├── DFS迭代 → 保存待访问节点
├── 树遍历 → 前序/中序/后序迭代版
└── 单调栈 → 维护单调序列
适合栈的算法:
| 算法 | 利用栈的什么特性 | 为什么适合 |
|---|---|---|
| 括号匹配 | LIFO:最近遇到的先匹配 | 左括号等待右括号,最近的先配对 |
| 表达式计算 | LIFO:运算符优先级 | 高优先级运算符先出栈计算 |
| DFS迭代版 | LIFO:后进先出遍历 | 子节点后入栈,先出栈访问 |
| 单调栈 | LIFO:维护单调序列 | 不满足单调性的元素弹出 |
| 二叉树遍历 | LIFO:模拟递归 | 递归本质是栈调用 |
4.4 栈模拟递归原理
递归的本质是栈:
递归调用:
func(3) {
func(2) {
func(1) {
// 处理
}
// 处理
}
// 处理
}
栈模拟:
push(3)
push(2)
push(1)
pop → 处理1
pop → 处理2
pop → 处理3
递归就是隐式使用栈,迭代是显式使用栈。
4.5 括号匹配原理
为什么用栈?
括号匹配规则:
遇到左括号 → 等待匹配
遇到右括号 → 匹配最近的左括号
栈的作用:
遇到左括号 → push进栈
遇到右括号 → pop栈顶匹配
栈空或不匹配 → 错误
为什么最近?
因为括号必须成对且有序:
[()] 正确:[等待(,(等待),)匹配(,]匹配[
[(]) 错误:[等待(,(等待],]不能匹配(
栈的LIFO特性正好满足"最近匹配"需求。
4.6 队列的基本原理
定义:队列是只能在一端(队尾)插入、另一端(队头)删除的线性结构。
特性:FIFO(First In First Out)先进先出
类比:排队买票,先排队的人先买
队列结构:
队头 队尾
↓ ↓
[元素A][元素B][元素C][元素D] ← 只能从队尾插入
↑
只能从队头删除
操作:
enqueue:入队(队尾插入)
dequeue:出队(队头删除)
front:查看队头元素
4.7 队列与算法的关联
队列的FIFO特性 → 算法应用
先进先出的应用:
├── BFS → 按层次遍历
├── 滑动窗口 → 维护窗口元素
├── 任务调度 → 先来先服务
├── 生产者-消费者 → 缓冲区
└── 消息队列 → 异步处理
适合队列的算法:
| 算法 | 利用队列的什么特性 | 为什么适合 |
|---|---|---|
| BFS | FIFO:先入队的先处理 | 层次遍历,上一层先处理完 |
| 滑动窗口 | FIFO:维护窗口顺序 | 窗口左端出队,右端入队 |
| 层序遍历 | FIFO:按层处理 | 父节点先入队,子节点后入队 |
4.8 BFS用队列原理
为什么BFS用队列?
BFS遍历顺序:
按层次遍历:第1层 → 第2层 → 第3层 ...
队列保证顺序:
父节点入队 → 出队时访问 → 子节点入队
同一层节点按入队顺序依次访问
子节点在父节点之后入队 → 保证层次顺序
对比DFS:
DFS用栈:后入栈的先访问(深入优先)
BFS用队列:先入队的先访问(横向优先)
4.9 双端队列(deque)
定义:两端都可以插入和删除的队列。
操作:
push_front:前端插入
push_back:后端插入
pop_front:前端删除
pop_back:后端删除
应用:
滑动窗口:两端都可能移除元素
4.10 优先队列(堆)
定义:按优先级出队的队列,总是取出优先级最高的元素。
实现:堆(完全二叉树)
特性:
- 插入:O(log n)
- 取出最高优先级:O(1)
- 删除最高优先级:O(log n)
应用:
- Top K问题:维护最大的K个元素
- 任务调度:优先级高的先执行
- Dijkstra算法:找最短距离节点
5. 哈希表:极速查找的映射结构
5.1 哈希表的基本原理
定义:哈希表通过哈希函数将键映射到存储位置,实现快速查找。
核心思想:
键 → 哈希函数 → 存储位置 → 值
工作流程:
1. 计算哈希值:hash = hash_func(key)
2. 计算存储位置:index = hash % table_size
3. 存取数据:table[index] = value
示例:
key = "apple"
hash = hash_func("apple") = 5
index = 5 % 10 = 5
table[5] = "苹果"
核心特性:
1. 键值映射 → 快速定位数据位置
2. 平均O(1)查找 → 直接计算位置
3. 无序存储 → 不保持插入顺序(unordered)
4. 键唯一 → 每个键对应一个值
5. 空间换时间 → 预分配空间加速查找
5.2 哈希函数原理
哈希函数的作用:将任意键转换为固定范围的整数。
好的哈希函数特性:
1. 均匀分布:键均匀映射到各个位置
2. 计算快速:哈希计算效率高
3. 确定性:相同键总是得到相同哈希值
常见哈希函数:
- 整数:hash = key(直接用)
- 字符串:hash = Σ(char_i * base^i) mod table_size
- 自定义:std::hash<T>
5.3 哈希碰撞处理
碰撞问题:不同键映射到同一位置。
原因:
table_size有限,键无限多
必然存在:key1 ≠ key2,但 hash(key1) == hash(key2)
解决方法:
├── 链地址法(拉链法)
│ 每个位置存储链表,碰撞元素追加到链表
│ C++ unordered_map/set 使用此方法
│
├── 开放地址法
│ 碰撞时寻找下一个空位置
│ 线性探测:+1, +2, +3...
│ 二次探测:+1², +2², +3²...
│
└── 再哈希法
使用多个哈希函数
链地址法详解:
哈希表结构:
table[0] → nullptr
table[1] → [key1, value1] → [key2, value2] → nullptr
table[2] → [key3, value3] → nullptr
...
查找流程:
hash = hash_func(key)
index = hash % table_size
遍历 table[index] 的链表查找 key
时间复杂度:
理想(无碰撞):O(1)
最坏(全部碰撞):O(n)
平均(良好哈希):O(1)
5.4 哈希表的时间复杂度
| 操作 | 平均 | 最坏 | 原因 |
|---|---|---|---|
| 插入 | O(1) | O(n) | 最坏全部碰撞到同一位置 |
| 删除 | O(1) | O(n) | 同上 |
| 查找 | O(1) | O(n) | 同上 |
5.5 哈希表与算法的关联
哈希表的映射特性 → 算法应用
键值映射的应用:
├── 快速查找 → 判断元素是否存在
├── 计数 → 统计元素出现次数
├── 去重 → 判断是否重复
├── 映射 → 存储键值关系
└── 缓存 → 快速数据访问
适合哈希表的算法:
| 算法 | 利用哈希表的什么特性 | 为什么适合 |
|---|---|---|
| 两数之和 | O(1)查找 | 查找目标值-当前值 |
| 判断重复 | 键唯一性 | 重复键碰撞 |
| 字符计数 | 键值存储 | 记录每个字符出现次数 |
| 交集/并集 | 键唯一性 | 快速判断是否存在 |
| 最长无重复子串 | 键唯一性 | 判断字符是否在窗口内 |
5.6 哈希表应用场景
使用哈希表场景:
├── 需要快速查找(O(1))
├── 需要判断元素是否存在
├── 需要统计出现次数
├── 需要去重
├── 需要存储映射关系
└── 键的类型支持哈希
不适合哈希表场景:
├── 需要保持有序
├── 需要范围查找
├── 需要遍历所有元素
├── 键类型不支持哈希(自定义类型需定义哈希函数)
└── 内存受限(哈希表空间开销大)
5.7 哈希表 vs 有序容器
| 特性 | 哈希表(unordered) | 有序容器(ordered) |
|---|---|---|
| 查找 | O(1) 平均 | O(log n) |
| 插入 | O(1) 平均 | O(log n) |
| 删除 | O(1) 平均 | O(log n) |
| 有序遍历 | 不支持 | 支持 |
| 范围查找 | 不支持 | 支持 |
| 空间效率 | 较低 | 较高 |
| 实现复杂度 | 中等 | 高(平衡树) |
选择建议:
选择哈希表:
- 只需要单点查找
- 不需要有序遍历
- 查找操作频繁
- 键类型可哈希
选择有序容器:
- 需要有序遍历
- 需要范围查找
- 需要找前驱后继
- 需要最值查找
6. 树:层次组织的递归结构
6.1 树的基本原理
定义:树是由节点组成的层次结构,每个节点有一个父节点(除根节点)和多个子节点。
树的结构:
根节点(A)
/ \
子节点(B) 子节点(C)
/ \ \
叶节点(D) 叶节点(E) 叶节点(F)
术语:
- 根节点:没有父节点
- 叶节点:没有子节点
- 层次:根在第1层,子节点在第2层...
- 深度:从根到某节点的路径长度
- 高度:从某节点到叶节点的最长路径长度
- 子树:以某节点为根的树
核心特性:
1. 层次结构 → 父子关系
2. 递归定义 → 子树也是树
3. 无环 → 不能回到祖先
4. 根唯一 → 只有一个根节点
5. 节点有限 → 有n个节点的树有n-1条边
6.2 二叉树
定义:每个节点最多有两个子节点的树。
二叉树结构:
A
/ \
B C
/ \ \
D E F
节点表示:
struct TreeNode {
int val;
TreeNode* left; // 左子节点
TreeNode* right; // 右子节点
};
特殊类型:
满二叉树:
每个节点要么是叶节点,要么有两个子节点
A
/ \
B C
/ \ / \
D E F G
完全二叉树:
前k-1层满,第k层从左到右填充
A
/ \
B C
/ \ /
D E F
二叉搜索树(BST):
左子树值 < 根值 < 右子树值
5
/ \
3 7
/ \ / \
2 4 6 8
6.3 树的存储方式
链式存储:
cpp
struct TreeNode {
int val;
TreeNode* left;
TreeNode* right;
};
// 空间开销:每个节点存2个指针
// 适合:任意形状的树
数组存储(完全二叉树):
索引关系:
父节点索引:parent = (i - 1) / 2
左子节点索引:left = 2 * i + 1
右子节点索引:right = 2 * i + 2
示例:
A(0)
/ \
B(1) C(2)
/ \ /
D(3)E(4)F(5)
数组:[A, B, C, D, E, F]
优点:
- 空间紧凑(无指针开销)
- 索引访问快(堆排序用此存储)
缺点:
- 只适合完全二叉树
- 需要预留空间
6.4 树与算法的关联
树的层次性 → 算法应用
层次结构的应用:
├── 递归遍历 → 天然适合递归
├── DFS → 深度探索
├── BFS → 层次遍历
├── 分治 → 子树独立处理
└── 动态规划 → 树形DP
适合树的算法:
| 算法 | 利用树的什么特性 | 为什么适合 |
|---|---|---|
| DFS | 层次结构 | 深入探索子树 |
| BFS | 层次结构 | 按层遍历 |
| 递归遍历 | 递归定义 | 子树也是树 |
| 分治 | 子树独立性 | 子树可独立处理 |
| 树形DP | 子树独立性 | 子树结果可传递 |
6.5 树遍历原理
前序遍历:根 → 左 → 右
递归:
void preorder(TreeNode* root) {
if (!root) return;
visit(root); // 先访问根
preorder(root->left); // 递归左子树
preorder(root->right); // 递归右子树
}
迭代(栈):
void preorder(TreeNode* root) {
stack<TreeNode*> stk;
stk.push(root);
while (!stk.empty()) {
TreeNode* node = stk.top(); stk.pop();
visit(node);
if (node->right) stk.push(node->right); // 右先入栈
if (node->left) stk.push(node->left); // 左后入栈(先出栈)
}
}
应用:复制树、前缀表达式
中序遍历:左 → 根 → 右
递归:
void inorder(TreeNode* root) {
if (!root) return;
inorder(root->left); // 先递归左子树
visit(root); // 访问根
inorder(root->right); // 递归右子树
}
迭代(栈):
void inorder(TreeNode* root) {
stack<TreeNode*> stk;
TreeNode* cur = root;
while (cur || !stk.empty()) {
while (cur) { // 左子树全部入栈
stk.push(cur);
cur = cur->left;
}
cur = stk.top(); stk.pop();
visit(cur); // 访问
cur = cur->right; // 转右子树
}
}
应用:BST排序输出、中缀表达式
后序遍历:左 → 右 → 根
递归:
void postorder(TreeNode* root) {
if (!root) return;
postorder(root->left); // 递归左子树
postorder(root->right); // 递归右子树
visit(root); // 最后访问根
}
迭代(栈):
方法:前序(根→左→右)反转 = 后序(左→右→根)
1. 前序遍历改为:根→右→左
2. 反转结果得到:左→右→根
应用:计算树高度、删除树、后缀表达式
层序遍历:按层从左到右
迭代(队列):
void levelorder(TreeNode* root) {
queue<TreeNode*> q;
q.push(root);
while (!q.empty()) {
int size = q.size(); // 当前层节点数
for (int i = 0; i < size; i++) {
TreeNode* node = q.front(); q.pop();
visit(node);
if (node->left) q.push(node->left);
if (node->right) q.push(node->right);
}
}
}
应用:按层处理、找某层节点
6.6 二叉搜索树(BST)
特性:
1. 左子树所有值 < 根值
2. 右子树所有值 > 根值
3. 左右子树也是BST
4. 中序遍历 = 升序序列
BST查找原理:
查找流程:
target < root → 在左子树查找
target > root → 在右子树查找
target == root → 找到
时间复杂度:
平均:O(log n)(平衡树)
最坏:O(n)(退化为链表)
为什么高效?
每次比较排除一半子树(类似二分查找)
BST插入原理:
插入流程:
1. 查找插入位置(类似查找)
2. 找到空位置后创建节点
3. 连接到父节点
时间复杂度:O(log n) 平均
BST删除原理:
删除流程:
1. 叶节点:直接删除
2. 只有一个子节点:用子节点替换
3. 有两个子节点:
- 找右子树最小节点(或左子树最大节点)
- 用该节点替换被删除节点
- 删除替换节点原位置
为什么用右子树最小?
右子树最小 > 左子树所有值
右子树最小 < 右子树其他值
满足BST性质
7. 堆:优先级管理的特殊树
7.1 堆的基本原理
定义:堆是满足堆性质的完全二叉树,通常用数组存储。
堆性质:
大顶堆:父节点 ≥ 子节点
小顶堆:父节点 ≤ 子节点
大顶堆示例:
9(0)
/ \
8(1) 7(2)
/ \ /
5(3)6(4) 4(5)
数组存储:[9, 8, 7, 5, 6, 4]
索引关系:
父节点:(i-1)/2
左子节点:2*i+1
右子节点:2*i+2
核心特性:
1. 完全二叉树 → 数组存储高效
2. 堆顶是最大/最小 → 优先队列
3. 部分有序 → 不是完全排序
4. 操作高效 → 插入删除O(log n)
7.2 堆的时间复杂度
| 操作 | 时间复杂度 | 原因 |
|---|---|---|
| 取堆顶 | O(1) | 直接访问根节点 |
| 插入 | O(log n) | 向上调整 |
| 删除堆顶 | O(log n) | 向下调整 |
| 堆化 | O(n) | 从底向上构建 |
7.3 堆调整原理
向上调整(插入):
插入流程:
1. 新元素加到数组末尾
2. 与父节点比较
3. 不满足堆性质则交换
4. 重复直到满足或到达根节点
示例(大顶堆):
插入3到[9,8,7,5,6]
数组变为[9,8,7,5,6,3]
父节点(5):3<5,满足,停止
插入10到[9,8,7,5,6]
数组变为[9,8,7,5,6,10]
父节点(5):10>5,交换 → [9,8,7,10,6,5]
父节点(8):10>8,交换 → [9,10,7,8,6,5]
父节点(9):10>9,交换 → [10,9,7,8,6,5]
满足大顶堆,停止
向下调整(删除):
删除流程:
1. 移除堆顶元素
2. 将最后一个元素移到堆顶
3. 与子节点比较
4. 不满足堆性质则与最大子节点交换
5. 重复直到满足或到达叶节点
示例(大顶堆):
删除[10,9,7,8,6,5]的堆顶
移除10,末尾5移到堆顶 → [5,9,7,8,6]
子节点(9,7):5<9,与9交换 → [9,5,7,8,6]
子节点(8,6):5<8,与8交换 → [9,8,7,5,6]
满足大顶堆,停止
7.4 堆与算法的关联
堆的优先级特性 → 算法应用
堆顶最值的应用:
├── Top K问题 → 维护最大/最小的K个元素
├── 优先队列 → 高优先级先处理
├── 堆排序 → 利用堆顶最值排序
├── 合并有序数组 → 维护最小元素
└── Dijkstra → 最短距离节点优先
适合堆的算法:
| 算法 | 利用堆的什么特性 | 为什么适合 |
|---|---|---|
| Top K | 堆顶最值 | 维护大小为K的堆,堆顶是第K大/小 |
| 堆排序 | 堆顶最值 | 每次取出堆顶得到有序序列 |
| 合并K数组 | 小顶堆堆顶最小 | 每次取最小元素合并 |
| 优先队列 | 堆顶优先级最高 | 高优先级先处理 |
7.5 Top K问题原理
为什么用堆?
问题:找最大的K个元素
方法1:排序
排序后取前K个:O(n log n)
方法2:小顶堆
维护大小为K的小顶堆:
- 堆顶是当前第K大的元素
- 新元素大于堆顶:弹出堆顶,插入新元素
- 新元素小于堆顶:跳过
时间复杂度:O(n log K)
为什么更快?
K通常远小于n
log K < log n
原理详解:
小顶堆维护最大的K个:
堆顶 = 当前K个中最小的 = 当前第K大
新元素处理:
元素 > 堆顶 → 元素比当前第K大更大 → 替换堆顶
元素 < 堆顶 → 元素比当前第K大更小 → 跳过
结果:
堆中保存最大的K个元素
堆顶是第K大的元素
8. 图:复杂关系的网络结构
8.1 图的基本原理
定义:图是由顶点和边组成的结构,表示元素之间的关系。
图的结构:
顶点(V):节点
边(E):连接关系
术语:
- 无向图:边无方向
- 有向图:边有方向
- 加权图:边有权值
- 度:与顶点相连的边数
- 路径:从一个顶点到另一个顶点的边序列
- 连通:两顶点之间存在路径
核心特性:
1. 任意连接 → 多对多关系
2. 复杂结构 → 无固定层次
3. 网络表示 → 关系建模
4. 可遍历 → DFS/BFS
8.2 图的存储方式
邻接矩阵:
矩阵表示:matrix[i][j] = 1表示有边
示例:
A B C D
A [ 0 1 1 0 ]
B [ 1 0 0 1 ]
C [ 1 0 0 1 ]
D [ 0 1 1 0 ]
优点:
- 判断是否有边:O(1)
- 适合稠密图
缺点:
- 空间:O(V²)
- 不适合稀疏图
邻接表:
链表/数组表示:每个顶点存储相连顶点列表
示例:
A → [B, C]
B → [A, D]
C → [A, D]
D → [B, C]
优点:
- 空间:O(V+E)
- 适合稀疏图
缺点:
- 判断是否有边:O(V)遍历列表
8.3 图的时间复杂度
| 操作 | 邻接矩阵 | 邻接表 |
|---|---|---|
| 判断是否有边 | O(1) | O(V) |
| 找所有邻居 | O(V) | O(degree) |
| 空间复杂度 | O(V²) | O(V+E) |
| DFS/BFS | O(V²) | O(V+E) |
8.4 图与算法的关联
图的网络特性 → 算法应用
网络结构的应用:
├── DFS → 深度探索路径
├── BFS → 找最短路径(无权图)
├── 最短路径 → Dijkstra/Floyd
├── 拓扑排序 → 任务依赖关系
├── 连通性 → 判断是否连通
└── 最小生成树 → Kruskal/Prim
适合图的算法:
| 算法 | 利用图的什么特性 | 为什么适合 |
|---|---|---|
| DFS | 网络连接 | 深入探索一条路径 |
| BFS | 网络连接 | 找无权图最短路径 |
| Dijkstra | 加权边 | 找加权最短路径 |
| 拓扑排序 | 有向边 | 处理依赖关系 |
| 最小生成树 | 连通性 | 找最小成本连接 |
8.5 DFS与BFS原理
DFS(深度优先):
原理:一条路走到底,走不通再回头
流程:
1. 从起点开始
2. 选一个方向深入
3. 走到底或走不通
4. 回退一步,选另一个方向
5. 重复直到遍历完
实现:栈或递归
void DFS(Graph& g, int v) {
visited[v] = true;
for (int neighbor : g.neighbors(v)) {
if (!visited[neighbor]) {
DFS(g, neighbor);
}
}
}
应用:
- 判断连通性
- 拓扑排序
- 找所有路径
BFS(广度优先):
原理:一层一层遍历,先近后远
流程:
1. 从起点开始
2. 访问所有相邻节点(第1层)
3. 访问第1层的相邻节点(第2层)
4. 重复直到遍历完
实现:队列
void BFS(Graph& g, int start) {
queue<int> q;
q.push(start);
visited[start] = true;
while (!q.empty()) {
int v = q.front(); q.pop();
for (int neighbor : g.neighbors(v)) {
if (!visited[neighbor]) {
visited[neighbor] = true;
q.push(neighbor);
}
}
}
}
应用:
- 无权图最短路径
- 层次遍历
- 找最近节点
9. 数据结构选择策略
9.1 按操作频率选择
查找频率高:
├── 单点查找 → 哈希表(O(1))
├── 范围查找 → 平衡树(O(log n))
├── 有序查找 → 二分查找(有序数组)
└── 频繁查找特定值 → 哈希表
插入删除频率高:
├── 头部操作 → 链表(O(1))
├── 中间操作 → 链表(O(1)找到位置后)
├── 末尾操作 → 数组(O(1))
└── 优先级操作 → 堆(O(log n))
遍历频率高:
├── 顺序遍历 → 数组(缓存友好)
├── 有序遍历 → 平衡树
└── 层次遍历 → BFS用队列
9.2 按数据特征选择
数据有序:
├── 保持有序 → 平衡树
├── 不需保持 → 哈希表(查找更快)
└── 只需查找 → 有序数组+二分
数据唯一性:
├── 需要去重 → set
├── 需要计数 → map
└── 需要键值 → map
数据大小变化:
├── 大小固定 → 数组
├── 大小动态 → vector/list
└── 大小未知 → 链表
数据有层次:
├── 父子关系 → 树
├── 多对多 → 图
└── 优先级 → 堆
9.3 按时间要求选择
要求O(1):
├── 查找 → 哈希表
├── 头部操作 → 链表
├── 末尾操作 → 数组
└── 取最值 → 堆顶(堆)
要求O(log n):
├── 查找 → 平衡树/BST
├── 插入删除 → 平衡树/堆
├── 范围查找 → 平衡树
└── 有序数组操作 → 二分+移动
要求O(n):
├── 链表查找
├── 数组中间插入删除
├── 哈希表最坏情况
└── 图遍历
9.4 按空间限制选择
空间紧张:
├── 优先数组(无额外开销)
├── 避免哈希表(预分配空间)
├── 避免链表(指针开销)
└── 避免平衡树(节点开销)
空间充足:
├── 可用哈希表(查找快)
├── 可用平衡树(有序)
└── 可用堆(优先队列)
10. 数据结构与算法映射表
10.1 数据结构 → 支持的算法
| 数据结构 | 核心特性 | 支持的算法 | 原理关联 |
|---|---|---|---|
| 数组 | 连续存储 | 二分查找、双指针、滑动窗口 | 索引访问、跳跃访问 |
| 链表 | 离散存储 | 快慢指针、反转、合并 | 指针操作灵活 |
| 栈 | LIFO | 括号匹配、DFS迭代、单调栈 | 最近先处理 |
| 队列 | FIFO | BFS、滑动窗口、层序遍历 | 先进先处理 |
| 哈希表 | 键值映射 | 快速查找、计数、去重 | O(1)定位 |
| 树 | 层次结构 | DFS、BFS、递归遍历、分治 | 子树独立性 |
| 堆 | 堆顶最值 | Top K、堆排序、优先队列 | 优先级管理 |
| 图 | 网络连接 | DFS、BFS、最短路径、拓扑排序 | 关系遍历 |
10.2 算法 → 需要的数据结构
| 算法 | 核心需求 | 推荐数据结构 | 选择原因 |
|---|---|---|---|
| 二分查找 | 有序、索引访问 | 有序数组 | 可以跳跃访问 |
| 双指针 | 索引访问 | 数组 | 两个位置协同 |
| 滑动窗口 | 索引访问、顺序 | 数组、队列 | 动态调整边界 |
| 快慢指针 | 指针操作 | 链表 | 控制步数找位置 |
| DFS | 后进先出 | 栈、递归 | 最近访问优先 |
| BFS | 先进先出 | 队列 | 层次顺序 |
| 回溯 | 保存状态、回退 | 递归栈 | 状态保存恢复 |
| 动态规划 | 状态存储 | 数组 | 存储dp值 |
| 哈希优化 | 快速查找 | 哈希表 | O(1)查找 |
| 单调栈 | 维护单调性 | 栈 | 不满足则弹出 |
| Top K | 维护最值 | 堆 | 堆顶是第K大/小 |
| Dijkstra | 最短距离优先 | 堆 | 取最小距离 |
10.3 问题类型 → 数据结构+算法组合
| 问题类型 | 数据结构 | 算法 | 组合原理 |
|---|---|---|---|
| 有序查找 | 有序数组 | 二分查找 | 连续存储支持跳跃 |
| 子数组问题 | 数组 | 滑动窗口 | 索引调整边界 |
| 链表操作 | 链表 | 快慢指针 | 指针灵活操作 |
| 括号匹配 | 栈 | LIFO匹配 | 最近左括号先匹配 |
| 层次遍历 | 队列 | BFS | 先入队的先处理 |
| 判断重复 | 哈希表 | 快速查找 | 键唯一性 |
| 树遍历 | 树+栈/队列 | DFS/BFS | 层次结构遍历 |
| 最短路径 | 图+堆 | Dijkstra | 最小距离优先 |
| Top K | 堆 | 维护K个最大 | 堆顶是第K大 |
10.4 数据结构转换技巧
数组 → 哈希表优化:
原问题:查找数组中是否存在某值
暴力:O(n)遍历
优化:哈希表预处理,O(1)查找
适用:查找频率高
数组 → 双指针优化:
原问题:两数之和、三数之和
暴力:O(n²)嵌套循环
优化:双指针O(n)或O(n log n)
适用:有序或可通过排序变有序
链表 → 数组转换:
原问题:链表随机访问
链表:O(n)遍历
优化:转为数组O(1)访问
适用:需要多次随机访问
代价:转换成本O(n)
递归 → 栈转换:
原问题:DFS遍历
递归:隐式栈
优化:显式栈(避免栈溢出)
适用:深度大、怕栈溢出
总结
数据结构是算法的载体,选择正确的数据结构是解题的关键:
核心原则:
1. 数据规模决定复杂度要求
2. 操作频率决定数据结构选择
3. 数据特征决定算法方向
4. 时间空间权衡
记住:
- 数组支持跳跃 → 二分、双指针、滑动窗口
- 链表指针灵活 → 快慢指针、反转、合并
- 栈后进先出 → 括号匹配、DFS、单调栈
- 队列先进先出 → BFS、滑动窗口
- 哈希表O(1)查找 → 快速查找、计数、去重
- 树层次结构 → DFS、BFS、递归
- 堆堆顶最值 → Top K、优先队列
- 图网络结构 → DFS、BFS、最短路径
最后更新:2024 年