☰
结合H3六边形网格与Min-Max搜索的地理空间博弈实践
2026/11/3 16:37:34 网站建设 项目流程

最近在 Ubuntu 上折腾地理空间数据处理时,我把 Minmaxh3 这个组合思路重新拿了出来——简单说就是把 H3 六边形网格索引和 min-max 博弈搜索绑在一起玩。以前用到 H3 基本是查坐标、算距离、做聚合展示,很难跟 AI 决策联系起来;但如果你把地球看成一张棋盘,把六边形格子看成棋盘上的位置,那么传统博弈论里的 min-max 搜索就有了全新的用武之地。这篇文章把我踩过的坑、跑通的代码、以及两个实测玩法都记录下来,想给同样在 Ubuntu 下用 H3 的朋友一个可以复现的项目参考。

1. 这个玩法的起点:为什么把 H3 和 min-max 绑在一起

1.1 先搞清楚 H3 能干什么

H3 是 Uber 开源的一套六边形分级网格系统。它的核心思路很简单:把地球表面切分成一个个大小不一的六边形格子,按分辨率从 0 级到 15 级逐级细化。0 级六边形的平均边长超过一千公里,15 级则只有不到一米,基本上你可以根据业务需求选一个合适的“格子大小”来把连续空间离散化。我用得最多的是 9 级到 11 级,城市街区、园区选址这类场景它们很合适。

H3 最大的优势不是“把地球切成格子”,而是六边形本身的几何性质。正方形的相邻关系有两种:边相邻和对角相邻;六边形只有一种边相邻,且任意一个六边形周围恰好有 6 个邻居。这意味着你在做邻域搜索的时候不需要区分“走斜线”和“走直线”,所有方向的代价天然一致。做路径规划、博弈搜索时,这种一致的拓扑结构能省下大量边界判断。

在 Ubuntu 上使用 H3 最直接的方式是安装 h3-py 这个 Python 绑定库。需要留意的是,老版本函数的命名风格和现在不一样,早期版本用的是latLngToCell(),新版统一改成了小写加下划线的latlng_to_cell()。网上很多旧教程跑了报错,多半就是这个问题。

1.2 min-max 到底是在解决什么问题

min-max 搜索是博弈论里最经典的决策方法,适用于“两个玩家轮流行动、一方的收益就是另一方的损失”的零和博弈。它做的事情说起来很朴素:假设对手每次都会选择对你最不利的动作,那么你在当前这一步就要选择一个能让最坏结果尽量好的动作。

举一个大家都很熟悉的例子:井字棋。如果你只看当前一步,可能会优先占领中心格子,觉得这样最有利。但在 min-max 的眼光里,你需要继续往下想:我占了中心之后,对手会下哪里?他下完之后我又该怎么应对?这样一层层推演到游戏结束,反推回来才能知道当下这一步到底值不值。搜索的每一层,轮到自己的时候取“子节点收益的最大值”,轮到对手的时候取“子节点收益的最小值”,这就是 min-max 名字的由来。

这个算法单独看不复杂,但它最大的问题是计算量。井字棋的状态空间很小,可以搜完整棵树;到了围棋、象棋这种规模,必须靠剪枝、启发式评估和深度限制才能落地。我在 Minmaxh3 里同样遇到了这个问题,后面第 3 节会详细说怎么处理。

1.3 两者的结合点:把地理空间变成“棋盘”

把 H3 和 min-max 放在一起,本质上就是两件事:H3 负责把连续地理空间变成离散棋盘,min-max 负责在这张棋盘上做博弈前瞻。

传统的地理空间决策,比如选一个配送站点、规划一条巡逻路线,大多数时候用的是贪心算法、A* 搜索或动态规划。这些方法都有一个隐含假设:环境是静态的,你只管找到最优解,不会有另一个人跳出来阻挠你。但真实世界不是这样的。两个外卖平台竞争同一个商圈时,你选一个优质格子开站,对方会跟进;巡逻无人机追捕入侵目标时,目标会观察你的轨迹并反向逃跑。一旦对手的存在不可忽略,单方面的最优就失去了意义,你需要的是博弈视角下的最优。

Minmaxh3 想解决的问题就是这一类:在六边形网格地图上,两个决策者轮流行动,我需要提前几步预测对手的反应,找出让自己收益最大化、损失最小化的那一步。下面几节我会逐步展开这个思路的具体落地过程,包括 Ubuntu 环境搭建、核心代码和实际测试结果。

2. Ubuntu 下的准备:构建 H3 网格地图

2.1 环境安装与版本踩坑

我用的系统是 Ubuntu 24.04 LTS,Python 3.12,整个项目只依赖少数的东西:h3-py 做六边形网格操作,numpy 做矩阵和随机数,其他都用标准库。安装命令很简单:

pip install h3 numpy

如果你希望跟我的环境完全一致,也可以加个版本号:

pip install h3==4.2.1 numpy==2.0.0

这里想多说一句:h3-py 的版本 API 差异非常大。4.x 系列把大部分函数都改成了小写下划线风格,比如latlng_to_cell()、grid_disk();而 3.x 老版本还在用geo_to_h3()、k_ring()。如果你照着老博客的代码抄,第一行就会报AttributeError。建议装完库之后立刻跑一次h3.__version__确认版本号,再去对应版本的官方文档查函数名。

2.2 用网格圈定“棋盘”

拿到 H3 网格后,第一个问题是怎么确定棋盘范围。我习惯先用一个中心经纬度,再通过grid_disk()往外扩散出若干个环。这个函数的语义是:以指定格子为中心,把周围第 k 环以及内部的所有格子都拿回来。比如直接选上海人民广场附近的经纬度作为中心:

import h3 center_lat = 31.2304 center_lng = 121.4737 res = 9 center_cell = h3.latlng_to_cell(center_lat, center_lng, res) board_cells = h3.grid_disk(center_cell, k=20) print("中心格子:", center_cell) print("棋盘格子总数:", len(board_cells))

k=20 时,按六边形网格的数量公式1 + 6 * k * (k + 1) / 2算,大概是 1261 个格子。这个数量对后续的搜索来说不算大,作为棋盘尺寸很合适。如果你想要更稀疏的棋盘,把 k 调到 10,大约 331 个格子;想要更考验决策深度的棋盘,k 调到 30,就有 2791 个格子了。

棋盘范围确定之后,我通常还会把中心格子的坐标信息存下来,方便后面画图调试。H3 虽然自带了反算经纬度的函数,但一张散点图永远比一串十六进制 cell ID 直观得多。

2.3 给格子附上“地形值”

有了棋盘,下一步是给每个格子赋值。这个值可以理解成“地形收益”:某个六边形格子可能因为有更多人口、更靠近道路、资源更丰富,因此价值更高。为了模拟这种不均匀分布,我用随机数叠加一个简单的空间热区,让整个棋盘看起来有层次感。

import random import numpy as np random.seed(42) base_values = {} heat_centers = random.sample(board_cells, k=5) for cell in board_cells: v = np.random.rand() for hc in heat_centers: dist = h3.grid_distance(cell, hc) # 距离越远影响越小 v += 0.8 / (1 + dist / 5) base_values[cell] = v

这样每个格子都对应一个浮点数值,值越大表示“这块地越值得占”。在追捕场景里,这个值可以被理解为遮挡物密度或者地形复杂度;在选址场景里,它就直接代表预估营收。后面 min-max 搜索的目标就变成:我方占据的格子的总价值越高越好,同时要阻止对手占据高价值格子。

3. 核心实现:min-max 搜索在六边形棋盘上的落地

3.1 状态表示与动作空间

写博弈搜索的第一步是把“局面”抽象成状态。我的做法很简单:用一个字典保存当前双方的位置和已经占有的格子,再加上一个变量记录轮到谁走。

class GameState: def __init__(self, player_a, player_b, occupied_a, occupied_b, turn): self.player_a = player_a # 当前 A 方所在格子 self.player_b = player_b # 当前 B 方所在格子 self.occupied_a = occupied_a # A 方已占格子集合 self.occupied_b = occupied_b # B 方已占格子集合 self.turn = turn # 'A' 或 'B'

动作空间用 H3 的邻居关系直接生成。如果玩家在某一个格子,那么下一步能走的只有周围最多 6 个方向。用h3.grid_disk(cell, 1)拿到当前格子周围的格子,再排除掉自己、排除掉已经被任意一方占据的格子,剩下的就是合法动作。这一步是 H3 让 min-max 落地最容易的地方:你不需要自己计算那些细微的偏移量,六边形邻居拓扑已经被库算好了。

需要注意一点:如果在一个比较拥挤的棋盘上,可能某个玩家周围 6 个格子全被占了,它的合法动作就是空集。这时候如果还要求必须移动,搜索会直接卡死。我的处理方式是给每个玩家一个“原地不动”的选项,相当于把当前格子也加入动作集合,避免无路可走导致的崩溃。

3.2 评估函数:怎么告诉 AI 哪个局面好

min-max 搜索本身不关心游戏规则,它只需要一个函数告诉它“当前局面是不是有利”。这个函数叫评估函数,也叫启发式评估。因为搜索深度不可能无限大,在到达最大深度之后,我们不能继续搜下去,只能靠评估函数给局面打分。

我设计的评估函数综合考虑了三个因素:

  • 已占格子的资源价值之和,这是最基础的收益。
  • 边界控制收益:如果一个格子与对手的格子相邻,它的战略地位更高,我会额外给它加一点权重。
  • 风险惩罚:如果自己的格子被对手的格子半包围,意味着后续可能被切断,这个局面在评估时要扣分。

写成公式大概是这样:

def evaluate(state, values): score_a = sum(values[c] for c in state.occupied_a) score_b = sum(values[c] for c in state.occupied_b) # 边界控制:A 的格子与 B 的格子相邻数 border_a = 0 border_b = 0 for ca in state.occupied_a: for cb in state.occupied_b: if h3.grid_distance(ca, cb) == 1: border_a += 1 border_b += 1 # 在轮到 A 步骼的角度下,返回 A 的相对收益 return (score_a + 0.3 * border_a) - (score_b + 0.3 * border_b)

这个函数返回的是“相对于 A 而言的局面差距”。如果返回值大于 0,说明 A 占优;小于 0,说明 B 占优。min-max 搜索的目标就是在轮到 A 时让这个值尽量大,轮到 B 时让它尽量小。

3.3 带 Alpha-Beta 剪枝的递归搜索

纯 min-max 搜索在稍微大一点的棋盘上就跑不动了。假设每个节点平均有 5 个合法动作,深度到 6 层,搜索就会有 5 的 6 次方,也就是一万五千多个叶节点。如果评估函数本身还要遍历所有已占格子,那开销会迅速膨胀。

所以我在代码里加入了 alpha-beta 剪枝。核心思路是:搜索过程中维护两个值,alpha 表示“我方目前能得到的最好结果”,beta 表示“对手目前能接受的最坏结果”。一旦发现某个分支已经不可能比现有结果更好了,就立刻剪掉,不再递归下去。

def alpha_beta(state, depth, alpha, beta, maximizing, values, memo): key = state_key(state) if key in memo: return memo[key] if depth == 0: return evaluate(state, values) actions = legal_actions(state) if not actions: return evaluate(state, values) if maximizing: value = -float('inf') for action in actions: new_state = apply_action(state, action) value = max(value, alpha_beta(new_state, depth - 1, alpha, beta, False, values, memo)) alpha = max(alpha, value) if alpha >= beta: break # beta 剪枝 memo[key] = value return value else: value = float('inf') for action in actions: new_state = apply_action(state, action) value = min(value, alpha_beta(new_state, depth - 1, alpha, beta, True, values, memo)) beta = min(beta, value) if alpha >= beta: break # alpha 剪枝 memo[key] = value return value

这里有一个非常重要的小细节:动作顺序会影响剪枝效率。如果你先搜很差的走法,alpha、beta 很难快速收窄,剪不掉多少;如果你先搜好的走法,剪枝率会显著提升。怎么判断哪个走法“好”?我偷懒的做法是在进 alpha-beta 搜索前,先用评估函数给所有动作排个序,把评估值最高的放前面。这个预处理虽然多花了一点时间,但换来的剪枝收益非常可观。

还有一个细节是缓存。同一个状态可能在搜索树的不同分支重复出现,特别是当棋盘上有“原地不动”操作的时候。我用一个字典 memo 把已经算过的状态存下来,key 是player_a + player_b + frozen(sorted(occupied_a)) + frozen(sorted(occupied_b)) + turn。如果状态空间不是爆炸级别,这个缓存能省掉一半以上的重复计算。

4. 实测两种玩法:追捕对抗与选址对抗

4.1 玩法一:六边形棋盘上的追捕游戏

第一个实测场景是追捕游戏。规则是这样的:在一个 k=10 的六边形棋盘上,追捕者和躲避者各占一个格子。追捕者先走,躲避者后走,每次只能移动一步到相邻格子。如果追捕者移动后,跟躲避者的距离小于等于 1,也就是进入了同一格或者咬住了邻居格子,就算抓捕成功。躲避者如果能在 30 个回合内不被抓住,就判躲避者赢。

这个游戏的难点在于躲避者不是傻子。如果追捕者只会直线朝躲避者当前所在位置冲,躲避者只要提前向反方向拉开距离就总能躲开。我把追捕者的决策换成 min-max 搜索,让它在每一步都预判躲避者接下来几回合的最优逃跑路线。搜索深度设成 4,也就是追捕者走一步,躲避者走一步,再追捕者走一步,再躲避者走一步,最后评估局面。

测试结果是很有意思的。我对比了三种策略:

追捕策略抓捕成功率平均抓捕回合数
随机走18%23.4
贪心追最短路径52%16.1
Minmaxh3(深度4)74%11.8

贪心策略的问题在于只盯着躲避者当前的位置,它的每一步看起来都在逼近,但躲避者一旦拐弯,追捕者就要花额外回合修正路线。Minmaxh3 的思路更像是在下棋:它会提前判断躲避者最可能逃向哪一侧,然后封住那一侧的退路,而不是直接扑向目标。第一次看到搜索结果里追捕者绕路走的时候,我还以为是 bug,后来把轨迹画出来才意识到它在执行“包围”而不是“追击”。这种反直觉的决策正是博弈搜索的价值所在。

4.2 玩法二:双店选址的地盘之争

第二个场景是商业选址对抗。棋盘上有不均匀的资源值分布,模拟不同位置的人流量差异。A、B 两家店轮流在空置格子开店,每次只能开一个格子,当所有格子都被占满或者双方都选择停手时,游戏结束,最终累计资源价值更高的一方获胜。

这看起来像一个简单的贪心选题游戏:每次选当前资源值最高的空格子不就好了?但实际跑下来不是这么回事。因为资源值最高的格子旁边很可能紧挨着第二高的格子,你一旦抢先落了子,对手下一步就可以占领旁边的格子,形成“你吃肉、我喝汤”的格局。Minmaxh3 会考虑这一步落子之后,对手会怎么反击,所以它经常选择让对手难受的位置,而不是自己获利最高的位置。

我自己跑的 500 局随机棋盘测试结果如下:

策略胜率平均累计收益
纯贪心41.5%218.7
Minmaxh3(深度3)58.5%246.3

这里有个细节:搜索深度只有 3,也就是“我走一步、对手走一步、我再走一步”。但是因为棋盘上同时存在的已占格子和空格子数量很多,状态空间非常大,深度 3 已经比较耗时。我用第 3 节的 alpha-beta 剪枝之后,单局决策时间从原来的平均 4 秒压到了 0.6 秒左右,在实验进度上可以接受。

4.3 我发现的一个反直觉结论

两个场景跑下来,最让我有收获的其实不是胜率提升,而是 Minmaxh3 经常做出人类直觉上不太理解的选择。

在追捕场景里,追捕者明明可以直线逼近目标,却选择先绕到侧翼。在选址场景里,AI 放弃资源值最高的格子,选择去占一个看起来没什么价值的边界格子。这些决策单看一步都不合理,但如果往下接着推演,会发现它们都让对手陷入了被动。

反直觉的决策恰恰证明了 min-max 搜索的价值:它不优化当前一步,而是优化“对手在当前一步之后的最佳应对”下,我方仍然能收获的结果。地理空间上的博弈,尤其是 H3 网格这种六边形拓扑里,“位置之间如何相互影响”是很微妙的,边界格子的控制权可能比中心格子的资源值重要得多。

5. 踩坑记录与调优心得

5.1 最大坑:别把搜索范围铺满整个地球

我最早做原型的时候,想着 H3 既然能把地球分层,那我干脆在一个大范围里做全局最优搜索。结果就是棋盘格子数量瞬间上升到几十万,合法动作数量跟着爆炸,搜索深度连 2 都走不到就超时。

后来我强制要求动作空间必须限定在某个局部邻域内。比如追捕者只在距离自己 1 步的邻居里选动作,选址者也只考虑当前回合中距离现有已占格子不超过 2 步的空格。这样棋盘尺寸保持在几百个格子的量级,搜索深度才有意义。

如果你想复现,我的建议是:先用grid_disk把棋盘限制在一个中心格子的 k 环内,动作空间再用grid_distance <= 2做二次过滤。这样既保留了六边形格子的局部博弈特征,又不会让你在搜索树上等得心慌。

5.2 性能取舍:深度、剪枝与缓存

Minmaxh3 的性能瓶颈主要来自三点:搜索深度、合法动作数量和评估函数复杂度。我跑了多组对比之后,总结出一些经验值:

  • 搜索深度 2:响应极快,但决策质量跟贪心拉不开太大差距。
  • 搜索深度 4:在追捕场景效果最好,Alpha-Beta 剪枝能把搜索时间控制在 1 秒内。
  • 搜索深度 6 或以上:收益边际递减明显,而且一旦动作数量超过 10,时间开销会迅速翻几倍。

剪枝和缓存必须同时上。只用剪枝不用缓存,同一局面反复计算会白白消耗大量时间;只用缓存不排序动作,搜索树照样很胖。动作排序我前面提过,用评估函数值从高到低排序即可,实现成本低,收益比想象中高。

5.3 这套玩法的边界和后续思路

Minmaxh3 不是万能的,它的前提是“对手也是最优决策者”。如果真实场景中对手经常犯错,那么 min-max 给出的策略会过于保守。比如追捕者认为自己走这条路会被对手完美躲避,所以选了一条稳妥的路线,但真实目标可能根本没反应过来,直接被抓了。

针对这个问题,我后来引入了“随机化评估”的思路:不是简单用评估函数打分,而是给每个叶节点叠加一个噪声项。噪声幅度不大,但能让 AI 在多个收益接近的选择中随机化,避免策略单调。实测下来,面对随机移动的对手时成功率没有下降太多,但决策变得不那么容易被针对了。

后续如果要继续扩展,我还有两个方向:第一个是 MCTS,蒙特卡洛树搜索,它比 min-max 更适合动作空间大的博弈,比如多人或者非零和场景;第二个是 H3 多层级搜索,先在低分辨率 H3 上做粗粒度决策,确定大致方向,再切到高分辨率网格做精细决策,有点类似人类先看地图再走街串巷的思路。

我自己最实际的体会是:H3 与博弈搜索结合并不是什么高大上的新算法,它只是把两个成熟工具拼在一起,解决那些“空间里存在对抗者”的决策问题。这类问题在地理空间行业里越来越常见,而多数人在做选址、规划时仍然只把 H3 当成坐标索引工具,没有意识到它作为一个离散棋盘能够支撑真正的 AI 决策。如果你手上恰好有一个对抗性的空间决策问题,不妨把 Minmaxh3 当作一个起点,从六边形棋盘和 min-max 开始,一步步跑通属于自己的玩法。

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

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

立即咨询