基础算法 - 二分

二分的基础算法是在单调序列上或单调函数中进行查找。当问题的答案具有单调性时,就可以通过二分把求解转化为判定。

整数集合上的二分

书中所使用的二分的写法保证最终答案处于闭区间 [ l , r ] [l,r] [l,r] 以内,循环以 l = r l = r l=r 结束,每次二分的中间值 m i d mid mid 会归属于左半段与右半段二者之一。

在单调递增序列 a a a 中查找 ≥ x \ge x x 的数中最小的一个(即 x x x x x x 的后继):

while (l < r) {
	int mid = (l + r) >> 1;
	if(a[mid] >= x) r = mid;
	else l = mid + 1; 
}
return a[l];

在单调递增序列 a a a 中查找 ≤ x \le x x 的数中最大的一个(即 x x x x x x 的前驱):

while (l < r) {
	int mid = (l + r + 1) >> 1;
	if(a[mid] <= x) l = mid;
	else r = mid - 1; 
}
return a[l];

如上面两段代码所示,这种二分写法会有两种形式。

  • 缩小范围时, r = m i d r = mid r=mid l = m i d + 1 l = mid + 1 l=mid+1 ,取中间值时, m i d = ( l + r ) > > 1 mid = (l + r) >> 1 mid=(l+r)>>1
  • 缩小范围时, l = m i d l = mid l=mid r = m i d − 1 r = mid - 1 r=mid1 ,取中间值时, m i d = ( l + r + 1 ) > > 1 mid = (l + r + 1) >> 1 mid=(l+r+1)>>1

书上提到,在这两段代码中二分实现里采用了右移运算 >> 1,而不是整数 ÷ 2 \div2 ÷2 。这是因为右移运算是向下取整,而整数除法是向零取整,在二分值域包含负数时,后者无法正常工作了。

最后书中总结了二分写法的流程:

  1. 通过分析具体问题,确定左右半段哪一个是可行区间,以及 m i d mid mid 归属哪一半段;
  2. 根据分析结果,选“ r = m i d , l = m i d + 1 , m i d = ( l + r ) > > 1 r = mid,l = mid + 1, mid = (l + r) >> 1 r=mid,l=mid+1,mid=(l+r)>>1 ”和“ l = m i d , r = m i d − 1 , m i d = ( l + r + 1 ) > > 1 l = mid,r = mid - 1, mid = (l + r + 1) >> 1 l=mid,r=mid1,mid=(l+r+1)>>1”两个配套形式之一;
  3. 二分结束终止是 l = = r l == r l==r ,该值就是答案所在位置。

实数域上的二分

在实数域上,就只需要确定所需精度 e p s eps eps ,以 l + e p s < r l + eps < r l+eps<r 为循环条件,每次根据在 m i d mid mid 上的判定选择 r = m i d r = mid r=mid l = m i d l = mid l=mid 分支之一即可。一般需要保留 k k k 位小数时,则取 e p s = 1 0 − ( k + 2 ) eps = 10^{-(k + 2)} eps=10(k+2)

while (l + 1e-5 < r) {
	double mid = (l + r) / 2;
	if(check(mid)) r = mid;
	else l = mid;
}

有时精度无法确定可以使用固定次数的二分方法,这样得到的精度也会很高。

for (int i = 0; i < 100; ++ i) {
	double mid = (l + r) / 2;
	if(check(mid)) r = mid;
	else l = mid;
}

三分求单峰函数

单峰函数: 拥有唯一的极值点(极大值或极小值),在极值点的左侧严格单调(上升或下降),右侧严格单调(下降或上升)。

对于这种单峰函数 或 单谷函数,我们通常使用三分法求其极值。

以单峰函数 f f f 为例,我们在函数定义域 [ l , r ] [l, r] [l,r] 上任取两点 l m i d lmid lmid r m i d rmid rmid ,把函数分成三段。

  1. f ( l m i d ) > f ( r m i d ) f(lmid) > f(rmid) f(lmid)>f(rmid) ,则 l m i d lmid lmid r m i d rmid rmid 要么同时处于极大值点左侧(单调上升函数段),要么处于极大值点两侧。无论哪种情况下,极大值点都在 l m i d lmid lmid 右侧,可令 l = l m i d l = lmid l=lmid
  2. 同理,若 f ( l m i d ) > f ( r m i d ) f(lmid) > f(rmid) f(lmid)>f(rmid) ,则极大值点一定在 r m i d rmid rmid 左侧,可令 r = r m i d r = rmid r=rmid
  3. f ( l m i d ) = f ( r m i d ) f(lmid) = f(rmid) f(lmid)=f(rmid) 则,任取 l = l m i d l = lmid l=lmid 或者 r = r m i d r = rmid r=rmid 都可。

如果我们在 l m i d lmid lmid r m i d rmid rmid 取三等分点,那么定义域每次都会缩小 1 3 \frac{1}{3} 31 。如果我们取 l m i d lmid lmid r m i d rmid rmid 为二等分点两侧极其接近的地方,那么定义域范围每次缩小 1 2 \frac{1}{2} 21 。总之可以通过 O ( l o g ( n ) ) O(log(n)) O(log(n)) 的时间复杂度即可在指定精度下求出极值。

二分答案转化为判定

简单来说就是我们把最优解的问题,转化为给定一个值 m i d mid mid ,判定是否存在一个可行方案评分达到 m i d mid mid 的问题。

书上的一个经典例子来阐述。

N N N 本书排成一行,已知第 i i i 本的厚度是 A i A_i Ai。把它们分成连续的 M M M 组,使 T T T 最小化,其中 T T T 表示厚度之和最大的一组的厚度。

这种问题就是经典的 “最大值最小” 问题,此时答案就具有单调性了,可用二分转化为判定的最常见、最典型的特征之一。我们把 “把书划分为 M M M 组的方案” 作为定义域,“厚度之和最大的一组的厚度”作为值域,此时问题需要最小化这个厚度,也就是值域要尽可能的小。

那么我们二分这个值域,如果当前判定值 m i d mid mid 满足定义域 M M M 组内,那么值域还可以缩小,反之只能值域往的方向缩。

最后二分到了一个分界点就是答案了。

// 把 n 本书分成 m 组,每组厚度之和 <= x,是否可行
bool check(int x) {
	int res = 1, sum = 0;
	for(int i = 1; i <= n; ++i) {
		if(sum + a[i] <= x) sum += a[i];
		else res+=, sum = a[i];
	}
	return res <= m;
}

int main() {
	// 值域最小肯定是书的厚度最厚的厚度,最大肯定是书的厚度总和。
	
	int l = max_of_ai, r = sum_of_ai;
	
	while(l < r) {
		int mid = (l + r) >> 1;
		if(check(mid)) r = mid;
		else l = mid + 1; 
	}
	cout << l << endl;
	return 0; 
}

【例题】最佳牛围栏
给定正整数数列 A A A ,求一个平均数最大的、长度不小于 L L L 的(连续的)子段。
分析:
二分答案,判定 “是否存在一个长度不小于 L L L 的子段,平均数不小于二分的值”。

书中给出了将问题化繁为简的方法,把数列中每个数减去二分的值,就转化为判定 “是否存在一个长度不小于 L L L 的子段,子段和非负” 。

对于这个问题提出了两个很经典的问题:

  1. 求一个子段,它的和最大,没有 “长度不小于 L L L” 这个限制。
  2. 求一个子段,它的和最大,子段长度不小于 L L L

对于问题 1 1 1 是基于递推的思想,我们设 f ( u ) f(u) f(u) 为以第 u u u 个数结尾的最大连续子段和,因为连续所以只能由 f ( u − 1 ) f(u - 1) f(u1) 递推过来,如果 f ( u − 1 ) < 0 f(u - 1) < 0 f(u1)<0 ,那么很显然它对 f ( u ) f(u) f(u) 没有任何帮助 f ( u ) = a [ u ] f(u) = a[u] f(u)=a[u] ,反正有用, f ( u ) = f ( u − 1 ) + a [ u ] f(u) = f(u - 1) + a[u] f(u)=f(u1)+a[u]

这个算法可以推展到二维上,题目是 最大的和

现在我们看问题 2 2 2 ,子段和可以转化为前缀和相减的形式,即:
max ⁡ i − j ≥ L { A j + 1 + A j + 2 + ⋯ + A i } = max ⁡ L ≤ i ≤ n { s u m i − min ⁡ L ≤ i ≤ i − L { s u m j } } \max_{i - j \ge L}\{A_{j + 1} + A_{j + 2} + \cdots +A_{i}\} = \max_{L\le i \le n} \{sum_i - \min_{L\le i\le i - L}\{sum_j\}\} ijLmax{Aj+1+Aj+2++Ai}=Linmax{sumiLiiLmin{sumj}}

随着 i i i 增长, j j j 的取值范围 0 0 0 ~ i − L i - L iL 每次只会增加 1 1 1 。也就是说每次只会有一个新的取值进入 m i n { s u m j } min\{sum_j\} min{sumj} 的候选集合,所以我们没必要每次循环枚举 j j j ,只需要用一个变量记录当前最小值,每次与新的取值 s u m i − L sum_{i - L} sumiL m i n min min 就可以了。

double ans = -1e10;
double min_val = 1e10;
for(int i = L; i <= N; ++i) {
	min_val = min(min_val, sum[i - L]);
	ans = max(ans, sum[i] - min_val);
}

解决了这个问题后,就可以解决题目的判定问题了。

代码入下:

#include <iostream>
#include <cstring>
#include <algorithm>

using namespace std;
const int N = 100010;
int n, f;
double a[N], S[N];

bool check(double x) {

    for(int i = 1; i <= n; ++i) 
        S[i] = S[i - 1] + a[i] - x;

    double ans = S[f] - S[0];
    double min_e = S[0];

    for(int i = f + 1; i <= n; ++i) {
        min_e = min(S[i - f], min_e);
        ans = max(ans, S[i] - min_e);
    }

    return ans >= 0;
}


int main()
{
    scanf("%d%d", &n, &f);
    for (int i = 1; i <= n; i ++ )
        scanf("%lf", &a[i]);

    double l = - 1e6, r = 1e6;
    double eps= 1e-6;

    while (r - l > eps) {
        double mid = (l + r) / 2;

        if(check(mid)) 
            l = mid;
        else 
            r = mid;
    }


    printf("%d", int(r * 1000));

    return 0;
}

【例题】特殊排序

N N N 个元素,每一堆元素之间的大小关系是确定的,关系不具有传递性。也就是说,元素的大小关系是 N N N 个点 与 N × ( N − 1 ) / 2 N \times (N - 1) / 2 N×(N1)/2 条有向边构成的任意有向图。

然而这是一道交互式试题,这些关系不能一次性得知,你必须通过不超过 10000 10000 10000 次提问,每次提问只能了解某两个元素之间的关系,把这 N N N 个元素排成一行,使得每个元素都小于右边与它相邻的元素。 N ≤ 1000 N \le 1000 N1000

分析:

简述下来,就是有 N N N 个数,数与数的大小可以通过一个内置的函数获得,然后对这 N N N 个数排序。询问次数不能超过 10000 10000 10000

假设前 i − 1 i - 1 i1 个已经排好序列的序列,我们如何快速知道第 i i i 个元素该放在这个序列的那个位置呢。很显然是利用二分,这样对于每个数都可以在 O ( l o g ( n ) ) O(log(n)) O(log(n)) 的时间复杂度获取在那个位置,那么总共的询问次数就肯定不会超出 10000 10000 10000 。最后排序的事件复杂度是 O ( n 2 ) O(n^2) O(n2) 不会超时。

代码如下:

// Forward declaration of compare API.
// bool compare(int a, int b);
// return bool means whether a is less than b.

class Solution {
public:
    vector<int> specialSort(int N) {
        vector<int> ans;
        ans.push_back(1);

        for(int i = 2; i <= N; ++i) {
            int l = 0, r = ans.size() - 1;
            while(l < r) {
                int mid = l + r + 1 >> 1;
                if(compare(ans[mid], i)) l = mid;
                else  r = mid - 1;
            }

            ans.push_back(i);

            for(int j = ans.size() - 2; j > r; -- j) 
                swap(ans[j], ans[j + 1]);

            if(compare(i, ans[r])) swap(ans[r], ans[r + 1]);
        }

        return ans;
    }
};
  • 4
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值