1. 问题背景与需求分析
德克萨斯州的奶牛场正面临严峻的热浪挑战,Farmer John需要将威斯康星的牛奶高效运送到德克萨斯。运输网络由T个城镇和C条双向道路组成,每条道路都有特定的通行费用。我们的核心任务是找到从起点城镇Ts到终点城镇Te的最低运输成本路径。
这个问题本质上是一个加权无向图中的单源最短路径问题。考虑到:
- 城镇数量T最多2500个(中等规模图)
- 道路数量C最多6200条(稀疏图)
- 所有道路费用为正数(Ci > 0)
这些特征决定了我们需要选择适合稀疏图且效率较高的最短路径算法。
2. 算法选型与复杂度分析
2.1 候选算法对比
对于最短路径问题,常见的解决方案有:
| 算法 | 时间复杂度 | 适用场景 | 本题适用性 |
|---|---|---|---|
| Floyd-Warshall | O(T³) | 任意两点间最短路径 | 不适用(T=2500时计算量达1.5×10¹⁰) |
| Bellman-Ford | O(T×C) | 含负权边的最短路径 | 可用但效率不高 |
| Dijkstra(朴素) | O(T²) | 正权图单源最短路径 | 可用(T²≈6×10⁶) |
| Dijkstra(堆优化) | O(C log T) | 正权稀疏图单源最短路径 | 最优解(C log T≈6200×8=49600) |
2.2 选择堆优化Dijkstra的理由
- 时间复杂度优势:对于T=2500,C=6200的稀疏图,堆优化版本比朴素版快约120倍
- 空间效率:使用邻接表存储仅需O(T+C)空间,而邻接矩阵需要O(T²)
- 扩展性:相同代码可处理更大规模的图(如T=1e5级别)
注意:虽然本题数据规模下朴素Dijkstra也能通过,但养成使用最优解的习惯对算法竞赛至关重要。
3. 实现细节与关键技术
3.1 图的存储结构
采用链式前向星(邻接表的一种实现)存储稀疏图:
cpp复制int h[2600]; // 每个节点的第一条边索引
int vtex[15000]; // 边指向的节点
int nxt[15000]; // 下一条边索引
int wt[15000]; // 边权值
int idx; // 当前边计数
存储双向边时需要特别注意:
cpp复制void addedge(int u, int v, int w) {
vtex[idx] = v;
wt[idx] = w;
nxt[idx] = h[u];
h[u] = idx++;
}
// 添加双向边
addedge(u, v, w);
addedge(v, u, w); // 反向边
3.2 最小堆的实现技巧
C++的priority_queue默认是大顶堆,三种实现小顶堆的方式:
- 存储负值法(不推荐):
cpp复制priority_queue<int> pq;
pq.push(-dis);
int min_dis = -pq.top();
- 使用greater比较器(推荐简单场景):
cpp复制priority_queue<int, vector<int>, greater<int>> pq;
- 结构体重载运算符(推荐复杂场景):
cpp复制struct Node {
int id, dis;
bool operator<(const Node& rhs) const {
return dis > rhs.dis; // 注意是>号
}
};
priority_queue<Node> pq;
3.3 关键算法流程
Dijkstra算法的核心流程:
- 初始化所有节点距离为INF,起点距离为0
- 将起点加入优先队列
- 循环直到队列为空:
- 取出当前距离最小的节点u
- 标记u为已访问
- 遍历u的所有邻接边u→v:
- 松弛操作:if(dis[v] > dis[u] + w) 更新dis[v]
cpp复制void dijkstra(int s) {
dis[s] = 0;
q.push({s, 0});
while(!q.empty()) {
Node u = q.top(); q.pop();
if(vis[u.id]) continue; // 关键!避免重复处理
vis[u.id] = true;
for(int i = h[u.id]; i != -1; i = nxt[i]) {
int v = vtex[i], w = wt[i];
if(dis[v] > dis[u.id] + w) {
dis[v] = dis[u.id] + w;
q.push({v, dis[v]});
}
}
}
}
4. 易错点与调试技巧
4.1 常见错误类型
-
数组越界:
- 边数组大小应为2*C(双向边)
- 城镇编号从1开始,数组要开T+1
-
初始化问题:
cpp复制memset(h, -1, sizeof(h)); // 邻接表初始化 memset(dis, 0x3f, sizeof(dis)); // 距离初始化为INF -
优先队列误用:
- 忘记重载运算符导致大顶堆
- 错误比较dis和节点ID
4.2 调试方法
- 小数据测试:使用题目样例,手工模拟算法流程
- 打印中间结果:
cpp复制cout << "Processing node " << u.id << " with dis=" << u.dis << endl; - 边界检查:
- 单节点图(T=1)
- 起点等于终点的情况
- 多重边(相同u,v可能有不同w)
5. 算法优化与扩展
5.1 进一步优化方向
- 配对堆优化:某些场景下比二叉堆更快
- A*算法:如果有启发式函数可用
- 双向Dijkstra:起点和终点同时搜索
5.2 相关问题扩展
-
记录路径:增加pre数组记录前驱节点
cpp复制if(dis[v] > dis[u.id] + w) { dis[v] = dis[u.id] + w; pre[v] = u.id; // 记录路径 q.push({v, dis[v]}); } -
次短路问题:维护到每个点的最短和次短距离
-
k短路问题:使用Yen's算法
6. 完整代码实现
cpp复制#include <iostream>
#include <queue>
#include <cstring>
using namespace std;
const int MAXN = 2505;
const int MAXM = 6200 * 2 + 5;
struct Edge {
int to, w, next;
} edges[MAXM];
struct Node {
int id, dis;
bool operator<(const Node& rhs) const {
return dis > rhs.dis;
}
};
int head[MAXN], dis[MAXN];
bool vis[MAXN];
int T, C, Ts, Te;
priority_queue<Node> pq;
void addedge(int u, int v, int w, int& idx) {
edges[idx] = {v, w, head[u]};
head[u] = idx++;
}
void dijkstra(int s) {
memset(dis, 0x3f, sizeof(dis));
dis[s] = 0;
pq.push({s, 0});
while(!pq.empty()) {
Node u = pq.top(); pq.pop();
if(vis[u.id]) continue;
vis[u.id] = true;
for(int i = head[u.id]; i != -1; i = edges[i].next) {
int v = edges[i].to, w = edges[i].w;
if(!vis[v] && dis[v] > dis[u.id] + w) {
dis[v] = dis[u.id] + w;
pq.push({v, dis[v]});
}
}
}
}
int main() {
memset(head, -1, sizeof(head));
cin >> T >> C >> Ts >> Te;
int idx = 0;
for(int i = 0; i < C; ++i) {
int u, v, w;
cin >> u >> v >> w;
addedge(u, v, w, idx);
addedge(v, u, w, idx);
}
dijkstra(Ts);
cout << dis[Te] << endl;
return 0;
}
7. 实际应用中的注意事项
-
内存管理:
- 估算好数组大小(特别是边数组)
- 使用vector可能更方便但稍慢
-
输入输出优化:
cpp复制ios::sync_with_stdio(false); cin.tie(0); -
多测试用例处理:
- 记得重置全局变量
- 清空优先队列
-
算法选择策略:
- T≤1e3:朴素Dijkstra更简单
- T≤1e5:必须堆优化
- 含负权边:Bellman-Ford或SPFA
在解决这类最短路径问题时,理解算法原理比记忆模板更重要。建议自己推导松弛操作的正确性,并思考为什么Dijkstra不能处理负权边。实践中,可以先用小数据手工模拟,再逐步扩展到完整实现。