Python实现禁忌搜索算法:从原理到TSP问题实战优化
2026/8/28 3:31:27 网站建设 项目流程

1. 项目概述:当数学建模遇上禁忌搜索

如果你正在准备数学建模竞赛,或者在工作中需要解决一些复杂的组合优化问题,比如车辆路径规划、生产调度、网络布局,那你大概率听说过“禁忌搜索”这个名字。它不像遗传算法那样名声在外,也不如模拟退火听起来那么“物理”,但在很多实际问题中,禁忌搜索往往能更快、更稳定地找到高质量的可行解。这个算法的核心思想非常“人性化”:它模拟了一个有记忆的探索者,会记住最近走过的“弯路”(禁忌表),并在一段时间内避免重蹈覆辙,从而迫使自己去探索新的区域。这次,我们不谈复杂的数学推导,就用Python,从零开始,亲手实现一个禁忌搜索算法,并把它应用到一个经典的旅行商问题(TSP)上。你会发现,用一百多行清晰的代码,就能搭建一个强大的优化引擎,这比单纯调用现成的库更能让你理解算法的每一个“齿轮”是如何咬合的。

2. 算法核心思想与设计拆解

2.1 禁忌搜索的“记忆”哲学

禁忌搜索属于一种元启发式算法。所谓“元启发式”,可以理解为一种高级的、指导性的搜索策略框架,它不依赖于问题的具体数学性质,而是通过一套规则来引导搜索过程。禁忌搜索最核心的机制就是“禁忌表”。你可以把它想象成我们大脑的短期记忆:当我们尝试解决一个问题时,如果某个方法最近试过了但效果不好,我们本能地会暂时不去用它,转而尝试其他可能。禁忌表正是模拟了这一过程。

具体来说,算法从一个初始解(可能很糟糕)开始,然后在其“邻域”内寻找更好的解。邻域是指通过一些特定操作(比如交换两个城市的位置、逆转一段路径)可以从当前解直接得到的所有解的集合。每次迭代,算法从当前解的邻域中选出一个最好的候选解(即使它比当前解差),并移动到该解。同时,将本次移动所采用的“操作”记录到禁忌表中,并设定一个“禁忌期限”(禁忌长度)。在禁忌期限内,这个操作被禁止再次使用,从而避免搜索过程陷入局部循环或原地打转。

2.2 关键组件与我们的设计选择

为了实现一个通用的禁忌搜索框架,我们需要定义几个核心组件,并在TSP问题上实例化它们:

  1. 解的表达:对于TSP,一个解就是一条访问所有城市恰好一次并回到起点的路径。我们用城市编号的列表来表示,例如[0, 3, 1, 2, 4]
  2. 邻域结构:即如何从一个当前解生成一系列“邻居”解。这里我们采用最常用的“2-opt”交换。操作定义为交换路径中两个不同位置的城市。例如,对解[A, B, C, D, E]交换位置1和3的城市,得到新解[A, D, C, B, E]。我们将这个“交换位置(1,3)”定义为一个“移动”。
  3. 禁忌对象:禁忌什么?通常有两种选择:禁忌解本身或禁忌移动。禁忌解需要存储整个解,内存开销大且判断耗时。因此我们选择禁忌“移动”,即禁忌一对位置(i, j)。在禁忌期内,禁止再次交换这两个位置。
  4. 禁忌表:我们将使用一个字典来实现。键是(i, j)(确保i < j以避免重复),值是这个移动被禁忌直到哪一次迭代。例如,{(1,3): 15}表示交换位置1和3的操作,直到第15次迭代后才被解禁。
  5. 藐视准则:这是算法的“灵活”之处。如果某个被禁忌的移动能产生一个比历史最优解还要好的解(即“特赦”情况),那么我们可以破例选择这个移动。这保证了算法不会错过真正优秀的解。
  6. 终止条件:我们设定两个条件:最大迭代次数,以及连续若干次迭代最优解未改善的“耐心值”。

注意:禁忌长度的选择是个经验活。太短,算法容易陷入循环;太长,会过度限制搜索空间。通常设置为问题规模(如城市数)的平方根附近,并通过实验调整。

3. Python实现详解与核心代码

3.1 环境准备与问题定义

我们首先需要准备TSP问题的数据。这里我们随机生成20个城市的坐标来创建一个问题实例。同时,我们需要一个计算路径总距离的函数。

import numpy as np import matplotlib.pyplot as plt import random # 1. 生成模拟数据:20个城市的二维坐标 num_cities = 20 np.random.seed(42) # 固定随机种子,确保结果可复现 cities = np.random.rand(num_cities, 2) * 100 # 2. 计算距离矩阵(对称矩阵,对角线为0) def calculate_distance_matrix(points): n = len(points) dist_mat = np.zeros((n, n)) for i in range(n): for j in range(i+1, n): dist = np.linalg.norm(points[i] - points[j]) dist_mat[i][j] = dist dist_mat[j][i] = dist return dist_mat distance_matrix = calculate_distance_matrix(cities) # 3. 计算一条路径的总距离 def total_distance(route, dist_mat): """计算给定路径的总距离。route是城市索引列表,如[0,2,1,3]""" dist = 0 for i in range(len(route)): from_city = route[i] to_city = route[(i + 1) % len(route)] # 取模以实现闭环 dist += dist_mat[from_city][to_city] return dist

3.2 禁忌搜索算法主体实现

接下来是算法的核心部分。我们将它封装成一个函数,参数包括初始解、最大迭代次数、禁忌长度等。

def tabu_search(initial_route, dist_mat, max_iter=1000, tabu_tenure=10, aspiration_criteria=True, patience=50): """ 禁忌搜索算法主函数 参数: initial_route: 初始路径列表 dist_mat: 距离矩阵 max_iter: 最大迭代次数 tabu_tenure: 禁忌长度(禁忌表项存活的迭代次数) aspiration_criteria: 是否启用藐视准则 patience: 提前停止耐心值(最优解连续未更新的迭代次数) 返回: best_route: 历史最优路径 best_distance: 历史最优距离 history: 每次迭代的最优距离记录(用于绘图分析) """ current_route = initial_route.copy() current_dist = total_distance(current_route, dist_mat) best_route = current_route.copy() best_dist = current_dist # 初始化禁忌表(空字典) tabu_list = {} # 记录搜索历史 history = [] no_improve_counter = 0 # 开始迭代 for iteration in range(max_iter): # 生成当前解的所有可能移动(交换两个城市位置) n = len(current_route) best_candidate_route = None best_candidate_dist = float('inf') best_move = None # 遍历所有可能的交换(i, j),寻找最佳候选解 for i in range(n): for j in range(i+1, n): # 确保 i < j,避免重复 # 执行移动:交换位置i和j的城市 candidate_route = current_route.copy() candidate_route[i], candidate_route[j] = candidate_route[j], candidate_route[i] candidate_dist = total_distance(candidate_route, dist_mat) move = (i, j) # 判断该移动是否被禁忌 is_tabu = move in tabu_list and tabu_list[move] > iteration # 评估候选解 # 条件1:未被禁忌,且优于当前找到的最佳候选 # 条件2(藐视准则):即使被禁忌,但结果优于历史全局最优 if (not is_tabu and candidate_dist < best_candidate_dist) or \ (aspiration_criteria and is_tabu and candidate_dist < best_dist): best_candidate_route = candidate_route best_candidate_dist = candidate_dist best_move = move # 如果没找到候选解(理论上不会,除非禁忌表设置极其不合理),则终止 if best_candidate_route is None: print(f"迭代 {iteration}: 未找到可行候选解,提前终止。") break # 移动到最佳候选解 current_route = best_candidate_route current_dist = best_candidate_dist # 更新禁忌表:将本次采用的移动加入禁忌表,设置其过期时间 # 同时,更新禁忌表中所有项的“存活时间” tabu_list[best_move] = iteration + tabu_tenure # 可选:清理过期的禁忌表项以节省内存(非必须,但更规范) tabu_list = {move: expiry for move, expiry in tabu_list.items() if expiry > iteration} # 更新历史最优解 if current_dist < best_dist: best_dist = current_dist best_route = current_route.copy() no_improve_counter = 0 # 重置未改进计数器 print(f"迭代 {iteration}: 发现新的全局最优解 {best_dist:.2f}") else: no_improve_counter += 1 history.append(best_dist) # 提前停止条件:最优解连续 patience 次迭代未改进 if no_improve_counter >= patience: print(f"迭代 {iteration}: 最优解连续 {patience} 次未更新,提前终止。") break return best_route, best_dist, history

3.3 生成初始解与执行算法

一个简单的初始解可以是城市的顺序列表。但更好的初始解(如最近邻法)可以加速收敛。这里我们先使用随机初始解来演示算法的“优化能力”。

# 生成初始解(随机排列) initial_route = list(range(num_cities)) random.shuffle(initial_route) initial_distance = total_distance(initial_route, distance_matrix) print(f"初始路径: {initial_route}") print(f"初始路径总距离: {initial_distance:.2f}") # 执行禁忌搜索 best_route, best_distance, history = tabu_search( initial_route=initial_route, dist_mat=distance_matrix, max_iter=500, tabu_tenure=int(np.sqrt(num_cities)) + 3, # 禁忌长度经验公式 aspiration_criteria=True, patience=30 ) print(f"\n优化后路径: {best_route}") print(f"优化后总距离: {best_distance:.2f}") print(f"距离提升: {(initial_distance - best_distance) / initial_distance * 100:.1f}%")

4. 结果可视化与算法行为分析

4.1 绘制优化前后路径对比图

代码跑完了,我们得看看效果。可视化是最直观的评估方式。

def plot_route(ax, points, route, title, color='blue', linewidth=1.5): """在给定的axes上绘制一条路径""" ax.scatter(points[:, 0], points[:, 1], c='red', s=50, zorder=5) for i, city in enumerate(points): ax.annotate(str(i), (city[0], city[1]), fontsize=8) # 按路径顺序连接城市 route_points = points[route] route_points = np.vstack([route_points, route_points[0]]) # 闭合路径 ax.plot(route_points[:, 0], route_points[:, 1], color=color, linewidth=linewidth, marker='o', markersize=4) ax.set_title(title) ax.set_aspect('equal') ax.grid(True, alpha=0.3) # 创建对比图 fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 6)) plot_route(ax1, cities, initial_route, f'初始路径 (距离={initial_distance:.2f})', color='lightcoral') plot_route(ax2, cities, best_route, f'禁忌搜索优化后 (距离={best_distance:.2f})', color='seagreen') plt.tight_layout() plt.show()

运行后,你会看到两幅图并列。左边是杂乱无章、交叉严重的初始随机路径,右边则是经过禁忌搜索优化后,路径变得清晰、有序,交叉基本消除。距离值的对比会非常明显,通常有20%-50%的优化幅度。

4.2 收敛曲线分析

我们记录了每次迭代后的历史最优距离history,绘制收敛曲线可以让我们洞察算法的搜索过程。

# 绘制收敛曲线 plt.figure(figsize=(10, 5)) plt.plot(history, linewidth=2) plt.xlabel('迭代次数') plt.ylabel('历史最优距离') plt.title('禁忌搜索收敛曲线') plt.grid(True, alpha=0.3) # 标记初始距离作为参考 plt.axhline(y=initial_distance, color='r', linestyle='--', alpha=0.7, label=f'初始距离 ({initial_distance:.2f})') plt.legend() plt.show()

观察这条曲线,你会发现:

  1. 快速下降期:算法初期会快速找到一系列改进解,曲线陡峭下降。
  2. 平台期与波动:随着搜索深入,改进变得困难,曲线进入平台期,并伴有小幅波动。这些波动可能是藐视准则生效,接受了被禁忌的“好移动”。
  3. 收敛:最终曲线趋于平稳,表明算法在当前参数下已很难找到更优解。

实操心得:收敛曲线是调参的重要依据。如果曲线下降太慢,可以尝试减小禁忌长度,让搜索更活跃;如果曲线过早平坦且结果不佳,可以增加禁忌长度或最大迭代次数,让算法探索更充分。

5. 参数调优与高级技巧

5.1 关键参数的影响与调优指南

禁忌搜索的性能很大程度上依赖于参数设置。以下是核心参数的调优思路:

参数典型值/范围影响调优建议
禁忌长度 (tabu_tenure)sqrt(n)n/2(n为问题规模)控制搜索的“记忆力”。太短易循环,太长限制探索。sqrt(n)开始。如果收敛过快但结果差,适当增加;如果陷入局部最优,可尝试动态长度(如在一个区间内随机)。
最大迭代次数 (max_iter)500 - 5000+决定搜索的总预算。结合收敛曲线判断。确保曲线已进入稳定的平台期。对于复杂问题,需要更多迭代。
邻域大小通常探索全部或部分邻域每次迭代评估的候选解数量。全邻域计算开销大。对于大规模问题,采用候选列表策略:只随机采样或评估一部分“有希望”的邻域移动,能极大提速。
藐视准则通常启用避免错过优质解的关键机制。务必启用。这是跳出局部最优的重要保障。
终止耐心值 (patience)50 - 200允许最优解不改进的连续迭代次数。防止无意义的长时间运行。设为最大迭代次数的10%-20%是个不错的起点。

5.2 算法变体与性能提升策略

基础的禁忌搜索已经很强,但我们可以通过一些策略让它更强大:

  1. 强化初始解:不要用完全随机的初始解。使用一个简单的启发式算法(如最近邻法、贪婪算法)生成一个较好的初始解,可以大幅减少算法前期的“瞎逛”时间。
  2. 多样化搜索:当搜索陷入僵局时(即长时间无改进),可以主动引入“扰动”。例如,随机执行多次不受禁忌表限制的移动,将当前解跳到一个全新区域,然后重新开始禁忌搜索。这被称为“禁忌搜索-重启”策略。
  3. 自适应禁忌长度:固定禁忌长度可能不是最优的。可以设计规则使其动态变化。例如,当解的质量提升快时,缩短禁忌长度以加强局部搜索;当陷入停滞时,增加禁忌长度以促进多样化。
  4. 并行化探索:可以同时运行多个禁忌搜索线程(具有不同的初始解或参数),并定期交换彼此找到的优秀解。这能有效扩大搜索范围。

这里给出一个“最近邻初始解”的改进示例:

def nearest_neighbor_initial(points, start_city=0): """使用最近邻法构造初始路径""" n = len(points) unvisited = set(range(n)) unvisited.remove(start_city) route = [start_city] current = start_city while unvisited: # 找出离当前城市最近的未访问城市 next_city = min(unvisited, key=lambda city: np.linalg.norm(points[current] - points[city])) route.append(next_city) unvisited.remove(next_city) current = next_city return route # 使用最近邻法生成更好的初始解 good_initial_route = nearest_neighbor_initial(cities) good_initial_dist = total_distance(good_initial_route, distance_matrix) print(f"最近邻初始解距离: {good_initial_dist:.2f}") # 用这个更好的初始解再次运行禁忌搜索 best_route_v2, best_dist_v2, history_v2 = tabu_search( initial_route=good_initial_route, dist_mat=distance_matrix, max_iter=300, # 因为起点更好,可能需要的迭代次数更少 tabu_tenure=int(np.sqrt(num_cities)) + 3, patience=30 ) print(f"基于最近邻初始解的优化结果: {best_dist_v2:.2f}")

你会发现,从一个较好的起点开始,算法收敛更快,且最终结果可能更优或相当。

6. 常见问题与调试技巧实录

在实际编码和运行中,你可能会遇到以下典型问题:

问题1:算法很快收敛到一个很差的解,然后一动不动。

  • 可能原因1:禁忌长度太长。搜索被过度限制,无法进行有效的局部探索。
    • 排查:查看收敛曲线,是否在最初几次迭代后立刻变成水平直线。
    • 解决:显著减小tabu_tenure,例如设为int(np.sqrt(n))或更小。
  • 可能原因2:邻域结构设计不当或候选解生成有误
    • 排查:在迭代循环中打印best_candidate_distcurrent_dist,看候选解是否真的在变化。检查交换操作的代码逻辑。
    • 解决:确保邻域移动能正确生成新解。对于TSP,2-opt交换是可靠的。

问题2:算法结果不稳定,每次运行差距很大。

  • 可能原因:初始解是随机的,且算法对初始解敏感。
    • 解决
      1. 采用确定性或启发式初始解(如最近邻法)。
      2. 多次运行算法,取最好结果。可以简单写个循环。
      num_runs = 10 best_of_all = float('inf') best_route_of_all = None for run in range(num_runs): init_route = list(range(num_cities)) random.shuffle(init_route) final_route, final_dist, _ = tabu_search(init_route, distance_matrix, max_iter=300) if final_dist < best_of_all: best_of_all = final_dist best_route_of_all = final_route print(f"第{run+1}次运行,结果: {final_dist:.2f}") print(f"\n{num_runs}次运行中的最佳结果: {best_of_all:.2f}")

问题3:对于城市数量很多(比如100个以上)的问题,速度非常慢。

  • 原因:我们的实现是“全邻域”搜索,每次迭代要评估O(n^2)个候选解,计算每个解的距离又是O(n),总复杂度是O(max_iter * n^3),无法扩展。
  • 优化策略
    1. 候选列表:不评估所有邻域,只评估一部分。例如,对于每个城市i,只考虑与它最近的k个城市进行交换。
    2. 增量计算:交换两个城市只影响路径中部分边的距离,无需重新计算整条路径的距离。可以设计一个函数,只计算移动带来的距离变化delta,将复杂度从O(n)降到O(1)
    3. 使用更高效的数据结构:如将路径和距离变化维护在特定结构中。

问题4:如何将代码应用到其他优化问题(如车间调度、背包问题)?

  • 核心是抽象:我们的禁忌搜索框架是通用的。你需要为你的具体问题定义:
    1. 解的表达:用一个数据结构表示你的解(如调度序列、物品选择向量)。
    2. 目标函数:替换total_distance,计算你的解对应的成本或收益。
    3. 邻域移动:定义如何从当前解通过一个“小改动”生成新解(如交换两个工序、翻转一个物品的选择状态)。
    4. 禁忌对象:决定是禁忌“解”还是“移动”。对于组合问题,禁忌“移动”更常见。
  • 模板化思考:一旦你抽象出这几个组件,就可以几乎复用上面的tabu_search函数主体,只需替换对应的函数调用。

调试时,最实用的方法是打印关键变量。在迭代开始、选择移动、更新禁忌表等关键步骤后,打印当前解、候选解、禁忌表状态等信息。这能帮你清晰地看到算法的决策过程,快速定位逻辑错误。例如,在每次迭代后打印current_dist,best_dist和禁忌表的大小,可以直观感受搜索进程和内存使用。

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

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

立即咨询