K-th Path - 题解
比赛与标签
比赛: Codeforces Round 575 (Div. 3) 标签: brute force, constructive algorithms, shortest paths, sortings 难度: *2200
喵喵,这是个什么问题呀?
主人,你好喵~ 今天我们来解决一个超级有趣的寻路问题呐!(ฅ'ω'ฅ)
题目给了我们一张大大的地图(一个连通的无向带权图),上面有 n
个城市(顶点)和 m
条双向道路(边),每条道路都有一个长度(权重)。
我们的任务是,找出所有不同城市对之间(比如从城市A到城市B)的最短旅行时间。然后,把所有这些最短时间从小到大排成一排,找到第 k
小的那个时间是多少。
要注意哦,从城市 A 到城市 B 的最短路和从 B 到 A 是一样的,我们只算一次,而且自己到自己的路程不算在内哦!
寻找第k短路的小秘密喵~
哇,这个图好大呀,有 n
个点呢!如果让我们把所有 n * (n-1) / 2
个点对之间的最短路都算出来,那可太慢了喵... 比如对每个点都跑一次 Dijkstra,复杂度是 O(n * (m + n log n))
,肯定会超时的说!(;´Д`)
但是但是!主人请看,k
的值非常非常小哦,最多只有400!这一定是解题的关键所在,喵~
既然我们只关心前 k
个最短的路径,那这些路径本身应该也不会太长吧?一条很长的路径,怎么可能排到前面去呢?而路径的长度,是由构成它的边的长度累加起来的。所以,我们可以大胆地猜测一下:构成前 k
条最短路径的那些路径,很可能只用到了整个图中边权最小的那一部分边!
这个想法是不是很棒?就像做蛋糕只需要最好的那几颗草莓,而不是把一整个果园的草莓都搬过来一样!
于是,我们的策略就清晰起来啦:
筛选关键边: 我们不需要考虑所有的
m
条边。我们只关心那些权重最小的边。取多少条呢?既然要找第k
短路,那么就先看看前k
短的边吧!我们把所有边按权重从小到大排序,只取出前k
条。构建核心子图: 这
k
条边连接了多少个点呢?一条边连接2个点,所以最多也就2*k
个点嘛。当k=400
时,最多也就800个点。哇,和原来最多200000
个点相比,这个规模小太多啦!我们就用这k
条边和它们连接的所有点,构建一个新的、小小的图。在子图上求解: 在这个小图上,点的数量很少(我们记作
R
,R <= 2k
),我们就可以放心大胆地计算所有点对之间的最短路了!最简单的方法就是,对小图里的每个点都跑一次 Dijkstra 算法,得到它到其他所有点的最短距离。收集与排序: 我们把在小图上算出来的所有不同的最短路径长度(
d(i, j)
且i < j
)都收集到一个列表里。找到答案: 最后,把这个列表排序,取出第
k
个元素,就是我们想要的答案啦!因为我们用前k
条边生成了不止k
条路径(这些边可以组合嘛),所以第k
小的路径有极大概率就在其中。事实证明,这个贪心的想法是正确的喵!
让代码动起来喵!
下面就是把这个思路变成现实的代码啦,我已经加上了详细的注释,方便主人理解每一步在做什么哦~
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
int main() {
// 加速输入输出,让程序跑得更快喵~
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, m, k;
cin >> n >> m >> k;
// 用一个元组(tuple)的vector来存储所有的边
// 格式是 {权重, 点1, 点2},这样可以直接按权重排序
vector<tuple<ll, int, int>> edges;
for (int i = 0; i < m; i++) {
int x, y;
ll w;
cin >> x >> y >> w;
edges.emplace_back(w, x, y);
}
// 第一步:按照边的权重从小到大排序!
sort(edges.begin(), edges.end());
// 我们最多只需要考虑前k条边就足够了
// 如果总边数m比k还小,那就全都要~
int take = min(m, k);
// 用一个set来收集这'take'条边所连接的所有点,set可以自动去重哦
set<int> distinctNodes;
// Eprime是我们的核心子图的边集
vector<tuple<ll, int, int>> Eprime;
for (int i = 0; i < take; i++) {
auto [w, u, v] = edges[i];
distinctNodes.insert(u);
distinctNodes.insert(v);
Eprime.emplace_back(w, u, v);
}
// 把set里的不连续的节点编号,映射到从0开始的连续新编号
vector<int> nodes(distinctNodes.begin(), distinctNodes.end());
int R = nodes.size(); // R 是我们子图的节点数
map<int, int> node_to_index;
for (int i = 0; i < R; i++) {
node_to_index[nodes[i]] = i;
}
// 这就是我们构建的只包含关键点的小图啦!
vector<vector<pair<int, ll>>> graph(R);
for (auto [w, u, v] : Eprime) {
int i = node_to_index[u];
int j = node_to_index[v];
graph[i].emplace_back(j, w);
graph[j].emplace_back(i, w);
}
// 存放所有计算出的最短路径长度
vector<ll> all_dists;
// 对小图里的每一个点,我们都跑一次Dijkstra算法
for (int i = 0; i < R; i++) {
vector<ll> dist(R, LLONG_MAX); // 初始化距离为无穷大
// 优先队列优化的Dijkstra
priority_queue<pair<ll, int>, vector<pair<ll, int>>, greater<>> pq;
dist[i] = 0;
pq.emplace(0, i);
while (!pq.empty()) {
auto [d, u] = pq.top();
pq.pop();
if (d != dist[u]) continue; // 已经有更短的路径了,跳过
for (auto [v, w] : graph[u]) {
if (dist[u] + w < dist[v]) {
dist[v] = dist[u] + w;
pq.emplace(dist[v], v);
}
}
}
// 把从i出发到其他点的最短路存起来 (j > i 保证不重复)
for (int j = i + 1; j < R; j++) {
if (dist[j] != LLONG_MAX) {
all_dists.push_back(dist[j]);
}
}
}
// 最后一步,排序所有找到的路径长度
sort(all_dists.begin(), all_dists.end());
// 输出第k个!因为数组下标从0开始,所以是k-1的位置
if (k <= all_dists.size()) {
cout << all_dists[k - 1] << endl;
} else {
// 如果生成的路径总数小于k,题目保证k不会超过总路径数,
// 但为了代码健壮性,这里可以输出最后一个
cout << all_dists.back() << endl;
}
return 0;
}
跑得快不快呀?
时间复杂度: O(m log m + k² log k) 的说
- 对
m
条边排序需要O(m log m)
。 - 构建子图需要
O(k)
。 - 子图的节点数
R
最多为2k
,边数最多为k
。 - 我们对子图中的
R
个节点,每个都跑了一次 Dijkstra。Dijkstra 的复杂度是O(E' + R log R)
,其中E'
是子图边数。所以这部分总共是O(R * (k + R log R))
。代入R = O(k)
,就是O(k * (k + k log k)) = O(k² log k)
。 - 最后对所有找到的路径排序,路径数量最多是
O(R²) = O(k²)
条,排序需要O(k² log(k²)) = O(k² log k)
。 - 所以总的复杂度由
O(m log m)
和O(k² log k)
决定。因为m
可能很大,所以主要是排序边的时间开销,后面处理小图的部分因为k
很小,所以非常快!
- 对
空间复杂度: O(m + k²) 的说
- 存储所有
m
条边需要O(m)
的空间。 - 子图的邻接表需要
O(R + E') = O(k)
的空间。 - 存储所有找到的最短路径需要
O(R²) = O(k²)
的空间。 - 所以总的空间复杂度是
O(m + k²)
,也是很够用的说!
- 存储所有
这次学到了什么喵?
主人,这次的冒险是不是超有收获?我们来总结一下学到的小知识点吧!
核心思想 - 降维打击: 这道题最精髓的地方就是观察到
k
很小,从而把一个在大图上看似无解的 All-Pairs Shortest Path 问题,转化成了一个在关键点构成的子图上的可解问题!这是一种非常重要的、通过抓住问题瓶颈来简化问题的思想,喵~贪心选择: 我们贪心地只选择权重最小的
k
条边来构建子图,这个策略非常有效。它告诉我们,最终的解往往隐藏在最优的局部结构中。Dijkstra算法的应用: Dijkstra 是求单源最短路径的经典算法,一定要熟练掌握哦!在这里,我们通过对子图的每个点都跑一次 Dijkstra,巧妙地解决了子图上的 All-Pairs Shortest Path 问题。
离散化/节点映射: 当题目给的点编号很大但不连续时(比如 1, 5, 1000),将它们映射到
0, 1, 2...
这样的连续整数,是处理图论问题的常用技巧。它能让数组和邻接表的使用变得非常方便和高效,喵~
所以呀,主人~ 以后遇到看起来很吓人的大数据问题,一定要仔细看看有没有什么特殊的、小小的约束条件,那可能就是解题的突破口哦!加油喵!(๑•̀ㅂ•́)و✧