三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

五子棋AI核心算法解析:从Alpha-Beta剪枝到评估函数设计

五子棋AI核心算法解析:从Alpha-Beta剪枝到评估函数设计

1. 项目概述:为什么一个五子棋AI能火遍GitHub?

五子棋,这个规则简单到几乎人人都会的棋类游戏,似乎和“前沿AI”扯不上太大关系。毕竟,它棋盘小、规则明,远不如围棋、象棋那般变化万千。但恰恰是这种“简单”,让它成为了检验AI算法思想、学习编程实践的绝佳沙盒。在GitHub上搜索“Gomoku”(五子棋的英文名),你会发现成百上千个相关项目,从简单的规则引擎到复杂的深度学习模型,应有尽有。那么,什么样的五子棋AI项目才能脱颖而出,成为“最受欢迎”的那一个?它绝不仅仅是下棋厉害,更在于其代码的清晰度、架构的启发性、以及作为学习范本的完整性

一个受欢迎的五子棋AI项目,通常具备以下几个特征:首先,它实现了一个强而有效的核心算法,比如经典的Alpha-Beta剪枝配合启发式评估函数,或者更现代的蒙特卡洛树搜索(MCTS),甚至神经网络。其次,它拥有友好的交互界面,可能是命令行,也可能是简单的图形界面(GUI),让用户能直观地对战或观摩。最重要的是,它的代码结构清晰,注释详尽,将复杂的AI决策过程拆解成可理解的模块,如棋盘表示、走法生成、评估函数、搜索算法等,让学习者能一步步跟进,甚至自己动手修改和优化。

我花了大量时间研究GitHub上各类五子棋AI,发现那些star数高的项目,无一例外都是优秀的“教学项目”。它们就像一份活生生的算法教科书,把书本上枯燥的Minimax、Zobrist哈希等概念,变成了屏幕上跳动棋子背后实实在在的代码逻辑。对于初学者,可以从理解棋盘状态如何用二维数组表示开始;对于进阶者,可以深入研究评估函数的设计如何影响AI的“棋风”——是激进进攻还是稳重防守;对于高手,甚至可以尝试集成强化学习,让AI自我对弈进化。

接下来,我将以一个典型的、集大成的五子棋AI项目为蓝本,深度拆解其实现。我们将从最核心的算法思想开始,一步步构建出一个具备中等棋力的AI,并探讨如何让它变得更“聪明”。这个过程,不仅是复现一个项目,更是一次对经典AI搜索与决策思维的沉浸式体验。

2. 核心算法选型与思路拆解

为五子棋AI选择一个核心算法,本质上是在搜索深度、决策质量和计算资源之间寻找平衡。五子棋虽然棋盘只有15x15,但全程的理论可能走法(约10^170)依然是天文数字,穷举所有可能直到终局(即“完全信息博弈”的求解)在普通计算机上是不现实的。因此,我们必须使用启发式搜索。

2.1 算法家族巡礼:从Minimax到深度学习

Minimax(极小化极大算法)是博弈AI的基石。它假设对手总是做出对你最不利的走法(极小化你的收益),而你则选择对自己最有利的走法(极大化你的收益)。算法通过递归模拟双方后续对弈,形成一个博弈树,并在树的末端(达到一定深度或游戏结束)通过一个评估函数给局面打分,最后将这些分数回溯到根节点,选择最优分支。

注意:纯Minimax的搜索深度极其有限。对于一个分支因子(每步可选走法)约为50的五子棋,搜索深度为4层就需要评估约50^4 = 6,250,000个局面,这已经对性能构成压力。

Alpha-Beta剪枝是Minimax的革命性优化。它通过传递两个值——Alpha(当前层玩家至少能保证的分数下界)和Beta(对手至多能忍受的分数上界)——来提前“剪掉”那些不可能影响最终决策的分支。简单来说,如果发现某一步棋对于对手来说太糟(对手有更好的选择可以避免这个局面),或者对于自己来说不够好(自己有已知的更好选择),就停止对这个分支的深入搜索。这能在不改变搜索结果的前提下,极大减少需要评估的节点数,通常能让有效搜索深度增加2-4层。

蒙特卡洛树搜索(MCTS)提供了另一种思路。它不依赖于复杂的评估函数,而是通过“模拟对弈”来评估走法。其核心步骤是:选择(从根节点开始,用UCB等公式选择子节点)、扩展(为选中的节点添加一个或多个未探索的子节点)、模拟(从扩展节点开始,用快速随机策略下完一盘棋)、回溯(根据模拟结果更新路径上所有节点的统计信息,如胜利次数/访问次数)。经过多次迭代,访问次数最多的根节点子节点就被认为是当前最佳走法。MCTS在围棋AlphaGo中一战成名,其优势在于无需领域知识(评估函数),但劣势是前期决策可能很随机,且需要大量模拟才能收敛。

神经网络(NN)与强化学习(RL)是当今的潮流。我们可以训练一个神经网络来充当评估函数(价值网络),或者直接预测最佳走法(策略网络)。通过让AI自我对弈(强化学习),网络能学习到人类难以形式化的复杂棋感。然而,这需要大量的数据和计算资源,且模型可解释性较差。

2.2 我们的选择:Alpha-Beta剪枝 + 启发式评估

对于我们的项目,目标是在有限复杂度内实现一个棋力可观、代码清晰、易于理解和扩展的AI。因此,Alpha-Beta剪枝配合精心设计的启发式评估函数是最佳选择。它平衡了性能与效果,其算法流程透明,非常适合教学和作为进一步优化的基础。

我们的核心思路框架如下:

  1. 棋盘表示:用二维数组(如15x15)表示棋盘,0为空,1为黑棋,2为白棋。
  2. 走法生成:不是搜索所有空位,而是只搜索“有意义的空位”,即周围(如3格以内)已有棋子的位置。这能大幅减少分支因子。
  3. 评估函数:设计一个函数,对任何一个未结束的棋盘局面给出一个分数。分数越高,对当前行棋方越有利。
  4. Alpha-Beta搜索:以当前棋盘为根节点,递归地模拟双方后续走法,利用评估函数给叶子节点打分,并通过Alpha-Beta剪枝高效地找到最优走法。
  5. 迭代加深:由于时间限制,我们可能无法完成固定深度的搜索。迭代加深策略是先搜索1层深度,然后2层,3层……直到时间用完。这样我们总能得到一个在允许时间内最深度的搜索结果,并可以随时返回当前最佳走法。

这个组合拳是经典而强大的。接下来,我们将深入每个环节,看看代码具体如何实现,以及有哪些提升棋力的“黑科技”。

3. 棋盘表示与走法生成:效率的基石

AI的思考建立在数据之上。如何高效地表示棋盘状态,并智能地生成候选走法,是影响整个系统性能的第一个关键。

3.1 数据结构的选择:二维数组与位棋盘

最直观的方式是使用二维数组board[15][15]。访问和修改任何位置的状态都是O(1)的时间复杂度,非常快。我们可以用简单的整数表示状态:0空,1黑子,2白子。

class Board: def __init__(self, size=15): self.size = size self.board = [[0 for _ in range(size)] for _ in range(size)] self.current_player = 1 # 1 for black, 2 for white

然而,对于评估函数(需要频繁判断棋型),二维数组的遍历效率可能成为瓶颈。一种更高级的优化是使用位棋盘(Bitboard)。为每个玩家使用一个15x15的二进制位图(可以用一个225位的整数或几个长整数表示),某位为1表示该位置有该玩家的棋子。位运算的极致速度可以极大加速棋型判断(如通过移位和与操作来检测连五)。但位棋盘代码更复杂,可读性降低。作为教学项目,我们优先选择清晰易懂的二维数组,在需要极致优化时再考虑位棋盘。

3.2 启发式走法生成:从“全盘扫描”到“局部聚焦”

一个最朴素的走法生成器会返回棋盘上所有空位。在15x15的棋盘上,开局就有225个选择,这会导致搜索树爆炸。

核心优化思想:五子棋是局部性很强的游戏。一颗棋子只能影响其周围的位置。因此,我们只考虑那些“活跃”的空位,即距离任何已有棋子一定范围内的位置。这个范围通常被称为“邻居范围”或“影响范围”。

def get_legal_moves(self, board, last_move=None, neighbor_radius=2): """ 获取合法的走法(候选空位)。 :param board: 当前棋盘 :param last_move: 上一步棋的位置 (row, col),用于优化 :param neighbor_radius: 邻居半径,考虑周围几格内有棋子的空位 :return: 列表,元素为 (row, col) 元组 """ moves = set() size = len(board) # 如果棋盘为空(开局),直接返回天元点附近 if last_move is None: center = size // 2 return [(center, center)] # 或返回天元周围几个点 # 否则,扫描整个棋盘,但只添加“活跃”空位 for r in range(size): for c in range(size): if board[r][c] != 0: # 已有棋子,跳过 continue # 检查该空位周围neighbor_radius范围内是否有棋子 if self.has_neighbor(board, r, c, radius=neighbor_radius): moves.add((r, c)) # 如果活跃空位集合为空(理论上不应发生),则退回返回所有空位 if not moves: # 保底策略,返回所有空位(性能会下降) for r in range(size): for c in range(size): if board[r][c] == 0: moves.add((r, c)) return list(moves) def has_neighbor(self, board, row, col, radius=2): """检查指定位置半径radius范围内是否有棋子""" size = len(board) min_r = max(0, row - radius) max_r = min(size - 1, row + radius) min_c = max(0, col - radius) max_c = min(size - 1, col + radius) for r in range(min_r, max_r + 1): for c in range(min_c, max_c + 1): if board[r][c] != 0: return True return False

实操心得neighbor_radius参数是一个重要的调优点。设为1可能过于激进,会错过一些关键的“跳冲”点;设为3则可能包含太多无用空位,拖慢搜索。通常设置为2是一个很好的平衡。此外,在开局前几步,可以特殊处理,直接返回几个固定的开局点(如天元及其周围),以节省计算时间并引导AI走向常见开局。

4. 评估函数设计:AI的“棋感”灵魂

评估函数是AI的“眼睛”,它需要量化一个局面对当前行棋方的优劣。一个糟糕的评估函数会让搜索算法在错误的道路上狂奔。设计评估函数是五子棋AI中最具艺术性和技术性的部分。

4.1 棋型识别:从基础到组合

评估的基础是识别棋盘上的各种“棋型”。对于一行、一列或一条斜线上的连续五个点,我们截取所有可能的五元组进行分析。常见的棋型及其对应分数(假设当前行棋方为黑棋)如下:

棋型模式 (示例,B=黑,W=白,_=空)描述对黑棋的威胁/价值典型分数
BBBBB连五胜利+100000 (极大值)
_BBBB_活四下一手必胜+10000
_BBB_B__BB_BB_冲四(单侧有棋)对方必须防守,否则下一手成活四胜+1000
_BBB__活三有潜力形成活四+500
_BB_B_跳活三有潜力形成活四+500
_BB__B_(中间空两格)眠三只有一端被封,形成冲四威胁+200
_BB___(一端被封)死三两端或一端被封,无法形成冲四+0
__BB__活二有潜力+50
_B_B_跳活二有潜力+50
WWWWW对手连五对手胜利-100000
_WWWW_对手活四你必须防守-10000
............

关键点:评估函数需要同时计算当前行棋方对手的棋型。最终的局面对分是我方总分 - 对手总分 * 一个系数(通常略大于1,如1.1)。这个系数体现了“进攻优先”或“重视防守”的策略倾向。系数大于1意味着AI认为对手的威胁比我方的机会更紧迫,棋风会更偏向防守。

4.2 实现细节:效率与准确性

遍历整个棋盘,对四个方向(水平、垂直、两条对角线)上的所有五元组进行模式匹配是直接但低效的。更高效的做法是增量更新

增量更新策略:棋盘每次落子,只影响该子周围一定范围内(例如,左右各4格)的棋型。我们可以维护一个“评分缓存”,记录当前棋盘的总分。当落下一子时,只重新计算受影响的那些行、列、对角线上的棋型分数,更新缓存。当回溯(悔棋)时,再减去这个子的影响。这比每次评估都全盘扫描快几个数量级。

class Evaluator: def __init__(self): # 预定义棋型模式与分数的映射表 self.pattern_score = { "BBBBB": 100000, "_BBBB_": 10000, # ... 其他模式 } # 缓存棋盘哈希值到分数的映射 self.cache = {} def evaluate(self, board, player): """评估棋盘对player的有利程度""" board_hash = self.hash_board(board) if board_hash in self.cache: return self.cache[board_hash] score = 0 size = len(board) # 遍历所有可能的五元组(简化示例,实际需优化) for r in range(size): for c in range(size): # 检查水平、垂直、两条对角线四个方向 # 提取五元组,转换为模式字符串(如"BB_W_") # 查表累加分数 pass # 计算对手分数 opponent = 3 - player # 如果player是1,对手是2;反之亦然 opp_score = self.evaluate_for_player(board, opponent) my_score = self.evaluate_for_player(board, player) final_score = my_score - opp_score * 1.1 # 防守倾向系数 self.cache[board_hash] = final_score return final_score def evaluate_for_player(self, board, player): """专门计算某一方的原始分数""" # ... 具体实现 ... pass def hash_board(self, board): """生成棋盘的唯一哈希,用于缓存。可以使用Zobrist Hashing技术。""" # 简化版:将棋盘转为字符串 return ''.join(str(cell) for row in board for cell in row)

注意事项:模式匹配时要注意边界处理。棋盘边缘的五元组可能不足五个点,需要特殊处理或忽略。另外,评估函数是AI棋力的天花板。你可以通过添加更复杂的棋型(如“双活三”、“四三前驱”)、考虑棋子的位置权重(中心比边角价值高)、甚至引入一些“棋理”规则(如“梅花阵”的潜在优势)来不断提升AI的强度。这是一个可以无限深挖的“调参”黑洞。

5. Alpha-Beta搜索核心实现

有了棋盘、走法和评估,我们现在可以组装AI的大脑——搜索算法。我们将实现带迭代加深和启发式排序的Alpha-Beta剪枝。

5.1 算法骨架与递归

def alpha_beta_search(board, depth, alpha, beta, maximizing_player, evaluator, move_generator): """ Alpha-Beta剪枝搜索核心函数。 :param board: 当前棋盘状态 :param depth: 剩余搜索深度 :param alpha: 当前层玩家至少能保证的最佳分数(下界) :param beta: 对手至多能忍受的分数(上界) :param maximizing_player: True表示当前是最大化玩家(通常为AI自己)的回合 :param evaluator: 评估函数对象 :param move_generator: 走法生成器对象 :return: (最佳分数, 最佳走法) """ # 终止条件:达到深度限制或游戏结束 if depth == 0 or game_over(board): # 评估当前局面。注意:评估总是从“当前行棋方”视角 # 我们需要知道当前该谁下,以确定评估视角。 # 通常我们会传递一个`current_player`参数。 score = evaluator.evaluate(board, current_player) return score, None if maximizing_player: max_eval = float('-inf') best_move = None # 生成当前所有可能走法 legal_moves = move_generator.get_legal_moves(board) # 关键优化:对走法进行启发式排序,先搜索看起来最好的走法,能提高剪枝效率 ordered_moves = order_moves(legal_moves, board, is_maximizing=True) for move in ordered_moves: # 模拟落子 make_move(board, move, current_player) # 递归搜索,轮到对手(最小化玩家) eval_score, _ = alpha_beta_search(board, depth-1, alpha, beta, False, evaluator, move_generator) # 撤销落子(回溯) undo_move(board, move) if eval_score > max_eval: max_eval = eval_score best_move = move # 更新alpha值 alpha = max(alpha, eval_score) # Alpha-Beta剪枝:如果当前分支的alpha值已经大于等于beta,对手不会允许走到这个局面,剪枝 if alpha >= beta: break # Beta剪枝 return max_eval, best_move else: # 最小化玩家的回合(对手),逻辑对称 min_eval = float('inf') best_move_for_min = None legal_moves = move_generator.get_legal_moves(board) ordered_moves = order_moves(legal_moves, board, is_maximizing=False) for move in ordered_moves: make_move(board, move, opponent_player) eval_score, _ = alpha_beta_search(board, depth-1, alpha, beta, True, evaluator, move_generator) undo_move(board, move) if eval_score < min_eval: min_eval = eval_score best_move_for_min = move beta = min(beta, eval_score) if beta <= alpha: break # Alpha剪枝 return min_eval, best_move_for_min

5.2 迭代加深与超时控制

我们不知道固定搜索深度需要多少时间。迭代加深策略允许我们在时间耗尽前,总是得到最深度的搜索结果。

def iterative_deepening_search(board, max_depth, time_limit, evaluator, move_generator): """ 迭代加深搜索。 :param time_limit: 最大思考时间(秒) """ start_time = time.time() best_move_so_far = None current_depth = 1 while current_depth <= max_depth: # 检查是否超时 if time.time() - start_time > time_limit: print(f"时间到,返回深度 {current_depth-1} 的结果。") break print(f"正在搜索深度 {current_depth}...") # 调用alpha_beta_search,初始alpha=-inf, beta=+inf score, best_move = alpha_beta_search( board, current_depth, float('-inf'), float('inf'), True, evaluator, move_generator ) if best_move is not None: best_move_so_far = best_move print(f"深度 {current_depth} 找到最佳走法: {best_move}, 预估分数: {score}") current_depth += 1 # 如果连深度1都没搜完(理论上不会),则返回一个随机合法走法 if best_move_so_far is None: legal_moves = move_generator.get_legal_moves(board) best_move_so_far = random.choice(legal_moves) if legal_moves else None return best_move_so_far

5.3 走法排序优化:大幅提升剪枝效率

Alpha-Beta剪枝的效率极度依赖于走法的搜索顺序。如果总是先搜索最好的走法,那么Beta剪枝会很快发生,砍掉大量无用分支。一个简单的启发式排序规则是:根据该走法落子后,局面的静态评估分数进行排序。对于最大化玩家,优先搜索静态评估高的走法;对于最小化玩家,优先搜索静态评估低的走法(即对对手有利的走法)。

def order_moves(moves, board, is_maximizing): """ 对走法列表进行启发式排序。 """ move_score_pairs = [] for move in moves: # 快速模拟落子并评估 make_move(board, move, current_player) quick_score = quick_evaluate(board, current_player) # 一个更轻量级的评估函数 undo_move(board, move) move_score_pairs.append((move, quick_score)) # 根据是最大化还是最小化玩家排序 if is_maximizing: move_score_pairs.sort(key=lambda x: x[1], reverse=True) # 分数高的在前 else: move_score_pairs.sort(key=lambda x: x[1]) # 分数低的在前 return [move for move, score in move_score_pairs]

实操心得quick_evaluate函数可以非常粗糙,比如只计算落子点周围小范围内的棋型变化,甚至只计算该子直接形成的棋型(成五、活四、冲四等)。它的目的不是精确,而是快速给出一个相对优劣的排序。这个优化通常能将搜索效率提升数倍甚至数十倍。

6. 性能优化与高级技巧

一个基础的Alpha-Beta五子棋AI已经可以具备不错的棋力。但要让它更强、更快,还需要以下“黑科技”。

6.1 置换表(Transposition Table)

不同的走法顺序可能导致相同的棋盘局面(称为“置换局面”)。置换表是一个缓存,存储已经搜索过的局面的搜索结果(分数、最佳走法、搜索深度等)。当再次遇到相同局面时,如果缓存中的搜索深度足够,就可以直接使用缓存结果,避免重复搜索。

实现关键

  1. 哈希键:使用Zobrist Hashing为棋盘生成几乎唯一的64位哈希键。它通过为每个(位置,棋子类型)预生成一个随机数,然后将棋盘上所有棋子的对应随机数进行异或得到。添加或移除一个棋子时,只需用该位置的随机数异或当前哈希值即可更新,效率极高。
  2. 表项内容:通常包含哈希键、搜索深度、分数类型(精确值、下界、上界)、分数值、最佳走法。
  3. 替换策略:当哈希冲突或表满时,常用“深度优先”策略,保留搜索深度更深的表项。

加入置换表后,搜索函数在开始时先查表,如果命中且深度满足要求,则根据分数类型进行相应处理(直接返回值或进行剪枝)。

6.2 开局库与残局库

  • 开局库:存储经过人类高手或自我对弈验证的优质开局走法序列。AI在开局阶段直接使用库中的走法,避免在开局广阔的局面中进行低效搜索。这不仅能节省时间,还能引导AI走向经过考验的有利局面。
  • 残局库:对于剩余棋子很少的确定性格局(例如,必胜或必败局面),可以预先计算并存储。当搜索遇到这些局面时,直接查表返回结果,无需继续搜索。对于五子棋,可以构建一个包含所有10子以内局面的残局库,虽然构建耗时,但能保证在这些局面下的绝对正确性。

6.3 并行化搜索

Alpha-Beta搜索本质上是顺序的,因为剪枝依赖于前序走法的搜索结果。但我们可以采用一些并行策略:

  • 根节点并行:在根节点,对所有候选走法启动并行的搜索任务。由于根节点没有可剪枝的兄弟节点信息,这种并行是安全的。最后比较各任务返回的结果,选择最优。
  • Principal Variation Splitting (PVS):一种更复杂的并行算法,它先串行搜索主要变例(Principal Variation,即当前认为的最佳走法序列),然后并行搜索其他变例,并利用主要变例的分数来对其他变例进行剪枝。

对于Python项目,可以使用concurrent.futuresmultiprocessing模块实现根节点并行,这对拥有多核CPU的机器是有效的性能提升手段。

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

在开发和调试五子棋AI的过程中,你一定会遇到各种奇怪的问题。以下是我踩过的一些坑和解决方法。

7.1 AI表现愚蠢,走“瞎棋”

  • 检查评估函数:这是最常见的问题。打印出AI认为的“最佳走法”及其评估分数,然后人工审视该局面。评估函数是否高估了某些无关紧要的“活二”,而低估了对手致命的“冲四”?尝试调整棋型分数和防守系数。
  • 检查搜索深度:深度是否太浅?AI可能因为看不到后续的威胁而走出昏招。尝试增加搜索深度,观察棋力变化。同时注意,深度增加会指数级增加时间,确保你的走法生成和评估足够高效。
  • 检查走法生成:你的走法生成器是否漏掉了一些关键的空位?例如,一个距离所有棋子3格以外的空位,在特定局面下可能是做杀的关键“跳点”。可以临时将neighbor_radius调大进行测试。

7.2 搜索速度太慢,思考时间过长

  • 性能分析:使用Python的cProfile模块找出性能瓶颈。通常是评估函数或走法生成函数被调用了数百万次。
  • 优化评估函数:实现增量评估和缓存(置换表)。确保你的棋型识别代码没有低效的循环。
  • 优化走法生成:确保使用了启发式生成(只搜索活跃空位)。在排序函数order_moves中,确保quick_evaluate极其轻量。
  • 降低迭代加深的起始和最大深度:也许你的硬件只能稳定搜索到6层,强行搜8层会导致超时,反而在深度5时就返回了结果。设置合理的max_depth

7.3 出现“无效走法”或棋盘状态错误

  • 落子/悔棋(Undo)的对称性:确保make_moveundo_move严格配对,并且正确恢复了棋盘状态和当前玩家。这是回溯搜索正确性的基础。
  • 棋盘边界检查:在所有访问board[row][col]的地方,确保rowcol在有效范围内。特别是在检查棋型或邻居时。
  • 深度复制与引用:在递归搜索中,你是传递了棋盘的引用,还是创建了副本?如果传递引用,必须在每次递归调用后撤销走法。如果创建副本(如new_board = copy.deepcopy(board)),则无需撤销,但内存和时间的开销巨大。通常采用传递引用+撤销的方式。

7.4 置换表导致搜索结果不稳定

  • 哈希冲突:Zobrist Hashing虽然冲突概率极低,但并非为零。如果使用较小的哈希表,冲突可能发生。可以增加哈希表大小,或使用128位哈希。
  • 分数类型处理错误:置换表中存储的分数可能是精确值、下界(lower bound)或上界(upper bound)。在查表使用时,必须根据Alpha-Beta的当前窗口([alpha, beta])和分数类型来决定是直接返回值、进行剪枝还是忽略缓存。处理逻辑错误会导致搜索错误。
  • 局面信息不完整:Zobrist哈希通常只编码棋盘棋子分布,但有时还需要编码“当前行棋方”和“禁手规则”等信息到哈希键中,否则会导致不同玩家视角下的相同棋盘被误认为同一局面。

调试AI的一个有效方法是让AI自我对弈,并记录棋谱。观察它在特定局面下的决策,思考为什么它会走出某一步。同时,可以编写一些单元测试,例如测试评估函数对已知必胜局面的打分是否为极大值,测试走法生成在空棋盘上是否返回中心点等。

← 返回列表