简介:面向物流管理、运筹学、工业工程及数学建模等专业的课程设计、期末大作业与毕业设计场景,这份基于Matlab的报童问题仿真源码包,以经典单周期随机库存模型为核心对象,帮助读者理解需求不确定条件下最优订货量的决策逻辑与利润期望计算过程。压缩包内共4个文件,均为m脚本,包含一个主程序与三个辅助模块:主程序负责参数初始化、循环模拟与结果输出,三个辅助脚本分别构造正态、均匀与指数分布三类典型随机需求场景,配合详细注释可清晰看到蒙特卡洛模拟从随机数生成、样本利润累加到最优解搜索的完整实现链条。整套代码体积仅4KB,结构轻量、逻辑集中,适合已具备Matlab基础的学生对照学习、调试排错或扩展需求分布函数。目前已有65人学习下载,注释覆盖每个关键步骤,既便于撰写实验报告,也可作为答辩演示时讲解模型原理与代码流程的辅助材料。
1. 报童问题仿真的起点:为什么要在Matlab里做
校园报亭的老板每天要决定进多少份报纸,订多了剩下只能按废纸卖,订少了顾客买不到,利润白白溜走。这就是经典的报童问题:需求随机、订货量单一、错过当天就再也没有补偿机会。很多人第一次接触它是在运筹学的课堂上,课本给出临界比公式,但真让你给库存系统定一个进货量时,你还需要知道这个公式是怎么从随机数据里“长”出来的。
用Matlab做报童问题仿真,最大的好处是随机数生成、概率分布拟合、数组批量运算和可视化全在一个环境里。你不需要像Python那样反复拼接多个库,也不用手动实现蒙特卡洛的循环优化。把需求分布参数一改,利润曲线立刻画出来,最优订货量直接从图中读出来。这套源码把每一个变量、每一行计算都做了注释,适合三类人来读:正在准备物流与供应链课程设计的学生、想在库存决策里引入仿真验证的运营工程师、以及想搞懂蒙特卡洛方法如何在离散决策问题落地的Matlab使用者。
2. 报童问题的数学模型与仿真流程拆解
2.1 成本与利润函数:利润导向和成本导向两种建模口径
报童问题可以写成“利润最大化”,也可以写成“期望损失最小化”。为了贴合Matlab仿真中最直接的评分函数,我一般用利润导向的公式。
设单份报纸的进价为c,售价为p,如果卖不出去,收盘前以残值s处理,通常s远低于c。给定订货量Q,某天的实际需求为D,那么:
- 卖出数量:
sold = min(Q, D) - 剩余数量:
leftover = Q - sold - 当日收入:
p * sold + s * leftover - 当日成本:
c * Q - 当日利润:
(p - c) * sold - (c - s) * leftover
换个角度看,p - c是每卖出一份赚的钱,c - s是每剩一份亏的钱。如果缺货了,需求大于订货量的部分虽然不直接出现在利润公式里,但因为leftover为0,缺货量越大,sold被限制在Q,利润无法进一步增长,这其实等价于损失了机会收益。想更严格地刻画缺货惩罚,可以在利润上再减一项shortage_penalty * max(D - Q, 0),但那样会引入新的主观参数,所以我通常不在基础仿真里加。
在库存理论中,最优订货量有一个解析临界值:
- 超储成本
Co = c - s - 缺货成本
Cu = p - c - 最优订货量
Q*满足F(Q*) = Cu / (Cu + Co),其中F是需求累计分布函数
这个公式不需要仿真就能算,但它的前提是需求分布已知。仿真存在的意义,恰恰是在你不确定需求分布、或者分布比较复杂(比如截断、混合分布)时,依然能用随机样本逼近最优解。所以临界比公式是后续验证仿真程序的“标准答案”,不是替代方案。
2.2 蒙特卡洛仿真三大步骤:生成样本、枚举订货量、统计平均
报童问题的蒙特卡洛仿真流程很固定,写成伪代码只有三步。
生成需求样本。根据对需求历史的判断,选择分布并生成N天的随机需求向量。如果手头有历史销售记录,更好的做法是先用fitdist拟合出分布参数,再从这个拟合分布中抽样。注意需求必须是整数且非负,所以用正态分布生成后要取整,并把负值截断为0。
枚举订货量。从0到一个足够大的Q_max,步长通常取1。Q_max至少要覆盖需求分布的99%分位数,否则最优值可能被截掉。
统计平均利润。对每个订货量Q,把需求向量整体代入利润公式,算N天的平均利润,得到一条Q与平均利润的曲线,最高点对应最优订货量。
这个流程中使用向量化运算,Matlab里不需要对每天写循环:
sold = min(Q, demand); leftover = Q - sold; daily_profit = price * sold + salvage * leftover - cost * Q; avg_profit = mean(daily_profit);min(Q, demand)利用了Matlab的隐式扩展,标量Q会和demand向量的每个元素比较,一次返回整个向量。后面的profit计算同样是对向量操作,最终mean得到平均利润。理解这个向量化模式,后面源码主循环里才能读懂为什么算300个Q也就一两秒。
需求分布的选取直接影响仿真结论,下面这张表是常见的几个选择:
| 需求分布 | Matlab生成函数 | 典型应用场景 |
|---|---|---|
| 正态分布 | normrnd(mu, sigma, N, 1)+round | 日均需求大、波动对称 |
| 泊松分布 | poissrnd(lambda, N, 1) | 事件到达率固定、需求离散且均值等于方差 |
| 负二项分布 | nbinrnd(r, p, N, 1) | 过离散数据,方差远大于均值 |
| 经验分布 | datasample(history, N, 'Replace', true) | 有真实历史记录,不想强加分布假设 |
如果用的是泊松分布,生成结果天然是非负整数,不需要截断。但泊松要求方差等于均值,实际销售数据往往方差偏大,此时用负二项分布更稳妥。我见过不少课程设计直接用正态分布,最后仿真出现负需求,这是最容易踩的坑。
3. 基于Matlab的报童问题仿真源码实现
3.1 主脚本:一轮完整仿真并画出利润曲线
下面这套源码就是标题里说的“源码+详细注释”的核心版本。把全部代码保存为newsboy_sim.m,直接运行即可看到最优订货量和利润曲线。
% newsboy_sim.m % 报童问题蒙特卡洛仿真主脚本 % 运行环境:Matlab R2019b 及以上(低版本需手动处理隐式扩展) clear; clc; rng(42); % 固定随机种子,保证每次运行结果可复现 % ---------- 经营参数 ---------- price = 5.0; % 每份报纸售价 p cost = 2.0; % 每份报纸进价 c salvage = 0.5; % 闭市后每份残值 s Q_max = 300; % 最大订货量,建议设置为需求99.9%分位数以上 N = 10000; % 仿真样本量,代表模拟10000天的营业情况 % ---------- 需求分布参数 ---------- mu = 120; sigma = 25; % 日均需求均值与标准差 demand = max(round(normrnd(mu, sigma, N, 1)), 0); % 取整为整数,负数截断为0,避免“卖负份报纸”的荒谬场景 % ---------- 枚举订货量 ---------- Q_list = 0:Q_max; % 从0逐步试到Q_max avg_profit = zeros(size(Q_list)); % 预分配内存,提升循环速度 for i = 1:length(Q_list) Q = Q_list(i); sold = min(Q, demand); leftover = Q - sold; revenue = price * sold + salvage * leftover; total_cost = cost * Q; daily_profit = revenue - total_cost; avg_profit(i) = mean(daily_profit); end % ---------- 提取最优解 ---------- [max_profit, idx] = max(avg_profit); optimal_Q = Q_list(idx); fprintf('最优订货量 Q* = %d\n', optimal_Q); fprintf('对应平均利润 = %.4f\n', max_profit); % ---------- 可视化 ---------- figure; plot(Q_list, avg_profit, 'b-', 'LineWidth', 1.5); xlabel('订货量 Q'); ylabel('平均利润'); title('报童问题仿真:平均利润随订货量变化'); grid on; hold on; plot(optimal_Q, max_profit, 'ro', 'MarkerFaceColor', 'r');这段代码的逻辑顺序是:先固定随机种子,保证改天再跑能得到同样曲线;接着定义所有经营参数,需求分布用均值为120、标准差为25的正态分布近似;然后从0到300逐一遍历订货量,每个Q都复用同一个demand向量,确保不同Q之间的差异纯粹来自订货量,不混入新的随机噪声。
rng(42)之所以重要,是因为没有固定种子时,两次运行的最优Q可能差1到2份,初学者往往误以为算法写错了。参数Q_max如果设得太小,比如只有100,而需求经常超过150,最优解就被截掉了,曲线会一直上升,看不到顶点。建议先用prctile(demand, 99.9)检查需求上限,再决定Q_max。
3.2 用真实历史数据拟合需求分布
没有历史数据时,像上面那样手填期望和方差就够了。但如果你有过去几十天的销售记录sales_history,应该用数据说话,而不是拍脑袋估计。
% fit_demand.m % 从历史需求数据拟合分布,并生成仿真用需求样本 pd = fitdist(sales_history, 'Normal'); % 拟合正态分布 mu_hat = pd.mu; sigma_hat = pd.sigma; % 生成仿真需求 demand_sim = max(round(random(pd, N, 1)), 0); % 或者不假设正态,直接用经验分布重抽样 demand_bootstrap = datasample(sales_history, N, 'Replace', true);fitdist是Statistics and Machine Learning Toolbox里的函数,如果报错,说明没装这个工具箱。替代方案是手动计算均值和标准差:mu_hat = mean(sales_history); sigma_hat = std(sales_history);再用normrnd抽样。
经验分布重抽样datasample的好处是完全不依赖分布假设,适合有大量历史记录的库存系统。它的缺点是样本只会重复出现历史里出现过的值,若真实需求可能取到20.5这类历史中没有的整数,重抽样无法外推。混合做法是先用fitdist选模型,再用统计检验判断拟合效果。
3.3 封装成函数:参数化调用与批处理
脚本适合一次性分析,但如果你要给不同成本参数跑几十组对比,最好封装成函数。下面这个函数把主脚本的核心逻辑抽出来,输入价格、成本、残值和需求分布参数,输出最优订货量以及整条利润曲线。
function [optimal_Q, Q_list, avg_profit] = ... newsboy_solver(price, cost, salvage, mu, sigma, Q_max, N) % newsboy_solver 报童问题仿真求解函数 % 输入: % price, cost, salvage : 售价、进价、残值 % mu, sigma : 需求均值和标准差(正态分布) % Q_max, N : 最大订货量、仿真样本量 % 输出: % optimal_Q : 最优订货量 % Q_list : 被枚举的订货量向量 % avg_profit : 每个订货量对应的平均利润 rng('default'); demand = max(round(normrnd(mu, sigma, N, 1)), 0); Q_list = (0:Q_max)'; avg_profit = zeros(size(Q_list)); for i = 1:length(Q_list) sold = min(Q_list(i), demand); leftover = Q_list(i) - sold; daily_profit = price * sold + salvage * leftover - cost * Q_list(i); avg_profit(i) = mean(daily_profit); end [~, idx] = max(avg_profit); optimal_Q = Q_list(idx); end封装后的函数可以放进另一份参数扫描脚本里,配合前面的newsboy_sim.m主脚本,一个负责单次分析,一个负责批量计算。注意函数内部使用了rng('default'),这会把随机种子重置到Matlab启动时的默认状态,如果你想在多次运行中得到不同结果,可以把这个种子改为函数入参。
4. 仿真结果分析:分布、样本量和参数敏感性
4.1 不同需求分布下的最优订货量差异
很多人以为需求只要均值相同,用什么分布都差不多,实际上不是这样。在利润公式里,超储和缺货造成的损失是不对称的,因此分布的偏度和尾部形状会显著改变最优订货量。
用三组分布做对比:正态分布(120, 25)、泊松分布λ=120、负二项分布(均值约120,方差约200)。从临界比公式可以定性推断,方差异常大的负二项分布会有更长的右尾,需求经常冲到160以上,导致缺货概率上升,为了减少缺货损失,最优Q会右移。泊松分布的方差等于均值,分布更集中,最优Q在临界分位数附近,而经验分布可能因为历史数据中的几个极端日而让最优Q明显增大。
仿真曲线也有特征:方差越大,利润曲线越平缓,峰值区域越宽。这说明当需求波动大时,Q在最优解附近变动10份,平均利润损失并不大;反之需求很稳定时,利润曲线顶部尖锐,订多订少都会快速亏钱。
如果要把这个对比做进仿真,可以用第三节的函数分别传入不同的需求生成逻辑。注意正态分布需要取整截断,负二项分布虽然生成的是整数,但参数化方式是用失败次数和成功概率,写代码时直接调用nbinrnd(r, p, N, 1),想固定均值需要先解方程,不如直接用fitdist从历史数据拟合。
4.2 样本量N对最优解稳定性的影响
蒙特卡洛仿真的结果天然带随机误差,样本量越小,最优Q抖动越厉害。为了直观看到这个现象,可以跑这样一组实验:
N_list = [100, 500, 1000, 5000, 20000]; result_table = zeros(length(N_list), 2); for k = 1:length(N_list) rng(k); demand = max(round(normrnd(120, 25, N_list(k), 1)), 0); % 复用第3章中的枚举循环,得到最优Q % 记录到 result_table end运行后会看到,N=100时最优Q可能从128跳到140,N=5000以上才稳定在135左右。原因在于,平均利润曲线的最大值区间本身很平,样本噪声带来的微小误差足够让峰值点左右移动好几格。所以如果只想得到一个“大致可用”的答案,N=1000就能满足;但如果你想比较两个策略之间3%的利润差异,至少需要N=10000。
从计算成本上看,Q_max=300、N=10000的循环体在Matlab里只要几十毫秒,N=20000也只是一百多毫秒。瓶颈反而在画图时的交互响应,所以大胆用大样本。内存方面,存储demand向量的大小是N×8字节,N=10万也才0.8MB,完全不需要担心。
4.3 成本参数变化时最优订货量的移动规律
改变cost、price、salvage中的任何一个,利润曲线都会整体变形。最直观的验证办法是固定price=5、salvage=0.5,把cost从1.5逐步调到3.5,观察最优Q如何下降。
从临界比公式看,当成本升高时,超储成本Co = cost - salvage变大,缺货成本Cu = price - cost变小,临界比Cu/(Cu+Co)变小,对应需求累积分布的分位数左移,最优Q自然降低。反过来,若残值s提高,剩余报纸的损失变小,商家更愿意多进货。
这部分对应到业务上的含义是:如果你的供应商涨价,你不仅要少进货,更要用仿真重新算出精确的进货量,而不是凭感觉打个八折。源码里可以用for循环遍历不同的cost值,把每个cost对应的最优Q记录下来,画成一张价格-订货量曲线,供采购决策参考。
5. 让仿真更可靠:固定种子、置信区间和解析验证
5.1 多次独立运行与置信区间
一次仿真得到的optimal_Q只是一个点估计。稳健的做法是改变随机种子跑20次,得到20个最优Q,看它们的波动范围。
num_runs = 20; Q_samples = zeros(num_runs, 1); for k = 1:num_runs rng(100 + k); demand = max(round(normrnd(120, 25, 10000, 1)), 0); % 枚举Q,记录最优值 Q_samples(k) = optimal_Q; end fprintf('最优Q的均值: %.2f\n', mean(Q_samples)); fprintf('标准差: %.2f\n', std(Q_samples));当标准差小于1时,说明结果已经稳定;如果标准差大于3,就该增大样本量N。这个是给任何仿真类项目排除“随机抖动”的通用手段,报童问题也不例外。
5.2 用临界比解析解做交叉验证
仿真代码写完后,一定要用理论公式验证实现没有bug。正态分布需求下,用norminv直接算出解析解:
Cu = price - cost; % 缺货成本 Co = cost - salvage; % 超储成本 q_theory = norminv(Cu / (Cu + Co), mu, sigma); fprintf('解析最优Q = %.2f,仿真最优Q = %d\n', q_theory, optimal_Q);q_theory是连续值,optimal_Q是整数,两者通常差不超过1。如果差得很多,优先检查需求生成时是否正确取整、截断负值,以及Q_max是否盖住了峰点。这一步能筛掉九成逻辑错误。
5.3 扩展到多周期和带订货提前量的场景
报童问题是最经典的单周期模型,它的Matlab仿真框架稍加改动就能扩展。把单日需求向量改成多周期需求矩阵,每行代表一个周期,加上期初库存、固定订货成本、提前期等条件,利润函数就从向量运算变成矩阵运算,核心的min(Q, demand)逻辑依然可用。扩展时尽量保留主脚本的注释风格,每个参数都写明含义,这样别人接手时不用猜你两行前的Co是什么。
把以上代码组合成一个newsboy_sim.m,配合newsboy_solver函数和固定随机种子,你就拥有了一套既能快速验证理论、又能应对真实需求形状的库存仿真工具箱。
本文还有配套的精品资源,点击获取