ArcGIS碎图斑合并提速25倍:并查集+PolygonNeighbors优化实战

ArcGIS碎图斑合并并查集
于 2026-10-02 07:35:40 修改
·本内容遵循CC 4.0 BY-SA版权协议

上个月我终于把部门里那个“按属性合并碎图斑”的 ArcGIS 脚本从 3 小时压到了 7 分钟,测试数据是 10 万个面图斑,优化前后性能提升 25 倍以上。这篇想把完整的优化思路写清楚:旧方案慢在哪、并查集在这里到底起什么作用、每一步代码怎么落、实战里又踩了哪些坑。如果你也经常跟地类图斑、不动产数据、叠加分析结果打交道,或者正在写遇到一次几百个碎图斑就卡半天的 ArcPy 工具,这篇可以直接当作业抄。

先说结论。整个优化只做了一件事:把原来“边查相邻边合并”的迭代流程,改成“先算清楚每个图斑最终属于哪一组,再统一跑一次 Dissolve”。中间用来算分组的数据结构,就是标题里的并查集(Union-Find)。几何操作依然交给 ArcGIS 自己,但流程依赖的核心变成了表操作和内存运算,速度自然完全不一样。

1. 先搞清楚:碎图斑合并到底难在哪

1.1 业务场景:从叠加分析结果到成图的最后一步

先说碎图斑怎么来的。最典型的是图层叠加分析之后的结果。比如土地利用数据分别叠加上行政界线、生态红线、坡度分级,跑完 Intersect 或 Union,一张原本几十个地块的图层,瞬间被切出几万甚至几十万个面。属性没变,但同一个地类被打散成一大堆互相挨着的小碎图斑。

另一种情况来自人工修图和矢量化。我在实际项目里见过整片“林地”中间夹着二十来个比指甲盖还小的“草地”碎面,这些面往往是采集误差、线跟踪残留或者相邻图层边缘不齐造成的。业务上要求在最终出图或做面积统计前,把同一属性值、又相邻的碎图斑合并成大块,减小数据量,也方便后续制图和查询。

这些碎图斑的特征很一致:面积小、数量多、属性值和周围大片地块相同、彼此共享边界。看上去就是 Dissolve 的活,按属性字段融合一次不就行了?但问题在于——只按属性融合,会让不相邻的同类图斑跨区域融合在一起,这是业务上绝对不能接受的。比如一块林地在山这边一块、山那边一块,不能因为属性都是“林地”就捏成一个多部件面。必须加上“相邻”这个空间条件,于是问题就变成了:把 属性相同 + 空间相邻 的图斑归到同一组,再融合。

1.2 旧脚本的典型流程:循环选中、循环合并

旧工具的核心逻辑大致长这样,我相信不少同学第一版都是这么写的:

PYTHON
# 伪代码:旧的迭代合并逻辑
changed = True
while changed:
changed = False
with arcpy.da.SearchCursor(fc, ["OID@", attr_field]) as cur:
for oid, val in cur:
# 按位置选择与当前要素相邻的图斑
arcpy.SelectLayerByLocation_management(lyr, "BOUNDARY_TOUCHES", fc)
# 在同一批相邻图斑里,再按属性筛选
arcpy.SelectLayerByAttribute_management(lyr, "SUBSET",
f"{attr_field} = '{val}'")
if arcpy.GetCount_management(lyr)[0] != "0":
# 把这些要素 Dissolve 合并成一个,删掉原要素,写回合并结果
changed = True
break

这个思路很好理解:逐个扫图斑,找到和它相邻又同属性的,合并,然后从头再来。但速度惨不忍睹。10 万图斑的数据,里面大约有两万多个需要发生合并操作的组,每一组可能要经过多轮才能彻底并完,实际跑下来全流程三个多小时。

1.3 慢的根源:为什么 3 小时是必然的

旧方案慢不是代码写得烂,而是流程本身有三层性能瓶颈叠加。

第一层是空间查询反复触发。SelectLayerByLocation 每一次都要在整个图斑集合上做空间过滤,虽然 ArcGIS 有空间索引,但 BOUNDARY_TOUCHES 这种邻接判断还要解算候选要素的几何,一秒钟能跑几次就算不错了。高层循环里每合并一次就要重新选一轮,次数上万以后,光是空间查询就能吃掉 70% 以上的时间。

第二层是编辑会话的额外开销。旧脚本通常把合并放在编辑会话里,每次生成新几何、删除旧几何,都要维护撤销栈、脏区标记,数据在版本化企业库里的开销更大。个别实现还在同一图层上反复进行“删一批、写一批”,字段索引和空间索引也跟着反复重建。

第三层也是隐藏最深的一层:合并后的结果会改变邻接关系。两个图斑合并成一个新面之后,新面可能又和之前并过的面相邻了,所以循环不能简单地往后继续,必须从头重新扫。越往后,已经合并出的大块越多,每次扫描要跳过的候选也越多,整体复杂度接近 O(n²)。

所以旧方案的“3 小时”不是偶然,是这几层开销叠加的必然结果。想靠优化写 SQL 式查询、或者给选择加个缓存,都解决不了根本问题,因为它的执行模型就是“边算边改、改完重算”。

2. 换个思路:先算“最终分组”,再一次性融合

2.1 从“边合并边查”到“先分组后融合”

既然合并之后能形成什么新面,取决于最终的分组方案,那我们完全可以换个执行顺序:先把分组关系全部算完,再只执行一次真正意义上的几何融合。

分组规则其实很干净:两个图斑要并到一组,前提是它们之间能通过一串“属性相同 + 空间相邻”的图斑连起来。这不就是图论里的连通分量问题吗?把每个图斑看作一个节点,相邻且属性相同的两个图斑之间连一条边,最后要做的就是把每个连通分量里的节点合并成一个输出要素。

在这个模型里,旧方案的“合并后又产生新的相邻关系”这个麻烦根本不存在了。因为合并前后的邻接关系不会改变分组结果:A 和 B 相邻同属性并了,B 和 C 相邻同属性又并了,那最终 A、B、C 一定是同一个连通分量,提前算出这个结论和逐步合并算出的结论完全一致。

2.2 并查集(Union-Find)到底是啥

并查集是算法里非常经典的数据结构,专门用来高效维护元素的分组关系。它支持两个操作:find(x) 查 x 属于哪个组,union(x, y) 把 x 和 y 所在的两个组合并成一个。听起来简单,但配上路径压缩和按秩合并,单次操作复杂度接近 O(1),实际就是常数级。

用生活类比来说,就像线下活动里摸清谁和谁是一伙的。每个人一开始只认识自己。每当发现两个人是一条线的,就把两边人马并成一伙。最后直接报一下“某个人属于哪一伙”就能得到全局分组。不需要重新把所有人排队问一遍。

实现上核心代码非常短:

PYTHON
class UnionFind:
def __init__(self, n):
self.parent = list(range(n))
self.rank = [0] * n
 
def find(self, x):
while self.parent[x] != x:
self.parent[x] = self.parent[self.parent[x]] # 路径压缩
x = self.parent[x]
return x
 
def union(self, a, b):
ra, rb = self.find(a), self.find(b)
if ra == rb:
return
if self.rank[ra] < self.rank[rb]:
self.parent[ra] = rb
elif self.rank[ra] > self.rank[rb]:
self.parent[rb] = ra
else:
self.parent[rb] = ra
self.rank[ra] += 1

路径压缩的作用是让每次 find 都尽量把节点直接挂到根上,按秩合并则是让树尽量矮,两个加起来就是教科书里说的“反阿克曼函数”级别复杂度。对于 10 万图斑,即使做几十万次 find 和 union,耗时也只是秒级。

2.3 为什么这个场景命中并查集的长处

处理碎图斑分组,并查集几乎是量身定做的。旧方案每次合并后都要重新计算邻接,是因为它假设“分组需要分步确认”。而并查集天生支持边扫描边合并,所有判定都基于原始邻接表做一遍,不需要在任何环节回去更新邻接关系。

另外,并查集天然给出了“代表性元素”。每个分组的根节点就是一个大组里任意一个图斑的索引,我们可以拿这个根索引当作分组 ID 回写到每一行,这样后续 Dissolve 只需要按这个字段融合即可。

相比用 BFS/DFS 遍历邻接图,并查集还有一个现实优势:ArcPy 里处理图遍历时要管理访问标记、队列、嵌套数据结构,内存开销和代码复杂度都更高。DSU 只需要两个整数数组,就算数据量到百万级,内存也只有几十 MB,完全没压力。

3. 邻接关系怎么高效得到:PolygonNeighbors 的正确打开方式

3.1 工具选型:PolygonNeighbors vs Spatial Join

分组的输入是“哪些图斑相邻”。这一步不能再自己在 ArcPy 里写嵌套循环去比较几何,必须让 ArcGIS 原生工具把邻接表建出来。我第一反应是 Spatial Join,后来换成了 PolygonNeighbors。

Spatial Join 的 INTERSECT 匹配虽然也能找出相交要素,但输出是“要素对”,数据多且杂。它会把所有相交的都列出来,包括包含、被包含、压盖的情况,如果只想拿“边界相邻”,还得在 Join 输出里做大量筛选。更重要的是,Spatial Join 是通用工具,不会专门给你算共享边的长度。

PolygonNeighbors 是专门干这个的:输入一个面要素类,输出一张邻接表,每一行是一条“相邻对”,还自带共享边界长度。内部用空间索引批量解算邻接关系,10 万个面跑下来也就是几分钟,比手写判断可靠得多。

3.2 参数与字段说明,以及版本差异

在 ArcPy 里的基本用法是:

PYTHON
arcpy.PolygonNeighbors_analysis(in_fc, "in_memory/neighbor_table", attr_field)

输出表结构一般长这样:

字段名 含义
src_OBJECTID 源图斑的要素 ID
nbr_OBJECTID 相邻图斑的要素 ID
length 两个图斑共享边界的长度
src_地类字段 / nbr_地类字段 传入 in_fields 时,自动带上两侧属性值

注意第三个参数 in_fields 只是把属性字段一起带进输出表,方便后续直接对比两侧属性值,并不影响邻接关系本身。如果你把 OBJECTID 也传进去,输出表里同样会出现 src_OBJECTID 和 nbr_OBJECTID。

不同版本字段命名有点差别:ArcMap 10.x 里共享边界长度字段名是 length,ArcGIS Pro 某些版本可能叫 Length 或者 Shape_Length。所以代码里最好不要写死字段名,动态从输出表字段列表里找最稳。

3.3 共边、共点、重叠三种关系的取舍

PolygonNeighbors 默认会把“共享边”和“只在某个顶点碰一下”的图斑都当作相邻。但业务上,只在角上碰一下的两个图斑,通常不应该合并,否则会生成中间只有一个点相连的畸形面,后续拓扑检查很容易报错。

怎么区分?看共享边长度字段。共享边界长度为 0,说明两边只共点不共边,可以直接过滤掉;大于 0 才是真正共边。

还有一种情况是输入数据本身有压盖、重叠。如果数据是从多个来源拼出来的,难免有小面积重合。PolygonNeighbors 默认会把有重叠面积的面也识别为相邻,这未必是业务想要的。我建议在跑工具之前先检查几何:Check Geometry 和 Repair Geometry 各跑一遍,确保图层没有拓扑异常,再用 NO_AREA_OVERLAP 参数把“仅共边界”作为硬条件。

PYTHON
arcpy.PolygonNeighbors_analysis(in_fc, nt, attr_field,
area_overlap="NO_AREA_OVERLAP")

4. ArcPy 完整实现:从邻接表到单轮直出

4.1 代码骨架总览

完整流程拆成六步:

  1. 给输入要素加上一个分组 ID 字段。
  2. 建立 OID 到数组索引的映射,防止 OID 不连续导致数组越界。
  3. 调用 PolygonNeighbors 生成邻接表。
  4. 遍历邻接表,两侧属性值相同(且不为空)就执行 union。
  5. 算出每个要素最终的分组 ID,用 UpdateCursor 回写。
  6. 最后按分组 ID 做一次 Dissolve。

核心思想就是标题里的“单轮直出”:所有分组信息提前算完,剩下所有几何融合交给一次 Dissolve。

4.2 可运行的完整代码

下面这段代码在 ArcGIS Pro 3.x 和 ArcMap 10.8 上都实测过,可以直接作为脚本工具用。命名上用了通用的 OID 字段,所以同时兼容要素类(OBJECTID)和 Shapefile(FID)。

PYTHON
# -*- coding: utf-8 -*-
import arcpy
 
class UnionFind:
def __init__(self, n):
self.parent = list(range(n))
self.rank = [0] * n
 
def find(self, x):
while self.parent[x] != x:
self.parent[x] = self.parent[self.parent[x]]
x = self.parent[x]
return x
 
def union(self, a, b):
ra, rb = self.find(a), self.find(b)
if ra == rb:
return
if self.rank[ra] < self.rank[rb]:
self.parent[ra] = rb
elif self.rank[ra] > self.rank[rb]:
self.parent[rb] = ra
else:
self.parent[rb] = ra
self.rank[ra] += 1
 
 
def merge_fragments_by_attr(in_fc, attr_field, out_fc):
arcpy.env.overwriteOutput = True
desc = arcpy.Describe(in_fc)
oid_field = desc.oidFieldName
 
group_field = "GRP_ID"
if group_field in [f.name for f in arcpy.ListFields(in_fc)]:
arcpy.DeleteField_management(in_fc, group_field)
arcpy.AddField_management(in_fc, group_field, "LONG")
 
# 建立 OID 到数组索引的映射
oid_to_idx = {}
idx_to_oid = []
with arcpy.da.SearchCursor(in_fc, [oid_field]) as cur:
for row in cur:
oid = row[0]
oid_to_idx[oid] = len(idx_to_oid)
idx_to_oid.append(oid)
 
uf = UnionFind(len(idx_to_oid))
 
# 生成邻接表
nt = "in_memory/neighbor_table"
arcpy.PolygonNeighbors_analysis(in_fc, nt, attr_field,
area_overlap="NO_AREA_OVERLAP")
 
# 动态解析输出字段名
nt_fields = [f.name for f in arcpy.ListFields(nt)]
src_f = next(f for f in nt_fields if f.startswith("src_"))
nbr_f = next(f for f in nt_fields if f.startswith("nbr_"))
len_f = "length" if "length" in nt_fields else None
 
# 属性值缓存:一次全表扫描,避免后续逐条回查
attr_cache = {}
with arcpy.da.SearchCursor(in_fc, [oid_field, attr_field]) as cur:
for oid, val in cur:
attr_cache[oid] = val
 
# 遍历邻接表,属性相同时做 union
fields = [src_f, nbr_f]
if len_f:
fields.append(len_f)
 
with arcpy.da.SearchCursor(nt, fields) as cur:
for row in cur:
src_oid, nbr_oid = row[0], row[1]
if len_f and row[2] is not None and row[2] <= 0:
continue # 只共点不共边,跳过
 
v1 = attr_cache.get(src_oid)
v2 = attr_cache.get(nbr_oid)
# NULL 不参与合并;文本字段清理一下首尾空格更稳
if v1 is None or v2 is None:
continue
if isinstance(v1, str):
v1 = v1.strip()
if isinstance(v2, str):
v2 = v2.strip()
if v1 == v2:
uf.union(oid_to_idx[src_oid], oid_to_idx[nbr_oid])
 
# 回写分组 ID
root_to_gid = {}
gid = 0
with arcpy.da.UpdateCursor(in_fc, [oid_field, group_field]) as cur:
for oid, _ in cur:
root = uf.find(oid_to_idx[oid])
if root not in root_to_gid:
root_to_gid[root] = gid
gid += 1
cur.updateRow((oid, root_to_gid[root]))
 
# 单轮 Dissolve,按分组 ID + 原属性字段输出
arcpy.management.Dissolve(in_fc, out_fc,
[group_field, attr_field],
multi_part="MULTI_PART")
 
arcpy.Delete_management(nt)

调用方式很简单:

PYTHON
if __name__ == "__main__":
merge_fragments_by_attr(
r"C:\data\land.gdb\parcels",
"LANDUSE",
r"C:\data\land.gdb\parcels_merged"
)

4.3 几个必须注意的实现细节

第一,OID 映射不能省。要素类在经历多次增删后,OID 经常出现断号,比如 1、2、5、8、9,直接用 OID 当数组下标必然越界。代码里用字典把 OID 映射到 0 到 n-1 的连续索引,再反向存一份,虽然多了一次全表扫描,但 10 万条也就是一两秒的事。

第二,in_memory 工作空间用完要删。邻接表放 in_memory 确实快,但如果后续还要跑别的流程,不删的话内存会一直占着。代码最后 Delete_management 一下,养成习惯。

第三,分组字段加到输入要素上,会改动原始数据。如果输入数据是只读的,或者你不希望工具污染原始库,可以先 CopyFeatures 一份到临时要素类,再运行本逻辑。实际生产环境我一般加一个“是否允许修改输入”的参数,默认走拷贝分支。

第四,Dissolve 的结果字段顺序和分组 ID 问题。输出里会带上 GRP_ID 字段。如果下游不需要这个分组的中间字段,可以在 Dissolve 之后把它删掉,或者 Dissolve 时只按 GRP_ID 融合,再利用属性传递规则把原属性字段保留过来。

4.4 最后的 Dissolve 与属性保留策略

上面代码 Dissolve 直接按 [GRP_ID, 属性字段] 两个字段融合,这样能把原始属性字段顺带保留在输出里,适合只需要一个字段分组的场景。

如果图斑还有其他要保留的字段,比如“权属单位名称”“原图斑面积”,就需要用 Dissolve 的统计参数。我的常用做法是:分组内保留面积最大那个图斑的属性,毕竟大图斑的属性往往更可信。

PYTHON
# 先让要素类带上 Shape_Area 字段(或通过计算几何生成 AREA 字段)
arcpy.management.Dissolve(
in_fc, out_fc,
[group_field],
[["Shape_Area", "MAX"], [attr_field, "LINK_FIELD"]],
multi_part="MULTI_PART"
)

LINK_FIELD 是 ArcGIS Pro 支持的一种统计方式,用来关联某个字段的值,具体参数要看你用的版本。老版本想实现“保留原字段”,那就先把“原字段 + 面积最大值”用 JOIN 关联出来,再按 JOIN 结果写回,或者干脆在 Dissolve 前新增一个“保留字段值”的字段,值取组内面积最大图斑的对应属性。

5. 实测数据:3小时到7分钟是怎么来的

5.1 测试环境与数据

这次压测的数据来自一个真实的地类图斑项目:文件地理数据库里的一个面要素类,共 103,856 个图斑,属性字段有 5 种地类值,其中约 60% 的图斑属于需要合并的碎图斑,最终能合并成约 12,000 个组。

机器配置不算高:Windows Server 2019,2.4GHz 8 核 CPU,64GB 内存,磁盘是普通 SSD。ArcGIS Pro 3.0,默认安装,没有额外调优。

5.2 新旧方案耗时拆解

阶段 旧方案 新方案
数据准备与检查 约 10 分钟 约 1 分钟
邻接关系构建 无(每次选择现算) 3 分 40 秒
分组计算 3 小时主要在合并循环 26 秒
分组 ID 回写 无 45 秒
Dissolve 融合 最后整层按属性合一道,约 15 分钟 2 分 20 秒
结果校验 随缘 约 30 秒
合计 约 3 小时 15 分钟 约 7 分钟

新旧方案的耗时都包含了输出结果要素类的过程。旧方案最后其实也要跑一次全图斑的融合,避免中间合并后还存在相邻碎片,这一步本身就是 15 分钟量级,说明纯粹的空间融合本来就不慢,慢的是前面反复的“选择 + 临时合并”。

5.3 性能提升的倍数与可扩展性

180 多分钟对 7 分钟,提升在 25 倍以上。更关键的是复杂度层面的差异:旧方案接近 O(n²),数据量翻倍耗时可能要翻 4 倍;新方案主要成本是 PolygonNeighbors 和 Dissolve,这两个都是带空间索引的批量操作,基本在 O(n log n) 级别。数据量越大,差距越明显。

我后来拿 100 万图斑的模拟数据测试过,新方案大约 40 分钟能完成。旧方案按增长曲线推算可能要跑 5 到 7 天,基本等于不可用。所以如果你的数据量比 10 万还大,这个优化的价值不是“快 25 倍”能概括的,而是“能不能跑完”的区别。

6. 实战中的常见问题与排查思路

6.1 PolygonNeighbors 输出为空或字段名对不上

最容易踩的坑就是输出表字段名。不同版本对前缀和长度字段的命名不完全一样,代码里动态解析字段名能规避大部分问题。如果运行完发现邻接表一行数据都没有,先检查输入要素类是不是真的存在相交或共边的情况。很多新手拿一个“看起来相邻但其实隔着微小缝隙”的图层直接跑,自然一条记录都没有。

微小缝隙问题在实际数据里太常见了:两个图斑边缘看着是贴着的,放大到 1:100 才发现中间隔着 0.001 米的缝。解决方法是先跑 Integrate 或 Repair Geometry 做容差处理,或者把数据投影到合适的平面坐标系后,用 buffer 一点点把缝隙弥补掉。这里必须提醒:修改几何之前一定要备份原始数据。

6.2 属性 NULL、空字符串和文本格式差异

属性值的坑比几何还隐蔽。我用旧数据测试时发现,同一种地类,有的图斑存的是“林地”,有的存的是“林地 ”(带空格),还有的存了 “林地\r”,直接按属性字段相等判定就会漏合并。所以代码里对字符串做了 strip(),建议你也保留这个处理。

更麻烦的是 NULL。NULL 值如果参与相等判断,很可能把一堆“无属性”的图斑全并到一起。我的业务规范是:属性为空的图斑不参与合并,保持原样输出。所以 union 之前显式判断 v1 is None or v2 is None 就跳过。

6.3 合并后出现空洞或多部件

分组逻辑是正确的,但 Dissolve 结果可能出现空洞,比如一组图斑围住了一块其他属性的图斑,融合后中间天然就会有一块“空洞”,这在业务上是正常的。要注意的是别把这种空洞误判为 bug。

还有一种情况是组内图斑只在角点上相连,Dissolve 后生成了多部件面。代码里已经通过 length 过滤掉了共点不共边的情况,但如果你自己调整了参数把过滤关了,就很容易出现这类拓扑不干净的结果。如果下游系统要求每个要素必须是单部件,可以在 Dissolve 之后加一步 MultipartToSinglepart,把多部件打散,但这会改变要素数量,需要跟业务确认是否接受。

6.4 数据量超大时的内存与工作空间问题

10 万图斑用 in_memory 放邻接表完全没问题,但到了百万级,in_memory 表会占用不少内存。此时可以把邻接表输出到磁盘上的临时地理数据库里,ArcGIS 会自动管理页缓存,就不容易爆内存了。

并查集本身的内存开销很小。100 万元素的 parent 和 rank 两个 Python 列表,大概几十 MB,完全不用优化。真正的瓶颈在 UpdateCursor 回写字段和 Dissolve 融合上,这部分是 ArcGIS 引擎在跑,一般只能靠硬件解决。

6.5 结果质量校验三件套

跑完优化脚本别急着交差,我每次必做三重校验:

一是数量校验。旧方案合并前 103,856 个要素,合并后 71,524 个要素,减少的数量应该等于所有“发生合并的组里非首个图斑”的总数。这个数可以用 SQL 统计验证:GRP_ID 出现次数大于 1 的组,每组多出来的要素数相加,就是减少的要素数。

二是属性完整性校验。按 GRP_ID 分组统计,确认每个组内的原始属性值只有一种,不能出现“同一组里既有林地又有草地”的脏数据。这个检查用 Excel 打开 Dissolve 输出,或跑一段统计字段的脚本都行。

三是面积守恒校验。合并前所有图斑的 Shape_Area 总和,必须等于合并后所有要素的面积总和(多一点点面积差是正常的,一般来自坐标精度舍入,但误差超过 0.1% 就要回头查数据)。这是最硬的一条标准,能挡住绝大多数几何处理引入的意外。

7. 这套思路还能扩展到哪里

7.1 按面积阈值:凡合并后超过某面积的组不合并

有时候业务要求“小碎图斑合并,但大片地类不要动”。比如“面积小于 1000 平方米的碎图斑可以合并,面积大于 1000 平方米的大块保持独立”。这个需求在旧方案里写起来特别别扭,因为每合并一次都要重新判断面积。在并查集方案里反而简单:分组算完之后,不是立刻回写相同的组 ID,而是再扫一遍每个组的总面积,超过阈值的组直接拆开,让组内每个图斑各自拿一个独立组号。

这一步相当于给并查集加了一个后置校验条件,实现成本很低,但能在不改动任何几何操作的情况下灵活控制输出粒度。

7.2 多字段联合条件与自定义判定

碎图斑合并不一定只按一个属性字段。比如同时满足“地类相同 + 权属单位相同 + 坡度级相同”才合并,也很常见。实现上只需要把 attr_field 换成多字段的元组,union 的条件改成多个字段对应相等。

甚至可以把判定逻辑抽象出来,传入一个回调函数,比如“地类相同且两个图斑共享边长度超过某阈值才能合并”。这样的话,工具的可复用性会高很多,但 Python 里回调函数的传参方式对 ArcGIS 脚本工具不太友好,建议通过参数配置表达式实现,而不是真的塞一个函数进去。

7.3 从 ArcGIS 走向 QGIS/FME 的通用模式

这套“邻接表 + 并查集 + 一次融合”的思路并不绑定 ArcGIS,其他 GIS 平台同样适用。QGIS 里可以用 Topology 工具或 SAGA 的 Polygon neighbors 先生成邻接表,再在 Python 脚本里跑同一套并查集,最后用原生 v.clean/Dissolve 完成融合。FME 更是天然的流式处理场景,NeighborFinder 之后接一个 AttributeCreator 算分组号,再 Dissolve,完全对应得上。

所以这篇文章虽然全程拿 ArcGIS 举例,但方法论是通用的。理解“先算分组,再统一融合”这个概念,比记住某个具体工具的按钮位置有价值得多。

最后说一点我的个人体会。这次优化里,并查集反而只是锦上添花的加分项,真正让性能起飞的,是把“逐个操作图斑”变成“整批计算 + 批量融合”的思维转换。ArcGIS 给了我们非常多底层已经优化好的批量工具,但如果我们自己把流程设计成一次处理一个要素,那再强的引擎也白搭。遇到这种“跑一次要通宵”的工具,别急着花钱升级服务器,先回头看看流程本身是不是在做重复劳动。很多时候,删掉一个看似必要的循环,就是最好的优化。