【题目描述】
知道黑暗城堡有 N 个房间,M 条可以制造的双向通道,以及每条通道的长度。
城堡是树形的并且满足下面的条件:
设 Di为如果所有的通道都被修建,第 i 号房间与第 1 号房间的最短路径长度;
而 Si 为实际修建的树形城堡中第 i 号房间与第 1 号房间的路径长度;
要求对于所有整数 i(1≤i≤N),有 Si=Di 成立。
你想知道有多少种不同的城堡修建方案。当然,你只需要输出答案对 2^31−1 取模之后的结果就行了。
【输入】
第一行为两个由空格隔开的整数 N,M;
第二行到第 M+1 行为 3 个由空格隔开的整数 x,y,l:表示 x 号房间与 y 号房间之间的通道长度为 l。
【输出】
一个整数:不同的城堡修建方案数对 2^31−1 取模之后的结果。
【输入样例】
4 6
1 2 1
1 3 2
1 4 3
2 3 1
2 4 2
3 4 1
【输出样例】
6
【提示】
样例说明
一共有 4 个房间,6 条道路,其中 1 号和 2 号,1 号和 3 号,1 号和 4 号,2 号和 3 号,2 号和 4 号,3 号和 4 号房间之间的通道长度分别为 1,2,3,1,2,1。
而不同的城堡修建方案数对 2^31−1 取模之后的结果为 6。
数据范围:
对于全部数据,1≤N≤1000,1≤M≤N*(N−1)/2,1≤l≤200。
今天我们要剖析的是图论中一个进阶模型------最短路径生成树(Shortest Path Spanning Tree, SPST)。
这道题完美地将"单源最短路算法"与"组合数学计数"糅合在了一起。只要你看破了它的数学本质,代码写起来会行云流水!
1. 题意转换
用一句话剥离题目的背景故事:
给定一张N个点、M条边的带权无向图,求以 1 号节点为根,且树上任意节点到根的距离都等于原图最短距离的"生成树"的构建方案总数。
2. 思考过程与解题思路
第一直觉的暴力解法是什么?
初学者看到"树形的城堡"和"方案数",第一直觉可能是用 DFS 暴力搜索出所有的生成树,然后再用求出这棵树上每个点到根的距离去和原图验证。但根据 Cayley 公式,完全图的生成树数量高达 。在
的数据规模下,枚举生成树的时间复杂度会绝对的超时。
考点锁定:
-
关键字 :"
" (树上距离必须等于原图理论最短路)是极其强烈的暗示!这指明了我们必须先求出单源最短路。
-
关键字 :"树形"。一棵包含所有节点的树,意味着每个节点(除了根节点 1)必须有且仅有一个直接父亲。
如何一步步推导到正解?
-
既然树上距离要等于最短路,第一步毫无悬念:以 1 号点为起点,跑一遍标准的 Dijkstra,求出到达每个节点v的真实最短距离
。
-
在最终的生成树里,如果节点u有资格当节点v的父亲(通道长度为l),它必须完美满足:
。物理意义就是:水只能沿着最短路的斜坡,从高处(距离近的点)流向低处(距离远的点)。
-
统计原图中有多少个邻居能成为v的"合法父亲"。因为各个节点的选择是互相独立的,根据高中的乘法原理,把所有节点的合法父亲数量乘起来,就是最终生成树的方案数。
3. 算法设计与样例推演
核心逻辑与数据结构:
-
链式前向星:应对密集图,提供最极致的内存和常数表现。
-
Dijkstra(小根堆优化):处理无负权边的单源最短路。
-
乘法原理统计:遍历全图边,累乘合法入边。
极简数据手玩推演:
带入题目样例:4 个房间。
-
跑完 Dijkstra 后得出最短路:
。
-
寻找各点的合法父亲(满足
):
-
对于房间 2:只有 1 号房间满足
。合法父亲 1 个。
-
对于房间 3:1 号房间满足
;2 号房间满足
。合法父亲 2 个。
-
对于房间 4:1 号、2 号、3 号房间加对应边长后均满足
。合法父亲 3 个。
-
-
方案数相乘:
。与样例输出完全一致。
4. 时空复杂度分析
-
时间复杂度 :
。其中M为原图边数。Dijkstra 堆优化的复杂度为
。最后遍历一遍所有的边寻找"合法父亲",复杂度为O(M)。在
时,总运算量在千万级别,C++ 在 1 秒内轻松满分跑完。
-
空间复杂度 :O(N + M)。链式前向星只需三个长度为
的一维数组(应对无向图双向存边),内存开销约为十几 MB,绝对安全。
5. 坑点与易错总结
-
链式前向星数组越界(极易TLE/RE) :本题
,极限情况下完全图M高达近 50 万。由于是双向存边,边数数组必须开到1,000,000以上。如果开小了,溢出的指针会被篡改,导致死循环从而TLE。
-
位运算优先级(极易 WA) :题目要求对
取模。在 C++ 中,减号
-的优先级高于左移<<!如果写成1LL << 31 - 1,会先算减法变成1LL << 30。必须严格加括号写成((1LL<<31)-1)。 -
大坑:Dijkstra 忘记 pop :在使用
priority_queue时,取出q.top()后必须紧接着立刻q.pop(),绝不能被后续的if(vis) continue;跳过,否则堆顶元素不清除会导致无限死循环。
6. 代码进阶与优化剖析
审视下方的代码,这是一份非常成熟、完全达到省选金牌标准的代码。它不仅避开了所有的坑点,更包含了值得初学者学习的 2 个高阶编码习惯:
-
高阶习惯 1:大巧不工的模块化解耦
作者并没有试图把"找父亲"的逻辑强行揉碎在 Dijkstra 的松弛操作中,而是极其干净利落地分成两步:先纯粹地跑完最短路,然后再单独开一个循环遍历边集寻找
。这种"将复杂问题解耦"的思维,让代码的可读性和正确率极高。
-
高阶习惯 2:极其稳健的常数控制
使用了静态数组模拟链表(链式前向星)来存图,彻底避免了
vector动态扩容带来的常数开销;同时利用小根堆重载<运算符,代码精简,运行常数被压榨到了极致。
7. 完整代码
cpp
//最短路径生成树
#include <iostream>
#include <cstring>//memset
#include <queue>
using namespace std;
int n,m;
int h[1010];
//无向图边数500000 双向存边开到1000010 防止越界
int vtex[1000010];
int nxt[1000010];
int wt[1000010];
int idx;
int d[2020];//求出1号点到每个点的理论最短路径长度d
int vis[2020];
long long sum[2020];//统计每个节点有合法入边
struct node{
int id;//节点编号
int dis;//该节点到起点的距离
//最大堆重载为最小堆
friend bool operator <(node a,node b){
return a.dis>b.dis;
}
}no[2020];
priority_queue<node> q;
//链式前向星存图
void addedge(int u,int v,int l){
vtex[idx]=v;
wt[idx]=l;
nxt[idx]=h[u];
h[u]=idx++;
}
//求单源最短路径 x为起点
void dijkstra(int x){
d[x]=0;//起点到自己距离为0
node tmp;
tmp.id=x;
tmp.dis=0;
q.push(tmp);//起点入队
while(!q.empty()){
node tmp;
tmp=q.top();//访问队首节点
q.pop();//该节点出队
int nid=tmp.id;//队首节点编号
//如果该节点已经点亮继续访问下一个队首节点
if(vis[nid]==1) continue;
//否则就点亮该节点
vis[nid]=1;
int p=h[nid];
//访问当前节点nid的所有邻接点v
while(p!=-1){
int v=vtex[p];
//如果v到1号点的距离大于经过nid到起点的距离
if(d[v]>d[nid]+wt[p]){
//就更新v到1号点最短距离
d[v]=d[nid]+wt[p];
//并把v入队
q.push({v,d[v]});
}
p=nxt[p];//更新指针 去下一个nid的邻接点
}
}
}
int main(){
ios::sync_with_stdio(false);
cin.tie(0);
cin>>n>>m;
//初始化头指针数组为空
memset(h,-1,sizeof(h));
//链式前向星存图
for(int i=1;i<=m;i++){
int x,y,l;
cin>>x>>y>>l;
//无向图双向存边
addedge(x,y,l);
addedge(y,x,l);
}
//初始化所有节点到1号点的距离为无穷
memset(d,0x3f,sizeof(d));
//接下来求出1号点到每个点的理论最短路径长度d
dijkstra(1);
//题目告诉我们最后的城堡是树形的,所以最后的城堡相邻点之间一定只有一条边相连
//最终也只有n-1条边 每个点v只会有一个父亲u
//我们需要找到对于每个点v,有多少条入边(sum[v])可以让他满足d[v]=d[u]+l即可
//最后把每个点满足条件的边数累乘就好了(sum[2]*sum[3]*......*sum[v])
//接下来遍历所有点
for(int i=2;i<=n;i++){
//访问i点的所有边 有多少条入边(sum[i])可以让他满足d[i]=d[v]+wt[p]
int p=h[i];
while(p!=-1){
//假设v是i的父节点
int v=vtex[p];
//看能否满足d[i]=d[v]+wt[p] 如果能满足代表v确实可以是i的父亲
if(d[i]==d[v]+wt[p]) sum[i]++;
//如果不满足d[i]=d[v]+wt[p] 如果能满足代表v不可以是i的父亲
p=nxt[p];
}
}
long long sum2=1;//存储最后结果
for(int i=2;i<=n;i++){
sum2=(sum2*sum[i])%((long long)(1ll<<31)-1);
}
cout<<sum2;
return 0;
}