从二分到三分:单峰函数极值搜索算法详解与实战
2026/8/27 3:38:21 网站建设 项目流程

1. 从“二分”到“三分”:当函数不再单调

在算法竞赛和编程面试中,二分查找(Binary Search)几乎是每个程序员都耳熟能详的利器。它之所以强大,是因为它完美地利用了单调性——无论是数组的单调递增,还是函数值的单调变化,只要我们能确定目标值在某个区间内,并且这个区间内的函数是单调的,我们就能以 O(log n) 的效率逼近答案。二分查找的核心思想是“每次排除一半的搜索空间”,这听起来既高效又优雅。

但现实世界中的问题往往比单调性更复杂。想象一下,你是一个无人机飞手,正在规划一条从A点到B点的飞行路径,目标是让总能耗最低。能耗可能和飞行速度、飞行高度有关。速度太慢,飞行时间太长,基础能耗高;速度太快,空气阻力呈平方甚至立方增长,能耗也会剧增。这中间必然存在一个“甜蜜点”——一个让总能耗最低的最佳速度。这个“总能耗-速度”函数,很可能就是一个单峰函数:函数值先下降,到达一个最低点(谷底)后,再开始上升。这个最低点,我们称之为极小值点。反过来,如果函数先上升后下降,那个最高点就是极大值点。单峰函数是凸函数或凹函数在特定区间上的表现。

面对一个单峰函数,传统的二分查找就失灵了。因为你无法仅仅通过比较中点的函数值,就确定目标极值点是在左半边还是右半边。例如,你比较了区间中点mid和其右侧一点mid+1的函数值,发现f(mid) < f(mid+1)。在单调递增函数中,这意味目标在右侧。但在单峰函数中,这有两种可能:要么midmid+1都位于极值点左侧(函数正在上升),要么mid位于极值点右侧而mid+1更靠右(函数正在下降)。仅凭两个点的比较,信息不足以做出决策。

这就是“三分搜索”(Ternary Search)登场的时刻。它的核心思想可以概括为:既然两个点不够,那我就用三个点来“感知”函数的走势。通过选取区间内的两个内分点,比较它们的函数值,我们就能可靠地判断极值点位于哪三分之一区间内,从而每次缩小三分之一的搜索空间。虽然每次迭代的收缩比例(2/3)略低于二分查找的(1/2),导致理论常数稍大,但它成功地将二分查找“每次排除一半”的思想,推广到了“每次排除三分之一”来解决单峰函数极值问题。对于无法求导或导数计算复杂的离散函数、模拟函数,三分法提供了一种稳定、通用的数值求解方案。

2. 三分法的核心原理:如何用三个点确定走势

理解三分法,关键在于弄清楚它如何通过两个内分点来确定极值点的位置。我们以在区间[l, r]上寻找极小值点为例(寻找极大值点的逻辑完全对称)。

首先,我们在区间内选择两个点m1m2,通常将它们选在区间的三等分点附近,这也是“三分”名称的由来。更常见的、精度更高的做法是选取两个黄金分割点,但三等分点更直观。设:m1 = l + (r - l) / 3m2 = r - (r - l) / 3显然,l < m1 < m2 < r

现在,我们计算f(m1)f(m2)。对于寻找极小值的情况,逻辑如下:

  1. 如果f(m1) < f(m2)

    • 这告诉我们,在m1m2这两个点中,m1处的函数值更小。
    • 考虑极小值点的位置。由于函数是单峰的(先降后升),极小值点左侧函数下降,右侧函数上升。
    • 如果极小值点在m2的右侧,那么从m1m2再到极小值点,函数应该先上升(过m2后)?不对,如果极小值点在m2右边,那么m1m2都位于极小值点左侧,函数在[m1, m2]区间应该是单调递增的(因为还没到谷底)。但这与f(m1) < f(m2)矛盾吗?不矛盾,f(m1) < f(m2)正说明在[m1, m2]区间函数是递增的!所以,m1m2都在极小值点左侧是可能的。
    • 然而,还有另一种可能:极小值点在m1m2之间。此时,m1在极小值点左侧(下降段),m2在极小值点右侧(上升段),同样满足f(m1) < f(m2)(因为m1m2更靠近谷底?不一定,如果m1离谷底很近而m2刚过谷底,f(m1)可能小于f(m2))。
    • 关键推理来了:无论哪种情况,极小值点都不可能位于m2的右侧。为什么?
      • 假设极小值点在m2右侧。那么m1m2都位于极小值点左侧的“下降-上升”曲线的上升段之前(即它们都在谷底左侧的下降段)。在下降段,函数值随着x增加而减小。由于m1 < m2,我们应该有f(m1) > f(m2)。但这与我们已知的f(m1) < f(m2)相矛盾。
    • 因此,当f(m1) < f(m2)时,我们可以安全地排除掉(m2, r]这个区间,将搜索范围缩小到[l, m2]。因为极小值点一定不在m2的右边。
  2. 如果f(m1) > f(m2)

    • 同理分析,此时m2处的函数值更小。
    • 运用对称的逻辑,我们可以得出结论:极小值点不可能位于m1的左侧。因为如果那样,m1m2将都位于极小值点右侧的上升段,应有f(m1) < f(m2),矛盾。
    • 因此,我们可以排除掉[l, m1)这个区间,将搜索范围更新为[m1, r]
  3. 如果f(m1) == f(m2)

    • 在连续函数中,这种情况通常意味着m1m2位于极小值点的两侧,且距离谷底“距离”相等(函数值相同)。此时,极小值点一定在[m1, m2]之间。我们可以安全地缩小区间为[m1, m2]
    • 在离散或存在平台区的函数中,我们可以将其归入上述任意一种情况处理,通常不会影响最终收敛到极值点所在的区间。

通过这样一轮比较,我们确保了极值点一定留在新的、更小的搜索区间内。不断重复这个过程,直到区间长度小于我们预设的精度eps,此时区间内的任意一点(通常取中点)都可以作为极值点的近似解。

注意:上述推导基于函数是严格单峰的假设。如果函数存在平台(一段相等的值)或者多个极值点,三分法可能会失效或收敛到错误的极值点。因此,应用三分法的前提是确认或强烈怀疑目标函数在搜索区间内是单峰的

2.1 整数三分:离散世界中的极值搜索

当定义域是整数时,我们进行的是整数三分。此时,区间[l, r]的边界和中间点都是整数。算法需要稍作调整,因为当区间长度很小时,传统的三等分点可能无法有效区分。

整数三分的模板通常这样写(寻找极小值):

while (r - l > 2) { // 当区间长度大于2时继续 int m1 = l + (r - l) / 3; int m2 = r - (r - l) / 3; if (f(m1) < f(m2)) { r = m2; // 排除(m2, r] } else { l = m1; // 排除[l, m1) } } // 此时区间[l, r]长度<=3,暴力枚举剩下的点找最小值 int ans = f(l); for (int i = l + 1; i <= r; ++i) { ans = min(ans, f(i)); }

循环条件r - l > 2确保了在循环体内,m1m2是两个不同的整数。退出循环后,区间内最多剩下3个整数点,直接比较即可,避免了因精度问题导致的死循环。

3. 三分搜索的通用模板与实现细节

下面提供一个用于求解连续函数极小值的、基于精度控制的浮点数三分模板(C++实现)。这个模板清晰地区分了l,r,m1,m2,并包含了防止无限循环的迭代次数限制。

// 三分搜索模板 (求凸函数极小值) double ternary_search(double l, double r) { const double eps = 1e-8; // 精度,根据题目要求调整 const int iter_limit = 100; // 迭代次数限制,防止死循环 int iter = 0; while (r - l > eps && iter < iter_limit) { iter++; double m1 = l + (r - l) / 3.0; double m2 = r - (r - l) / 3.0; if (f(m1) < f(m2)) { r = m2; // 极值点在 [l, m2] } else { l = m1; // 极值点在 [m1, r] } } return (l + r) / 2.0; // 返回近似极值点 }

关键参数解析:

  1. 精度eps:这是循环终止的条件。当搜索区间长度r - l小于eps时,我们认为已经找到了足够精确的极值点位置。eps的设置需要权衡精度和效率。对于大多数题目,1e-81e-7是一个安全的选择。如果函数值变化非常平缓,可能需要更小的eps;如果对精度要求不高,可以适当调大以加快速度。

  2. 迭代次数限制iter_limit:这是一个重要的安全措施。理论上,三分法总会收敛。但在实际编程中,由于浮点数的精度限制,可能会出现r - l始终无法小于eps的情况(例如,在极值点附近函数非常平坦)。设置一个迭代上限(如100或200次),可以强制循环退出,避免超时。100次迭代足以将初始区间长度缩小到原来的(2/3)^100 ≈ 3e-18倍,对于绝大多数问题足够了。

  3. 返回值:通常返回当前区间[l, r]的中点。因为当循环结束时,极值点必然落在这个很小的区间内,中点是一个合理的近似。

模板的使用与变体:

  • 求极大值:只需将比较条件反转即可。即如果f(m1) > f(m2),则r = m2;否则l = m1。或者更简单的方法:定义一个g(x) = -f(x),然后对g(x)求极小值,结果是一样的。
  • 整数三分:如前所述,循环条件改为while (r - l > 2),并在循环后暴力枚举剩余点。
  • 黄金分割比例:更优的选择点比例是黄金分割比φ ≈ 0.618。即取m1 = l + (r - l) * (1 - φ),m2 = l + (r - l) * φ。这样每次迭代的区间收缩比例是固定的φ,理论上比三等分法效率稍高一点,但代码复杂度略有增加,三等分法在竞赛中已完全够用。

4. 实战应用:识别问题与构建目标函数

三分法本身是一个框架,其威力完全体现在你如何将它应用于具体问题。这分为两步:1) 识别出问题可以转化为单峰函数求极值;2) 正确构造出这个目标函数f(x)

4.1 经典例题解析:士兵找宿舍问题

问题描述:一条笔直的大路上有N个士兵,他们的位置已知(坐标p[i])。现在要建立一个宿舍,使得所有士兵从宿舍出发再回到宿舍(比如早上从宿舍去站岗,晚上回宿舍)所走的总距离最小。士兵可以同时移动,求这个最小总距离。

分析与建模

  1. 决策变量:宿舍的位置,记作坐标x
  2. 目标函数:总距离f(x)。对于位置在p[i]的士兵,他需要走|p[i] - x|的距离到达宿舍,再走同样的距离回来,所以贡献是2 * |p[i] - x|
  3. 因此,f(x) = 2 * Σ|p[i] - x|
  4. 函数性质:绝对值函数|p[i] - x|是一个 V 型函数(先减后增),多个 V 型函数的和,f(x)是一个分段线性凸函数(形状像一个碗)。它在整个实数域上是凸的,并且最小值点就在所有士兵位置的中位数处。但即使你不知道中位数这个结论,你也可以观察到,f(x)是一个明显的单峰函数(凸函数),因此可以直接在[min(p), max(p)]区间上使用三分法求其最小值。

代码框架

double p[N]; // 士兵位置 int n; double f(double x) { double sum = 0; for (int i = 0; i < n; ++i) { sum += fabs(p[i] - x) * 2; // 往返距离 } return sum; } double solve() { double l = *min_element(p, p + n); double r = *max_element(p, p + n); return ternary_search(l, r); // 使用前面的三分模板 }

4.2 进阶例题:光照强度问题(Lighting)

问题描述:你有一条长度为 L 的线段。上面有 N 盏灯,第 i 盏灯在位置a[i],照明强度为I[i]。在位置 x 处接收到的光照强度定义为:S(x) = Σ( I[i] / ( (x - a[i])^2 + d^2 ) ),其中 d 是一个给定的常数(防止分母为零)。现在要在该线段上找一个点,使得该点的光照强度最小。求这个最小强度值。

分析与建模

  1. 决策变量:点的位置x
  2. 目标函数f(x) = S(x),即该点的总光照强度。
  3. 函数性质:每一项I[i] / ((x - a[i])^2 + d^2)都是一个关于x的“钟形”函数(类似正态分布曲线),在x = a[i]处取得最大值I[i]/d^2,向两边对称衰减。多个这样的“钟形”函数相加,得到的f(x)是一个连续、平滑、可导的函数。它的图像是多个波峰叠加。我们需要找的是最小值
  4. 关键洞察:虽然整个函数可能有多个局部极小值(因为波峰叠加会产生波谷),但题目通常保证(或通过数据范围暗示)在给定的线段[0, L]上,f(x)是一个单谷函数(凸函数)。这是因为当灯的位置分布相对均匀时,叠加效应会使函数两端较高,中间较低。因此,我们可以假设在[0, L]区间上,f(x)是单峰的(有一个极小值),从而应用三分法。
  5. 三分搜索:在[0, L]区间上对f(x)进行三分求极小值。

这个例子比上一个复杂,因为它涉及更复杂的函数形式。它考验的是你将实际问题抽象成数学函数,并判断其是否具有单峰性质的能力。在实际比赛中,有时需要通过观察函数形式、求导分析,或者基于题目背景的合理猜测来做出判断。

4.3 避坑指南:什么情况下不能用三分?

三分法不是万能的,错误应用会导致错误答案甚至死循环。以下情况需要警惕:

  1. 非单峰函数:这是最根本的禁忌。如果函数在搜索区间内有多个极值点(多峰函数),三分法可能会收敛到某个局部极值点,而非全局最优。例如,函数sin(x)[0, 10π]上有多个波峰波谷。
  2. 平台区:如果函数有一段是常数(平台),三分法在比较f(m1)f(m2)时如果相等,按照我们的模板会执行l = m1。这可能导致算法在平台区来回震荡,收敛变慢。不过,只要最终极值点在区间内,算法仍会收敛到平台上的某个点。
  3. 离散函数与边界:对于整数三分,要特别注意边界条件。如果极值点恰好就在边界lr上,我们的模板(循环条件r-l>2)在暴力枚举阶段能正确处理。但要确保初始区间[l, r]包含了可能的极值点。
  4. 精度与迭代:浮点数三分中,eps设置过小配合没有迭代限制,在函数非常平坦的区域可能导致无限循环。务必设置迭代次数上限
  5. 函数计算成本:三分法每次迭代需要计算两次函数值f(m1)f(m2)。如果f(x)的计算非常昂贵(例如需要运行一次复杂的模拟),那么三分法的开销可能变得不可接受。此时需要考虑是否能用求导等解析方法,或者使用更高效的优化算法(如梯度下降)。

个人经验:在竞赛中,如果题目要求输出一个实数答案,并且描述中出现了“最小化/最大化某个量”、“找到最优的位置/参数”这类词语,同时你能够将这个“量”写成一个关于决策变量的数学表达式,就应该立刻考虑三分法。先在心里或纸上简单画一下这个函数可能的形状(想想端点值、中间值),如果感觉它像是一个“碗”或者“拱形”,那么三分法就值得一试。

5. 调试技巧与效率优化

即使理解了原理和模板,在实际编码中也可能遇到各种问题。这里分享一些调试和优化三分法的经验。

5.1 如何验证三分法的正确性?

  1. 打印搜索过程:在三分循环内,打印出每一轮的l, r, m1, m2, f(m1), f(m2)。观察区间是否在稳步缩小,以及缩小的方向是否符合逻辑(f(m1)<f(m2)r向左缩)。
  2. 与暴力枚举对比:对于小范围数据(例如整数定义域且范围很小),或者可以离散化采样的情况,写一个暴力程序,枚举所有可能点(或密集采样)计算f(x),找出最小值点及其函数值。将三分法的结果与暴力结果对比,验证是否一致。
  3. 绘制函数图像:如果可能,用 Python 的 Matplotlib 等工具,在搜索区间内采样几百个点,画出y = f(x)的图像。直观地检查它是否确实是单峰的,以及三分法找到的点是否在谷底/峰顶附近。
  4. 检查边界条件:单峰函数的极值点有可能就在边界上。确保你的三分法初始区间[l, r]包含了边界,并且算法在边界处也能正确工作(例如,对于整数三分,最后的暴力枚举包含了lr)。

5.2 三分法的效率与常数优化

  1. 减少函数计算次数:这是最大的优化点。在f(m1) < f(m2)的情况下,我们更新r = m2。注意,下一轮迭代的m2'很可能等于这一轮的m1。因此,我们可以复用函数值,避免重复计算。

    double ternary_search_optimized(double l, double r) { const double eps = 1e-8; while (r - l > eps) { double m1 = l + (r - l) / 3; double m2 = r - (r - l) / 3; double f1 = f(m1); double f2 = f(m2); if (f1 < f2) { r = m2; // 下一轮的 m2_new 可能等于当前的 m1,但 f(m1) 已经计算过了。 // 为了简化代码,通常不刻意保存,除非f(x)计算极其昂贵。 } else { l = m1; } } return (l + r) / 2; }

    对于计算极其昂贵的f(x),可以增加逻辑来缓存f(m1)f(m2),用于下一轮迭代。

  2. 整数三分的循环条件while (r - l > 2)是安全且高效的标准写法。有些写法用while (l < r)并在内部处理m1m2相等的情况,但更容易出错。坚持使用>2的条件和事后枚举,代码更清晰健壮。

  3. 精度与迭代次数的权衡:如果题目对精度要求不高(例如只要求输出整数或保留少量小数),可以适当增大eps(如1e-5)以减少迭代次数。同时,迭代上限iter_limit可以设为log(初始区间长度/eps) / log(1.5)的近似值,再加一些余量。

5.3 三分法与其他优化算法的对比

  • vs. 二分查找:二分用于单调函数找特定值或边界;三分用于单峰函数找极值。二者思想同源(分治),但应用场景不同。
  • vs. 求导法:如果函数f(x)容易求导,那么令f'(x)=0解方程往往是更直接、更精确的方法。三分法适用于导数复杂、不存在或难以求解的情况(例如,f(x)本身就是一个黑盒模拟过程)。
  • vs. 模拟退火/爬山算法:对于多峰函数或复杂搜索空间,三分法会失败,而模拟退火等随机优化算法有可能找到全局最优解,但代价是不保证最优性且运行时间不确定。三分法在适用范围内是确定性的、高效的。

6. 从三分到更一般的凸函数优化

三分法解决的是一维单峰函数(凸函数或凹函数)的极值问题。这是凸优化中最简单的特例。理解三分法,是迈向更广泛优化问题求解的第一步。

凸函数(Convex Function)有一个优美的性质:其局部极小值就是全局极小值。三分法本质上是在利用凸函数的“碗状”性质。对于高维凸函数,我们有梯度下降、牛顿法等更强大的工具。但在一维情况下,三分法以其实现简单、无需导数、鲁棒性强的特点,占据了一席之地。

在实际编程中,当你遇到一个求最优解的问题,并且决策变量只有一个连续参数时,思考顺序应该是:

  1. 能否写出目标函数f(x)
  2. f(x)在搜索区间上是否看起来是单峰的(凸的或凹的)?可以通过端点趋势、物理意义或简单求导判断。
  3. 如果答案是肯定的,那么三分法就是你工具箱里的首选武器。

最后,记住三分法的核心口诀:取两点,判高低,弃外侧,缩区间,至精度。将这个流程内化,再结合对问题本身的深刻理解,你就能将许多看似复杂的最优化问题,优雅地转化为几次函数求值和比较。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询