旅行商问题(TSP)(1)(Route.py)(TSP 问题中的点与路径核心类)
旅行商问题从实现到测试
共四篇博客分别讲解,四篇的内容分别是
1.TSP 问题中的点与路径核心类
2.TSP 的两种贪心启发式算法实现(最近邻算法和最近插入算法)
3.TSP 项目的测试验证体系
4.TSP 项目的 “时间统计与属性注入” 工具
在解决旅行商问题(TSP)时,“点” 和 “路径” 是两个最基础的概念。Route.py 文件通过 Point
类和 Route
类,将这两个概念封装成可操作的对象,为后续实现 TSP 启发式算法打下了核心基础。
Route.py完整代码在文章最下面
题目背景
一个商品推销员要去若干个城市推销商品,该推销员从一个城市出发,需要经过所有城市后,回到出发地。应如何选择行进路线,以使总的行程最短。这就是图论和计算机科学中著名的旅行商问题(Traveling Salesperson Problem,TSP)。
一、Point 类:定义 “点” 的属性与行为
Point
类代表 TSP 中的一个 “城市” 或 “节点”,核心是存储坐标并提供与其他点的交互方法。
1. 核心属性
x
:点的横坐标(数值类型,如整数、浮点数)y
:点的纵坐标(数值类型,如整数、浮点数)(与 x 类型一致)
这两个属性通过构造方法 __init__
初始化,是所有后续计算的基础。
2. 关键方法解析
(1)构造与等价判断:__init__
和 __eq__
def __init__(self, x, y):self.x = xself.y = ydef __eq__(self, other):if not isinstance(other, Point):return Falsereturn self.x == other.x and self.y == other.y
__init__
:直接接收 x 和 y 坐标,无复杂逻辑,确保点的初始化简单直观。__eq__
:判断两个点是否 “相等”—— 必须都是Point
实例,且 x、y 坐标完全一致。这是后续判断 “路径是否包含某点”“两点是否重复” 的关键。
(2)字符串表示:__repr__
def __repr__(self):return f"Point({self.x}, {self.y})"
打印 Point
实例时,会显示 Point(0, 0)
这样的格式,而非默认的内存地址。调试时能快速识别每个点的坐标,非常实用。
(3)距离计算:dist_to_point
def dist_to_point(self, other):if not isinstance(other, Point):raise TypeError("other must be a Point instance")dx = self.x - other.xdy = self.y - other.yreturn math.sqrt(dx ** 2 + dy ** 2)
- 功能:计算两个点之间的欧几里得距离(平面上两点间直线距离),是 TSP 中 “路径长度” 的核心计算逻辑。
- 细节:先校验
other
是否为Point
实例,避免传入错误类型;通过勾股定理sqrt(dx² + dy²)
计算距离,返回浮点数。
(4)找最近点:closest_point
def closest_point(self, pointsList):if not pointsList or not all(isinstance(p, Point) for p in pointsList):raise ValueError("pointsList must be non-empty and contain Point instances")return min(pointsList, key=lambda p: self.dist_to_point(p))
- 功能:从一个点列表中,找到距离当前点最近的点。
- 细节:
- 输入校验:确保列表非空且所有元素都是
Point
实例,避免无效输入; - 核心逻辑:用
min
函数结合 lambda 表达式,以 “当前点到列表中点的距离” 为排序依据,返回距离最小的点。
- 输入校验:确保列表非空且所有元素都是
- 用途:后续 “最近邻算法” 中,每次选择 “下一个最近点” 会直接用到这个方法。
(5)可哈希支持:__hash__
def __hash__(self):return hash((self.x, self.y))
- 作用:使
Point
实例支持 “哈希操作”,从而可以用in
判断 “点是否在列表中”(如startPoint in pointsL
),这是算法中 “筛选未访问点” 的基础。
注:转四、为什么 Point 类需要 hash 方法?—— 从 “哈希操作” 到 TSP 算法需求
二、Route 类:定义 “路径” 的属性与行为
Route
类代表 TSP 的一条 “路径”(即访问城市的顺序),核心是管理路径列表、计算总距离、可视化路径等。
1. 核心属性
pointsList
:路径中的点列表(元素均为Point
实例),如[A, B, C, A]
(闭合路径,起点 = 终点);run_time
:算法运行时间(由装饰器注入,用于后续统计和可视化);algo_name
:生成路径的算法名称(如 “nearestNeighbor”,同样由装饰器注入,用于区分不同算法的结果)。
2. 关键方法解析
(1)构造与初始化:__init__
def __init__(self, pointsList):if not all(isinstance(p, Point) for p in pointsList):raise TypeError("All elements in pointsList must be Point instances")self.pointsList = pointsList.copy() # 深拷贝避免外部列表修改影响内部self.run_time = 0.0self.algo_name = "Unknown"
- 输入校验:确保路径列表中所有元素都是
Point
实例,避免混入其他类型; - 深拷贝:用
copy()
复制输入列表,防止外部修改原列表时影响Route
内部的路径数据。
(2)路径等价判断:__eq__
(重点!)
TSP 中,“路径等价” 的定义很特殊:忽略起点和方向(如 A→B→C→A
与 B→C→A→B
等价,与 A→C→B→A
不等价)。这个方法是判断 “算法结果是否符合预期” 的核心。
def __eq__(self, other):if not isinstance(other, Route):return False# 第一步:排除长度和总距离不同的路径if len(self.pointsList) != len(other.pointsList):return Falseif round(self.total_distance(), 3) != round(other.total_distance(), 3):return False# 第二步:提取有效节点(去掉闭合的终点,如[A,B,C,A]→[A,B,C])self_nodes = self.pointsList[:-1] if self.pointsList[0] == self.pointsList[-1] else self.pointsListother_nodes = other.pointsList[:-1] if other.pointsList[0] == other.pointsList[-1] else other.pointsList# 第三步:验证节点完全一致(无遗漏、无多余)all_in = all(node in other_nodes for node in self_nodes)len_equal = len(self_nodes) == len(other_nodes)if not (all_in and len_equal):return False# 第四步:验证循环等价(忽略起点)或反向等价(忽略方向)combined = self_nodes * 2 # 拼接成循环列表,如[A,B,C]→[A,B,C,A,B,C]other_len = len(other_nodes)has_forward_match = any(combined[i:i + other_len] == other_nodes for i in range(len(self_nodes))) # 正向匹配has_reverse_match = any(combined[i:i + other_len] == other_nodes[::-1] for i in range(len(self_nodes))) # 反向匹配return has_forward_match or has_reverse_match
- 逻辑拆解:
- 先排除 “长度不同” 或 “总距离不同” 的路径(快速过滤明显不等价的情况);
- 去掉闭合路径的终点(避免
A→B→C→A
和A→B→C
因长度差异被误判); - 确保两个路径包含的节点完全一致(避免 “少城市” 或 “多城市” 的情况);
- 通过 “循环列表匹配” 判断是否忽略起点(如
A→B→C
和B→C→A
),通过 “反向列表匹配” 判断是否忽略方向(如A→B→C
和C→B→A
)。
(3)字符串表示:__repr__
def __repr__(self):return f"Route(Points: {len(self.pointsList)-1}个城市 | 总距离: {self.total_distance():.3f} | 算法: {self.algo_name})"
打印 Route
实例时,会显示 “城市数量、总距离、算法名”,如 Route(Points: 5个城市 | 总距离: 14.898 | 算法: nearestInsertion)
,方便快速查看路径核心信息。
(4)路径可视化:display_route
将路径用 matplotlib 绘制成图表并保存为 PDF,是直观验证路径正确性的重要手段。
def display_route(self, file_name):if not self.pointsList:raise ValueError("Route is empty, cannot display")# 1. 提取坐标并确保路径闭合(若未闭合,补充起点到终点的连线)x = [p.x for p in self.pointsList]y = [p.y for p in self.pointsList]if self.pointsList[0] != self.pointsList[-1]:x.append(self.pointsList[0].x)y.append(self.pointsList[0].y)# 2. 绘制图表plt.figure(figsize=(10, 6))plt.plot(x, y, marker='o', linestyle='-', color='#1f77b4', markersize=6, linewidth=1.5) # 蓝色实线+圆点标记plt.scatter(x[0], y[0], color='red', s=100, zorder=5, label="Start/End") # 起点用红色大圆点标记# 3. 设置标题(含算法名、总距离、运行时间)title = f"""TSP Route - {self.algo_name}
Total Distance: {self.total_distance():.3f} | Run Time: {self.run_time:.6f}s"""plt.title(title, fontsize=12, pad=20)plt.xlabel("X Coordinate", fontsize=10)plt.ylabel("Y Coordinate", fontsize=10)plt.legend(fontsize=9)plt.grid(alpha=0.3) # 浅色网格,方便看坐标# 4. 保存PDF到指定目录output_dir = "TSP_Results"if not os.path.exists(output_dir):os.makedirs(output_dir) # 目录不存在则创建save_path = f"{output_dir}/{file_name}_{self.algo_name}.pdf"plt.savefig(save_path, dpi=300, bbox_inches='tight', format='pdf')plt.close()print(f"📊 路线图已保存:{save_path}")
- 核心细节:
- 路径闭合:若输入路径未闭合(如
[A,B,C]
),自动补充起点到终点的连线,符合 TSP “回到起点” 的要求; - 视觉区分:路径用蓝色实线,起点用红色大圆点(zorder=5 确保在最上层,不被其他点遮挡);
- 结果保存:自动创建
TSP_Results
目录,文件名包含 “自定义名称 + 算法名”,避免不同结果覆盖。
- 路径闭合:若输入路径未闭合(如
(5)总距离计算:total_distance
计算路径的总长度(闭合路径,起点到终点的总距离)。
def total_distance(self):if len(self.pointsList) < 2:return 0.0total = 0.0n = len(self.pointsList)# 第一步:计算路径中相邻点的距离之和(如A→B、B→C、C→A)for i in range(n - 1):total += self.pointsList[i].dist_to_point(self.pointsList[i + 1])# 第二步:若路径未闭合,补充终点到起点的距离if self.pointsList[0] != self.pointsList[-1]:total += self.pointsList[-1].dist_to_point(self.pointsList[0])return total
- 逻辑:先累加相邻点的距离,再判断是否需要补充 “终点到起点” 的距离,确保返回的是 TSP 要求的 “闭合路径总长度”。
三、Route.py 的核心作用
Route.py 不是孤立的文件,而是整个 TSP 项目的 “数据基础”:
- 为
heuristics.py
提供算法所需的 “点” 和 “路径” 对象(算法输入是Point
列表,输出是Route
对象); - 为
Lab_test.py
提供测试验证的核心方法(如__eq__
判断路径是否正确,total_distance
对比预期距离); - 为可视化提供支持(
display_route
生成直观的路径图,方便调试和结果展示)。
若忘记某个方法的逻辑,可重点查看对应方法的注释和输入输出,尤其是 Point.closest_point
和 Route.__eq__
这两个对算法和测试至关重要的方法。
四、为什么 Point 类需要 hash 方法?—— 从 “哈希操作” 到 TSP 算法需求
在理解 Point
类为何需要 __hash__
方法前,我们需要先理清 Python 中 “哈希” 的核心作用,再结合 TSP 算法的实际需求,就能明白这个方法的必要性。
1、先搞懂:Python 中的 “哈希” 到底是啥?
“哈希”(Hash)本质是一种快速查找技术——通过一个“哈希函数”(比如 __hash__
方法),把对象(比如 Point
实例)转换成一个固定的整数(称为 “哈希值”)。这个哈希值会被 Python 用来:
- 快速定位对象:比如在列表、集合中判断 “某个对象是否存在” 时,Python 会先通过哈希值缩小查找范围,而不是逐个遍历所有元素(遍历效率极低,尤其当元素数量多的时候);
- 支持 “可哈希对象” 的特殊操作:只有 “可哈希对象”(实现了
__hash__
和__eq__
方法的对象)才能被放入set
(集合)、作为dict
(字典)的键 —— 这些数据结构的核心优势就是 “快速查找”,依赖的正是哈希值。
2、关键前提:__hash__
和 __eq__
是 “黄金搭档”
Python 规定:要让对象支持哈希操作,必须同时实现 __hash__
和 __eq__
方法,两者的逻辑必须一致 ——
- 如果两个对象通过
__eq__
判断为 “相等”(比如Point(0,0)
和Point(0,0)
),它们的__hash__
方法必须返回相同的哈希值; - 如果两个对象的
__hash__
返回相同的哈希值(称为 “哈希碰撞”),Python 会再通过__eq__
确认它们是否真的相等(避免误判)。
在 Point
类中:
__eq__
方法判断 “两个点的 x、y 坐标完全相同” 即为相等;__hash__
方法用hash((self.x, self.y))
生成哈希值 —— 因为元组(x,y)
是可哈希的,且只要x
、y
相同,元组的哈希值就相同,完美匹配__eq__
的逻辑。
3、回到 TSP 算法:为什么需要 __hash__
?
这里有个关键问题:怎么判断 “两个点是不是同一个”?
对我们来说,只要两个点的 x、y 坐标一样(比如 A 是 (0,0),另一个点也是 (0,0)),那就是同一个点。但 Python 不知道啊 —— 如果没做特殊处理,Python 会把 “哪怕坐标一样的两个 Point 实例” 当成 “两个不同的东西”(因为它们在电脑内存里存的位置不一样)。
__hash__
方法的核心作用,是让 Point
实例支持 in
关键字判断(比如 startPoint in pointsL
),而这个判断是 TSP 算法中 “筛选未访问点” 的基础 —— 我们以 “最近邻算法” 为例,看具体需求:
①最近邻算法的核心步骤:筛选“未访问点”
最近邻算法的逻辑是:从起点出发,每次选择 “当前点的最近未访问点”,直到所有点都被访问。为了实现这个逻辑,我们需要:
- 维护一个 “未访问点列表”(比如
unvisited = pointsL.copy()
); - 每次选中一个点后,从 “未访问点列表” 中移除它(标记为已访问);
- 关键前提:判断 “某个点是否在未访问列表中”(比如
startPoint 是否在 pointsL 中
、选中的点是否在 unvisited 中
)。
②没有 __hash__
,in
判断会出问题吗?
如果 Point
类没有实现 __hash__
方法:
- Python 会使用默认的哈希逻辑(基于对象的内存地址生成哈希值)—— 即使两个点的
x
、y
坐标完全相同(比如p1 = Point(0,0)
和p2 = Point(0,0)
),它们的内存地址不同,哈希值也不同; - 此时用
p1 in [p2]
判断会返回False
(因为 Python 先比较哈希值,发现不同就直接判定 “不存在”),这完全违背了我们对 “相同点” 的定义 —— 比如算法中可能误把 “已访问的点” 当作 “未访问”,导致逻辑错误。
③有了 __hash__
,in
判断才正确
当 Point
类实现了 __hash__
后:
- 只要两个点的
x
、y
相同,它们的哈希值就相同,in
判断会正确返回True
; - 比如
startPoint = Point(0,0)
,pointsL = [Point(0,0), Point(1,1)]
,startPoint in pointsL
会正确返回True
,确保我们能从列表中找到起点; - 再比如 “未访问列表”
unvisited = [Point(1,1), Point(2,2)]
,当我们选中Point(1,1)
后,用unvisited.remove(Point(1,1))
能正确移除它,不会因为 “内存地址不同” 而报错 “点不在列表中”。
4、总结:__hash__
的作用链
__hash__
方法看似简单,实则是 TSP 算法正常运行的 “隐形基石”,作用链可概括为:实现 __hash__
→ Point 实例支持正确的哈希操作
→ 支持用 in 判断“点是否在列表中”
→ 正确维护“未访问点列表”
→ TSP 算法(如最近邻)能正常筛选未访问点
。
简单来说:没有 __hash__
,算法就无法正确识别 “相同的点”,更无法判断 “某个点是否已访问”,最终导致逻辑错误 —— 这就是 Point
类必须实现 __hash__
方法的根本原因。
hash 方法的本质,就是给每个 Point 实例发一个 “专属身份证号”,但这个身份证号有个规矩:只要两个点的 x、y 坐标一样,身份证号就必须一样;坐标不一样,身份证号尽量不一样。
一句话说清
hash 方法就是给每个 Point 实例按 “x、y 坐标” 发个 “专属身份证号”,帮 Python 快速认清楚 “哪个点是同一个”,这样 TSP 算法才能正确判断 “点有没有访问过”,不会重复走或漏掉点。
五、Route.py完整代码
import math
import os
import matplotlib.pyplot as pltclass Point:def __init__(self, x, y):"""初始化点坐标(案例任务2要求)"""self.x = xself.y = ydef __eq__(self, other):"""判断两点相等:x、y完全一致(案例任务2要求)"""if not isinstance(other, Point):return Falsereturn self.x == other.x and self.y == other.ydef __repr__(self):"""点的字符串表示(如Point(0,0),案例任务2要求)"""return f"Point({self.x}, {self.y})"def dist_to_point(self, other):"""计算欧几里得距离(案例任务2要求)"""if not isinstance(other, Point):raise TypeError("other must be a Point instance")dx = self.x - other.xdy = self.y - other.yreturn math.sqrt(dx ** 2 + dy ** 2)def closest_point(self, pointsList):"""找到列表中最近的点(案例任务2要求)"""if not pointsList or not all(isinstance(p, Point) for p in pointsList):raise ValueError("pointsList must be non-empty and contain Point instances")return min(pointsList, key=lambda p: self.dist_to_point(p))def __hash__(self):"""使Point实例可哈希(支持列表包含判断)"""return hash((self.x, self.y))class Route:def __init__(self, pointsList):"""初始化路径(案例任务2要求)"""if not all(isinstance(p, Point) for p in pointsList):raise TypeError("All elements in pointsList must be Point instances")self.pointsList = pointsList.copy()self.run_time = 0.0 # 装饰器注入的运行时间self.algo_name = "Unknown" # 装饰器注入的算法名def __eq__(self, other):"""路径等价性:忽略起点和方向(案例任务2要求,如A→B→C与B→C→A相等)"""if not isinstance(other, Route):return False# 排除长度和距离不同的情况if len(self.pointsList) != len(other.pointsList):return Falseif round(self.total_distance(), 3) != round(other.total_distance(), 3):return False# 提取有效节点(排除闭合的终点)self_nodes = self.pointsList[:-1] if self.pointsList[0] == self.pointsList[-1] else self.pointsListother_nodes = other.pointsList[:-1] if other.pointsList[0] == other.pointsList[-1] else other.pointsList# 验证节点完全一致all_in = all(node in other_nodes for node in self_nodes)len_equal = len(self_nodes) == len(other_nodes)if not (all_in and len_equal):return False# 验证循环等价(忽略起点)和反向等价(忽略方向)combined = self_nodes * 2other_len = len(other_nodes)has_forward_match = any(combined[i:i + other_len] == other_nodes for i in range(len(self_nodes)))has_reverse_match = any(combined[i:i + other_len] == other_nodes[::-1] for i in range(len(self_nodes)))return has_forward_match or has_reverse_matchdef __repr__(self):"""路径的字符串表示(案例任务2要求)"""return f"Route(Points: {len(self.pointsList)-1}个城市 | 总距离: {self.total_distance():.3f} | 算法: {self.algo_name})"def display_route(self, file_name):"""绘制路线图并保存为PDF(案例任务2要求,含算法名和运行时间)"""if not self.pointsList:raise ValueError("Route is empty, cannot display")# 提取坐标并闭合路径x = [p.x for p in self.pointsList]y = [p.y for p in self.pointsList]if self.pointsList[0] != self.pointsList[-1]:x.append(self.pointsList[0].x)y.append(self.pointsList[0].y)# 绘制图表(符合案例图1风格:蓝色实线+红色起点)plt.figure(figsize=(10, 6))plt.plot(x, y, marker='o', linestyle='-', color='#1f77b4', markersize=6, linewidth=1.5)plt.scatter(x[0], y[0], color='red', s=100, zorder=5, label="Start/End")# 标题含算法名、运行时间、总距离(案例任务4要求)title = f"""TSP Route - {self.algo_name}
Total Distance: {self.total_distance():.3f} | Run Time: {self.run_time:.6f}s"""plt.title(title, fontsize=12, pad=20)plt.xlabel("X Coordinate", fontsize=10)plt.ylabel("Y Coordinate", fontsize=10)plt.legend(fontsize=9)plt.grid(alpha=0.3)# 保存PDF(案例要求输出到文件)output_dir = "TSP_Results"if not os.path.exists(output_dir):os.makedirs(output_dir)save_path = f"{output_dir}/{file_name}_{self.algo_name}.pdf"plt.savefig(save_path, dpi=300, bbox_inches='tight', format='pdf')plt.close()print(f"📊 路线图已保存:{save_path}")def total_distance(self):"""计算闭合路径总距离(案例任务2要求,如A→B→C→A)"""if len(self.pointsList) < 2:return 0.0total = 0.0n = len(self.pointsList)for i in range(n - 1):total += self.pointsList[i].dist_to_point(self.pointsList[i + 1])# 补充未闭合路径的起点距离if self.pointsList[0] != self.pointsList[-1]:total += self.pointsList[-1].dist_to_point(self.pointsList[0])return total