省流 十年OI一场空,一用memset见祖宗
前情提要
很普通的一道 普及+/提高 的题,题面如下:
P10725 [GESP202406 八级] 最远点对
题目描述
小杨有一棵包含 n 个节点的树,这棵树上的任意一个节点要么是白色,要么是黑色。
小杨想知道相距最远的一对不同颜色节点的距离是多少。
输入格式
第一行包含一个正整数 n,代表树的节点数。
第二行包含 n 个非负整数 $a_1,a_2,\cdots,a_n$(对于所有的 1\le i\le n,均有 a_i 等于 0 或 $1$),其中如果 a_i=0,则节点 i 的颜色为白色;如果 a_i=1,则节点 i 的颜色为黑色。
之后 (n-1) 行,每行包含两个正整数 x_i,y_i,代表存在一条连接节点 x_i 和 y_i 的边。
保证输入的树中存在不同颜色的点。
输出格式
输出一个整数,代表相距最远的一对不同颜色节点的距离。
输入输出样例 #1
输入 #1
5
0 1 0 1 0
1 2
1 3
3 4
3 5
输出 #1
3
说明/提示
样例解释
相距最远的不同颜色的一对节点为节点 2 和 5。
数据范围
本题采用捆绑测试。
| 子任务编号 | 得分 | n | a_i | 特殊条件 |
|---|---|---|---|---|
| 1 | 30 | \le 10^5 | 0\le a_i\le 1 | 树的形态为一条链 |
| 2 | 30 | \le 10^3 | 0\le a_i\le 1 | |
| 3 | 40 | \le 10^5 | 0\le a_i\le 1 |
对于全部数据,保证有 1\le n\le 10^5,0\le a_i\le 1。
明显可以用树上DP求解,定义状态
得出状态转移方程
(a) 计算当前子树
我们要找不同颜色的最远点对。
- 如果在 u 当前已知的子树部分中,最远的白点距离是 dp[u][0],
- 而在子树 v 中,最远的黑点距离是 dp[v][1],
则通过边 (u,v) 可以形成一条路径,长度为:
(+1 是因为 u 和 v 之间有一条边)
同理,也可能是:
更新答案
ans=max(max(dp[x][0]+dp[nxt][1],dp[x][1]+dp[nxt][0])+1,ans);
记住这句话,后面要考
(b) 向上返回
表示如果 v 的子树中有某个最远的白点或黑点,那么到 u 的距离要加 1。
更新答案
dp[x][0]=max(dp[x][0],dp[nxt][0]+1);
dp[x][1]=max(dp[x][1],dp[nxt][1]+1);
很快写出如下代码
#include<bits/stdc++.h>
#define int long long
using namespace std;
int n,ans;
int color[100005];
int dp[100005][2];
vector<int> edge[100005];
void dfs(int x,int fa)
{
for(int i=0;i< edge[x].size();i++)
{
int nxt=edge[x][i];
if(nxt==fa) continue;
dfs(nxt,x);
ans=max(max(dp[x][0]+dp[nxt][1],dp[x][1]+dp[nxt][0])+1,ans);
dp[x][0]=max(dp[x][0],dp[nxt][0]+1);
dp[x][1]=max(dp[x][1],dp[nxt][1]+1);
}
return;
}
signed main()
{
memset(dp,0x80,sizeof dp);
cin>>n;
for(int i=1;i<=n;i++) cin>>color[i];
for(int i=1;i<=n;i++) dp[i][color[i]]=0;
for(int i=1;i< n;i++)
{
int u,v;
cin>>u>>v;
edge[u].push_back(v);
edge[v].push_back(u);
}
dfs(1,0);
cout<<ans;
return 0;
}
问题浮现
一运行,坏了
PS C:\Users\77570\Documents> g++.exe "C:\Users\77570\Documents\P10725 [GESP202406 八级] 最远点对.cpp" -o "C:\Users\77570\Documents\P10725 [GESP202406 八级] 最远点对.exe" -g3 -std=c++17
PS C:\Users\77570\Documents> C:\Users\77570\Documents\"P10725 [GESP202406 八级] 最远点对.exe"
5
0 1 0 1 0
1 2
1 3
3 4
3 5
72340172838076673
PS C:\Users\77570\Documents>
我去,这是啥
简单观察发现,72340172838076673=0x101010101010101。
凭经验,大抵是溢出了,把0x80换成个0xcf试试看
PS C:\Users\77570\Documents> g++.exe "C:\Users\77570\Documents\P10725 [GESP202406 八级] 最远点对.cpp" -o "C:\Users\77570\Documents\P10725 [GESP202406 八级] 最远点对.exe" -g3 -std=c++17
PS C:\Users\77570\Documents> C:\Users\77570\Documents\"P10725 [GESP202406 八级] 最远点对.exe"
5
0 1 0 1 0
1 2
1 3
3 4
3 5
3
PS C:\Users\77570\Documents>
修好了?
不会吧,0x8080808080808080离LONG_LONG_MIN还远着,仅仅+1 -1的递推爆不了
然后就对着递推式盯了30min 糖逼一个
猛然间发现我的代码里有一句
ans=max(max(dp[x][0]+dp[nxt][1],dp[x][1]+dp[nxt][0])+1,ans);
完辣
0x80+0x80必然爆,而0xcf+0xcf没事。
GPT是这么回答的
是的,0x80 这个例子可以说是一个极其经典、标准教科书级的「补码溢出陷阱」案例。
它几乎把**有符号补码整数的一切“坑点”**都展示得淋漓尽致。
下面我带你逐层解析为什么它这么典型![]()
一、为什么说“典型”
我们先看 0x80 填充的结果:
memset(dp, 0x80, sizeof dp);
对一个 64 位的 long long,每个字节都是 0x80:
0x8080808080808080
这在补码中表示:
十进制 = -9187201950435737472 ≈ -9.187×10^18
非常接近 long long 的最小值 −9.223372036854775808×10^18。
这就是陷阱的开端。
二、补码的定义复习
对于一个 n 位补码整数:
负数 x = 2^n − |x|
对于 0x8080808080808080:
x = 2^64 - 0x7F7F7F7F7F7F7F80
= -9187201950435737472
最高位 1 → 表示负数。
这没问题,但是——它离 LLONG_MIN 太近了!
三、陷阱:一加就炸
在 DFS 中出现的操作:
ans = max(ans, dp[x][0] + dp[y][1] + 1);
于是:
(-9.187e18) + (-9.187e18) + 1 = -1.837e19 + 1
数学上,这个结果小于 LLONG_MIN = -9.223e18,
所以它 溢出了 long long 的表示范围。
四、C++ 层面:这就是 “有符号溢出 UB”
C++ 标准([expr.add]/10)明文规定:
如果加法或减法的结果超出目标有符号整数类型能表示的范围,则行为未定义。
也就是说:
- 编译器不保证结果正确
- 甚至不保证程序继续正常执行
- 任何结果(正数、负数、NaN)都可能出现
五、CPU 层面:补码模加(真实硬件行为)
虽然语言标准说“UB”,但硬件不会真的炸,它执行模 2⁶⁴ 加法:
0x8080808080808080 + 0x8080808080808080 = 0x0101010101010100
再 +1 → 0x0101010101010101
于是你得到:
72340172838076673 = 0x0101010101010101
也就是负负得正,结果反而成了小正数。
这就是最危险的地方:
程序没崩,但逻辑已彻底错。
六、为什么说它“教科书级”
这个例子之所以经典,是因为它完美击中三个补码陷阱点:
| 陷阱 | 0x80 是否命中 |
说明 |
|---|---|---|
| ① 字节填充与整数解释不同 | memset 按字节填,不是按整数 |
|
| ② 有符号溢出是 UB | a + a 超出 long long 范围 |
|
| ③ 补码模加回绕导致逻辑错误 | 溢出后翻回正区间,结果看似“正常” |
简直是“补码未定义行为三连击”。
七、这类陷阱的泛化版本
0x80 在不同位宽上都会“炸”:
| 位宽 | 填充字节 | 实际值(补码解释) | 加自己后 |
|---|---|---|---|
| 8 位 | 0x80 |
−128 | 溢出为 0 |
| 16 位 | 0x8080 |
−32640 | 溢出为 0x0100 |
| 32 位 | 0x80808080 |
−2139062144 | 溢出为 0x01010100 |
| 64 位 | 0x8080808080808080 |
−9187201950435737472 | 溢出为 0x0101010101010100 |
无论在哪种宽度上,“一加自己就翻正”。
这就是为什么 0x80 是个**“万能溢出示例”**。
八、对比“安全”的字节填充
| 填充值 | 补码解释 | 加自己结果 | UB? | 备注 |
|---|---|---|---|---|
| 0x00 | 0 | 0 | 安全但无意义 | |
| 0xFF | −1 | −2 | 补码闭合,安全 | |
| 0x80 | −9.18e18 | 溢出 | ||
| 0x88 | −8.60e18 | 溢出 | “神秘 0x1111…” 案例 | |
| 0xCF | −3.50e18 | 不溢出 |
九、结论
0x80是一个标准补码溢出陷阱案例。
它体现了:
![]()
memset字节填充与整数初始化的危险组合;
有符号溢出的未定义行为;
模 2⁶⁴ 加法“负负得正”的错觉;
补码世界中“静默错误”的致命性。
几乎所有编译器和 CPU 都能在这个例子上验证补码模型的行为,
因此它在教学、调试和标准分析中经常被用作经典演示。
十、总结成一句话:
memset(..., 0x80, ...)是补码世界里最漂亮的一颗“定时炸弹”:
**看似只是个负数,实则一加就翻正,且无警告、无崩溃、结果错得离谱。
这样就可以该出正确答案了
#include<bits/stdc++.h>
#define int long long
using namespace std;
int n,ans;
int color[100005];
int dp[100005][2];
vector<int> edge[100005];
void dfs(int x,int fa)
{
for(int i=0;i< edge[x].size();i++)
{
int nxt=edge[x][i];
if(nxt==fa) continue;
dfs(nxt,x);
ans=max(max(dp[x][0]+dp[nxt][1],dp[x][1]+dp[nxt][0])+1,ans);
dp[x][0]=max(dp[x][0],dp[nxt][0]+1);
dp[x][1]=max(dp[x][1],dp[nxt][1]+1);
}
return;
}
signed main()
{
memset(dp,0xff,sizeof dp);
cin>>n;
for(int i=1;i<=n;i++) cin>>color[i];
for(int i=1;i<=n;i++) dp[i][color[i]]=0;
for(int i=1;i< n;i++)
{
int u,v;
cin>>u>>v;
edge[u].push_back(v);
edge[v].push_back(u);
}
dfs(1,0);
cout<<ans;
return 0;
}
完结撒花!