用Python模拟退火解决婚礼排座难题:组合优化实战 参加过几次婚礼你就会发现最让新人头疼的往往不是订酒店也不是选菜单而是排座位。30 桌客人300 多号人谁和谁关系好得放一桌谁和谁绝不能碰面带孩子的需要安排在哪长辈离主桌多远合适——这些需求叠在一起光靠 Excel 手工拖拽一顿饭的功夫都未必理得清楚。这个场景本质上是典型的组合优化问题在有限的桌位容量下把一组带约束的“对象”分配到若干“容器”里同时最大化“满意程度”。用 Python 来解这件事比想象中简单得多。我在实际帮朋友处理婚礼座位时就用一套“贪心初始解 模拟退火局部优化”的思路把整个排座过程从半天压缩到了几秒钟。这篇博文就把当时的方法、代码和踩过的坑完整记录一遍适合正在筹备婚礼、想帮朋友写个小工具或者刚学 Python 想找一个真实可落地的实战项目的读者。不要求你有多少数学基础中学概率论的接受能力就够用。1. 把“安排座位”翻译成数学问题1.1 婚礼座位为什么比会议排座难很多第一次接触这个需求的人会想按单位、按部门一桌桌切不就行了在实际操作里婚礼的座位约束远比会议复杂而且大部分约束是“软”的不是“硬”的。会议的约束通常很清晰同部门尽量同坐领导坐前排人数按名单走。但婚礼上的信息往往是模糊的——两位新人各自有大量亲友这些人彼此不认识男方父母的老同学可能和新娘的小学同学有旧怨几位长辈年纪大了需要靠近出入口但不能正对着音响带小孩的桌最好离舞台远一点否则孩子一哭就打断仪式。这些需求可以分成两类一类是“必须满足”的硬约束比如每桌人数不能超过容量、每位客人必须且只能安排一个座位另一类是“尽量满足”的软约束比如亲友之间的亲密度、回避名单、位置偏好。硬约束决定了一个方案“能不能用”软约束决定了方案“好不好”。1.2 用变量、约束和目标函数还原真实需求要写代码第一步就是把口语化的需求翻译成数学表达。我当时是这么定义的宾客集合 G共 n 人桌集合 T共 m 桌每桌容量 C比如常规 10 人就近安排。决策变量 x[i][j]取值 0 或 11 表示第 i 位宾客被安排到第 j 桌。硬约束任一人只能坐一桌每行和为 1任一桌人数不超过容量且不低于某个下限比如不能一桌只有 3 个人否则酒店也不同意。软约束需要打分。我给每一对宾客之间的“同坐满意度”打一个分 s[i][j]正数代表想坐一起负数代表不想坐一起。如果想表达“绝对不要同坐”就在惩罚里把权重拉高到让人无法接受的程度。这样一来目标函数就变成了一个简单的求和所有桌内宾客两两之间满意度的总和最大化这个值。换一种等价说法是把所有负向关系看成分数惩罚目标变成“最小化总惩罚”。这个建模方式非常直观而且后续扩展也很方便新增一种约束本质上就是在惩罚函数里加一项。1.3 一个可以用生活经验验证的底层结构如果对组合优化没概念可以把这个问题理解成一个“换座游戏”一开始你随便把人都丢进各桌肯定有人坐得不舒服。然后你不断尝试把两个人互换每换一次就算一下总惩罚是变大了还是变小了。如果换完变好了就保留变差了就以一定概率保留——这个“一定概率”是精髓它让方案有机会跳出局部最优。后面讲模拟退火时会详细说。这种思路不只在婚礼上有用。考试排考场、公司团建分组、拼车派单、甚至 CPU 任务调度底层都是同一个模型有限资源、多个对象、成对关系约束、最大化整体满意度。把婚礼做明白了以后遇到类似的分配问题你会有一种“天下没有新鲜事”的感觉。2. 方案选型不是一上来就上遗传算法2.1 常见求解方案横向对比在我动手之前先梳理了常见方案因为不同量级的婚礼规模适合的算法差别很大。方案思路优点缺点适用规模纯贪心按组切块先分大簇再填零头速度快、代码短容易陷入局部最优软约束基本照顾不到100 人以下且关系简单回溯搜索逐个宾客尝试所有桌位不满足就回退能精确找最优解组合爆炸人数一多就跑不动30 人以下整数规划求解器建模成数学规划交给 OR-Tools 等求解器约束表达能力强、解质量高需要额外安装库初学者有上手成本300 人完全可以跑遗传算法/模拟退火随机搜索 迭代优化能处理任意复杂的软约束、实现灵活参数需要调不一定每次最优几百到几千人婚礼常见规模是 20 到 40 桌也就是 200 到 400 位宾客这个量级对混合整数规划求解器来说非常轻松。如果你是学运筹学的直接用 OR-Tools 的 CP-SAT 求解器会非常标准下面我也会提到。但我当时考虑的是这个脚本要发给朋友自己用对方电脑上未必装了依赖最好只靠 Python 标准库就能跑起来。所以我最终选了“贪心初始解 模拟退火”的方案这样无论 Windows 还是 macOS只要装了 Python 就能直接跑不额外折腾依赖。2.2 为什么“贪心初始解 局部搜索”最顺手核心原因有三个第一婚礼座位的约束大多是软性的局部搜索很适合处理这类“不求全局最优但求大家满意”的场景第二数据规模不大模拟退火跑个几千次迭代也就几秒钟第三代码逻辑透明出了问题可以一步步打印看新人也能看懂在干嘛。另外把“贪心”只放在初始解阶段而不是作为全程策略是为了利用它快速生成一个“大体合理”的方案。比如姑妈一家五口尽量放同一桌同事组尽量别切散这些工作贪心算法很擅长。但贪心的问题在于“先来后到”先安排的人把好位置占了后面的人只能将就。这时候用模拟退火做局部交换就能把贪心方案的粗糙之处慢慢打磨润色。2.3 分值权重设计把“绝对不能”和“尽量别”分开这是整个项目里最容易踩坑的地方。很多第一次做的人会把所有关系都变成一个等级的惩罚结果要么把所有客人都拆得七零八落要么就是灾难性组合怎么也拆不掉。我当时的设计原则是把“绝对无法接受同坐”的惩罚设成一个天文数字比如 10000 分把“不太合适同坐但还能接受”的惩罚设成 5 到 20 分把“想坐一起但拆开也没关系”的奖励设成 0 到 10 分。这样模拟退火在优化时会先想尽办法消除那些巨额的硬性惩罚再慢慢优化其他软性目标——权重差距足够大就自然形成了优先级顺序。这个思路也可以迁移到很多日常场景里比如排值班表时长和规则的关系、安排工位时团队聚集与个人偏好的折中原则都是“分层设权重先满足强约束再优化弱目标”。3. 动手写一个能用的婚礼座位安排脚本3.1 先把客人数据整理成结构化的 CSV写算法之前最花时间的其实是数据准备。当时我列了一个 CSV字段如下name客人姓名group所属分组比如“男方亲友”“女方同事”“新郎发小”“新娘闺蜜”等table_pref如果新人已经提前内定了某位客人必须坐哪桌就填桌号填 0 表示不限制avoid和谁不能同坐用姓名逗号分隔没有就留空with_必须和谁同坐用姓名逗号分隔比如一家人一般要强制放一起need_high_chair是否需要儿童座椅现场安排时座位要预留空间我实际整理时发现新人对宾客的了解程度远比自己想象得少。一提到“谁和谁关系好”新人能说出几对但说不出全貌。所以我做了一个“默认分组亲密度”的假设同一 group 的人默认有基础同坐分比如 8 分不同 group 但都是新郎朋友的默认 2 分不同 group 且来自不同关系圈的默认 0 分。然后我打开一个微信备注表手动把一些特殊关系对加进 CSV比如两人是前同事想叙旧加一条 with_ 关系两人曾经闹过矛盾没法碰面加进 avoid。这样处理起来比手填一个完整关系矩阵省力很多。3.2 核心数据结构和准备函数代码按下面的思路组织。我在这里给出一个简化但完整的版本读者可以在此基础上扩展。import csv import random import math from collections import defaultdict class Guest: def __init__(self, name, group, avoidNone, with_None, must_table0, need_high_chairFalse): self.name name self.group group self.avoid set(avoid.split(,)) if avoid else set() self.with_ set(with_.split(,)) if with_ else set() self.must_table must_table self.need_high_chair need_high_chair def load_guests(path): guests [] with open(path, encodingutf-8-sig) as f: reader csv.DictReader(f) for row in reader: guests.append(Guest( namerow[name].strip(), grouprow[group].strip(), avoidrow.get(avoid, ), with_row.get(with_, ), must_tableint(row.get(table_pref, 0)), need_high_chairrow.get(need_high_chair, False) True )) return guests这里有个小细节CSV 文件用utf-8-sig编码打开是为了兼容 Excel 导出的文件在 Windows 下常见的 BOM 头不然第一列字段名可能变成\ufeffname后面读取天天出 bug。3.3 构建关系矩阵和惩罚函数我设定了三个基础分同桌基础分两个客人同一 group 得 8 分同一大阵营比如都是男方亲友得 5 分否则 0 分。强制同桌关系with_里指定的一对人如果不在一桌惩罚 500 分。回避关系avoid里指定的一对人只要同坐一桌惩罚 10000 分这是最高优先级。在模拟退火里我们习惯用“惩罚越低越好”所以奖励也要转化成正惩罚的负值。基础分越高说明同坐越合理因此惩罚值就越低。实现如下def compute_penalty(assign, guests, table_capacity): # assign: 字典 {桌号: [guest_name, ...]} penalty 0.0 name_to_guest {g.name: g for g in guests} for table_id, names in assign.items(): # 硬约束人数不超过容量 if len(names) table_capacity: penalty (len(names) - table_capacity) * 100000 # 硬约束每桌不能太空比如少于 5 人 if 0 len(names) 5: penalty (5 - len(names)) * 100000 # 两两关系 for i in range(len(names)): for j in range(i 1, len(names)): a, b name_to_guest[names[i]], name_to_guest[names[j]] # 基础同组奖励 if a.group b.group: penalty - 10 elif a.group.split(_)[0] b.group.split(_)[0]: penalty - 5 # 回避关系 if a.name in b.avoid or b.name in a.avoid: penalty 10000 # 强制同坐 if a.name in b.with_ or b.name in a.with_: penalty 500 # 如果不同桌这个惩罚会持续存在 # 硬约束强制桌号 name_to_table {} for table_id, names in assign.items(): for name in names: name_to_table[name] table_id for g in guests: if g.must_table 0 and name_to_table[g.name] ! g.must_table: penalty 100000 return penalty这段代码里惩罚数字看着吓人但实际作用就是把优先级分层。1 万和 10 万之间的差距会让模拟退火几乎不可能牺牲“回避关系”去优化其他软目标。3.4 贪心初始解给退火一个还不错的起点贪心阶段我采用最简单的方式先把同 group 的宾客按人数估算成桌再处理剩下的散客。具体策略是按 group 中人数循环每凑满一桌接近容量时就生成一桌对于一个 group 人数是 0 的散客逐个丢进当前人数最少且未满的桌。这个策略的最大优点是代码容易理解初始方案不会出现“同一个三姑六婆团体被切得稀碎”的问题。缺点是它完全不考虑 avoid 关系但这些问题留给模拟退火去处理完全够用。def greedy_init(guests, table_capacity): # 按 group 聚合 groups defaultdict(list) for g in guests: groups[g.group].append(g.name) tables [] for group, names in groups.items(): random.shuffle(names) for i in range(0, len(names), table_capacity): tables.append(names[i:itable_capacity]) # 把超过容量的桌拆开把不足人数的桌合并或补人 # 简化这里只做 “超过则拆分”的示例 final_tables [] for t in tables: while len(t) table_capacity: final_tables.append(t[:table_capacity]) t t[table_capacity:] if t: final_tables.append(t) assign {i1: final_tables[i] for i in range(len(final_tables))} return assign3.5 模拟退火局部搜索不断交换让惩罚降下来模拟退火的流程其实是“爬山”的升级版。爬山算法只接受“变优”的交换但容易卡在局部最优退火算法在前期允许一定概率接受“变差”的交换就好比爬山时允许先下坡再爬另一座更高的坡。随着温度降低接受差解的概率越来越小最终收敛到一个比较稳定的解。实现时我每次随机选两桌再从两桌各随机选一位客人尝试交换。如果交换后总惩罚变低就接受如果变高就按温度相关的概率决定是否接受。def simulated_annealing(assign, guests, table_capacity, max_iter50000, T0100.0, alpha0.999): current_penalty compute_penalty(assign, guests, table_capacity) best_assign {k: list(v) for k, v in assign.items()} best_penalty current_penalty T T0 table_ids list(assign.keys()) for it in range(max_iter): t1, t2 random.sample(table_ids, 2) i random.randrange(len(assign[t1])) j random.randrange(len(assign[t2])) # 交换 assign[t1][i], assign[t2][j] assign[t2][j], assign[t1][i] new_penalty compute_penalty(assign, guests, table_capacity) delta new_penalty - current_penalty if delta 0: current_penalty new_penalty if current_penalty best_penalty: best_penalty current_penalty best_assign {k: list(v) for k, v in assign.items()} else: # 梅特罗波利斯接受准则 if random.random() math.exp(-delta / T): current_penalty new_penalty else: # 换回去 assign[t1][i], assign[t2][j] assign[t2][j], assign[t1][i] T T0 * (alpha ** (it 1)) if it % 10000 0: print(fiter{it}, penalty{current_penalty}, T{T:.2f}) return best_assign, best_penalty这里要说一个非常重要的调参经验T0和alpha决定了搜索的随机性和收敛速度。T0 太低一上来就只爬山容易局部最优T0 太高前期纯随机乱逛浪费迭代次数。我当时设 T0100alpha0.999在 300 人规模的案例里大概迭代 3 万次后惩罚值基本不再下降耗时在几秒量级。这个参数不是绝对的每个数据集的规模、约束密度不同需要做几次小规模试跑来调整。3.6 输出可执行的座位卡和 Excel 报告算法算完不是终点真正让功能落地的是结果输出。我写了一个导出函数把每桌名单写成一张排班式表格同时在有特殊备注的客人后面标注括号比如“王小萌宝宝椅”“李伯素食”。最后调用了csv.writer输出成 Excel 可以直接打开的 CSV 文件文件名包含时间戳避免反复运行覆盖。def export_result(assign, guests, output_path): name_to_guest {g.name: g for g in guests} with open(output_path, w, encodingutf-8-sig, newline) as f: writer csv.writer(f) writer.writerow([桌号, 姓名, 分组, 备注]) for table_id in sorted(assign.keys()): for name in sorted(assign[table_id]): g name_to_guest[name] notes [] if g.need_high_chair: notes.append(宝宝椅) if g.must_table: notes.append(f固定桌{g.must_table}) writer.writerow([f桌{table_id}, name, g.group, ;.join(notes)])这一步虽然简单但对新人的父母来说非常友好。他们不需要打开 Python只需要对着 Excel 里打印出来的座位表安排引座即可。项目的价值也在这一瞬间真正体现出来算法再漂亮最后能变成一张可打印的座位卡才叫完成了闭环。4. 用真实数据跑一遍结果怎么看怎么调4.1 构造一个 40 人的小型样例来验证为了测试脚本我模拟了一份 40 人的宾客名单男方亲友 16 人、女方亲友 14 人、新人同事 10 人每桌容量 10 人目标 4 桌。其中构造了几对必须回避的关系以及一家三口必须同桌的关系。跑完结果后我主要看三件事有没有违反硬约束也就是每桌人数是否合法、固定桌号是否正确有没有回避关系两人同一桌同组的人是不是基本集中在一起。第一次跑出来的结果很典型总惩罚不是 0有一个“必须同桌”的家庭被拆到了两桌因为初始解和交换过程中这个约束并没有在贪心阶段被优先处理。后来我在compute_penalty里把“强制同坐”的惩罚从 500 提高到 5000重新跑这个家庭就完整地回到同一桌了。这是一个很重要的问题软硬约束之间的层级差距太小求解器会为了方便其他软约束而牺牲它。要区分“高优先级软约束”和“低优先级软约束”用不同量级的惩罚值来表达。4.2 惩罚值迭代收敛曲线怎么看我在终端打印了每 10000 轮的惩罚值格式是iter0, penalty...。第一次跑的时候penalty 从 48000 逐步降到 3000 左右但仍然有残留说明有强制关系没有满足。我打开结果仔细看发现有两位客人的avoid名单里写反了A 避讳 B但 B 的 avoid 里没有 A。我的代码里判断条件是双向的所以不影响但如果只写了单向判断就会出现 B 被安排到 A 同桌的漏网情况。所以建议在compute_penalty里始终写双向检查或者加载数据时就统一把 avoid 的关系做对称化for g in guests: for other in guests: if g.name in other.avoid: other.avoid.add(g.name)数据清洗和校验占了这个项目三分之一的精力一点都不夸张。4.3 从算法结果到现场执行表拿到算法结果之后我通常还会人工再过一遍重点看几类信息长辈桌是否靠前且离出入口近带孩子的桌是否远离音响和过道双方父母和至亲是否在主桌附近。这些信息很难全部写进 CSV所以在导出座位表后我会加一列“现场备注”在新人确认后再生成打印版。如果你想把流程做得更专业可以把“区域优先级”也建到模型里把物理位置距离或楼层位置转化成约束惩罚但那样数据准备工作量会明显增大。对大多数婚礼来说人工微调几桌花不了几分钟算法把 80% 的脑力活干完已经很值得了。4.4 应该跑多少次、选哪次结果模拟退火是随机算法也就是说每次运行得到的结果不完全一样。稳妥的做法是让脚本循环跑 5 到 10 次记录每次的最优惩罚值输出惩罚值最小的一次。我实际采用的是“多轮跑 选最优”策略每次跑完后把best_penalty记下来。最终再人工审核最优结果而不是只看最后一次跑出来的方案。如果你用 OR-Tools 这类精确求解器就不存在这个问题但相应要接受额外安装依赖。5. 常见问题与排查技巧实录5.1 输出结果里有人数超过容量的桌出现这个情况时首先要检查compute_penalty里对超员的惩罚是不是足够大。如果超员惩罚是 10 万理论上是不会被接受的但有一种例外初始解拆分逻辑写得不严谨导致某桌一开始就超过了容量后面的退火交换又只换人、不改变每桌人数上限。我的解决方案是在模拟退火外层加一个“修复阶段”先把所有超员桌的多余人随机移动到空位最多的桌再进入正式优化循环。这个修复操作放在贪心初始解的最后一步效果更好。5.2 所有“回避关系”都避开了但同组的人被拆得太散很多求稳的人会设置一个很高的“强制同坐”惩罚但如果权重超过了“回避关系”的权重就可能出现为了把一家人塞到同一桌把另外几组人的回避关系给牺牲了。正确做法是给不同约束明确分层回避 10 万强制同坐 500同组同坐奖励 10。这样算法会在保证回避绝对满足的前提下再尽量满足强制同坐最后才追求同组聚会。5.3 跑了几万次迭代还在收敛中怎么判断要不要继续增加迭代有一个经验技巧每 5000 轮记录一次最优惩罚值如果连续 3 次记录没有变化基本可以判断搜索已经稳定继续迭代的收益很低。也可以用多轮跑同一数据看最优值波动范围如果多次运行结果相差很小说明稳定性不错。如果每次结果波动很大通常意味着初始解太差或者温度下降太快这时候优先调高 T0再考虑增加迭代次数。5.4 临时加人怎么办婚礼前三天临时加人是最常见的突发情况。我的做法不是重新跑全量数据而是在生成座位表后预留 1 到 2 桌作为“机动桌”干脆不参与算法分配留给现场临时安排。同时每桌容量按 10 来计算但酒店摆的是 12 人桌多出的空间可以容纳临时加的人。如果你希望算法在遇到加人时自动重新分配可以在 CSV 里新增一行然后重跑脚本但人工再核对一遍还是有必要。5.5 客人重名导致关系匹配错乱这是一个很容易被忽视的坑。婚礼宾客中重名很常见尤其是一方老人那边的朋友很多叫“建国”“秀英”的。如果 CSV 里只用姓名做唯一标识avoid关系就会匹配错。我后来的做法是给每位宾客加一个唯一 ID 列姓名只用于最后输出展示所有关系和 group 判断都用 ID 完成。具体到 CSV可以这样设计class Guest: def __init__(self, uid, name, group, ...): self.uid uid self.name name如果不想改代码结构也可以给姓名加后缀比如“张秀英男方三舅母”虽然不太优雅但数据量小的时候也能解决。总之唯一标识这件事在项目一开始就要想好别等到出现匹配错乱再返工。5.6 如果完全不想写局部搜索直接用 OR-Tools 是否更好如果你本身熟悉 OR-Tools或者愿意安装第三方库用 CP-SAT 求解器确实更严谨因为它能找到全局最优解或者给出已经证明最优的 gap。尤其当你的硬约束条件特别多、且要求必须全部满足时求解器比模拟退火更让人放心。代码思路也很简单定义x[i][j]的 BoolVar添加每桌人数、每人一桌的约束把软约束作为目标函数系数然后调用 Solver。但我个人的经验是婚礼座位这个场景里新人更在乎的是“看起来合理 方便现场执行”而不是“数学证明全局最优”。模拟退火实现足够快、足够灵活、足够透明用来处理日常生活中的资源分配问题其实是一个更平衡的选择。6. 这个项目还可以继续玩出什么花婚礼座位安排只是我拿 Python 做组合优化的第一个练手项目。把它跑通之后我发现类似的思路可以直接迁移到一堆场景里公司年会座次安排、培训班的考试座位编排、多日团建的分组换组、朋友聚会拼桌点菜预算分配。只要是“把一批东西放到一些篮子里同时满足各种成对关系”的需求这套“建模 初始解 局部搜索”的组合就都能用。如果你想往更深处走一步还会发现这个项目背后连着一片完整的知识版图。比如线性规划与整数规划我们把问题当成 0-1 整数规划来建模可以系统学习单纯形法和分支定界法了解求解器内部的原理。元启发式算法除了模拟退火之外遗传算法、禁忌搜索、粒子群算法都适合拿来继续实验。同一份数据在 3 种算法下比较收敛速度和效果是很好的算法入门练习。可视化把每次退火的惩罚值画成曲线能很直观地看到温度对搜索行为的影响把座位表画成热力关系图能帮助新人一眼看出“谁和谁被放得太近”。更复杂的部署把脚本包成一个小网页新人自己在浏览器里上传 CSV、设置权重、点“生成座位表”后端用 Flask 或 FastAPI 调用这段逻辑家里人用起来零门槛。我在实际使用中最深的体会是这样的工具最核心的价值其实不是“算出最优解”而是“帮一家人把话说清楚”。为了让 CSV 里的avoid和with_字段有数据新人要和父母反复确认谁和谁必须坐一起、谁最好避开谁这个过程反而促成了很多原来说不出口的沟通。算法只是把大家心里的那杆秤变成了一个可执行的版本。如果让我给一个最后的建议那就是别一开始就追求功能齐全的大工程先用最简单的贪心加模拟退火跑通一个 40 人的样例看到输出结果的那一刻你就知道下一步该怎么迭代了。用 Python 优化婚礼座位这件事本质上也是在用数学为重要的一天减少一点忙乱加一点从容。