0x61
最短路
对于一张有向图,我们一般采用邻接矩阵和邻接表两种存储方法。对于无向图,可以把无向边看作两条方向相反的有向边,从而采用与有向图一样的存储方式。因此,在讨论最短路问题时,我们都以有向图为例。
设有向图
G
=
(
V
,
E
)
G=(V,E)
G=(V,E),
V
V
V是点集,
E
E
E是边集,
(
x
,
y
)
(x,y)
(x,y)表示一条从
x
x
x到
y
y
y的有向边,其边权(或称长度)为
w
(
x
,
y
)
w(x,y)
w(x,y)。设
n
=
∣
V
∣
,
m
=
∣
E
∣
n=\lvert V \rvert ,m=\lvert E \rvert
n=∣V∣,m=∣E∣,邻接矩阵
A
A
A是一个
n
∗
n
n*n
n∗n的矩阵,我们把它定义为:
A
[
i
,
j
]
=
{
0
,
i
=
j
w
(
i
,
j
)
,
(
i
,
j
)
∈
E
+
∞
,
(
i
,
j
)
∉
E
A[i,j]=\left \{\begin{array}{l} 0,i=j \\ w(i,j),(i,j)\in E \\ +\infty,(i,j)\notin E \end{array} \right.
A[i,j]=⎩
⎨
⎧0,i=jw(i,j),(i,j)∈E+∞,(i,j)∈/E
邻接矩阵的空间复杂度为 O ( n 2 ) O(n^2) O(n2)。
在0x13
节中已经讲解过了“邻接表”,并用数组模拟链表的形式进行了代码实现。长度为
n
n
n的表头数组
h
e
a
d
head
head记录了从每个节点出发的第一条边在
v
e
r
ver
ver和
e
d
g
e
edge
edge数组的存储位置,长度为
m
m
m的边集数组
v
e
r
ver
ver和
e
d
g
e
edge
edge记录了每条边的终点和边权,长度为
m
m
m的数组
n
e
x
t
next
next模拟了链表指针,表示从相同节点出发的下一条边在
v
e
r
ver
ver和
e
d
g
e
edge
edge数组中的存储位置。邻接表的空间复杂度为
O
(
n
+
m
)
O(n+m)
O(n+m)。
void add(int x,int y,int z)
{
ver[++tot]=y,edge[tot]=z;
next[tot]=head[x],head[x]=tot;
}
//访问从x出发的所有边
for(int i=head[x];i;i=next[i])
{
int y=ver[i],z=edge[i];
}
1.单源最短路径
单源最短路径问题(Single Source Shortest Path
,SSSP
问题)是说,给定一张有向图
G
=
(
V
,
E
)
G=(V,E)
G=(V,E),
V
V
V是点集,
E
E
E是边集,节点以
[
1
,
n
]
[1,n]
[1,n]之间的连续整数编号,
(
x
,
y
,
z
)
(x,y,z)
(x,y,z)描述一条从
x
x
x出发,到达
y
y
y,长度为
z
z
z的有向边。设1号点为起点,求长度为
n
n
n的数组
d
i
s
t
dist
dist,其中
d
i
s
t
[
i
]
dist[i]
dist[i]表示从起点1到节点
i
i
i的最短路径的长度。
Dijkstra
算法
Dijkstra
算法的流程如下:
1.初始化 d i s t [ 1 ] = 0 dist[1]=0 dist[1]=0,其余节点的 d i s t dist dist值为正无穷大。
2.找出一个未被标记的、 d i s t [ x ] dist[x] dist[x]最小的节点 x x x,然后标记节点 x x x。
3.扫描节点 x x x的所有边 ( x , y , z ) (x,y,z) (x,y,z),若 d i s t [ y ] > d i s t [ x ] + z dist[y]>dist[x]+z dist[y]>dist[x]+z,则使用 d i s t [ x ] + z dist[x]+z dist[x]+z更新 d i s t [ y ] dist[y] dist[y]。
4.重复上述 2 ∼ 3 2\sim 3 2∼3两个步骤,直到所有节点都被标记。
Dijkstra
算法基于贪心思想,它只适用于所有边的长度都是非负数的图。当边长
z
z
z都是非负数时,全局最小值不可能再被其他节点更新,故在第一步选出的节点
x
x
x必然满足:
d
i
s
t
[
x
]
dist[x]
dist[x]已经是起点到
x
x
x的最短路径。我们不断选择全局最小值进行标记和扩展,最终可得到起点1到每个节点的最短路径的长度。
int a[3010][3010],d[3010],n,m;
bool v[3010];
void dijkstra()
{
memset(d,0x3f,sizeof(d));
memset(v,0,sizeof(v)); //节点是否被标记过
d[1]=0;
for(int i=1;i<n;++i) //重复进行n-1次
{
int x=0;
//找到未标记节点中dist最小的
for(int j=1;j<=n;++j)
if(!v[j]&&(x==0||d[j]<d[x]))
x=j;
v[x]=1;
//用全局最小值点x更新其他节点
for(int y=1;y<=n;++y)
d[y]=min(d[y],d[x]+a[x][y]);
}
}
int main()
{
cin>>n>>m;
memset(a,0x3f,sizeof(a));
for(int i=1;i<=n;++i) a[i][i]=0;
for(int i=1;i<=m;++i)
{
int x,y,z;
scanf("%d%d%d",&x,&y,&z);
a[x][y]=min(a[x][y],z);
}
dijkstra();
for(int i=1;i<=n;++i)
printf("%d\n",d[i]);
}
上面程序的时间复杂度为
O
(
n
2
)
O(n^2)
O(n2),主要瓶颈在于第一步的寻找全局最小值的过程。可以使用二叉堆(C++ STL priority_queue
,0x71
节)对
d
i
s
t
dist
dist数组进行维护,用
O
(
l
o
g
n
)
O(logn)
O(logn)的时间获取最小值并从堆中删除,用
O
(
l
o
g
n
)
O(logn)
O(logn)的时间执行一条边的扩展和更新,最终可在
O
(
m
l
o
g
n
)
O(mlogn)
O(mlogn)(实际上为
O
(
(
m
+
n
)
l
o
g
n
)
O((m+n)logn)
O((m+n)logn))的时间内实现Dijkstra
算法。
const int N=100010,M=100010;
int head[N],ver[M],edge[M],Next[M],d[N];
bool v[N];
int n,m,tot;
//pair第一维为dist,第二维为节点编号
priority_queue<pair<int,int>,vector<pair<int,int> >,greater<pair<int,int> > q;
void add(int x,int y,int z)
{
ver[++tot]=y,edge[tot]=z,Next[tot]=head[x],head[x]=tot;
}
void dijkstra()
{
memset(d,0x3f,sizeof(d));
memset(v,0,sizeof(v)); //节点是否被取出过标记过
d[1]=0;
q.push({0,1});
while(!q.empty())
{
int x=q.top().second;
q.pop();
if(v[x]) continue;
v[x]=1;
for(int i=head[x];i;i=Next[i])
{
int y=ver[i],z=edge[i];
if(d[y]>d[x]+z)
{
d[y]=d[x]+z;
q.push({d[y],y});
}
}
}
}
int main()
{
cin>>n>>m;
for(int i=1;i<=m;++i)
{
int x,y,z;
scanf("%d%d%d",&x,&y,&z);
add(x,y,z);
}
dijkstra();
for(int i=1;i<=n;++i)
printf("%d\n",d[i]);
}
Bellman-Ford
算法和SPFA
算法
给定一张有向图,若对于图中的某一条边 ( x , y , z ) (x,y,z) (x,y,z),有 d i s t [ y ] ≤ d i s t [ x ] + z dist[y]\leq dist[x]+z dist[y]≤dist[x]+z成立,则称该边满足三角形不等式。若所有边,满足三角形不等式,则 d i s t dist dist数组就是所求最短路。
我们首先介绍基于迭代思想的Bellman-Ford
算法。它的流程如下:
1.扫描所有边 ( x , y , z ) (x,y,z) (x,y,z),若 d i s t [ y ] > d i s t [ x ] + z dist[y]>dist[x]+z dist[y]>dist[x]+z,则用 d i s t [ x ] + z dist[x]+z dist[x]+z更新 d i s t [ y ] dist[y] dist[y]。
2.重复上述步骤,直到没有更新操作发生。
Bellman-Ford
算法的时间复杂度为
O
(
n
m
)
O(nm)
O(nm)。
实际上,SPFA
算法在国际上称为“队列优化的Bellman-Ford
算法”,仅在中国大陆流行“SPFA
算法”的称谓。回顾0x26
节对“广搜变形”的讨论与总结。SPFA
算法的流程如下:
1.建立一个队列,最初队列中只含有起点1。
2.取出队头节点 x x x,扫描它的所有出边 ( x , y , z ) (x,y,z) (x,y,z),若 d i s t [ y ] > d i s t [ x ] + z dist[y]>dist[x]+z dist[y]>dist[x]+z,则使用 d i s t [ x ] + z dist[x]+z dist[x]+z更新 d i s t [ y ] dist[y] dist[y]。同时,若 y y y不在队列中,则把 y y y入队。
3.重复上述步骤,直到队列为空。
在任意时刻,该算法的队列都保存了待扩展的节点。每次入队相当于完成一次
d
i
s
t
dist
dist数组的更新操作,使其满足三角不等式。一个节点可能会入队、出队多次。最终,图中节点收敛到全部满足三角不等式的状态。这个队列避免了Bellman-Ford
算法中对不需要扩展的节点的冗余扫描,在随机图上运行效率为
O
(
k
m
)
O(km)
O(km)级别,其中
k
k
k是一个较小的常数。但在特殊构造的图上,该算法可能退化为
O
(
n
m
)
O(nm)
O(nm),必须谨慎使用。
const int N=100010,M=100010;
int head[N],ver[M],edge[M],Next[M],d[N];
bool v[N];
int n,m,tot;
queue<int> q;
void add(int x,int y,int z)
{
ver[++tot]=y,edge[tot]=z,Next[tot]=head[x],head[x]=tot;
}
void spfa()
{
memset(d,0x3f,sizeof(d));
memset(v,0,sizeof(v)); //是否在队列中
d[1]=0;v[1]=1;
q.push(1);
while(!q.empty())
{
int x=q.front();
q.pop();
v[x]=0;
for(int i=head[x];i;i=Next[i])
{
int y=ver[i],z=edge[i];
if(d[y]>d[x]+z)
{
d[y]=d[x]+z;
if(!v[y]) q.push(y),v[y]=1;
}
}
}
}
int main()
{
cin>>n>>m;
for(int i=1;i<=m;++i)
{
int x,y,z;
scanf("%d%d%d",&x,&y,&z);
add(x,y,z);
}
spfa();
for(int i=1;i<=n;++i)
printf("%d\n",d[i]);
}
值得一提的是,即使图中存在长度为负数的边,Bellman-Ford
和SPFA
算法也能够正常工作。
另外,如果图中不存在长度为负数的边,那么类似于优先队列BFS
,我们也可以使用二叉堆(C++ STL priority_queue
)对SPFA
算法进行优化,堆代替了一般的队列,用于保存待扩展的节点,每次取出“当前距离最小的”节点(堆顶)进行扩展,节点第一次从堆中取出时,就得到了该点的最短路。这与堆优化Dijkstra
算法的流程完全一致。“二叉堆优化基于贪心的Dijkstra
算法”和“优先队列优化基于BFS
的SPFA
算法”两种思想殊途同归,都得到了非负权图上
O
(
m
l
o
g
n
)
O(mlogn)
O(mlogn)的单源最短路径算法。
2.任意两点间最短路径
为了求出图中任意两点间的最短路径,当然可以把每个点作为起点,求解
N
N
N次单源最短路径问题。不过,在任意两点间最短路问题中,图一般比较稠密。使用Floyd
算法可以在
O
(
N
3
)
O(N^3)
O(N3)时间内完成求解,并且程序实现非常简单。
Floyd
算法
设
D
[
k
,
i
,
j
]
D[k,i,j]
D[k,i,j]表示“经过若干个编号不超过
k
k
k的节点”从
i
i
i到
j
j
j的最短路长度。该问题可划分为两个子问题,经过编号不超过
k
−
1
k-1
k−1的节点从
i
i
i到
j
j
j,或者从
i
i
i先到
k
k
k再到
j
j
j。于是:
D
[
k
,
i
,
j
]
=
min
(
D
[
k
−
1
,
i
,
j
]
,
D
[
k
−
1
,
i
,
k
]
+
D
[
k
−
1
,
k
,
j
]
)
D[k,i,j]=\min(D[k-1,i,j],D[k-1,i,k]+D[k-1,k,j])
D[k,i,j]=min(D[k−1,i,j],D[k−1,i,k]+D[k−1,k,j])
初值为
D
[
0
,
i
,
j
]
=
A
[
i
,
j
]
D[0,i,j]=A[i,j]
D[0,i,j]=A[i,j],其中
A
A
A为本节开头定义的邻接矩阵。
可以看到,Floyd
算法的本质是动态规划。
k
k
k是阶段,所以必须置于最外层循环中。
i
i
i和
j
j
j是附加状态,所以应该置于内层循环中。这也解释了为何很多初学者按照
i
,
j
,
k
i,j,k
i,j,k的顺序执行循环,会得到错误的结果。
与背包问题的状态转移方程类似,
k
k
k这一维可被省略。最初我们可以直接用
D
D
D保存邻接矩阵,然后执行动态规划的过程中。当最外层循环到
k
k
k时,内层有状态转移:
D
[
i
,
j
]
=
min
(
D
[
i
,
j
]
,
D
[
i
,
k
]
+
D
[
k
,
j
]
)
D[i,j]=\min(D[i,j],D[i,k]+D[k,j])
D[i,j]=min(D[i,j],D[i,k]+D[k,j])
最终,
D
[
i
,
j
]
D[i,j]
D[i,j]就保存了
i
i
i到
j
j
j的最短路长度。
int d[310][310],n,m;
int main()
{
cin>>n>>m;
memset(d,0x3f,sizeof(d));
for(int i=1;i<=n;++i) d[i][i]=0;
for(int i=1;i<=m;++i)
{
int x,y,z;
scanf("%d%d%d",&x,&y,&z);
d[x][y]=min(d[x][y],z);
}
for(int k=1;k<=n;++k)
for(int i=1;i<=n;++i)
for(int j=1;j<=n;++j)
d[i][j]=min(d[i][j],d[i][k]+d[k][j]);
for(int i=1;i<=n;++i)
{
for(int j=1;j<=n;++j)
printf("%d ",d[i][j]);
put("");
}
}
传递闭包
在交际网络中,给定若干个元素和若干对二元关系,且关系具有传递性( i , j i,j i,j有二元关系换成点相当于两者可相互抵达)。“通过传递性推导出尽量多的元素之间的关系”的问题被称为传递闭包。
建立邻接矩阵
d
d
d,其中
d
[
i
,
j
]
=
1
d[i,j]=1
d[i,j]=1表示
i
i
i和
j
j
j有关系,
d
[
i
,
j
]
=
0
d[i,j]=0
d[i,j]=0表示
i
i
i与
j
j
j没有关系。特别地,
d
[
i
,
i
]
d[i,i]
d[i,i]始终为1。使用Floyd
算法可以解决传递闭包问题(相当于看从
i
,
j
i,j
i,j两者之间是否能相互抵达):
bool d[310][310];
int n,m;
int main()
{
cin>>n>>m;
for(int i=1;i<=n;++i) d[i][i]=1;
for(int i=1;i<=m;++i)
{
int x,y;
scanf("%d%d",&x,&y);
d[x][y]=d[y][x]=1;
}
for(int k=1;k<=n;++k)
for(int i=1;i<=n;++i)
for(int j=1;j<=n;++j)
d[i][j]|=d[i][k]&d[k][j];
}