二分杂谈

二分杂谈

注:本文内容需要读者已经掌握二分和三分的原理。

整数二分绪论

整数二分,本质上是在一个 00...0011...11 序列中寻找最右的 0 或者最左的 1 的下标。根据不同题目我们有不同的目标。

值得注意的是,我们并不能保证序列中一定存在 0 或者是 1。所以往往需要判无解的情况。

我们固然可以结束二分后对二分的结果再次判断一遍是否合法,但有更好的方法。类似于 lower_boundupper_bound,无解返回 .end()。会在之后详细介绍各种形式的时候进行讲解。

其中,lower_boundupper_bound 本质上都是在找最左的 1

整数二分形式

目前广为流传的二分有三大形式:不记录答案法,记录答案法,二分边界法。会在之后详细介绍。

其中不记录答案法需要具体题目具体分析(《进阶指南》中使用的便是不记录答案法),记录答案法不需要根据题目变化,所以我本人更推荐记录答案法。二分边界法比较接近二分的本质,部分高水平选手很喜欢这种方法。

\(mid\) 的选取

\(mid\) 因为要除 \(2\),这就存在了向下取整,向 \(0\) 取整,向上取整的问题。

\(0\) 取整任何时候都是错误的。最常见的错误写法 int mid=(l+r)/2; 。一般情况下这么写都没有出错的原因是,很少 \(l,r\) 会是负数。

因为除 \(2\) 的本质是向 \(0\) 取整,可以用 \((-3)/2\) 在各自的编译器上验证。\((-3)/2=-1.5\) 向下取整是 \(-2\),向 \(0\) 取整是 \(-1\)

向下取整有两种写法:

  1. int mid=(l+r)>>1;
  2. int mid=l+(r-l)/2;

其中最常用的是第一种写法,第二种写法在迭代器上比较常见,因为迭代器没有加法一说。

向上取整最常用的是基于第一种写法进行修改:int mid=(l+r+1)>>1;

不记录答案法

不记录答案法因为不用变量记录答案,所以需要时时刻刻保证区间 \([l,r]\)包含答案,最后 \(l\) 或者 \(r\) 就是答案。

这里存在一个问题,如果一个序列全是 0 或者全是 1 怎么办?那我们就没办法保证区间中包含一个答案了。

有一种解决方案是二分结束后对答案再次 Check 一次,但有更好的办法。

如果我们要找最左的 1,就在区间 \([1,n]\) 的后面即 \(n+1\) 的位置假设存在一个 1

如果我们要找最右的 0 ,就在区间 \([1,n]\) 的前面即 \(0\) 的位置假设存在一个 0

值得注意的是,这个位置是我们假设出来的,实际上并不存在。所以我们不能 Check 这两个假设出来的位置上的值。即虽然区间上有这个位置,但这个位置的值不能取到,即左闭右开 \([1,n+1)\) 和左开右闭 \((0,n]\)

因为需要时时刻刻保证区间 \([l,r]\)包含答案,所以当区间长度为 \(1\)\(l=r\) 的时候,我们就找到了答案,需要退出了。故循环条件为 \(l<r\)

假设我们要找最左的 1

if(Check(mid)==1):现在已经确定了 \(mid\) 处的值是 1 了,因为我们要时时刻刻保证区间中包含答案,而我们不能保证 \(mid\) 左边一定还有 1。所以需要保留 \(mid\) 在区间中,令 \(r=mid\)

if(Check(mid)==0):现在已经确定了 \(mid\) 处的值是 0 了,不管 \(mid\) 右边到底有没有 1(其实一定有,因为我们假设了 \(n+1\) 的位置为 1 了),\(mid\)\(mid\) 左边一定都不是答案,所以都可以删除掉,所以令 \(l=mid+1\)

考虑 \(mid\) 的取整问题。最坏情况下不存在最左的 1,所以序列为 00..00'1'。其中这个 1 其实是不存在的,所以不能去 Check 他。当考虑到二分快要结束的时候,即 0'1',如果 \(mid\) 是向上取整,会访问到不存在的位置,所以只能让 \(mid\) 向下取整。

int l=1,r=n+1;
while(l<r){
	int mid=(l+r)>>1;
	if(Check(mid)){
		r=mid;
	}
	else{
		l=mid+1;
	}
}
//l 或者 r都是答案,如果等于 n+1 说明无解

找最右的 0 同理,我们在最左边添加一个不存在的 0 保证区间存在一个答案。

if(Check(mid)==0):我们不能保证 \(mid\) 右边还有 0 存在,所以 \(mid\) 需要保留在区间当中,令 \(l=mid\)

if(Check(mid)==1):因为 \(mid\) 不是答案,不需要保留,直接令 \(r=mid-1\)

二分快结束时,序列为 '0'1,这里这个 0 是不存在的,如果向下取整会访问越界,所以找最右的 0 需要让 \(mid\) 使用向上取整。

int l=0,r=n;
while(l<r){
	int mid=(l+r+1)>>1;
	if(Check(mid)){
		l=mid;
	}
	else{
		r=mid-1;
	}
}
//l 或者 r都是答案,如果等于 0 说明无解

记录答案法

记录答案法需要时时刻刻保证区间 \([l,r]\) 处于一个未被确定的状态下,无论一部分区间是合法还是不合法,只要确定下来了都称之为确定。

记录答案法使用一个变量来记录答案,所以只要初始化的时候设立一个特殊值表示无解即可。如果二分结束后记录答案的变量没有被覆盖,就说明一个合法的解都没有。通常使用 \(-1\) 来标记。

记录答案法的 \(mid\) 选取,无论是向下取整还是向上取整都是可以的,因为我们用答案变量记录了无解的情况,所以不需要考虑区间是左开右闭还是左闭右开,区间中任意一个值都是合法的可以被 Check 的。既然每一个值都可以被 Check,那就避免了不记录答案法需要考虑上下取整的问题。

关于循环条件:哪怕区间长度只有 \(1\),那也是未被确定的状态,所以 \(l=r\) 的情况下也不能退出循环。循环条件为 \(l<=r\)

假设我们要找最左的 1

if(Check(mid)==1):现在已经确定了 \(mid\) 处的值是 1 了,因为序列是 00..0011..11,所以 \(mid\) 右边一定都是 1,也被确定下来了,但我们不能确定 \(mid\) 的左边到底是什么情况,所以左边 \([l,mid-1]\) 还处于未被确定的情况。

因为我们找到了一个 1,虽然不能保证是最左的 1,但我们也不能保证不是最左的,所以先把他记录下来,同时 \(mid\)\(mid\) 右边的区间都已经被确定下来了,我们需要时时刻刻保证区间 \([l,r]\) 处于一个未被确定的状态,所以让 \(r=mid-1\),区间收缩到 \([l,mid-1]\)

int ans=-1;
int l=1,r=n;
while(l<=r){
	int mid=(l+r)>>1;
	if(Check(mid)){
		ans=mid,r=mid-1;
	}
	else{
		l=mid+1;
	}
}

找最右的 0 同理:

int ans=-1;
int l=1,r=n;
while(l<=r){
	int mid=(l+r)>>1;
	if(Check(mid)){
		ans=mid,l=mid+1;
	}
	else{
		r=mid-1;
	}
}

二分边界法

我们令左指针 \(l\) 永远指向 0,右指针 \(r\) 永远指向 1

因为不能保证序列一定存在 01,所以最左边添加一个不存在的 0,最右边添加一个不存在的 1。即左开右开。

考虑这个序列,这个二分形式是在寻找最右的 0 和最左的 1 之间的的那个边界,或者说分割线。当 \(r-l==1\)\(l,r\) 挨在一起的时候,我们就找到了这个边界。所以循环条件为 \(r-l>1\) 或者写为 \(l+1<r\)

基于这个循环条件,我们无论如何都不可能访问到最左边和最右边补充的边界,所以上下取整都无所谓。

int l=0,r=n+1;
while(r-l>1){
	int mid=(l+r)>>1;
	if(Check(mid)==1){
		r=mid;
	}
	else{
		l=mid;
	}
}
//最后需要 0 就用 l,需要 1 就用 r

整数二分的二分次数问题

虽然时间复杂度是显然的 \(O(\log{n})\)。但 OI 中的初赛,考研 408 的数据结构题目中或者一些特殊的情况,有时会考察准确的二分次数。

(或者有出题人出的交互题丧心病狂的卡了个位数?)

直接背公式 \(\lfloor \log _{2} {n} \rfloor + 1\)

如何理解这个公式,其实二分和倍增本质上是一回事。都是对于一个 \(k\) 位的二进制数,从高位到低位确定每一位到底是 \(0\) 还是 \(1\)。所以二分次数就是一个数的二进制位数,而二进制位数为 \(\lfloor \log _{2} {n} \rfloor + 1\)

实数二分的精度问题

如果说整数二分是离散下的二分,那实数二分就是连续下的二分了。往往是给定一个函数 \(f(x)\),我们要求出满足 \(f(x)=c\)\(x\) 的值。

这里存在一个问题,实数上存在精度问题,很难严格判断等于。所以我们通常设立一个精度误差 \(eps\)

\(eps\) 是希腊第五个字母 \(\epsilon\)——Epsilon 的缩写,通常表示很小的数量或极限值。

一般而言,题目要求 \(k\) 位小数,通常设置 \(eps=1e-(k+2)\),即 \(10^{-(k+2)}\)。通常循环条件为 \(r-l>eps\)

需要注意,有时会要求 \(f(x)\)\(c\) 之间的差值小于 \(eps\),这时候循环条件就是 \(fabs(f(r)-f(l))>eps\)

通常而言这么设置就就行,非常无脑,但这里深究一下二分次数和确定精度位数之间的关系。

我们假设二分了 \(x\) 次,确定下来的精度位数为 \(10^{-y}\)。不妨二分初始值为 \(1\),即有 \(\frac{1}{2^x} \le 10^{-y}\)

整理后得到 \(2^{x} \ge 10^{y}\)。可以得出 \(y \le x\log_{10}{2}\)。即二分 \(x\) 次可以确定 \(\lfloor x\log_{10}{2} \rfloor\) 位数。这个数大概在 \(0.301029996\),简单理解为确定了 \(\lfloor 0.3x \rfloor\) 位数即可。

那如果题目要求确定 \(y\) 位数,需要二分多少次呢?还是 \(2^{x} \ge 10^{y}\) 这个式子,这回左右同时取以 \(2\) 为底的对数,可以得到 \(x \ge y \log_{2}{10}\)。这个数大概是 \(3.32192809\),一般认为需要二分 \(\lceil 3.3y \rceil\) 次。

需要注意的是,这里的 \(y\) 是精确的位数不是精确的小数,也就是说不是精确到小数点后 \(5\)\(y\) 就一定等于 \(5\)。我们还需要考虑到整数部分的位数。

这样就可以用 for 循环的次数来控制精度,不需要设置 \(eps\) 了。不管题目要求什么位数,在 long long 范围内,循环 \(100\) 次肯定是够的。但用循环次数控制精度容易算错,所以实际当中还是推荐使用 \(eps\) 常量来控制。

整数三分绪论

三分可以求单峰函数或者单谷函数的极值。这里以单峰函数为例进行讨论。

题外话:我在很长一段时间里都以为单峰函数和凸函数是一回事,直到我发现“人”字形的函数也是单峰函数,这不是个凸函数。

需要注意的是,三分要求这个函数(假设是单峰函数)在极值点左侧必须严格上升,极值点右侧必须严格下降,但对极值点本身没有要求。

我将左侧严格上升,极值点处为一个平台,右侧严格下降的这种三分称之为平顶山三分。

不同于二分,二分要求函数可以不严格单调。但三分不行,理由是如果山坡上出现了平台,当 \(lmid\)\(rmid\) 相等时,无法判断是到达了山顶还是在某一侧的山坡上。本文之后会从另一个角度去解释为什么山坡上不能出现平台。

整数三分的一般写法

一般来说,整数三分的框架如下(求单峰函数的极大值):

while(l < r) {
	int lmid = l + (r - l) / 3;
	int rmid = r - (r - l) / 3;
    if(f(lmid) <= f(rmid)){
		l = lmid + 1;
	}
    else{
		r = rmid - 1;
	}
}
return max(f(l),f(r));

这是网上流传很广的一个整数三分框架,但因为我并没有找到整数三分的模板题,对于这份框架我本身是存在一些疑问的。这段内容全部是基于我个人的理论分析,没有经过实测检验,属于存疑内容。

首先是 \(mid\) 的选取,如果我们使用 \(\lceil (r-l)/3 \rceil\),当 \(l,r\) 挨在一起的时候会导致区间错误。所以这里要使用向下取整。直观上理解为我可以每次少抛弃一些区间,但我不能不小心多抛弃。

还是同样的问题,除法本质上是向 \(0\) 取整,但负数的情况比较少见,所以写除 \(3\) 一般情况下不会出现问题。但从严谨角度考虑,我个人认为需要强调一下向下取整。

因为无论什么函数,一定存在极值,所以不存在无解的情况。

最后,我认为三分结束后,\(l,r\) 一定是相等的,对于这份框架里最后的 return max(f(l),f(r)); 我是存在疑问的,我个人认为不需要。我在纸上也手玩了一下左右端点差值为 \(1,2,3\) 的情况,都没有发生 \(l,r\) 不相等的情况。

每次删除了区间的 \(1/3\),所以相当每次保留了区间的 \(2/3\)。假设三分次数为 \(x\) 次,我们有 \(n*(\frac{2}{3})^x \ge 1\),可以得出 \(x= \log _{\frac{3}{2}} n\)

这里有一点很奇怪,根据理论分析,每次选择 \((l+r)/2\)\((l+r)/2+1\) 作为 \(lmid,rmid\) 会常数上更快,这样算出来 \(x= \log _2 n\)。我也向一些群友考证过,确实可以这样写,并且确实更快。但我查到的资料都是除 \(3\),非常的奇怪。

while(l < r) {
	int lmid = ((l+r)>>1)+0;
	int rmid = ((l+r)>>1)+1;
    if(f(lmid) <= f(rmid)){
		l = lmid + 1;
	}
    else{
		r = rmid - 1;
	}
}
return f(l);

三分转二分,二分斜率

对于一个单峰函数来说,左侧一定是增的,右侧一定是降的。而单调递增部分导数大于 \(0\),单调递减部分导数小于 \(0\)

我们重新来看一下对于三分函数的要求,山坡处不可以有平台,但山峰处可以。从导数的角度理解,如果有平台,导数为 \(0\)。我们假设导数大于 \(0\)0,导数小于 \(0\)1,等于 \(0\) 的情况随便放到这两种中的某一种情况里即可。

二分要求序列必须是 00...0011...11,如果在左右山坡处出现平台,根据我们把导数为 \(0\) 算在 0 还是 1 上的不同,就会导致 00..00'11'00..0011..11 的情况存在。所以必须保证山坡处严格单调

对于山峰要求并不严格,因为导数为 \(0\) 可以放到左侧或者右侧,只不过是找最左边的山顶和最右边的山顶的区别。

差分其实就是离散意义下的求导,所以二分斜率做平顶山三分需要做差。我们不妨设为 \(f(mid)-f(mid-1)\)。直到左侧一直不增为止。

这里需要注意,对于区间左端点 \(mid=1\) 时,不存在所谓的 \(mid-1\)。我们当然可以特判左端点的情况,这没有任何问题。但由于求极值问题不存在无解情况,所以可以直接使用不记录答案法。既然在左端点处不希望进行差分,可以使用左开右闭的二分方法避免对左端点进行 Check

int l=1,r=n;
while(l<r){
	int mid=(l+r+1)>>1;
	if(f(mid)-f(mid-1)>0){
		l=mid;
	}
	else{
		r=mid-1;
	}
}
//l 或者 r都是答案

实数三分

除非真的可以对这个函数求导,不然连续意义下我们不能用二分斜率的方法进行求解了。而大多数题目要求的函数,都是无法求导的函数。

我们把实数二分和整数三分结合一下,就可以得出:

double l=0,r=100;
while(l + eps < r) {
	int lmid = l + (r - l) / 3.0;
	int rmid = r - (r - l) / 3.0;
    if(f(lmid) <= f(rmid)){
		l = lmid;
	}
    else{
		r = rmid;
	}
}

这里需要注意,虽然我写的是除 \(3.0\),但根据整数三分章节的最后部分,选择除 \(2.0\) 然后加减 \(eps\) 会常数更低收敛更快。

while (r - l > eps){
	double mid = (l + r) / 2.0;
	if (f(mid - eps) < f(mid + eps)) {
		l = mid; // 这里不写成mid - eps,防止死循环;可能会错过极值,但在误差范围以内所以没关系
	}
	else {
		r = mid;
	}
}

常数优化,黄金分割 0.618 法

每次三分都要求解两个点的函数值,这太慢了。那能不能上一次求解的函数值,在下一次三分可以复用呢?

我们假设上一次三分区间为 \([l,r]\),求解的两个点为 \(lmid,rmid\)。下一次三分区间为 \([l,rmid]\),求解的点为 \(lmid',rmid'\)

\(rmid\) 自然不可能再复用,因为 \(rmid\) 是区间的端点。那只剩下 \(lmid\) 还有一些可能。\(lmid\) 也不可能在 \(lmid'\) 上复用,因为区间缩小了,不可能 \(lmid'\) 不变。所以现在就是要求出一个等式,让 \(lmid=rmid'\)

我们不妨设区间的长度为 \(len=r-l\),我们有 \(lmid=l+len*\alpha\)\(rmid=r-len*\alpha\)。我们需要求解一下比率 \(\alpha\) 是多少。普通的三分这个比率是 \(1/3\)

因为每次比率为 \(\alpha\),所以每次三分也会删除掉 \(len*\alpha\) 的长度,即保留下来了 \(len*(1-\alpha)\) 长度区间。

已知上层三分:

  • 区间 \([l,r]\)
  • 长度 \(len=r-l\)
  • \(lmid=l+len*\alpha\)
  • \(rmid=r-len*\alpha\)

已知下层三分:

  • 区间 \([l,rmid]\)
  • 长度 \(len'=len*(1-\alpha)\)
  • \(lmid'=l+len'*\alpha\)
  • \(rmid'=rmid-len'*\alpha\)

求解:\(lmid=rmid'\),即 \(l+len*\alpha=rmid-len'*\alpha\)

\(rmid,len'\) 带入后得:\(l+len*\alpha=r-len*\alpha-len*(1-\alpha)*\alpha\)

\(r-l=len\) 带入后得:\(len * \alpha ^ 2 - 3 * len * \alpha + len = 0\),因为 \(len\) 不等于 \(0\),消去 \(len\) 后得 \(\alpha ^ 2 - 3 * \alpha + 1 = 0\)

解得 \(\alpha_1=\frac{3+\sqrt{5}}{2},\alpha_2=\frac{3-\sqrt{5}}{2}\)。因为 \(0<\alpha<1\),所以 \(\alpha=\frac{3-\sqrt{5}}{2}\)

如果不认识这个数字,可以考虑令 \(\alpha'=1-\alpha\),解得 \(\alpha'=\frac{\sqrt{5}-1}{2}\),正好是黄金分割比 \(0.618\)

现在每次保留的区间长度为 \(0.618n\),比 \(\frac{2}{3}n\) 收敛更快,同时每次只需要求出一个点的值,所以和普通三分比常数非常小。

const double EPS=1e-6;
const double ALPHA=(3.0-sqrt(5.0))/2.0;
/*====================*/
double f(double x);
/*====================*/
void Solve(void)
{
	double l = 0, r = 1000;
    double lval,rval;int flag=0;
	while(true)
	{
		double lmid = l + (r - l) * ALPHA;
		double rmid = r - (r - l) * ALPHA;
		if(flag==0){
			lval=f(lmid),rval=f(rmid);
		}
        if(flag==+1){
			rval=lval,lval=f(lmid);
		}
		if(flag==-1){
			lval=rval,rval=f(rmid);
		}
		if(fabs(lval-rval)<EPS)break;//题目要求输出 f(x) 的值,不能简单的用 l,r 来控制精度。
		if (lval < rval)
		{
			r = rmid,flag=+1;
		}
		else
		{
			l = lmid,flag=-1;
		}
	}
	printf("%.4f\n", lval);
}

后记

本文主要是受到《浅谈二分的边界问题》一文启发,后来我本人也在二分的边界问题上发现一直没有整明白过,于是准备好好的梳理一下和二分有关的内容。

主要都是我在群里和群友讨论的时候,学到的一些内容,群友对本文的贡献功不可没。我只不过是把群友的话翻译整理了一遍仅此而已。

我也没想到一不小心就写出来了接近 1w 字。因为恶魔妹妹本人很菜,一些内容都是我自己思考的结果,可能会有错误的地方,欢迎一起讨论。

最后,宣传一下我的群:欢迎加群 “菜菜园子” 730602769。

~By ProtectEMmm,2025年4月24日

posted @ 2025-04-24 16:57  ProtectEMmm  阅读(584)  评论(0)    收藏  举报