大意失荆州。今天考试一到可以用Dijkstra水过的题目我竟然没有做出来,这说明基础还是相当重要。考虑到我连Tarjan算法都不太记得了,我决定再过一遍蓝皮书,对图论做一个小的总结。图论这个部分可能会分几次发布出来。
0 图的储存
一般而言,在代码中我们会用(M)表示边的个数,(N)表示点的个数。
- 邻接矩阵:用一个矩阵(A_{ij})表示点(i,j)的距离或连通性。
- 邻接表:用
vector
类型edge
储存。其中edge[i][j]
表示以(i)为起点的第(j)条边。 - 链式前向星:非常重要的一种储存方式。是一种类似“哈希表”的结构,其中用
head
记录每个点对应的边链表的表头,每个边记录边链表的下一条边。
无向边可以用两条方向相反的有向边代替。
1 最短路
1.1 单源最短路问题(SSSP,Single Source Shortest Path)
对于任意一个点(i),求其到某一个固定点的距离(dis_i)。
在求解最短路问题中,有一个核心的“松弛操作relax”:
它是所有SSSP算法的基础。
如果有(d(i,j)>d(i,k)+d(k,j)),我们就说(k)能松弛((i,j))。
Dijkstra算法
贪心算法。其使用条件是没有负边。
这里采用了一个贪心的策略:注意到全局最优的SP是不可能再被松弛的,否则就跟这个性质不符了。我们每次就用这个最优的SP去松弛其他的SP,从而使得每一个SP都逐步变得最优,即不可松弛。
这个算法的代码如下:
inline void dijkstra()
{
memset(dis, 0x3f, sizeof(dis));
memset(used, 0, sizeof(used));
dis[S] = 0;
while(1)
{
int transferNode = getTransferNode();
if(transferNode == -1)
return;
for(rg int e = head[transferNode]; e; e = edge[e].next)
{
int to = edge[e].to;
checkMin(dis[to], dis[transferNode] + edge[e].len);//用最小值更新dis[to]
}
used[transferNode] = true;
}
}
其中getTransferNode()
是找到dis
最小的中转点。
inline int getTransferNode()
{
int transferNode = -1;
number disMin = Inf;//number 是typedef过的long long
for(rg int i = 1; i <= N; ++ i)
{
if(!used[i] && dis[i] < disMin)
{
disMin = dis[i];
transferNode = i;
}
}
return transferNode;
}
由于要枚举中转点和每个与中转点相连的点,上面代码的时间复杂度是(O(N^2))的。
当然,我们也可以用二叉堆(priority_queue)来维护(dis)数组。这样可以在(O(Nlog N))的时间内完成算法。上述getTransferNode
部分代码如下:
priority_queue< pair<number, int> > heap;
inline int getTransferNode()
{
int transferNode = -1;
while(heap.size() > 0)
{
transferNode = heap.top().second;
heap.pop();
if(used[transferNode])
continue;
used[transferNode] = true;
return transferNode;
}
return -1;
}
主过程代码如下:
inline void dijkstra()
{
memset(dis, 0x3f, sizeof(dis));
memset(used, 0, sizeof(used));
dis[S] = 0;
heap.push(make_pair(0, S));
while(1)
{
int transferNode = getTransferNode();
if(transferNode == -1)
return;
for(rg int e = head[transferNode]; e; e = edge[e].next)
{
int to = edge[e].to;
if(dis[to] > dis[transferNode] + edge[e].len)
{
dis[to] = dis[transferNode] + edge[e].len;
heap.push(make_pair(-dis[to], to));
}
}
used[transferNode] = true;
}
}
Bellman-Ford和SPFA
由于负边权会导致(dis_i>dis_j+d(j,i)),最小的(dis)反而可能会更新出一个比它更小的(dis)。这样,一个点可能会不断被更新,这个全局最优的贪心策略就不成立了。
在之前的Dijkstra算法上,我们再考虑如何进一步优化。
如果对于任意的边((i,j)),均有(dis_ileq dis_j+d(i,j)),那么这个(dis)就是所求的答案。这个不等式叫做三角形不等式。因此,我们可以考虑这样一种做法:我们由“点松弛”变为“边松弛”,使得每条边满足三角型不等式。
Bellman-Ford的算法流程大致就是:不断地扫描并更新每一条边,使得每一条边都满足三角形不等式,直到没有边可以再更新。
由于Bellman-Ford算法是固定的(O(NM)),在实际运行上速度有时不如Dijkstra,这里就不张贴这个代码了。我们考虑队列优化版的Bellman-Ford算法:SPFA(Shortest Path Fast Algorithm)算法。这个算法的命名其实和“快速排序”这个名字一样一根筋。正因如此,SPFA这个名字只在中国流行。
仔细想想,根据Dijkstra算法的推论,是不是只有全局最优值才能更新其他值?我们可以类似地,每次只对刚更新完的(dis)点所相连的边进行松弛。我们可以考虑建立一个队列,当一个节点被更新之后,就将其加入队列,并在之后考虑它所相连的边是否能被更新。
由于每一次入队和出队都代表一次边的更新,而一个节点可能入队出队若干次,故算法的时间复杂度下限为(Omega(M)),上限为(O(MN))。
算法的代码如下:
queue<int> q;
bool state[maxN];
#define IN_QUEUE true
#define NOT_IN_QUEUE false
inline void SPFA()
{
memset(dis, 0x3f, sizeof(dis));
memset(state, NOT_IN_QUEUE, sizeof(state));
dis[S] = 0; state[S] = IN_QUEUE; q.push(S);
while(!q.empty())
{
int cur = q.front(); q.pop();
state[cur] = NOT_IN_QUEUE;
for(rg int e = head[cur]; e; e = edge[e].next)
{
int to = edge[e].to;
if(dis[to] > dis[cur] + edge[e].len)
{
dis[to] = dis[cur] + edge[e].len;
if(state[to] == NOT_IN_QUEUE)
{
state[to] = IN_QUEUE;
q.push(to);
}
}
}
}
}
#undef IN_QUEUE
#undef NOT_IN_QUEUE
SPFA算法接近上界的条件是图是疏密图或网格图。如果图中没有负边权,我们可以用优先队列队优化这个算法。此时算法时间复杂度是比较稳定的(O(Mlog N)),但是往往就不如Dijkstra优了。
在实际应用的过程中,要根据实际条件判断如何使用。图中往往有样例数据、数据范围、子任务等要素。可以通过这些来判断图是否稀疏,从而分门别类地使用对应算法。
SPFA算法的一大优势在于可以判断是否有负环。根据(O(NM))的时间复杂度上限可以知道,如果一个点被更新大于等于(n)次,说明这个图一定存在负环。此时存在一条无穷小的路径,SSSP也就没有意义了。
1.2 最短路衍生算法
SPFA的过程也可以用dfs实现。但是显然的,由于SPFA往往需要对若干个点更新多次,如果使用dfs,不仅可能会超时,还有可能因迭代过多而发生栈溢出。但是,dfs的SPFA有一个非常明显的长处:找负环。
和bfs的做法不同,我们将队列改为栈,每次将当前节点入栈,搜索完后将当前节点出栈。只有可以被松弛的节点才有搜索的必要。
代码很简单,如下:
queue<int> q;
bool state[maxN];
#define IN_STACK true
#define NOT_IN_STACK false
inline bool SPFA(int curNode)
{
state[curNode] = IN_STACK;
for(rg int e = head[curNode]; e; e = edge[e].next)
{
int to = edge[e].to;
if(dis[to] > dis[curNode] + edge[e].len)
{
dis[to] = dis[curNode] + edge[e].len;
if(state[to] == IN_STACK || SPFA(to) == true)
return true;
}
}
state[curNode] = NOT_IN_STACK;
return false;
}
再主程序中对每一个节点进行一次SPFA,直到找到负环为止。
对于随机图,我们可以先运行一个SPFA_INIT:首先选定一个初始点,每次扩展到一个节点时,随机地选择一条可松弛的边进行下一步扩展;如果不存在可松弛边,就结束这一过程。
对每个点进行一次SPFA_INIT,然后再用SPFA对每个点进行扩展,这样效率会有所提高。
更近一步的,我们还可以使用一个IDFS_SPFA,即加深迭代搜索。每次选取搜索的深度分别为(1,2,4,8,16,cdots)是一个不错的选择。这些过程都比较简单,就是在原代码上进行简单的改装。代码就不再粘贴。
1.3 多源最短路径算法(MSSP)
Floyd算法
非常经典的算法。其实质是动态规划算法。
通过类比,我们发现松弛操作(d(i,j)=min{d(i,j),d(i,k)+d(k,j)})与区间DP十分类似。这意味着(i)和(j)两个状态变量不适合用来划分阶段。
我们设(F(k,i,j))表示以(1,2,cdots,k)点作为中转点,(i,j)之间的最短距离。我们可以在选前(k-1)个点的基础上,选择第(k)个点,并看其是否更优。有方程:
注意这个方程满足两个性质:
- 转移(F(k,i,j)=F(k-1,i,j))成立。
- 第(k)阶段刚计算出的状态量((i,j))不会被再次引用。
由此,我们可以删去(k)维,得到状态转移方程:
注意这里(k)是阶段,要放在最外层循环。最后可以算出任意两点的最短路。
有(N)个阶段,每个阶段(N^2)个状态,所以转移的时间复杂度为(O(N^3))。
Floyd算法还可以判断点的连通性:只要把状态表示成(F(k,i,j))表示成“以前(k)个点为中转点,能否让(i,j)连通”。转移方程同理进行对应修改即可。这个问题和“传递闭包”有关,“闭包”的概念可以在网络上或者离散数学教材上找到,并不是一个很高深的概念。
Dijkstra算法?
从理论上来讲,可以用(dis_{i,j})表示以(i)为源点,(j)到源点的最短路,然后做(N)次Dijkstra算法。如果采用二叉堆优化,理论上可以用(O(N^2log N))的时间完成处理。但是至于为什么在网络上并没有有关介绍,是常数原因还是其他?我本人也不清楚。如果有哪位大佬知道原因或者做过有关测试,欢迎在下方留言。