简介:面向优化理论、机器学习与运筹学学习者的专题文档,聚焦拟凸问题如何转化为凸问题可行性问题,并用二分算法搜索满足条件的阈值。内容以定义、性质、转化和算法为主线:先辨析拟凸函数与凸函数的条件差异,说明凸问题解集的凸性与局部最优即全局最优的特点,再讨论通过引入变量和约束把原问题改写为可行性判断,最后按初始化上下界、取中点、检查可行性、更新搜索区间的流程讲解二分步骤。包内为1个docx文档,大小约200KB,结构紧凑,适合课程复习、算法推导和建模前的概念梳理。已有559人学习,可帮助读者理解拟凸优化难以直接处理时的转化路径,掌握阈值搜索的实现框架与边界更新逻辑。
1. 拟凸问题的二分求解:一个不凸的目标函数,为什么能靠一堆凸问题逼出来
把一个目标函数本身不凸的优化问题,交给凸问题求解器反复求解几十次,最后还能拿到全局最优——听起来像玄学,但它在凸优化体系里完全说得通。区别在于:从头到尾都不让求解器去碰那个不凸的目标函数,只让它回答一个是非题——「存在一个可行点,使目标值不超过 α 吗?」拟凸函数的每一个下水平集都是凸集,于是这个是非题就是标准的凸可行性问题;而水平集随 α 单调嵌套,于是二分可以在这串是非题上把最优值夹逼出来。搞懂这条链路,你就明白为什么分式规划、minimax、鲁棒约束这类看着没法处理的非凸问题,最后都能落到凸求解器上:外层做的是搜索,内层做的是凸问题,两边各自简单,拼起来却能解一大类原本无从下手的问题。
2. 下水平集的凸性:拟凸问题能被「切开」的真正原因
2.1 从一条不等式到一个凸集
拟凸函数的定义是:对定义域内任意 x、y 和任意 λ ∈ [0,1],满足
f(λx + (1-λ)y) ≤ max{ f(x), f(y) }这条不等式的形状很怪,它不约束函数图像的弯曲方向,只约束函数值的「高度不上升」。真正的信息藏在它的等价形式里:对任意实数 α,下水平集
S_α = { x ∈ dom f | f(x) ≤ α }必须是凸集。证明只有两行:取 x、y ∈ S_α,则 f(x) ≤ α、f(y) ≤ α,于是 max{f(x), f(y)} ≤ α,由定义得 f(λx+(1-λ)y) ≤ α,即 λx+(1-λ)y ∈ S_α,凸性成立。反过来也成立,只要所有下水平集都是凸集,函数就是拟凸的。
这个等价关系是整篇文章的地基,值得反复咀嚼:拟凸性压根不是函数图像的几何性质,而是水平集这一族集合的集合性质。函数图像可以扭得很难看,但只要每个「高度不超过 α 的截面」都是凸的,它就是拟凸的。常见的实现思路是把这个性质当成工具箱:你不需要证明 f 凸,只需要证明对每个 α,S_α 能写成凸约束的可行域——这两件事的难度差着数量级。
顺带说清和凸函数的关系。凸函数一定拟凸,因为 S_α 是凸函数的下水平集,天然凸。反过来不成立,经典反例是比值型函数 f(x) = ‖Ax − b‖₂ / (cᵀx + d),只要分母在可行域上恒正,它就是拟凸的,但一般不是凸的。还有更朴素的例子:在正半轴上,1/x 是拟凸但非凸的。这类函数在工程里非常多,因为「效率 = 产出/投入」天然就是比值,而比值的水平集是「产出 ≤ α · 投入」,一个锥约束,凸得很。
2.2 二分的真正前提是单调嵌套,不是凸性
很多人把二分能用的原因归结为「拟凸」,其实这个说法漏了关键的一层。二分作为一种搜索策略,它需要的唯一性质是可行性关于 α 的单调性:
α₁ ≤ α₂ ⟹ S_α₁ ⊆ S_α₂这条包含关系直接从集合定义读出来,跟凸不凸毫无关系。它的作用是保证「一旦某个 α 可行,所有更大的 α 都可行」,于是可行域在实数轴上是半直线 [α*, +∞),二分才有意义。
凸性在这里扮演的是另一个角色——它只负责让每一次判定本身变得可解。把两件事拆开看,整个方法的骨架就清楚了:
| 性质 | 来源 | 作用 |
|---|---|---|
| 水平集单调嵌套 | 集合定义,天然成立 | 外层二分的正确性 |
| 水平集是凸集 | 拟凸性 | 内层判定的可解性 |
如果 f 是拟凸的但 S_α 没有可计算的凸表示,外层二分逻辑依然成立,只是内层判定会退化成 NP-hard 的黑箱——你依然可以二分,但每次判定可能得靠枚举。反过来,如果 f 是凸的(更强),那根本不需要二分,直接一次求解就完了。所以拟凸恰好卡在一个甜点上:比凸弱,弱到包含了大量真问题;又比一般非凸强,强到内层还能交给凸求解器。
提示:判断一个具体问题能不能用二分框架,先别急着验凸性,先问「S_α 我写得出来吗」。写不出来,拟凸也白搭。
还有一个容易被忽略的细节:单调嵌套保证了二分的正确性,但不保证最优值可达。若 f 在可行域上下半连续、可行域紧,则 α* 处 S_α* 非空,最优可达;若 f 不下半连续,可能出现「下确界存在但取不到」的病态情形,此时二分照样收敛到 α*,但返回的那个 x 的真实目标值会严格大于 α*。这解释了后面避坑章节里那条「跑完发现代回原函数值偏大」的现象,它的根因可能不在数值,而在问题本身。
2.3 一次具体的水平集改写
拿上面那个比值目标做样板,把「不凸的优化」翻译成「凸的可行性」。原问题:
minimize ‖Ax − b‖₂ / (cᵀx + d) subject to cᵀx + d > 0 lo ≤ x ≤ hi直接把它丢进建模库,会撞上 DCP 规则——凸表达式除以凹表达式不在允许的组合里,建模阶段就报错。但如果换成判定形式「是否存在 x 使目标 ≤ α」,不等式两边乘上正分母,得到
‖Ax − b‖₂ ≤ α · (cᵀx + d)左端是范数,凸;右端是仿射表达式乘正常数,仿射。这是一条标准的二阶锥约束,DCP 检查一路绿灯。整个转化过程没有任何近似,等号严格成立——这正是「化为可行性问题」这句话在数学上的确切含义,它不是启发式,是一次恒等变形。
import cvxpy as cp import numpy as np def build_level_set(A, b, c, d, alpha, lo, hi): """把 S_alpha ∩ C 写成凸约束集合,供后续可行性判定使用""" n = A.shape[1] x = cp.Variable(n) cons = [ # 水平集约束:||Ax-b|| <= alpha*(c^T x + d),等价于一个二阶锥 cp.norm(A @ x - b, 2) <= alpha * (c @ x + d), # 分母必须为正,否则乘过去会翻不等号,整个改写失效 c @ x + d >= 1e-8, x >= lo, x <= hi, ] return x, cons这段的价值不在代码本身,而在参数含义:alpha是外层传进来的阈值,每换一个alpha,约束集合就整体缩放一次,可行域随之单调收缩或扩张;c @ x + d >= 1e-8那条看着不起眼,却是整个改写合法性的前提——分母一旦可能取负,‖Ax−b‖ ≤ α(cᵀx+d)与f(x) ≤ α就不再等价,二分会在一个完全错误的可行域上运行,而它不会报错,只会给你一个看起来正常的错答案。
3. 二分外层:把「最小化」替换成「判定 + 夹逼」
3.1 不变量设计:上界必须真可行,下界必须真不可行
外层循环的正确性完全建立在一对不变量上。维护区间 [L, U],要求同时满足两条:存在一个已知的可行点 x_U 使 f(x_U) ≤ U;且 S_L ∩ C = ∅,即 L 严格不可行。有了这两条,最优值 α* 必然被夹在 [L, U] 里,因为 α* 是最小的可行阈值,它不可能小于 L(L 不可行),也不可能大于 U(U 已经可行)。
更新规则看着简单,但有一处细节决定了成败:mid 判定为可行时,不能直接把 U 赋成 mid。二分只输出阈值,不输出解——如果只记 mid 不记点,最后你手上只有一个数,没有任何 x。所以每次判定可行时必须把求解器给出的 x 一并存下来,并用它的真实目标值去收缩上界:
若 mid 可行,取回 x_mid: U ← min(U, f(x_mid)) # 注意:不是 U ← mid best_x ← x_mid 否则: L ← mid用真实值 f(x_mid) 而不是 mid 收缩上界,这个看似微小的改动同时解决两个问题。第一,mid 只是阈值,实际可行点的目标值可能明显更小,用真实值能让上界下降得更快。第二,也是最关键的,内层求解器有容差,它可能把边界附近的点误判为可行,用 mid 当上界就把一个不可达的值写进了不变量,后续所有收缩都建立在错误基础上;而用 f(x_mid) 回代,即使判定有偏差,上界始终对应一个真实存在的点,不变量不会被污染。这个习惯我后面还会反复提。
下界的维护则简单粗暴:只要判定不可行,就把 L 抬到 mid。这里没有任何回代的余地,因为你手上没有「不可行的证据」,只有一个求解器返回的 status。也正因如此,下界的可靠性完全依赖于内层判定的可靠性——这是整个方法最脆弱的一环,第 5 章会专门拆。
3.2 迭代次数:为什么是 log₂ 而不是别的
区间每轮折半,宽度按 1/2 的幂衰减。初始宽度 W₀ = U₀ − L₀,要缩到 ε 以内所需的轮数满足 W₀ / 2^k ≤ ε,即
k ≥ log₂( W₀ / ε )代入具体数字感受一下量级:初始区间宽度 100,要求精度 1e-6,k ≈ 27 轮。这个数字很能说明问题——精度每提高三个数量级,只多花约 10 轮。二分对精度的「价格」是对数级的,便宜得离谱,这也是它经久不衰的原因。
但别被这个对数骗了。每轮的内层判定是一次完整的凸问题求解,总成本是 27 次凸求解,而不是 27 次浮点运算。如果内层是内点法解一个几千维的 SOCP,单次几百毫秒,总时间就是十几秒。所以真正要优化的从来不是外层轮数,而是内层单次成本。常见的实现思路是:能把内层从「完整求解」降级为「一次切割」就降级,切平面法、椭球法都是这个思路,代价是外层轮数从 log(1/ε) 涨到 O(1/ε²) 或 O(n·log(1/ε)),需要按问题规模权衡。
def bisect_on_alpha(feasible_oracle, f_value, L, U, eps=1e-6, max_iter=200): """外层二分骨架:feasible_oracle(alpha) 返回可行点或 None""" best_x, best_val = None, float("inf") for it in range(max_iter): if U - L <= eps: # 用区间宽度判停,不要用 L < U break mid = 0.5 * (L + U) x = feasible_oracle(mid) if x is not None: v = f_value(x) # 关键:回代真实目标值 if v < best_val: best_val, best_x = v, x U = min(U, v) # 上界永远对应一个真实点 else: L = mid # 不可行,抬高下界 return best_x, best_val, L, U, it + 1函数签名里把feasible_oracle和f_value分成两个参数不是多余的抽象。前者是「判定 + 取点」,后者是「在真实目标上求值」,两者在生产实现里往往来自不同的代码路径——前者调求解器,后者是你自己写的业务公式。把它们分开,才能强制在每轮都做一次回代校验,而不是偷懒复用求解器内部的表达式值。
3.3 初始区间:上界便宜,下界难求
U₀ 好办,随便找一个可行点就行。可行点的来源很多:问题的某个显然解、松弛问题的最优解、或者干脆从一个大阈值开始逐步放大试探。工程上常用的写法是给一串递增的试探阈值(1、10、100、1e3……),第一个判定可行的就拿来做上界。这个「阶段一」本身花的轮数也是对数级的,代价可以忽略。
L₀ 才是真正棘手的地方。它必须是一个「确定不可行」的值,随便给会出两类事故。给大了——比如 L₀ 实际可行——二分会收敛到一个严格大于 α* 的值,而且过程完全正常,没有任何报错,属于典型的静默错误。给小了只是多跑几轮,无害。所以原则很明确:宁小勿大。
几个安全的选取方式:目标函数非负时直接取 0;目标有物理下界时取那个下界;有对偶信息时用拉格朗日对偶给出的最优值下界,这是最紧的一种,能省掉大量轮次。还有一种偷懒但常见的做法是把 L₀ 设成一个明显不可达的负数,反正多几轮而已。
注意:L₀ 的选取错误是这套方法里最难排查的一类 bug,因为它不改变程序的任何行为特征,只改变答案。写完实现后,务必用一个小规模暴力网格验证一次。
4. 内层可行性:每次二分到底在解什么
4.1 三条实现路径,代价差一个数量级
内层要回答的问题是「S_α ∩ C 是否非空」。实现路径有三条,选择哪条直接决定了整套方法的适用边界。
第一条,直接把约束丢给凸求解器,目标设成常数 0,看返回的 status 是不是 optimal。这是最省事的做法,好处是精度高、鲁棒,坏处是每轮都要从头做一次内点法迭代,问题规模大时开销吓人。而且它把「可行性」这个语义压缩成了一个 status 字符串,你无法从中提取任何结构信息。
第二条,把可行性判定写成一句极大化:可行性等价于 max_{x∈C} [α·g(x) − f(x)] ≥ 0(针对 f ≤ α·g 这种比值型约束)。当判定为不可行时,这个极大值严格小于 0,而且最优点处自然给出一个分离超平面,把当前点与水平集分开。这个超平面就是所谓的可行性证书,它的价值在于可以用它更新下界,甚至直接构造下一步的搜索方向。
第三条,只要求一个分离预言机(separation oracle):给定点 x,若 f(x) ≤ α 就报告「在内部」,否则返回一个超平面把 x 与 S_α 分开。有了这个预言机,椭球法或切平面法就能工作,每轮只做一次廉价的切割,不做完整求解。这是理论上最优雅的路径,复杂度 O(n² log(1/ε)) 次切割,但前提是你能构造出这个预言机。
这里藏着整篇文章最容易被误解的一点:对一般的拟凸函数,S_α 的凸性你「知道」,但未必「写得出来」。比值型、minimax 型这些结构化的问题,水平集有显式表达式,第一条路径就够用。但对于一个黑箱给出的拟凸函数——比如某个仿真器的输出——你只知道水平集凸,却没有任何办法把它写成约束,只能靠次梯度或伪凸性去构造分离超平面。这就是为什么二分框架在结构化问题上用得多,在纯黑箱问题上用得少:瓶颈从来不在外层,而在内层的可表达性。
4.2 容差如何在内外层之间传递
内层求解器带着自己的容差 δ 运行,外层带着终止精度 ε 运行。这两个数字之间的关系,决定了最终答案的可信度。
如果 δ 和 ε 同量级,灾难就来了。mid 落在 α* ± δ 这条模糊带里时,内层可能这一轮说可行、下一轮说不可行,取决于求解器的内部状态。外层的表现是区间来回震荡,收敛轮数远超 log₂(W₀/ε) 的理论值,最后返回的 x 与 U 之间还可能有肉眼可见的差距。
正确的做法是让两个容差至少差两个数量级:内层 δ 取 ε/100,或者反过来把外层精度放宽到 δ 的 10 倍以上。前者更安全,代价是内层求解需要更严格;后者省事,代价是答案精度受限。
但真正管用的还不是调容差,而是回代。每次内层报「可行」时,拿回它的解,用原始公式独立算一遍 f(x),要求 f(x) ≤ mid + δ_feas。不满足就当成不可行处理,把 L 抬上去。这个检查能在外层挡住绝大多数假可行——因为它们本来就只是数值噪声,一旦用真实公式核算就露馅了。我一般会把这个校验写成内层 oracle 的最后一步,任何拿不到真实可行点的情况一律返回 None,宁可多跑一轮也不让污染物进来。
不过要留意反向的不对称:假可行会污染上界,假不可行只是让下界抬得偏保守,最终结果偏大一点点,无害。所以校验要单向严格——放行条件卡死,拒绝条件可以宽松。
4.3 完整可跑实现:外层二分 + 内层 SOCP 判定
把前面几块拼起来,下面是完整的两层实现,直接可跑,用的是前面那个比值目标。
import cvxpy as cp import numpy as np def solve_ratio(A, b, c, d, lo, hi, eps=1e-6, feas_delta=1e-9, max_iter=200): n = A.shape[1] def f_value(x): """真实目标:回代校验和上界收缩都走这条独立的计算路径""" return np.linalg.norm(A @ x - b) / (c @ x + d) def feasible_oracle(alpha): """判定 S_alpha ∩ C 非空;可行则返回点,否则返回 None""" x = cp.Variable(n) cons = [cp.norm(A @ x - b, 2) <= alpha * (c @ x + d), c @ x + d >= 1e-8, x >= lo, x <= hi] prob = cp.Problem(cp.Minimize(0.0), cons) # 目标为常数,只问可行性 try: prob.solve(solver=cp.CLARABEL) except cp.error.SolverError: return None # 求解器异常当作不可行 if prob.status not in ("optimal", "optimal_inaccurate"): return None xv = np.clip(x.value, lo, hi) # 回代校验:真实比值必须真的不超过 alpha,才认这个点 return xv if f_value(xv) <= alpha + feas_delta else None # 阶段一:逐步放大阈值,找初始可行点 best_x, best_val = None, np.inf for probe in (1.0, 10.0, 100.0, 1e3, 1e4): x0 = feasible_oracle(probe) if x0 is not None: best_x, best_val = x0, f_value(x0) break if best_x is None: raise RuntimeError("试探范围内无可行点,检查 lo/hi 或分母正性") L, U = 0.0, best_val # 比值非负,0 是安全下界 it = 0 while U - L > eps and it < max_iter: it += 1 mid = 0.5 * (L + U) x_mid = feasible_oracle(mid) if x_mid is not None: v = f_value(x_mid) if v < best_val: best_val, best_x = v, x_mid U = min(U, v) # 上界用真实值,不用 mid else: L = mid # 判定失败,抬高下界 return best_x, best_val, L, U, it参数说明:eps是外层终止精度,控制最终区间宽度;feas_delta是回代校验的宽容度,必须远小于eps,否则假可行会漏网;max_iter是死循环保险丝,正常收敛时远达不到;lo、hi是变量箱约束,如果没有界可以设成很大的数,但那样阶段一的试探阈值需要跟着放大。返回值里同时给出区间 [L, U],两者都应该接近best_val,如果差得远,说明收敛过程有问题,这本身就是一个自检信号。
跑一遍典型的随机实例,通常 20 到 25 轮就收敛,返回点的真实目标值与 U 的差在 1e-9 量级。如果轮数明显超出 log₂(U₀/eps) 的估计值,多半是内层容差和内层判定在打架,往 5.2 那条去看。
5. 避坑与排查:二分在数值上翻车的几个经典位置
5.1 现象:跑完代回原函数,值比返回的 α 大
现象很直白——二分返回一个阈值 α 和对应的点 x,你把 x 代进原始公式一算,f(x) 比 α 大了 1e-4 甚至更多。程序没报错,区间也确实收缩到了 ε,但答案不可行。
原因基本只有一个:上界用了 mid 而不是真实值。求解器在边界附近返回 optimal_inaccurate,mid 被当成可行值写进了不变量,后续所有收缩都基于这个「不存在的可行点」。等到收敛,U 已经和任何真实点都脱钩了。
解决就是回代校验,写进内层 oracle 的最后一行:拿回解之后独立算一次 f(x),超过 mid + feas_delta 就一律当成不可行。这个检查的成本可以忽略,但挡住了绝大多数数值污染。当年我第一次实现这个框架时就是栽在这上面,调了半天以为是求解器精度问题,最后发现是自己的不变量被污染了。
5.2 现象:轮数远超 log₂ 的估计,区间来回震荡
理论上 27 轮收敛,实际跑了 80 轮还在动,而且轨迹不单调。
原因是内外层容差撞在了一起。内层 δ 与外层 ε 同量级时,mid 落在 α* ± δ 的模糊带内,判定结果在可行与不可行之间随机翻转,L 和 U 交替被推到 mid 附近,形成拉锯。
解决办法有两条,一起用更稳:把外层终止精度设成内层容差的 10 倍以上(比如内层 1e-9、外层 1e-6),或者把内层精度收紧到 ε/100。另外加一条诊断——把每轮的 (L, U) 打日志画出来,正常应该是一条平滑收敛的阶梯曲线,锯齿状就是容差打架的典型特征。
5.3 现象:建模库直接报 DCP 错误,连求解器都没进去
错误信息通常是「problem does not follow DCP rules」,堆栈指向目标函数那一行。
原因在于试图直接最小化一个比值或拟凸表达式。建模库的 DCP 规则只认「凸 + 凸 = 凸」「凸 / 凹正 = 凸」这类组合,而比值目标的分子凸、分母凹,落在规则之外。
解决是回到水平集形式:不要写Minimize(norm(A@x-b)/(c@x+d)),写成Minimize(0),把norm(A@x-b) <= alpha*(c@x+d)放进约束。约束允许比目标函数宽松得多的表达,因为每条约束单独检查凸性,不需要整体的凸组合关系。这是「化为可行性问题」最实际的一个好处——不只是理论上正确,而是在建模层面直接解锁了一批原本写不出来的问题。
5.4 现象:程序卡死不退出,或者迭代了几万次
while L < U这种写法在浮点下几乎永远不成立。当 L 和 U 相邻到只有一两个 ulp 的差距时,mid = (L+U)/2会等于 L 或 U,区间不再收缩,循环空转。
解决是三重保险:判停条件改成区间宽度U - L > eps,不要用等号比较;加max_iter保险丝;在循环内部检测 mid 是否严格落在 (L, U) 内,不满足就 break。第三条约等于在防御浮点表示本身的退化,虽然少见,但在区间跨度极大(比如 1e-300 到 1e300)时确实会触发。
5.5 现象:结果稳定地偏移一个常数,怎么调精度都没用
这不是数值问题,是建模问题,而且最隐蔽。
两个常见来源。一是下界 L₀ 设大了——设成了一个实际可行的值,那么 [L₀, U₀] 从一开始就没有包含 α*,二分在错误区间里收敛,结果偏大且完全正常。二是分母的正性假设没落实——cᵀx + d > 0如果没有作为硬约束写进去,可行域里就会存在分母为负的点,那些点上乘过去不等号翻转,判定语义彻底错乱。
排查方式是做一次小规模暴力验证:在低维实例上把可行域网格化,直接对每个网格点算 f,取最小值,跟二分结果对照。如果差一个常数,基本可以确定是初始区间或者约束遗漏,而不是算法。这个验证我建议每个新问题都做一次,五分钟的事,能省掉几小时的玄学调试。
6. 设计取舍:二分、Dinkelbach 与切平面,什么时候谁更划算
6.1 收敛速率的账要算清楚
二分是线性收敛,比率 1/2,每轮一次完整内层求解。但同样是解比值型问题,还有更快的方法,最经典的是 Dinkelbach 迭代:令 α_{k+1} = f(x_k)/g(x_k),其中 x_k 是 min[f(x) − α_k·g(x)] 的解。它是牛顿型方法,超线性收敛,典型情况下 5 到 8 轮就收敛到机器精度。
| 方法 | 收敛速率 | 典型轮数 | 对问题的要求 |
|---|---|---|---|
| 二分 | 线性(1/2) | log₂(W₀/ε) ≈ 25 | 只要求水平集可表达为凸 |
| Dinkelbach | 超线性 | 5 ~ 8 | 要求目标有 f/g 分式结构 |
| 切平面 / 椭球 | 多项式 | O(1/ε²) / O(n log(1/ε)) | 要求有分离预言机 |
看起来二分完败,但表格最后一列才是重点。Dinkelbach 的快,是用「知道目标长成 p/q 且 p 凸 q 凹正」这个结构信息换来的。一旦问题不是比值型——比如 minimax 分式、多约束的可行性交集、或者目标来自外部黑箱——你就没有 p 和 q 可拆,Dinkelbach 的迭代式根本写不出来。二分的适用范围要宽得多,因为它只要求「水平集是凸的」,不要求目标有任何代数形式。
这也是为什么二分在实践中地位稳固:它不快,但它是通用退路。当你手上的问题结构不规整、或者你还没有信心确认它属于哪个特殊族时,先上一版二分把答案拿到手,再考虑换成专用方法提速,是相当稳妥的工程节奏。
6.2 什么时候该换方法
有三个信号值得警惕。内层判定给不出可靠证书时——比如求解器只返回一个 status 字符串,你无法从中提取分离超平面——二分就是唯一选择,此时追求速度没有意义,先把可靠性做扎实。目标函数带噪声时(例如 f 来自蒙特卡洛估计),单调嵌套被打破,二分会在噪声带上乱跳,这时应该改成对目标做平滑或者用样本平均近似,而不是硬跑二分。需要极高精度时(1e-12 量级),二分要 40 轮内层求解,而 Dinkelbach 可能 8 轮就到,差距大到不能用「通用性」来辩护。
反过来,目标不可微、约束带箱约束加上一堆业务规则、可行域形状不规整时,二分几乎是唯一能保证全局最优的选择——因为它只依赖可行性判定,不依赖任何梯度信息。这一点在实践中的价值远超那十几轮的差距。
6.3 怎么验证你的实现是对的
最后落到验证方法上,这块比算法本身更容易被忽视。三个检查按成本递增:一是区间终值自检,返回的 L 和 U 应该都收敛到 best_val 附近,差得远就是收敛过程有问题;二是回代检查,把 best_x 代进原始公式,结果必须 ≤ U + 1e-9;三是小规模暴力对照,在低维实例上网格化求解,与二分结果比较。第三条最笨,但它是唯一能抓住「初始区间设错」这类静默错误的办法。
后来我每次接入一个新的拟凸问题,不管多简单,都强制走一遍第三条——先在 2 维或 3 维上做网格对照,确认二分的答案落在暴力最优的 ε 邻域里,再放到高维实例上跑。这个习惯救过我至少两次,其中一次是分母正性假设没落实,二分在错误可行域上跑出了一条漂亮的收敛曲线,答案却偏了 30%。二分这套东西的迷惑性就在这里:它永远会给你一个答案,而且看起来很像对的。希望帮到你。
本文还有配套的精品资源,点击获取