二分 | 基本算法
参考《算法竞赛进阶指南》、AcWing题库
二分
二分法是一种随处可见却非常精妙的算法, 经常能为我们打开解答问题的突破口。 二分的基础的用法是在单调序列或单调函数中进行查找。因此当问题的答案具有单调性时, 就可以通过二分把求解转化为判定 (根据复杂度理论, 判定的难度小于求解), 这使得二分的运用范围变得很广泛。进一步地, 我们还可以扩展到通过三分法去解决单峰函数的极值以及相关问题。
据说, 只有 的程序员能写对二分。二分的实现方法多种多样, 但是其细节之处确实需要仔细考虑。对于整数域上的二分, 需要注意终止边界、左右区间取舍时的开闭情况, 避免漏掉答案或造成死循环; 对于实数域上的二分, 需要注意精度问题。
整数集合上的二分
本文所使用的二分的写法保证最终答案处于闭区间 以内, 循环以 结束, 每次二分的中间值 mid 会归属于左半段与右半段二者之一。
在单调递增序列 中查找 的数中最小的一个 (即 或 的后继):
1 | while (l < r) { |
在单调递增序列 中查找 的数中最大的一个(即 或 的前驱):
1 | while (l < r) { |
在第一段代码中, 若 , 则根据序列 的单调性, mid 之后的数会更大, 所以 的最小的数不可能在 mid 之后, 可行区间应该缩小为左半段。因为 也可能是答案, 故此时应取 mid。同理, 若 , 取 。
在第二段代码中, 若 , 则根据序列 的单调性, mid 之前的数会更小, 所以 的最大的数不可能在 mid 之前, 可行区间应该缩小为右半段。因为 也可能是答案, 故此时应取 。同理, 若 , 取 。
如上面两段代码所示, 这种二分写法可能会有两种形式:
- 缩小范围时, , 取中间值时, 。
- 缩小范围时, , 取中间值时, 。
如果不对 mid 的取法加以区分, 假如第二段代码也采用 , 那么当 等于 1 时, 就有 。接下来若进入 分支, 可行区间未缩小, 造成死循环: 若进入 分支, 造成 , 循环不能以 结束。因此对两个形式采用配套的 mid 取法是必要的。上面两段代码所示的两个形式共同组成了这种二分的实现方法。注意, 我们在二分实现中采用了右移运算 , 而不是整数除法 。这是因为右移运算是向下取整, 而整数除法是向零取整, 在二分值域包含负数时后者不能正常工作。
仔细分析这两种 mid 的取法, 我们还发现: 不会取到 这个值, 不会取到 这个值。我们可以利用这一性质来处理无解的情况, 把最初的二分区问 分别扩大为 和 , 把 数组的一个越界的下标包含进来。如果最后二分终止于扩大后的这个越界下标上, 则说明 中不存在所求的数。
总而言之, 正确写出这种二分的流程是:
- 通过分析具体问题, 确定左右半段哪一个是可行区间, 以及 mid 归属哪一半段。
- 根据分析结果, 选择 “ , mid ” 和 “ ” 两个配套形式之一。
- 二分终止条件是 , 该值就是答案所在位置。
本文使用的这种二分方法的优点是始终保持答案位于二分区间内, 二分结束条件对应的值恰好在答案所处位置, 还可以很自然地处理无解的情况, 形式优美。唯一的缺点是由两种形式共同组成, 需要认真考实际问题选译对应的形式。也有其他的二分写法, 采用 “ mid mid ” 或 “ ”来避免产生两种形式, 但也相应地造成了丟失在 mid 点上的答案、二分结束时可行区间未缩小到确切答案等问题, 需要额外加以处理, 这里就不再一一讲述。
C++ STL 中的 lower_bound 与 upper_bound 函数实现了在一个序列中二分查找某个整数 的后继。
实数域上的二分
在实数域上二分较为简单, 确定好所需的精度 , 以 为循环条件, 每次根据在 mid 上的判定选择 或 分支之一即可。一般需要保留 位小数时, 则取 。
1 | while (r - l > eps) { |
有时精度不容易确定或表示, 就干脆采用循环固定次数的二分方法, 也是一种相当不错的策略。这种方法得到的结果的精度通常比设置 eps 更高。
1 | for (int i = 0; i < 100; ++i) { |
三分求单峰函数极值
有一类函数被称为单峰函数, 它们拥有唯一的极大值点, 在极大值点左侧严格单调上升, 右侧严格单调下降; 或者拥有唯一的极小值点, 在极小值点左侧严格单调下降, 在极小值点右侧严格单调上升。为了避免混淆, 我们也称后一种为单谷函数。对于单峰或单谷函数, 我们可以通过三分法求其极值。
以单峰函数 为例, 我们在函数定义域 上任取两个点 与 , 把函数分为三段。
- 若 , 则 与 要么同时处于极大值点左侧 (单调上升函数段), 要么处于极大值点两侧。无论哪种情况下, 极大值点都在 右侧, 可令 。
- 同理, 若 , 则极大值点一定在 左侧, 可令 。 如果我们取 与 为三等分点, 那么定义域范围每次缩小 。如果我们取 与 在二等分点两侧极其接近的地方, 那么定义域范围每次近似缩小 。通过 级别的时间复杂度即可在指定精度下求出极值。这就是三分法。
注意, 我们在介绍单峰函数时特别强调了 “严格” 单调性。若在三分过程中遇到 , 当函数严格单调时, 令 或 均可。如果函数不严格单调, 即在函数中存在一段值相等的部分, 那么我们无法判断定义域的左右边界如何缩小, 三分法就不再适用。
二分答案转化为判定
一个宏观的最优化问题也可以抽象为函数, 其 “定义域” 是该问题下的可行方案, 对这些可行方案进行评估得到的数值构成函数的 “值域”, 最优解就是评估值最优的方案 (不妨设评分越高越优)。假设最优解的评分是 , 显然对于 , 都不存在一个合法的方案达到 分, 否则就与 的最优性矛盾; 而对于 , 一定存在一个合法的方案达到或超过 分, 因为最优解就满足这个条件。这样问题的值域就具有一种特殊的单调性一一在 的一侧合法、在 的另一侧不合法, 就像一个在 上值为 1 , 在 上值为 0 的分段函数, 可通过二分找到这个分界点 。借助二分, 我 们把求最优解的问题, 转化为给定一个值 , 判定是否存在一个可行方案评分达到 的问题。接下来我们通过一个经典的例子理解上述概念。
二分判定经典例子
有 本书排成一行,已知第 本的厚度是 。把它们分成连续的 组,使 最小化,其中 表示厚度之和最大的一组的厚度。
题目描述中出现了类似于 “最大值最小” 的含义, 这是答案具有单调性, 可用二分转化为判定的最常见、最典型的特征之一。如果我们以 “把书划分为 组的方案” 作为定义域, “厚度之和最大的一组的厚度” 作为评分 (即值域), 需要最小化这个厚度值, 也就是评分越小越优。相应地, 假设最终答案为 , 因为 的最优性, 如果要求每组的厚度都 , 那么这 组一定不能容纳这些书, 可能需要更多的组才能把书分完, 也就意味着对于本题的限制条件不存在可行的分书方案。如果每组的厚度可以 , 那么一定存在一种分书方案使得组数不会超过 。最优解就处于分书可行性的分界点 上。
1 | // 把n本书分成m组,每组厚度之和<=size,是否可行 |
102. 最佳牛围栏
农夫约翰的农场由 块田地组成,每块地里都有一定数量的牛,其数量不会少于 头,也不会超过 头。
约翰希望用围栏将一部分连续的田地围起来,并使得围起来的区域内每块地包含的牛的数量的平均值达到最大。
围起区域内至少需要包含 块地,其中 会在输入中给出。
在给定条件下,计算围起区域内每块地包含的牛的数量的平均值可能的最大值是多少。
输入格式
第一行输入整数 和 ,数据间用空格隔开。
接下来 行,每行输入一个整数,第 行输入的整数代表第 片区域内包含的牛的数目。
输出格式
输出一个整数,表示平均值的最大值乘以 再 向下取整 之后得到的结果。
数据范围
输入样例:
1 | 10 6 |
输出样例:
1 | 6500 |
算法分析
二分答案, 判定 “是否存在一个长度不小于 的子段, 平均数不小于二分的值” 。
如果把数列中每个数都减去二分的值, 就转化为判定 “是否存在一个长度不小于 的子段, 子段和非负”。下面我们着重来解决以下两个问题:
- 求一个子段, 它的和最大, 没有 “长度不小于 ” 这个限制。
无长度限制的最大子段和问题是一个经典问题, 只需 扫描该数列, 不断把新的数加入子段, 当子段和变成负数时, 把当前的整个子段清空。扫描过程中出现过的最大子段和即为所求。 - 求一个子段, 它的和最大,子段的长度不小于 。
子段和可以转化为前缀和相减的形式, 即设 表示 的和, 则有:
仔细观察上面的式子可以发现, 随着 的增长, 的取值范围 每次只会增大 1。换言之, 每次只会有一个新的取值进入 的候选集合, 所以我们没有必要每次循环枚举 , 只需要用一个变量记录当前最小值, 每次与新的取值 sum 取 min 就可以了。
1 | double ans = -1e10; |
解决了问题 2 之后, 我们只需要看一下最大子段和是不是非负数, 就可以确定二分上下界的变化范围了。
请读者自己思考使用二分的前提: 为什么该问题的答案具有单调性? 整道题目的参考程序如下:
1 |
|
Solution
113. 特殊排序
有 个元素,编号 ,每一对元素之间的大小关系是确定的,关系具有反对称性,但不具有传递性。
注意:不存在两个元素大小相等的情况。
也就是说,元素的大小关系是 个点与 条有向边构成的任意有向图。
然而,这是一道交互式试题,这些关系不能一次性得知,你必须通过不超过 次提问来获取信息,每次提问只能了解某两个元素之间的关系。
现在请你把这 个元素排成一行,使得每个元素都小于右边与它相邻的元素。
你可以通过我们预设的 bool 函数 compare 来获得两个元素之间的大小关系。
例如,编号为 和 的两个元素,如果元素 小于元素 ,则 compare(a,b) 返回 true,否则返回 false。
将 个元素排好序后,把他们的编号以数组的形式输出,如果答案不唯一,则输出任意一个均可。
数据范围
输入样例
1 | [[0, 1, 0], [0, 0, 0], [1, 1, 0]] |
输出样例
1 | [3, 1, 2] |
算法分析
根据数学归纳法, 假设前 个元素已经按要求排成一行, 如果能确定第 个元素应该放在哪一个前面, 即可解决此问题。
我们可以通过这样一种二分法确定第 个元素的位置: 若第 个元素比第 个元素小, 令 , 否则令 。二分的初始区间可设为 , 区间中的 这个值表示放在所有 个元素之后。
可以证明二分一定可以找到一个合法的位置插入, 证明如下:
- 如果第 个元素比第 个元素小。
继续比较 与 这两个元素, 若第 个元素比第 个元素大, 则 可以插在 与 之问; 否则说明元素 比元素 小, 那就再比较 与 这两个元素, 依此类推, 直到发现第 个元素比第 1 个元素小,那就放在最前边。 - 如果第 个元素比第 个元素大, 同理。
以上只是一个证明, 我们当然不会真的去依次比较 与每个元素。实际上通过二分, 我们每询问一次 ( 与 ), 就可以把区间 缩小一半, 因此至多 次询问就可以确定 应该放在哪里。把 个元素排成一行的总询问次数不超过 。
本题的解答过程事实上证明了:任意有向完全图(又称竞赛图)都存在 Hamilton 路径。
1 | // Forward declaration of specialSort API. |