1. 项目缘起:从“旅行推销员”到代码实践
最近在整理一些经典的组合优化问题案例,TSP(旅行商问题)自然是绕不开的一座大山。这问题说起来简单:一个推销员要去N个城市推销商品,每个城市去一次且仅一次,最后回到起点,怎么走总路程最短?但就是这个看似简单的描述,让无数数学家和程序员“头秃”了几十年。它属于NP-hard问题,城市数量一多,暴力枚举所有路径((N-1)! 条)在有限时间内基本不可能。所以,大家转向了各种启发式算法,试图在可接受的时间内找到一个“还不错”的解。
在众多启发式算法里,模拟退火(Simulated Annealing, SA)一直是我的心头好。它不像遗传算法那样需要设计复杂的交叉、变异操作,也不像蚁群算法那样参数调起来让人眼花缭乱。模拟退火的核心思想非常物理、非常直观:模仿金属退火过程,通过控制“温度”这个参数,让搜索过程既有“跳出局部最优”的探索能力,又有“收敛到优质解”的开发能力。这次,我就把手头一个用Python实现模拟退火求解TSP的代码拿出来,结合我自己的调试和优化经验,从头到尾捋一遍。你会发现,实现一个能跑的SA框架可能就几十行代码,但真想让它跑得好、解得快,里面的门道可不少。
2. 模拟退火算法核心:不只是“概率接受差解”
很多人对模拟退火的初印象就是“以一定概率接受更差的解,从而跳出局部最优”。这个说法没错,但太笼统了。要真正用好它,必须理解其背后的三个核心机制,以及它们是如何在代码中具体体现的。
2.1 能量函数与邻域结构:定义你的“问题世界”
在模拟退火的语境里,“能量”就是我们要优化的目标函数值。对于TSP,能量就是路径的总长度。计算总长度是基础操作,这里有个小技巧:通常我们会预先计算好所有城市两两之间的距离矩阵dist_matrix。这样,在评估一条路径的能量时,只需要做N次查表和加法,而不是每次临时计算距离,能极大提升效率,尤其是城市数量较多时。
import numpy as np import math def calc_distance(city1, city2): """计算两个城市间的欧氏距离""" return math.sqrt((city1[0] - city2[0])**2 + (city1[1] - city2[1])**2) def create_dist_matrix(cities): """创建距离矩阵""" n = len(cities) dist_matrix = np.zeros((n, n)) for i in range(n): for j in range(i+1, n): dist = calc_distance(cities[i], cities[j]) dist_matrix[i][j] = dist_matrix[j][i] = dist return dist_matrix def total_distance(path, dist_matrix): """计算一条路径的总距离(能量)""" total = 0.0 n = len(path) for i in range(n): total += dist_matrix[path[i]][path[(i+1) % n]] # 最后一个城市回到起点 return total比能量函数更重要的是“邻域结构”,它定义了如何从当前解产生一个新解。在TSP中,最常用的邻域操作有:
- 交换(Swap):随机选择路径中的两个位置,交换它们对应的城市。
- 逆转(Reverse/2-opt):随机选择路径中的一段子路径,将其顺序完全颠倒。
- 插入(Insert):随机选择一个城市,将其插入到路径的另一个随机位置。
我的经验是,对于中小规模的TSP(城市数<100),2-opt操作通常效果最好。因为它一次性能改变路径中多个边的连接,扰动更大,更容易跳出局部最优的“小水坑”。而交换操作扰动较小,在低温阶段进行微调时可能更有用。在实际代码中,我常常将两者结合:高温时多用2-opt进行大胆探索,低温时引入交换进行精细调整。
def generate_new_path_2opt(old_path): """使用2-opt(片段逆转)产生新路径""" n = len(old_path) new_path = old_path.copy() # 随机选择两个不同的索引,并确保 i < j i, j = sorted(np.random.choice(n, 2, replace=False)) # 逆转 i 到 j 之间的片段 new_path[i:j+1] = new_path[i:j+1][::-1] return new_path def generate_new_path_swap(old_path): """使用交换操作产生新路径""" n = len(old_path) new_path = old_path.copy() i, j = np.random.choice(n, 2, replace=False) new_path[i], new_path[j] = new_path[j], new_path[i] return new_path2.2 退火计划表:控制搜索的“节奏感”
这是模拟退火的精髓所在,直接决定了算法的性能和最终解的质量。它主要包含四个参数:
- 初始温度(T_init):温度太高,算法几乎完全随机搜索,效率低下;温度太低,又容易过早陷入局部最优。一个经验公式是:
T_init = -ΔE_avg / ln(P_init),其中ΔE_avg是随机产生一批新解时,能量差(新解能量-旧解能量)的平均值,P_init是你期望在初始时接受差解的概率,比如0.8。实践中,如果嫌麻烦,也可以根据目标函数值的数量级进行估算,比如设为目标函数值范围的若干倍。 - 终止温度(T_end):通常设为一个非常接近0的正数,比如1e-7。当温度低于此值时,算法停止,此时它基本只接受更好的解,相当于一个局部搜索。
- 温度衰减系数(alpha):最常见的衰减方式是
T_new = alpha * T_old,其中alpha是一个略小于1的数,如0.95到0.99。alpha越大,降温越慢,搜索越充分,但耗时也越长。我个人的习惯是从0.99开始尝试。 - 马尔可夫链长度(L):在每个温度下迭代的次数。太短,搜索不充分;太长,浪费时间。一个常见的策略是
L = 100 * N(N为城市数),或者设置一个固定值如1000-5000,并通过实验调整。
class SimulatedAnnealingTSP: def __init__(self, cities, dist_matrix): self.cities = cities self.dist_matrix = dist_matrix self.n = len(cities) self.best_path = None self.best_energy = float('inf') self.history = {'temp': [], 'energy': [], 'best_energy': []} def solve(self, T_init=1000, T_end=1e-7, alpha=0.99, L=2000): # 初始化路径(随机排列) current_path = np.random.permutation(self.n).tolist() current_energy = total_distance(current_path, self.dist_matrix) self.best_path = current_path.copy() self.best_energy = current_energy T = T_init while T > T_end: for _ in range(L): # 以一定概率选择不同的邻域操作 if np.random.random() < 0.7: # 70%概率使用2-opt new_path = generate_new_path_2opt(current_path) else: # 30%概率使用交换 new_path = generate_new_path_swap(current_path) new_energy = total_distance(new_path, self.dist_matrix) delta_e = new_energy - current_energy # Metropolis准则:判断是否接受新解 if delta_e < 0 or np.random.random() < math.exp(-delta_e / T): current_path, current_energy = new_path, new_energy # 更新历史最优解 if current_energy < self.best_energy: self.best_path = current_path.copy() self.best_energy = current_energy # 记录当前温度下的状态,用于分析 self.history['temp'].append(T) self.history['energy'].append(current_energy) self.history['best_energy'].append(self.best_energy) # 降温 T *= alpha return self.best_path, self.best_energy2.3 Metropolis准则:算法跳出能力的“灵魂”
接受差解的概率由P = exp(-ΔE / T)决定。这是整个算法能跳出局部最优的关键。
- 当 ΔE < 0(新解更好):
exp(-ΔE / T)大于1,所以一定接受。这是“下山”过程。 - 当 ΔE > 0(新解更差):以概率
P接受。温度T很高时,即使ΔE很大,P也可能不小,算法有勇气跳到更远的地方;随着T降低,接受差解的概率越来越小,算法越来越“保守”,最终稳定在一个(希望是全局或优质的局部)最优解附近。
这里有一个极易忽略的坑:ΔE和T的量级必须匹配!如果ΔE的典型值是几百万,而T从1000开始衰减,那么-ΔE/T会是一个非常巨大的负数,导致exp(-ΔE/T)在绝大多数情况下计算结果为0(浮点数下溢),算法实际上失去了接受差解的能力,退化成普通的局部搜索。因此,初始温度的设置必须参考目标函数值的尺度。如果路径总距离在10^4量级,T_init设在10^3量级可能就太小了。
3. Python实现中的性能陷阱与优化技巧
把算法思路翻译成Python代码不难,但写出高效、健壮的代码需要一些技巧。下面是我在实现过程中踩过的一些坑和总结的优化经验。
3.1 距离矩阵与向量化操作
前面提到了预计算距离矩阵,这是最重要的优化,没有之一。避免了在能量评估的循环中重复调用math.sqrt和乘法运算。对于N个城市,能量计算复杂度从 O(N²) 降到了 O(N)。
更进一步,我们可以利用NumPy的向量化操作来加速能量计算。虽然对于单次路径评估,循环和向量化差别不大,但在每个温度下要进行L次评估,累积起来就很可观了。
def total_distance_vectorized(path, dist_matrix): """使用numpy向量化操作计算总距离(稍快)""" # 将路径索引转换为numpy数组以便高级索引 idx = np.array(path) # 利用roll操作获取下一个城市的索引 next_idx = np.roll(idx, -1) # 使用高级索引一次性获取所有距离并求和 return np.sum(dist_matrix[idx, next_idx])注意,np.roll会产生一个新数组,对于超大规模路径,其开销也需要考虑。但在大多数情况下,向量化版本更具可读性和一定的速度优势。
3.2 路径表示与邻域操作的效率
路径通常用一个列表或numpy数组表示,存储城市的访问顺序。在进行邻域操作(如2-opt)时,要特别注意避免不必要的完整列表拷贝。
我最初写的generate_new_path_2opt函数是new_path = old_path.copy(),然后进行切片逆转。这对于Python列表是可行的,因为切片操作会创建新列表。但如果old_path是numpy数组,直接切片赋值new_path[i:j+1] = old_path[i:j+1][::-1]是原地操作的一部分,但最开始的copy()仍然是必须的,否则会修改原路径,破坏算法状态。
一个更极致的优化是,在高温、大量接受差解的阶段,可以尝试不总是创建完整的新路径副本,而是记录对当前路径的“差分”修改,并在能量计算时只更新受影响的部分距离。但这会大大增加代码复杂度,除非面对城市数量极大(>1000)的情况,否则收益可能不如优化其他部分明显。
3.3 随机数生成与随机性控制
模拟退火依赖随机数进行邻域扰动和Metropolis判断。使用np.random模块比Python内置的random模块更快,尤其是在需要生成大量随机数时。
另外,固定随机种子对于调试和结果复现至关重要。在开发阶段,设置np.random.seed(42)可以让每次运行都产生相同的随机序列,这样当你修改了某个参数(比如降温系数),观察到的效果变化才是真实的,而不是随机性带来的噪声。
def solve(self, T_init=1000, T_end=1e-7, alpha=0.99, L=2000, seed=None): if seed is not None: np.random.seed(seed) # ... 其余求解代码 ...在最终多次运行取最优解时,再去掉固定的种子,或者使用不同的种子运行多次。
4. 参数调优实战:如何让算法“跑得又好又快”
模拟退火的参数没有银弹,需要针对具体问题进行调整。以下是我常用的调优流程和策略。
4.1 初始温度的自动化估计
手动拍一个初始温度很麻烦。我们可以实现一个简单的自适应方法来估计T_init。
def estimate_initial_temperature(cities, dist_matrix, num_samples=100, initial_accept_prob=0.8): """通过采样估计初始温度""" n = len(cities) current_path = np.random.permutation(n).tolist() current_energy = total_distance(current_path, dist_matrix) delta_es = [] for _ in range(num_samples): new_path = generate_new_path_2opt(current_path) new_energy = total_distance(new_path, dist_matrix) delta_e = new_energy - current_energy if delta_e > 0: # 只收集变差的能量差 delta_es.append(delta_e) # 更新当前路径,继续采样 current_path, current_energy = new_path, new_energy if delta_es: avg_delta_e = np.mean(delta_es) # 根据公式 T = -ΔE_avg / ln(P_init) T_init = -avg_delta_e / math.log(initial_accept_prob) else: # 如果采样中全是更优解,说明初始路径很差,可以设一个较大的默认值 T_init = 1000 * current_energy / n # 一个经验公式 return max(T_init, 1.0) # 确保温度为正这个方法通过随机采样一批状态转移,计算变差的ΔE的平均值,然后反推出能让你期望的初始接受概率(如0.8)成立的温度。虽然不精确,但比盲目猜测要好得多。
4.2 降温策略与停止准则的变体
除了等比降温,还有其它策略:
- 线性降温:
T_new = T_old - dT。降温速度恒定,但需要精心选择dT。 - 自适应降温:根据当前解的接受率来调整降温速度。例如,如果当前温度下的接受率很高,说明还没充分搜索,可以慢点降温;如果接受率很低,说明已经接近稳定,可以加快降温。实现起来稍复杂,但有时效果更好。
停止准则也可以更智能:
- 连续若干温度最优解未改进:如果最优解连续
K个温度都没有更新,可以提前终止。 - 能量变化率过低:监控当前解能量的变化,如果变化微乎其微,也可以停止。
def solve_adaptive(self, T_init=None, L=2000, no_improve_limit=50): if T_init is None: T_init = estimate_initial_temperature(self.cities, self.dist_matrix) T = T_init current_path = np.random.permutation(self.n).tolist() current_energy = total_distance(current_path, self.dist_matrix) self.best_path = current_path.copy() self.best_energy = current_energy no_improve_count = 0 while no_improve_count < no_improve_limit: accepted = 0 for _ in range(L): # ... 生成新解并判断是否接受 ... if accepted_this_move: accepted += 1 accept_rate = accepted / L # 自适应降温:接受率高则慢降,接受率低则快降 if accept_rate > 0.6: alpha = 0.98 # 慢降 elif accept_rate < 0.2: alpha = 0.90 # 快降 else: alpha = 0.95 # 中速降 T *= alpha # 检查最优解是否更新 if current_energy < self.best_energy: self.best_energy = current_energy self.best_path = current_path.copy() no_improve_count = 0 else: no_improve_count += 1 if T < 1e-10: # 绝对温度下限 break return self.best_path, self.best_energy4.3 马尔可夫链长度与迭代平衡
L的设置需要权衡。我的经验法则是:
- 与问题规模相关:
L = k * N,其中k在50到200之间。城市越多,每个温度下需要更多的尝试来探索状态空间。 - 与降温系数配合:如果
alpha很大(如0.99),降温慢,每个温度下可以设置较小的L(如100*N),因为总迭代次数多。如果alpha较小(如0.90),降温快,每个温度下应设置较大的L(如500*N),以确保在每个温度下都能充分搜索。 - 动态调整:也可以让
L随着温度降低而增加。高温时进行粗搜索,L可以小一些;低温时进行精细搜索,L增大。
一个简单的动态调整可以是:L_current = int(L_init * (1 + math.log(1 + T_init / T))),这样温度越低,链长越长。
5. 结果可视化与算法诊断
算法跑完了,怎么知道它运行得好不好?光看一个最终路径长度是不够的。可视化是强大的诊断工具。
5.1 绘制优化过程曲线
绘制能量(当前解距离)和最优能量随迭代(或温度)下降的曲线,可以直观看到算法的收敛过程。
import matplotlib.pyplot as plt def plot_optimization_history(sa_solver): """绘制优化历史曲线""" history = sa_solver.history iterations = range(len(history['energy'])) plt.figure(figsize=(12, 4)) plt.subplot(1, 2, 1) plt.plot(iterations, history['energy'], 'b-', alpha=0.6, label='Current Energy') plt.plot(iterations, history['best_energy'], 'r-', linewidth=2, label='Best Energy') plt.xlabel('Iteration (Temperature Step)') plt.ylabel('Total Distance') plt.title('Energy Convergence') plt.legend() plt.grid(True, linestyle='--', alpha=0.5) plt.subplot(1, 2, 2) plt.semilogy(iterations, history['temp'], 'g-') plt.xlabel('Iteration (Temperature Step)') plt.ylabel('Temperature (log scale)') plt.title('Temperature Schedule') plt.grid(True, linestyle='--', alpha=0.5) plt.tight_layout() plt.show()从曲线中我们可以看出:
- 能量曲线是否平稳下降?如果当前能量曲线剧烈震荡,说明温度可能还太高,或者邻域操作扰动太大。
- 最优能量曲线是否在持续改进?如果在很长一段迭代后最优解都没变化,可能陷入了局部最优,需要考虑增加初始温度或调整邻域操作。
- 降温曲线是否合理?是否符合预期的衰减速度。
5.2 绘制最终路径图
将城市坐标和最终找到的最优路径画出来,是最直接的成果展示。
def plot_path(cities, path, title='Best TSP Path Found'): """绘制TSP路径图""" cities_arr = np.array(cities) path_arr = np.array(path + [path[0]]) # 闭合路径 plt.figure(figsize=(10, 8)) plt.scatter(cities_arr[:, 0], cities_arr[:, 1], c='red', s=100, zorder=5) plt.plot(cities_arr[path_arr, 0], cities_arr[path_arr, 1], 'b-', linewidth=1.5, zorder=4) # 标注城市编号 for i, (x, y) in enumerate(cities): plt.text(x, y, str(i), fontsize=12, ha='center', va='center', color='white', zorder=6) plt.xlabel('X Coordinate') plt.ylabel('Y Coordinate') plt.title(title) plt.axis('equal') plt.grid(True, linestyle='--', alpha=0.5) plt.show()通过看图,可以快速判断解的质量:路径是否有明显的交叉?是否绕了远路?这能给你直观的反馈,帮助你调整邻域操作(例如,2-opt操作的一个重要特性就是消除路径交叉)。
5.3 与基准问题对比
如果你知道所求解的TSP实例的最优解或已知最优解(例如TSPLIB中的标准问题),可以将算法结果与最优解进行对比,计算近似比(你的解长度 / 最优解长度),这是衡量算法性能的客观指标。
即使不知道最优解,也可以运行多次模拟退火(使用不同随机种子),统计解的平均值、标准差和最好值,评估算法的稳定性和鲁棒性。
def run_multiple_trials(cities, num_trials=10): """多次运行SA,统计结果""" dist_matrix = create_dist_matrix(cities) results = [] best_of_all = None best_energy_all = float('inf') for trial in range(num_trials): solver = SimulatedAnnealingTSP(cities, dist_matrix) best_path, best_energy = solver.solve(T_init=1000, seed=trial) # 使用trial作为种子 results.append(best_energy) if best_energy < best_energy_all: best_energy_all = best_energy best_of_all = best_path.copy() print(f'Trial {trial+1}: Best Energy = {best_energy:.2f}') results_arr = np.array(results) print(f'\n--- Summary after {num_trials} trials ---') print(f'Best: {results_arr.min():.2f}') print(f'Worst: {results_arr.max():.2f}') print(f'Average: {results_arr.mean():.2f}') print(f'Std Dev: {results_arr.std():.2f}') return best_of_all, best_energy_all, results_arr多次运行能帮你确认,找到的好解是运气还是算法参数设置得当。如果结果方差很大,说明算法对初始状态敏感,可能需要增加初始温度或马尔可夫链长度来增强探索能力。
6. 超越基础:进阶优化思路
当你掌握了基本的模拟退火实现后,可以尝试以下进阶策略来进一步提升解的质量。
6.1 混合策略:SA与局部搜索结合
模拟退火擅长全局探索,但在低温末期的局部开发能力可能不如一些专门的局部搜索算法(如2-opt局部搜索、Lin-Kernighan等)。一个常见的混合策略是:
- 先运行完整的模拟退火过程,得到一个较好的解。
- 将这个解作为初始解,运行一个贪婪的局部搜索(例如,反复尝试所有可能的2-opt交换,只要能使路径变短就接受),直到无法再改进。
这相当于用SA进行“粗调”,用局部搜索进行“精修”。在代码实现上,可以在SA的solve方法返回前,增加一个局部搜索的步骤。
def local_search_2opt(path, dist_matrix): """对给定路径进行贪婪的2-opt局部搜索""" n = len(path) improved = True best_path = path.copy() best_energy = total_distance(best_path, dist_matrix) while improved: improved = False for i in range(n): for j in range(i+2, n): # 确保片段长度至少为2 # 尝试交换边 (i, i+1) 和 (j, j+1) # 计算交换后距离的变化量,避免重复计算整条路径 # 这里简化处理,直接构造新路径并计算能量 new_path = best_path.copy() # 逆转 i+1 到 j 的片段 new_path[i+1:j+1] = new_path[i+1:j+1][::-1] new_energy = total_distance(new_path, dist_matrix) if new_energy < best_energy: best_path, best_energy = new_path, new_energy improved = True break # 找到改进就跳出内层循环,重新开始扫描 if improved: break return best_path, best_energy # 在SA求解后调用 best_path_sa, best_energy_sa = sa_solver.solve() best_path_final, best_energy_final = local_search_2opt(best_path_sa, dist_matrix)6.2 并行化与多起点策略
模拟退火的内循环(每个温度下的L次迭代)是顺序的,但我们可以从两个层面并行:
- 多起点并行:同时从多个不同的随机初始路径开始运行独立的SA过程,最后取所有结果中的最优解。这可以充分利用多核CPU,并且由于初始状态的随机性,更有可能找到全局最优解。可以用Python的
multiprocessing或concurrent.futures模块实现。 - 并行尝试邻域:在每个温度下,可以同时生成多个候选新解(例如,用多个线程或进程),然后并行计算它们的能量,最后统一进行Metropolis判断。但这涉及到状态同步,实现起来比多起点策略复杂。
多起点策略实现简单,收益明显,是我最推荐的进阶实践。
from concurrent.futures import ProcessPoolExecutor def run_sa_for_seed(seed): """单个种子下的SA运行任务""" np.random.seed(seed) solver = SimulatedAnnealingTSP(cities, dist_matrix) path, energy = solver.solve(T_init=1000, T_end=1e-7, alpha=0.995, L=1500) return path, energy def parallel_sa(cities, num_processes=4, num_trials_per_process=3): """并行多起点SA""" dist_matrix = create_dist_matrix(cities) seeds = list(range(num_processes * num_trials_per_process)) # 生成不同的种子 best_global_energy = float('inf') best_global_path = None with ProcessPoolExecutor(max_workers=num_processes) as executor: futures = [executor.submit(run_sa_for_seed, seed) for seed in seeds] for future in concurrent.futures.as_completed(futures): path, energy = future.result() if energy < best_global_energy: best_global_energy = energy best_global_path = path return best_global_path, best_global_energy6.3 针对TSP的特殊邻域操作
除了通用的交换和逆转,TSP还有一些更高效的专用邻域操作,可以在SA的框架内使用:
- 3-opt:断开路径的三条边,然后以另一种方式重新连接。它比2-opt的搜索空间更大,扰动更强,适合在高温阶段使用。
- 双桥移动(Double-Bridge Move):一种特殊的4-opt移动,能产生非常大的扰动,常用于跳出非常深的局部最优,是许多元启发式算法(如迭代局部搜索ILS)中的“抖动”操作。
实现这些操作稍微复杂一些,需要仔细处理路径片段的切割和重组,但它们能显著提升算法对复杂解空间的探索能力。