【CPL - 001】二分

手写二分在涉及负数的时候,处理得不好容易导致死循环,比如下面这个例子:

对于任意的 \(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;
}

这里有时候有人担心溢出的风险,其实我觉得是没有必要的,如果加法都要溢出了那么大概是不适合用这个长度的整数了。而且只能说明出题人真恶心。

参考资料

  1. 左移和右移运算符 ( " <<" 和 ">>" ) | Microsoft Docs
  2. 《算法竞赛进阶指南》P25 0x04 二分
posted @ 2022-03-18 00:08  purinliang  阅读(31)  评论(0编辑  收藏  举报