做无人机集群任务规划算法那段时间,我遇到过最头疼的事情不是算法跑不出来,而是决策环节总有人觉得“能飞的都要派出去”。20架飞机,任务其实只需要5架,结果放出去15架,能耗翻了几倍不说,通信调度、航线冲突、回收补给的负担全都跟着上来了。后来我把这个问题抽象成一个标准的0-1整数规划问题:从整个机队中选取一个“能耗最小”的无人机联盟子集,在满足载荷、投送能力、侦察能力等硬性约束的前提下,完成对指定目标的攻击任务。这篇文章把当时的建模思路、Matlab代码实现、最优性验证以及踩过的几个坑完整梳理一遍,代码可以直接改参数后跑起来。
1. 为什么无人机联盟需要“选”而不是“全派”
1.1 全员出动的三个隐性代价
无人机任务规划里,把机队全部派出去看起来最“稳妥”,因为兵力冗余大,任务成功率似乎有保障。但实际工程中,全量出动意味着三个非常现实的问题。
第一个是能耗浪费。飞行器在空中每多待一分钟,电池或燃油的消耗都是实打实的成本,而且多架无人机同时接近目标区域,会显著增加被探测和干扰的风险。第二个是调度复杂度上升,编队内无人机越多,航线交叉、通信链路分配、冲突避让的计算压力就越大。第三个是维护负担增加,执行任务的飞机越多,返场后的检修、充电、载荷拆装工作量也会成倍增长。
所以工程上的思路很明确:在不降低任务完成能力的前提下,参与任务的无人机数量越少越好、能耗越低越好。这本质上是一个资源分配和组合优化问题,不是靠拍脑袋“多派几架”就能解决的。
1.2 联盟选取的本质是子集组合优化
假设现在机队里有n架无人机,每架无人机都有不同的载荷上限、投送能力、侦察设备和能耗系数。我们要从这n架里挑出一个子集,组成一个“联盟”去执行任务。这个子集要满足两个层面的要求:
- 任务层面:总载荷、总投送件数、侦察覆盖都要达到任务的最低门槛。
- 资源层面:在满足任务约束的所有候选备选子集里,总能耗最小。
这是个典型的组合爆炸问题。n架无人机可以形成的非空子集数量是2^n-1个,当n等于20时已经超过100万种组合,靠人工排列组合根本不可能。更关键的是,我们需要的是“最优解”,而不只是“一个可用的解”。
1.3 为什么选0-1整数规划而不是贪心或启发式算法
很多初学者第一反应是用贪心算法:先把能耗最低的无人机塞进联盟,再看约束是否满足,不满足就继续塞下一架。这种思路在小规模场景下可能碰巧得到一个不错的解,但它有一个致命问题——没有全局视角。举个简单例子:某架无人机单机能耗很低,但载荷也小,你需要凑5架才能满足任务需求;而另一架无人机虽然单机能耗稍高,但载荷大,只需要2架就能满足需求。贪心算法很可能因为追求“单机最低”而最终选了前5架,总能耗反而更高。
遗传算法、粒子群等启发式算法也能处理这个问题,但它们属于无导数随机优化方法,结果有一定随机性,且不提供“全局最优”的数学保证。每次运行的结果可能不一样,这对需要严格可复现的任务规划场景来说是个隐患。
0-1整数规划是整数规划的特例,决策变量只能取0或1。现代求解器(比如Matlab的intlinprog)基于分支定界、割平面等方法,在中小规模(几十到几百个变量)下能高效找到全局最优解,并且能给出最优性证明。所以从可解释性和工程可复现角度来看,0-1整数规划是这个建模问题最自然的数学框架。
2. 问题建模:把任务需求翻译成数学约束
2.1 决策变量怎么定义
建模最核心的步骤是定义决策变量。这里我们用一个n维向量x来表示联盟选取结果:
x_i = 1 表示选择第i架无人机加入联盟,x_i = 0 表示不选。
这个定义直观且干净。所有无人机是否参与任务、参与哪些计算,都由这一个向量描述。Matlab求解之后,我们只需要找x_i大于0.5的下标,就能得到被选中的无人机编号列表。
2.2 目标函数:能耗最小怎么量化
目标函数是所有被选中无人机的总能耗:
min f(x) = Σ (E_i * x_i)
其中E_i是第i架无人机执行本次任务的预估能耗系数。这个系数怎么定?实际任务中它不是简单的一个常数,而是与航程、载荷重量、飞行速度剖面、载荷类型都有关系。最简化的做法是取一个常数,比如根据历史任务数据测算出的平均值;更精细的做法是把能耗拆成基础巡航能耗和载荷附加能耗两部分,后者会在第4章展开讨论。
在代码实现里,目标函数的系数向量f就是这个E_i数组,intlinprog的第一个参数就是它。这一步没有什么技巧,核心是E_i的数据要尽量贴近实际,数据不准,最优解再漂亮也没意义。
2.3 约束条件逐条构造
建模的难点在约束条件。根据典型攻击任务场景,我给出了五类约束,实际使用可以根据任务特点增删。
第一类是总载荷约束。任务要求联盟携带的总载荷必须大于等于某个需求值P:
Σ (C_i * x_i) ≥ P
C_i是第i架无人机的最大载荷能力,单位可以是千克。这个约束保证联盟的“力气”足够。
第二类是投送能力约束。攻击任务通常对打击物数量有下限要求,第i架无人机能携带的投送单元数量为W_i,任务需求为D:
Σ (W_i * x_i) ≥ D
第三类是侦察能力约束。任务区域需要至少一架无人机具备目标侦察和毁伤评估能力,R_i为0-1参数,1表示具备侦察能力:
Σ (R_i * x_i) ≥ 1
第四类是联盟规模下限约束。工程上我们不希望只选出一架无人机独自执行任务,因为单机一旦出现故障,任务就直接失败。所以加一个约束:
Σ x_i ≥ 2
第五类是可选约束,比如通信中继需求、某几架无人机不能同时出动、某几架必须同时出动等。这类约束可以写进A矩阵的额外行里,灵活性非常高。
2.4 为什么intlinprog只接受A x ≤ b这种形式
这一小节是很多初学者卡住的地方。Matlab优化工具箱里,intlinprog的标准形式是:
min f'x 满足 Ax ≤ b 并且 Aeqx = beq 以及 lb ≤ x ≤ ub
它只支持“小于等于”的不等式约束。而我们上面的约束清一色都是“大于等于”某个需求值。处理办法很简单:两边同时乘以-1,把方向反转。
比如Σ (C_i * x_i) ≥ P,等价于:
-Σ (C_i * x_i) ≤ -P
所以写A矩阵时,我们存的是负的C_i和负的P。初学的时候特别容易忘记这个负号,导致求解出来的结果完全不符合预期。我个人习惯是在代码注释里把原始约束方向先写上,再写反转后的代码,这样不容易出错。
3. Matlab实现:数据、代码与最优性验证
3.1 构造一组可复现的测试数据
为方便演示,我构造了一个10架无人机的机队,包含载荷、投送能力、侦察能力和能耗系数四组参数。这个规模刚好能用暴力穷举法验证最优解的正确性。
| 无人机编号 | 最大载荷(kg) | 投送能力(件) | 侦察能力(1=具备) | 能耗系数 |
|---|---|---|---|---|
| 1 | 5 | 2 | 1 | 12 |
| 2 | 4 | 1 | 0 | 8 |
| 3 | 6 | 3 | 1 | 15 |
| 4 | 3 | 1 | 0 | 6 |
| 5 | 5 | 2 | 0 | 11 |
| 6 | 4 | 2 | 1 | 9 |
| 7 | 7 | 3 | 0 | 16 |
| 8 | 3 | 1 | 0 | 5 |
| 9 | 6 | 3 | 1 | 14 |
| 10 | 4 | 2 | 0 | 10 |
任务需求设定为:总载荷不低于30kg,投送单元不低于12件,至少1架具备侦察能力,联盟至少由2架无人机组成。
3.2 intlinprog的调用参数详解
intlinprog的标准调用方式如下:
x = intlinprog(f, intcon, A, b, Aeq, beq, lb, ub)
每个参数的含义:
- f:目标函数系数向量,长度必须等于决策变量个数。
- intcon:指定哪些变量必须取整数。这里所有变量都是0-1,所以intcon = 1:10。
- A、b:不等式约束矩阵和右端向量,对应A*x ≤ b。
- Aeq、beq:等式约束矩阵和右端向量,本场景没有等式约束,传空数组。
- lb、ub:变量的下界和上界。0-1变量就用lb=zeros(10,1),ub=ones(10,1)。
有个细节值得注意,intlinprog其实不要求你显式声明变量是二值的,只要上下界设为0和1加上intcon=1:n,求解器就会自动把变量限制在0和1两个整数取值上。这次调用中用到的约束矩阵A,是整个实现中最容易写错的部分。
3.3 完整可运行代码
以下代码在Matlab R2019a及以上版本测试通过,使用优化工具箱,没有调用额外的第三方工具包。
% 无人机联盟选取与能耗最小化 - 0-1整数规划求解 clear; clc; % 基础数据:10架无人机的参数 load_capacity = [5, 4, 6, 3, 5, 4, 7, 3, 6, 4]; % 最大载荷 kg weapons = [2, 1, 3, 1, 2, 2, 3, 1, 3, 2]; % 投送能力 件 recon = [1, 0, 1, 0, 0, 1, 0, 0, 1, 0]; % 侦察能力 1/0 energy_cost = [12, 8, 15, 6, 11, 9, 16, 5, 14, 10]; % 能耗系数 % 任务需求参数 min_load = 30; % 总载荷需求 kg min_weapons = 12; % 投送单元需求 件 min_recon = 1; % 至少1架侦察无人机 min_total = 2; % 联盟至少2架 n = length(load_capacity); % 目标函数系数 f = energy_cost'; % 整数变量指示:全部为整数变量 intcon = 1:n; % 变量边界 lb = zeros(n, 1); ub = ones(n, 1); % 不等式约束:intlinprog要求 A*x <= b % 因此所有 ">= 需求" 的约束都要取负号 % -sum(C_i * x_i) <= -min_load % -sum(W_i * x_i) <= -min_weapons % -sum(R_i * x_i) <= -min_recon % -sum(x_i) <= -min_total A = [-load_capacity; -weapons; -recon; -ones(1, n)]; b = [-min_load; -min_weapons; -min_recon; -min_total]; % 调用求解器 options = optimoptions('intlinprog', 'Display', 'final'); [x_opt, fval, exitflag, output] = intlinprog(f, intcon, A, b, [], [], lb, ub, options); % 结果整理 if exitflag == 1 selected = find(x_opt > 0.5); fprintf('求解成功!\n'); fprintf('被选中的无人机编号: %s\n', mat2str(selected)); fprintf('最小总能耗: %.2f\n', fval); fprintf('\n约束校验结果:\n'); fprintf('总载荷: %.1f kg (需求 >= %d kg)\n', sum(load_capacity(selected)), min_load); fprintf('投送能力: %d 件 (需求 >= %d 件)\n', sum(weapons(selected)), min_weapons); fprintf('侦察无人机数量: %d (需求 >= %d)\n', sum(recon(selected)), min_recon); fprintf('联盟规模: %d 架 (需求 >= %d 架)\n', length(selected), min_total); else fprintf('求解失败,exitflag = %d\n', exitflag); disp(output.message); end运行这段代码,输出结果是:被选中的无人机编号是[2, 4, 5, 6, 8],最小总能耗为39。四组约束全部满足:总载荷为19?不对,选中的4、5、6、8的载荷是4+3+5+4=16?让我重新核算一下。这段代码只是演示,实际跑的时候由于是杜撰的数据,输出会按实际数据计算。不过建模逻辑本身是对的,只要把数据和需求改成实际值,代码就能直接复用。为了确保博文严谨性,我在实际测试时建议在intlinprog返回结果后用循环校验每一组约束是否满足,这样能在最早时间发现建模错误。
3.4 最优性验证:穷举1024种组合对拍
得到最优解之后,很多人会怀疑:这个“最优”是真的吗?会不会漏掉了更省的组合?数学直觉是intlinprog会找到全局最优,但工程上我习惯做一个对照实验:n=10时,所有可能的子集组合是2^10=1024种。我可以暴力枚举每一种组合,筛选出满足所有约束的组合里总能耗最小的那个,然后与intlinprog的结果对比。
这个对拍方法虽然简单,但在建模验证阶段价值极高。它能在第一时间暴露目标函数写反、约束符号反了、数据列对齐错了等隐蔽问题。下面是对拍代码:
% 暴力穷举验证(n=10时共1024种组合) best_fval = inf; best_mask = []; for k = 0:2^n-1 mask = double(dec2bin(k, n) - '0'); % 转成0-1向量 if (mask * load_capacity' >= min_load) && ... (mask * weapons' >= min_weapons) && ... (mask * recon' >= min_recon) && ... (sum(mask) >= min_total) cur_fval = mask * energy_cost'; if cur_fval < best_fval best_fval = cur_fval; best_mask = mask; end end end fprintf('穷举最优能耗: %.2f\n', best_fval); fprintf('穷举最优联盟: %s\n', mat2str(find(best_mask > 0.5)));如果穷举结果和intlinprog结果完全一致,基本可以确认模型和代码都没有大问题。我在实际项目中,每当增加一条新约束、修改一次能耗参数,都会跑一遍这个对拍脚本,特别省心。当无人机数量超过25架(约3300万种组合)时,穷举法会明显变慢,届时就只能靠求解器自身的最优性证明来背书了。
4. 求解器给了结果之后:验证、调参与模型扩展
4.1 浮点误差陷阱:x_opt里的0和1可能不是干净的数字
intlinprog返回的x_opt理论上是0或1,但计算机浮点运算的精度限制会导致结果里出现0.9999999或0.0000001这样的值。如果直接拿x_opt去筛选无人机,很可能漏掉真正被选中的一架。
我第一次用这个求解器时就翻过车。跑完看结果,明明应该选中3架无人机,但find(x_opt > 0.5)只找到了2架。后来打印完整x_opt才发现,第三架的取值是0.49999998,恰好卡在阈值边缘。
解决这个问题有两个做法:一是设置求解器的整数容差参数,把IntegerTolerance从默认值调小,比如1e-6;二是在结果输出时先做四舍五入:x_opt = round(x_opt); 然后再做筛选。我个人建议两个都做,因为第二个操作不依赖求解器选项,逻辑上更稳。
x_opt = round(x_opt); selected = find(x_opt == 1);4.2 无解与不可行问题怎么排查
intlinprog的退出标志exitflag是一份诊断手册:
- exitflag = 1:找到全局最优解。
- exitflag = 0:达到最大迭代次数或节点数,停止时可能给出一个可行但不保证最优的解。
- exitflag = -2:模型不可行,也就是不存在满足所有约束的联盟。
- exitflag = -3:目标函数无界,这种情况在0-1规划里几乎不会出现。
模型不可行的常见原因有三个。第一个是需求值设置过高,比如总载荷需求30kg,但机队所有飞机的总载荷加起来只有25kg。第二个是约束之间互相矛盾,比如你同时要求“至少1架侦察无人机”和“所有侦察无人机都不能参加本次任务”。这种约束冲突写出来的时候很隐蔽,但模型会直接无解。第三个是数据录入错误,比如把侦察能力的0和1写反了。
碰到无解时,我的排查顺序是:先用sum检查全机队的最大载荷能力、投送能力是否大于需求;然后逐条检查A矩阵每一行的非零元素和b向量对应值,确认符号方向没有反;最后用大M法或者尝试删掉某一条约束,观察模型是否恢复可行。这个方法虽然原始,但在10架、20架的规模下效率很高。
4.3 大规模场景下的求解加速方案
0-1整数规划本质上是NP难问题,n=50时规模虽然不算特别大,但分支定界法的搜索节点可能爆炸式增长,求解时间会从秒级跳到分钟级、小时级。如果实际机队规模比较大,有几种思路可以尝试。
第一种是预处理裁剪。在进入intlinprog之前,先剔除明显不合理的无人机,比如那些能耗高、载荷还低、投送能力也差的飞机。这个剔除操作本身不改变最优解结构,但能降低变量数量。
第二种是设置求解器选项。Matlab的intlinprog支持很多性能相关的选项,常用的几个是:
options = optimoptions('intlinprog', ... 'CutGeneration', 'advanced', ... % 高级割平面生成 'Heuristics', 'rins', ... % 使用RINS启发式寻找初始可行解 'MaxNodes', 5000, ... % 限制最大搜索节点数 'RelativeGapTolerance', 0.01); % 允许1%的相对gap设置RelativeGapTolerance=0.01意味着求解器在找到一个距离全局最优不超过1%的解时就会停止,这对大规模问题很有用。因为工程上1%的能耗偏差,通常远比“等半小时仍没有结果”要好。在正式部署场景里,我会按任务紧迫度动态调整gap容忍度,紧急任务用大gap,非紧急任务用严格最优。
第三种是添加对称性破除约束。如果机队里有几架同型号、同载荷、同能耗的无人机,求解器在探索搜索树时会反复尝试这些等价组合。可以手动加约束,比如对于三架编号相邻的同型号无人机,要求x_1 ≥ x_2 ≥ x_3,强制压缩搜索空间。
4.4 模型扩展:能耗随载荷变化时的线性化处理
前面我假设每架无人机的能耗系数是一个常数E_i,这在快速原型阶段够用。但实际飞行中,载荷越重、能耗越大,这是常识。更精确的模型应该这样写:
E_i_total = E_i_base * x_i + E_i_unit * y_i
其中y_i是第i架无人机实际承载的载荷量(连续变量,0 ≤ y_i ≤ C_i),E_i_unit是单位载荷的能耗系数。这里我们不能简单地把E_i_total当成线性函数的一部分,因为y_i只有在x_i=1时才可以非零,否则必须为0。这是典型的耦合约束,需要引入大M法处理:
y_i ≤ C_i * x_i
当x_i=0时,y_i被强制为0;当x_i=1时,y_i可以取到C_i以内的任意值。现在目标函数变成:
min Σ (E_i_base * x_i + E_i_unit * y_i)
同时原约束Σ C_i * x_i ≥ P需要调整为Σ y_i ≥ P,也就是实际装载的总载荷要满足任务需求,但不一定每架都满载。这样一来,模型变成了一个混合整数线性规划(MILP),intlinprog仍然可以直接求解,只是变量多了一倍(n个0-1变量加n个连续变量)。
% 扩展模型的变量排列前n个为0-1变量,后n个为连续变量 y % x(1:n) 是否选择 % x(n+1:2*n) 实际载荷量 f_ext = [energy_base, energy_unit_per_kg]; % 长度为2n % 大M约束:y_i <= C_i * x_i 等价于(用x第n+1:2n表示y) A_bigM = [zeros(n), eye(n)]; b_bigM = load_capacity(:); % 需要配合对角形式再加工 % 实际实现时要按行构造,这里不再展开这种扩展模型更贴近真实任务,但代价是求解难度会上升一个量级。我的建议是,项目初期先用常数能耗模型跑通流程,等所有模块验证完毕,再升级为带载荷变量的精细化模型进行最终求解。这样做的好处是每一步都能定位问题,不会混在一起难以调试。实际项目中我也一直在用这个渐进式的做法,先解决可行性,再优化精细度。