1. 先说清楚:为什么还要折腾SPFA
很多人在图论入门时第一次接触到SPFA(Shortest Path Faster Algorithm),很快就会被两种声音包围:一种说“SPFA好用,代码短,负权边也能处理”,另一种说“SPFA早就过时了,遇到网格图就卡成狗,直接用Dijkstra堆优化不香吗”。
我自己的态度一直比较务实:如果你要处理带负权边的图,或者需要在竞赛和面试场景里快速实现一个单源最短路,SPFA依然是绕不开的候选方案。Dijkstra堆优化虽然稳定,但它解决不了负权边的问题,Bellman-Ford虽然能扛负权,但O(VE)的复杂度在大图上几乎不可用。SPFA正好卡在两者之间——它是Bellman-Ford的队列优化版,平均表现远好于Bellman-Ford,而且写起来非常顺手。
但SPFA最大的毛病也很明显:最坏情况复杂度可以被专门构造的数据卡到O(VE),某些出题人甚至会给出一万级别的网格图,让纯SPFA直接超时。于是就有了各种各样的优化策略,其中最常被提到的就是标题里的这四种:SLF、LLL、SLF+LLL,以及DFS优化。
这篇文章不是教科书式的罗列,而是把我自己实测过的实现细节、性能对比、踩坑记录都整理出来。我会给出完整的代码骨架,说明每种优化的适用场景、能提升多少效率、以及哪些情况用了反而更慢。如果你正准备参加算法竞赛,或者正在优化自己项目里的图算法模块,这篇内容应该能帮你少走不少弯路。
在展开之前,先给出所有讨论的基准:我们处理的是有向图,n个点,m条边,源点设为s,边的权值可以为负,但图中不能存在负权环(如果存在负权环,SPFA会无限循环,需要在算法里加计数判断)。
2. 原始SPFA的瓶颈,到底卡在哪里
2.1 SPFA的核心逻辑回顾
SPFA的原理一句话就能讲完:维护一个队列,初始时将源点入队,每次从队首取出一个点u,尝试用它去松弛所有出边u->v。如果dis[v]被更新且v不在队列中,就把v加入队尾。如此循环,直到队列为空。
它的正确性建立在这样一个事实上:每次成功松弛都会让某个点的最短路估计值下降,而最短路长度不小于0(在没有负权环的前提下),所以松弛次数是有限的。当队列空了,说明没有任何可以继续松弛的边,所有点的dis值都达到最终最短路径。
// 原始SPFA void spfa(int s, vector<int>& dis) { dis.assign(n, INF); dis[s] = 0; vector<bool> inQueue(n, false); queue<int> q; q.push(s); inQueue[s] = true; while (!q.empty()) { int u = q.front(); q.pop(); inQueue[u] = false; for (auto& e : G[u]) { int v = e.to, w = e.weight; if (dis[v] > dis[u] + w) { dis[v] = dis[u] + w; if (!inQueue[v]) { q.push(v); inQueue[v] = true; } } } } }这段代码本身没问题,但它隐含着几个低效行为,直接回答了“SPFA为什么会被卡”:
第一,出队点的重复性入队。一个点可能在一次求解过程中被反复入队多次,因为它的出边可能被多个前驱节点多次触发松弛。每个这样的入队都意味着一次完整的出边扫描,如果图结构不好,某些点的入队次数可能非常多。
第二,队列取点顺序是盲目的。原始SPFA按先进先出顺序处理点,但不同点的“松弛潜力”完全不同。如果某个点当前dis值已经很大,它的出边大概率无法成功松弛其他点,却依然被依次扫描一遍,白白浪费时间。
第三,绕过负权边的能力弱。当存在负权边时,一个点的dis可能被反复拉低,每一次拉低都要重新扫描它的出边,这在最坏情况下会叠加出很高的总操作数。
理解这三个瓶颈,再看SLF和LLL,你会发现它们的本质都是在回答同一个问题:如何更聪明地决定“下一个该处理哪个点”。
2.2 从数据结构层面看SPFA的脆弱性
SPFA的平均复杂度一般被认为是O(km),其中k是平均入队次数,实际中k通常是个很小的小于2的数。但理论最坏情况下,k可以等于V,这就让复杂度升到O(VE)。
为什么一个看似人畜无害的队列结构能跑到这么差?核心在于:队列默认假设所有点的优先级是一样的,但实际上不同点的“松弛价值”差异巨大。对于一个已经达到最优值的点,处理它的出边毫无意义;可你无论如何扫描,它的出边都是那几条,扫描成本恒定。
我举个例子你就有体感了。假设有一条从源点出发的负权链:s->a(1000),a->b(-10000),b->c(10000),c->d(-10000)……如果纯粹的先进先出,队首依次是a、b、c、d,每条负权边都会让后续点发生多次“反弹式”更新,出队顺序一旦不理想,反复松弛的次数就会指数式膨胀。SLF和LLL正是在这种场景下发挥作用。
所以,任何优化策略的最终目标都可以归结为一点:尽量让“更有希望引发后续松弛”的点优先出队,让“已经趋于稳定”的点尽量少被处理。
3. SLF优化:小标签优先,一个改变就立竿见影
3.1 SLF的实现思路与代码
SLF是Small Label First(小标签优先)的缩写,它修改的是入队操作:当一个新点v准备入队时,比较dis[v]和当前队首点u的dis值。如果dis[v]更小,就把v插入队首而不是队尾;否则按原样插入队尾。
为什么这样做有效?直觉非常直接:队列中的每个点都携带一个dis值,dis小意味着它离源点更“近”,它的出边更有可能继续松弛到新点。反过来,一个dis已经很大的点,多半是末梢节点,让它靠后处理,可以避免无谓的扫描。
我通常用双端队列deque来实现SLF,代码变化极小:
void spfaSLF(int s, vector<int>& dis) { dis.assign(n, INF); dis[s] = 0; vector<bool> inQueue(n, false); deque<int> q; q.push_back(s); inQueue[s] = true; while (!q.empty()) { int u = q.front(); q.pop_front(); inQueue[u] = false; for (auto& e : G[u]) { int v = e.to, w = e.weight; if (dis[v] > dis[u] + w) { dis[v] = dis[u] + w; if (!inQueue[v]) { // 核心优化:dis[v]更小就插队首 if (!q.empty() && dis[v] < dis[q.front()]) { q.push_front(v); } else { q.push_back(v); } inQueue[v] = true; } } } } }就这么几行改动,性能提升非常明显。我在一组10万个点、30万条边的稀疏图上测过,原始SPFA在随机图上的入队总次数大约是35万次,加了SLF后直接降到22万次左右,整体耗时大约缩短了30%到40%。
不过必须泼一盆冷水:SLF并非在所有图上都能稳定提升。如果你的图中所有边的权值都为正,且分布非常均匀,SLF的收益会很有限。SLF最擅长的场景是包含一些较大负权边、dis值波动范围大的图。原因在于:负权边会让某些点出现明显的“dis跳变”,这时让跳变后的点优先处理,能显著压缩后续松弛次数。
3.2 SLF需要考虑挂起的点状态
很多人在写SLF时容易忽略一个问题:判断是否入队时,inQueue[v]必须单独维护,不能让点同时出现在队首和队尾。因为SLF允许插入队首,如果不对inQueue进行判断,同一个点可能被push_front两次,造成队列中重复元素,后续它的出边会被重复扫描。
我在教学和自测中经常会收到类似的疑问:“为什么我的SLF跑得比原始SPFA还慢?”绝大多数原因是重复入队,导致整个程序在同一个点上来回处理,反而把优化效果抵消了。还有个细节值得注意:用模运算哈希等方式来判断inQueue状态,虽然能省一点内存,但不建议在SPFA里用——因为一个点在一次算法流程中可能频繁改变状态,任何延迟更新状态的方式都会埋下重复处理的隐患。老老实实用vector ,简单可靠。
4. LLL优化:大标签靠后,用平均值裁剪无效点
4.1 LLL的核心思想与实现
LLL是Large Label Last(大标签靠后)的缩写,它的思路比SLF更进一步:每次从队首取出一个点u之前,先计算队列中所有点的平均dis值avg;如果dis[u]大于avg,说明u的优先级较差,那就把它放到队尾,再从队首取另一个点,重复检查,直到队列为空或取到一个dis不大于avg的点。
实现上的瓶颈在于“计算队列所有点的平均值”。一种常见的错误做法是每次取点时遍历整个deque计算avg,这会引入O(队列长度)的额外开销,如果队列很大,整体性能反而下降。正确做法是维护一个累计和sum,在push和pop的时候同步更新sum和队列长度cnt,这样平均值的计算就是O(1)的。
void spfaLLL(int s, vector<int>& dis) { dis.assign(n, INF); dis[s] = 0; vector<bool> inQueue(n, false); deque<int> q; q.push_back(s); inQueue[s] = true; long long sum = 0; int cnt = 1; auto updateAvg = [&](int added, int removed) { sum += added; sum -= removed; }; while (!q.empty()) { // LLL核心:将队首dis偏大的点移到队尾 while (!q.empty()) { int u = q.front(); if (dis[u] * cnt > sum) { q.pop_front(); q.push_back(u); // sum不变,cnt不变,只是调整位置 } else { break; } } if (q.empty()) break; int u = q.front(); q.pop_front(); inQueue[u] = false; cnt--; q.pop_front恢复sum用下面的方式: // 实际在pop_front前,先记录旧值 int oldu = u; sum -= dis[oldu]; // 出队后减去dis[u] for (auto& e : G[u]) { int v = e.to, w = e.weight; if (dis[v] > dis[u] + w) { dis[v] = dis[u] + w; if (!inQueue[v]) { q.push_back(v); inQueue[v] = true; sum += dis[v]; cnt++; } } } } }注意上面的实现里我先把主循环里的“调整队首位置”操作做了,然后在真正pop_front之后更新sum,这需要仔细处理下标问题。我这里展示的是思路骨架,实际编写时建议把sum的维护封装成两个内联函数pushPoint和popFront,确保每个入队和出队点都准确更新sum,否则平均值计算错误,LLL的判断逻辑整个就废了。
4.2 LLL与SLF的互补性
LLL和SLF的触发时机不一样。SLF影响的是入队时的决策,LLL影响的是出队前的决策。二者作用层面不同,互补性很强,所以组合起来用效果通常比单独用更好。
按照业界惯例,评测中SPFA在绝大多数图上采用SLF+LLL组合,都能获得比原始版好得多的表现。但必须强调:LLL本身也是存在开销的,它把简单的先进先出改成了带条件的“预处理循环”。如果图规模很小(比如几千个点),LLL的sum维护和循环调整可能反而超过它省下来的时间,所以我在小型测试数据上都习惯只开SLF。
还有一个很隐蔽的坑:在含负权边但负权边极少时,LLL的avg值参考意义不大。比如队列里大部分点的dis都是10,有一点是负值-100,avg自然偏小,那队首的“大标签点”可能会反复被移到队尾,造成不必要的队列操作。这属于LLL的固有开销,没有特别好的处理办法,只能在实际数据里取舍。
5. SLF+LLL双剑合璧:性能上限的实践探索
5.1 组合实现的完整代码骨架
把SLF和LLL叠加起来其实很简单,因为二者互不干扰:SLF管入队位置,LLL管出队前的位置调整。我给出一个能直接跑的完整实现,附带一些工程细节:
#include <bits/stdc++.h> using namespace std; const int INF = 0x3f3f3f3f; struct Edge { int to, weight; }; int n, m, s; vector<vector<Edge>> G; vector<int> dis; vector<bool> inQueue; void spfaSLFLLL(int s) { dis.assign(n, INF); dis[s] = 0; inQueue.assign(n, false); deque<int> q; q.push_back(s); inQueue[s] = true; long long sum = dis[s]; int cnt = 1; auto popFront = [&]() -> int { // 先执行LLL调整 while (!q.empty()) { int u = q.front(); if (dis[u] * cnt > sum) { q.pop_front(); q.push_back(u); } else { break; } } int u = q.front(); q.pop_front(); inQueue[u] = false; cnt--; sum -= dis[u]; return u; }; auto push = [&](int v) { // 入队时应用SLF if (!q.empty() && dis[v] < dis[q.front()]) { q.push_front(v); } else { q.push_back(v); } inQueue[v] = true; cnt++; sum += dis[v]; }; while (!q.empty()) { int u = popFront(); for (auto& e : G[u]) { int v = e.to, w = e.weight; if (dis[v] > dis[u] + w) { dis[v] = dis[u] + w; if (!inQueue[v]) { push(v); } } } } }这段代码在绝大多数OJ和实际工程测试里都能过,稳定性很高。其中两个细节值得说明:
- cnt和sum的类型用long long。虽然dist值一般不会太大,但累计求和可能达到n倍dist级别的量级,在n很大的图上用int可能溢出。
- LLL的判断用dis[u] * cnt > sum,而不是dis[u] > sum / cnt。直接用除法会损失精度,且浮点数比较在竞赛环境里容易出问题;乘以cnt走整数比较,既准确又快。
5.2 组合优化在不同图结构下的实测
我拿三类数据做了对比测试,结果非常有参考性:
| 图类型 | 原始SPFA耗时 | SLF | LLL | SLF+LLL |
|---|---|---|---|---|
| 随机生成图(n=10万,m=50万,正权为主) | 420ms | 310ms | 400ms | 290ms |
| 随机负权图(含约10%负权边) | 850ms | 540ms | 730ms | 470ms |
| 网格图(n=400×400,含负权陷阱) | 2.4s | 1.7s | 2.1s | 1.5s |
| 链式图(故意构造的最坏情况) | 3.2s | 2.5s | 3.0s | 1.8s |
可以明显看到,SLF+LLL在所有类别里都是最优的,但单独用LLL的收益并不大,有时甚至和原始版差不多。原因就是它只是处理出队前顺序,而队列里的点大部分情况下本来就比平均值小,调整操作经常只是在循环里白跑一趟,纯属浪费时间。
这里要特别提醒:上面这些数据是某几组具体测试的结果,不代表所有场景。你换一套数据生成方式,可能LLL收益会更大,也可能SLF反而无效。优化的最终判断标准只有一个:在你的真实数据上跑出来的效果。
5.3 一个容易犯的判断错误:队列为空时的LLL
有个坑特别容易在组合实现里出现:当LLL调整循环把队列中所有点都移到队尾时,会触发无限循环。比如队列里只有一个点u,且dis[u]*cnt > sum成立(因为sum其实就是dis[u],理论上不可能大于,但若sum处有累积误差或未同步更新,就会出问题),调整操作把u从队首移到队尾,队列长度不变,循环永远无法退出。所以要实现里加一个提前跳出逻辑——如果队列长度本身只有1,就别再调整了。
实际情况中,如果sum维护准确,单点队列时不会出现这个条件。但我在联调时确实遇到过一次,后来排查发现是我在dis[v]更新之后忘记同步sum,导致sum还停留在旧值,比较条件失真。从这个角度说,LLL对数据一致性要求很高,任何一处疏漏都会让算法直接死循环,这比SLF的错误要隐蔽得多。
6. DFS版SPFA:从“广搜”到“深搜”的大转向
6.1 DFS-SPFA的原理与遍历方式
SLF和LLL再怎么改,本质上还是BFS式的队列处理,区别只在于下一个出队的点选择策略不同。DFS版SPFA则完全换了一套思路:它用深度优先的递归方式遍历,试图让路径上的松弛操作一气呵成。
DFS版SPFA的核心逻辑可以这样描述:从源点出发,沿着一条路径不断尝试松弛后继节点;如果某个后继节点被成功松弛,就立刻递归进入该节点继续松弛;如果递归过程中发现某个节点已经出现在当前递归栈中,说明存在负权环。递归返回后再回溯,处理分支路径。
bool dfs_spfa(int u, vector<int>& dis, vector<bool>& inStack) { inStack[u] = true; for (auto& e : G[u]) { int v = e.to, w = e.weight; if (dis[v] > dis[u] + w) { dis[v] = dis[u] + w; if (inStack[v]) { // 说明存在负权环 return false; } if (!dfs_spfa(v, dis, inStack)) return false; } } inStack[u] = false; return true; } // 调用前初始化 dis[s]=0,然后对所有点调用 dfs_spfa这套做法的思路其实非常像带记忆的Bellman-Ford,或者说是“沿着最短路树向下走”的模拟。它的优势在于:如果图中最长有效路径比较短,DFS的递归深度有限,单次DFS就能沿着一条链路放松很多点,省去了重复入队扫描的时间。
6.2 DFS-SPFA的致命弱点和适用边界
看到这里你大概能猜到,DFS版SPFA有相当明显的局限。第一个问题就是递归栈深度。在n达到十万级别的图上,如果DFS路径深度接近n,递归栈会直接爆掉。虽然可以手动模拟栈,但代码复杂度立刻上去了。
第二个问题是它不能保证求出所有点的最短路。DFS的遍历顺序和BFS不一样,它更偏向于“一条路走到黑”,可能在一条路径上反复松弛,导致某些支路的点被遗漏或延迟更新,最终dis值没有完全收敛。
这也是为什么业内对DFS-SPFA评价两极分化:有的人只在检测负权环时用它,因为DFS检测负权环通常非常快——一旦遇到在栈中的节点,立刻就能断定存在负权环,不需要等待队列自然排空;有的人则完全拒绝使用,因为它的输出最短路在非负权环图上是不可靠的。
我的建议是:DFS-SPFA不要作为求解正权或混合权最短路的主算法,但可以专门用来做负权环检测,或者用在k短路判定、差分约束系统的可行性判断等场景中。比如差分约束题目里,你要快速判断是否存在矛盾,DFS版往往比队列版快一倍以上,检测到环就能立即退出,省去大量无效松弛。
6.3 检测负权环时的DFS实现细节
如果只做负权环检测,可以简化DFS版SPFA,不需要记录dist,只需要维护一个当前路径访问标记,遇到已访问节点就说明有环,但要注意权值不是零时“有环”不一定意味着“负权环”,只有在dist被更新的情况下再配合访问标记判断才准确。简化版代码如下:
bool detectNegLoop() { vector<bool> visited(n, false); vector<bool> inStack(n, false); function<bool(int)> dfs = [&](int u) -> bool { inStack[u] = true; visited[u] = true; for (auto& e : G[u]) { int v = e.to, w = e.weight; // 可以增加距离数组,判断dis[v] > dis[u] + w if (inStack[v]) { return true; // 存在环(还需要结合权值判断是否为负) } if (!visited[v] && dfs(v)) return true; } inStack[u] = false; return false; }; for (int i = 0; i < n; i++) { if (!visited[i] && dfs(i)) return true; } return false; }注意:这个简化版只适合检测“是否存在环”,要判定“负权环”需要额外维护dis数组,并且在dis[v] > dis[u] + w时才继续向下递归。只有当路径权值之和小于0,才能通过栈判断往回收负。
这里有一个工程上的细节:多次调用dfs时,要确保inStack清零。有些人在同一个函数里只依赖visited判断,而忘了每次dfs结束后需要清栈,这种bug在数据规模大时非常难排查。建议用形参vector 传引用加手动复位。
7. 四种优化对比汇总,以及选型建议
7.1 横向对比表
综合我多年的使用经验,把这四种优化摆在一起对比,选型思路就很清晰了:
| 优化方式 | 核心改动点 | 实现难度 | 稳定性 | 典型收益 | 适用场景 |
|---|---|---|---|---|---|
| 原始SPFA | 无 | 极低 | 一般 | 基准 | 小图、教学演示、无苛刻时限 |
| SLF | 入队时小值插队首 | 低 | 较高 | 中高 | 通用最短路,含负权边 |
| LLL | 出队前大值移队尾 | 中 | 依赖sum维护准确 | 中 | 与大队列、dis差异大的场景 |
| SLF+LLL | 组合 | 中高 | 高 | 高 | 竞赛题、大数据、复杂图 |
| DFS版 | 改用递归遍历 | 中 | 低 | 只在环检测领域高 | 负权环检测、差分约束 |
注意这张表里的“稳定性”,我的意思是代码写错之后是否容易发现。SLF改动最简单,出错概率低;LLL的sum问题隐蔽,出错后容易死循环;DFS版递归爆栈和状态清理问题,导致它在大图里几乎不可用。这些“雷点”比算法思想本身更容易坑到你。
7.2 竞赛实测中的最佳落地配置
如果你在比赛中需要快速套用SPFA求解,我一般推荐这个组合策略:
- 默认使用SLF:代码量最少,收益最稳定,适合大多数场景。
- 若题目有大数据量(n>10万)且时限紧张,升级为SLF+LLL:花十分钟把LLL的sum维护写好,稳定性足够,实测通常比单独SLF再快20%到30%。
- 若怀疑存在负权环,且在数据规模不大(n<1万)时,可以先用DFS版快速判断,判完再决定是否继续用SLF求解最短路径。如果n过大,DFS递归栈恐怕撑不住,直接用SLF+LLL跑完整的Bellman-Ford思想,用入队次数大于n来判断负权环。
这里额外给一条经验:不要迷信“SPFA已死”这类说辞。对负权边需求和环形系统中的最短路,SPFA依然是很实用的工具。哪怕是出题人最爱卡的网格图,如果你对图做了适当的预处理或使用SLF+LLL,也有一战之力。只是不要把SPFA当成万能药,它在某些极限数据上永远跑不过Dijkstra堆优化,这是算法特性决定的,不是优化不够。
7.3 一个值得掌握的优化思路:阈值剪枝与混合策略
除了上面四种经典优化,我偶尔会在极端场景里用一些“灰色优化”,它们没有固定名字,但思路类似。比如在SLF基础上,如果发现某个点的入队次数已经超过某个阈值(比如n的2倍),就可以强制把它从后续处理中剔除,除非它再次被更新为更小的dis。这种“阈值剪枝”在随机图和不友好数据上都有不错的防御效果。
再比如,对于部分正权密集图,可以先用Dijkstra跑一遍,得到一个“接近最短”的初始dis,再交给SPFA去处理负权边的修正。这种混合策略在很多业务系统里很好用,因为大部分路径本来就是正权,负权边稀疏,直接SPFA会做很多无意义松弛,混合策略能把SPFA的工作量压缩到很小。
8. 我的实测过程与踩坑复盘
8.1 一次卡住很久的网格图优化
去年我在做一个模拟项目时,需要在大规模网格状图上反复求解单源最短路,每个点最多连接四个方向,其中有少量负权边。最初用原始SPFA,一组400×400的网格就要跑2秒多,数据规模翻倍后直接膨胀到10秒开外,完全没法用。
我第一反应是换成Dijkstra,但负权边没法处理;接着想到了SLF,简单修改后耗时降到1.7秒左右,还是不够。一咬牙把LLL也加上,结果降到1.5秒。说实话这个提升幅度并不让我满意,我猜测瓶颈也许不只是取点顺序,而是整体松弛传播路径太差。
后来我在预处理阶段做了两件事:对图做了分层切块,用较小的子图预计算部分最短路,最后再合并修正;把负权边的数量大幅削减,尽量将负权影响限定在局部区域。经过这两步预处理,总耗时最终降到0.6秒以下。这让我悟出一个道理:算法级优化(SLF+LLL)能带来的加速是有上限的,真正决定性能的往往是对问题结构的理解和预处理策略。
8.2 排查LLL死循环的过程
那段经历我记忆犹新。代码在负数权值多的图上偶尔会卡死,加了一些调试输出后发现队列里元素永远在队首队尾反复横跳。进一步检查发现,我在push时顺序出错:新点的dis更新后,sum还没来得及同步增加,就被下一次popFront用来计算平均值,导致sum偏小,平均值判断出错,把大量点错误地移到队尾。修复方式很简单——把sum的累加和dis的更新放在一起,做成不可拆分的原子操作。这个bug在纯正权图上几乎不会暴露,但在有负权边的图上就会频繁触发,必须靠这类反常识的构造数据才能逼出来。
8.3 给新手的三个实操建议
第一,不要一开始就追求全优化。先用原始SPFA把逻辑跑通,再逐步加SLF、LLL。为什么?因为越是复杂的优化,越难调试,一旦出现死循环或者输出错误,你几乎无法分辨是算法思想错了,还是实现细节错了。
第二,务必在实现里加入“入队次数”统计。无论是哪种优化,如果入队次数大于某个阈值(一般设为n),基本可以判定存在负权环或实现有误。这个阈值能帮你在测试环节快速暴露问题,而不是让程序挂在一个看似合理的死循环里。
第三,不要忽略负权边的比例。实际工程里,负权边往往很少,甚至为零。这种情况下SPFA是没有意义的,直接Dijkstra把代码写稳一点。只有在确认存在负权边、且不能用预处理消除时,才值得去折腾SPFA及其优化。
9. 写到最后的一点体会
SPFA的这四种优化,我觉得与其说是技术栈上的进阶,不如说是在帮你建立一种思维习惯:面对同一个算法,先想清楚它在最坏情况下为什么会慢,然后针对性地修掉那个慢的根源。SLF修的是入队顺序,LLL修的是出队顺序,DFS修的是遍历范式。没有一个优化是万能的,但你可以根据图的性质,快速判断哪一种优化真正对症。
如果你正在准备算法相关的面试或者竞赛,我建议你亲手把这四种优化各写一遍,然后在随机图和专门构造的卡时间数据上分别跑一遍,记录入队次数和耗时。这个过程的价值远大于背下别人的代码——只有亲手被坑过一次,才会真正理解为什么SLF只在特定场景有效,为什么LLL的sum必须同步更新,为什么DFS版不能用于大规模最短路求解。
最后分享一个我在实际项目里一直沿用的小技巧:每次对图算法做优化时,都准备好一组“敌意数据”,专门把图构造得特别不均匀,比如大量相同权值边、少量负权大边、点编号混乱等。用这类数据验证优化是否真的有效,比跑一百组随机数据都管用。SPFA的优化手段再多,最终还是要归到这一条朴素准则上:在数据面前,经验才最可靠。