Matlab ode45与手写RK4:常微分方程数值解对照与避坑
简介面向MATLAB数值计算初学者与需要求解常微分方程的工程学习者这份文档围绕龙格-库塔Runge-Kutta方法展开重点说明ode45所采用的四阶五阶单步算法、截断误差特点及非刚性问题的求解思路。压缩包共1个doc文件大小约70KB内容紧凑便于作为算法入门、课程复习与代码查阅的便携资料。文档梳理了ode45的调用形式、参数含义与odeset选项设置并结合一阶、高阶常微分方程给出变量替换、求解与绘图示例同时提供自编定步长龙格-库塔函数的完整实现便于对照ode45理解k1至k4的计算流程、加权更新公式及与自带函数的差异。内容还涉及事件检测输出、相关ode函数的选择以及对刚性与非刚性问题的判别提示。已有4541人学习适合希望从数学原理到MATLAB落地逐步对照的读者。1. 手写 RK4 和 ode45 摆在一起先看清两条曲线差在哪三方程耦合的常微分方程组初值[1 1 3]时间区间[0 15]。同一份右端函数一半交给 Matlab 自带的ode45一半喂给自己写的四阶 Runge-Kutta 定步长循环两条曲线画在同一张图上形状大体贴合但采样点数量差了十几倍末端还悄悄分叉。很多人对数值解 ODE 的第一印象就卡在这个画面上ode45凭什么敢用变步长手写的 Runge-Kutta 为什么只能老老实实一步步挪。这份资料把两件事摊开讲了四阶 Runge-Kutta 的四个斜率怎么来的、ode系列函数的调用签名每一项填什么。它适合正在用 Matlab 解微分方程、却被步长、容差、刚性这些词反复绊住的人也适合想把ode45当黑匣子用、结果一出问题就只能干瞪眼的人。下面按“公式怎么落地 → 接口怎么调 → 高阶怎么降 → 哪里会翻车”走一遍。2. 四阶 Runge-Kutta 的四次斜率把公式拆成能跑的循环2.1 从欧拉的一步到 RK4 的四步一阶常微分方程写成y f(x,y)最朴素的做法是把导数当成差商(Y(n1) - Yn)/h ≈ f(Xn,Yn)移项就得到前向欧拉公式Y(n1) Yn h*f(Xn,Yn)。这个式子的几何含义很直白拿当前点的斜率笔直往前走一步。问题也直白它只用了区间左端一个点的信息相当于用矩形面积代替曲边梯形局部误差是O(h²)步长稍微放大曲线立刻塌。微分中值定理给了另一条路存在某个0 t 1使得Y(n1) Yn h*f(Xn t*h, Y(Xn t*h))。这里的f值叫区间上的平均斜率欧拉法粗暴地取了t 0。Runge-Kutta 的思路是既然一个点估不准平均斜率那就在区间内多预计算几个点把它们的斜率加权平均。取两个点就是二阶取四个点就是四阶。四阶的四个斜率是这么排布的斜率计算位置依赖K1区间左端点(Xn, Yn)当前解K2半步长点用 K1 推进K1K3半步长点用 K2 推进K2K4整步长点用 K3 推进K3合成公式是Y(n1) Yn h*(K1 2*K2 2*K3 K4)/6。权重1/6、2/6、2/6、1/6不是拍脑袋凑的是把Y(n1)在Xn处做泰勒展开逐项和f的各阶偏导匹配出来的结果匹配到h⁴项局部截断误差就落到O(h⁵)全局误差O(h⁴)。中间那两步推导确实繁琐工程上记住两条就够用每步四次函数调用换四阶精度局部误差是步长的五次方步长减半单步误差降到 1/32。顺带说一句原料里的表述“ode45采用四阶五阶 Runge-Kutta 单步算法截断误差为(Δx)³”。这个三次方的说法是简化ode45实际用的是 Dormand-Prince 4(5) 对用五阶公式估计误差、用四阶公式推进工程上按四阶全局精度理解不会出错但别拿三次方去反推步长。2.2 定步长 runge_kutta1参数表、预分配与列向量约定把上面的公式写成函数不难难的是写成能同时吃标量和向量的版本。资料里给出的runge_kutta1就是这种写法我按自己的习惯补了预分配和维度保护function [x, y] runge_kutta1(ufunc, y0, h, a, b) % ufunc 微分方程右端函数句柄签名 dy ufunc(x, y)dy 必须是列向量 % y0 初始值标量或列向量长度等于方程个数 % h 定步长正数 % a, b 时间起点与终点要求 b a n floor((b - a) / h); % 完整步数末尾不足一步的区间直接丢弃 x zeros(1, n 1); y zeros(numel(y0), n 1); % 预分配避免循环里长数组反复扩容 x(1) a; y(:, 1) y0(:); % 强制转列向量防止行向量传进来埋雷 for ii 1:n k1 ufunc(x(ii), y(:, ii)); k2 ufunc(x(ii) h/2, y(:, ii) h*k1/2); k3 ufunc(x(ii) h/2, y(:, ii) h*k2/2); k4 ufunc(x(ii) h, y(:, ii) h*k3); y(:, ii1) y(:, ii) h*(k1 2*k2 2*k3 k4)/6; x(ii1) x(ii) h; end end逐项说清楚。n floor((b-a)/h)意味着(b-a)不是h的整数倍时最后那一小段不会被积到想要严格落在b上就得自己把h调成能整除的长度或者把最后一步单独处理。zeros(numel(y0), n1)这一行不能省Matlab 的数组在循环里逐列追加会反复重新分配内存几百步看不出来几十万步就是肉眼可见的慢。y0(:)这个写法是血泪经验。调用方传[1 1 3]进来是 1×3 的行向量如果不转置y(:,1) y0在部分版本里会报维度错误在另一些版本里会悄悄广播成 3×3后面所有k值全错图还能画出来只是完全不对。函数内部统一列向量调用方就不用每次记得加转置。拿资料里的一阶方程试一下odefun (t, y) (y 3*t) / t^2; % 方程 t^2*y y 3t [t, y] runge_kutta1(odefun, -2, 0.01, 1, 4); plot(t, y, -); xlabel(t); ylabel(y); title(t^2y y 3t, y(1) -2);这里y0 -2是标量numel得 1y是 1×(n1) 的行向量y(:,ii)取出的是标量k1到k4也都是标量整条链路退化成纯量运算跑得通。要验证精度拿解析解(3*Ei(1) - 2*exp(1))/exp(1/t) - (3*Ei(1/t))/exp(1/t)在同一批t上求值做差即可Ei是指数积分函数Matlab 里写作ei。步长 0.01、区间长度 3共 300 步末端误差量级通常在1e-8上下换个h 0.02再跑一遍误差应该涨到十几倍这个比值就是四阶收敛最直观的证据。2.3 步长 h 不是越小越好截断误差与舍入误差的拉锯新手最容易形成的直觉是“步长越小越准”这句话只对了一半。定步长 RK4 的总误差由两部分叠加截断误差随h减小按h⁴下降舍入误差随步数增加按1/h上升两条曲线一降一升中间有个最优点。步长小到1e-8以下双精度浮点的有效位被逐步蚕食结果反而变差曲线开始出现细碎的毛刺这就是典型的“算得越认真越不准”。实践经验先按(b-a)/100到(b-a)/1000取一个h跑一遍看曲线形状再做步长减半对照如果末端解的相对变化已经小于你能接受的精度就停手。别一上来就1e-6既慢又未必更准。还有一个更隐蔽的约束是稳定性跟精度无关。显式 RK4 的绝对稳定域在实轴负方向大致到-2.78附近意思是方程里出现快速衰减模态、且λ*h的模超过这个量级时数值解会震荡发散哪怕真解光滑得很。这时候把h再调小反而“治好”了其实是把λ*h重新压回稳定域里代价是步数暴涨。这类方程就是后面要说的刚性方程正确做法是换隐式求解器不是硬压步长。判断方式很简单把h减半如果误差按 16 倍降那是精度主导如果误差纹丝不动甚至变大而曲线还在抖那就是撞上稳定性边界了。3. ode45 的三个入参和一堆 options调用签名逐项拆3.1 odefun、tspan、y0 的合法写法和错位重灾区最常用的调用形式是[T, Y] ode45(odefun, tspan, y0)三个入参各有各的坑。odefun是右端函数的句柄三种写法都合法函数文件名要加比如test_fun匿名函数直接写(t,y) ...老版本的内联函数inline(...)现在基本不用了能跑但性能差。注意test_fun和test_fun是两回事后者会把函数当变量求值报错信息还特别含糊。函数签名固定是dy odefun(t, y)参数顺序不能反t在前y在后跟数学写法f(x,y)的直觉正好相反这是最高频的错位点。tspan有两种形态行为完全不同。写成两元素[t0 tf]时求解器按自己的自适应步长内部推进返回它实际踩过的所有节点点数由求解器决定可能几十也可能几千。写成多元素向量[t0, t1, ..., tf]时求解器仍然按内部步长算但只在指定的这些时刻返回插值结果点数是固定的。想要等间距输出又不想干预内部步长用第二种想让求解器自己决定采样密度用第一种。odefun (t, y) (y 3*t) / t^2; tspan [1 4]; y0 -2; [T, Y] ode45(odefun, tspan, y0); % 自适应节点T 是列向量 plot(T, Y, -o);y0必须是向量长度等于方程个数标量方程就传标量。传行向量[1 1 3]在多数版本里能跑但内部会做一次形状转换一旦你的odefun返回的也是行向量两者对不上就会报维度错误。统一用列向量[1; 1; 3]是最省心的写法。返回的T是列向量长度和Y的行数一致Y是矩阵每一列对应一个分量取第一个分量写Y(:,1)。这一点跟plot(T,Y)直接画多条线是自洽的但和很多人的“每行一个时刻”的直觉相反取错维度画出来的图会是一堆乱七八糟的折线。3.2 odeset 里真正影响结果的四个参数options用odeset构造参数名不区分大小写但拼错不会报错只会被静默忽略这是个经典的坑。真正常用的就那么几个参数默认值作用什么时候改RelTol1e-3相对误差容限要更高精度时降到1e-6到1e-10AbsTol1e-6绝对误差容限可标量或向量分量量级差异大时按分量给MaxStep区间长度的 1/10步长上限怕错过快速变化段时压小Events空事件函数句柄需要抓过零、终止积分时误差判据是“或”的关系第i个分量的估计误差满足err_i max(RelTol*|y_i|, AbsTol_i)就算通过任一条件满足即可。这意味着当某个分量数值接近零时RelTol那一项自动失效全靠AbsTol兜底。如果你的解里有个分量在1e-8量级附近晃默认AbsTol 1e-6等于完全不管它曲线会呈现明显的阶梯状很多人以为是方程有问题其实是容差放得太松。MaxStep的作用容易被低估。求解器在解变化平缓的区域会主动放大步长理论上误差控得住但如果方程里有窄脉冲、或者你后续要拿结果做频谱分析采样点太稀会直接丢掉高频成分。把它压到0.01这种量级代价是步数变多收益是曲线细节保得住。opts odeset(RelTol, 1e-8, AbsTol, 1e-10, MaxStep, 0.05); [T, Y] ode45(odefun, [1 4], -2, opts);改容差要看收益比。RelTol从1e-3降到1e-6步数大概翻几倍再降到1e-10步数继续涨但结果的可见精度提升已经很有限除非你在做长期积分或者对初值敏感的问题否则1e-8就到头了。3.3 sol 结构体、deval 与四输出按需选返回形式只要一个输出参数ode45返回的是结构体sol里面存了求解器内部的稠密输出信息可以用deval在任意时刻插值求值精度和求解器内部一致sol ode45(odefun, [1 4], -2); tt linspace(1, 4, 200); yy deval(sol, tt); % 稠密输出插值比 interp1 更贴求解器真实轨迹 plot(tt, yy);这一点值得多说两句。拿到[T,Y]之后用interp1做插值本质是对已经稀疏化的采样点做二次加工T本身就不密的地方插出来的值跟真实轨迹有偏差。deval用的是求解器在每一步内部构造的插值多项式虽然也是近似但它是求解器误差控制的一部分一致性更好。做论文图、要做精细对比的时候优先走deval。需要事件检测时用四个输出[T, Y, TE, YE, IE] ode45(...)。TE是事件触发时刻YE是对应时刻的状态IE是触发的是第几个事件事件函数可以返回向量同时监控多个条件。不设Events的时候后三个返回空数组写了也白写。事件函数的具体写法放到最后一章。顺带提一句sol的另一个用处把上次算到一半的结果当初始条件接着算。sol.x(end)和sol.y(:,end)取出末端时刻和状态重组成新的tspan和y0再调一次比分段手算省事。长时间积分分段跑还有个好处中途可以检查结果是否发散不至于跑了一夜才发现早就炸了。4. 高阶方程降阶把 y 拆成两个一阶方程再喂进去4.1 变量替换的机械流程ode45只认一阶方程组二阶及以上必须先降阶。这一步没有技巧全是固定套路照着做就不会错。给定 n 阶方程y⁽ⁿ⁾ g(t, y, y, ..., y⁽ⁿ⁻¹⁾)令x1 yx2 y一直到xn y⁽ⁿ⁻¹⁾。求导后得到一阶方程组x1 x2 x2 x3 ... x(n-1) xn xn g(t, x1, x2, ..., xn)初始条件也要跟着换y0 [y(t0); y(t0); ...; y⁽ⁿ⁻¹⁾(t0)]。原来二阶方程给两个初值降阶后状态向量就是二维一一对应。拿资料里的方程y -t*y e^t*y 3*sin(2t)走一遍。令x1 yx2 y则x1 x2x2 -t*x1 e^t*x2 3*sin(2t)。初值y(3.9) 2、y(3.9) 8对应的y0就是[2; 8]。用表格把对应关系固定下来写代码时照着填原方程里的量状态向量分量初值yx(1)y0(1)yx(2)y0(2)y由方程组第二行给出不需要4.2 列向量 odefun 的写法与索引对齐function dx odefun(t, x) % x(1) y, x(2) y dx zeros(2, 1); % 必须预分配成列向量 dx(1) x(2); dx(2) -t*x(1) exp(t)*x(2) 3*sin(2*t); endzeros(2,1)这行是必须的。不预分配直接写dx(1) ...Matlab 会动态扩数组每次扩都重新分配内存短积分看不出来长积分就是性能黑洞。更要命的是如果不预分配、又漏写了某个分量返回的dx长度比y0短ode45会报维度不匹配报错信息指向求解器内部看不出问题出在你的函数里。调用tspan [3.9 4.0]; y0 [2; 8]; [t, x] ode45(odefun, tspan, y0); plot(t, x(:,1), -o, t, x(:,2), -*); legend(y, y); xlabel(t); ylabel(y);x(:,1)是yx(:,2)是y画图时别弄反。tspan只给了 0.1 的长度ode45会自动选步长输出的T可能只有几个点画出来的折线很粗想要平滑曲线就在tspan里给一串等距时间点或者把结果丢给deval。阶数再高也是同一套流程。三阶方程令x1 y、x2 y、x3 yodefun里dx就是 3×1。新手常犯的错是“降阶降了一半”dx(1) x(2)写了dx(2) x(3)忘了直接把y的表达式塞进dx(2)结果状态向量长度对不上或者方程物理意义全错。写完odefun后默念一遍每一行dx(i)的右边只能出现x的各分量和t出现y就是没换干净。4.3 三方程耦合系统定步长 RK4 与 ode45 的正面对照单个方程降阶只是热身真正体现向量化价值的是耦合方程组。资料里给的这组function dy test_fun(t, y) dy zeros(3, 1); dy(1) y(2) * y(3); dy(2) -y(1) y(3); dy(3) -0.51 * y(1) * y(2); end初值[1; 1; 3]区间[0 15]。三个方程通过y(1)、y(2)、y(3)互相耦合没有任何一个能单独解出来正好用来测向量化的runge_kutta1[T, F] ode45(test_fun, [0 15], [1; 1; 3]); [T1, F1] runge_kutta1(test_fun, [1; 1; 3], 0.25, 0, 15); subplot(1,2,1); plot(T, F); title(ode45 自适应步长); subplot(1,2,2); plot(T1, F1.); title(自编 RK4, h 0.25);注意F1.这个转置。runge_kutta1返回的y是3×(n1)每列一个时刻而plot按行画线不转置画出来是三条竖线加一堆横线看着像乱码。F1.是普通转置不是共轭转置这里全是实数所以两者等价但养成用.的习惯没坏处。两张图放在一起前几秒几乎重合到t 10之后开始分叉。原因不是 RK4 算错了而是h 0.25在这个系统里太粗。这组方程有正 Lyapunov 指数式的敏感依赖初值或步长的微小差异会被指数放大定步长的累积误差在长时间积分后压不住。要公平比较把ode45的MaxStep也设成0.25让它和自编版本走在同一批节点上opts odeset(MaxStep, 0.25, RelTol, 1e-6, AbsTol, 1e-8); [T2, F2] ode45(test_fun, [0 15], [1; 1; 3], opts);这时候两者差异会小很多剩下的差别来自ode45内部的五阶误差估计和自适应调整属于算法本身的差异不是实现错误。这个对照实验的价值在于它告诉你自编函数什么时候够用、什么时候必须让位给内置求解器。短区间、平滑解、精度要求不高自己写的定步长版本完全够长区间、有敏感依赖、或者要求误差可控ode45的自适应机制省下来的调试时间远超那点性能开销。再补一个实操细节runge_kutta1里n floor((b-a)/h)这里(15-0)/0.25 60正好整除不会丢尾巴。如果h取0.26n 57实际只积到14.82末端那一段凭空消失画图时T1和T的长度不一样plot会报错。用定步长函数之前先算一下步数或者改成linspace生成时间轴再反推h。5. 跑飞、报错、结果发散五条踩坑记录与排查路径5.1 几分钟没动静最后抛步长警告现象命令窗口刷出Warning: Unable to meet integration tolerances without reducing the step size below the smallest value allowed或者干脆卡在那里进度条纹丝不动。原因方程是刚性的。显式 Runge-Kutta 的稳定域有限遇到衰减极快的模态时步长被稳定性而不是精度按在地上越压越小最后撞上浮点下限。资料里那句“若长时间没结果应该就是刚性的”说的就是这个。解决换隐式求解器ode15s是首选ode23s、ode23tb备选。有解析 Jacobian 就通过odeset(Jacobian, J)传进去收敛会快不少。判断是否刚性有个土办法先跑ode45看它是不是在某个时间段步长骤降如果是那段就是刚性区。5.2 维度对不上赋值报错现象Unable to perform assignment because the size of the left side is 1-by-1 and the size of the right side is 3-by-1或者Index exceeds matrix dimensions。原因三种情况最常见。y0传了行向量odefun返回列向量dx没预分配动态增长时长度跑偏odefun里索引从 0 开始写Matlab 是 1 基。解决y0统一y0(:)odefun第一行dx zeros(n,1)索引检查一遍有没有x(0)。改完跑一个小tspan验证别直接上长区间。5.3 曲线末端炸开现象解在最后一段指数上升或者剧烈震荡跟解析解完全对不上缩小tspan又正常了。原因可能是方程本身在这个区间不稳定真解就发散可能是tspan写反了[4 1]这种降序写法ode45会报错但有些自编函数不检查也可能是步长越过了稳定域。解决先确认tspan(2) tspan(1)。再看真解是否本来就发散用解析解或者小步长ode15s交叉验证。如果是数值不稳定压MaxStep或者直接换隐式求解器。别一上来就怀疑方程写错了先排除求解器配置。5.4 自编 RK4 和 ode45 越比越偏现象两条曲线前半段重合后半段越差越远加大ode45的容差精度也没改善多少。原因比较基准不对。ode45返回的T是它自己踩的节点自编版本返回的是等距节点两者在完全不同的时间点上采样直接画图看起来就是两条曲线。长时间积分还有误差放大效应初始的微小差异被指数放大。解决用deval(sol, T1)在自编版本的时间点上取值再比或者给ode45设MaxStep h对齐步长。比较之前先明确你要验证的是“实现正确性”还是“算法精度”前者用短区间小步长后者才需要长区间。5.5 改了参数结果不变现象改了方程里的系数、或者改了odeset里的容差重新跑曲线一模一样。原因匿名函数在创建时就把外部变量值捕获了后面改工作区里的变量句柄里用的还是旧值。另一种情况是odeset的参数名拼错比如把RelTol写成Reltol这个其实能认不区分大小写或者写成RelTolerance拼错的名字被静默忽略。解决不要在循环里改完变量复用旧句柄每次都重新odefun (t,y) ...。参数多的话用额外入参odefun(t, y, p1, p2)较新版本的ode45支持这种写法把参数当第四个、第五个入参传进去比在句柄里捕获干净得多。odeset写完检查一遍拼写不确定就把opts打印出来看。6. 让结果站得住事件检测与步长减半验证事件检测是我用得最多、也最容易被忽略的功能。很多问题不需要完整积分只想知道“状态什么时候第一次穿过零”比如碰撞时刻、开关切换点、种群模型的灭绝时间。硬扫输出点再找符号变化精度受采样密度限制还容易漏掉快速穿越。Events直接把这个问题交给求解器。事件函数签名固定返回三个值function [value, isterminal, direction] myEvent(t, y) value y(1); % 监控第一个分量过零即触发 isterminal 1; % 1 触发时终止积分0 只记录不终止 direction 0; % 0 两个方向都抓1 只抓上升-1 只抓下降 end调用时把句柄塞进odeset四个输出里后三个就是事件结果opts odeset(Events, myEvent, RelTol, 1e-8); [t, y, te, ye, ie] ode45(test_fun, [0 15], [1; 1; 3], opts); fprintf(首次过零时刻 %.6f对应状态 [%.4f %.4f %.4f]\n, te(1), ye(1,:));te是触发时刻ye是对应状态ie是第几个事件事件函数返回向量时可以同时监控多个条件。isterminal设成 1 之后积分到点就停t和y只到那个时刻为止后面想看也看不到所以做完整轨迹分析时要设成 0。direction设错是常见的坑只关心上升沿却写了 0结果抓到的第一个点是下降沿后面所有索引都错位。验证数值解可信度我固定走一遍步长减半同一套代码h取一组值比较末端解的二范数差看比值是不是接近 16。接近说明四阶收敛成立结果可信远小于 16说明已经进到舍入误差主导区再减小步长没有意义远大于 16 或者乱跳说明撞上了稳定性边界或者方程本身有奇点得回头查模型。hs [0.2 0.1 0.05 0.025]; err zeros(size(hs)); ref runge_kutta1(test_fun, [1; 1; 3], 0.01, 0, 15); % 拿小步长当参考 for k 1:numel(hs) [~, yk] runge_kutta1(test_fun, [1; 1; 3], hs(k), 0, 15); err(k) norm(yk(:,end) - ref(:,end)); end ratio err(1:end-1) ./ err(2:end) % 理论上应接近 16这个习惯救过我好几次。有一次曲线看着挺漂亮收敛比却是 2 左右一查是odefun里漏了一项降阶之后方程根本没写全图上的光滑只是低阶方法碰巧拟合出来的假象。从那以后我每次交付数值解结果之前deval抽点对照和步长减半验证都强制走一遍两个都过才敢往下用。这份资料里的自编runge_kutta1和ode45对照脚本正好可以直接拿去做这件事改改右端函数就能套到自己的方程上。希望帮到你。本文还有配套的精品资源点击获取