
一直以为空间索引是个高不可攀的东西直到有次做一个小游戏原型卡到崩溃几千个圆形的碰撞检测用双层循环跑一帧要算七百多万次距离直接掉到个位数帧率。后来换了四叉树quad tree做空间划分同样场景下查询量降到几万次一下就流畅了。这篇文章就从零开始用Python手写一个能用的四叉树讲清楚原理、实现、可视化、调参和常见坑。适合正在做2D游戏、地图应用、点云处理或任何二维空间搜索需求的开发者就算你只是刚学完Python基础也能照着一行行敲出来并且真正理解背后“为什么会变快”的逻辑。1. 四叉树能解决什么问题1.1 从暴力遍历到空间索引先看最朴素的做法假设有n个点你想知道哪些点落在某个矩形区域内最简单就是遍历全部点判断每个点是否在矩形里。这个操作本身是O(n)看起来不慢但如果你要对每个点都做一次邻近查询总复杂度就是O(n^2)。n5000时就是2500万次判断在Python这种解释型语言里跑起来体感就是“卡死”。四叉树做的事情很简单用一个递归的树形结构把平面切成越来越多的小格子。根节点代表整个矩形区域每个节点最多有四个子节点分别对应西北、东北、西南、东南四个象限。当你往树里插入一个点它会一直下钻到足够小的格子查询时只需要访问与目标区域重叠的少数格子跳过完全不相干的大片区域。这个过程把“从全部点里找”变成了“从附近格子里找”平均查询复杂度能降到O(logn)级别。生活里一个很贴切的类比是图书馆找书。暴力遍历就像是把整座图书馆的书一本一本看封面四叉树则是先按楼层、再按书架、再按层数逐级缩小范围。空间索引不是魔法它只是帮你提前把“书”按位置放好查询时少走弯路。1.2 四叉树的适用场景与不适用场景四叉树最适合二维空间里的静态或偶尔更新数据。常见的应用包括2D游戏中的碰撞检测、视野裁剪、单位搜索地图系统里的POI兴趣点查询、道路网格划分图像处理中的区域分割、四叉树压缩点云数据/粒子系统的邻居搜索大数据可视化的抽稀和聚合但四叉树不是一个万能银弹。它不适合高维数据——四叉树是二维天然结构三维请用八叉树更高维老老实实上KD树或基于向量索引的库。它也不适合数据频繁动态增删且查询很少的场景因为每次分裂和合并都有开销如果只是偶尔查一次直接遍历反而更省事。还有一个容易被忽略的边界如果点分布极度不均匀四叉树会退化成一条“深链”性能还不如数组遍历。后面我会专门说这个问题。接下来我们不再停留在概念层面直接动手设计数据结构。2. 核心原理与数据结构设计2.1 四叉树的基本结构一个标准的四叉树节点包含这几个信息节点覆盖的矩形区域通常用x, y, width, height表示子节点列表长度固定为4也可能为空表示是叶子节点存储在当前节点的点列表只有叶子节点可以直接存储点可选的节点深度以及点的容量上限设计上有一个经典决策到底一个节点能存多少个点才分裂通常设一个capacity参数比如4或8。当点数量超过capacity时就把当前节点分成四个子节点然后把原有的点和新点重新分配到对应子节点里。为什么要设capacity而不是“一个格子里只存一个点”因为树的深度和格子数会爆炸比如一万个点都集中在很小的区域那树可能极深查询反而退化。实际使用中一个叶子节点容纳少量点比如4~16个是性能和内存的合理折中。2.2 用Python定义节点类先写最基础的Point和Node类。Point很简单保存x和y坐标Node则需要有边界信息和操作逻辑。class Point: __slots__ (x, y) def __init__(self, x, y): self.x x self.y y def __repr__(self): return fPoint({self.x:.2f}, {self.y:.2f})之所以用__slots__是因为四叉树里会创建大量Point实例默认的__dict__会占用不少内存。对性能敏感的场景这一行能省下非常可观的内存开销。接下来是Node类。我这里用一个字典存子节点而不是固定长度列表这样语义更清晰只有“nw”“ne”“sw”“se”四个键。需要说明的是实际性能上列表和字典差别不大但字典在节点分裂时更容易理解。class Node: def __init__(self, x, y, width, height, capacity8, depth0): self.x x self.y y self.width width self.height height self.capacity capacity self.depth depth self.points [] # 叶子节点存点 self.children {} # 非叶子节点存四个子节点 def is_leaf(self): return not self.children def subdivide(self): half_w self.width / 2 half_h self.height / 2 cx self.x half_w cy self.y half_h self.children[nw] Node(self.x, self.y, half_w, half_h, self.capacity, self.depth 1) self.children[ne] Node(cx, self.y, half_w, half_h, self.capacity, self.depth 1) self.children[sw] Node(self.x, cy, half_w, half_h, self.capacity, self.depth 1) self.children[se] Node(cx, cy, half_w, half_h, self.capacity, self.depth 1) # 把当前节点的点重新分配到子节点 for pt in self.points: self._insert_into_child(pt) self.points.clear()这里有个关键点分裂后父节点不再存点所有点都下沉到子叶子节点。插入时如果发现子节点之后又满了子节点也会继续分裂——这个递归过程会自然进行。2.3 插入逻辑与动态分裂插入一个点时如果节点是叶子且没满直接加入points如果满了先分裂再让所有点包括新点进入对应子节点。如果不是叶子就直接交给对应子节点处理。判断“点属于哪个象限”通过比较坐标和中心点来完成。def _which_child(self, pt): cx self.x self.width / 2 cy self.y self.height / 2 if pt.x cx: if pt.y cy: return nw else: return sw else: if pt.y cy: return ne else: return se def _insert_into_child(self, pt): child_key self._which_child(pt) child self.children[child_key] child.insert(pt) def insert(self, pt): # 判断点是否在节点范围内 if not (self.x pt.x self.x self.width and self.y pt.y self.y self.height): return False if self.is_leaf(): if len(self.points) self.capacity: self.points.append(pt) return True else: self.subdivide() # 如果不是叶子或者刚分裂完就交给子节点 self._insert_into_child(pt) return True注意插入前要检查点是否落在当前节点区域内。我习惯用左闭右开的边界self.x pt.x self.x self.width这样可以避免点在多个边界上被重复插入也让所有格子的统计面积刚好等于根节点面积。另一个容易忽略的是insert的返回值。如果点不在当前节点范围内返回False是很有用的——在递归外层的QuadTree会捕获这个结果并决定是否忽略这个点。3. 完整实现插入、查询与可视化3.1 插入数据与自动分裂的实现把节点类和QuadTree容器放在一起得到完整可运行的四叉树。QuadTree只需要保存根节点和全局点数量对外提供insert和query接口。class QuadTree: def __init__(self, x, y, width, height, capacity8, max_depth20): self.root Node(x, y, width, height, capacity, 0) self.capacity capacity self.max_depth max_depth self.size 0 def insert(self, x, y): pt Point(x, y) # 防止超出边界 if not (self.root.x x self.root.x self.root.width and self.root.y y self.root.y self.root.height): return False # 这里深度限制放在Node内部处理见下面的insert_depth result self._insert(self.root, pt, 0) if result: self.size 1 return result def _insert(self, node, pt, depth): if depth self.max_depth: node.points.append(pt) return True if node.is_leaf(): if len(node.points) node.capacity: node.points.append(pt) return True else: node.subdivide() child_key node._which_child(pt) child node.children[child_key] return self._insert(child, pt, depth 1)这里我把深度限制单独提出来当深度达到max_depth时即使点数量超过capacity也不再分裂直接硬塞进当前叶子节点。这是一个非常重要的保护措施否则当大量相同的点落在同一个位置时树会无限分裂下去递归深度直接撑爆。为什么不用Node自己的depth累加因为插入时我们是从根节点一路递归最好把当前深度作为参数传递这样控制起来更直接。Node内部保留depth是为了调试和可视化方便后面画格子时知道层级。3.2 区域查询与范围搜索四叉树最大的优势就是查询。给定一个矩形范围我们不需要遍历所有点只需要递归访问与查询矩形相交的节点。查询逻辑如果当前节点区域和查询区域不相交直接返回空如果是叶子节点逐个检查点是否在查询范围内如果不是叶子递归查询所有相交的子节点def query(self, qx, qy, qw, qh): results [] self._query(self.root, qx, qy, qw, qh, results) return results def _query(self, node, qx, qy, qw, qh, results): # 快速拒绝节点区域和查询矩形不相交 if not (node.x qx qw and node.x node.width qx and node.y qy qh and node.y node.height qy): return if node.is_leaf(): for pt in node.points: if qx pt.x qx qw and qy pt.y qy qh: results.append(pt) else: for child in node.children.values(): self._query(child, qx, qy, qw, qh, results)这个实现有几个细节值得说说。判断两个矩形相交时我用了“不重叠则返回”的反向判断只要node.x qx qw或者node.x node.width qx等条件成立就说明不相交。这种写法比正向判断所有相交情况更不容易出错。还有一个常见的优化如果查询矩形完全包含了当前节点区域那就不用再递归到叶子去判断每个点了直接把当前节点里所有点返回即可。下面这个改进在查询范围很大时能显著提速def _query_fast(self, node, qx, qy, qw, qh, results): if not (node.x qx qw and node.x node.width qx and node.y qy qh and node.y node.height qy): return # 如果查询区域完整覆盖了节点区域直接取全部点 if qx node.x and node.x node.width qx qw and \ qy node.y and node.y node.height qy qh: self._collect_all(node, results) return if node.is_leaf(): for pt in node.points: if qx pt.x qx qw and qy pt.y qy qh: results.append(pt) else: for child in node.children.values(): self._query_fast(child, qx, qy, qw, qh, results) def _collect_all(self, node, results): if node.is_leaf(): results.extend(node.points) else: for child in node.children.values(): self._collect_all(child, results)这个优化逻辑很简单如果当前节点整个都在查询矩形内那它的所有后代节点肯定也都在查询矩形内没必要再做矩形相交判断直接深度优先遍历收拢所有点。在范围很大的场景下这个优化能省掉大量无意义的矩形相交计算。3.3 用matplotlib把树画出来写算法最怕“看起来对但实际没对”所以一定要可视化验证。用matplotlib把四叉树的边界和点画出来一眼就能看出划分是否合理。import matplotlib.pyplot as plt def plot_quadtree(tree, axNone): if ax is None: fig, ax plt.subplots(figsize(8, 8)) _plot_node(ax, tree.root) ax.set_xlim(tree.root.x, tree.root.x tree.root.width) ax.set_ylim(tree.root.y, tree.root.y tree.root.height) ax.set_aspect(equal) ax.set_title(Quad Tree Visualization) def _plot_node(ax, node): # 画当前节点的矩形边框 rect plt.Rectangle((node.x, node.y), node.width, node.height, fillFalse, edgecolorgray, linewidth0.8) ax.add_patch(rect) # 画点 if node.is_leaf(): for pt in node.points: ax.plot(pt.x, pt.y, ro, markersize2) else: for child in node.children.values(): _plot_node(ax, child)实测一个小例子在1000x600区域里随机生成500个点capacity设为4然后画出来。你会看到点密集的地方格子被切得很小点稀疏的地方格子很大。这就是四叉树自适应划分的直接体现。import random random.seed(42) tree QuadTree(0, 0, 1000, 600, capacity4) for _ in range(500): tree.insert(random.uniform(0, 1000), random.uniform(0, 600)) plot_quadtree(tree) plt.show()运行出来的图能帮你直观验证插入逻辑是否正确。如果你发现两个点画到了同一个格子里但格子里点数明显超过capacity那就要检查分裂逻辑是不是漏了。4. 边界情况与常见坑4.1 点重叠与容量控制四叉树最经典的问题就是重复点。比如同一个坐标被插入无数遍每次插入都会让叶子节点“满了”于是分裂、下沉、再满、再分裂直到深度限制被强制截断。如果遇到大量重复点光靠max_depth硬截断虽然能防止栈溢出但性能会很差。更好的做法是在插入时容忍重复点但限制同一个格子的点数量——比如Node在分裂后对落入同一子节点的点能继续分裂如果到达max_depth就把points作为一个“溢出袋”继续追加。这个方案在很多工程实现里都存在不是一个完美的解法但实用性很强。实际工程中还有一个选择如果业务语义上不需要重复点可以在插入前用if point not in node.points去重。但这样会让插入退化为O(k)k是一个格子里点的数量。通常不建议在插入时做全套去重除非数据源确实有大量重复。4.2 递归深度与性能退化除了重复点点分布极不均衡也会让四叉树变成“高瘦树”。比如所有点都落在一小条斜线上虽然有四个象限但每次只有左下和右上的子节点有数据树就会退化成类似二叉树的形状深度可能达到n。解决办法有三条路调大capacity让叶子节点多装一些点减少分裂深度调低max_depth限制树最深不能超过某个值比如20层改用松散四叉树loose quadtree每个节点的矩形范围比实际格子扩大一点让点尽量留在浅层松散四叉树的实现思路是每个节点在分裂时把四个格子的边界扩大某个百分比比如10%这样分布在边界附近的点不会立刻下钻。代价是同一个点可能同时落在多个兄弟节点的范围内插入时一般只选择一个子节点但查询时可能重复访问。这种设计对动态数据更友好但实现复杂度明显提高新手不建议一开始就上。4.3 内存与速度的权衡capacity是四叉树最重要的调参旋钮。capacity越小树的格子越密查询时访问的节点更少但每个节点都要存储四个子节点引用和单独的点的列表内存开销更大。capacity越大树越浅内存更省但查询退化成“在较大格子里线性扫描”的频率更高。我通常建议从capacity8开始然后对比capacity4、16、32下的查询耗时时长。下面是一个简单的基准模板import time import random random.seed(1) points [(random.uniform(0, 10000), random.uniform(0, 10000)) for _ in range(20000)] for cap in (4, 8, 16, 32): tree QuadTree(0, 0, 10000, 10000, capacitycap) for x, y in points: tree.insert(x, y) start time.perf_counter() for _ in range(1000): qx random.uniform(0, 9000) qy random.uniform(0, 9000) tree.query(qx, qy, 1000, 1000) elapsed time.perf_counter() - start print(fcapacity{cap:2d}, query 1000 times: {elapsed:.4f}s)在我的一次测试里2万点查询1000次capacity4大约0.18秒capacity8大约0.14秒capacity16大约0.16秒capacity32大约0.21秒。所以capacity8~16在当前数据分布下是比较合适的。但这仅仅是参考你自己的数据分布不同结果也会不同。5. 实际案例碰撞检测与最近邻搜索5.1 用四叉树加速碰撞检测碰撞检测最常见的需求是给定一个圆圆心半径找出所有可能与之碰撞的点或物体。暴力方法是遍历所有点算距离比半径。用四叉树则是先做一个范围查询把圆心周围半径r的矩形区域内的点找出来再对这些候选点做精确的圆形碰撞判断。精确距离计算只对被选中的点执行计算量大幅下降。def query_circle(tree, cx, cy, radius): # 先查外接矩形 candidates tree.query(cx - radius, cy - radius, radius * 2, radius * 2) hits [] for pt in candidates: dx pt.x - cx dy pt.y - cy if dx * dx dy * dy radius * radius: hits.append(pt) return hits这里有个很重要的工程细节先过矩形查询再过圆形过滤。如果直接对全量点做圆形判断那还是O(n)四叉树帮你把候选集缩小到矩形范围内的点误差只有边界处多出来的那一点点。对于半径比较小的碰撞检测这个剪枝效果非常明显。另一个碰撞检测场景是“找出所有互相碰撞的点对”比如粒子系统。这时候可以遍历每个点用上面那个query_circle找邻居再通过id(pt)或者索引去重避免同一个点对算两次。实际使用时我会给每个点附一个唯一id然后用集合记录已经处理过的对。5.2 扩展到最近邻搜索四叉树不仅能做范围查询还能做k近邻搜索。最简单粗暴的办法从半径r1开始不断扩大范围查询直到找到k个点。这种指数扩张策略写起来容易但有一个致命问题最后一次查询可能选到一堆距离很远的多余点而且循环次数不好控。更优雅的方式是用一个优先队列按“节点矩形到目标点的最小距离”排序每次从队列里取出最近的节点如果是叶子就检查里面的点如果是内部节点就展开它的四个子节点。这个思路和我之前实现R树k近邻时完全一样只是四叉树的距离计算更简单。import heapq def nearest_neighbors(tree, target_x, target_y, k): heap [] # 元素格式(距离下界, 节点/点标记, 对象) heapq.heappush(heap, (rect_distance(tree.root, target_x, target_y), node, tree.root)) result [] while heap and len(result) k: dist, kind, obj heapq.heappop(heap) if kind node: if obj.is_leaf(): for pt in obj.points: d (pt.x - target_x) ** 2 (pt.y - target_y) ** 2 heapq.heappush(heap, (d, point, pt)) else: for child in obj.children.values(): heapq.heappush(heap, (rect_distance(child, target_x, target_y), node, child)) else: result.append(obj) return result[:k]其中rect_distance计算一个矩形到目标点的最小欧氏距离的平方这个函数用顶点分类判断即可是几何算法的基本功。这个实现的正确性依赖堆里距离下界的单调性但不展开说太多有兴趣的读者可以自己画个图验证一下。6. 调优与扩展思路6.1 性能基线与Python生态扩展四叉树在纯Python下能跑多快取决于数据量和递归深度。如果数据量在十万级做范围查询依然可以接受但再往上就吃力了。有几种常见的优化路径用numpy向量化叶子节点里的点判断把Python循环换成批量布尔运算把Node的subdivide放到插入前预分配减少运行时对象创建用__slots__减少Point和Node的内存占用将递归改为显式栈避免过深的递归调用Python解释器开销如果数据特别大可以直接把插入阶段做成批量重建而不是动态插入如果你的项目已经用到了shapely或geopandas其实不用重复造轮子shapely的strtree就是R树的Python封装scipy.spatial.cKDTree也能处理k近邻。四叉树的价值更多在于理解原理和定制特殊逻辑比如按业务规则裁剪分裂条件。6.2 与R树、网格索引的对比很多人会问有四叉树为什么还需要R树R树更适合“节点区域大小不一”的场景它支持空间对象的层次包围盒每个对象可以有自己的矩形范围而四叉树是固定四分划分每个叶子格子大小相等。对纯点数据来说四叉树实现简单、性能稳定对多边形/线段这类“非点对象”R树更有优势。还有一种更简单的网格索引把平面分成固定大小的格子每个格子对应一个点列表。网格索引的查询复杂度近似O(1)但内存可能与数据范围成正比且对分布不均匀的数据不友好。四叉树本质上是“自适应网格”它继承了网格的简单性又通过递归细分解决了内存浪费。所以如果你的数据分布比较均匀固定网格也够用如果分布极不均匀四叉树是更好的选择。我自己的经验是做点数据的碰撞检测或区域查询先上四叉树能cover 80%的需求。只有当你明确需要空间对象索引、多维向量搜索或者数据库集成时再去考虑R树、KD树或专门的空间数据库。最后再分享一个小技巧调四叉树性能时别只看平均查询时间一定要画出树的划分图看看是不是出现大量空节点、深链或者边界点被重复分配的问题。我踩过最深的坑就是忘记处理落在边界上的点结果相邻两个格子都把同一个点算进去导致碰撞检测出现“幽灵碰撞”。后来统一成左闭右开区间这类问题一下就消失了。四叉树本身不复杂复杂的是边界条件和数据分布把它画出来看很多问题都会自己浮现出来。