ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

族谱关系建模:用图数据结构与BFS实现血缘路径查询

2026/9/9 10:56:08 拓冰建站 浏览量
族谱关系建模:用图数据结构与BFS实现血缘路径查询 家谱做到第三代Excel里那种层级缩进的表格基本就崩了。尤其是要把我妻子的弟弟的儿子这种关系也画进树里的时候你会发现树形结构根本没法表达——一个人只能有一个父节点挂不进去。后来我把族谱数据换成图来建模核心就是成员当节点、关系当边再跑一遍图搜索查两个成员之间的血缘关系路径一套系统才真正跑起来。这篇文章把我从建模、建图、路径查询到关系语义翻译走过的完整过程写出来包含可复现的数据结构设计思路和代码适合正在做族谱类产品、家谱App或者对图数据结构在实际场景中落地感兴趣的开发者参考。1. 为什么族谱关系必须用图来建模1.1 树形结构表达族谱的致命缺陷很多人一开始都觉得族谱就是一棵树祖先在根子子孙孙往下挂。这个直觉在直系世系表里勉强能用但只要牵扯到婚姻、兄弟姐妹、堂表亲树结构就崩了。原因在于树有两条硬约束每个节点只有一个父节点且节点之间只有父子链路。但真实血缘关系网络不是这样的。以最常见的场景为例两个表兄弟一个走父系一个走母系他们在树里完全没有共同路径可以汇聚如果再把嫁给叔叔的姐姐的女儿这种姻亲加进去树就彻底挂不住了。所以族谱数据的正确抽象应该是一个无向图或有向图成员是节点关系是边。一个成员可以有多个父节点生父母、养父母可以有配偶、兄弟姐妹、子女任意两个成员之间通过一系列边连通。血缘关系路径查询本质就是在这个图上寻找一条连接两个节点的路径。1.2 图模型中成员与关系的本质图模型 G(V, E) 放在族谱场景里V 就是每位家族成员E 是成员之间的关系。关系需要分类因为血缘关系路径的语义完全由边的类型决定父子关系包含父-子、母-子、母-女等有明确的代际方向配偶关系夫妻没有血缘但连接了两个家族分支收养关系法律上视同血亲遗传上没有视产品语义决定是否算血缘路径兄弟姐妹关系可由共同父母推导不一定需要单独存储再婚、继亲等复杂关系真实族谱里很常见需要额外标记我实际建图时的经验是不要单独存兄弟/姐妹这条边因为它能从共同父/母节点推导出来。如果存了反而会让图里多出来一堆冗余边路径查询时会出现大量重复结果。兄弟姐妹关系的本质是两个节点的父亲或母亲是同一个父节点让算法通过父节点去推导语义更干净。2. 血缘关系的数据结构设计节点、边与关系方向2.1 节点字段怎么设计节点存储成员实体信息最少要有这几个字段字段说明典型类型member_id成员唯一的ID建图索引用INT/LONGname姓名VARCHARgender性别称呼计算要用ENUM/MALE/FEMALEbirth_year出生年份推算长幼可用INTgeneration辈分编号根祖先为0往下递增INT这里 generation 特别关键。后面判断两个成员谁辈分高、翻译祖父/侄孙这类称呼时光靠路径遍历不够必须有一个代际参考系。你可以不用真实代数用一个相对值把已知最老祖先设为0他的子女是1孙子是2一路递增。这个字段在查询血缘关系路径时能直接给出辈分差省掉整条路径走完再做代数推断。2.2 边的类型与方向约定建图时每条边必须带类型和方向。我的做法是统一用邻接表每条边的数据结构包含关系类型和方向标记class Edge: def __init__(self, from_id, to_id, rel_type, direction_info): self.from_id from_id # 边的起点 self.to_id to_id # 边的终点 self.rel_type rel_type # parent-child / spouse / adoption 等 self.direction_info direction_info # 表示这条边从起点到终点的方向含义对父子/母子这种有方向的边我建议统一存两份父节点邻接表里有一条指向子的边子节点邻接表里也有一条指向父的边分别用方向标记区分。这样查询路径时无论从长辈往晚辈走还是从晚辈往长辈走都天然支持。对配偶这种无向边存储时加一条边的两端互指即可方向标记指向 spouse。2.3 邻接表建图的代码实现我实际用的核心数据结构和建图代码如下基于 Python 伪代码但结构可以平移到你熟悉的任何语言class Member: def __init__(self, member_id, name, gender, generation): self.member_id member_id self.name name self.gender gender self.generation generation class FamilyGraph: def __init__(self): self.members {} # 邻接表: member_id - list[tuple(邻居id, 关系类型, 方向)] self.adj {} def add_member(self, member): self.members[member.member_id] member self.adj.setdefault(member.member_id, []) def add_parent_child(self, parent_id, child_id): # 父(母) - 子的方向relation 记为 parent-child self.adj[parent_id].append((child_id, parent-child, child)) # 子 - 父(母) 的反射方向 self.adj[child_id].append((parent_id, parent-child, parent)) def add_spouse(self, m1, m2): self.adj[m1].append((m2, spouse, spouse)) self.adj[m2].append((m1, spouse, spouse))建图时有个关键坑单成员可能有继父/继母/养父母parent-child 边不能简单只加一条。我在给一个离婚重组家庭建模时就是同一节点挂了两条父子/母子边查询路径时必须靠关系类型区分否则路径语义会错。这也是为什么边必须带 rel_type不能只存一个邻接节点编号。3. 查询两位成员之间的血缘关系路径BFS的改造与正确用法3.1 为什么首选BFS而不是DFS在图里找两个节点之间的路径DFS 也能找到但它找到的第一条路径不一定最短。血缘关系的场景里我们通常想要的是最近的亲缘路径——比如两个人既是堂兄弟又是连襟现实中完全可能我们期望查询结果返回堂兄弟这条更近的血缘路径而不是先绕一大圈姻亲。BFS 的特性是逐层扩展第一次碰到目标节点时走过的边数一定是最少的。族谱图的规模通常不大一个家族几百到几千人BFS 在时间和空间上都不成问题。所以我首选 BFS 搜最短路径DFS 只用于找出所有可达路径这种少数场景。但这里有个重要改造点BFS 默认是把所有边等权对待而族谱查询必须区分血缘边和姻亲边。最简单有效的做法是给 BFS 加一个边类型过滤器默认查询只走血缘相关的边parent-child、adoption把 spouse 边直接跳过除非你需要找通过婚姻连接的两家人这类跨家族路径。3.2 带方向约束的路径搜索族谱图虽然建的是双向可达的邻接表但搜索时并不说所有方向都算血缘路径。举个例子A 和 B 是叔侄路径是 A - 父亲 - 兄弟 - B。这里中间节点是 A 的父亲的兄弟也就是 B 的父亲。方向转换点在共同祖先那里。所以搜索时必须允许路径方向发生先上行、后下行或先下行、后上行的转换但不能允许无意义的来回横跳。我实现的时候没有在 BFS 里对方向做复杂约束只要求一条路径中相邻两步不要立刻反向。这个约束在生成边的时候就已经隐含满足邻接表里不会出现 A - B - A 这种二连跳因为 visited 集合已经挡住了。真正需要约束的是搜索允许经过的边类型这个用过滤器控制。3.3 完整路径搜索实现from collections import deque BLOOD_RELATIONS {parent-child, adoption} # 血缘边类型 SPOUSE_RELATION {spouse} def find_shortest_blood_path(graph, start_id, target_id, allow_spouseFalse): if start_id not in graph.adj or target_id not in graph.adj: return None visited {start_id} queue deque([(start_id, [])]) while queue: current, path queue.popleft() if current target_id: return path for neighbor, rel_type, direction in graph.adj[current]: if not allow_spouse and rel_type not in BLOOD_RELATIONS: continue if neighbor in visited: continue visited.add(neighbor) queue.append((neighbor, path [(current, neighbor, rel_type, direction)])) return None如果 allow_spouseFalse这个函数返回的就是纯血亲路径如果 allow_spouseTrue它会找到最短路径但要靠方向信息和 relation 来判断语义。实测一个几百人的家族图谱BFS 基本都是毫秒级返回不需要额外优化。4. 从路径到亲缘称呼血缘关系语义判定4.1 路径上边的组合如何翻译拿到一条路径比如 A - B - C - D - E原始数据是节点 ID 列表和边关系列表。这一步要做的是把路径翻译成可读字符串比如A 是 E 的姑奶奶这种人类能懂的结果。翻译规则是逐段分析路径的方向。基本思路从起点 A 出发沿着路径走每一条边要么是上行走向父辈/祖父辈要么是下行走向子辈/孙辈。上下行方向的变更点就是旁系关系的共同祖先或共同后代。用一个生活化类比路径就像在爬楼梯上行等于往楼上的长辈层走下行等于往楼下的晚辈层走。如果全程都是上行A 就是 E 的祖先如果全程都是下行A 是 E 的后代如果先上行再下行中间拐点就是那个分叉点通常是共同祖先两边下来的分支是旁系。4.2 直系与旁系、辈分差的计算判断直系还是旁系看路径里方向是否发生过逆转方向序列全部一致全上行或全下行 直系血亲方向序列出现上行转下行或下行转上行 旁系血亲方向序列出现两次以上逆转 关系更远但语义还是旁系只是分叉点在更上方/更下方辈分差可以直接用 generation 字段计算辈分差 generation(A) - generation(B)正数说明 A 比 B 高一辈负数说明低一辈。这一步配合 BFS 路径长度基本能把关系定级。需要说明的是世代差和路径长度是两回事。比如 A 和 B 是堂兄弟路径可能是 A-父-祖父-伯父-B一共4条边但 generations 差可能只有0。不能只靠步数判断必须结合方向序列。4.3 婚姻边与旁系血缘的组合处理族谱中大量的舅公姑婆表叔这些称呼都隐含了一条姻亲连接链。以我舅舅的儿子为例从我出发先上行到我母亲再由母亲走 sibling 关系即通过母亲的父亲/母亲作为共同父节点跳到舅舅最后下行到舅舅的儿子。这一整段路径里如果不开 allow_spouse仍然能走通因为核心链路由血缘边构成。但如果查询我妻子的侄女那就必须走 spouse 边了我 - 妻子 - 妻子的兄弟 - 妻子的兄弟的女儿。这种路径和纯血缘路径语义不同我在产品里会明确标注姻亲路径。我的建议是默认查询只返回纯血亲路径姻亲路径作为独立选项避免用户混淆。5. 工程化落地大数据量、环与性能优化的实操问题5.1 环结构与复杂婚姻关系会不会把 BFS 打崩真实族谱里存在亲上加亲的情况比如表哥娶表妹会造成图里形成环。BFS 的 visited 集合天然防环不会死循环。但它的副作用是BFS 只会返回最先到达目标节点的一条最短路径如果存在多条同长度路径只会返回其中一条。对血缘关系查询来说这通常就是想要的答案因为最近血缘关系有唯一性现实中几乎没有完全等距的双重亲缘关系需要并列展示。如果产品需要展示所有可能路径就得放弃 visited靠限制深度上限比如 max_depth8来防爆。在几百人的族谱里不加 visited 地暴力搜索是灾难节点会反复进入路径数量呈指数爆炸。我的经验是普通用户查询只返回最短路径深度限制在6步以内输出路径可读性也最好。超过6步的旁系用户几乎不会关心具体路径叫什么。5.2 性能优化双向BFS与辈分剪枝如果不做任何优化BFS 在几千人的图上也只是毫秒级。但考虑到线上服务可能被高频调用我还是做了两个优化一个是双向 BFS。从起点和终点同时做 BFS每轮扩展更小的一边等两边访问集合有交集就找到路径。对族谱这种直径很短的图平均路径长度2~4双向 BFS 能减少一大半探索量。另一个是辈分剪枝。查询前先比对 target 和 start 的 generation 差。如果两部跨度超过5代大概率不是用户关心的近亲可以直接判定为关系较远没必要继续搜索。这个剪枝在巨大族谱里跨十几代那种能挡住很多无效查询。5.3 关系方向的一致性与数据落库项目里最容易出错的地方不是算法而是数据录入的半结构化问题。有人把继父也录成 parent-child有人把岳父录成 parent-child导致路径上出现假血缘。我的解法是关系类型枚举严格限定parent-child 只能用于生物学父母或法律收养姻亲一律用 spouse 边连接对于岳父这类关系需要通过 spouse 边跳两次才能到达而不会污染血缘主链。数据最终落库时我用的是关系表 邻接表双写关系表面向审计和维护邻接表面向查询。每次有新增成员或修改关系就异步重建整图或增量更新邻接表。对这种规模的数据不用上太复杂的图数据库关系型数据库存节点和边内存里跑 BFS成本最低服务也最稳定。最后说一个我踩过的特别值得注意的坑查询算法本身很好写难的是你怎么定义血缘。如果你没有在一开始就把边类型、方向、姻亲策略定清楚后面做出来的路径查询一定会出现查出了姻亲而不是血缘、称呼翻译对不上这些看似是算法 bug 实则是模型 bug 的问题。把这个想明白了族谱成员关系的路径查询就成功了一半。