二分与三分

问题引入

假如计算机随机生成了一个在 \([1,100]\) 之间的数字 \(x\),由你来猜,但是只给你 \(8\) 次机会,每次会提醒你猜的数比生成的数大还是小,你如何保证一定能在 \(8\) 次以内猜中?

  • 策略一:从小往大依次猜 \(1,2,3,\dots\),最坏情况下,如果数字是 \(100\),你需要猜 \(100\) 次!

  • 策略二:从大往小猜,情况如上,最坏情况是 \(1\),也需要猜 \(100\) 次!

  • 策略三:随机猜,更不好把握次数!

我们考虑这个问题的时候,不妨反过来想,如何能够有把握地淘汰尽可能多的数?如果每次我们都猜正中间的数字,则我们一定能淘汰掉一半!

  • 策略四:对于当前剩下的区间 \([l,r]\),我们猜第 \(mid=(l+r)/2\) 个数字
    1. 如果 \(a[mid]<x\),则淘汰掉 \([l,mid]\) 区间内所有的数,在 \([mid+1, r]\) 区间内继续猜;
    2. 如果 \(a[mid]>x\),则淘汰掉 \([mid, r]\) 区间内所有的数,在 \([l,mid-1]\) 区间内继续猜;
    3. 如果 \(a[mid]==x\),结束。

这种策略,我们称为“二分查找”,由于每次都可以使查询的区间缩小一半,所以对于 \(n\) 个元素的区间,其查询效率为 \(O(log\ n)\)

大家需要注意,使用“二分查找”的前提是:数组是有序的,通常在使用前可能需要先排序

二分查找

二分查找是一个基础的算法,也叫折半查找。

二分查找就是将查找的数值和子数组的中间值做比较:

如果被查找的键等于中间值,找到元素,算法结束;

如果被查找的键小于中间值,就在子数组中继续查找;

如果被查找的键大于中间值,就在子数组中继续查找;

所谓中间值,就是此次查找区间内位于正中间的数值,如区间为[l,r],则中间值的位置(l+r)/2

下面是一个查找字母 PQ 的过程:

例:输入一个 \(100\) 以内的整数 \(k\),通过二分查找猜出 \(k\) 的值,看看用了几次。

#include <iostream>
int a[107];
int main() {
	for (int i = 1; i <= 100; ++i)
		a[i] = i;
	int k;
	scanf("%d", &k);
	int lo = 1, hi = 100, cnt = 0;
	while (lo <= hi) {
		int mid = (lo + hi) >> 1;
		printf("%d: %d\n", ++cnt, mid);
		if (a[mid] == k) return 0;
		if (a[mid] < k) lo = mid + 1;
		else hi = mid - 1;
	}
	return 0;
}
  • 有时,我们并非二分查找某个具体的值,而是去查找最大数的最小值,或最小数的最大值,这时候,我们通常只有在二分查找结束的时候才能获取数值。
#include <iostream>
int a[107];
bool valid(int k) {
	// 判断k是否是合法的,如果合法返回true,否则返回false
}
int main() {
	int n, k;
	scanf("%d%d", &n, &k);
	for (int i = 1; i <= n; ++i)
		scanf("%d", a + i);
	int left = 1, right = 100;
	while (left <= right) {
		int mid = (left + right) >> 1;
		// 求满足条件的最大值
		if (valid(mid)) left = mid + 1;
		else right = mid - 1;
	}
	printf("%d\n", right);
	return 0;
}
#include <iostream>
int a[107];
bool valid(int k) {
	// 判断k是否是合法的,如果合法返回true,否则返回false
}
int main() {
	int n, k;
	scanf("%d%d", &n, &k);
	for (int i = 1; i <= n; ++i)
		scanf("%d", a + i);
	int left = 1, right = 100;
	while (left <= right) {
		int mid = (left + right) >> 1;
		// 求满足条件的最小值
		if (valid(mid)) right = mid - 1;
		else left = mid + 1;
	}
	printf("%d\n", left);
	return 0;
}

简单地记作:求最大数的最小值是l,求最小数的最大值是r

《算法进阶指南》

书中李煜东给出了另一种颇为巧妙的方法:

//在单调递增序列 a 中查找 >=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 中查找 <=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];

不难发现:mid = (l+r) >> 1 不会取到 rmid = (l + r + 1) >> 1 不会取到 l

我们可以利用这一性质来处理无解的情况,把最初的二分区间 \([1,n]\) 分别扩大为 \([1,n+1]\)\([0,n]\),把 \(a\) 数组的一个越界的下标包含进来。如果最后二分终止于扩大后的这个越界下标上,则说明 \(a\) 中不存在所求的数。

总而言之,正确写出这种二分的流程是:

  1. 通过分析具体问题,确定左右半段哪一个是可行区间,以及 mid 归属哪一半段;

  2. 选择 mid = (l + r) >> 1, r = mid, l = mid + 1mid = (l + r + 1) >> 1, r = mid - 1, l = mid 两个配套形式之一;

  3. 二分终止条件是 l == r,该值就是答案所在位置。

本书使用的这种二分方法的优点是:

始终保持答案位于二分区间内,二分结束条件对应的值恰好在答案所处位置,还可以很自然地处理无解的情况,形式优美。唯一的缺点是由两种形式共同组成,需要认真考虑实际问题选择对应的形式。

STL中的二分查找

需要 #include <algorithm>

  1. lower_bound(spos, endpos+1, value):返回 \([spos, endpos]\) 中第一个大于等于 \(value\)地址不是数组下标);
  2. upper_bound(spos, endpos+1, value):返回 \([spos, endpos]\) 中第一个大于 \(value\)地址不是数组下标);

如果想要得到数组下标,需要减去数组的首地址

其他二分写法

初赛中的“阅读程序”“完善程序”可能会有关于二分的代码,因此多接触不同的二分写法是有必要的。

推荐阅读:《二分查找有几种写法?它们的区别是什么?》

三分查找介绍

在区间内用 两个 mid 将区间分成三份,这样的查找算法称为三分查找,也就是三分法,三分法常用于求解单峰函数的最值。

二分查找所面向的搜索序列的要求是:具有单调性(不一定严格单调);没有单调性的序列不能使用二分查找。

与二分查找不同的是,三分法所面向的搜索序列的要求是:序列为一个 凸性函数。通俗来讲,就是该序列 必须有一个最大值(或最小值),在最大值(最小值)的左侧序列,必须满足不严格单调递增(递减),右侧序列必须满足不严格单调递减(递增)。如下图,表示一个有最大值的凸性函数:

如图所示,已知左右端点 \(L,R\),要求找到蓝点的位置。

思路:通过不断缩小 [L,R] 的范围,无限逼近蓝点。

做法:

  1. 先取 [L,R] 的中点 mid

  2. 再取 [mid,R] 的中点 mmid

  3. 通过比较 f(mid)f(mmid) 的大小来缩小范围,当最后 L=R-1 时,再比较下这两个点的值,我们就找到了答案。

  1. f(mid) > f(mmid) 的时候,我们可以断定 mmid 一定在蓝点的右边。

反证法:

假设 mmid 在蓝点的左边,则 mid 也一定在蓝点的左边,又由 f(mid) > f(mmid) 可推出 mmid < mid,与已知矛盾,故假设不成立。

所以,此时可以将 R = mmid 来缩小范围。

  1. f(mid) < f(mmid) 的时候,我们可以断定 mid 一定在蓝点的左边。

反证法:

假设 mid 在蓝点的右边,则 mmid 也一定在蓝点的右边,又由 f(mid) < f(mmid) 可推出 mid > mmid,与已知矛盾,故假设不成立。

同理,此时可以将 L = mid 来缩小范围。

int SanFen(int l,int r) {//找凸点
    while(l < r-1) {
        int mid  = (l+r)/2;
        int mmid = (mid+r)/2;
        if( f(mid) > f(mmid) )
            r = mmid;
        else
            l = mid;
    }
    return f(l) > f(r) ? l : r;
}

double 类型的三分也基本相同

算法介绍

  1. 先将区间三分,每个区间的长度为 1/3(right-left)

mid1 = left + (right-left)/3, mid2 = right - (right-left)/3;

  1. 比较 mid1 和 mid2 谁更靠近极值,如果 mid1 更靠近极值,右区间改为 mid2,否则左区间改为 mid1 (后面的代码都是以求最大值为例)

if (calc(mid1) < calc(mid2)) left = mid1;

else right = mid2;

  1. 重复 1,2 过程,直到不满足 left + EPS < right,也就是找到最值(EPS=epsilon)。
const double EPS = 1e-6;//0.000001
while (l + EPS < r) {
    ml = l + (r-l) / 3;
    mr = r - (r-l) / 3;
    if (calc(ml) < calc(mr)) l = ml;
    else r = mr;
}

还有一种写法,就是两次二分。

double calc(double a) {}
void solve() {
    double l, r, ml, mr;
    l = MIN, r = MAX;
    while (l + EPS < r) {
        ml = (l + r) / 2;
        mr = (ml + r) / 2;
        if (calc(ml) >= calc(mr)) r = mr;
        else l = ml;
    }
}

如何判断一个函数是单峰函数?

在信息学中,一个区间内,零个或一个偶函数与零个或一个单调函数的复合,通常构成的就是一个单峰函数。注意:单调函数本身也是单峰函数。

练习1:三分法

#include<cstdio>
const double EPS = 1E-12;
int n;
double a[20];
double solve(double x) {
    double sum = 0;
    //x^3-3x^2-3x+1=((x-3)*x-3)*x+1
    for(int i = 1; i <= n; ++i) {
        sum = sum + a[i];
        if (i < n) sum *= x;
    }
    return sum;
}
int main() {
	double l, r;
    scanf("%d%lf%lf", &n, &l, &r); ++n;
    for(int i = 1; i <= n; ++i)
        scanf("%lf", a + i);
    while(r-l >= EPS) {
        double ml = (l + r) / 2;
        double mr = (ml + r) / 2;
        if(solve(ml) - solve(mr) >= EPS)
            r = mr;
        else
            l = ml;
    }
    printf("%.5lf\n", l);
    return 0;
}

练习2:传送带

此题需要在二个维度上嵌套三分。

#include <cstdio>
#include <cmath>
#include <algorithm>
typedef std::pair<double, double> Loc;
Loc a, b, c, d;
#define x first
#define y second
const double EPS = 1e-5;
double p, q, r;
double dis(Loc x1, Loc x2) {
    return sqrt((x1.x-x2.x)*(x1.x-x2.x) + (x1.y-x2.y)*(x1.y-x2.y));
}
Loc find(Loc x1, Loc x2, double k) {
    //通过比例算出具体点的坐标位置
    Loc p;
    p.x = (x2.x-x1.x)*k + x1.x;
    p.y = (x2.y-x1.y)*k + x1.y;
    return p;
}
double get_dis(double x, double y) {
    //在确定AB上及CD上的位置后,计算出实际路程长度
    Loc x1 = find(a, b, x), x2 = find(c, d, y);
    return dis(a, x1)/p + dis(x1, x2)/r + dis(x2, d)/q;
}
double calc(double x) {
    ////对CD段的位置进行比例三分查找
    double l = 0, r = 1;
    while (l + EPS <= r) {
        double ml = l + (r-l)/3, mr = r - (r-l)/3;
        if (get_dis(x, ml) > get_dis(x, mr)) l = ml;
        else r = mr;
    }
    return get_dis(x, l);
}
int main() {
    scanf("%lf%lf%lf%lf", &a.x, &a.y, &b.x, &b.y);
    scanf("%lf%lf%lf%lf", &c.x, &c.y, &d.x, &d.y);
    scanf("%lf%lf%lf", &p, &q, &r);
    double l = 0, r = 1;
    while (l + EPS <= r) {
        //对AB段的位置进行比例三分查找
        double ml = l + (r-l)/3, mr = r - (r-l)/3;
        if (calc(ml) > calc(mr)) l = ml;
        else r = mr;
    }
    printf("%.2f", calc(l));
    return 0;
}
posted @ 2024-04-11 14:18  飞花阁  阅读(82)  评论(0)    收藏  举报
//雪花飘落效果