手写二分在涉及负数的时候,处理得不好容易导致死循环,比如下面这个例子:
对于任意的 \(x_1<x_2\) ,若 \(check(x_1)\) 为真,则 \(check(x_2)\) 也为真(真区间在右边)时:
ll findFirst (ll L, ll R) {
while (L < R) {
ll M = (L + R) / 2;
if (check (M)) {
R = M;
} else {
L = M + 1;
}
}
return L;
}
在 \(L==-1, R==0\) 时, \(M==0\) ,若 \(check(M)\) 为真,则下一个迭代时 \(L==-1, R==0\) ,造成死循环。
此类问题的根源来自于保留 \(M\) 的值的那个分支,在上面的例子里,保留 \(M\) 的分支为 \(R==M\) 。只要让 \(M\) 永远不等于 \(R\) ,那么每次迭代都能使得答案区间的长度减少至少 \(1\) 。
下面这样修复可以吗:
ll findFirst (ll L, ll R) {
while (L < R) {
ll M = (L + R + 1) / 2;
if (check (M)) {
R = M;
} else {
L = M + 1;
}
}
return L;
}
其实也是不可以的,在 \(L==0, R==1\) 时, \(M==1\) ,若 \(check(M)\) 为真,则下一个迭代时 \(L==0, R==1\) ,造成死循环。
不想分析那么清楚怎么办?可以把退出的条件放宽一点,在 \(L == R - 1\) 的情形下进行额外检测。
不想写那么多额外的检测怎么办?这里需要使用 C++ 的右移运算符,对于有符号数的负值右移运算符的结果是一个 ub ,但是在 x86 和 x64 的体系下,有符号数的右移运算实现为“算术右移”。
使用这一点可以保证 \(-3>>1==-2, -1>>1==-1, 1>>1==0, 3>>1==1\) ,这就是 \(\lfloor\frac{x}{2}\rfloor\) ,除以2的向下取整。
使用向下取整的方法就可以保证永远取不到上边界啦!
然而这种方法是使用 ub 的,会导致漏洞和CPU的架构相关,bug会非常难以理解。建议还是写特殊处理。
ll findFirst (ll L, ll R) {
while (L < R + 1) {
ll M = (L + R) / 2;
if (check (M)) {
R = M;
} else {
L = M + 1;
}
}
if (check (L)) {
return L;
}
return R;
}
当真区间在左边时:
ll findLast (ll L, ll R) {
while (L < R) {
ll M = (L + R) / 2;
if (check (M)) {
L = M;
} else {
R = M - 1;
}
}
return L;
}
这样写直接就死循环了,正数都会死循环,分析跟上面一样,我们关心那个保留 \(M\) 的分支,发现是赋值给 \(L\) ,而 \(L==M\) 在正数除以2的时候就会轻松出现。
这里的正确写法是
ll findFirst (ll L, ll R) {
while (L < R) {
ll M = (L + R) >> 1;
if (check (M)) {
L = M;
} else {
R = M - 1;
}
}
return L;
}
ll findLast (ll L, ll R) {
while (L < R) {
ll M = (L + R + 1) >> 1;
if (check (M)) {
L = M;
} else {
R = M - 1;
}
}
return L;
}
分析问题,确定问题的真区间是在左半段还是右半段是有必要的,然后针对两种情况选择不同的写法。但是要注意的是这种使用 ub 的行为是不可移植的。
或者用一个不带有 ub 的写法。
ll findFirst (ll L, ll R) {
while (L < R + 1) {
ll M = (L + R) / 2;
if (check (M)) {
L = M;
} else {
R = M - 1;
}
}
if (check (L)) {
return L;
}
return R;
}
ll findLast (ll L, ll R) {
while (L < R + 1) {
ll M = (L + R) / 2;
if (check (M)) {
L = M;
} else {
R = M - 1;
}
}
if (check (R)) {
return R;
}
return L;
}
这里有时候有人担心溢出的风险,其实我觉得是没有必要的,如果加法都要溢出了那么大概是不适合用这个长度的整数了。而且只能说明出题人真恶心。
参考资料
- 左移和右移运算符 ( " <<" 和 ">>" ) | Microsoft Docs
- 《算法竞赛进阶指南》P25 0x04 二分