1. 项目概述与问题背景
去年带队参加数学建模竞赛,碰上了华为杯B题“方形件组批优化问题”,这题目一出来,当时我们团队就意识到,这绝对是个典型的工业工程与运筹学交叉的硬骨头。它模拟的是板材切割下料(Cutting Stock Problem)和订单组批(Order Batching)的混合场景,在制造业,尤其是家具、钣金、玻璃、服装排版等行业里太常见了。简单说,就是你有一堆不同尺寸的方形订单(我们称之为“件”),需要把它们安排到更大尺寸的方形原材料板(我们称之为“板”)上进行切割,目标是用最少的板,或者让板材利用率最高,从而节省成本。
但这个问题“优化”二字前面加了“组批”,味道就变了。它不再是简单的、单一批次的下料问题。题目隐含了生产调度中的“批处理”思想:哪些订单可以合并在一起生产(即“组”成一个“批”),共享同一块原材料板?这涉及到订单的交付期、板材的库存或规格限制、以及切割工艺本身的约束。比如,两个订单如果尺寸相差太大,硬塞到一块板上,边角料可能多到令人发指;但如果尺寸互补,说不定就能严丝合缝,利用率飙升。所以,核心挑战在于如何动态地“挑选”和“组合”订单,形成一个又一个的“批次”,并对每个批次进行高效的排版布局,最终使得所有订单完成的同时,总成本(通常正比于使用的板材总数)最小化。
这问题对数学建模的要求非常综合。它需要清晰的优化模型(通常是混合整数规划,MILP)来描述复杂的空间布局和逻辑组合关系,需要高效的算法(精确求解器或启发式算法)来处理大规模问题实例,还需要扎实的编程能力(Python是绝对主力)来实现模型和算法,并进行可视化验证。下面,我就结合我们当时的解题全过程,把模型建立、算法设计、编程实现以及那些踩过的坑,系统地梳理一遍。
2. 问题核心与数学模型构建
面对这样一个二维布局与组合优化问题,第一步也是最重要的一步,就是用一个严谨的数学模型把它“框”住。模型建得好,后面的求解和编程才能事半功倍。
2.1 关键要素与假设澄清
在动笔写公式之前,必须明确问题中的“玩家”和“规则”:
- 方形件 (Items): 有
N个待生产的订单,每个订单i对应一个需要切割出来的方形件,其边长为w_i(宽) 和h_i(高)。注意,方形件允许旋转90度,这意味着在排版时,我们可以将件视为(w_i, h_i)或(h_i, w_i)。这是一个重要的自由度。 - 原材料板 (Plates): 我们拥有一种或多种规格的方形原材料板。为简化,我们先考虑单一规格,板的长和宽均为
W。目标是使用尽可能少的这种板。 - 组批 (Batching): 并不是所有件都必须放在同一块板上。我们需要决定如何将
N个件划分到K个批次(对应K块板)中。每个批次内的件将被排版到同一块板上。K本身也是一个需要最小化的变量。 - 排版布局 (Packing): 对于分配给同一块板的件,我们需要确定每个件在板上的具体位置(左下角坐标
(x_i, y_i))以及是否旋转,同时确保任意两个件不重叠,且所有件都在板的边界内。
基于此,我们引入第一个关键假设:采用“先组批,后排版”的两阶段框架。即先决定件的分组,再对每个组独立求解排版问题。虽然存在更复杂的联合优化模型,但两阶段法结构清晰,易于建模和求解,是实际中非常常用的策略。
2.2 混合整数规划模型详解
我们选择混合整数规划作为核心模型框架,因为它能精确地表达“如果-那么”这类逻辑约束。
1. 决策变量定义:
z_{ik}: 0-1变量。如果件i被分配到板k,则为1;否则为0。r_i: 0-1变量。表示件i是否旋转。r_i=0表示按原方向(w_i, h_i)放置;r_i=1表示旋转90度,按(h_i, w_i)放置。x_i, y_i: 连续变量。表示件i在它所分配的板上的左下角横、纵坐标。l_{ij}, r_{ij}, a_{ij}, b_{ij}: 0-1变量。用于处理件i和件j(假设它们在同一块板k上)之间的不重叠约束。这是一组辅助变量,其含义是:l_{ij}=1表示件i在件j的左边(即x_i + w_i' <= x_j)。r_{ij}=1表示件i在件j的右边(即x_i >= x_j + w_j')。a_{ij}=1表示件i在件j的下边(即y_i + h_i' <= y_j)。b_{ij}=1表示件i在件j的上边(即y_i >= y_j + h_j')。 其中w_i'和h_i'是根据旋转变量r_i调整后的实际占用宽度和高度:w_i' = (1-r_i)*w_i + r_i*h_i,h_i' = (1-r_i)*h_i + r_i*w_i。
u_k: 0-1变量。表示板k是否被使用。
2. 目标函数:最小化使用的板材总数。由于我们预先不知道会用到多少块板,可以预设一个足够大的板索引上限K_max(例如K_max = N,最坏情况下一件一板)。
Minimize: sum_{k=1}^{K_max} u_k3. 约束条件:
- 分配约束:每个件必须且只能分配到一块板上。
sum_{k=1}^{K_max} z_{ik} = 1, for all i - 板使用逻辑:如果至少有一个件分配给板
k,则该板被标记为使用。z_{ik} <= u_k, for all i, k也可以写成sum_i z_{ik} <= N * u_k,但前一种形式在求解器中通常更紧。 - 边界约束:件必须在板边界内。注意,
x_i和y_i只有在件i被分配到板k时才有意义。我们需要用大M法将分配关系与坐标约束耦合。对于所有i和k:
这里的x_i + w_i' <= W + M*(1 - z_{ik}) y_i + h_i' <= W + M*(1 - z_{ik}) x_i >= 0 - M*(1 - z_{ik}) y_i >= 0 - M*(1 - z_{ik})M是一个很大的正数(例如M = 2*W)。当z_{ik}=0(件i不在板k上)时,约束被放松,自动成立;当z_{ik}=1时,约束生效,强制件在板内。 - 不重叠约束:这是模型中最复杂的部分。对于任意两个不同的件
i和j,如果它们被分配到同一块板(即存在某个k使得z_{ik}=1且z_{jk}=1),那么它们必须满足“不重叠”条件,即至少满足l_{ij}, r_{ij}, a_{ij}, b_{ij}中的一个为1。同样使用大M法:
最后一条约束强制至少一个方向分离条件成立。当x_i + w_i' <= x_j + M*(1 - l_{ij}) + M*(2 - z_{ik} - z_{jk}) x_i >= x_j + w_j' - M*(1 - r_{ij}) - M*(2 - z_{ik} - z_{jk}) y_i + h_i' <= y_j + M*(1 - a_{ij}) + M*(2 - z_{ik} - z_{jk}) y_i >= y_j + h_j' - M*(1 - b_{ij}) - M*(2 - z_{ik} - z_{jk}) l_{ij} + r_{ij} + a_{ij} + b_{ij} >= 1 - M*(2 - z_{ik} - z_{jk})i和j不在同一板时,(2 - z_{ik} - z_{jk}) >= 1,右侧可能为负数,约束自动满足,不强制它们分离。 - 变量类型约束:所有
z_{ik},u_k,r_i,l_{ij},r_{ij},a_{ij},b_{ij}为0-1变量;x_i,y_i为非负连续变量。
注意:大M的选取与模型性能:这是混合整数规划建模的经典技巧,也是容易出问题的地方。
M不能太小,否则可能错误地截断可行域;也不能太大,否则会导致线性规划松弛质量很差,求解器难以找到强割平面,从而极大拖慢求解速度。一个稳妥的取法是M = W + max(w_i, h_i),确保即使件放在板外一点,约束也能被“放松”。
这个模型是一个标准的混合整数线性规划模型,可以直接丢给 CPLEX、Gurobi 或开源的 SCIP 等求解器。但它的规模会随着件数N急剧膨胀,特别是O(N^2)级别的l, r, a, b变量和约束,当N超过几十时,求解将变得非常困难。因此,我们必须借助算法思维来简化或寻找近似解。
3. 求解策略与算法设计
面对大规模问题,直接求解完整的MILP模型是不现实的。我们需要设计高效的启发式或元启发式算法。这里介绍我们当时采用的“基于序列的启发式算法”结合“局部搜索”的策略,它在求解质量和时间上取得了很好的平衡。
3.1 两阶段求解框架再认识
我们明确采用两阶段:
- 组批阶段:决定哪些件组成一个批次。目标是让每个批次内件的总“面积”尽可能接近板材面积
W*W,同时兼顾件尺寸的多样性,以便于后续排版。 - 排版阶段:对每个已形成的批次,求解一个二维矩形带装填问题,目标是能否将所有件放入
W*W的板中。这里可能涉及旋转。
这两个阶段相互影响。组批结果直接影响排版的可行性;而排版算法的能力强弱,又决定了我们组批时约束可以放松到什么程度。一个常见的策略是:组批阶段采用宽松的评估(如仅基于面积),排版阶段则采用较强的算法(如启发式布局算法)去尝试实现,如果失败,则反馈给组批阶段进行调整。
3.2 组批启发式算法:基于面积和相似度的聚类
我们放弃了直接优化z_{ik}这种“硬”建模,转而采用一种“软”的聚类思想。
- 核心指标:批次内件总面积
S_batch = sum_{i in batch} w_i * h_i。理想情况下,S_batch应略小于W*W(考虑到缝隙和布局浪费)。 - 辅助指标:尺寸多样性。如果一批里全是细长条或全是小方块,可能很难紧密排版。我们引入一个简单的“形状差异度”度量,例如计算批次内件长宽比的方差。
- 算法步骤(类似首次适应递减FFD变种):
- 预处理:将所有件按面积从大到小排序。大件优先处理,因为它们更难安排。
- 批次创建:初始化一个空批次。遍历排序后的件列表。
- 尝试放入:对于当前件
i,遍历所有已创建的批次。如果某个批次b满足S_batch + area_i <= W*W * utilization_target(例如utilization_target=0.95,预留5%空间给布局浪费),并且放入后批次的形状差异度变化在可接受范围内,则将件i放入该批次b,更新S_batch。 - 创建新批次:如果现有批次都无法容纳(面积超限或形状太差),则为件
i创建一个新的批次。 - 后处理:检查所有批次,对于面积过小的批次,尝试将其合并到其他批次中,前提是合并后面积不超标且排版算法验证可行。
这个算法速度很快,O(N log N + N*B),其中B是批次数量。但它得到的只是一个“粗糙”的组批方案,需要后续排版阶段验证和修正。
3.3 排版启发式算法:左下角放置策略与模拟退火
对于单个批次的排版问题,我们采用了经典的“左下角放置”策略作为构造算法,并辅以“模拟退火”进行优化。
- 左下角放置:
- 将批次内的件按某种规则排序(如宽度递减、面积递减、或长边递减)。
- 初始化一个空的
W*W板,维护一个“左下角候选点集”。初始点集只包含原点(0,0)。 - 按顺序处理每一个件。对于当前件,尝试所有可能的放置方式(原始方向和旋转90度)。对于每种放置方式,从候选点集中选择一个点放置,使得放置后,件的右上角坐标尽可能小(即更“紧凑”),通常选择使得
y坐标最小,其次x坐标最小的点。 - 放置后,将该件从列表中移除,并更新候选点集:通常将新放置件的右上角右侧点和上方点加入候选集,并移除被覆盖的点。
- 如果某个件找不到任何可放置的位置,则本次排版失败。
- 模拟退火优化: 左下角放置的结果严重依赖于件的处理顺序。我们可以用模拟退火来搜索更好的顺序。
- 状态:一个件的排列顺序。
- 邻域操作:随机交换两个件的位置,或者随机将一个件移动到另一个位置。
- 能量函数:排版完成后,所使用的板的高度(因为我们固定宽度为W,所以目标是最小化排版后的最大高度,理想情况是 <= W)。如果排版失败,则赋予一个很高的惩罚值。
- 过程:从初始顺序(如面积递减)开始,以一定温度
T初始化。在每次迭代中,生成一个邻域状态,计算能量差ΔE。如果ΔE < 0(新状态更优),则接受新状态;否则以概率exp(-ΔE / T)接受。然后按照冷却计划降低温度T。 - 终止:当温度降到阈值以下,或达到最大迭代次数时停止。
将排版算法封装成一个函数can_pack(batch_items),返回布尔值(是否成功)以及排版布局详情。这个函数将用于验证组批结果,并指导组批算法的调整。
3.4 整体求解流程串讲
- 数据读入与预处理:读入
N,W, 所有件的(w_i, h_i)。计算面积、长宽比等特征。 - 初始组批:运行基于面积和相似度的聚类算法,得到初始批次集合
Batches。 - 批次可行性验证与迭代调整:
- 对每个批次,调用
can_pack函数尝试排版。 - 将所有排版失败的批次标记出来。
- 修复策略:对于失败批次,将其拆散,将其中的件重新作为“未分配件”插入到之前的组批流程中,但这次可以调整
utilization_target(调低)或形状差异度容忍度。也可以尝试将失败批次中的某个“问题件”与其他批次中的某个件交换。 - 重复“验证-修复”过程若干次,直到所有批次都能成功排版,或达到迭代上限。
- 对每个批次,调用
- 输出结果:输出最终的批次划分方案、每块板上的具体排版坐标和旋转状态、总用板数
K、以及总的板材利用率(所有件总面积) / (K * W * W)。
4. Python程序实现与关键代码解析
我们选择 Python 作为实现语言,主要依赖pulp(用于小规模MILP验证或子问题求解)、numpy、matplotlib(用于可视化)。核心算法部分自己实现。
4.1 数据结构设计
class Item: def __init__(self, id, width, height): self.id = id self.width = width self.height = height self.area = width * height self.rotated = False # 初始未旋转 class Batch: def __init__(self, batch_id): self.batch_id = batch_id self.items = [] # 存放 Item 对象 self.total_area = 0 self.layout = None # 排版结果,格式:{item_id: (x, y, rotated)} class PackingSolver: def __init__(self, plate_width): self.W = plate_width # ... 其他初始化4.2 左下角放置算法实现
def bottom_left_pack(self, items, ordering='width_decreasing'): """ 尝试用左下角策略排版一组物品。 :param items: Item 对象列表 :param ordering: 排序规则 :return: (success, layout_dict, used_height) """ # 1. 排序 if ordering == 'width_decreasing': sorted_items = sorted(items, key=lambda it: max(it.width, it.height), reverse=True) elif ordering == 'area_decreasing': sorted_items = sorted(items, key=lambda it: it.area, reverse=True) else: sorted_items = items[:] placed = [] # 已放置的物品信息,每个元素为 (x, y, w, h) candidate_points = [(0, 0)] # 左下角候选点 for item in sorted_items: best_place = None best_point = None best_rect = None # (x, y, w, h) # 尝试两种朝向 for rotated in [False, True]: w = item.height if rotated else item.width h = item.width if rotated else item.height if w > self.W or h > self.W: # 单个件超过板尺寸,直接失败 if not rotated: return False, {}, 0 else: continue # 遍历所有候选点,找到最佳放置位置 for (cx, cy) in candidate_points: # 检查放置后是否超出边界 if cx + w > self.W or cy + h > self.W: continue # 检查是否与已放置件重叠 overlap = False for (px, py, pw, ph) in placed: if not (cx + w <= px or cx >= px + pw or cy + h <= py or cy >= py + ph): overlap = True break if overlap: continue # 找到可行位置,评估“紧凑度”,这里用 cy + h (放置后的顶部y坐标)作为指标 if best_place is None or (cy + h) < best_place[1]: best_place = (cx, cy + h) # 记录顶部y坐标用于比较 best_point = (cx, cy) best_rect = (cx, cy, w, h) item.rotated = rotated # 临时记录旋转状态 if best_rect is None: # 该物品无处可放 return False, {}, 0 # 放置物品 placed.append(best_rect) # 更新候选点集:移除被覆盖的点,添加新的候选点(右上方和正上方) new_candidate_points = [] for (cx, cy) in candidate_points: if not (best_point[0] <= cx < best_point[0] + best_rect[2] and best_point[1] <= cy < best_point[1] + best_rect[3]): new_candidate_points.append((cx, cy)) # 添加新点:放置物的右上角右侧和正上方 new_candidate_points.append((best_point[0] + best_rect[2], best_point[1])) # 右侧 new_candidate_points.append((best_point[0], best_point[1] + best_rect[3])) # 上方 # 去重和排序(可选,按x然后y排序) candidate_points = sorted(list(set(new_candidate_points)), key=lambda p: (p[1], p[0])) # 计算排版后的最大高度 max_height = max([y + h for (x, y, w, h) in placed], default=0) success = max_height <= self.W # 生成布局字典 layout_dict = {} for idx, item in enumerate(sorted_items): # 注意:这里需要根据实际放置情况匹配item和placed中的矩形,简化处理假设顺序一致 x, y, w, h = placed[idx] # 需要反推出item的旋转状态,这里简化,实际需要更精细的匹配 layout_dict[item.id] = (x, y, item.rotated) return success, layout_dict, max_height4.3 模拟退火优化排版顺序
def simulated_annealing_pack(self, items, initial_order=None, max_iter=1000, initial_temp=10.0, cooling_rate=0.99): """ 使用模拟退火优化物品顺序,寻找可行排版。 """ if initial_order is None: current_order = items[:] random.shuffle(current_order) else: current_order = initial_order[:] # 计算当前状态的能量 success, layout, height = self.bottom_left_pack(current_order, ordering='given') if not success: current_energy = float('inf') else: current_energy = height # 能量就是排版高度,越低越好 best_order = current_order[:] best_energy = current_energy best_layout = layout T = initial_temp for i in range(max_iter): # 生成邻域状态:随机交换两个物品 new_order = current_order[:] a, b = random.sample(range(len(new_order)), 2) new_order[a], new_order[b] = new_order[b], new_order[a] # 评估新状态 success, new_layout, new_height = self.bottom_left_pack(new_order, ordering='given') new_energy = float('inf') if not success else new_height # 决定是否接受新状态 delta_e = new_energy - current_energy if delta_e < 0 or (current_energy != float('inf') and random.random() < math.exp(-delta_e / T)): current_order = new_order current_energy = new_energy if success: current_layout = new_layout # 更新全局最优 if current_energy < best_energy: best_energy = current_energy best_order = current_order[:] best_layout = current_layout # 降温 T *= cooling_rate if T < 1e-3: break final_success = best_energy != float('inf') return final_success, best_layout, best_energy, best_order4.4 主程序流程控制
def main(): # 1. 读取数据 data = load_data('data.txt') # 假设数据文件格式:第一行N, W,后面N行每行w_i, h_i N, W, items = data # 2. 初始化求解器和批次管理器 solver = PackingSolver(W) batch_manager = BatchManager(W) # 3. 初始组批 (基于面积的启发式) batches = batch_manager.initial_batching(items) # 4. 迭代验证与调整 max_retry = 5 for retry in range(max_retry): all_packed = True failed_batches = [] for batch in batches: success, layout, height = solver.simulated_annealing_pack(batch.items) if success and height <= W: batch.layout = layout batch.packed_height = height else: all_packed = False failed_batches.append(batch) batch.layout = None if all_packed: print(f"所有批次在第 {retry+1} 轮验证通过!") break # 修复失败的批次 if retry < max_retry - 1: print(f"第 {retry+1} 轮有 {len(failed_batches)} 个批次排版失败,尝试修复...") # 修复策略:将失败批次拆散,重新插入未分配池,并调整组批参数(如降低面积利用率目标) unassigned_items = [] for fb in failed_batches: unassigned_items.extend(fb.items) batches.remove(fb) # 调整参数,例如将面积利用率目标从0.95降到0.90 batch_manager.utilization_target = 0.90 - 0.02 * retry new_batches_from_retry = batch_manager.batch_items(unassigned_items) batches.extend(new_batches_from_retry) else: print("达到最大重试次数,仍有批次无法排版。") # 最终处理:可以将失败批次中的件单独成板(最坏情况) for fb in failed_batches: # 强制每个件单独一个板 for item in fb.items: single_batch = Batch(len(batches)+1) single_batch.items.append(item) # 单个件肯定能放下,直接生成简单布局 single_batch.layout = {item.id: (0, 0, False)} batches.append(single_batch) # 5. 输出结果 output_results(batches, W) # 6. 可视化(可选) visualize_batches(batches, W)5. 常见问题、调试技巧与优化心得
在实际编程和调试过程中,我们遇到了不少坑,也总结了一些加速和优化的技巧。
5.1 算法效率与加速
- 候选点集管理:左下角放置算法中,候选点集可能会膨胀得很快。我们发现在每次放置后,对候选点集进行“精简”是必要的。例如,如果一个点
p1在另一个点p2的左下方(即p1.x <= p2.x and p1.y <= p2.y),那么p1总是比p2“更好”,因为从p1开始放置能更早接触到板边界或其他物品。因此可以删除像p2这样的“被支配点”。这能显著减少需要检查的点数。 - 模拟退火参数调优:初始温度
initial_temp、冷却速率cooling_rate和迭代次数max_iter需要根据问题规模调整。对于几十个物品的批次,max_iter=2000~5000通常足够。初始温度应设置得足够高,使得在初期有较大概率接受劣解,避免陷入局部最优。一个经验法则是,初始温度可以设置为目标函数值(排版高度)量级的若干倍。 - 早期剪枝:在组批阶段,如果一个批次的面积总和已经超过
W*W,可以直接标记为“需拆分”,无需进入耗时的排版尝试。这是一个非常有效的预过滤。
5.2 模型与求解的陷阱
- 大M值过大导致求解缓慢:这是用MILP求解器直接解完整模型时最常见的问题。除了之前提到的谨慎设置M值外,还可以尝试添加有效的线性规划松弛加强约束,例如“每个板上的物品总面积不超过板面积”。虽然这个约束对于二维问题并不充分(因为存在形状不匹配的浪费),但它能提供一个下界,帮助求解器更快剪枝。
- 对称性问题:在MILP模型中,板
k和板k+1如果都没被使用,或者内容可以互换,这对模型来说是不同的解,但对实际问题是一样的。这种对称性会导致求解树爆炸性增长。可以添加对称性破缺约束,例如按某种顺序激活板索引:u_k >= u_{k+1}。这强制要求先使用编号小的板。 - 求解器超时与启发式初始解:对于稍大规模的问题,直接求最优解可能不现实。我们可以将启发式算法(如前面描述的流程)得到的结果,作为MILP模型的初始解提供给求解器。这能极大加快求解器找到优质解甚至证明最优性的速度。在PuLP中,可以通过设置
LpVariable的initialValue属性来实现。
5.3 代码调试与验证
- 可视化是王道:一定要写一个可视化函数,将排版结果用
matplotlib画出来。不同批次用不同颜色,旋转的件要标注清楚。肉眼一看,很多逻辑错误(如重叠、出界)无所遁形。这也是论文中展示结果的重要部分。 - 单元测试:对关键函数编写小规模测试。例如,测试
bottom_left_pack函数:给两个一定能放下的小方块,看它是否成功;给一个超过板尺寸的大方块,看它是否正确报告失败;给三个件,看输出布局是否真的无重叠。 - 边界条件:特别注意件尺寸等于板尺寸、件面积和为板面积完全相等、以及件旋转后尺寸不变(正方形件)等情况。这些边界情况最容易引发算法中的除零错误或逻辑漏洞。
- 随机种子:模拟退火和随机打乱顺序时,固定随机种子(如
random.seed(42))对于复现问题和调试至关重要。在最终版本中,可以移除固定种子以获得不同的搜索轨迹。
5.4 针对竞赛的进阶思考
在数学建模竞赛中,除了把问题解出来,还要体现建模的深度和广度。
- 多模型对比:我们不仅可以提交启发式算法的结果,还可以对小规模算例(例如N<=20)用完整的MILP模型求解,并将结果与启发式结果对比,说明启发式算法的接近最优程度。这展示了我们对问题本质的理解。
- 敏感性分析:分析板材宽度
W变化对用板总数和利用率的影响。或者分析如果允许板材本身也有多种规格可供选择,模型和算法该如何调整。这能体现模型的扩展性。 - 算法鲁棒性测试:在论文中展示算法在不同规模、不同尺寸分布(如大小件混合、全是小件、全是大件)随机数据上的表现,统计其成功率和平均利用率。用图表展示,非常直观。
- 复杂度与可扩展性讨论:明确指出我们提出的两阶段启发式算法的时间复杂度,并讨论当问题规模极大(如N>1000)时,可以采用的进一步优化策略,例如并行计算(不同批次排版可以同时进行)、更高级的元启发式算法(如遗传算法用于组批阶段)等。
最后,把整个思路、模型、算法、结果和分析清晰地整理成文档,配合上简洁优雅的代码和直观的可视化图表,一份完整的竞赛解决方案就诞生了。这个过程不仅锻炼了解决复杂优化问题的能力,更是一次从问题抽象、模型构建、算法设计到工程实现的完整训练。