线索树
在二叉树线索化中,前序、中序、后序线索树分别利用空指针域存储对应遍历顺序下的前驱和后继信息。但就遍历算法而言,只有后序线索树在实现后序遍历时仍然需要辅助栈(或借助父指针等额外结构),而前序和中序线索树可以完全不用栈。
三种线索树的核心特点
- 中序线索树:最常用。每个节点增加左标志(LTag)和右标志(RTag)。遍历时,从最左节点开始,反复找后继:若右孩子是线索(RTag=1),则直接沿线索移动;否则找右子树的最左节点。无需栈。
- 前序线索树:也能实现无栈遍历。从根开始,若左孩子存在,前序后继就是左孩子;若左孩子为空但右孩子存在,后继是右孩子;若当前节点是叶子,则利用右线索跳到后继。无需栈。
- 后序线索树:问题复杂。后序遍历顺序是“左-右-根”。当访问完一个节点后,它的下一个节点(后序后继)可能是:
- 若当前节点是父节点的左孩子,则父节点的右子树(如果存在)的后序第一个节点,或者父节点本身(如果无右子树)。
- 若当前节点是父节点的右孩子,则后继就是父节点。
- 但线索树只存储了前驱/后继关系,却没有存储父节点信息。从任意节点出发,无法直接通过有限次线索找到后序后继——因为线索只指向祖先或子孙节点,而寻找父节点需要栈来记录路径。
为什么后序线索树遍历需要辅助栈?
- 根本原因:后序遍历要求先访问左右子树再访问根,所以从根到叶子的下降过程中,必须记住返回路径。后序线索虽然能帮助寻找前驱(便于从下向上回溯),但从一个节点到它的后序后继,无法仅靠线索直接到达。
- 典型实现方式:使用栈来模拟递归过程。例如,从根开始,先将左链节点依次压栈,然后处理右子树;或者利用三叉链表(增加父指针)替代栈。
对比总结
| 线索树类型 | 对应遍历是否需要栈 | 原因简述 |
|---|---|---|
| 中序线索树 | 不需要 | 后继可通过右线索或右子树最左节点直接得到 |
| 前序线索树 | 不需要 | 后继可通过左/右孩子或右线索得到 |
| 后序线索树 | 需要 | 缺少父节点信息,从节点无法直接确定后序后继 |
因此,答案是:后序线索树的后序遍历需要辅助栈(或等价机制)。前序和中序线索树的遍历均可在线性时间内无栈完成。
广义表
广义表(Generalized List)是线性表的推广,允许元素本身也是广义表(即子表),非常适合表示层次结构(如树、文本、LISP 表达式等)。以下从定义、表示法、长度、深度、存储结构等方面详细介绍。
广义表的定义
广义表是 n(n ≥ 0)个元素的有限序列,通常记作:
LS = (a₁, a₂, ..., aₙ)
- LS 为表名(可省略)。
- 每个元素 aᵢ 可以是:
- 原子(Atom):不可再分的数据元素(如字母、数字)。
- 子表(Sub‑list):另一个广义表。
- n 称为广义表的长度。
- 特殊形式:
- 空表:
(),长度 = 0。 - 纯原子表:如
(a, b, c),所有元素都是原子。 - 嵌套表:如
(a, (b, c), d)。
- 空表:
广义表的定义是递归的:一个广义表由原子或更小的广义表组成。
广义表的图形表示与书写
- 括号表示法:最常用。原子直接写,子表用括号括起来。
A = (a, (b, c), d) B = ( ( ), (e, f) ) - 树形表示:每个表看作一个根节点,其子节点为直接元素;原子是叶子节点,子表是内部节点。
长度(Length)
定义:广义表 直接元素 的个数,即最外层括号内元素的数目。
- 空表
()长度 = 0。 (a, (b, c), d)长度 = 3(元素依次为:原子 a、子表 (b,c)、原子 d)。((a, b))长度 = 1(只有子表 (a,b))。(a)长度 = 1。
长度只计算第一层的元素个数,不展开子表。
深度(Depth)
定义:广义表中括号最大嵌套层数。不同教材对原子的深度定义略有差异,最常用的是:
- 原子的深度 = 0
- 空表的深度 = 1
- 非空表的深度 = 1 + max(各元素的深度)
示例(采用原子深度=0定义):
| 广义表 | 计算过程 | 深度 |
|---|---|---|
( ) | 空表,直接定义 | 1 |
(a) | a深度0 → max=0 → 1+0=1 | 1 |
(a, (b, c)) | a:0, (b,c):1 → max=1 → 1+1=2 | 2 |
( ( ( ) ) ) | 内层()深度1 → 中层深度2 → 外层深度3 | 3 |
(a, (b, (c, d)), e) | a:0, (b,(c,d))深度? → 内部(c,d)深度1 → (b,(c,d))深度2 → 子表深度2 → max=2 → 1+2=3 | 3 |
注意:个别教材将原子的深度定义为 1,此时空表深度 = 0 或 1,计算公式相应调整。考试和常见数据结构教材(如严蔚敏版)采用原子深度=0的定义,上表即为标准答案。
表头(Head)和表尾(Tail)
广义表的两个基本运算(源自 LISP 语言):
- 表头:非空广义表的 第一个元素,可以是原子或子表。
Head( (a, (b, c), d) ) = a Head( ((a,b), c) ) = (a,b) - 表尾:非空广义表 去掉表头后剩下的元素组成的表(结果一定是一个广义表)。
Tail( (a, (b, c), d) ) = ( (b, c), d ) Tail( ((a,b), c) ) = (c)
空表没有表头和表尾。
存储结构(常用链式存储)
广义表是一种递归结构,通常用带头结点的双向链表或单链表存储。每个节点包含:
- 标志域(tag):0 表示原子,1 表示子表。
- 若 tag=0(原子):值域(data)存放原子值。
- 若 tag=1(子表):指针域(hp,表头指针)指向子表的头结点。
- 同级指针(tp,表尾指针)指向下一个元素的节点。
节点结构示例(C语言描述):
typedef struct GNode {
int tag; // 0:原子, 1:子表
union {
char data; // 原子值
struct GNode *hp; // 子表的表头指针
};
struct GNode *tp; // 指向下一个元素(表尾)
} GList;
空表用 NULL 或一个特殊头结点表示。
与线性表的对比
| 特性 | 线性表 | 广义表 |
|---|---|---|
| 元素类型 | 相同,且不可再分 | 可以不同,可以是表 |
| 结构 | 线性(单层) | 递归多层 |
| 长度 | 元素个数 | 直接元素个数 |
| 深度 | 固定为 1 | 可大于 1 |
| 共享 | 不共享 | 可以共享子表(有向无环图) |
应用举例
- LISP 语言:程序和数据均用广义表表示,如
( + 3 ( * 4 5 ) )。 - 树的存储:用广义表表示树(如
(A (B, C (D, E))))。 - 文本结构:章节、段落的多级嵌套。
总结
- 长度:最外层元素个数(空表为 0)。
- 深度:括号最大嵌套层数(原子 0,空表 1,非空表 1+子表最大深度)。
- 广义表通过递归定义,支持表头、表尾操作,常用链式存储,是处理层次数据的理想模型。
有向图成环
判断有向图是否存在环,不能直接用并查集,因为边有方向。常用的可靠方法有两种:拓扑排序 和 DFS 着色法。
拓扑排序(Kahn 算法)
原理:反复删除入度为 0 的顶点及其出边。若最后所有顶点都被删除,则无环;否则图中有环。
步骤:
- 计算所有顶点的入度。
- 将所有入度为 0 的顶点入队。
- 循环取出队首顶点,将其从图中“删除”——即遍历它的所有邻接点,将这些邻接点的入度减 1。若减后入度为 0,则入队。
- 若最终删除的顶点数 < 总顶点数,则存在环。
时间复杂度:O(V+E),适合任意有向图。
代码示意(C++ 风格):
bool hasCycle(int n, vector<vector<int>>& edges) {
vector<int> indeg(n, 0);
vector<vector<int>> adj(n);
for (auto& e : edges) {
adj[e[0]].push_back(e[1]);
indeg[e[1]]++;
}
queue<int> q;
for (int i = 0; i < n; i++)
if (indeg[i] == 0) q.push(i);
int cnt = 0;
while (!q.empty()) {
int u = q.front(); q.pop();
cnt++;
for (int v : adj[u])
if (--indeg[v] == 0) q.push(v);
}
return cnt != n; // 有环当且仅当 cnt < n
}
DFS 着色法(三色标记)
原理:DFS 过程中,用三种颜色标记顶点的状态:
- 0(白色):未访问
- 1(灰色):正在当前递归栈中(正在访问其子孙)
- 2(黑色):已完全访问(所有子孙已处理)
若 DFS 遇到一个邻接点的颜色为 1,说明发现了一条返祖边(back edge),即存在环。
步骤(递归实现):
- 对每个白色顶点调用 DFS。
- DFS(u):
- 标记 u 为灰色
- 遍历邻接点 v:
- 若 v 为灰色 → 找到环,返回 true
- 若 v 为白色 → 递归 DFS(v),若返回 true 则上层也返回 true
- 标记 u 为黑色
- 返回 false
时间复杂度:O(V+E),同样高效。
代码示意:
bool dfs(int u, vector<int>& color, vector<vector<int>>& adj) {
color[u] = 1; // 灰色,正在访问
for (int v : adj[u]) {
if (color[v] == 1) return true; // 环
if (color[v] == 0 && dfs(v, color, adj)) return true;
}
color[u] = 2; // 黑色,完成
return false;
}
bool hasCycle(int n, vector<vector<int>>& edges) {
vector<vector<int>> adj(n);
for (auto& e : edges) adj[e[0]].push_back(e[1]);
vector<int> color(n, 0); // 0白色
for (int i = 0; i < n; i++)
if (color[i] == 0 && dfs(i, color, adj))
return true;
return false;
}
为什么并查集不适用于有向图?
- 并查集维护的是无向连通性,只能判断两个顶点是否在同一连通分量中。对于有向边
u→v,我们无法确定从 v 是否能回到 u,而环需要路径v→...→u存在。并查集无法表示方向,因此不能直接用于有向图判环。
总结
| 方法 | 原理 | 适用性 | 是否需要额外空间 |
|---|---|---|---|
| 拓扑排序(Kahn) | 反复删除入度为0的顶点 | 所有有向图 | O(V) 入度数组 + 队列 |
| DFS 三色法 | 检测返祖边(DFS栈中节点再次出现) | 所有有向图 | O(V) 颜色数组 + 递归栈 |
两种方法都能在 O(V+E) 时间内完成,可根据习惯选用。若图较大且递归深度可能过大,建议用迭代的拓扑排序。
DFS 三色
结论:不一定要用“三色”这个具体的命名,但若使用 DFS 来判断有向图是否有环,算法逻辑上必须区分节点的三种状态。
如果只用简单的“已访问 / 未访问”(即二值状态)是不够的,一定会出现误判。不过,“三色”有多种等价的实现方式,并非必须写成整数 0/1/2。
为什么二值状态(仅 visited)不行?
在有向图中,DFS 遇到一个已访问的节点,并不代表有环,可能是因为这是一条横叉边或前向边,指向了之前已经处理完毕的其他分支。
反例(无环图,但二值法会误判):
边为 1 -> 2,1 -> 3,3 -> 2(图结构:1 分叉指向 2 和 3,3 再指向 2,显然没有环)。
- 使用仅标记
visited的 DFS,从1出发,先走2,标记 2 为已访问,回溯。 - 然后走
3,3的邻接点是2。此时发现2已经访问过了。 - 若只用二值判断,就会错误地报告“存在环”(因为遇到了已访问节点),但实际上
3 -> 2只是指向了之前分支的节点,并不构成环。
DFS 必须区分的三种状态是什么?
要正确判断,DFS 必须区分以下三种身份:
| 状态 | 含义 | 对应“三色” |
|---|---|---|
| 未访问 | 该节点还未被 DFS 探索过。 | 白色(0) |
| 正在访问中(在递归栈内) | 当前 DFS 路径上的节点,即从起点到当前节点的祖先节点。 | 灰色(1) |
| 访问已完成 | 该节点的所有邻接点都已递归完毕,已安全回溯,不可能再构成环。 | 黑色(2) |
核心判断逻辑:在 DFS 遍历邻接点时,只有当遇到“正在访问中(灰色)”的节点时,才说明发现了环(后向边)。遇到黑色节点(已完成)或白色节点(未访问)都是安全的。
实现方式不一定叫“三色数组”
虽然大家习惯叫“三色遍历”,但在代码实现上,你有完全等价的替代写法,并不一定非要定义一个存 0/1/2 的数组:
写法 A(经典三色数组):
vector<int> color(n, 0); // 0白, 1灰, 2黑写法 B(双布尔数组): 用一个
visited标记是否访问过,再用一个inStack专门标记当前递归栈内的节点。vector<bool> visited(n, false); vector<bool> inStack(n, false); // DFS 进入时:visited[u]=true; inStack[u]=true; // 判断环:if (visited[v] && inStack[v]) -> 有环; // 回溯时:inStack[u]=false;这和“三色”完全等价:
!visited=白色,inStack=灰色,visited && !inStack=黑色。写法 C(迭代模拟栈): 如果手写栈模拟递归,需要额外维护一个状态位(如迭代器下标),本质上依然要区分“刚入栈”和“子节点处理完要出栈”这两种状态。
如果不使用 DFS,还有其他方法
如果你问的是“判断有向图成环”这个任务本身,那么不一定非要依赖 DFS 的三色法,还有其它经典算法:
- 拓扑排序(Kahn 算法):基于 BFS,反复删除入度为 0 的节点。如果最后删除的节点数少于总节点数,则说明有环。这种方法完全不涉及 DFS,也不需要三色状态。
- Floyd 判圈算法:仅针对单链/特定结构,不通用。
总结
- 若限定在“DFS 框架”内:必须在逻辑上区分三种状态(未访问、在栈中、已完成)。如果不区分,一定会把交叉边误判为环。
- 关于“三色”名称:不一定要用
0/1/2的整数数组,也可以用两个bool数组(visited+inStack)实现,本质等价。 - 若跳出 DFS:你可以完全不用三色,直接用 Kahn(拓扑排序) 来判断,时间复杂度同样是 O(V+E)。
DAG
DAG(Directed Acyclic Graph,有向无环图) 是图论中最重要的概念之一,它既具备了“方向”带来的逻辑约束,又因为“无环”而拥有了极高的计算价值。简单来说,DAG 就是带方向的、且无法从某个顶点出发沿箭头走回自身的图。
下面从定义、核心性质、必备算法、判定方法到工业应用,为你进行全方位拆解。
数学定义与形式化
- 有向(Directed):每条边都有明确的方向(u → v),表示从 u 到 v 的单向关系。
- 无环(Acyclic):图中不存在任何回路(Cycle),即不存在一条路径
v0 → v1 → ... → vk使得v0 = vk且k > 0。
一个最直观的类比是**“时间的单向流动”**——事件只能从过去指向未来,决不能绕一圈回到过去。
最重要的核心性质(DAG 的“灵魂”)
- 拓扑排序(Topological Ordering)存在性:这是 DAG 最本质的特征。任意 DAG 至少存在一种拓扑序,即将所有顶点排成线性序列,使得对于每一条有向边
u → v,u 在序列中一定位于 v 之前。非 DAG(有环图)无法满足此条件。 - 偏序关系:DAG 表达了集合上的偏序关系(Partial Order),顶点代表元素,边代表“先决条件”或“依赖”。
- 最长路径与最短路径可解:在一般图中,求最长路径是 NP-Hard 问题;但在 DAG 中,利用拓扑排序 + 动态规划(DP),可以在 O(V+E) 时间内求出单源最长/最短路径(甚至能处理负权边,无需 Bellman-Ford 那样复杂)。
- 传递闭包:DAG 可以通过 Floyd 或 DFS 求出传递闭包,用来判断任意两点间是否存在路径(可达性)。
DAG 上的核心算法(按拓扑序 DP)
由于拓扑序保证了父节点永远在子节点之前处理,DAG 是动态规划(DP)的最佳载体。几乎所有 DAG 上的问题都遵循同一套模板:
- 求出拓扑排序。
- 按拓扑序正序遍历(用于求依赖前的状态)或逆序遍历(用于求依赖后的状态)。
- 对于当前顶点
u,遍历其所有邻接边u → v,更新v的状态。
典型应用:
- 单源最短路径:初始化起点 dist=0,按拓扑序松弛每条边(可处理负权边)。
- 关键路径(Critical Path):求整个 DAG 中的最长路径,常用于项目管理(PERT/CPM 网络),决定项目的最短完工时间。
- 路径计数:计算从起点到终点的不同路径总数。
- 表达式求值:处理具有依赖关系的计算图。
如何判定一个有向图是 DAG?(检测环)
有两种经典方法,均是 O(V+E) 时间复杂度:
方法一:Kahn 算法(基于 BFS 的拓扑排序)
- 计算所有顶点的入度。
- 将所有入度为 0 的顶点入队。
- 不断弹出队首顶点,将其所有邻接点的入度减 1。若某邻接点入度变为 0,则入队。
- 判据:如果最终弹出的顶点总数 等于 V,则是 DAG;否则存在环。
方法二:DFS 三色法(基于递归回溯)
- 标记顶点状态为 0(未访问)、1(当前递归栈中)、2(已完成)。
- 若 DFS 遍历时遇到状态为 1 的邻接点,说明发现了“返祖边”(Back Edge),即存在环。
- 若不出现上述情况,则是 DAG。
注意:仅用“已访问/未访问”(二色)无法区分后向边和跨边,容易误判,必须用三色(详情可回顾我们之前讨论的 DFS 判环)。
现实世界中的经典应用(为什么它无处不在)
DAG 不仅在理论中重要,更是现代计算机系统的底层基石:
- 软件包依赖管理(如 npm, Maven, pip):包 A 依赖包 B,图是 DAG。拓扑排序决定了安装顺序。
- 编译器中的指令调度:将代码转换为 DAG(有向无环图),检测公共子表达式,并确定最优的执行顺序。
- Spark / Flink 等大数据计算引擎:将数据处理任务抽象为 DAG 执行计划,引擎根据依赖关系拆分 Stage,并自动优化。
- 区块链(DAG 账本):如 IOTA 等加密货币不使用链式区块,而使用 DAG 结构(Tangle),允许并发交易确认,提高吞吐量。
- 版本控制系统(Git):尽管 Git 的历史图允许合并(形成有向无环图,严格来说合并引入的是分叉,但 Git 的提交历史本质上是一个 DAG,因为不能回到过去覆盖根节点)。
- 规划与调度(PERT 图):工程项目中的活动节点按依赖关系连接,通过关键路径法计算项目最小工期。
DAG 与 树、一般图的对比
| 特性 | 树 | DAG | 一般有向图 |
|---|---|---|---|
| 环 | 无环 | 无环 | 可能有环 |
| 方向 | 可为无向或有向 | 必须有向 | 必须有向 |
| 连通性 | 强连通(V-1 条边) | 可不连通 | 可不连通 |
| 路径唯一性 | 任意两点路径唯一 | 两点间可有多条路径(多条依赖链) | 多条路径 |
| 拓扑排序 | 一定有 | 一定有 | 不一定(有环则无) |
| 结构共享 | 不允许(一个节点只有一个父) | 允许(一个节点可有多个父,即多个依赖) | 允许 |
关键进步:DAG 比树更具表达能力,因为它能表示“多源依赖”(一个任务依赖多个前置任务),同时又比一般有向图更安全(可线性求解,无死锁风险)。
一个容易被忽略的陷阱
在 DAG 中,即使有向图无环,但如果把边看成“双向可达”的无向图,DAG 的底层无向图可能是有环的。例如 1→2, 1→3, 2→3,有向无环,但忽略方向后 1-2-3-1 构成了无向环。这在物理布线或网络拓扑中是需要额外留意的,但在依赖逻辑中完全合法。
总结
DAG 是**“有方向的层级依赖关系”**的数学模型。理解它的核心在于两点:拓扑序决定计算顺序,无环保证算法收敛。无论是做工程调度、编写编译器,还是优化机器学习计算图,本质上都是在 DAG 上跑 DP,利用拓扑序一次遍历即可解决复杂的依赖难题。如果面试中遇到“如何处理任务依赖”或“如何求最长路径”,首选答案就是“转为 DAG 并利用拓扑排序求解”。
kruskal & prim
Kruskal和Prim都是求解最小生成树(MST)的经典贪心算法,核心区别在于策略、数据结构和适用场景。下面进行详细对比:
| 维度 | Kruskal(克鲁斯卡尔) | Prim(普里姆) |
|---|---|---|
| 基本思想 | 按边权从小到大选边,只要不形成环就加入MST,直到有 V-1 条边。 | 从任意顶点开始,每次选择连接已选集合与未选集合的最小权边,将该边和另一端顶点加入MST。 |
| 操作对象 | 边(全局贪心) | 顶点(局部贪心) |
| 主要数据结构 | 并查集(DSU) + 边集数组 | 优先队列(最小堆)或邻接矩阵 |
| 时间复杂度 | O(E log E) ≈ O(E log V) | 二叉堆:O(E log V) 邻接矩阵:O(V²) |
| 空间复杂度 | O(V + E) | O(V + E) |
| 适用图类型 | 稀疏图(E « V²) | 稠密图(E ≈ V²)用邻接矩阵版更优;稀疏图也可用堆版 |
| 对非连通图处理 | 自动得到最小生成森林(边数 < V-1 即不连通) | 需检查是否所有顶点被访问,否则图不连通 |
| 是否依赖起点 | 不依赖,结果唯一(边权互异时) | 依赖起点,但最终MST权值相同 |
| 正确性依据 | 按边权递增,利用回路性质(若边两端已连通则跳过) | 利用割性质(连接两个集合的最小边必属某MST) |
| 实现复杂度 | 较低:排序 + 并查集 | 中等:需维护堆或数组,代码稍长 |
| 典型瓶颈 | 排序时间 O(E log E) | 堆操作 O(E log V) 或 数组扫描 O(V²) |
详细对比说明
算法步骤示例(同一图)
图:顶点 A,B,C,D,边 AB=1, AC=3, BC=2, BD=4, CD=5。
Kruskal:
排序:1(AB), 2(BC), 3(AC), 4(BD), 5(CD)
→ 选 AB(连通 {A,B})
→ 选 BC(连通 {A,B,C})
→ 跳过 AC(会环)
→ 选 BD(连通 D) → 结束,MST 权=1+2+4=7。Prim(从 A 开始):
A 邻接:AB=1, AC=3 → 选 AB(加入 B)
当前集合 {A,B},连接外部的最小边:BC=2(B→C) → 选 BC(加入 C)
当前 {A,B,C},连接外部最小边:BD=4(B→D) → 选 BD(加入 D) → 结束,MST 同。
时间复杂度详解
- Kruskal:排序 O(E log E)。由于 E ≤ V²,log E ≤ 2 log V,故可写为 O(E log V)。并查集操作接近 O(α(V)),可忽略。
- Prim:
- 二叉堆实现:每个顶点入堆一次,每条边可能被检查一次,堆操作 O(log V),总 O((V+E) log V) = O(E log V)。
- 斐波那契堆:O(E + V log V),但常数大,实际较少用。
- 邻接矩阵版:每次找最小边扫描一整个数组,O(V²),适合稠密图(E ≈ V²,此时 O(V²) 优于 O(E log V) ≈ O(V² log V))。
适用场景选择
- 稀疏图(比如 E < V log V):Kruskal 更优,因为排序后并查集操作轻量,且不依赖复杂的堆结构。
- 稠密图(比如 E ≈ V(V-1)/2):Prim 用邻接矩阵(O(V²))最快,Kruskal 的 O(E log V) ≈ O(V² log V) 较慢。
- 图已经按边权排序好:Kruskal 可降至 O(E α(V)),极大加速。
- 动态加边场景:Kruskal 只需维护并查集,更方便;Prim 需重建堆。
优缺点速记
- Kruskal:
✅ 实现简单,利用并查集容易判断环。
✅ 对边数少的图高效。
❌ 需要全局排序,无法在线构建。 - Prim:
✅ 适合稠密图。
✅ 可以随时从当前已选集合扩展,适合在线算法。
❌ 堆版实现稍复杂,矩阵版空间大(V²)。
总结
- 追求简洁代码 + 稀疏图:选 Kruskal。
- 稠密图 或 需要 O(V²) 简单实现:选 Prim(邻接矩阵)。
- 两者均正确,掌握区别可针对具体问题选择最优方案。
dijkstra
Prim、Kruskal 和 Dijkstra 都是基于贪心策略的图算法,但解决的问题和细节有本质区别。下面从目标、核心操作、适用条件、正确性原理等角度详细对比。
总体对比表
| 维度 | Prim | Kruskal | Dijkstra |
|---|---|---|---|
| 解决的问题 | 最小生成树(MST) | 最小生成树(MST) | 单源最短路径(SSSP) |
| 输出 | 连接所有顶点的无环子图,边权和最小 | 同左 | 从源点到每个顶点的最短距离(及路径) |
| 图类型 | 无向图(可处理负权边,但通常非负) | 无向图(可处理负权边) | 有向图或无向图,但边权必须非负(经典 Dijkstra) |
| 操作对象 | 顶点(逐步扩展已选集合) | 边(全局选最小边) | 顶点(从源点逐步确定最短距离) |
| 数据结构 | 优先队列(最小堆)或邻接矩阵 | 并查集 + 边集数组 | 优先队列(最小堆) |
| 时间复杂度 | O(E log V)(堆)/ O(V²)(矩阵) | O(E log E) ≈ O(E log V) | O((V+E) log V)(堆) |
| 贪心依据 | 每次加入连接已选集合与未选集合的最小边(割性质) | 每次加入全局最小且不形成环的边(回路性质) | 每次选择当前距离源点最近的未确定顶点,松弛其邻边 |
| 能否处理负权 | 能(边权为负不影响 MST 正确性) | 能 | 不能(会失效,需用 Bellman‑Ford) |
| 依赖起点 | 是(从任意顶点开始,MST 唯一性不依赖起点,但算法执行依赖) | 否 | 是(必须指定源点) |
| 终止条件 | 已选顶点数 == V | 已选边数 == V-1 | 所有顶点已确定最短距离(或队列空) |
关键区别详解
目标不同
- Prim / Kruskal:构建一棵生成树(包含所有顶点且无环),使边权总和最小。不关心顶点之间的路径长度,只关心总代价。
- Dijkstra:找到从源点到每个其他顶点的最短路径(路径上边权和最小)。MST 的路径不一定是最短路径(例如,MST 中两点间路径可能绕远)。
集合含义不同
- Prim:维护一个“已加入 MST 的顶点集合”S,每次扩展时寻找 S 与 V-S 之间的最小边。
- Dijkstra:维护一个“已确定最短距离的顶点集合”S,每次从 V-S 中选择 dist 最小的顶点加入 S,然后松弛它的邻边(更新 V-S 中顶点的 dist)。
- Kruskal:没有“已选集合”的概念,而是维护一个森林,通过并查集判断是否会成环。
更新方式不同(Prim vs Dijkstra)
这两个算法代码非常相似,但优先级队列中存储的键值含义不同:
- Prim:
key[v]表示 v 到当前 MST 集合的最短边权。当新顶点 u 加入后,遍历邻接点 v(未加入),若w(u,v) < key[v],则更新key[v] = w(u,v)。 - Dijkstra:
dist[v]表示 从源点 s 到 v 的当前最短路径长度。当顶点 u 被确定为最短距离后,遍历邻接点 v,若dist[u] + w(u,v) < dist[v],则更新dist[v] = dist[u] + w(u,v)。
因为更新依据不同,即使同一个图,Prim 生成树中的路径完全不等价于 Dijkstra 的最短路径。
对边权符号的容忍度
- 负权边:
- Prim/Kruskal:完全可用。MST 定义不依赖正负,负权边只会使总权值更小,算法依然正确。
- Dijkstra:不可用。负权边会导致已确定最短距离的顶点可能被后续更短的路径更新,破坏贪心假设。
- 有向图:
- Prim/Kruskal:要求图是无向的(若输入有向图,需要特殊处理或视为无向)。
- Dijkstra:适用有向图和无向图(无向边视为两条相反的有向边)。
输出结果的形态
- Prim/Kruskal 输出一棵树(|V|-1 条边),该树覆盖所有顶点。
- Dijkstra 输出一棵最短路径树(从源点到所有顶点的最短路径构成的子图),也是树形结构,但不一定是 MST。例如,一个三角形,边权 AB=1, AC=2, BC=3,MST 是 AB+AC=3,而从 A 出发的最短路径树则是 AB (1) 和 AC (2)(与 MST 相同,但这是巧合);若边权 AB=1, AC=2, BC=100,MST 是 AB+AC=3,最短路径树仍是 AB+AC,但若再考虑 D 点,往往不同。
举例直观区分
图例:4 个顶点(1,2,3,4),边权:
1-2: 1
1-3: 4
2-3: 2
2-4: 5
3-4: 3
MST(Prim 或 Kruskal):选择边 1-2(1)、2-3(2)、3-4(3),总权=6。
在 MST 中,1 到 4 的路径为 1-2-3-4,长度=1+2+3=6。Dijkstra(源点=1):
dist[2]=1,dist[3]=min(4, 1+2)=3,dist[4]=min(∞, 1+5, 3+3)=6(通过 1-2-4 路径 1+5=6,或 1-2-3-4 路径 1+2+3=6)。
实际最短路径 1→4 可以是 1-2-4(权6)或 1-2-3-4(也是6),与 MST 中的路径长度相同,但若改变边权(如 2-4=6, 3-4=2),则 MST 仍会选 3-4,而最短路径 1→4 为 1-2-3-4(权=1+2+2=5),两者不同。
总结选择建议
- 需要连接所有顶点的最小成本网络(如布线、管道) → Prim(稠密图) 或 Kruskal(稀疏图)。
- 需要从某个点出发到其他点的最短路线(如导航、路由) → Dijkstra(边权非负)。
- 若图有负权边且需要最短路径 → 使用 Bellman‑Ford 或 SPFA,不能用 Dijkstra。
- 若图是有向的且需要最小生成树 → 需先转化成无向图(如取对称化),或者使用专门的有向最小生成树算法(Edmonds 算法),但这不是 Prim/Kruskal 的范畴。
dijkstra & bellman-ford
Dijkstra 和 Bellman‑Ford 都是求解单源最短路径(Single Source Shortest Path, SSSP)的经典算法,但它们在适用边权、时间复杂度、处理负权环的能力上有本质区别。下面从核心思想、约束条件、执行流程、性能等维度详细对比。
核心区别速览
| 维度 | Dijkstra | Bellman‑Ford |
|---|---|---|
| 边权要求 | 必须非负(不能有负权边) | 可以有负权边(但不能有可达的负权环) |
| 处理负权环 | 不适用(算法会出错) | 能检测负权环,但无法得到正确最短路径(无最短路径) |
| 算法思想 | 贪心:每次选距离源点最近的未确定顶点,松弛其邻边 | 动态规划:对所有边进行 V-1 轮松弛,逐步逼近最短路径 |
| 时间复杂度 | 使用二叉堆:O((V+E) log V) 使用斐波那契堆:O(E + V log V) 邻接矩阵:O(V²) | O(V·E)(朴素实现) 可优化至 O(V·E) 仍高于 Dijkstra |
| 是否依赖拓扑序 | 否,但需借助优先队列 | 否,但可借助队列优化(SPFA) |
| 适用场景 | 非负权图,尤其稠密图或要求高效时 | 存在负权边,或需要检测负权环时 |
| 空间复杂度 | O(V+E) | O(V+E) |
详细原理对比
算法思想
- Dijkstra:基于贪心策略。维护一组已确定最短距离的顶点 S,每次从 V-S 中选出距离源点最近的顶点 u 加入 S,然后尝试通过 u 更新其邻接点 v 的距离(松弛)。其正确性依赖于:一旦某个顶点被加入 S,它的最短距离就再也不会被更新——这要求所有边权非负,否则后续可能通过负权边找到更短路径,破坏贪心。
- Bellman‑Ford:基于动态规划。进行 V-1 轮(V 为顶点数)迭代,每轮对所有边进行松弛操作。第 i 轮结束后,得到的是从源点出发经过最多 i 条边的最短路径长度。由于无负权环时最短路径至多包含 V-1 条边,V-1 轮后必然得到所有顶点的最短距离。若再进行一轮(第 V 轮)还能松弛,则说明存在负权环。
操作步骤对比
Dijkstra(堆优化版):
- 初始化
dist[源点]=0,其余为 ∞,所有顶点未标记。 - 将所有顶点(dist 为键)加入优先队列。
- 循环直到队列空:
- 弹出 dist 最小的顶点 u(若已标记则跳过)。
- 标记 u 为已确定。
- 对 u 的每个邻接点 v:若
dist[u] + w(u,v) < dist[v],则更新dist[v]并压入队列。
- 结束后
dist[]即为最短距离。
Bellman‑Ford:
- 初始化
dist[源点]=0,其余为 ∞。 - 重复 V-1 次:
- 对图中的每条边 (u, v, w):若
dist[u] + w < dist[v],则更新dist[v]。
- 对图中的每条边 (u, v, w):若
- 再对每条边检查一次:若还能松弛,则说明存在从源点可达的负权环。
关于负权边与负权环
- 负权边但不构成负权环:Bellman‑Ford 可正确计算出最短路径(例如
1→2 (5), 1→3 (6), 3→2 (-2),从 1 到 2 的最短路径是 1→3→2,长度为 4)。Dijkstra 会错误地先确定 1→2 (5) 为最短,永远无法修正。 - 负权环(环总权值 < 0):若从源点可达该环,则最短路径可无限减小(无下限)。Bellman‑Ford 可以检测出来:第 V 轮还能松弛则存在负权环。Dijkstra 无法检测,且会陷入无限循环或给出错误结果。
时间复杂度与优化
- Dijkstra:
- 二叉堆:O((V+E) log V) ≈ O(E log V)(稀疏图友好)
- 斐波那契堆:O(E + V log V)(理论最优,实际复杂)
- 邻接矩阵:O(V²)(稠密图简单实现)
- Bellman‑Ford:
- 朴素:O(V·E)(对于稠密图可能 O(V³))
- 队列优化(SPFA):平均 O(kE),k 常为小常数,但最坏仍退化为 O(V·E),可被构造数据卡成 O(VE) 甚至 O(2^V)?实际 SPFA 最坏 O(VE) 同样。无负权时不建议用 Bellman‑Ford,应用 Dijkstra。
应用场景举例
| 场景 | 推荐算法 | 原因 |
|---|---|---|
| 地图导航(边长均为正) | Dijkstra | 效率高,正确 |
| 金融套利检测(负权环建模) | Bellman‑Ford | 可检测负权环 |
| 带有时效成本的调度(可能负权) | Bellman‑Ford | 能处理负权 |
| 大规模非负权稀疏图 | Dijkstra(堆) | 接近线性 |
| 图中存在负权边但需最短路径 | Bellman‑Ford 或 SPFA | Dijkstra 失效 |
总结记忆口诀
- Dijkstra:贪心选最近,负权要回避,堆优速度快,无边权限制用起来。
- Bellman‑Ford:松弛轮 V‑1,检测负权好利器,虽然慢得稳,有负用它没问题。
若边权都非负,优先选择 Dijkstra(更快);若图含负权边或需检测负环,则必须用 Bellman‑Ford 或其优化变体。
Floyd
Floyd算法(Floyd-Warshall算法)是一种用于求解所有顶点对之间最短路径的动态规划算法。它允许图中存在负权边,但不能有负权环(否则最短路径无定义)。下面从原理、步骤、时间复杂度、代码实现、优缺点及与单源算法的对比等方面详细介绍。
核心思想
Floyd算法基于动态规划,定义状态 d[k][i][j] 表示 从 i 到 j 且中间顶点编号不超过 k 的最短路径长度。递推关系为:
d[k][i][j] = min( d[k-1][i][j], d[k-1][i][k] + d[k-1][k][j] )
含义:不经过顶点 k 的最短路径 与 经过顶点 k 的最短路径 两者取小。
实际实现时,可以使用二维数组滚动更新,空间降为 O(V²):
dist[i][j] = min(dist[i][j], dist[i][k] + dist[k][j])
其中 k 是中间顶点的迭代次序。
算法步骤
初始化:
- 对于 i==j,
dist[i][i] = 0。 - 若存在边 i→j,
dist[i][j] = weight(i,j)。 - 否则
dist[i][j] = INF(无穷大)。
- 对于 i==j,
三层循环(k, i, j):
- 外层循环
k = 0 to V-1(每个顶点作为中间点) - 中层循环
i = 0 to V-1(起点) - 内层循环
j = 0 to V-1(终点) - 执行:
if dist[i][k] + dist[k][j] < dist[i][j],则更新dist[i][j]。
- 外层循环
检测负权环:
- 算法结束后,若任何
dist[i][i] < 0,则存在从 i 出发回到 i 的负权环(因为正常情况下 dist[i][i]=0)。
- 算法结束后,若任何
时间复杂度与空间复杂度
时间复杂度:O(V³)
三层循环,每层大小 V,适用于顶点数较小的图(通常 V ≤ 500 左右可以接受)。空间复杂度:O(V²)
二维数组存储距离矩阵。
代码示例(Python)
def floyd_warshall(V, edges):
INF = float('inf')
dist = [[INF] * V for _ in range(V)]
# 初始化
for i in range(V):
dist[i][i] = 0
for u, v, w in edges:
dist[u][v] = w # 有向图;无向图需添加 dist[v][u]=w
# 核心三重循环
for k in range(V):
for i in range(V):
for j in range(V):
if dist[i][k] != INF and dist[k][j] != INF:
new_len = dist[i][k] + dist[k][j]
if new_len < dist[i][j]:
dist[i][j] = new_len
# 检测负权环
for i in range(V):
if dist[i][i] < 0:
print("存在负权环")
return None
return dist
优缺点
优点
- 实现简单:三重循环,代码量极小。
- 同时计算所有顶点对:一次运行得到全源最短路径。
- 处理负权边:只要无负权环,结果正确。
- 稠密图友好:对于 V 较小但边稠密的图,O(V³) 可能优于 V 次 Dijkstra(V×O(E log V))。
缺点
- 复杂度高:O(V³) 无法处理大规模图(V ≥ 1000 时可能太慢)。
- 空间 O(V²):存储全矩阵,V=10000 时需要约 400MB(双精度浮点),可能过大。
- 不能处理负权环:虽然能检测,但无法得到有意义的最短路径。
与单源最短路径算法的对比
| 算法 | 适用范围 | 时间复杂度 | 空间 | 负权边 | 负权环 | 返回值 |
|---|---|---|---|---|---|---|
| Floyd | 全源,V 较小 | O(V³) | O(V²) | 允许 | 可检测 | 所有点对最短距离 |
| Dijkstra | 单源,非负权 | O((V+E)log V) 堆 | O(V+E) | 禁止 | 无法检测 | 源点到各点的距离 |
| Bellman‑Ford | 单源,可负权 | O(VE) | O(V+E) | 允许 | 可检测 | 源点到各点的距离 |
选择建议:
- 需要全源最短路径且 V ≤ 500 → Floyd 最省事。
- 只需要单源最短路径 → 若边权非负用 Dijkstra,若有负权用 Bellman‑Ford。
- 图非常稀疏且 V 很大 → 运行 V 次 Dijkstra(O(V E log V))可能比 Floyd 更快。
路径重建
除了最短距离,Floyd 也可以记录路径:维护一个 next[i][j] 数组,初始时若 i→j 有边,则 next[i][j] = j;当发现通过 k 能缩短路径时,设置 next[i][j] = next[i][k]。最后根据 next 数组逐步输出路径。
next = [[-1]*V for _ in range(V)]
for i in range(V):
for j in range(V):
if dist[i][j] != INF and i != j:
next[i][j] = j
# 在更新 dist 时同时更新 next
if dist[i][k] + dist[k][j] < dist[i][j]:
dist[i][j] = dist[i][k] + dist[k][j]
next[i][j] = next[i][k]
应用场景
- 传递闭包:判断有向图中任意两点是否可达(将边权设为 1,INF 表示不可达,运行 Floyd 后判断 dist[i][j] 是否 < INF)。
- 求最小环:在带权图中,对于每个 k,在更新前用
dist[i][j] + weight(j,k) + weight(k,i)更新最小环长度。 - 图论问题中的预处理:如 Floyd 之后再用其他算法。
- 小规模网络路由:静态路由表计算。
总结
Floyd 算法是一种简洁优美的动态规划算法,适合顶点数较少(V ≤ 500)、需要所有点对最短路径且可能有负权边的场景。虽然时间复杂度为 O(V³),但常数小、实现简单,在竞赛和工程中仍有广泛应用。理解它的三层循环顺序(k 必须在最外层)是正确性的关键——这保证了递推的阶段性。
优先队列 Dijkstra
优先队列(二叉堆)实现的 Dijkstra 算法时间复杂度为 O((V + E) log V),这是教科书上的标准结论。要理解为什么,需要把复杂度拆解为“堆操作次数”和“单次操作代价”来看。
但请注意,这个公式成立有一个严格的前提:堆中每个顶点只保留一个有效条目,并且支持“减小键值(Decrease-Key)”操作(即更新顶点距离时直接在堆内部修改,不插入新副本)。
如果使用不支持 Decrease-Key 的普通优先队列(如很多语言自带的 heapq/PriorityQueue),复杂度会变为 O(E log E),但由于 E 与 V 的关系,它常被等价视为 O(E log V)。下面先讲标准情况,再解释这个细节。
复杂度拆解:两个核心操作
Dijkstra 的主循环中,所有操作归结为两类:
操作一:提取最小距离顶点(Extract-Min)
- 每一轮循环,算法会从堆中取出当前距离最小的顶点,并将其加入已确定集合。
- 整个算法运行期间,每个顶点恰好被提取一次(因为一旦取出,距离就最终确定,不再入堆)。
- 所以总共执行 V 次 提取操作。
- 在二叉堆中,删除最小值并调整堆结构的复杂度为 O(log V)(堆的高度)。
- 贡献:V × O(log V) = O(V log V)
操作二:松弛边并更新距离(Relaxation + Decrease-Key)
- 当取出顶点 u 后,算法会遍历 u 的所有出边,对邻接点 v 做松弛(即判断
dist[v] > dist[u] + w)。 - 整个算法运行期间,每条边都会被检查一次(因为每个顶点取出时遍历其所有出边,总和就是 E 条边)。
- 如果松弛成功(找到了更短路径),就需要更新堆中 v 的距离值(Decrease-Key)。
- 在最坏情况下,每条边都可能触发一次更新,因此最多执行 E 次 Decrease-Key 操作。
- 在二叉堆中,向上调整减小键值的时间复杂度也是 O(log V)。
- 贡献:E × O(log V) = O(E log V)
- 当取出顶点 u 后,算法会遍历 u 的所有出边,对邻接点 v 做松弛(即判断
总时间复杂度相加
将上述两部分相加: O(V log V) + O(E log V) = O((V + E) log V)
重要补充:为什么实际代码中有时是 O(E log V)?
如果你用 Python 的 heapq 或 Java 的 PriorityQueue 实现,通常不会去实现复杂的 Decrease-Key,而是采用**“懒惰删除”**策略:
- 每次松弛成功时,直接向堆中插入一个新的距离副本
(new_dist, v),而不去修改或删除旧的(old_dist, v)。 - 当弹出堆顶时,检查该条目是否过期(即当前
dist[v]与弹出的距离是否一致),若过期则丢弃。
在这种实现下:
- 堆中最多可能同时存在 E 个条目(因为每条边都可能产生一次松弛)。
- 插入和删除(弹出)操作的复杂度都是 O(log E)。
- 最坏情况下,算法会执行 E 次插入 和 E 次弹出(因为有 E 个条目可能被压入,即使很多被丢弃),总复杂度为 O(E log E)。
那为什么教科书都说 O(E log V) 呢?
因为 E ≤ V²(完全图),所以 log E ≤ 2 log V,因此 O(E log E) = O(E log V)。在数量级上,二者是等价的。而且大多数教材默认使用更优的 Decrease-Key 实现,直接给出严谨的 O((V+E) log V)。
补充:为什么 V 项有时被省略?
在连通图中,E ≥ V - 1,此时 (V + E) 与 E 处于同一个数量级(因为 V 可以被 E 吸收),所以很多人简写为 O(E log V)。
但在非连通图或极稀疏图(如 E 远小于 V)中,V 项不能省略,必须写成 O((V+E) log V),否则会丢失“初始化 V 个顶点入堆”所消耗的线性时间成本。
总结
| 操作 | 执行次数 | 单次堆代价 | 总代价 |
|---|---|---|---|
| 提取最小值(Extract-Min) | V | O(log V) | O(V log V) |
| 松弛更新(Decrease-Key / 插入) | E | O(log V) | O(E log V) |
| 合计 | - | - | O((V+E) log V) |
最终结论:堆操作次数(V 和 E)乘以堆调整高度(log V),就得到了这个经典复杂度。如果使用斐波那契堆优化,Decrease-Key 可降为 O(1),则复杂度能进一步提升至 O(E + V log V)。
floyd 负环
结论:可以。
Floyd-Warshall 算法不仅能识别负环,而且相比 Bellman-Ford,它在检测上有一个天然优势——它可以检测出图中任意位置的负环,不依赖于特定的源点。
具体方法和原理如下:
如何识别(核心方法)
在执行完标准的三重循环(O(V³))之后,只需检查距离矩阵的对角线:
- 如果存在某个顶点
i,使得dist[i][i] < 0,则说明图中存在负环。
代码示例(Python):
def floyd_warshall(n, edges):
# 初始化 dist 矩阵...
dist = [[INF] * n for _ in range(n)]
for i in range(n): dist[i][i] = 0
# 赋值边权...
# 标准三重循环
for k in range(n):
for i in range(n):
for j in range(n):
if dist[i][k] + dist[k][j] < dist[i][j]:
dist[i][j] = dist[i][k] + dist[k][j]
# 检测负环
for i in range(n):
if dist[i][i] < 0:
return True # 存在负环
return False
为什么判断对角线有效?
- 初始化时,
dist[i][i] = 0表示顶点到自身距离为 0。 - 在松弛过程中,如果存在一个环
i -> ... -> i的总权重为负数,那么递推公式dist[i][i] = min(dist[i][i], dist[i][k] + dist[k][i])就会捕捉到这条路径。 - 一旦
dist[i][i]变成负数,就说明算法发现了一条从i出发回到i且权值为负的闭合路径,即负环。
与 Bellman-Ford 的区别(Floyd 的优势)
| 算法 | 检测范围 | 限制条件 |
|---|---|---|
| Bellman-Ford | 只能检测从给定源点出发可达的负环 | 如果负环在另一个不连通的子图中,且源点不在该子图,则检测不到 |
| Floyd-Warshall | 检测全图任意位置的负环 | 不需要指定源点,三重循环结束后检查所有 dist[i][i] 即可,只要有负环就一定会在某个 i 的对角线上体现 |
重要注意事项(算法失效)
虽然 Floyd 能识别负环,但一旦识别出负环,算法计算出的其他顶点间最短距离将失去数学意义(结果为 -∞)。
- 在存在负环时,标准的三重循环依然会运行完毕,但由于负环的存在,
dist矩阵中的值会随着循环的进行不断被刷新变小(理论上会无限减小,但受限于V次迭代,只会反映有限步数内的最优)。 - 实际应用:当你检测到
dist[i][i] < 0时,你应该立即停止使用该结果进行路径查询,只取“存在负环”这个布尔结论。
极端情况补充
- 不连通图:如果图中存在负环,但没有任何顶点到该环(或环到任何顶点)有边,Floyd 依然能检测到,因为只要
i在环上,dist[i][i]在计算时只依赖环内路径,不依赖外部连通性。 - 整数溢出:如果负环权值很大(绝对值大),多次松弛可能导致
dist值溢出。在实际编码时,需要设定INF并进行防止溢出的判断(如if dist[i][k] != INF and dist[k][j] != INF),但若存在负环,持续相加可能导致溢出,建议使用 Python(大整数)或加long long,并在检测时注意边界。
堆
要回答这个问题,首先要明确对比的具体场景。通常我们说的“自顶向下”和“自底向上”调整,主要针对建堆(Heapify)过程,而堆的插入/删除操作则是另一种情况。
绝大多数面试和教材中讨论的“自顶向下更耗时”,指的是建堆。下面分场景为你详细拆解:
建堆场景(核心差异)
- 自底向上(Floyd 建堆):从最后一个非叶节点开始,向前遍历到根节点,对每个节点执行向下调整(Sift-Down)。时间复杂度为 O(n)。
- 自顶向下(插入建堆):从空堆开始,依次将每个新元素追加到数组末尾,然后执行向上调整(Sift-Up)。时间复杂度为 O(n log n)。
为什么自顶向下更耗时? 根本原因在于 “节点数量” 与 “需要移动的距离” 的乘积总和不同。
自底向上:节点越多,所在的层数越低,下沉距离越短。
树底部的节点数量最多(约占一半),但它们最多只需下沉 1 次(因为下方没有更多子节点或只需交换一次)。顶部的节点虽然要下沉 log n 次,但顶部节点只有少数几个。数学计算求和后,总移动次数趋于线性 O(n)。自顶向下:节点插入越晚,所在的层数越高,上浮距离越长。
当插入后半段元素时,树的高度已经很高。约一半的节点最终处于树的底层,它们在插入时都需要从底部一路向上浮动到合适位置,平均移动距离接近 log n。因此总移动次数为 O(n log n)。
一句话结论:自底向上把“大量体力活”分给了数量庞大的底层节点(它们只需干很少的活),而把“少量重活”留给了顶层节点;自顶向下则恰好相反,导致总体工作量更大。
堆的插入与删除场景(引申)
如果你是在问单次操作:
- 插入(自底向上 Sift-Up):新元素在末尾,上浮比较只与父节点比较。
- 删除堆顶(自顶向下 Sift-Down):将末尾元素移到堆顶,下沉时需要与两个子节点比较,并选最大的那个交换。
在单次操作中,自顶向下(删除)通常比自底向上(插入)略耗时,因为下沉比较需要多一次子节点大小判断,循环内的比较次数更多。但两者的时间复杂度都是 O(log n),这种耗时差异是常数级别的,远小于建堆时的 O(n) vs O(n log n) 量级差异。
总结记忆点
- 问**“建堆”**:自底向上更快(O(n)),自顶向上更慢(O(n log n)),因为底层节点数量红利。
- 问**“单次操作”**:自顶向下(Sift-Down)稍慢于自底向上(Sift-Up),仅因比较次数较多(常系数差异)。
如果你的问题特指某个具体场景,欢迎补充上下文,我可以再针对性细化分析。😊
堆排序
是的,堆排序本质上就是一种选择排序。 要理解这一点,需要先明确“选择排序”在算法分类上的定义,而非仅局限于“每次遍历找最值”的具体实现。
选择排序的核心定义
在算法理论中,**选择排序(Selection Sort)**泛指一类算法策略:每一轮从未排序的序列中选出最大(或最小)的元素,将其放到已排序序列的末尾(或开头),直到所有元素有序。
这个定义包含两个固定动作:
- 选择:从剩余无序元素中找出极值。
- 交换/放置:将选出的极值与当前无序区的边界元素交换,使其进入有序区。
堆排序完全遵循这个流程
堆排序的每一步都严格对应上述定义:
- 选择:利用大顶堆(或小顶堆)的性质,堆顶元素就是当前无序序列中的最大值(或最小值)。取堆顶即完成了“选择”。
- 交换/放置:将堆顶元素与堆数组的最后一个元素交换。此时,这个极值就被移动到了已排序区的头部(或尾部)。
- 缩小无序区:将堆的大小减 1,然后对新的堆顶执行向下调整(Heapify),为下一次“选择”做准备。
结论:堆排序的算法骨架就是“反复选择极值并交换”,这与冒泡式选择排序的骨架完全一致,只是“找极值”的手段不同。
与“简单选择排序”的区别(为何容易被混淆)
很多人下意识觉得选择排序就是 O(n^2) 的嵌套循环,那是因为教科书通常先讲简单选择排序(Simple Selection Sort)——它通过线性扫描 O(n) 来找极值。
堆排序可以看作是选择排序的高级优化版:
| 算法 | 找极值的方式 | 单次选择耗时 | 总复杂度 |
|---|---|---|---|
| 简单选择排序 | 遍历整个无序区扫描 | O(n) | O(n²) |
| 堆排序 | 利用堆的树形结构调整 | O(log n) | O(n log n) |
它们的关系类似于:“二分查找”是“顺序查找”的优化,但两者都属于“查找算法”。同理,堆排序是选择排序的优化,因为它的根本策略没有变,变的只是获取极值的实现技术。
再对比归并排序(加深理解)
为了彻底厘清,可以对比另一个经典排序:
- 归并排序:核心是“分治+合并”,不管当前最大值,先把数据拆碎再组合。
- 堆排序:核心是“选择+交换”,每次都关心当前剩余数据的最大值。
正是因为堆排序在执行过程中,总在关注全局最值并逐个抽出,这完全吻合“选择”这一动作的本质,因此各大经典教材(如《算法导论》)都将堆排序归入“选择排序”这一大类。只不过在日常口语中,“选择排序”常被狭义地指代那三种简单排序(选择、插入、冒泡)之一,但在数据结构理论里,堆排序确实是不折不扣的选择排序家族成员。
Rabin-Karp
Rabin-Karp 算法的复杂度分为时间复杂度和空间复杂度,且时间复杂度需要区分平均情况和最坏情况。
设文本串长度为 n,模式串长度为 m(通常 n ≥ m):
时间复杂度
平均 / 最佳情况:O(n + m)
- O(m):计算模式串的哈希值,并计算文本串中第一个长度为 m 的子串的哈希值。
- O(n):遍历文本串,利用滚动哈希(Rolling Hash)在 O(1) 时间内逐个计算后续每个位置的长度为 m 的子串哈希值,并与模式串哈希值比较。
- 在理想情况下,哈希冲突极少,每次比较哈希值(O(1))即可确定是否匹配,因此整体接近线性 O(n+m)。
最坏情况:O(n × m)
- 当哈希冲突极其严重时(例如哈希函数设计不当,或所有子串的哈希值都相同),算法会退化为朴素匹配。
- 每次哈希匹配成功时,都需要逐个字符比较(O(m))来验证是否真的相等,以防误报(假阳性)。
- 如果文本串中几乎所有长度为 m 的子串哈希值都与模式串相同,则需要进行 (n - m + 1) 次比较,每次比较 O(m),总复杂度退化为 O(n × m)。
特别说明:如果使用模数很大的质数(如 2^64 自然溢出或 1e9+7)来最大限度降低冲突概率,在实际工程和竞赛中,平均复杂度 O(n+m) 是被广泛认可的,极少出现极端退化情况。
空间复杂度
- O(1)(辅助空间)
- 算法仅需存储模式串的哈希值、当前文本子串的哈希值,以及滚动更新时所需的前缀幂次(如进制数的幂)。
- 如果预先计算所有子串的前缀哈希数组(另一种实现方式),则空间复杂度会升至 O(n),但通常 Rabin-Karp 的滚动实现只需常量级额外空间。
总结一览表
| 维度 | 复杂度 | 说明 |
|---|---|---|
| 平均时间复杂度 | O(n + m) | 滚动哈希 O(1) 更新,冲突极少 |
| 最坏时间复杂度 | O(n × m) | 大量哈希冲突导致逐个字符回退验证 |
| 空间复杂度 | O(1) | 仅维护几个哈希变量和幂次值(常规滚动实现) |
一个常见的误区:很多人误以为 Rabin-Karp 总是 O(n+m)。请记住,它的高效率依赖于低碰撞概率,如果需要绝对保证线性时间且不允许最坏情况退化,应选择 KMP 算法(O(n+m) 严格最坏情况)或 Boyer-Moore 算法。