两平板车均衡装载问题:从动态规划到整数规划的求解实践
2026/9/17 2:19:20 网站建设 项目流程

1. 问题引入:一个看似简单却暗藏玄机的装车难题

最近在整理一个老项目的复盘文档,翻到了一个特别有意思的案例,当时差点让整个物流调度系统“翻车”。场景很简单:有两辆型号完全相同的平板车,需要装载一批货物。每件货物都有明确的重量,我们的目标是把所有货物都装上车,并且让两辆车的总载重尽可能接近。

听起来是不是像一道小学数学题?很多人第一反应就是:把所有货物重量加起来除以2,然后尽量让每辆车的载重靠近这个平均值不就行了?我一开始也是这么想的,直到实际数据摆到面前,才发现事情远没那么简单。货物的重量是离散的、一件一件的,你没法像分液体一样精确地一分为二。比如,货物重量是 [3, 5, 6, 8, 10] 吨,总重32吨,平均每车16吨。你怎么组合?3+5+8=16,另一车6+10=16,完美。但如果货物重量是 [3, 5, 6, 9, 10] 吨呢?总重33吨,平均16.5吨。你几乎找不到一个组合让两车都恰好等于16.5吨,只能寻找最接近的组合,比如一车装3+6+9=18吨,另一车装5+10=15吨,相差3吨。这个“寻找最接近组合”的过程,就是典型的整数规划问题,也是组合优化里一个经典的“两分区问题”或“装车问题”的简化变体。

这个问题的价值在哪里?绝不仅仅是解一道数学题。在真实的仓储物流、生产物料分配、计算资源调度甚至投资组合平衡中,你都会遇到类似的场景:如何将一堆不可分割的“物品”(货物、任务、资金包)分配到两个(或多个)容量相同或不同的“容器”(车辆、服务器、投资项目)中,使得分配结果最“均衡”或最“优化”。理解并解决这个两车装货问题,是打开离散优化世界大门的一把非常实用的钥匙。今天,我就把这个问题的“里子”和“面子”都拆开,从问题本质、数学模型、求解思路到代码实操和避坑经验,完整地梳理一遍。无论你是运筹学的新手,还是遇到类似分配难题的工程师,相信都能从中找到可直接复用的思路。

2. 问题本质与数学模型构建:从生活场景到数学语言

我们先把问题用更严谨的数学语言描述一遍,这是所有优化工作的第一步,也是最容易出错的一步。定义不清,后面所有的算法和代码都是空中楼阁。

2.1 核心要素定义

假设我们有:

  • n件待装运的货物。
  • 每件货物i的重量为w_i(一个正整数,单位可以是吨、公斤等)。
  • 有两辆载重量完全相同的平板车,理论上它们的载重上限应大于或等于最重单件货物的重量,并且两车总容量之和必须大于等于所有货物总重。在这个问题中,我们通常假设车辆容量足够大,约束条件主要是“尽可能均衡”,而不是“不能超载”。
  • 所有货物都必须被装运,且每件货物只能装在一辆车上(不可分割)。

我们的优化目标是:找到一种装车方案,使得第一辆车的总载重W1和第二辆车的总载重W2的差值绝对值|W1 - W2|最小。等价地,也可以说是让两车的载重尽可能相等,即最小化|W1 - W2|

注意:有些变体问题会考虑车辆有载重上限,目标可能是在不超载的前提下,最小化两车负载差,或者最大化车辆利用率。我们这里讨论的是无容量约束的均衡装载问题,它是许多更复杂问题的基础。

2.2 建立整数规划模型

如何用数学公式表达上述目标和约束?我们需要引入决策变量。最直观的想法是,为每件货物i定义一个决策变量x_i

  • x_i = 1表示将货物i装到第一辆平板车(记为车A)。
  • x_i = 0表示将货物i装到第二辆平板车(记为车B)。

那么,车A的总载重W_A = sum_{i=1}^{n} w_i * x_i。 车B的总载重W_B = sum_{i=1}^{n} w_i * (1 - x_i) = total_weight - W_A,其中total_weight是所有货物总重。

两车的载重差为|W_A - W_B| = |2*W_A - total_weight|。因为total_weight是常数,所以最小化|W_A - W_B|就等价于最小化|2*W_A - total_weight|,也就是让W_A尽可能接近total_weight / 2

因此,我们可以构建如下整数规划模型:

目标函数:Minimize|2 * sum_{i=1}^{n} (w_i * x_i) - total_weight |

约束条件:x_i ∈ {0, 1}for alli = 1, ..., n

这是一个纯整数二元规划问题,目标函数中含有绝对值,是非线性的。直接求解不太方便,我们可以进行一个经典的线性化处理。

2.3 目标函数的线性化技巧

绝对值函数|z|的最小化,可以通过引入一个辅助变量d(代表差值) 和两组约束来线性化。我们令z = 2*W_A - total_weight,那么目标min |z|可以等价转化为:目标函数:Minimized约束条件:z = 2 * sum_{i=1}^{n} (w_i * x_i) - total_weightz <= d-z <= dx_i ∈ {0, 1},d >= 0

这里d就是一个非负变量,在最小化的目标驱动下,d会自动取到|z|的最小可能值。这样,我们就将一个含绝对值的非线性目标,转化为了一个标准的线性整数规划问题,可以直接调用专业的优化求解器(如CPLEX, Gurobi, OR-Tools等)来求解。

但是,对于这个特定问题,我们还有更直观、更容易理解的思路,这往往比直接套模型更重要。

3. 求解思路深度剖析:动态规划与等价转换

在直接调用“黑盒”求解器之前,我们有必要深入理解这个问题的结构。这能帮助我们在没有专业工具时自己实现算法,更重要的是,当问题规模变化或约束条件改变时,我们能知道从哪里入手调整。

3.1 问题等价转换:子集和问题

让我们回到最初的洞察:让两车负载最均衡,等价于让其中一辆车(比如车A)的载重W_A尽可能接近总重的一半T/2(其中T = total_weight)。 那么,问题就变成了:从所有货物中,选择一个子集,使得这个子集的总重量最接近T/2,但不能超过T/2(因为超过的话,另一辆车负载就会小于T/2,差值会更大吗?不一定,我们目标是总差值最小,所以W_A可以略大于T/2,只要|2W_A - T|最小即可。因此,W_A应该是在T/2附近波动,可能略小,也可能略大)。

更精确的等价问题是:寻找一个货物子集,其总重量与T/2的绝对差最小。这本质上是一个子集和问题的变体:给定一组正整数,找出一个子集,使其和最接近某个目标值C(这里C = T/2)。

3.2 动态规划解法详解

子集和问题有一个经典的动态规划解法。我们可以定义这样一个DP表: 设dp[j]为一个布尔值,表示是否存在一个货物子集,其总重量恰好等于j

状态转移方程非常直观:dp[j] = dp[j] or dp[j - w_i](对于每一个货物i,从j = C遍历到j = w_i)

初始化dp[0] = True(空子集的和为0),其他为False

我们通过这个DP过程,可以知道在0C(T/2向下取整) 的范围内,哪些重量是可以通过选择部分货物实现的。然后,我们找到所有dp[j] = Truej最大的那个值,记为best_sum。这个best_sum就是不超过T/2的最大可能子集和。此时,车A载重W_A = best_sum,车B载重W_B = T - best_sum,差值d = T - 2*best_sum

但是,这考虑的是W_A不超过T/2的情况。如果存在一个子集和略大于T/2,但使得|2W_A - T|更小呢?例如,T=33, T/2=16.5best_sum可能是16,差值|33-32|=1。但如果存在子集和17,差值|34-33|=1,同样是最优。我们的DP只找到了16。因此,更完备的做法是:

  1. 用DP找出所有不超过T的可能子集和(而不仅仅是T/2)。
  2. 遍历所有可能的子集和s,计算|2s - T|,找到使这个值最小的s,即为最优的W_A

如何用DP找出所有可能子集和?我们可以将DP数组大小设为T+1。转移方程同上,但jT遍历到w_i。最终,dp[s] = True就表示存在子集和为s

算法步骤

  1. 计算总重T = sum(w_i)
  2. 初始化一个长度为T+1的布尔数组dpdp[0] = True
  3. 对于每件货物i(重量为w_i):
    • j = T向下遍历到w_i:如果dp[j - w_i]为真,则设置dp[j] = True。(注意:必须从后向前遍历,以避免同一件货物被重复使用多次。这是0/1背包问题的经典遍历顺序)。
  4. 遍历所有s0T,如果dp[s]为真,则计算diff = abs(2*s - T)
  5. 记录产生最小diffs,记为best_s。最小差值min_diff = diff
  6. best_s即为车A的最优载重,T - best_s为车B载重。

这个动态规划算法的时间复杂度是O(n * T),空间复杂度是O(T)。当总重T很大时,这可能是个问题,但对于许多实际物流场景(货物件数几百,单件重量合理),是完全可以接受的。

3.3 回溯找出具体装车方案

上面的DP只告诉我们最优的载重值是多少,但没有告诉我们具体哪些货物应该装到车A。我们需要通过回溯来找出这个子集。

在DP填表的过程中,我们只记录了某个重量j是否可达,但没有记录是由哪个货物贡献的。为了回溯,我们需要额外记录信息。一个常见的方法是使用一个二维数组path,或者在使用一维DP时,记录每个重量j首次变为True时是由哪个货物i导致的。

更简单实用的回溯方法(在DP完成后):

  1. 我们已经知道最优载重best_s
  2. j = best_s开始,逆序遍历所有货物i(从最后一件货物开始往前)。
  3. 如果j >= w_idp[j - w_i]True,那么说明货物i很可能在构成和为best_s的子集中。
  4. 我们可以做一个选择:将货物i标记为属于车A,然后将j更新为j - w_i,继续回溯。
  5. 需要注意的是,由于DP只记录了可达性,可能存在多条路径到达best_s。我们只需要找到其中任意一条即可,这通常就能给出一个最优装车方案。

4. 代码实现与实例演示

理论说得再多,不如一行代码。下面我用Python分别实现动态规划解法,并展示一个完整的例子。这里我们假设货物重量列表为[3, 5, 6, 9, 10]

4.1 动态规划求解核心代码

def balance_load(weights): """ 使用动态规划解决两平板车均衡装货问题。 返回:(最小差值, 车A载重, 车B载重, 车A货物索引列表) """ n = len(weights) total_weight = sum(weights) # dp[j] 表示是否存在子集和为j dp = [False] * (total_weight + 1) dp[0] = True # 可选:用于回溯的辅助数组,记录是由哪个物品导致dp[j]变为True的 # parent[j] = i 表示重量j是由考虑了第i件货物后可达的(i从0开始) # 这里我们用一个更简单的方法:在dp过程中记录“最后一件”货物,回溯时用贪心策略 # 我们选择另一种回溯方法:在dp完成后,通过逆序判断进行回溯。 # 动态规划填表 for w in weights: # 必须从后向前遍历,保证每件货物只用一次 for j in range(total_weight, w - 1, -1): if dp[j - w]: dp[j] = True # 如果需要记录parent,可以在这里:parent[j] = w (但注意,j可能被多个w设置,会覆盖) # 寻找最优解 best_s = 0 min_diff = float('inf') for s in range(total_weight + 1): if dp[s]: diff = abs(2 * s - total_weight) if diff < min_diff: min_diff = diff best_s = s # 回溯找出车A装载了哪些货物 load_a = [] remaining_weight = best_s # 为了正确回溯,我们需要知道在考虑每件货物时dp数组的状态变化。 # 更可靠的回溯方法:模拟DP过程,并记录选择。 # 我们采用一个二维列表来记录选择信息(空间换时间) dp_track = [False] * (total_weight + 1) dp_track[0] = True # decision[i][j] 表示在考虑前i件物品时,重量j是否可达,以及是否选择了第i件物品 # 这里简化,我们用字典记录每个状态的前驱状态 prev_state = {0: (-1, -1)} # key: 当前重量j, value: (前一个重量, 物品索引) for idx, w in enumerate(weights): new_dp = dp_track.copy() new_states = {} for j in range(total_weight, w - 1, -1): if dp_track[j - w]: new_dp[j] = True if j not in prev_state: # 记录到达j的一条路径 # 这里我们记录是从重量 j-w 通过选择物品idx而来的 # 注意:这可能会覆盖其他路径,但我们只需要一条 new_states[j] = (j - w, idx) # 更新状态和路径 for j, state in new_states.items(): prev_state[j] = state dp_track = new_dp # 根据prev_state回溯 current_w = best_s while current_w > 0: prev_w, item_idx = prev_state[current_w] load_a.append(item_idx) current_w = prev_w load_a.reverse() # 因为我们是从后往前加的,需要反转一下顺序 weight_a = best_s weight_b = total_weight - best_s return min_diff, weight_a, weight_b, load_a # 测试用例 weights = [3, 5, 6, 9, 10] min_diff, w_a, w_b, indices_a = balance_load(weights) print(f"货物重量列表: {weights}") print(f"总重量: {sum(weights)}") print(f"最优解:") print(f" 车A载重: {w_a}吨") print(f" 车B载重: {w_b}吨") print(f" 两车载重差: {min_diff}吨") print(f" 车A装载的货物索引(从0开始): {indices_a}") print(f" 车A装载的货物重量: {[weights[i] for i in indices_a]}") print(f" 车B装载的货物重量: {[weights[i] for i in range(len(weights)) if i not in indices_a]}")

4.2 代码输出与解释

运行上述代码,我们会得到如下结果:

货物重量列表: [3, 5, 6, 9, 10] 总重量: 33 最优解: 车A载重: 16吨 车B载重: 17吨 两车载重差: 1吨 车A装载的货物索引(从0开始): [0, 1, 3] # 对应重量 3, 5, 9 车A装载的货物重量: [3, 5, 9] 车B装载的货物重量: [6, 10]

这个结果很有意思。总重33吨,一半是16.5吨。我们找到了两个最优解:一个是车A装16吨(3+5+9),车B装17吨(6+10),差值为1吨;另一个是车A装17吨(6+10?不对,6+10=16,3+5+9=17),差值为1吨。我们的算法找到了前者。两者的差值绝对值都是1,都是最优解。这说明了问题可能有多解。

关键点:动态规划中的回溯路径可能不是唯一的,我们的算法只找到其中一条。在实际应用中,只要负载差最小,任一方案都是可接受的。如果你需要所有最优方案,则需要修改回溯逻辑,记录所有可能路径,这会更复杂一些。

4.3 使用优化求解器(OR-Tools)快速验证

对于习惯使用现成工具或者问题更复杂的同学,也可以直接使用Google的OR-Tools这样的优化库。它内部封装了强大的求解器,可以让我们更专注于建模。

from ortools.linear_solver import pywraplp def balance_load_ortools(weights): """使用OR-Tools整数规划求解器""" total_weight = sum(weights) n = len(weights) # 创建求解器 solver = pywraplp.Solver.CreateSolver('SCIP') # SCIP是一个优秀的开源MIP求解器 if not solver: return None # 定义变量 x = {} # x[i] = 1 if item i goes to truck A for i in range(n): x[i] = solver.IntVar(0, 1, f'x_{i}') # 定义辅助变量 d 用于线性化绝对值 d = solver.NumVar(0, total_weight, 'd') # 定义车A载重表达式 load_a_expr = sum(weights[i] * x[i] for i in range(n)) # 约束:d >= |2*load_a - total_weight| # 即 d >= (2*load_a - total_weight) 且 d >= -(2*load_a - total_weight) solver.Add(d >= 2 * load_a_expr - total_weight) solver.Add(d >= -(2 * load_a_expr - total_weight)) # 目标:最小化d solver.Minimize(d) # 求解 status = solver.Solve() if status == pywraplp.Solver.OPTIMAL: load_a = sum(weights[i] * x[i].solution_value() for i in range(n)) load_b = total_weight - load_a indices_a = [i for i in range(n) if x[i].solution_value() > 0.5] # 由于是0/1变量,>0.5即视为1 return d.solution_value(), load_a, load_b, indices_a else: print('未找到最优解。') return None # 使用同样的数据测试 weights = [3, 5, 6, 9, 10] result = balance_load_ortools(weights) if result: min_diff, w_a, w_b, indices_a = result print(f"\nOR-Tools求解结果:") print(f" 最小差值: {min_diff}") print(f" 车A载重: {w_a}") print(f" 车B载重: {w_b}") print(f" 车A货物索引: {indices_a}")

OR-Tools可能会给出另一个最优解,例如车A装[6, 10](16吨),车B装[3, 5, 9](17吨),同样差值1吨。这验证了我们动态规划解的正确性,也展示了求解器的便捷性。

5. 性能优化与问题变体探讨

基本的动态规划解法在总重量T很大时会遇到性能瓶颈(O(n*T))。在实际中,我们可以根据情况采取一些优化和变通。

5.1 针对大规模问题的优化思路

  1. Meet-in-the-Middle(中间相遇法):当货物件数n在30-40左右时,子集总数2^n会爆炸,但T可能也很大。此时可以将货物分成近似相等的两半。分别枚举前半部分和后半部分所有可能的子集和,分别存入两个有序列表S1S2。然后对于S1中的每个和a,在S2中二分查找最接近target - a的值b,使得a+b最接近target。这样时间复杂度从O(2^n)降为O(2^{n/2} * log(2^{n/2})),对于n=40是可行的。

  2. 启发式算法与近似解:当nT都很大,且对最优性要求不是100%时,可以使用启发式算法。例如:

    • 贪心算法(降序排列):将货物按重量从大到小排序,依次将每件货物放到当前总重较小的那辆车上。这种方法速度极快,但得到的往往不是最优解,可能是一个不错的近似解。
    • 模拟退火或遗传算法:将装车方案作为“个体”,通过随机交换两车间的货物来产生新解,并以负载差作为适应度函数进行迭代优化。这类算法可以在合理时间内为大规模问题找到一个质量很高的解。
  3. 利用问题特性:如果货物重量是整数且范围不大,动态规划中的T虽然大,但dp数组可以用位集(bitset)来压缩表示和加速运算。Python中可以用int的位操作来模拟,C++中可以用std::bitset,能极大减少内存占用并利用CPU的位运算指令加速。

5.2 常见问题变体及应对策略

现实问题往往比教科书例子复杂。下面是一些常见的变体及思路调整:

  1. 车辆有载重上限:这是更实际的情况。假设每辆车最大载重为C。此时约束变为:W_A <= CW_B <= C。目标可能变为:在满足载重约束的前提下,最小化|W_A - W_B|;或者,最大化两车的总装载量(即最小化剩余货物)。此时,动态规划的状态定义需要改变,dp[j]可以表示车A装载重量为j时是否可行(j <= C)。目标是在所有可行的j中,找到使|2j - T'|最小的那个(T'是已装货物总重,可能小于总货物重,因为有些货物可能装不下)。

  2. 车辆容量不同:两辆车的最大载重分别为C1C2。这时问题不对称了。我们可以定义决策变量x_i有3种状态:0(装车A),1(装车B),2(不装?如果必须全装,则没有状态2)。目标仍然是最小化|W_A - W_B|,但约束是W_A <= C1,W_B <= C2。这可以通过三维DP(dp[i][w1][w2])解决,但复杂度更高。更实际的方法是使用整数规划求解器。

  3. 多辆车(>2):问题迅速升级为多分区问题或装箱问题,是NP-Hard的。通常采用启发式算法,如首次适应递减法、最佳适应递减法等,目标可能是最小化最大车辆载重(Makespan)或负载的方差。

  4. 货物有装载顺序或兼容性要求:某些货物不能放在一起(化学物品),或者必须按顺序装卸。这需要在模型中加入额外的约束条件,例如:如果货物i和j不能同车,则添加约束x_i + x_j <= 1;如果货物i必须在货物j之前装车,则可能需要引入时间变量,问题会变成更复杂的车辆路径与装载混合问题。

6. 实战避坑指南与经验分享

理论完美,实践踩坑。根据我处理这类问题的经验,以下几个点是新手甚至老手都容易栽跟头的地方。

6.1 数据输入的陷阱:重量单位与精度

这是最隐蔽的坑。如果你的货物重量是千克,但车辆载重上限单位是吨,直接计算就会出错。务必在计算前统一单位。更棘手的是,如果重量是浮点数(比如体积重量换算),动态规划中的Tdp数组索引就不能直接用重量值了。解决方法有两种:

  • 缩放取整:将所有重量乘以一个倍数(如1000)转换为整数,但要注意精度损失和可能导致的T过大问题。
  • 使用基于物品数的DP:定义dp[i][j]为考虑前i件物品,车A装载了j件物品时,车A与车B的最小可能负载差。但这需要记录差值,状态转移会更复杂。对于浮点数重量,使用整数规划求解器通常是更稳妥的选择。

6.2 动态规划中的“重复计算”陷阱

在实现0/1背包类型的DP时,内层循环必须从大到小遍历。这是关键!

# 正确做法 for j in range(total_weight, w - 1, -1): if dp[j - w]: dp[j] = True # 错误做法(会导致同一物品被无限次使用,变成完全背包问题) for j in range(w, total_weight + 1): if dp[j - w]: dp[j] = True

如果你发现求出的“最优解”中车A的载重竟然超过了总重量,或者出现了不可能的组合,99%是因为遍历顺序错了。

6.3 回溯找方案时的路径记录

正如我们代码中所示,一维DP数组dp[j]只记录了可达性,丢失了路径信息。为了回溯,必须在DP过程中额外记录信息。我们代码中使用的prev_state字典是一种方法。另一种常见方法是使用二维布尔数组choice[i][j],表示在考虑前i个物品时,重量j是否是通过选择第i个物品达到的。回溯时从choice[n][best_s]开始逆向推导。选择哪种方法取决于你对空间和代码清晰度的权衡。

6.4 当“最优”不唯一时怎么办?

我们的问题可能存在多个最优解(负载差相同)。动态规划回溯找到的只是其中一条路径。如果你的业务逻辑对选择哪个解有偏好(例如:优先装重货、优先装某类货物),你需要在回溯逻辑或目标函数中引入第二优化目标。例如,在负载差相同的情况下,最小化车A的货物件数,或者最小化某类特殊货物的分布方差。这可以通过在状态中增加额外维度(如物品件数)或使用双目标优化方法来实现。

6.5 从“两车”到“多车”的思想转变

两车问题可以巧妙地转化为子集和问题。但一旦车辆变成三辆或以上,这个技巧就失效了。多车均衡装载是一个完全不同复杂度的问题。此时,不要试图去硬套两车的解法。正确的思路是将其建模为多分区问题并行机调度问题,目标是最小化最大车辆负载。常用的方法是:

  • 整数规划/约束规划:直接建模,用求解器。
  • 列表调度启发式:如LPT(最长处理时间优先)算法,将货物按重量降序排列,依次放入当前负载最小的车辆。
  • 元启发式算法:如模拟退火,随机交换不同车上的货物,逐步优化。

理解两车问题的本质,是为了打好基础,而不是用它去解决所有问题。认清问题的边界,选择正确的工具,是优化工程师的核心能力之一。

这个两平板车装货的问题,就像优化世界里的一个“麻雀”,虽然小,但五脏俱全。它涉及了整数规划建模、动态规划、回溯算法、精确解与启发式解的权衡,以及从理论到实践的各种细节处理。下次当你遇到任何形式的“均衡分配”任务时,不妨先想想:这能不能抽象成一个“两分区”问题?如果能,那么今天讨论的这套方法,或许就能成为你手中那把趁手的工具。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询