作为数据结构的课程笔记,以便查阅。如有出错的地方,还请多多指正!
目录
Dijkastra
- Dijkastra 用来解决单源点的最短路径问题:给定带权有向图 G G G 和源点 v v v,求从 v v v 到 G G G 中其余各顶点的最短路径
算法描述
贪心算法:按路径长度非递减次序产生最短路径
- 把顶点的集合
V
V
V 分成两组:
- S S S:{已求出最短路径的顶点}
- T = V − S T=V-S T=V−S:{尚未确定最短路径的顶点}
- 每次从 T T T 中找出距离值最小的顶点加入到 S S S 中,直到 S = V S=V S=V 为止
- 保证:
- 每一个顶点对应一个距离值
- S S S 中顶点:从 V 0 V_0 V0 到此顶点的最短路径长度
- T T T 中顶点:从 V 0 V_0 V0 到此顶点的只包括 S S S 中顶点作中间顶点的最短路径长度
- S S S 中各顶点的距离值 ≤ ≤ ≤ T T T 中各顶点的距离值
- 每一个顶点对应一个距离值
步骤:
- 初始时,令 S = V 0 S={V_0} S=V0, T = 其余顶点 T={其余顶点} T=其余顶点, T T T 中距离值为 V 0 V_0 V0 到 V i V_i Vi 对应的距离值,即,若存在 < V 0 , V i > <V_0,V_i> <V0,Vi>,为 < V 0 , V i > <V_0,V_i> <V0,Vi> 弧上的权值;若不存在 < V 0 , V i > <V_0,V_i> <V0,Vi>,为 ∞ \infty ∞
- 从 T T T 中选取一个其距离值为最小的顶点 W W W,加入 S S S。然后对 T T T 中其余顶点的距离值进行修改:若加进 W W W 作中间顶点,从 V 0 V_0 V0 到 V i V_i Vi 的距离值比不加 W W W 的路径要短,则修改此距离值;这样就保证了 T T T 中顶点保存的距离一定是从源点只经过 S S S 中各顶点的最短路径的距离
- 重复上述步骤,直到
S
=
V
S=V
S=V 为止
- Dijkstra 算法可以很好地解决无负权图的最短路径问题, 但如果出现了负权边, Dijkstra 算法就会失效
- 例如图 10-39 中设置
A
A
A 为源点时, 首先会将点
B
B
B 和点
C
C
C 的
d
i
s
t
dist
dist 值变为
−
1
-1
−1 和
1
1
1, 接着由于点
B
B
B 的
d
i
s
t
dist
dist 值最小, 因此用点
B
B
B 去更新其未访问的邻接点。在这之后点
B
B
B 标记为己访问, 于是将无法被从点
C
C
C 出发的边
C
B
CB
CB 更新, 因此最后
d
i
s
t
[
B
]
dist[B]
dist[B] 是
−
1
-1
−1 而非最短路径长度
−
4
-4
−4
- 例如图 10-39 中设置
A
A
A 为源点时, 首先会将点
B
B
B 和点
C
C
C 的
d
i
s
t
dist
dist 值变为
−
1
-1
−1 和
1
1
1, 接着由于点
B
B
B 的
d
i
s
t
dist
dist 值最小, 因此用点
B
B
B 去更新其未访问的邻接点。在这之后点
B
B
B 标记为己访问, 于是将无法被从点
C
C
C 出发的边
C
B
CB
CB 更新, 因此最后
d
i
s
t
[
B
]
dist[B]
dist[B] 是
−
1
-1
−1 而非最短路径长度
−
4
-4
−4
算法有效性证明
贪心算法的有效性证明:最优子结构性质 + 贪心选择性质
- 最优子结构性质:由 Dijkastra 算法步骤易证,每次更新得到的 T T T 中各顶点对应的距离一定为从源点出发,只经过 S S S 中各顶点得到的最短路径的距离
- 贪心选择性质:设
d
i
s
t
[
i
]
dist[i]
dist[i] 为从源点到
i
i
i 只经过
S
S
S 中顶点的最短路径长度。假设我们在这一步中选择的最短路径点为
u
u
u。如下图所示,如果存在一条从源到
u
u
u 的路且该路径中存在不属于
S
S
S 的顶点,则设这条路初次走出
S
S
S 之外到达的顶点为
x
∈
V
−
S
x\in V-S
x∈V−S,然后徘徊于
S
S
S 内外若干次,最后离开
S
S
S 到达
u
u
u
∵ d ( v , x ) + d ( x , u ) = d ( v , u ) ≤ d i s t [ u ] , d ( x , u ) ≥ 0 ∴ d i s t [ x ] ≤ d ( v , x ) ≤ d i s t [ u ] \because d(v,x)+d(x,u)=d(v,u)\leq dist[u],d(x,u)\geq0\\ \therefore dist[x]\leq d(v,x)\leq dist[u] ∵d(v,x)+d(x,u)=d(v,u)≤dist[u],d(x,u)≥0∴dist[x]≤d(v,x)≤dist[u]与贪心选择矛盾,因此 d i s t [ u ] dist[u] dist[u] 必为 v v v 到 u u u 的最短路径
算法实现
d[ ]
:一维数组,用于描述路径长度d[v]
存放当前找到的从源点 V 0 V_0 V0 到 v v v 的最短路径长度,其初始值为 V 0 V_0 V0 到顶点 v v v 的弧的权值
pre[ ]
:描述最短路径pre[v]
中存 V 0 → v V_0 \rightarrow v V0→v 的最短路径中与 v v v 相邻的点的索引,不存在路径则用 − 1 -1 −1 表示。最后输出路径时需要用到堆栈 / 递归
vis[ ]
:描述顶点是否已求得最短路径vis[v]
为 1,表示 v ∈ S v\in S v∈S;为 0,表示 v ∈ T v\in T v∈T
邻接矩阵
const int MAXV = 1000; // 最大顶点数
const int INF = 0x3fffffff; // 这里为了防止溢出, 不使用 0x7fffffff
int n, G[MAXV][MAXV]; // n 为顶点数
int d[MAXV]; // 起点到达各点的最短路径长度
int pre[MAXV]; // pre[v] 表示从起点到顶点 v 的最短路径上 v 的前一个顶点
bool vis[MAXV] = {false}; // 标记数组, vis[i]==true 表示已访问
void Dijkstra(int s) { // s 为起点
fill(d, d + MAXV, INF); // fill 函数将整个 d 数组赋为 INF
for(int i = 0; i < n; i++)
pre[i] = i; // 初始状态设每个点的前驱为自身
d[s] = 0; // 起点 s 到达自身的距离为 0
for(int i = 0; i < n; i++) {
int u = -1, MIN = INF; // u 使 d[u] 最小, MIN 存放该最小的 d[u]
for(int j = 0; j < n; j++){ // 找到未访问的顶点中 d[j] 最小的
if (vis[j] == false && d[j] < MIN) {
u = j;
MIN = d[j];
}
}
// 找不到小于 INF 的 d[u], 说明剩下的顶点和起点 s 不连通
if (u == -1)
return;
vis[u] = true; // 标记 u 为己访问
for(int v = 0; v < n; v++) {
if(vis[v] == false && G[u][v] != INF && d[u] + G[u][v] < d[v]){
d[v] = d[u] + G[u][v];
pre[v] = u; // 记录 v 的前驱顶点是 u
}
}
}
}
T ( n ) = O ( n 2 ) T(n)=O(n^2) T(n)=O(n2)
居然达到了邻接矩阵的最优时间复杂度!
邻接表
struct Node {
int v, dis; // v 为边的目标顶点, dis 为边权
};
vector<Node> Adj[MAXV];
int n; // n 为顶点数
int d[MAXV];
int pre[MAXV];
bool vis[MAXV] = {false};
void Dijkstra(int s) {
fill(d, d + MAXV, INF);
for(int i = 0; i < n; i++)
pre[i] = i;
d[s] = 0;
for (int i = 0; i < n; i++) {
int u = -1, MIN = INF;
for(int j = 0; j < n; j++) {
if (vis[j] == false && d[j] < MIN) {
u = j;
MIN = d[j];
}
}
if(u == -1)
return;
vis[u] = true;
// 只有下面这个 for 与邻接矩阵的写法不同
for (int j = 0; j < Adj[u].size(); j++) {
int v = Adj[u][j].v;
if(vis[v] == false && d[u] + Adj[u][j].dis < d[v]){
d[v] = d[u] + Adj[u][j].dis;
pre[v] = u;
}
}
}
}
- 上面代码中每次找最小距离的操作还是用了遍历整个数组的方法,但是如果采用最小堆,最终的时间复杂度为:
T ( n ) = O ( e l o g n ) T(n)=O(elogn) T(n)=O(elogn)
Bellman-Ford (BF)
参考《算法笔记》
- BF 算法可解决单源最短路径问题,也能处理有负权边的情况 (不能处理负值圈)
算法描述及有效性证明
for (i = 0; i < n - 1; i++) { // 执行 n - 1 轮操作,其中 n 为顶点数
for (each edge u->v) { // 每轮操作都遍历所有边
if (d[u] + length[u->v] < d[v]) { // 以 u 为中介点可以使 d[v] 更小
d[v] = d[u] + length[u->v]; // 松弛操作
}
}
}
证明
- 如果最短路径存在,那么最短路径上的顶点个数肯定不会超过
V
V
V 个。于是,如果把源点
s
s
s 作为一棵树的根结点,把其他结点按照最短路径的结点顺序连接,就会生成一棵最短路径树。
- 由于初始状态下 d [ s ] d[s] d[s] 为 0, 因此在接下来的步骤中 d [ s ] d[s] d[s] 不会被改变(也就是说,最短路径树中第一层结点的 d d d 值被确定)。接着,通过 Bellman-Ford 算法的第一轮操作之后,最短路径树中的第二层顶点的 d d d 值也会被确定下来;然后进行第二轮操作, 于是第三层顶点的 d d d 值也被确定下来。这样计算直到最后一层顶点的 d d d 值确定。由于最短路径树的层数不超过 V V V 层,因此 Bellman-Ford 算法的松弛操作不会超过 V − 1 V-1 V−1 轮。证毕
- 此时, 如果图中没有从源点可达的负环 (负值圈), 那么数组
d
d
d 中的所有值都应当已经达到最优。因此,如下面的伪代码所示, 只需要再对所有边进行一轮操作, 判断是否有某条边
u
→
v
u\rightarrow v
u→v 仍然满足
d
[
u
]
+
l
e
n
g
t
h
[
u
→
v
]
<
d
[
v
]
d[u]+ length[u\rightarrow v] < d[v]
d[u]+length[u→v]<d[v], 如果有, 则说明图中有从源点可达的负环, 返回
false
; 否则,说明数组 d d d 中的所有值都已经达到最优, 返回true
for (each edge u->v) { // 对每条边进行判断
if(d[u] + length[u->v] < d[v]) { // 如果仍可以被松弛
return false; // 说明图中有从源点可达的负环
}
return true; // 数组 d 的所有值都已经达到最优
}
时间复杂度
- 可以看出,如果使用邻接表,则 Bellman-Ford 算法的时间复杂度是
O
(
V
E
)
O(VE)
O(VE), 其中
V
V
V 是顶点个数,
E
E
E 是边数;如果是用邻接矩阵,那时间复杂度就会上升到
O
(
V
3
)
O(V^3)
O(V3)
- 因此,一般都使用邻接表
算法实现
struct Node {
int v, dis; // v 为邻接边的目标顶点,dis 为邻接边的边权
};
vector<Node> Adj[MAXV]; // 邻接表
int n; // n 为顶点数
int d[MAXV]; // 起点到达各点的最短路径长度
bool Bellman(int s) { // s 为源点
fill(d, d + MAXV, INF); // fill 函数将整个 d 数组赋为 INF
d[s] = 0; // 起点 s 到达自身的距离为 0
int slacken_flag = false;
// 以下为求解数组 d 的部分
for (int i = 0; i < n - 1; i++) { // 执行 n - 1 轮操作,n 为顶点数
for(int u = 0; u < n; u++) { // 每轮操作都遍历所有边
for (int j = 0; j < Adj[u].size(); j++) {
int v = Adj[u][j].v; // 邻接边的顶点
int dis = Adj[u][j].dis; // 邻接边的边权
if (d[u] + dis < d[v]} { // 以 u 为中介点可以使 d[v] 更小
d[v] = d[u] + dis; // 松弛操作
slacken_flag = true;
}
}
}
// 如果在某一轮操作时, 发现所有边都没有被松弛,
// 那么说明数组 d 中的所有值都已经达到最优,
// 不需要再继续, 提前退出即可
if(!slacken_flag)
{
break;
}
slacken_flag = false;
}
// 以下为判断负环的代码
for(int u = 0; u < n; u++) { // 对每条边进行判断
for(int j = 0; j < Adj[u].size(); j++) {
int v = Adj[u][j].v; // 邻接边的顶点
int dis = Adj[u][j].dis; // 邻接边的边权
if(d[u] + dis < d[v]) { // 如果仍可以被松弛
return false; // 说明图中有从源点可达的负环
}
}
}
return true; // 数组 d 的所有值都已经达到最优
}
SPFA (Shortest Path Faster Algorithm)
参考《算法笔记》
算法描述及有效性证明
- 虽然 Bellman-Ford 算法的思路很简洁,但是
O
(
V
E
)
O(VE)
O(VE) 的时间复杂度确实很高,在很多情况下并不尽如人意。仔细思考后会发现,Bellman-Ford 算法的每轮操作都需要操作所有边, 显然这其中会有大量无意义的操作,严重影响了算法的性能。于是注意到,只有当某个顶点
u
u
u 的
d
[
u
]
d[u]
d[u] 值改变时, 从它出发的边的邻接点
v
v
v 的
d
[
v
]
d[v]
d[v] 值才有可能被改变。由此可以进行一个优化:
- 建立一个队列,将源点入队
- 每次将队首顶点 u u u 取出,然后对从 u u u 出发的所有边 u → v u\rightarrow v u→v 判断 d [ u ] + l e n g t h [ u → v ] < d [ v ] d[u] + length[u\rightarrow v] < d[v] d[u]+length[u→v]<d[v] 是否成立,如果成立,则进行松弛操作,于是 d [ v ] d[v] d[v] 获得更优的值,此时如果 v v v 不在队列中,就把 v v v 加入队列
- 这样操作直到队列为空(说明图中没有从源点可达的负环),或是某个顶点的入队次数超过 V − 1 V-1 V−1 (说明图中存在从源点可达的负环)
伪代码:
queue<int> Q;
Q.push(s); // 源点 s 入队
while (!Q.empty()) {
取出队首元素 u;
for(u 的所有邻接边 u->v) {
if(d[u] + dis < d[v]) {
d[v] = d[u] + dis;
if(v 当前不在队列) {
Q.push(v);
if(v 入队次数 > n - 1) {
// 有可达负环
return;
}
}
}
}
}
时间复杂度
- 它的期望时间复杂度是
O
(
k
E
)
O(kE)
O(kE), 其中
E
E
E 是图的边数,
k
k
k 是一个常数, 在很多情况下
k
k
k 不超过 2, 可见这个算法在大部分数据时异常高效, 并且经常性地优于堆优化的 Dijkstra 算法
- 但如果图中有从源点可达的负环,传统 SPFA 的时间复杂度就会退化成 O ( V E ) O(VE) O(VE)
判断负环
- 注意:使用 SPFA 可以判断是否存在从源点可达的负环,
- 如果负环从源点不可达, 则需要添加一个辅助顶点 C C C, 并添加一条从源点到达 C C C 的有向边以及 V − 1 V-1 V−1 条从 C C C 到达除源点外各顶点的有向边才能判断负环是否存在
算法实现
vector<Node> Adj[MAXV] ; // 邻接表
int n, d[MAXV], num[MAXV]; // num 数组记录顶点的入队次数
bool inq[MAXV]; // 顶点是否在队列中
bool SPFA(int s) {
// 初始化部分
memset(inq, false, sizeof(inq));
memset(num, 0, sizeof(num));
fill(d, d + MAXV, INF);
// 源点入队部分
queue<int> Q;
Q.push(s); // 源点入队
inq[s] = true;
num[s]++;
d[s] = 0; // 源点的 d 值为 0
// 主体部分
while (!Q.empty()) {
int u = Q.front();
Q.pop();
inq[u] = false; // 设置 u 为不在队列中
// 遍历 u 的所有邻接边 v
for (int j = 0; j < Adj[u].size(); j++) {
int v = Adj[u][j].v;
int dis = Adj[u][j].dis;
// 松弛操作
if (d[u] + dis < d[v]) {
d[v] = d[u] + dis;
if(!inq[v]) { // 如果 v 不在队列中
Q.push(v);
inq[v] = true;
num[v]++;
if (num[v] >= n)
return false; // 有可达负环
}
}
}
}
return true; //无可达负环
}
- SPFA 十分灵活,其内部的写法可以根据具体场景的不同进行调整。例如上面代码中的 FIFO 队列可以替换成优先队列, 以加快速度;或者替换成双端队列 (deque), 使用 SLF 优化和 LLL 优化 ,以使效率提高至少50%。除此之外,上面给出的代码是 SPFA 的 BFS 版本 ,如果将队列替换成栈,则可以实现 DFS 版本的 SPFA, 对判环有奇效
Floyd
- Floyd 可以用来解决全源最短路问题,即求每一对顶点之间的最短距离 (尽管可以重复执行 Dijkastra 算法
n
n
n 次,但是 Floyd 算法要更加简洁)
- 采用邻接矩阵, T ( n ) = O ( n 3 ) T(n)=O(n^3) T(n)=O(n3)
- Floyd 能处理负值边,但不能处理负值圈
算法描述
- 算法思想:逐个顶点试探法
步骤:
- 初始时设置一个 n n n 阶方阵,令其对角线元素为 0,若存在弧 < V i , V j > <V_i,V_j> <Vi,Vj>,则对应元素为权值;否则为 ∞ \infty ∞ (邻接矩阵)
- 逐步试着在原直接路径中增加一个中间顶点,若加入中间点后路径变短,则修改之;否则,维持原值
- 所有顶点试探完毕,算法结束
p a t h [ i ] [ j ] path[i][j] path[i][j]:最短路径 i → j i\rightarrow j i→j 上顶点 j j j 的前一个顶点的索引
算法有效性证明
- 设
D
[
k
]
[
i
]
[
j
]
D^{[k]}[i][j]
D[k][i][j]:从
i
i
i 到
j
j
j,不经过索引比
k
k
k 大的顶点的最短路径; 则
D
[
k
+
1
]
[
i
]
[
j
]
D^{[k+1]}[i][j]
D[k+1][i][j] 取下面两个值的最小值即可
- 经过点 k + 1 k+1 k+1: D [ k ] [ i ] [ k + 1 ] + D [ k ] [ k + 1 ] [ j ] D^{[k]}[i][k+1] +D^{[k]}[k+1][j] D[k][i][k+1]+D[k][k+1][j]
- 不经过点 k + 1 k+1 k+1: D [ k ] [ i ] [ j ] D^{[k]}[i][j] D[k][i][j]
算法实现
Status_e Floyd(pAdjacentMatrix_t pgraph, AdjacentMatrixWeightType_t distance[MAX_VERTEX_NUM][MAX_VERTEX_NUM], int path[MAX_VERTEX_NUM][MAX_VERTEX_NUM])
{
// init
for (int i = 0; i < pgraph->vexNum; ++i)
{
for (int j = 0; j < pgraph->vexNum; ++j)
{
distance[i][j] = pgraph->arc[i][j];
path[i][j] = (distance[i][j] < INFINITY && i != j) ? i : -1;
}
}
// 加入 k 作为中间顶点进行试探;路径: i->j
for (int k = 0; k < pgraph->vexNum; ++k)
{
for (int i = 0; i < pgraph->vexNum; ++i)
{
for (int j = 0; j < pgraph->vexNum; ++j)
{
if (distance[i][k] + distance[k][j] < distance[i][j])
{
distance[i][j] = distance[i][k] + distance[k][j];
if (i == j && distance[i][j] < 0)
{
printf("Err! \r\n");//负值圈
return err;
}
path[i][j] = path[k][j]; // 注意这里更新 path 的方式
}
}
}
}
return ok;
}