模拟退火算法实战:用Python解决旅行商问题(TSP)的完整代码解析
模拟退火算法实战:用Python解决旅行商问题(TSP)的完整代码解析
每次面对一个看似无解的复杂优化问题,比如规划一条覆盖几十个城市的最短路线,或者为一个庞大的数据中心寻找最优的服务器布局,那种感觉就像面对一团乱麻。传统的穷举法在问题规模稍大时就变得遥不可及,而一些贪婪算法又常常一头扎进局部最优的陷阱里出不来。这时候,一种灵感来源于物理世界的算法——模拟退火,就成了我们工具箱里一件优雅而强大的武器。它不保证找到绝对的最优解,但在有限的时间和资源内,它往往能给出一个令人惊喜的、接近最优的答案。这篇文章,就是为你,一位希望将理论算法转化为实际生产力的Python开发者,准备的一份从原理到实战的深度指南。我们将一起动手,用代码实现模拟退火算法,并把它应用在经典的旅行商问题上,看着算法如何一步步“退火”,从一团混乱中结晶出优美的路径。
1. 理解模拟退火:物理灵感与算法内核
在深入代码之前,我们有必要先抛开那些复杂的数学公式,从最直观的物理图像来理解模拟退火。想象一下,你是一位铁匠,手中有一块烧得通红的铁。此时,铁内部的原子处于高度活跃、无序的状态。你的目标是通过“退火”工艺,让这些原子重新排列,形成坚固、稳定的晶体结构。这个过程的关键在于缓慢降温。如果你把烧红的铁直接丢进冷水里淬火,原子会被“冻”在某个高能量的无序状态,材料会变得硬而脆。但如果你让它在一个高温炉中慢慢冷却,原子就有足够的时间,通过热运动的扰动,逐渐“滑入”能量更低的稳定位置。
模拟退火算法正是对这一物理过程的绝妙模拟:
- 解的状态 对应材料的微观状态(原子排列)。
- 目标函数值(如路径总长度) 对应系统的能量。我们的目标是找到能量最低(目标函数值最小)的状态。
- 温度 是算法中一个核心的控制参数。高温时,算法接受“坏解”(使能量升高的新状态)的概率大,探索能力强;随着温度降低,接受坏解的概率变小,算法逐渐稳定,倾向于在好的解附近进行局部精细搜索。
算法的灵魂在于其接受新解的 Metropolis准则。这个准则允许算法以一定的概率接受一个比当前解更差的解。正是这个机制,赋予了算法跳出局部最优陷阱的能力。接受差解的概率 p 由公式决定:
p = exp(-ΔE / T)
其中 ΔE 是新解与旧解的目标函数值之差(对于最小化问题,ΔE > 0 表示解变差),T 是当前温度。从这个公式我们可以直观地看到:
- 当
T很高时,即使ΔE很大,p也可能接近1,算法几乎“肆无忌惮”地探索解空间。 - 当
T很低时,只有ΔE很小的差解才有被接受的可能,算法行为更接近传统的局部搜索。 - 当
ΔE为负(解变好)时,p恒大于1,意味着好解总是被接受。
注意:理解“温度”在算法中的抽象意义至关重要。它不是一个物理量,而是一个控制算法“探索”与“利用”平衡的衰减参数。初始温度设置、降温速度(退火计划)是影响算法性能的关键超参数。
为了更清晰地对比模拟退火与其他常见优化策略的核心区别,我们可以看下面这个简单的表格:
| 特性 | 模拟退火 (SA) | 梯度下降法 | 随机搜索 |
|---|---|---|---|
| 核心思想 | 模拟物理退火过程,以概率接受差解 | 沿目标函数梯度反方向迭代 | 在解空间中完全随机采样 |
| 跳出局部最优能力 | 强,得益于Metropolis准则 | 弱,容易陷入最近的局部极小点 | 理论上强,但效率极低 |
| 收敛性 | 理论上能以概率1收敛到全局最优(无限时间) | 收敛到局部最优 | 不保证收敛 |
| 对目标函数要求 | 极低,只需能计算函数值 | 需要可微 | 极低 |
| 主要超参数 | 初始温度、降温速率、马尔可夫链长度 | 学习率 | 采样次数 |
| 适用场景 | 组合优化、非凸函数、离散问题 | 连续可微凸/非凸问题 | 任何问题,但效率是瓶颈 |
2. 问题定义:旅行商问题(TSP)的建模
旅行商问题是一个经典的NP-hard组合优化问题,描述非常简单:一个商人需要访问N个城市,每个城市只访问一次,最后回到起点,要求找到总距离最短的访问路线。尽管描述简单,但其解空间随着城市数量N呈阶乘级((N-1)!/2)增长,使得精确求解在N较大时变得不可能。
用模拟退火解决TSP,我们首先需要将问题“翻译”成算法能处理的形式:
- 解的表达:一个解就是城市的一个排列(
permutation)。例如,对于4个城市[A, B, C, D],一个可能的解是 [A, C, B, D],表示访问顺序。 - 目标函数:计算该排列下,按顺序遍历所有城市并返回起点的总距离。我们需要一个计算距离的函数。这里我们使用欧几里得距离作为示例。
- 邻域操作(产生新解):这是模拟退火在TSP上的关键设计。如何从当前解产生一个“邻居”解?常用的操作有:
- 交换(Swap):随机选择两个位置,交换这两个位置上的城市。
- 逆转(Reverse/2-opt):随机选择一段子路径,将其顺序完全反转。这是TSP中非常高效的一种邻域操作。
- 插入(Insert):随机选择一个城市,将其插入到另一个随机位置。
在接下来的实现中,我们将主要使用交换和逆转操作来生成新解,你会发现它们对解的质量有不同影响。
我们先来搭建项目的基础结构,并实现城市坐标生成、距离计算和可视化函数。这是所有后续工作的基石。
import numpy as np
import matplotlib.pyplot as plt
import random
import math
from typing import List, Tuple
import time
class TSPProblem:
"""定义TSP问题实例"""
def __init__(self, num_cities: int = 20, seed: int = 42):
"""
初始化TSP问题,随机生成城市坐标。
参数:
num_cities: 城市数量
seed: 随机种子,确保结果可复现
"""
np.random.seed(seed)
self.num_cities = num_cities
# 在[0, 100]的二维平面内随机生成城市坐标
self.coords = np.random.rand(num_cities, 2) * 100
# 预计算距离矩阵,加速距离查询
self._compute_distance_matrix()
def _compute_distance_matrix(self):
"""计算并存储所有城市两两之间的欧氏距离矩阵"""
self.dist_matrix = np.zeros((self.num_cities, self.num_cities))
for i in range(self.num_cities):
for j in range(i+1, self.num_cities):
dist = np.linalg.norm(self.coords[i] - self.coords[j])
self.dist_matrix[i][j] = dist
self.dist_matrix[j][i] = dist # 距离矩阵是对称的
def get_distance(self, city_a: int, city_b: int) -> float:
"""快速获取两个城市间的距离"""
return self.dist_matrix[city_a][city_b]
def total_distance(self, tour: List[int]) -> float:
"""
计算给定路径序列的总距离。
参数:
tour: 城市索引的列表,例如 [0, 3, 1, 2]
返回:
路径的总长度
"""
total = 0.0
num = len(tour)
for i in range(num):
# 从当前城市到下一个城市的距离,最后一个城市连接到起点
total += self.get_distance(tour[i], tour[(i + 1) % num])
return total
def plot_tour(self, tour: List[int], title: str = "TSP Path"):
"""可视化路径"""
plt.figure(figsize=(10, 6))
# 绘制城市点
plt.scatter(self.coords[:, 0], self.coords[:, 1], c='red', s=100, zorder=5)
for i, (x, y) in enumerate(self.coords):
plt.text(x, y, str(i), fontsize=12, ha='center', va='center', zorder=6)
# 绘制路径线
tour_coords = self.coords[tour + [tour[0]]] # 使路径闭合
plt.plot(tour_coords[:, 0], tour_coords[:, 1], 'b-', linewidth=1, alpha=0.8)
plt.scatter(tour_coords[0, 0], tour_coords[0, 1], c='green', s=150, marker='*', zorder=7, label='Start/End')
plt.xlabel("X Coordinate")
plt.ylabel("Y Coordinate")
plt.title(f"{title} | Total Distance: {self.total_distance(tour):.2f}")
plt.grid(True, alpha=0.3)
plt.legend()
plt.axis('equal')
plt.tight_layout()
plt.show()
# 示例:创建一个包含15个城市的问题并随机生成一条路径看看
if __name__ == "__main__":
problem = TSPProblem(num_cities=15)
random_tour = list(range(15))
random.shuffle(random_tour)
print(f"随机路径的总距离: {problem.total_distance(random_tour):.2f}")
problem.plot_tour(random_tour, "Random Initial Tour")
运行上面的代码,你会看到一张散点图,红色的点代表城市,蓝色的线是随机生成的一条访问路径。这个距离通常很大,路径交叉严重,我们的目标就是让模拟退火算法把它“熨平”。
3. 模拟退火算法核心实现
有了问题定义和基础工具,我们现在可以构建模拟退火算法的核心引擎了。这个部分我们将把之前讨论的物理概念——温度、退火计划、Metropolis准则——一一用代码实现。
一个健壮的模拟退火实现需要考虑以下几个模块:
- 初始解生成:一个简单的随机排列即可。
- 邻域移动生成器:实现交换、逆转等操作。
- 退火计划:决定温度如何随着迭代下降。常见的有线性衰减、指数衰减等。
- 内循环(马尔可夫链长度):在每个温度下,进行多少次邻域搜索尝试。
- 停止准则:何时结束算法?例如温度低于阈值、连续若干代解无改进等。
下面是我们完整的模拟退火求解器实现。我添加了大量注释,并设计了灵活的接口,方便你后续调整策略和参数。
class SimulatedAnnealingTSP:
"""模拟退火算法求解TSP"""
def __init__(self, problem: TSPProblem, initial_temperature: float = 1000.0,
cooling_rate: float = 0.995, min_temperature: float = 1e-3,
iterations_per_temp: int = 1000, use_2opt: bool = True):
"""
初始化SA求解器。
参数:
problem: TSPProblem实例
initial_temperature: 初始温度,控制早期探索性
cooling_rate: 降温系数,每次迭代温度乘以这个系数(指数退火)
min_temperature: 最低温度,低于此温度算法终止
iterations_per_temp: 每个温度下的迭代次数(马尔可夫链长度)
use_2opt: 是否在邻域操作中使用2-opt(逆转)作为主要扰动方式
"""
self.problem = problem
self.T_init = initial_temperature
self.cooling_rate = cooling_rate
self.T_min = min_temperature
self.iter_per_temp = iterations_per_temp
self.use_2opt = use_2opt
# 记录历史数据用于分析
self.best_tour_history = []
self.best_distance_history = []
self.current_distance_history = []
self.temperature_history = []
def _generate_initial_solution(self) -> List[int]:
"""生成初始解:随机排列"""
tour = list(range(self.problem.num_cities))
random.shuffle(tour)
return tour
def _generate_neighbor(self, tour: List[int]) -> List[int]:
"""
通过邻域操作产生一个新解(邻居)。
这里我们随机选择两种操作之一:交换两个城市,或进行2-opt逆转。
"""
new_tour = tour.copy()
n = len(new_tour)
if self.use_2opt and random.random() > 0.3: # 70%的概率使用2-opt
# 2-opt操作:随机选择两个索引i, j (i < j),逆转i到j之间的子路径
i = random.randint(0, n-2)
j = random.randint(i+1, n-1)
new_tour[i:j+1] = reversed(new_tour[i:j+1])
else:
# 交换操作:随机选择两个不同的位置进行交换
i, j = random.sample(range(n), 2)
new_tour[i], new_tour[j] = new_tour[j], new_tour[i]
return new_tour
def _calculate_delta_distance(self, old_tour: List[int], new_tour: List[int]) -> float:
"""
高效计算新旧路径的距离变化量ΔE。
注意:由于我们只做了局部改动(交换或逆转),可以只计算受影响部分距离的变化,
而不必重新计算整条路径,这能极大提升性能。这里为清晰起见,我们使用完整计算。
在实际高性能实现中,强烈建议实现增量计算。
"""
old_dist = self.problem.total_distance(old_tour)
new_dist = self.problem.total_distance(new_tour)
return new_dist - old_dist
def solve(self, verbose: bool = True) -> Tuple[List[int], float]:
"""
执行模拟退火算法主循环。
返回:
best_tour: 找到的最佳路径
best_distance: 最佳路径对应的距离
"""
# 初始化
current_tour = self._generate_initial_solution()
current_distance = self.problem.total_distance(current_tour)
best_tour = current_tour.copy()
best_distance = current_distance
T = self.T_init
iteration = 0
if verbose:
print(f"开始模拟退火优化...")
print(f"初始随机路径距离: {best_distance:.2f}")
print("-" * 50)
# 主退火循环
while T > self.T_min:
accepted_moves = 0
for _ in range(self.iter_per_temp):
# 1. 产生邻居解
new_tour = self._generate_neighbor(current_tour)
delta_dist = self._calculate_delta_distance(current_tour, new_tour)
# 2. 根据Metropolis准则决定是否接受新解
if delta_dist < 0:
# 新解更好,总是接受
accept = True
else:
# 新解更差,以概率 exp(-ΔE / T) 接受
probability = math.exp(-delta_dist / T)
accept = random.random() < probability
if accept:
current_tour = new_tour
current_distance += delta_dist # 更新当前距离
accepted_moves += 1
# 3. 更新历史最优解
if current_distance < best_distance:
best_tour = current_tour.copy()
best_distance = current_distance
if verbose and iteration % 500 == 0:
print(f"Iter {iteration:6d}, T={T:.4f}, Best Dist={best_distance:.2f}")
# 记录数据
self.current_distance_history.append(current_distance)
self.best_distance_history.append(best_distance)
self.temperature_history.append(T)
iteration += 1
# 4. 降温
T *= self.cooling_rate
# 可选:根据接受率动态调整马尔可夫链长度(高级技巧)
# acceptance_rate = accepted_moves / self.iter_per_temp
# if acceptance_rate < 0.1:
# # 接受率太低,可以提前升温或跳出循环
# pass
if verbose:
print("-" * 50)
print(f"优化完成!共迭代 {iteration} 次。")
print(f"找到的最短路径距离: {best_distance:.2f}")
print(f"相比初始解提升: {(self.problem.total_distance(self._generate_initial_solution()) - best_distance) / best_distance * 100:.1f}% (近似)")
self.best_tour_history = best_tour
return best_tour, best_distance
def plot_optimization_process(self):
"""绘制优化过程曲线,展示距离和温度随迭代次数的变化"""
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(12, 8))
iterations = range(len(self.best_distance_history))
ax1.plot(iterations, self.best_distance_history, 'g-', linewidth=1.5, label='Best Distance', alpha=0.7)
ax1.plot(iterations, self.current_distance_history, 'r-', linewidth=0.5, label='Current Distance', alpha=0.4)
ax1.set_xlabel('Iteration')
ax1.set_ylabel('Distance')
ax1.set_title('Simulated Annealing Optimization Progress')
ax1.legend()
ax1.grid(True, alpha=0.3)
ax2.plot(iterations, self.temperature_history, 'b-', linewidth=1)
ax2.set_xlabel('Iteration')
ax2.set_ylabel('Temperature (log scale)')
ax2.set_title('Temperature Schedule')
ax2.set_yscale('log')
ax2.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
现在,让我们创建一个问题实例并用我们的求解器来跑一下,看看效果。
# 创建并解决问题
problem = TSPProblem(num_cities=25, seed=123)
# 初始化求解器,参数可以调整
solver = SimulatedAnnealingTSP(problem,
initial_temperature=1000,
cooling_rate=0.995,
min_temperature=1e-3,
iterations_per_temp=1000,
use_2opt=True)
start_time = time.time()
best_tour, best_dist = solver.solve(verbose=True)
end_time = time.time()
print(f"\n优化耗时: {end_time - start_time:.2f} 秒")
# 可视化最终结果
problem.plot_tour(best_tour, title="Optimized Tour by Simulated Annealing")
# 查看优化过程
solver.plot_optimization_process()
运行这段代码,你会在控制台看到算法迭代的过程,最终输出优化后的路径长度和耗时。两张图会分别展示优化后的路径(应该比最初的随机路径整洁、交叉少得多)以及算法搜索过程中“当前解距离”和“历史最优解距离”的变化曲线。你会注意到,当前解距离(红线)上下跳动得很厉害,尤其是在高温阶段,这正是算法在探索解空间的表现。而历史最优解距离(绿线)则呈现一个阶梯式下降的趋势。
4. 参数调优与高级技巧:让算法更高效
模拟退火算法好用,但其性能极大地依赖于参数设置。一套糟糕的参数可能让算法在解空间里盲目游荡很久也找不到好解。这一节,我们就像调试精密仪器一样,来聊聊如何调整这些“旋钮”。
4.1 核心参数解析与调优指南
-
初始温度 (
initial_temperature):- 作用:决定算法初期的“探索野心”。温度太高,初期会接受大量差解,搜索随机,收敛慢;温度太低,则过早陷入局部搜索,可能跳不出局部最优。
- 调优方法:一个经验法则是,让初始温度下,接受差解的概率在一个较高的水平(例如0.8以上)。可以通过一个小实验来设定:随机产生大量邻域移动,计算目标函数变化的平均值
ΔE_avg,然后根据T0 = -ΔE_avg / ln(p)来估算,其中p是你期望的初始接受概率。
-
降温速率 (
cooling_rate):- 作用:控制温度下降的速度,是“退火计划”的核心。指数退火
T_{k+1} = α * T_k是最常用的方式。 - 调优方法:
α通常取值在[0.9, 0.999]之间。值越接近1,降温越慢,在每个温度下搜索得越充分,但耗时也越长。对于复杂问题,需要更慢的退火(更大的α)。可以尝试0.99,0.995,0.999等值。
- 作用:控制温度下降的速度,是“退火计划”的核心。指数退火
-
每个温度的迭代次数 (
iterations_per_temp):- 作用:也称为马尔可夫链长度。它决定了在每个温度下,算法尝试搜索的邻域解的数量。
- 调优方法:通常与问题规模相关。对于TSP,可以设置为城市数量的若干倍(如
100*n到1000*n)。太短则搜索不充分,太长则增加不必要的计算。一个高级技巧是使其与温度挂钩,高温时短一些(快速探索),低温时长一些(精细搜索)。
-
终止温度 (
min_temperature):- 作用:算法停止的条件之一。当温度低于此阈值时,算法几乎不再接受差解,可以终止。
- 调优方法:通常设为一个很小的正数,如
1e-3,1e-5。也可以结合其他终止条件,如连续若干代最优解无改进。
为了直观感受参数的影响,我们可以设计一个小实验,对比不同降温速率的效果:
def compare_cooling_rates(problem, rates=[0.99, 0.995, 0.999]):
"""比较不同降温速率对最终结果的影响"""
results = {}
for rate in rates:
print(f"\n测试降温速率 α = {rate}")
solver = SimulatedAnnealingTSP(problem,
initial_temperature=1000,
cooling_rate=rate,
min_temperature=1e-3,
iterations_per_temp=500) # 固定迭代次数
start = time.time()
best_tour, best_dist = solver.solve(verbose=False)
elapsed = time.time() - start
results[rate] = {'distance': best_dist, 'time': elapsed}
print(f" 最短距离: {best_dist:.2f}, 耗时: {elapsed:.2f}s")
# 简单结果对比
print("\n=== 结果对比 ===")
for rate, data in results.items():
print(f"α={rate}: 距离 {data['distance']:.2f}, 时间 {data['time']:.2f}s")
运行这个比较函数,你会发现 α=0.999 的慢速退火通常能找到更好的解,但花费的时间也显著更长。这体现了优化中永恒的 “效果-效率”权衡。
4.2 邻域操作的进阶选择
我们之前实现了交换和2-opt两种邻域操作。实际上,针对TSP,学术界和工业界有更多高效的邻域结构:
- 3-opt:比2-opt更复杂的操作,通过断开路径的三条边并重新连接,能产生更大的扰动,跳出更深局部最优的能力更强,但每次评估的计算量也更大。
- Lin-Kernighan (LK) 启发式:这是一种非常强大的局部搜索启发式,可以看作是2-opt和3-opt的智能组合与迭代。它被广泛用于TSP的高性能求解器中。将模拟退火与LK结合(例如,以一定概率在SA的每次迭代中调用LK进行深度局部搜索),可以极大提升解的质量。
下面是一个概念性的代码片段,展示如何将更复杂的邻域操作集成到我们的框架中:
class AdvancedSATSPSolver(SimulatedAnnealingTSP):
"""集成更多高级邻域操作的SA求解器"""
def _generate_neighbor_advanced(self, tour: List[int]) -> List[int]:
"""以不同概率尝试多种邻域操作"""
rand_val = random.random()
new_tour = tour.copy()
n = len(new_tour)
if rand_val < 0.6: # 60%概率使用2-opt
i = random.randint(0, n-2)
j = random.randint(i+1, n-1)
new_tour[i:j+1] = reversed(new_tour[i:j+1])
elif rand_val < 0.9: # 30%概率使用交换
i, j = random.sample(range(n), 2)
new_tour[i], new_tour[j] = new_tour[j], new_tour[i]
else: # 10%概率尝试插入操作
i = random.randint(0, n-1)
city = new_tour.pop(i)
j = random.randint(0, n-1)
new_tour.insert(j, city)
return new_tour
# 可以在这里重写 _generate_neighbor 方法,调用上面的高级版本
4.3 自适应退火与重启策略
为了让算法更智能,我们可以引入一些自适应机制:
- 自适应退火计划:根据当前搜索状态动态调整降温速率。例如,如果当前温度下接受率一直很高,说明温度可能还太高,可以加快降温;如果接受率骤降,可以适当减缓降温甚至短暂“回温”。
- 重启策略:当算法在低温下陷入停滞(最优解长时间不更新)时,可以保存当前最优解,然后从一个新的随机解(或对当前最优解施加一个较大扰动)开始,并重置到一个中等温度,重新进行退火。这能有效增加找到全局最优的概率。
# 一个简单的重启策略伪代码示例
def solve_with_restart(self, max_restarts=5):
global_best_tour = None
global_best_dist = float('inf')
for restart in range(max_restarts):
# 每次重启,可以稍微扰动上次的全局最优解作为初始解,而非完全随机
if global_best_tour:
current_tour = self._perturb_solution(global_best_tour, strength=0.1)
else:
current_tour = self._generate_initial_solution()
# 用稍低的初始温度开始新一轮退火
T = self.T_init / (restart + 1)
# ... 执行标准SA循环 ...
# 更新全局最优
if current_best_dist < global_best_dist:
global_best_dist = current_best_dist
global_best_tour = current_best_tour.copy()
print(f"重启 {restart+1}/{max_restarts} 完成,当前全局最优: {global_best_dist:.2f}")
return global_best_tour, global_best_dist
参数调优没有银弹,最好的方法是对你的特定问题(城市分布、规模)进行多次实验,记录不同参数组合下的结果(最终距离、运行时间、收敛曲线),然后根据你的需求(是追求极致解质量,还是要求快速得到一个可接受的解)来选择最合适的配置。这个过程本身,也充满了探索的乐趣。
更多推荐


所有评论(0)