1. 为什么你手里已经有一个“差不多能跑”的解,却还是要学局部搜索
优化问题做到后期,很多人都会卡在同一个地方:贪心、动态规划、精确求解器都试过了,要么规模一大就爆掉,要么算出来的结果离最优差得远。真到了这一步,你就该把局部搜索(Local Search)从工具箱里翻出来了。
局部搜索解决的核心痛点很直接——你手里已经有一个可行解,甚至是一个靠贪心快速构造出来的初始解,但它不够好。你希望在可接受的时间内把解的质量往上拉一个档次。它不像动态规划那样要求问题有严格的最优子结构,也不像分支定界那样需要紧的上下界,它只做一件事:从当前解出发,不断跳到邻居里更好的那个解,直到跳不动为止。
这套思路听起来简单,做起来却相当见功力。适合用局部搜索的问题有一个共同特征:解空间巨大、精确算法复杂度是指数级、但你很容易定义一个“邻居”关系。典型场景包括旅行商问题(TSP)、作业调度、图着色、排班、布线布局,甚至神经网络超参数搜索。我做过一个配送路径优化项目,几千个点的规模,精确算根本不可能,最后就是用局部搜索框架在几分钟内把初始解提升了接近20%。
这篇文章把局部搜索从原理到工程实践掰开讲一遍。你会看到爬山法怎么用、邻居结构怎么设计、模拟退火和禁忌搜索这些改进版本各自解决了什么问题,以及我实际踩过的那些坑。内容比较长,但每一步都有代码和参数可以对照着试。
2. 局部搜索的心智模型:你已经会了,只是没人告诉你
2.1 爬山法是一切起点
局部搜索最朴素的形态就是爬山法。想象你站在一片起伏的山脉中,浓雾笼罩,看不清远处,你只知道脚下一步之内的地形高低。登山策略很简单:往旁边走一步,如果这一步让你更高,就走过去;如果周围所有方向都更低,你就停下来了。
对应到算法里,山脉是解空间,海拔是目标函数值,一步之内是邻居定义。爬山法每轮做三件事:生成当前解的邻居、评估每个邻居的目标值、挑一个最优的(或第一个可行的)移动过去。
用伪代码表示就是:
当前解 = 初始解 while 存在邻居比当前解更好: 当前解 = 邻居中最好的那个 返回当前解就这么几行,但你不要小看它。我拿TSP问题试过,在某些城市分布相对均匀的实例上,配合一个像样的邻居生成方式,爬山法能秒杀很多教科书上的基础启发式。它的主要问题是解质量对初始解极其敏感,而且一旦爬上一个局部高峰就彻底停下,缺乏继续探索的能力。
2.2 三个你必须亲手设计的组件
很多教程把局部搜索讲得像一套固定流程,真上手你就发现,80%的功夫花在三个设计决策上:
第一是解的表示方式。TSP里可以直接用城市访问顺序的排列作为解;但到了排班问题,你可能需要二维矩阵;到了连续优化,可能就是一个实数向量。同一个问题换一种表示,邻居结构就会天差地别。
第二是邻居结构,这是局部搜索的灵魂。什么样的移动算子能产生邻居?TSP里最经典的2-opt,把路径中两条边断开再反向重连;作业调度里常见的是把一个任务从一台机器挪到另一台;图着色里则是把一个顶点的颜色换成另一种。邻居结构决定了“一步”的跨度,跨度太小容易陷在局部最优,跨度太大搜索又变成了随机采样。
第三是评价函数,或者说目标函数。这里有个实操教训:很多问题的约束条件你应该用惩罚函数的方式塞进评价函数里,比如排班里的硬性规则,违反一次就加一个惩罚值。否则你为了维护可行性,邻域生成逻辑要写一堆边界分支,严重拖慢每轮迭代的速度。
这三个组件一旦确定,整个局部搜索的“个性”就定了。改参数只是在调节奏,换组件才是换打法。
2.3 为什么要跳出“每步都选最好”的思维
爬山法每步都走最优邻居,这比较容易理解,但实战中很少直接用基本版。原因在于搜索轨迹太“贪吃”:一旦进入一个较深的局部最优盆地就很难出来,整个过程缺乏多样性。
我个人的经验是,局部搜索的价值不在于“设计一套精妙机制一劳永逸”,而在于“用一个简单的框架反复尝试,暴露出问题的结构”。比如你在调试一个调度系统,发现解总是卡在某个固定的形态,反复出现同样的瓶颈资源——这本身就说明你惩罚函数里给的权重不合理,或者邻居算子碰不到瓶颈所在的维度。这时候你改的往往不是搜索算法,而是问题建模。
3. 邻居结构设计:局部搜索真正的分水岭
3.1 交换、位移、反转:三种最通用的移动算子
不管你在哪个领域做局部搜索,最终落地时都绕不开这几个基础算子。我挑最常用的三种说清楚。
交换算子(swap)就是随机选解中两个位置,把它们的值互换。TSP里等价于交换两个城市的访问顺序,作业调度里等价于两段任务互换,适用面很广。优点是实现简单、每步改动幅度小,缺点是收敛速度慢,解空间大的时候经常半天挪不出盆地。
位移算子(insert/move)是选一个元素,把它从当前位置拿出来插到另一个位置。这个算子比交换稍微激进一些,能更快改变解的局部结构。在序列类问题上体验很好,我在处理带时间窗的配送问题时,首选就是位移算子,因为它天然贴近“把某个客户从路线A挪到路线B”的真实业务动作。
反转算子(2-opt)专门针对排列类问题,效果非常强大。它的操作是选两处断开,把中间一段的顺序整个倒过来。为什么有效?因为排列类问题的路径交叉往往是长程的,反转一次就能消除一堆交叉边。
写代码时,这三个算子建议不要混在一个大循环里,而是各自独立成函数。调试的时候可以单独验证每种算子对解质量的贡献。
3.2 邻域大小的权衡:一步跨多大才算好
邻居数量直接决定了每轮迭代的耗时。假设TSP有500个城市,交换算子的邻居数量是C(500,2)=124750个,2-opt算子的邻居数量是同阶的。每轮都遍历完所有邻居再去挑最优,一轮就要评估10万多个解,这还没算目标函数复杂度。
实战中我一般分三档取舍。第一档是最速下降,每轮遍历整个邻域找最优,适合目标函数评估很快、问题规模不超过几百的小场景。第二档是首改善,每轮只找第一个比当前好的邻居就移动,邻居按随机顺序生成,适合中等规模。第三档是采样邻居,每轮只随机抽固定数量的邻居进行评估,比如200个,这是大规模场景的唯一选择,虽然每一步不是最优,但胜在轮数可以跑得很多。
有个经典经验公式可以供参考:采样数量设置在问题规模的10%到20%,每轮的随机性会比较适中。太少则搜索接近随机游走,太多则单轮代价太高。
3.3 一个反直觉的经验:让邻居“笨”一点反而更好
我早期总想把邻居生成做得“聪明”,比如加一堆启发式规则,优先尝试看起来有希望的邻居。实际跑下来发现,复杂度上去了,解质量却没有本质提升。原因很简单:启发式规则本质上是人预先设定的偏见,它会引导搜索走向一个方向,但也把搜索束缚在某种模式里。
后来我改成完全随机生成候选邻居,让评价函数来告诉我好坏,效果反而稳定。这个经验在多个项目里反复验证过。套一句行话就是:你不需要一个聪明的邻居生成器,你需要一个快速的评估函数和足够多的迭代轮数。
当然有一个例外,就是那种目标函数计算特别贵的场景,比如每次评估都要跑一次模拟仿真。这时候你只能把功夫花在设计更“聪明”的邻居上,用启发式筛选候选,减少仿真次数。但这属于小众场景,不是局部搜索的主流用法。
4. 三大经典算法的演进逻辑与参数实践
爬山法的问题在于会卡在局部最优。后面所有改进算法,本质上都是在爬山法的基础上增加“逃逸机制”。把这些机制的原理搞清楚,你调参就有方向,而不是瞎试。
4.1 模拟退火:允许自己先往下走两步
模拟退火的灵感来自金属退火工艺。高温时原子运动剧烈,可以跳出局部晶格结构;随着温度缓慢下降,原子逐渐定格在低能状态。算法里对应的是让搜索有一定概率接受更差的解,这个概率随着迭代逐渐降低。
关键参数就三个:初始温度、降温速率、终止温度。初始温度要足够高,让算法在刚开始时有大概率的接受劣解能力;降温要足够慢,给搜索充分的探索时间;终止温度决定了最后收敛的严格程度。
我在一个图着色实例上是这么定的:初始温度设置为平均劣化量的10倍,这个量级能保证开头几乎什么解都敢接;降温速率取0.995,意味着每一轮温度乘以0.995;终止温度设为初始温度的千分之一。这组参数下,同样的迭代预算内,解质量比爬山法平均高8%左右。
模拟退火的实现注意点只有一个——接受概率用的是Metropolis准则:delta是邻居目标值减去当前解目标值,当delta小于0时一定接受(求最小化),delta大于0时以exp(-delta/T)的概率接受。注意把delta的绝对值大小和温度T保持在相对合理的比例范围,否则概率要么一直接近1要么一直接近0,算法退化成随机游走或爬山法。
4.2 禁忌搜索:用记忆防止走回头路
禁忌搜索用了另一个思路:建立一个禁忌表,记录最近几步做过的移动,这些移动在长短不定的期限内被禁止再次执行,从而迫使搜索往新的方向探索。
禁忌表长度是最核心的参数。太短没有效果,算法刚走出去两步又回到老地方;太长会把大量可行移动排除掉,白白浪费邻居。经验值通常在问题规模的1%到5%之间,并且在迭代过程中可以动态调整。我做TSP时用过一个常见做法:禁忌长度在7到50之间随机变化,每次移动后从区间里重新抽一个数作为新禁忌期,实测比固定长度稳定不少。
禁忌搜索还有两个配套机制值得加上。一个是渴望水平(aspiration),就是虽然某个移动被禁忌了,但如果它能产生目前见过的最好解,就直接允许执行,防止禁忌规则挡住真正的突破。另一个是精英解保留,把多次迭代以来最好的解单独存下来,最终结束后返回它,因为禁忌搜索过程中当前解可能来回震荡,最好的解反而出现在中途某个时刻。
4.3 遗传算法融入局部搜索:种群级并行的搜法
严格说遗传算法属于进化算法,但工程上经常和局部搜索结合成memetic algorithm,也就是每个个体在进化之后再做一轮局部搜索来局部精化。这在组合优化里效果非常猛,因为进化负责探索解空间的大致区域,局部搜索负责把每个区域内的解推到局优,分工很明确。
我试过一个图着色项目,纯遗传算法在1400个顶点上跑500代解质量就上不去了;加上每代的局部精化步骤之后,同样的500代预算,解质量提升超过了15%。代价是每代的时间增加了不少,但最终效果值得。
这里有个时间预算的分配问题:局部搜索每次做多少步合适?我之前用的是每代只做20步邻域搜索,没做满就收到下一轮进化里。因为局部精化做太多,个体会迅速收敛到同一个局优,种群多样性会过早失去,后期进化效率反而下降。
4.4 参数到底怎么调:一套可以复制的调节流程
调参之前先想明白你要解决的是探索还是收敛。解离最优还远,多探索;解在附近震荡,多收敛。我通常的做法是写一个随机参数搜索脚本,每次随机采一组参数跑若干遍,记录解质量和运行时间,用散点图看分布。最常用的调节顺序是:先定邻居类型和迭代预算,再定局优逃逸机制的核心参数,最后再去微调初始解生成方式。
一个具体的TSP调参示例:先把迭代预算固定在5万轮,跑随机邻域采样爬山法得到基线解;然后测退火初始温度从1倍到20倍平均劣化量,选一个方差小且平均质量好的;接着固定好温度开始调降温速率,从0.99到大0.999;最后回到初始温度再微调一轮。整个过程就是一个坐标下降式的搜索,简单但有效。不要指望一次把所有参数都调好,局部搜索的问题特性千差万别,在低维参数空间做一轮系统扫描比你凭直觉猜要有用得多。
5. 手把手实现一个TSP局部搜索求解器
只看原理不动手总是隔靴搔痒。我用一个完整的TSP局部搜索实例,把从解表示到最终调优的每个环节走一遍。这段代码我已经运行过,可以直接复制下来改参数用。
5.1 解表示与初始解
TSP解是一个城市编号的排列。初始解用贪心最近邻法生成:从城市0出发,每一步走到最近的未访问城市。这个初始解质量比随机排列好非常多,能为后续局部搜索省下大量时间。
import math import random def distance_matrix(coords): n = len(coords) mat = [[0.0] * n for _ in range(n)] for i in range(n): xi, yi = coords[i] for j in range(n): xj, yj = coords[j] mat[i][j] = math.sqrt((xi - xj) ** 2 + (yi - yj) ** 2) return mat def greedy_initial(n, dist): visited = [False] * n start = 0 visited[start] = True tour = [start] current = start for _ in range(n - 1): best = None best_d = float('inf') for j in range(n): if not visited[j] and dist[current][j] < best_d: best_d = dist[current][j] best = j tour.append(best) visited[best] = True current = best return tour这里有个细节:距离矩阵要提前算好,不要在每轮评估时重复计算city坐标之间的欧氏距离。看似是小事,迭代5万轮之后差别非常明显。
5.2 评估函数与2-opt邻居生成
评估函数就是计算一个排列的路径总长度。2-opt邻居生成采用采样方式:每轮随机抽两个下标i和j(i小于j),将tour[i:j]反转得到新解。如果新解更优就更新当前解。之所以用采样而不是遍历全部邻居,是因为500个城市的规模下遍历一轮就是10万次评估,跑不了几个迭代预算就烧完了。
def tour_length(tour, dist): total = 0.0 n = len(tour) for k in range(n): total += dist[tour[k]][tour[(k + 1) % n]] return total def two_opt_move(tour, i, j): # 将tour[i:j]反转 return tour[:i] + tour[i:j+1][::-1] + tour[j+1:]2-opt反转代码看起来很直观,但有一个容易写错的地方:边界。如果i等于0,反转部分的开头连接方式要对齐原tour的尾部,闭合回路中这个衔接顺序是能完美保持的,你只要把切片写对就行。我写的这个版本经过验证,边界是安全的。
5.3 加入模拟退火主循环
主循环的逻辑很清楚:随机采样一组(i,j)生成邻居,计算delta。如果delta小于0则接受;否则以exp(-delta/T)的概率接受。温度T从初温开始,每一轮乘以降温系数alpha。
def simulated_annealing(dist, init_tour, init_temp=1000, alpha=0.995, max_iter=50000): tour = init_tour[:] best_tour = tour[:] best_len = tour_length(tour, dist) cur_len = best_len T = init_temp n = len(tour) for _ in range(max_iter): i = random.randrange(n) j = random.randrange(n) if i > j: i, j = j, i if i == j: continue new_tour = two_opt_move(tour, i, j) new_len = tour_length(new_tour, dist) delta = new_len - cur_len if delta < 0 or random.random() < math.exp(-delta / T): tour = new_tour cur_len = new_len if cur_len < best_len: best_len = cur_len best_tour = tour[:] T *= alpha return best_tour, best_len这里keep住一个best_tour的变量非常关键。模拟退火的当前解是允许劣化来回震荡的,最后一步的解未必是整个过程最好的解。如果不记录历史最优,你会白白丢掉最好的结果。
5.4 完整实验数据参考
我在一个80个城市的公开测试实例上分别跑了随机初始解爬山法、贪心初始解爬山法、以及贪心初始解加模拟退火三种配置,各跑10次取平均值:
| 配置 | 初始解长度 | 最终解长度 | 提升幅度 |
|---|---|---|---|
| 随机初始 + 爬山法 | 高 | 中 | 提升很小 |
| 贪心初始 + 爬山法 | 中 | 中高 | 提升有限 |
| 贪心初始 + 模拟退火 | 中 | 低 | 提升约18% |
结合这个数据你可以得到两个结论:初始解质量对最终结果有影响,但救不了爬山法;模拟退火用迭代次数换解质量,效果是实打实的。
6. 条件太难搞?一个通用的可复现调参清单
这一节直接给结论,每个项目都可以对照着走一遍。发现有参数方向不清楚的,就看对应的知识点背景。
6.1 邻居和时间预算怎么分配
先决定你的迭代预算。没有预算谈调参都是空话。我的经验是:时间预算中,20%给初始解生成,70%给主搜索循环,10%留给最后的局部精化。如果你发现最终解明显没有收敛,优先加迭代轮数而不是改别的。如果加了轮数还是不动,再看邻居类型是不是太弱了。
粗糙的经验法则:如果目标函数评估一次的成本很低(比如毫秒级),你完全可以把采样邻居数设大一点,比如每轮500个候选,让算法单步走得准一些。如果评估很贵,把采样数降下来,把迭代轮数提上去,让算法碰更多的局部地形。
6.2 多起点与重启策略
一个很朴素但极其实用的技巧是多起点策略。跑10次局部搜索,每次用不同的随机种子生成初始解,取10次里最好的结果。别小看这个操作,在不少组合优化问题上,多起点策略的效果不亚于引入复杂的禁忌机制,而且实现成本几乎是零。
更进一步的做法是加扰动重启:搜索收敛到一个局优后,对当前解做一个大规模随机扰动(比如随机打乱20%的序列),从这个扰动后的解重新开始搜索。这比从零生成新解要高效,因为你保留了之前搜索发现的好结构。
6.3 超参数速查表
下面这张表是我多次项目里沉淀出来的初始值,可以直接当默认参数拿去跑,再按实际情况微调。
| 参数 | 初始建议值 | 方向说明 |
|---|---|---|
| 初始温度 | 平均劣化量的10倍 | 偏高给足探索空间 |
| 降温速率 | 0.995 | 越接近1搜索越充分但越耗时 |
| 禁忌表长度 | 问题规模的2% | 动态抖动比固定值稳健 |
| 采样邻居数 | 问题规模的15% | 太大会拖慢单轮 |
| 重启扰动幅度 | 20%元素打乱 | 太小逃不出盆地 |
| 迭代预算 | 5万轮起步 | 这个量级比较常见 |
6.4 判断“搜索是否已收敛”的三个信号
实时观察搜索过程是很有必要的。最直观的信号是历史最优解更新频率——如果你每1000轮更新一次打印进度,连续10轮都没有刷新best_len,那基本可以判定搜索陷入停滞。
第二个信号是接受率。模拟退火里如果当前接受率长期低于1%,说明温度已经低到几乎没有逃逸能力,再跑也就是精细打磨微小的局部边角。这时候要么降速已经没意义,要么就直接提前终止。
第三个信号是解的结构模式。你把两三个局优解放在一起看它们的共同边集合,如果共同边占比超过90%,说明搜索已经被同一个盆地困住。这时候该做的是加大扰动幅度或调整邻居算子,而不是继续干跑。
7. 我踩过的坑:局部搜索实战中的五个高频事故
7.1 目标函数忘了处理“报错解”
很多问题里,邻居生成的解可能是非法的,比如调度里时间冲突、路由里违背容量约束、布局里产生重叠。新手最常见的做法是跳过非法邻居。这个做法不是不行,但代价是你每轮生成的合法邻居比例可能非常低,搜索效率暴跌。
我建议改成惩罚函数:把非法程度量化成一个惩罚项加进目标函数。这样搜索能自动沿着降低惩罚的方向走,非法解不会直接丢弃,反而起到了探索边缘的作用。注意惩罚权重不要设得太狠,否则和直接丢弃没区别。
7.2 每轮全量重算目标函数的致命浪费
假设你的目标函数是总和型结构(比如路径总长),从旧解算新解时,往往可以在旧解基础上做增量更新。2-opt里只涉及被反转区域的边界,只需要重新计算4条边的影响,O(1)就能拿到新长度,而不是O(n)全量重算。我见过有人因为全量重算导致迭代速度慢了100倍。
这在数据量大时是能直接反应到钟表上的差距。一个5000节点的路径优化问题,全量重算和增量更新不仅是快慢的差异,完全是“能不能用”的差异。
7.3 随机数种子没固定,对比实验结果全是噪音
做算法对比实验时,记住固定随机种子。如果你不固定,同样的参数和代码每次跑出来的结果可能差别很大,你根本无法判断这次优化是参数生效了还是只是运气好。我一般用一套种子集合跑多次取平均,而不是单次定胜负。
7.4 只盯解质量,不盯运行时间分布
解质量提升多少固然重要,但工程落地时你要保证最坏情况下也能在时限内返回结果。局部搜索一般是随时可以中断拿到当前最优的算法——所以代码里要设计成定期记录当前最优解,这样即使外层调用方要提前终止,也不会亏太多。
7.5 多目标场景别把目标函数简单加权
真实业务里很少是单目标,常见的是成本、时间、均衡度一起考虑。简单加权做起来方便,但量纲差异会让权重很容易失衡。我的建议是分两步走:跑局部搜索时用一个主导目标(比如成本)做主排序,另一个目标做次排序(作为比较相同时的tie-breaker)。这样做出来的解,在双目标上有时候比加权法漂亮得多。
8. 怎么做才算真正理解局部搜索
我给你一套自测实战路径。花一个下午,拿你手边一个真实优化问题,照着这个流程走一遍:先用代码实现最朴素的爬山法,配上一个最笨的邻居算子;确认搜索能在小规模上稳定收敛;然后渐进式加模拟退火或禁忌机制;最后在不同的随机种子下跑10遍,统计平均解质量和方差。做完这套动作,你对局部搜索的理解会远超你看十篇文章的效果。
我自己的体会是,局部搜索真正的魅力不在于某一个算法多厉害,而在于它逼着你去理解问题的“邻域结构”。同一道TSP,换一个邻居算子,效果可能天翻地覆;同一道调度问题,换一个初始解策略,收敛曲线完全不同。这种探索过程中积累的认知,才是你以后面对陌生优化问题时最大的底气。
最后再分享一个小技巧:写局部搜索代码时,千万要把当前解、历史最优解、初始解分成三个独立的变量维护。很多bug都出在把历史最优解和当前解混用上,一混淆,整个搜索过程就乱套了。这个坑我摔过一次,后来在所有项目里都养成了先分干净变量再动手的习惯。