简介:一套面向数值分析与优化设计场景的三次插值法 MATLAB 实现,适合需要求解复杂函数极值、开展参数优化的科研学习者与工程人员使用。压缩包内共 4 个文件,全部为 m 脚本,整体仅 1KB;代码精简,内容涵盖主程序、目标函数定义、导数计算以及极值搜索区间设置,便于对照逻辑逐段学习与二次改造。已有 355 人学习浏览。整体围绕三次插值逼近的思路展开,可帮助读者掌握构造插值多项式、求解导数零点定位候选极值,再通过二阶导数判别极大或极小的完整流程,同时理解如何在优化设计中借助插值近似函数形态、缩小搜索范围并提升求解效率。对正在做课程设计或工程优化的使用者来说,这套小体积代码包提供了可直接运行的参考骨架与调试思路,具有较强的实践价值。
1. 三次插值法求极值:优化设计里被低估的快速逼近手段
三次插值法求极值,是优化设计里特别实用的一招。目标函数一旦复杂,解析解难求,直接把区间切细扫网格又太慢;先用一个三次多项式去贴合目标函数,再让这个多项式的导数为零,往往一两步就能锁定极值点的邻域。sancichazi 压缩包解压后正好就是一套这样的 MATLAB 流程:sancichazhi.m 负责插值和迭代,f_1.m 定义目标函数,diff_f_1.m 给出导数,range_1.m 负责选定初始三点。适合正在做机械参数优化、工程曲线拟合或者数值分析课程设计的人,尤其是想绕开 fminbnd 黑匣子、自己控制每一步怎么走、又担心二次逼近精度不够的读者。下面按数学原理、代码拆解、参数调整和踩坑顺序展开。
2. 三次插值法的数学基础:三点一导数的条件如何变成极值点
2.1 一个三次多项式只有四个系数:为什么三点之外还要一个导数条件
三次插值不是把三个点硬塞给三次函数。P(x) = a0 + a1 x + a2 x^2 + a3 x^3 有四个待定系数,而三个点只能提供三个函数值约束,方程组是欠定的。要把这个问题定下来,常规做法是再加一个一阶导数条件,所以压缩包里才会有 diff_f_1.m。常见的取法是三点函数值加左端点导数,也就是三次 Hermite 插值的一种形式。x1 处对齐函数值和导数值,x2、x3 处只对齐函数值。这样得到的三次多项式既穿过三个数据点,又在 x1 附近保持目标函数的趋势。
如果只有函数值没有导数,退化成过三点的二次插值,计算简单但不能反映一侧的斜率;在目标函数变化陡峭的地方误差大。这也是为什么资源里单独给出 diff_f_1.m,而不是在 f_1.m 里用diff符号微分草草了事。实际做优化设计的时候,导数信息是否稳定,直接决定三次插值能不能收敛到真正的极值点。
设 x1 < x2 < x3,记 u = x - x1,把多项式写成:
P(u) = f1 + df1 * u + a2 * u^2 + a3 * u^3
这里 f1 = f(x1),df1 = f'(x1)。a2 和 a3 由 P(u2) = f2、P(u3) = f3 两个条件确定,u2 = x2 - x1,u3 = x3 - x1。写成线性方程组就是:
u2 = x(2) - x1; u3 = x(3) - x1; M = [u2^2, u2^3; u3^2, u3^3]; rhs = [f2 - f1 - df1*u2; f3 - f1 - df1*u3]; a = M \ rhs; a2 = a(1); a3 = a(2);用M \ rhs而不是手写代数公式,原因在于数值稳定性。当 u2 和 u3 比较接近时,线性方程组条件数会变大,显式公式里面很容易出现两个大数相消,把有效位数抹掉;MATLAB 的\运算符会做列主元消去,能多保住一点精度。h 在 0.01 量级时两者差异还不明显,h 到 0.001 量级后,显式公式给出的 a2、a3 就开始出现可疑的数字。
2.2 求导为零:三次插值求极值的关键方程
现在有了三次模型 P,目标函数 f 的极值点原本要求 f'(x) = 0,这里改用 P 的驻点代替。P'(u) = df1 + 2a2u + 3a3u^2,这是一个二次方程。判别式 disc = 4a2^2 - 12a3*df1。如果 disc < 0,说明插值多项式在当前三点区间内没有驻点,三点选得不好,可能包住的是一个单调段而不是一个谷底。如果 disc >= 0,方程给出两个实根。
具体的求解和选根逻辑如下:
if abs(a3) < 1e-14 % 三次项退化,按二次处理 u_star = -df1 / (2*a2); else disc = 4*a2^2 - 12*a3*df1; if disc < 0 error('三次插值在当前三点区间内没有驻点'); end u1 = (-2*a2 + sqrt(disc)) / (6*a3); u2 = (-2*a2 - sqrt(disc)) / (6*a3); u_cand = [u1, u2]; % 只保留严格落在开区间 (0, u3) 内的候选点 u_cand = u_cand(u_cand > 0 & u_cand < u3); % 比较真实函数值,取更小者 f_cand = f_1(x1 + u_cand); [~, idx] = min(f_cand); u_star = u_cand(idx); end x_star = x1 + u_star;这里要跟新手强调一点:不要让三次插值算出来的极值点落在区间之外。候选点和边界重合没有意义,因为在真正的优化循环里,你已经用三点框住了一个局部极小值的邻域,三次插值的本质是用局部模型近似全局函数,局部模型只在区间内部才有解释力。所以 u_cand 必须同时满足 0 < u < u3,否则就应该报错而不是强行取根。
2.3 三种选点方案怎么选:取决于导数和函数计算成本
| 选点方案 | 需要的信息 | 优点 | 缺点 |
|---|---|---|---|
| 三点函数值 + 一点导数 | f(x1), f(x2), f(x3), f'(x1) | 导数信息锁定一侧趋势,本资源默认做法 | 导数不稳定时容易把根推出区间 |
| 两点函数值 + 两点导数 | f 和 f' 在两个端点 | 两端趋势都保持,误差界更整齐 | 要计算两个导数值,成本高 |
| 三点函数值(二次插值) | 只需 f 值 | 不用求导,抗噪声 | 对剧烈变化函数拟合差,迭代更慢 |
我的工程习惯是:如果 f_1.m 能写出解析导数,用三点加一点导数;如果目标函数是黑盒,只能测函数值,先考虑中心差分,或者先用二次插值迭代几步,等收敛到极小值附近再切换三次插值。range_1.m 里的网格预扫就是为这个服务的,先把大区间缩到极小值周围,再做三次插值。
3. 解包复现流程:sancichazhi.m 主程序与三个辅助脚本的配合
3.1 文件清单与调用关系
sancichazi.rar 解压出来,核心就是四个.m文件,调用关系非常清晰:
| 文件名 | 职责 | 被谁调用 |
|---|---|---|
| sancichazhi.m | 主程序,构造三次插值、求驻点、迭代收敛 | 直接在命令窗口运行 |
| f_1.m | 定义目标函数,是整个优化问题的业务模型 | sancichazhi.m、range_1.m |
| diff_f_1.m | 计算目标函数导数 | sancichazhi.m |
| range_1.m | 确定搜索区间,返回三个初始插值点 | sancichazhi.m |
调用顺序是:sancichazhi.m 启动,先调 range_1.m 取得三个横坐标;然后对这三个点求函数值和导数值;构造三次插值多项式,求 P'(x) = 0 的根;把这个根作为新的极值点候选;检查收敛;没收敛就更新三点,再循环。整个过程不需要人工干预,唯一要改的就是 f_1.m 和 range_1.m 里的参数。
3.2 目标函数和导数脚本:f_1.m 与 diff_f_1.m 的写法
压缩包里的 f_1.m 标准形态是这样,向量化写法很重要:
function y = f_1(x) % 目标函数,向量化写法 % 这个测试函数在 [0,4] 上有多个局部极值,适合验证三次插值法 y = exp(-0.3*x) .* cos(2.5*x) + 0.15*x.^2; end对应的解析导数放在 diff_f_1.m 里:
function dy = diff_f_1(x) % 目标函数 f_1 的解析导数 dy = -0.3*exp(-0.3*x) .* cos(2.5*x) ... - 2.5*exp(-0.3*x) .* sin(2.5*x) ... + 0.3*x; end这里必须用.*和.^,不要用*和^。range_1.m 里会用 linspace 一次生成一组向量,如果写成普通乘法,MATLAB 会报“矩阵维度不一致”,新手很容易在这翻车。
如果你换了一个没有解析表达式的目标函数,diff_f_1.m 可以改成中心差分,这是最常见做法:
function dy = diff_f_1(x) h = 1e-6; dy = (f_1(x + h) - f_1(x - h)) / (2*h); end中心差分 h 的取值是一个玄学点:太大,截断误差压不住;太小,舍入误差抬头。1e-6 对大多数双精度计算够用。如果目标函数有噪声,差分导数也不可靠,那就退回二次插值方案,别硬上三次插值。
3.3 初始区间怎么定:range_1.m 的粗网格搜索
range_1.m 的作用不是简单返回 [a,b] 两个端点,而是先做一次粗网格扫描,把最有可能的极小值邻域找出来,再返回三个相邻网格点。这比凭空给三个点靠谱得多,因为三次插值法本质是局部方法,初始区间必须把极值点包住。
function x0 = range_1() % 用粗网格先找目标函数的最小值邻域,返回三个插值点 a = 0; % 搜索区间左端 b = 4; % 搜索区间右端 n = 30; % 网格划分数 xgrid = linspace(a, b, n+1); fgrid = f_1(xgrid); [~, imin] = min(fgrid); % 如果最小值落在边界,说明区间没选对,直接报错提醒 if imin == 1 || imin == n+1 error('最小值落在搜索边界,请调整 a/b 或换个 f_1'); end % 取最小值点左右各一个网格点,构成三点,保证中间点函数值最低 x0 = xgrid(imin-1 : imin+1); end这个脚本里参数有三个:a、b、n。a 和 b 是你要搜索的自变量范围,n 是粗网格数。n 越大,初始三点离真实极值越近,但 range_1 本身的计算量也线性增大。对课程设计和一般工程优化,n = 30 到 50 通常够了。如果目标函数有十几个局部极小值,我会把 n 提高到 100 以上,避免粗网格漏掉最深的谷。
注意 range_1 返回的三个点是有顺序的,x(2) 是粗网格里的最小函数值点,x(1) 和 x(3) 在它两侧。这样构造三次插值的时候,函数值关系是“中间低、两端高”,刚好满足一个局部极小值 bracket 的条件。
3.4 主程序 sancichazhi.m:一轮插值加迭代
主程序把上面几个模块串起来。下面这个版本是我按压缩包功能补全的可运行形态,保留了迭代收敛和更新三点的逻辑:
function [x_star, f_star] = sancichazhi() % 三次插值法求极值主程序 % 返回:极值点 x_star 和对应函数值 f_star x = range_1(); % 三个初始插值点,按升序排列 tol = 1e-8; % 收敛阈值 maxIter = 15; % 最大迭代次数 for iter = 1:maxIter fx = f_1(x); % 取左端点 x(1) 的解析导数构造三次 Hermite 插值 x1 = x(1); f1 = fx(1); df1 = diff_f_1(x1); u2 = x(2) - x1; u3 = x(3) - x1; % 求解 a2、a3 M = [u2^2, u2^3; u3^2, u3^3]; rhs = [fx(2) - f1 - df1*u2; fx(3) - f1 - df1*u3]; coeff = M \ rhs; a2 = coeff(1); a3 = coeff(2); % 求 P'(u) = df1 + 2*a2*u + 3*a3*u^2 的零点 if abs(a3) < 1e-14 u_cand = -df1 / (2*a2); else disc = 4*a2^2 - 12*a3*df1; if disc < 0 error('判别式小于0:当前三点区间内没有驻点'); end u_cand = [(-2*a2 + sqrt(disc)) / (6*a3); (-2*a2 - sqrt(disc)) / (6*a3)]; end % 过滤出落在插值区间内的候选点 u_valid = u_cand(u_cand > 0 & u_cand < u3); if isempty(u_valid) error('三次插值极值点不在区间内,请重新给定三点'); end % 比较真实函数值,取更小者,确定本轮极值点 f_cand = f_1(x1 + u_valid); [~, idx] = min(f_cand); u_star = u_valid(idx); x_star = x1 + u_star; f_star = f_1(x_star); % 收敛判断:导数足够接近零 if abs(diff_f_1(x_star)) < tol || iter == maxIter fprintf('迭代 %d 次收敛,x* = %.10f,f(x*) = %.10e\n', ... iter, x_star, f_star); return; end % 更新三点:把函数值最大的点换成 x_star,再排序 x_all = sort([x, x_star]); f_all = f_1(x_all); [~, imax] = max(f_all); x_all(imax) = []; x = x_all; end end这里有两个参数值得调:tol控制收敛精度,一般的工程问题取 1e-6 到 1e-8 就够,再小容易受数值噪声限制;maxIter设 15 是防止区间退化后死循环。更新三点的策略是“丢掉函数值最大的点,换成新极值点”,这个策略简单有效,能让三点逐渐收缩到极小值附近。但要注意,如果 diff_f_1.m 用的是数值差分且 h 不合适,abs(diff_f_1(x_star))可能永远达不到 tol,这时候把收敛判据改成两点之间距离小于某个阈值会更稳。
4. 极值判定与精度边界:二阶导数符号和三点间距的参数选择
4.1 二阶导数判据:用 P''(x) 区分极大、极小和拐点
三次插值求出来的根只是驻点,不一定是极小值。要区分三类情况,最直接的办法是看插值多项式在驻点处的二阶导数:
P''(u) = 2a2 + 6a3*u
如果 P''(u) > 0,驻点是极小值;P''(u) < 0,是极大值;P''(u) ≈ 0,可能是拐点或者退化情况,需要换三点重新算。把这个判断补进主程序,就在 u_star 算出来之后加几行:
d2 = 2*a2 + 6*a3*u_star; if d2 > 0 kind = '极小值'; elseif d2 < 0 kind = '极大值'; else kind = '拐点/退化'; end fprintf('驻点类型:%s\n', kind);但是要记住,这是插值多项式的二阶导数,不是原函数的二阶导数。两者在小范围内接近,但不会完全一致。严谨做法是用 f_1 在 x_star 附近做中心差分求二阶导数,或者调用一次符号工具箱验证。对优化设计来说,插值多项式二阶导数符号已经足够用来排除“找到极大值”这种低级错误。
4.2 三点间距对插值误差的影响:区间缩得多小才算够
三次 Hermite 插值的逼近误差阶是 O(h^4),其中 h 是三点之间的平均间距。也就是说,如果原始函数足够光滑,间距缩小一半,插值多项式对函数值的逼近误差大约降为原来的 1/16。但极值点是通过导数零点定位的,误差阶会弱一点,通常正比于 h^3。间距从 0.4 缩到 0.2 再缩到 0.1,极值点位置误差大致按三个数量级往下掉:
| 三点平均间距 h | 典型极值点误差量级 | 适用场景 |
|---|---|---|
| 0.4 | 1e-2 左右 | 粗定位,课程设计演示 |
| 0.2 | 1e-3 左右 | 一般工程设计初步优化 |
| 0.1 | 1e-4 到 1e-5 | 需要较高精度时 |
| 0.05 | 1e-6 量级 | 接近收敛阶段 |
这组量级是假设目标函数至少二阶连续可导。如果函数本身有尖角或不连续,三次插值会在间断点附近剧烈震荡,误差反而变大。所以 range_1.m 的粗网格扫描不只是为了找最小值,也是在帮你避开那些不平滑的区域。工程上判断三点间距合不合适有个土办法:把三点对应的函数值打出来,如果中间点明显低于两端,并且三个点连起来看不出明显拐弯异常,这个区间就是可用的。
4.3 收敛判据怎么选:绝对导数阈值还是相对步长
主程序里用的是abs(diff_f_1(x_star)) < tol,这个判据在解析导数下没问题。但如果你把 diff_f_1.m 换成了中心差分,导数本身就有噪声,可能永远达不到 1e-8。比如 h = 1e-6,函数值在 1e-8 量级时,差分导数噪声就已经到了 1e-2 量级。这时候更可靠的做法是用相邻两次迭代的 x_star 距离来判断收敛:
if abs(x_star - x_prev) < 1e-7 break; end第三种做法是看函数值下降量。如果 |f(x_star) - f(x_prev)| 连续三次迭代都小于 1e-10,说明插值多项式已经把极小值附近磨得很平了。我的习惯是解析导数用导数判据,数值导数用步长判据,两个同时放进去最稳,避免在噪声底上做无谓的迭代。
5. 避坑指南:三次插值法求极值常见问题与排查
5.1 现象:插值得到的极值点不在区间内,主程序直接报错
- 现象:运行 sancichazhi.m 时提示“三次插值极值点不在区间内”,或者求出来的 x_star 比 x(1) 还小、比 x(3) 还大。
- 原因:三个初始点没有真正框住一个极小值。最常见的是 range_1.m 网格太粗,漏掉了真正的谷底;或者粗网格找到的最小值点落在边界上,三点实际是一条单调段。三次插值在这种情况下只会机械地拟合单调趋势,驻点自然跑到区间外。
- 解决:先打印三点坐标和对应的函数值,确认中间点确实低于两端点。如果中间点不是最低,把 range_1.m 的 n 调大,或者手动把 a、b 缩小。另外检查一下 f_1 是不是有多个局部极小值,粗网格扫到的可能不是目标区间内最深的谷。
5.2 现象:diff_f_1.m 返回 NaN 或 Inf
- 现象:迭代两三轮之后,代码既不报错也不收敛,变量变成 NaN,求根时 disc 跟着变 NaN。
- 原因:目标函数里有 log、sqrt、1/x 这类运算,更新三点时某一轮 x 取到了定义域之外。比如 f_1 里写了 log(x),而 x 更新到了负数;或者 exp 数值溢出变成 Inf。如果 diff_f_1.m 用的是中心差分,f_1(x-h) 在边界附近也可能踩到非法区域。
- 解决:在 diff_f_1.m 开头加一个保护性检查,x 越界就返回一个很大的数而不是 NaN:
if any(x <= 0) || any(x >= 1e6) dy = sign(x) * 1e6; return; end更彻底的做是在 range_1.m 里就限定 a、b,并且更新三点时强制新的 x_star 不越界。那以后我把 f_1 的定义域写死成注释,放在脚本第一行,防止换目标函数时候忘了改。
5.3 现象:三次项系数 a3 接近零,求根公式爆炸
- 现象:sancichazhi.m 里
6*a3作分母,算出来的 u_star 是几百几千,看起来完全不合常理。 - 原因:三点之间的函数值关系接近二次曲线,三次项系数 a3 在数值上被消到接近机器精度。这时候再用三次方程求根公式,分母和分子都是小量,结果被舍入误差主导。这不是算法错,是数据退化。
- 解决:在求根前加一个保护分支,a3 的绝对值小于 1e-14 就按二次插值处理。上面的主程序里已经写进了这个分支,实际使用中别把它删掉。如果你发现 a3 频繁接近零,说明目标函数在这个区间里本身就比较平缓,用三次插值属于杀鸡用牛刀,二次插值或直接取三点中点就够了。
5.4 现象:迭代不收敛,极值点在两个值之间来回震荡
- 现象:每轮输出的 x_star 在 0.98 和 1.02 之间反复跳,函数值也在两个相近的极小值附近来回换,直到 maxIter 用完。
- 原因:更新三点的策略太激进。把函数值最大的点直接丢掉后,三点区间可能只剩极值点一侧的点,另一侧的信息完全丢失,插值多项式在一个背离真实函数的形状里反复折腾。这种情况在目标函数有两个几乎等深的极小值时要特别注意,三次插值只能看到局部,看不到远处那个一样深的谷。
- 解决:更新三点之前,先确认新 x_star 确实落在当前三点区间内部;如果落在靠近端点的地方,不要丢掉远端点,而是保留它作为区间边界,避免区间收缩过快。也可以把 maxIter 调大到 30,但更有效的方法是把 range_1.m 的网格加细,让初始三点距离足够近。
6. 让三次插值结果可信:函数值回落检查和可视化验证
三次插值法求极值的最后一步,不是输出 x_star 就完事,而是验证这个点确实是目标函数的极小值。我每次跑完 sancichazhi.m,都会强制做两个检查:函数值是否低于三个插值点,以及原函数曲线上的这个点是否真的是谷底。第二件事尤其重要,因为插值多项式可能在某些局部区域和原函数偏离。
一个快速有效的验证脚本是画图加回落检查:
% 原函数曲线 xs = linspace(0, 4, 500); ys = f_1(xs); plot(xs, ys, 'b-', 'LineWidth', 1.2); hold on; % 标记三次插值找到的极值点 plot(x_star, f_star, 'ro', 'MarkerFaceColor', 'r', 'MarkerSize', 8); % 画一条竖线辅助观察 xline(x_star, 'k--'); xlabel('x'); ylabel('f(x)'); title('三次插值法求极值结果验证'); grid on; legend('目标函数', '三次插值极值点', 'Location', 'best');画出来之后,先看红点是不是落在一个向下凹的位置。如果红点落在斜坡上或者凸包上,说明三次插值在区间内没有正确逼近原函数,极值点不可信。此时第一步是重跑 range_1.m,把初始区间缩小。
函数值回落检查可以写成断言,放进主程序里:
x0 = range_1(); if f_star > min(f_1(x0)) + 1e-8 warning('极值点函数值没有低于初始三点,请检查插值区间'); end这个检查的意义在于:如果 x_star 处函数值甚至高于初始三点中最低的点,那说明三次插值给出的根不是极小值,只是多项式的一个驻点。真实优化里,一个没有“回落”的极值点是没有工程意义的,因为设计参数调到那里反而变差了。
我还常用 MATLAB 自带的 fminbnd 做一次交叉验证。不是取代三次插值法,而是拿成熟算法检验自己手写流程有没有逻辑错误:
[x_ref, f_ref] = fminbnd(@f_1, 0, 4); fprintf('三次插值:x* = %.10f, f = %.10e\n', x_star, f_star); fprintf('fminbnd :x* = %.10f, f = %.10e\n', x_ref, f_ref);两个结果如果在小数点后四位以内吻合,说明三点取对了,迭代也收敛了;如果差很多,多半是 range_1.m 的粗网格把三点取到了另一个局部极小值附近,而那个极小值不是全局极值。三次插值法本身是局部方法,它不负责全局搜索,全局性要靠 range_1.m 的网格密度来保证。
从那以后,我每次跑完 sancichazi 压缩包里的 sancichazhi.m,都会强制把原函数曲线和插值极值点叠到同一张图上,先看函数值有没有回落,再看导数零点的位置是否真的落在凹谷里。不看这一眼,我心里没底,哪怕纸面上收敛误差已经到 1e-10。希望这份拆解帮你也少走一次弯路。
本文还有配套的精品资源,点击获取