离婚协议这种人间烟火气最重的事情,居然能用递归函数写出来,我第一次听到这个想法的时候也觉得有点离谱。但仔细一想,作为软件测试出身的工程师,这类“把模糊的现实决策转成精确的计算规则”的问题恰恰是最有意思的挑战。抚养权分割本质上是一个多因素、多约束的组合分配问题,而递归这种“把一个大规模问题拆成结构相同的小问题”的思维,天然就适合建模这类分配逻辑。这篇文章我想从一个软件测试工程师的视角,把整个“递归分割孩子”的建模、实现、鲁棒性设计和测试过程完整讲一遍。内容覆盖核心算法、边界条件、失败注入测试,以及我踩过的几个真实的坑。如果你对算法设计、测试思维、或者“技术如何介入人文决策”这个话题感兴趣,这篇文章应该能给你一些不一样的东西。
1. 问题剖析:为什么“分割孩子”会被我建模成递归问题
1.1 一个容易被误读的“冷酷”需求背后,其实是抽象建模
很多程序员听到“用代码写离婚协议”这种需求,第一反应是“这也太没人情味了”。但实际上,把现实世界的决策规则变成可执行的代码,不是什么冷血行为,而是抽象建模能力的一种体现。抚养权分配涉及的因素极多:孩子的年龄、居住环境稳定性、教育资源的匹配度、父母双方的经济能力和陪伴时间、孩子本人的意愿,甚至还有地域因素。如果我们要做一个辅助决策工具,就必须把这些因素转化为可量化的输入,然后沿着明确的规则去计算。
这里要强调一点:代码只是辅助决策的工具,不是替代法官或当事人做最终判断。我参与过类似的项目后最深的感觉是,算法的价值不是给出一个“正确答案”,而是把所有可行方案和它们的量化指标摊开,让决策者看到一个全局的图谱。有了这个图谱,讨论的焦点就从“我觉得应该怎么分”变成“为什么这个方案在这些指标上更优”,沟通效率会高很多。
1.2 递归的核心思想:把“n个孩子怎么分”拆成“一个孩子怎么分 + 剩下n-1个孩子怎么分”
为什么这个问题适合用递归而不是迭代?最根本的原因是:我们事先不知道有多少个孩子,也不知道每个孩子的归属会对整体分配产生什么连锁影响。递归的思路非常直白——我先把第一个孩子分配给父亲,然后递归处理剩余的孩子;再把第一个孩子分配给母亲,再递归处理剩余的孩子。当剩余孩子数量为0时,就得到了一套完整的分配方案。整个过程其实就是在一棵二叉树上做深度优先遍历,树的每条从根到叶子的路径都代表一个完整的分配方案。
这种拆解方式比起嵌套n层for循环要优雅得多,因为你不需要在编码的时候就知道n是多少。写得好的递归函数,读起来就是一段逻辑自描述的代码:终止条件、状态更新、递归调用、状态恢复。每一步都对应着现实世界里的一个决策步骤。
1.3 定义清楚输入、输出和评估函数,是算法能否落地的关键
编程的第一课就是“先定义数据结构,再写逻辑”。在这个项目里我定义了这样几个输入:
- 每个孩子的唯一标识(不要用姓名,因为可能出现双胞胎同名的情况,要使用ID)
- 孩子的年龄
- 孩子与父亲、母亲分别的“综合匹配度”评分,0到100分
- 孩子的意愿倾向(只支持 father / mother / none 三种)
- 父母双方的“抚养容量”,比如经济能力、居住面积、可投入时间等,同样是0到100分
输出是一个分配方案:每个孩子最终归父亲还是母亲。但单纯输出一个方案还不够,必须同时输出这个方案的两个关键指标:双方总分差和偏好满足度。前者用于衡量“分得是否公平”,后者用于衡量“孩子意愿是否被尊重”。两个指标必须同时展示,因为只追求公平可能会忽视孩子本人的感受,而只追求偏好满足则可能把资源压力全压在其中一方身上。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心算法实现:递归枚举与多方案择优
2.1 数据结构与输入约定
我用了Python来做原型,主要是它的表达力强,适合快速验证想法。数据结构就是一个字典列表,每个孩子一个字典:
python复制children = [
{"id": "C001", "age": 8, "father_match": 85, "mother_match": 78, "preference": "father"},
{"id": "C002", "age": 12, "father_match": 72, "mother_match": 88, "preference": "mother"},
{"id": "C003", "age": 5, "father_match": 90, "mother_match": 91, "preference": "none"},
]
father_capacity = 80 # 父亲的抚养综合容量
mother_capacity = 75 # 母亲的抚养综合容量
这里的 father_match 表示孩子与父亲共同生活的综合匹配度,由教育环境、亲属支持度、生活习惯匹配等子项加权计算得到。father_capacity 是父亲这方可投入的抚养综合能力。二者是不同维度的数据,在评估时需要分开考虑。
2.2 递归枚举所有分配方案
核心枚举逻辑我封装成了一个生成器,用 yield 逐个返回完整方案,避免把所有方案都堆在内存里:
python复制def enumerate_assignments(children):
n = len(children)
assignment = [0] * n # 0 表示父亲,1 表示母亲
def dfs(idx):
if idx == n:
yield tuple(assignment)
return
assignment[idx] = 0
yield from dfs(idx + 1)
assignment[idx] = 1
yield from dfs(idx + 1)
yield from dfs(0)
这里有一个关键细节:assignment[idx] 在递归返回后会被下一次赋值覆盖,所以不需要显式“恢复现场”。但如果你用的是列表拼接的方式,比如 dfs(idx + 1, current + [0]),每次递归都会生成新列表,虽然不需要恢复现场,但会创建大量中间对象。对于孩子数量很少的场景问题不大,但孩子数量到一定程度后,内存会非常难看。
我自己写demo的时候用了“原地修改”的方式,因为它快,但我心里始终记着:如果哪天真要接入异步或并行逻辑,原地修改的共享状态会是个隐患。
2.3 评估函数:公平性指标与偏好满足度怎么算
拿到一套分配方案之后,我要分别计算父亲侧和母亲侧的总分。父亲侧总分由两部分构成:分配给父亲的所有孩子的 father_match 之和,再加上父亲容量分;母亲侧同理。
python复制def evaluate(children, assignment, father_capacity, mother_capacity):
father_total = father_capacity
mother_total = mother_capacity
preference_score = 0
for child, owner in zip(children, assignment):
if owner == 0:
father_total += child["father_match"]
else:
mother_total += child["mother_match"]
if child["preference"] == "father" and owner == 0:
preference_score += 1
elif child["preference"] == "mother" and owner == 1:
preference_score += 1
gap = abs(father_total - mother_total)
return gap, preference_score
之所以要把 capacity 加进来,是因为现实中父母双方的起点不一样,一个抚养容量明显偏低的父亲,即使分给他更多孩子,孩子在这边的综合成长条件也很可能不如另一方。这个模型虽然做了一定简化,但方向是对的。
2.4 择优标准:不能只看“均衡”,还要看“意愿满足”
接下来就是遍历所有方案,选出最好的一套。我这里采用了一个加权综合分:综合分 = 1.0 * 偏好满足度 - 0.3 * 归一化差距。差距越小越好,偏好满足越大越好,所以是负号。系数0.3是我拍脑袋定的,实际项目中应该通过历史案例调参,或者配置成可调整的策略参数。
python复制def best_scheme(children, father_capacity, mother_capacity):
best = None
best_score = float("-inf")
for assignment in enumerate_assignments(children):
gap, preference = evaluate(children, assignment, father_capacity, mother_capacity)
norm_gap = gap / max(1.0, sum(c["father_match"] for c in children))
score = 1.0 * preference - 0.3 * norm_gap
if score > best_score:
best_score = score
best = (assignment, gap, preference, score)
return best
这里用 max(1.0, ...) 做分母保护,是为了防止所有孩子的 father_match 之和为0时出现除零错误。这种细节写代码的时候很容易忽略,但恰恰是鲁棒性的起点。
3. 鲁棒性设计:真实环境里程序不崩,靠的是这些细节
3.1 输入校验:把脏数据拦在函数外面
程序写得再漂亮,如果输入的是脏数据,一样会出幺蛾子。我后来养成的习惯是:任何面向外部输入的函数,第一步永远先做校验。对于一个抚养权分配函数,要处理的异常输入包括:
- 孩子ID缺失或重复
- 年龄为负数
- 匹配度评分超出0到100的范围
- 偏好字段不是 father / mother / none
- 父母容量为负
- 孩子列表为空
校验逻辑我通常放在一个独立的函数里,不污染核心递归逻辑:
python复制def validate_input(children, father_capacity, mother_capacity):
if not isinstance(children, list):
raise TypeError("children must be a list")
seen_ids = set()
for child in children:
if not isinstance(child, dict):
raise TypeError("each child must be a dict")
if "id" not in child:
raise ValueError("missing id")
if child["id"] in seen_ids:
raise ValueError(f"duplicate id: {child['id']}")
seen_ids.add(child["id"])
if not isinstance(child.get("age", 0), int) or child["age"] < 0:
raise ValueError(f"invalid age for {child['id']}")
for key in ["father_match", "mother_match"]:
val = child.get(key)
if not isinstance(val, (int, float)) or val < 0 or val > 100:
raise ValueError(f"invalid {key} for {child['id']}")
pref = child.get("preference")
if pref not in ("father", "mother", "none"):
raise ValueError(f"invalid preference for {child['id']}")
if father_capacity < 0 or mother_capacity < 0:
raise ValueError("capacity must be non-negative")
这段代码看起来琐碎,但它是整个程序鲁棒性的第一道防线。我见过太多线上事故,来源就是“上游传了一个空值”“某个字段被误改成字符串”“ID重复导致后面的逻辑错乱”。校验不是形式主义,它是在给后面的所有逻辑一个安全前提。
3.2 特殊场景一:空列表、单孩子、双胞胎同名
这三种情况都是典型的边界条件。
空列表的情况,我在评估函数里返回的是一个“空方案”,此时 gap 为0,偏好满足度为0。调用方必须有能力区分“没有孩子可分配”和“分配失败”,否则后续逻辑会把它误判成“完美方案”。我的做法是让 main 函数在 children 为空时直接返回一个携带警告标记的结果对象。
单孩子的情况最简单,不用走递归,直接比较 father_match 和 mother_match,加上 preference 做最终判断。但我不会为了这个特例单独写分支,因为递归函数天然支持单孩子的情况——递归到 idx==n 时直接产生方案,两条路径各计算一次,谁更优谁胜出。所以这个特例不需要特殊代码,只需要测试覆盖。
双胞胎同名的情况好多人会踩坑。我用ID不用姓名作为主键的原因就在这里——假设两个孩子都叫“张小明”,如果程序里以姓名为键,后续评估、输出、追溯都会混乱。ID的设计不复杂,用 C001、C002 这类自增编号就够了,但这个约定能让数据模型稳定很多。
3.3 特殊场景二:多方案得分完全相同时,如何保证输出唯一
真实数据里经常出现并列分数,比如两个孩子时,父亲各带一个孩子和母亲各带一个孩子,两个方案的 gap 相同,偏好满足度也相同。这时候如果没有一个明确的tie-break规则,程序每次跑出来的结果可能不一样。对于测试来说,这是致命的——今天通过、明天失败,你根本分不清是功能出问题还是纯粹随机性导致的。
我的解决方案是“确定性tie-break”。在得分相同的情况下,优先选择编号更小方案中“第一个孩子归父亲”的方案。实现时给每个方案算一个“排序键”,用它作为次级排序依据:
python复制def tie_break_key(assignment):
# 把 assignment 元组直接转为二进制串,0在前,1在后
return tuple(0 if owner == 0 else 1 for owner in assignment)
然后 best_scheme 在 score 相同时比较 tie_break_key。这样就保证了无论什么时候运行,输出的都是同一个结果,测试的稳定性也随之而来。
3.4 特殊场景三:递归深度与栈溢出问题
Python的默认递归深度限制通常是1000。对于抚养权分配这种场景,单亲家庭孩子数量超过20个已经非常罕见了,哪怕算上最极端的“十个孩子”,2的10次方也才1024种方案,递归深度也远远不会碰到天花板。但作为软件工程师,心里要有这根弦:如果这个函数被复用到一个“孩子数量不设上限”的场景,比如批量处理多个家庭的导入文件,就可能在某个家庭有几百个孩子时直接触发 RecursionError。
我的处理方式分两级。第一级,在函数入口显式检查 len(children),超过一个阈值(比如20个)就直接抛异常,提示调用方当前算法不适合超大量输入。第二级,如果确实有处理较大规模数据的需求,递归要改写成显式栈的迭代回溯:
python复制def enumerate_assignments_iter(children):
n = len(children)
assignment = [0] * n
stack = [0]
while stack:
idx = stack[-1]
if idx == n:
yield tuple(assignment)
stack.pop()
if stack:
assignment[stack[-1]] = 1
continue
if assignment[idx] == 0:
assignment[idx] = 1
stack.append(idx + 1)
else:
stack.pop()
if stack:
assignment[stack[-1]] = 1
这段代码读起来没有递归版本那么直观,但它禁得住大规模输入。我在代码注释里写清楚了“这是递归版的安全替代”,这样子后续维护的人不至于一脸懵。
4. 软件测试视角:我如何给这棵递归树“找茬”
4.1 测试设计的第一步:等价类划分与边界值分析
面向这类决策型算法,测试设计的核心不是“写几个断言就好”,而是先做等价类划分,把无限输入域切成有限个有代表性的类别。我的划分方式是:
| 等价类 | 具体用例 | 预期结果 |
|---|---|---|
| 常规两孩,无偏好冲突 | 两个孩子分别倾向父母,评分均衡 | 返回按评分与偏好综合的最优方案 |
| 偏好明显冲突 | 两孩都倾向母亲,但父亲评分更高 | 结果需体现加权规则,不盲目跟随偏好 |
| 评分极端 | 某孩子父亲评分100,母亲评分0 | 该孩子明显倾向于父亲 |
| 单孩子场景 | 只有一个孩子,父母评分接近 | 必须返回一个明确归属 |
| 空列表 | 无孩子 | 返回空方案并带上警告标记 |
| 非法输入 | 评分超过100、年龄负数、ID重复 | 抛出清晰异常 |
等价类划分的价值在于,它能帮你用最少的人力覆盖最多的逻辑分支。边界值分析则是把注意力集中在“正好等于边界”的输入上:0个孩子、1个孩子、评分100、评分0、容量0。这些位置正是bug最容易滋生的地方。
4.2 针对递归函数最容易踩的坑:终止条件与状态回溯
递归函数写多了之后,我总结出三个高频bug点:终止条件写错、递归调用参数传错、状态回溯遗漏。其中“状态回溯遗漏”是最隐蔽的,因为程序不会报错,只是结果不对。
我实际犯过一个经典错误,写的是这样的代码:
python复制def dfs(idx, current):
if idx == n:
yield current
return
dfs(idx + 1, current + [0]) # 漏了 yield from
漏了 yield from,导致递归调用被创建成生成器对象后直接丢弃,函数实际上只返回了“第一个孩子归父亲”的那条路径,后面的方案全部丢失。这个问题在n=1时不会暴露,因为第一个方案就是唯一方案;n=2时就开始漏方案;n=3时结果就是错的。我当时是写了一个“穷举所有方案并和 itertools.product 结果对比”的测试用例,才把这个bug抓出来。从那以后我坚定了一个信念:递归和生成器组合使用的时候,必须检查每个递归调用是否把结果正确地向上一层传递。
4.3 用失败注入和随机属性测试给递归逻辑“上强度”
除了手写的测试用例,我强烈推荐给这类算法加随机属性测试。属性测试的核心不是验证某个具体输入的具体输出,而是验证“无论什么输入,输出必须满足哪些恒成立的不变量”。
对于这个抚养权分配算法,我定义了这样几个不变量:
- 方案长度必须等于孩子数量
- 每个孩子只属于父亲或母亲中的一方,不存在漏分或重复分配
- 父亲侧总分和母亲侧总分都必须是非负有限数值
- 方案必须满足输入的合法性校验
我写了一个不依赖第三方库的简单随机测试脚本:
python复制import random
def random_children(n):
children = []
for i in range(n):
children.append({
"id": f"C{i:03d}",
"age": random.randint(0, 18),
"father_match": random.randint(0, 100),
"mother_match": random.randint(0, 100),
"preference": random.choice(["father", "mother", "none"]),
})
return children
for trial in range(1000):
n = random.randint(0, 8)
children = random_children(n)
fc = random.randint(50, 100)
mc = random.randint(50, 100)
result = best_scheme(children, fc, mc)
if result is not None:
assignment, gap, pref, score = result
assert len(assignment) == n
assert all(x in (0, 1) for x in assignment)
assert gap >= 0
assert score == 1.0 * pref - 0.3 * gap / max(1.0, sum(c["father_match"] for c in children))
这个脚本跑1000次随机输入,能在几秒内完成,但覆盖的场景可能比手写100个固定用例都广。在我实际的使用过程中,这种测试真实抓到过一次问题——当某个孩子的 father_match 为0、其他孩子的 father_match 之和也为0时,归一化里出现了除零风险。虽然不是运行时崩溃,但结果里会出现 inf,这种问题在常规用例里极难碰到。
4.4 回归测试:需求从“最大化公平”变成“偏好优先”时怎么办
软件测试里最经典的问题之一就是需求变更带来的回归风险。假设最初的产品规则是“优先最大化公平”,后来运营反馈说“孩子意愿应该更受重视”,把权重系数从0.3调成0.8。这个改动看似只是改一个常量,但如果没有回归测试,你无法保证旧场景不受影响。
我的做法是维护一个固定场景的“快照测试”:
python复制def test_fairness_snapshot():
children = [
{"id": "C1", "age": 6, "father_match": 90, "mother_match": 60, "preference": "father"},
{"id": "C2", "age": 10, "father_match": 50, "mother_match": 80, "preference": "mother"},
]
assignment, gap, pref, score = best_scheme(children, 80, 75)
assert assignment == (0, 1) # C1归父亲,C2归母亲
当需求变化导致这个断言失败时,我们面对的不是“程序坏了”,而是“产品规则变了”。这时候要做的不是急着改代码,而是组织相关人员评估新规则下这个旧场景是不是本来就应该不同。快照测试在这里起到了“需求变更报警器”的作用,保证每个行为变化的来龙去脉都可追溯。
5. 算法之外的思考:代码能给出的,恰恰是决策的边界
5.1 为什么这类代码不能直接替代现实决策
把一个涉及孩子未来的重大决定交给一段递归函数,显然是有问题的。不是因为代码写得不够好,而是因为现实世界里最关键的信息很难被量化。孩子对父母的依恋程度、父母双方的沟通意愿、家庭成员之间的情感纽带,这些信息即使被打成0到100的分数,也会损失大量语义。而且法律的裁量过程包含对个案具体情境的权衡,这种裁量权需要人来行使。
所以我始终坚持一个原则:算法输出的不是“判决”,而是“决策辅助材料”。它能让用户站在全局视角看到所有可行方案及量化指标,但最终的选择必须由人来做。这个边界在项目一开始就要明确,否则后续很容易被误用。
5.2 我从中获得的可迁移能力:抽象建模、测试思维、递归直觉
这个项目做完之后,我最大的收获不是“会写递归”,而是“把模糊问题变成可计算问题的能力”。任何问题放在面前,先问三个问题:输入是什么?输出是什么?评估好坏的标准是什么?只要把这三个问题想清楚,代码写起来就是水到渠成的事。
测试思维也是同样的道理。不要假设输入是完美的,不要假设上游不会传脏数据来,不要假设所有字段都有值。带着这种“怀疑一切输入”的心态写代码,鲁棒性会自然地上一个台阶。
5.3 如果要继续扩展,我会往这几个方向走
这个项目虽然只是个原型,但扩展空间其实不小。我会优先做三件事:
第一,把评分规则从硬编码改成策略模式,让使用者可以灵活定义自己的指标项和权重。不同家庭、不同地区、不同文化背景下,对“匹配度”的理解差异很大,一个可配置的规则引擎比写死的逻辑更实用。
第二,加可视化。把每个孩子和父母的匹配雷达图、不同分配方案的总分对比图直接展示出来,比纯数字更有说服力。
第三,封装成HTTP服务。用 FastAPI 写一个接口,输入JSON数据,输出推荐方案和可选方案列表。这样前端、报表、甚至移动端都能直接调用。
如果它变成一个真实交付的项目,我还会加入更细粒度的监控和审计日志。每一次谁用什么参数跑了什么结果,都要记录下来,因为这不仅是决策辅助工具,还是可追溯的决策依据来源。
最后分享一个我在这类项目里踩过的真实坑:第一次写完所有逻辑的时候,我信心满满地跑了一个“安全空列表”的测试,结果程序直接抛了 IndexError。原因是枚举函数在n=0时压根就没有产出任何方案,而 best_scheme 没有处理“无方案可选”的情况。修复很简单,在 best_scheme 入口加一个空列表判断。但这件事让我彻底记住了:永远不要高估自己对边界情况的掌控力,测试不是为了证明程序不会出错,而是为了在出错之前找到那些“还没想过”的场景。
