0.概念
二分算法,顾名思义,就是每一次将区间分成 2 个一样的区间。
三分算法,同样顾名思义,就是每一次将区间分成 3 个一样的区间。
三分算法主要用来求解一些关于单调函数的问题。 而且考试中有一定的几率考到。
二分算法主要用来求一些简单的最值问题,例如在一些一次函数上。
三分算法主要用来求一些凹函数和凸函数的最值问题,典型地,在一些二次函数上,甚至是高次函数。
凸函数的定义:
-
学术描述:对此函数进行二次求导,最后的值恒 \(<0\)。
-
人话描述:先上升后下降的函数。
凹函数的定义就是先下降后上升的函数。
当然,函数的形状各种各样,以上的定义不一定是完全正确的。
只需要记住,三分的函数一定是单峰函数。
而且大部分三分都是基于浮点数的。
1.思路
强烈推介学习二分的思想之后再来看这部分。
一开始,仿照二分的思想,我们需要确定一个区间,使得最值一定在这个区间里面。
设区间的左端点为 \(L\),区间的右端点为 \(R\)。
我们在区间里面随便取两个点 \(lmid,rmid\) 使 \(lmid < rmid\),然后获取并比较函数在 \(lmid,rmid\) 的位置的值,记为 \(val_{lmid},val_{rmid}\)(文章以后会继续沿用此定义)。
设我们需要三分的函数是凸函数,如果是凹函数则可以看为反过来。
毕竟我们需要做的题目要么就是凸函数,要么就是凹函数,一定要在思考三分做法的前面思考函数的形状。
有以下三种情况:
第一种情况:\(val_{lmid} < val_{rmid}\)
则若峰值出现在 \(lmid\) 左边的某个位置,一定有一点比其更大(这里有一个现成的例子:\(rmid\)),矛盾。
所以,遇到这种情况时,我们就可以排除掉 \(lmid\) 左边的区间了。
第二种情况:\(val_{rmid} > val_{lmid}\)
则若峰值出现在 \(rmid\) 右边的某个位置,一定有一点更大(\(lmid\)),矛盾。
所以,遇到这种情况时,我们就可以排除掉 \(rmid\) 右边的区间了。
第三种情况:\(val_{lmid} = val_{rmid}\)
这种情况可以直接使用 else
判到第二种情况内。
得到新的 \(L,R\) 之后,再次选 \(lmid,rmid\) 即可。
这不是真正的三分,只是其中的一部分。三分是基于其进行了优化。
这时候,我们观察到一个区间被分为了三个部分:\([L,lmid]\)、\([lmid,rmid]\) 和 \([rmid,R]\)。
而不难注意到每一次都是第一个或者第三个子区间被排除。 因此引出三分的两个优化想法:
优化1:将以上三个区间的长度都尽量逼近至 \(\frac{1}{3} (R-L+1)\)。
优化2:将中间的区间的长度留的极小,无限趋近但不等于 \(0\)。将留下来的继续平分给其他两个区间。
执行这两个优化之后,三分的复杂度可以看成 \(O(\log n)\),其中 \(n = R-L+1\)。
但是因为 \(\log\) 的底数不管是什么值,答案总不会相差太大。所以加上那个优化都一样。
2.代码
浮点数三分:
// 寻找凸函数在[l, r]区间内的最值,eps 表示精度
double ternary_search_max(double l, double r, double eps) {// 当区间长度大于精度要求时持续三分while (r - l > eps) {// 得到两个中间点double lmid = l + (r - l) / 3.0;double rmid = r - (r - l) / 3.0;// 比较两个中间点的函数值,缩小搜索范围if (cal(lmid) < cal(rmid))l = lmid; // 排除左半区间elser = rmid; // 排除右半区间}return r;//返回右端点
}// 寻找凹函数在[l, r]区间内的最值,eps 表示精度
double ternary_search_min(double l, double r, double eps) {// 终止条件与最大值搜索相同while (r - l > eps) {double lmid = l + (r - l) / 3.0;double rmid = r - (r - l) / 3.0;// 比较逻辑与最大值搜索相反if (cal(lmid) > cal(rmid))l = lmid; // 最小值在右半区间elser = rmid; // 最小值在左半区间}return l;//返回左端点
}//实际上返回 l 还是 r 已经不重要了,因为此时 l 和 r 相差不大
整数三分:
// 寻找凸函数在[l, r]区间内的最值
int ternary_search_max(int l, int r) {// 当区间长度大于 3 时持续三分while (r - l > 3) {// 得到两个中间点int lmid = l + (r - l) / 3;int rmid = r - (r - l) / 3;// 比较两个中间点的函数值,缩小搜索范围if (cal(lmid) < cal(rmid))l = lmid;// 排除左半区间elser = rmid;// 排除右半区间}int ans = l;for (int i = l; i <= r; ++i)//这里直接暴力算,因为实际判断太复杂了if (cal(ans) < cal(i))ans = i;return ans;//返回结果
}
3.难点
乍一看,三分的代码以及思路实际上并不难,但是为什么三分的题目经常出现蓝紫题呢?
实际上,三分的难点在于判断函数是一个单峰函数。
面对一个问题,有数学功底的同学都会不难想到先将问题的值抽象成一个函数。
但是大部分人往往到这时就不会做了,反而会因为函数的其他性质被带偏:上贪心、动态规划……从而陷入深潭,拼尽全力无法做出。
但如果这时有人告诉你这是一个单峰函数,你会不会恍然大悟?
有很多正向得出单峰函数的方法。
- 第一种,求二阶导数。
但是如果函数过于复杂,而且分段也无法求出,这样就很难做出问题了。
- 第二种,抽点计数
因为在 NOI 系列竞赛中无法使用几何画板,所以我们就尝试写一个暴力,抽几个点绘制函数图像,看一下到底像不像单峰函数。
- 第三种,证明
根据数学知识,凸函数主要满足以下公式:
当上述公式成立时,这个函数就是凸函数。
当
成立时,这个函数就是凹(也称下凸)函数。
- 第四种,求出峰值并证明
我们还可以对于函数找出一峰点 \(x\),并证明,\(x\) 左边开始下降 or 上升,右边上升 or 下降。
第一种方法较少用,一般以第四种和第二种为主。
因此,想要将三分题目做的得心应手,必须要有很扎实的数学功底。
4.练习
P1883 【模板】三分 | 函数
因为同时考虑 \(100\) 个函数的值过于困难了,所以我们考虑只有两个函数 \(f(x)\) 和 \(g(x)\),并设 \(h(x)=\max(f(x),g(x))\)。
很显然,由于 \(a\ge 0\),则 \(f,g\) 为下凸函数(即凹函数)。我们需要证明 \(h\) 也为下凸函数,即需要证明
这个式子恒成立。
首先根据 \(h\) 的定义,将 \(f(x)\) 和 \(g(x)\) 往里代换:
因为 \(f,g\) 都为下凸函数,则可得:
将两个式子一比较,直接证毕。
考虑扩展到更多函数的情况:将得出来的下凸函数 \(h\) 再次与另一个下凸函数 \(k\) 进行 \(\max\) 合并,同理得到一个新的下凸函数;然后再次进行合并……
因此,可以知道:无论有多少个函数,只要都是下凸的,进行 \(\max\) 合并之后的出来的函数也一定下凸。
#include <bits/stdc++.h>
#define int long long
using namespace std;
int t, n;
const int N = 10010;
const double eps = 1e-9;//eps在不爆精度的情况下尽量小
int a[N], b[N], c[N];double cal(double x) {//计算函数值double ans = -1e18;for (int i = 1; i <= n; i++)ans = max(ans, x * a[i] * x + b[i] * x + c[i]);return ans;
}signed main() {cin >> t;while (t--) {cin >> n;for (int i = 1; i <= n; i++)cin >> a[i] >> b[i] >> c[i];double l = 0, r = 1000;//题目中规定了范围while (r - l > eps) {double lmid = l + (r - l) / 3.0;double rmid = r - (r - l) / 3.0;if (cal(lmid) > cal(rmid))l = lmid;elser = rmid;}printf("%.4lf\n", cal(l));}return 0;
}
CF201B Guess That Car!
简化描述
给出一个 \(n\times m\) 的方阵(行和列数从 \(1\) 开始),每一个格子的半径为 \(4\)(不要问我是从哪里来的,要问就去看样例解释)。每一个格子 \((i,j)\) 的中心都有一个数 \(C_{i,j}\)。
你需要确定一个格点,算出这个格点到每一个格子 \((i,j)\) 的中心点的欧几里得距离 \(d_{i,j}\),并最小化该式子:
思路
此题细节较多,又是中心又是格点,较难实现。注意要仔细阅读题目中的输出格式。
不妨按照输出格式设格点的位置为 \((x,y)\)。
根据中心点的性质,可以算出 \((i,j)\) 格子的中心点的位置为 \((i-0.5,j-0.5)\)。
不妨对 \(d_{i,j}\) 进行拆解:
所以 \(d_{i,j}^2=((x-i+0.5)\times 4)^2+((y-j+0.5)\times 4)^2\)。
因为这时候我们可以分 \(x,y\) 分别计算,直接针对横坐标和纵坐标跑三分即可。
而显然这是两个下凸函数。
#include <bits/stdc++.h>
#define int long long
using namespace std;
int n, m;
const int N = 1010;
int c[N][N];int cal(int x, int val) {//val=1表示行,val=2表示列,下同int ans = 0;for (int i = 1; i <= n; i++)for (int j = 1; j <= m; j++) {if (val == 1)ans += c[i][j] * ((x - i) * 4 + 2) * ((x - i) * 4 + 2);elseans += c[i][j] * ((x - j) * 4 + 2) * ((x - j) * 4 + 2);//套用公式}return ans;
}int smin(int l, int r, int val) {//对ternary_search_max的一些小改版while (r - l > 3) {int lmid = l + (r - l) / 3;int rmid = r - (r - l) / 3;if (cal(lmid, val) > cal(rmid, val))l = lmid;elser = rmid;}int ans = l;for (int i = l; i <= r; i++)if (cal(ans, val) > cal(i, val))ans = i;return ans;
}signed main() {scanf("%lld%lld", &n, &m);//注意使用正确的读入方式for (int i = 1; i <= n; i++)for (int j = 1; j <= m; j++)scanf("%lld", &c[i][j]);int lans = smin(0, n, 1), rans = smin(0, m, 2);printf("%lld\n%lld %lld\n", cal(lans, 1) + cal(rans, 2), lans, rans);return 0;
}
5.凸函数的性质
经过一些的题目练习之后,我们可以总结出凸函数的一些性质:
- 若 \(f(x)\) 是定义在凸集 \(S\) 上的凸函数,对于任意非负数 \(c\ge 0\),函数 \(c f(x)\) 也是凸函数。
- 若 \(f(x),g(x)\) 都是定义在凸集 \(S\) 上的凸函数,则函数 \(f(x)+g(x)\) 也是凸函数。
- 若 \(f(x),g(x)\) 都是定义在凸集 \(S\) 上的凸函数,且 \(g\) 值递增,那么 \(h(x)=g(f(x))\) 凸函数。
- 若 \(f(x)\) 为定义在凸集 \(S\) 上的凸函数,则对于任意实数 \(c\),集合 \(S_c=\{x|x\in S,f(x)\le c\}\) 是凸集。
- 若 \(f(x)\) 为定义在凸集 \(S\) 上的凸函数,则它的任意一个极小点就是它在 \(S\) 上的全局极小点,而且所有极小点的集合是凸集。
- 一个上升函数和一个下降函数取 \(\min\) 或 \(\max\) 都会重新形成一个单峰函数。