【基础】三分
想着一直都没有写过三分的模板,每次都是用for循环收尾,习惯确实不太好。
三分法求先增后减或者先减后增函数的最值。其实都没必要是上凸或者下凸的函数。
下面是每次缩减1/3长度的写法,比较容易理解,面对负数等情况也不容易出问题。
// x = 4, f(x) = 20
ll f (ll x) {
return -x * (x - 9);
}
ll ternary_search () {
ll L = -1e9, R = 1e9;
while (R - L + 1 >= 3) {
printf ("[%lld, %lld]\n", L, R);
ll M1 = L + (R - L + 1) / 3;
ll M2 = R - (R - L + 1) / 3;
// 如果f是上凸/先增后减函数,寻找“最靠左的”最大值,则为<号
// 如果f是下凸/先减后增函数,寻找“最靠左的”最小值,则为>号
// 如果要改成“最靠右的”最值,添加=号
if (f (M1) < f (M2)) {
L = M1;
} else {
R = M2;
}
}
// 如果f是上凸/先增后减函数,寻找“最靠左的”最大值,则为<号
// 如果f是下凸/先减后增函数,寻找“最靠左的”最小值,则为>号
// 如果要改成“最靠右的”最值,添加=号
if (f (L) < f (R)) {
L = R;
}
printf ("x = %lld, f(x) = %lld\n", L, f (L));
return L;
}
但其实对于中间点的选取,可以取接近1/2的位置,因为本质上凸函数最多存在一段单调递增的和一段单调递减的(对于上凸/先增后减函数,如果有减区间,一定是先增后减的),通过判断相邻的两个点的大小即可知道目前在增区间还是减区间。然后利用移位合并各种边界情况,写的跟二分一模一样,因为本质上就是二分最后一个单调增的位置。
// x = 4, f(x) = 20
ll f (ll x) {
return -x * (x - 9);
}
ll ternary_search () {
ll L = -1e9, R = 1e9;
while (L < R) {
printf ("[%lld, %lld]\n", L, R);
// 有负数,只能用移位不能用除法
ll M = (L + R) >> 1;
// 如果f是上凸/先增后减函数,寻找“最靠左的”最大值,则为<号
// 如果f是下凸/先减后增函数,寻找“最靠左的”最小值,则为>号
// 如果要改成“最靠右的”最值,添加=号
if (f (M) < f (M + 1)) {
L = M + 1;
} else {
R = M;
}
}
printf ("[%lld, %lld]\n", L, R);
printf ("x = %lld, f(x) = %lld\n", L, f (L));
return L;
}
注意这里的M的写法需要是移位而非除法,C++的除法是向0取整,而C++的移位是向负无穷取整。简单理解就是-1 >> 1还是-1,但是-1 / 2为0。
这里并不需要像二分那样面对“最靠右的”值时修改M的算法。如果修改为M = (L + R + 1) >> 1的话会导致M + 1越界R。这里只需要在if中添加一个等于号即可变成最靠右的写法(因为两个选择都会让L和R至少步进1,这个和二分并不一样)。边界情况就是L == R - 1的时候,此时一定要让M == L,然后 M + 1 == R,所以为了考虑负数要使用移位的方法。
相对的,二分也可以用相似的方法合并,通过在check函数前增加!取反来统一第一个选择支移动L,第二个选择支移动R,但是取反之后为了保证步长至少为1,所以M的取值还是要考虑偏左还是偏右。但是没必要,已经写习惯了这么久了。
有一种新的二分的写法,说不定以后有用:
int L = 0, R = 1e9;
int ans = -1;
while (L <= R) {
int M = (L + R) >> 1;
if (check (M)) {
ans = max (ans, M);
L = M + 1;
} else {
R = M - 1;
}
}
这种写法可以处理不存在答案的情况,而且不再需要区分M怎么取。最重要的是,LR是待搜索区间的长度,意味着区间长度衰减得比我习惯的写法快。对于某些特殊的题可能可以有常数级别的优化,毕竟check不见得是O(1)的操作,能省就省。