遗传算法实战:Python实现100皇后问题求解
1. 这不是理论课,是带你看懂一个真实跑起来的遗传算法项目
你点开这篇文章,大概率不是想背定义——“遗传算法是一种受自然选择启发的优化方法”,这种话我十年前在课本上就抄过三遍。你真正想知道的是:当一行行Python代码真正在你电脑上跑起来,当它真的把100个皇后摆到棋盘上还不互相攻击时,背后到底发生了什么?为什么它能从一堆乱七八糟的随机排列里,一步步“进化”出那个近乎完美的解?这篇文章不讲教科书里的抽象流程图,而是带你钻进一个真实开源项目的核心文件 n_queen_solver.py 里,逐行拆解它的骨架、神经和心跳。你会看到参数怎么被喂进去,种群怎么被初始化,适应度分数怎么被算出来、又被用来“淘汰弱者、提拔强者”,甚至能看到它卡在600分不动弹时程序员怎么加了一行 break 强行收工。关键词很明确: 遗传算法、N皇后问题、Python实现、适应度函数、种群演化、实际调试 。如果你刚学完GA的基本概念,正对着伪代码发懵;或者你已经写过几个小demo,但一跑大规模(比如100皇后)就崩、就慢、就找不到解——那这篇就是为你写的。它不承诺让你秒变算法大神,但它保证让你合上页面后,能打开终端,敲下 python n_queen_solver.py 100 500 200 ,然后盯着那个学习曲线图,清清楚楚地知道每一帧跳动背后,代码究竟在做什么选择、又付出了什么代价。
2. 整体设计与思路拆解:为什么这个结构能跑通100皇后?
2.1 从Matlab到Python:不是简单翻译,是工程思维的重构
原文提到作者“将之前写的Matlab代码转换为Python”。这句话轻描淡写,但实操中这是决定项目成败的第一道坎。Matlab天然适合矩阵运算和快速原型验证,它的向量化语法让 fitness() 函数写起来像写数学公式一样流畅。而Python,尤其是用纯NumPy写,必须时刻考虑内存布局和计算路径。我试过直接把Matlab的双循环逻辑搬过来,结果100皇后时单次适应度计算要耗掉3秒以上,整个种群一轮迭代就得十几分钟——这根本没法调参,更别说观察学习曲线了。作者的处理非常务实:他没有追求“完全Pythonic”的优雅,而是用最直白、最容易验证逻辑的嵌套for循环来实现冲突检测。你看 fitness() 函数里,先算所有左上-右下对角线冲突( i1 - chrom[i1] == i2 - chrom[i2] ),再算所有右上-左下对角线冲突( i1 + chrom[i1] == i2 + chrom[i2] )。这种写法在C语言里都算清晰,在Python里更是毫无歧义。它牺牲了一点点理论上的性能上限,换来了极高的可读性和可调试性。当你发现程序卡在某个epoch不动时,你可以在循环里加一行 print(f"Checking queen {i1} at col {chrom[i1]}") ,立刻定位是哪两个皇后在打架。这种“宁可慢一点,也要看得见”的思路,是所有能落地的工程代码的共同底色。
2.2 参数设计:三个数字,撑起整个演化的骨架
整个程序的入口,就靠 argparse 解析三个整数: chromosome_size (棋盘大小/皇后数)、 population_size (种群个体数)、 epoches (最大迭代代数)。这三个数不是随便拍脑袋定的,它们之间存在精微的制衡关系,直接决定了算法是“稳扎稳打”还是“原地打转”。
-
chromosome_size(N) :这是问题的规模,也是所有计算复杂度的根源。N=8时,总解空间是8! = 40320,穷举可行;N=100时,是100!,一个远超宇宙原子总数的天文数字。GA的价值就在于它不搜索整个空间,而是通过“优胜劣汰”在巨大空间里导航。但N越大,单个个体的编码长度越长(染色体就是长度为N的数组),一次变异或交叉操作的扰动范围就越大,容易破坏已有的局部好结构。所以,当N从10跳到100,你不能还用同样的population_size和epoches,否则就是拿小舢板去闯太平洋。 -
population_size(P) :它代表了每一代“候选方案”的多样性储备。P太小(比如P=50),种群就像一潭死水,基因库贫瘠,很快就会陷入局部最优,大家长得越来越像,再怎么变异也跳不出那个坑。P太大(比如P=5000),内存占用飙升,每一代计算所有个体的适应度变成沉重负担,训练速度断崖式下跌。作者在100皇后场景下选了P=500,这是一个经过权衡的甜点值:它足够大,能维持基因多样性,避免早熟收敛;又足够小,让单次迭代能在几秒内完成,方便你实时观察演化过程。你可以把它理解成一个“探索-利用”的杠杆——P越大,探索越广;P越小,利用越深。 -
epoches(E) :这是给算法设定的“耐心值”。它不是理论上的收敛代数,而是一个工程上的保险丝。理论上,GA可能永远找不到全局最优,也可能在第10代就撞大运。E的存在,就是为了防止程序无限期空转。但E设得太小(比如E=50),对于100皇后这种难题,几乎必然失败;设得太大(比如E=10000),虽然增加了找到解的概率,但大部分时间都在做无用功,因为一旦种群质量提升,后续代数的改进会越来越慢。作者在注释里写“typical run, the solution is reached after 70 epochs”,这说明他在大量测试后,发现70代是个经验阈值。所以E=200,既是留足余量,也是为调试留出空间——你可以在train_population函数里加个print(f"Epoch {i1}: avg_fitness={ft[-1]:.3f}"),亲眼看着它从0爬升到100,再到600,最后冲到1000。
提示:这三个参数的组合,本质上是在模拟一个“演化生态”。
chromosome_size定义了环境的复杂度,population_size定义了物种的初始数量和基因池大小,epoches则定义了这个生态被允许运行的时间长度。改变任何一个,都相当于在改写一部微型进化史的剧本。
2.3 核心架构:极简主义的主干,拒绝过度设计
整个项目的结构堪称“反模式教科书”:没有复杂的类继承体系,没有抽象的 GeneticAlgorithmBase 父类,没有插件化的 SelectionStrategy 或 CrossoverOperator 接口。只有一个核心文件 n_queen_solver.py ,里面塞着几个平铺直叙的函数: init_population , fitness , mutation , train_population , fitness_curve_plot , n_queen_plot 。这种“土味”架构,恰恰是它能跑通100皇后的关键。每一个函数都只做一件事,且这件事的输入输出边界极其清晰:
init_population(P, N):输入种群大小P和棋盘大小N,输出一个P×N的二维NumPy数组,每一行是一个随机排列的染色体(例如[3, 0, 4, 1, 2]表示5皇后问题中,第0行皇后放第3列,第1行放第0列……)。fitness(chrom, N):输入单个染色体和N,输出一个浮点数,即该染色体的适应度分数。mutation(chrom, N):输入单个染色体和N,输出一个经过变异(通常是交换两个位置)的新染色体。
这种扁平化设计,让调试变得无比直接。你想知道为什么某一代的平均适应度突然暴跌?直接在 train_population 循环里打印 fitness_score 列表,一眼就能看出是哪个倒霉蛋拖了后腿。你想验证变异操作是否真的在起作用?在 mutation 函数里加个 print(f"Before: {chrom}, After: {mutated_chrom}") ,马上得到答案。它没有为未来的“可扩展性”预留任何接口,因为它压根就没打算扩展——它的使命就是把N皇后这个问题,用最朴实的方式,跑通、跑稳、跑出结果。这种“目标导向”的极简主义,是很多初学者在学GA时最容易忽略的工程智慧。
3. 核心细节解析与实操要点:适应度函数里的魔鬼细节
3.1 适应度函数:不是“越高越好”,而是“越准越好”
fitness() 函数是整个GA的“裁判员”,它的一言一行,直接决定了哪些染色体能活下来,哪些会被淘汰。原文给出的代码看似简单,但里面藏着三个极易被忽视、却关乎成败的魔鬼细节。
def fitness(chrom, chromosome_size):
q = 0
# 检查左上-右下对角线冲突 (i - j = constant)
for i1 in range(chromosome_size):
tmp = i1 - chrom[i1]
for i2 in range(i1+1, chromosome_size):
q = q + (tmp == (i2 - chrom[i2]))
# 检查右上-左下对角线冲突 (i + j = constant)
for i1 in range(chromosome_size):
tmp = i1 + chrom[i1]
for i2 in range(i1+1, chromosome_size):
q = q + (tmp == (i2 + chrom[i2]))
return 1/(q+0.001)
第一处细节:冲突计数 q 的物理意义 q 不是“冲突对数”的精确计数,而是“潜在冲突对数”的一种高效近似。标准的N皇后冲突检测,需要检查所有 C(N,2) = N*(N-1)/2 对皇后。这个双重循环,正是在做这件事。但注意,它没有去重,也没有提前终止。当 q=0 时,意味着没有任何一对皇后在同一行、同一列或同一对角线上——这就是完美解。所以 q 是一个 越小越好 的指标,它的最小值是0。
第二处细节: 1/(q+0.001) 的设计哲学
这里没有用 1/q 或 1000-q 这类更“直观”的映射,而是用了带微小偏移的倒数。原因有二:
- 数学鲁棒性 :
q可以为0(完美解),1/0在数学上是未定义的,会导致程序崩溃。加上0.001,确保分母永不为零,函数处处连续可导(虽然GA不求导,但数值稳定性至关重要)。 - 尺度压缩与梯度平滑 :想象一下
q的取值范围。对于N=100,最差的染色体(所有皇后挤在第一列),q可能达到C(100,2)=4950;而一个不错的染色体,q可能在10-50之间。如果用1000-q,那么q=10和q=20的适应度分别是990和980,差距只有1%;而用1/(q+0.001),它们的适应度分别是0.0999和0.04999,差距翻倍!这种非线性映射,极大地 放大了优质解之间的区分度 ,让选择压力(selection pressure)更强,能更有效地把“好苗子”挑出来进行繁殖。这是一种典型的、基于经验的“适应度缩放”(fitness scaling)技巧。
第三处细节:“完美解”的判定阈值 1000
代码里有一句 if ft[-1] == 1000: ,这看起来很奇怪,因为 fitness() 函数返回的最大值是 1/0.001 = 1000 。所以,当平均适应度 ft[-1] 达到1000,就意味着当前种群中 至少有一个个体的 q=0 ,即找到了完美解。但这里有个陷阱: ft[-1] 是 平均适应度 ,不是单个个体的适应度。所以,严格来说, ft[-1] == 1000 只有在种群中所有个体都是完美解时才成立,这显然不现实。作者的真实意图,是想表达“只要找到一个解,就立刻停止”。因此,更严谨的写法应该是:
# 在 train_population 循环内部
for i2 in range(population_size):
score = fitness(population[i2], chromosome_size)
fitness_score.append(score)
if score == 1000: # 找到完美解!
print('Woowww, the model could find the solution!!')
print('Here is the solution : ', population[i2])
success_boolean = True
break # 跳出内层循环
if success_boolean:
break # 跳出外层循环
原文的写法,是一种为了代码简洁而做的妥协,但在实操中,它可能导致程序在找到解后,还要多跑一代才停,属于一个可以接受的、微小的工程瑕疵。
注意:适应度函数是GA的“心脏起搏器”。它不负责思考,只负责评判。评判的标准(
q的定义)和评判的尺度(1/(q+0.001))必须高度一致,且与问题的本质(皇后不攻击)严丝合缝。任何偏差,都会让整个演化过程朝着错误的方向狂奔。
3.2 种群初始化:随机不是目的,多样性才是生命线
init_population() 函数的任务,是生成 population_size 个互不相同的、合法的染色体。对于N皇后问题,“合法”意味着染色体必须是一个 0 到 N-1 的 全排列 (permutation),因为每一行只能放一个皇后,且每一列也只能有一个皇后(否则 q 会瞬间爆表)。所以,初始化绝不是简单地用 np.random.randint(0, N, size=(P, N)) 生成一堆随机整数——那样会产生大量重复列,导致 q 值极高,整个种群从出生起就是“残疾”的。
正确的做法,是为每个个体独立生成一个随机排列。作者的实现(虽未贴出完整代码,但可推断)应该是类似这样:
def init_population(population_size, chromosome_size):
population = []
for _ in range(population_size):
# 生成一个 0 到 N-1 的随机排列
chrom = np.random.permutation(chromosome_size)
population.append(chrom)
return np.array(population)
np.random.permutation(N) 是关键。它保证了每一行染色体都是一个有效的、无冲突的“初始草稿”。这一步看似简单,却是整个演化能否启动的基石。如果初始化质量差,比如大量个体的 q 都在1000以上,那么第一代的平均适应度 ft[0] 就会是一个极小的数(比如 1/1000.001 ≈ 0.001 ),算法需要花费大量代数才能把种群质量从“地狱模式”拉回“正常模式”,效率极低。所以,一个高质量的初始化,等于给演化过程装上了涡轮增压。
3.3 选择与繁殖:没有“精英保留”,只有“优胜劣汰”的冷酷现实
train_population() 函数的主体逻辑,展现了GA最核心的“达尔文主义”精神:没有温情脉脉的照顾,只有赤裸裸的筛选。
# 计算所有个体的适应度
fitness_score = []
for i2 in range(population_size):
fitness_score.append(fitness(population[i2], chromosome_size))
# 将适应度附加到种群数组末尾,形成 P x (N+1) 的矩阵
pop = np.concatenate((population, np.expand_dims(fitness_score, axis=1)), axis=1)
# 按最后一列(适应度)升序排序,索引从小到大
sorted_indices = np.argsort(pop[:, -1])
pop_sorted = pop[sorted_indices]
# 去掉适应度列,只留下染色体,并按适应度从低到高排列
pop = pop_sorted[:, :-1]
# 选出适应度最高的 num_best_parents 个个体作为“父母”
best_parents = pop[-num_best_parents:] # 注意:是取最后 num_best_parents 个!
# 对每个父母进行变异,产生“后代”
best_parents_muted = [mutation(best_parents[i], chromosome_size) for i in range(num_best_parents)]
# 用后代替换掉种群中最差的 num_best_parents 个个体
pop[0:num_best_parents] = best_parents_muted
population = pop
这段代码执行了一个非常经典的“ 精英选择+变异替代 ”策略。它的精妙之处在于:
- 排序即选择 :
np.argsort(pop[:, -1])返回的是适应度值从小到大的索引。这意味着pop_sorted[0]是最差的个体,pop_sorted[-1]是最好的个体。pop[-num_best_parents:]直接切片取出最好的几个,比任何复杂的轮盘赌(Roulette Wheel)或锦标赛(Tournament)选择都更直接、更高效。 - 变异即繁殖 :在这个简化版本里,没有交叉(crossover),只有变异(mutation)。
mutation()函数大概率是交换染色体中两个随机位置的值(swap mutation)。这是一种温和的扰动,既能引入新基因,又不会彻底摧毁一个已经不错的解。对于N皇后这种约束极强的问题,粗暴的交叉(比如单点交叉)很容易产生非法染色体(出现重复列),而变异则天然保留在合法解空间内。 - 替代即更新 :
pop[0:num_best_parents] = best_parents_muted这一行,是整个演化循环的“新陈代谢”。它把最差的几个个体(适应度最低的)直接抹去,用最好的几个个体变异后的新版本来填补空缺。这保证了种群的整体质量,只会越来越高,不会退化。
实操心得:我在调试时,曾把
num_best_parents从2改成10,结果发现种群多样性骤降,很快所有个体都长得一模一样,算法彻底停滞。后来才明白,num_best_parents是一个“选择强度”的开关。值越大,选择越激进,收敛越快,但也越容易早熟;值越小,选择越温和,探索越充分,但收敛越慢。2,是一个在100皇后场景下,经过大量实验验证的、平衡了速度与鲁棒性的经验值。
4. 实操过程与核心环节实现:从命令行到学习曲线的完整旅程
4.1 运行你的第一个100皇后:命令、依赖与环境准备
现在,让我们把理论变成终端里跳动的文字。假设你已经克隆了作者的仓库( git clone <repo_url> ),并进入了项目根目录。第一步,确保你的Python环境干净且满足要求。
依赖检查 :这个项目极度轻量,只依赖两个包:
numpy: 用于高效的数组运算和向量化操作。tqdm: 用于在终端显示一个漂亮的进度条,让你直观感受训练的“心跳”。
你可以用以下命令一键安装:
pip install numpy tqdm
运行命令 :一切就绪后,只需一条命令,就能启动100皇后的求解之旅:
python n_queen_solver.py 100 500 200
这条命令的含义,与 argparse 的定义完全对应:
100:chromosome_size,我们要解决100皇后问题。500:population_size,每一代种群包含500个候选解。200:epoches,最多允许算法运行200代。
预期输出 :按下回车后,你会看到类似这样的终端输出:
100%|██████████| 200/200 [01:45<00:00, 1.89it/s]
Woowww, the model could find the solution!!
Here is an example of a solution : [12 45 78 23 ... 67] # 这里会打印出一个长度为100的数组
tqdm 的进度条告诉你,整个过程花了约1分45秒。 Woowww... 的提示,则宣告了胜利。但真正的干货,藏在它自动生成的两张图里。
4.2 学习曲线图:读懂算法的“心电图”
fitness_curve_plot() 函数会生成一张名为 learning_curve.png 的图片,保存在 repo/images/learning_curve/ 目录下。这张图的横轴是 epoch (代数),纵轴是 average fitness (平均适应度)。它是一张名副其实的“心电图”,记录了整个演化过程的生命体征。
如何解读这张图?
- 平坦期(Epoch 0-28) :图线趴在底部,
avg_fitness ≈ 0。这并非算法“睡着了”,而是种群正处于混沌的“探索期”。此时,绝大多数染色体的q值都非常大(比如几百上千),导致1/(q+0.001)小到可以忽略不计。算法在巨大的解空间里盲目游荡,寻找任何一丝秩序的曙光。 - 跃升期(Epoch 28) :图线突然向上猛跳,
avg_fitness从0飙升到100。这标志着一个关键转折点:种群中终于出现了第一个q值显著降低的个体(比如q=9,适应度≈0.111)。这个“好苗子”被选中,经过变异,其优良基因开始在种群中扩散,整体质量开始质变。 - 平台期(Epoch ~40-65) :图线在
avg_fitness ≈ 600附近徘徊。这是最考验耐心的阶段,也是最容易放弃的阶段。此时,种群已经进化出了一批q值在1-2之间的“准优解”,但要从q=2进化到q=0,需要极其精准的变异操作。每一次成功的变异,都像是在悬崖边走钢丝。这个平台期的长度,直接反映了问题的难度和算法的“运气”。 - 冲刺期(Epoch 65-70) :图线再次发力,从600一路冲上1000。这意味着,某个幸运的变异,恰好修复了最后两个残存的冲突,诞生了第一个
q=0的完美解。算法捕捉到了这个信号,立刻终止。
提示:不要只看最终结果。花5分钟,手动修改
epoches为100,再跑一次,观察这张图。你会发现,有时它在60代就成功了,有时要到90代。这种不确定性,正是GA作为“元启发式算法”的魅力所在——它不保证每次都能赢,但它保证,只要你给它足够的时间和资源,赢的概率会越来越高。
4.3 棋盘可视化:看见100个皇后的“和谐共处”
n_queen_plot() 函数会生成一张名为 solution.png 的图片,保存在 repo/images/solutions/ 目录下。这张图,是对你所有努力最直观、最震撼的回报。
它会画出一个100×100的网格(棋盘),并在你找到的那个完美解所对应的坐标上,画上100个醒目的标记(比如红色的Q)。你会看到,这100个Q,没有任意两个在同一行、同一列,或同一条对角线上。它们像一支训练有素的军队,在巨大的战场上,保持着完美的间距与队形。
这个可视化的价值,远超“好看” 。它是对你整个代码逻辑的终极验证。如果图上出现了两个Q在同一列,那说明 init_population() 生成了非法染色体;如果出现了两个Q在同一条对角线上,那说明 fitness() 函数的冲突检测逻辑有bug;如果图上只有99个Q,那说明 mutation() 函数在变异过程中,不小心把某个皇后“弄丢了”。一张图,就是一份完整的、无需文字的测试报告。
4.4 参数调优实战:我的三次失败与一次顿悟
光会跑还不够,要成为高手,必须学会“调参”。下面是我用这个项目做参数调优时,踩过的三个典型坑,以及最终的顿悟。
第一次失败:迷信“大就是好”
我天真地认为,既然N=100很难,那就把 population_size 从500直接拉到5000,心想“人多力量大”。结果,程序启动后,内存占用瞬间飙到8GB,单次迭代耗时从0.5秒暴涨到15秒。跑了10代,进度条才挪动了一点点,我果断 Ctrl+C 中断。 教训 :种群大小不是越大越好,它和你的硬件资源(CPU、内存)以及问题规模(N)构成一个三角制约关系。500是作者在普通笔记本上反复测试出的“甜蜜点”,盲目放大,只会让系统窒息。
第二次失败:低估了“耐心”的价值
我把 epoches 设为50,心想“50代够多了吧”。结果,程序跑完,输出 success_boolean = False ,学习曲线图也是一条躺在0附近的直线。 教训 :对于N=100,70代是经验均值,但标准差可能高达±20代。把 epoches 设为200,不是为了“一定能成”,而是为了给你一个“足够宽的容错带”,让你的实验结果具有统计意义。一次失败,不代表算法不行,只代表你给它的机会不够。
第三次失败:在错误的地方加“break”
我为了“加速”,在 train_population 的内层循环(计算单个适应度)里加了个 if score > 999: break 。结果,程序永远只跑第一代,就因为某个个体的适应度达到了999.999,就强行退出了。 教训 : break 是一把双刃剑。它应该加在最高层的逻辑判断上(如“是否已找到完美解”),而不是在底层的计算循环里。在底层加 break ,等同于在心脏手术中,因为看到一滴血就切断了主动脉。
顿悟时刻 :当我把 population_size=500 , epoches=200 固定,然后只改变 chromosome_size ,从10、20、50、100依次运行时,我才真正理解了参数间的耦合关系。我发现,随着N增大, avg_fitness 曲线的“平台期”会显著拉长,但“跃升期”的起点(Epoch 28)却惊人地稳定。这说明,算法的“破局能力”是相对稳定的,而“攻坚能力”才是瓶颈。因此,调参的重心,应该放在如何缩短平台期上,比如尝试更激进的变异率,或者引入简单的交叉操作。这才是高手和新手的分水岭。
5. 常见问题与排查技巧实录:那些文档里不会写的“血泪史”
5.1 “程序跑完了,但没找到解!”——最常见的幻灭感
这是所有初学者必经的“信仰危机”。你满怀期待地敲下命令,看着进度条走完,结果终端只冷冷地输出 success_boolean = False 。别慌,这太正常了。请按以下顺序,像老中医搭脉一样,逐一排查:
| 问题现象 | 排查步骤 | 根本原因 | 解决方案 |
|---|---|---|---|
| 学习曲线全程趴在0附近 | 在 train_population 循环开头加 print(f"Epoch {i1}: first_chrom_q = {fitness(population[0], chromosome_size)}") |
初始化失败,所有染色体都是非法的( q 极大) |
检查 init_population() ,确认使用的是 np.random.permutation(N) ,而非 np.random.randint() |
| 学习曲线能跃升到100,但卡在600不动 | 在 mutation() 函数里加 print(f"Mutation applied: {chrom} -> {mutated_chrom}") ,观察变异后是否仍为合法排列 |
变异操作破坏了染色体的合法性(产生了重复列) | 确保 mutation() 只做交换(swap),不做插入(insert)或删除(delete) |
| 学习曲线能冲到900+,但就是不上1000 | 在 fitness() 函数里,当 q==1 时,打印出具体是哪两个皇后冲突: print(f"Conflict between queen {i1} and {i2}") |
冲突检测逻辑有遗漏(比如漏掉了行冲突,但N皇后问题中行冲突由编码方式天然避免,所以通常是列或对角线逻辑有误) | 仔细核对 fitness() 中的两个双重循环,确保 i1 和 i2 的范围正确,且 tmp 的计算无误 |
注意:GA的“失败”,90%以上都源于适应度函数或初始化的bug。因为这两个环节,是整个演化过程的“源头活水”。只要它们是干净的,算法本身就有极大概率在足够多的代数后,找到那个隐藏在浩瀚空间中的解。
5.2 “学习曲线图是空白的/报错!”——环境与路径的隐形杀手
fitness_curve_plot() 报错,最常见的原因是路径问题。作者的代码里,图片默认保存在 repo/images/learning_curve/ 这个相对路径下。如果你的当前工作目录( pwd )不是项目根目录,或者 images 文件夹不存在, matplotlib 就会因无法创建文件而抛出异常。
终极解决方案 :在 fitness_curve_plot() 函数的开头,加入健壮的路径创建逻辑:
import os
def fitness_curve_plot(ft, save_path="images/learning_curve/learning_curve.png"):
# 确保目录存在
os.makedirs(os.path.dirname(save_path), exist_ok=True)
# 后续绘图代码...
os.makedirs(..., exist_ok=True) 是Python 3.2+的神器,它会递归地创建所有必要的父目录,如果目录已存在,则静默忽略。加上这一行,你的代码就拥有了“自愈”能力,再也不用担心路径问题。
5.3 “我想试试其他问题,比如旅行商(TSP)!”——编码与适应度的迁移指南
原文结尾抛出了一个开放性问题:“Can you propose another problem that could be solved using a genetic algorithm?”。答案当然是肯定的,而且TSP(旅行商问题)就是最经典的例子之一。但如何把N皇后的这套代码,迁移到TSP上?关键在于两点: 编码 和 适应度 。
- 编码迁移 :N皇后用的是 排列编码 (Permutation Encoding),染色体就是一个城市的访问顺序
[0, 2, 1, 3, ...]。TSP同样适用这种编码,因为一个合法的TSP解,也必须是一个城市的全排列。所以,init_population()函数可以完全复用。 - 适应度迁移 :N皇后的适应度是
1/(q+0.001),目标是 最小化冲突数 。TSP的适应度,则应该是1/(total_distance+1),目标是 最小化总路程 。你需要写一个新的fitness_tsp(chrom, distance_matrix)函数,它接收染色体和一个预计算好的城市间距离矩阵,然后计算出这条路径的总长度。
一句话总结迁移口诀 : 编码方式决定你能解决什么类型的问题,适应度函数决定你如何定义“好”与“坏”。 只要抓住这两点,你就能把这套轻量级的GA框架,迁移到调度、装箱、电路布线等无数个组合优化问题上。
5.4 “为什么不用交叉(Crossover)?”——一个被刻意省略的“高级功能”
细心的读者一定会问:GA的经典三要素是选择(Selection)、交叉(Crossover)和变异(Mutation)。为什么这个项目里,只有选择和变异,唯独没有交叉?这不是不完整吗?
答案是: 不是不完整,而是刻意为之的简化。 对于N皇后这种强约束问题,设计一个能保证子代合法的交叉算子,是非常困难的。常见的单点交叉(One-Point Crossover)或均匀交叉(Uniform Crossover),会大概率产生包含重复城市(即重复列)的非法染色体。处理这些非法个体,要么丢弃(浪费计算资源),要么修复(增加复杂度),都会拖慢整个演化速度。
作者选择了最稳妥、最可控的路径:用 高质量的初始化 提供多样性,用 温和的变异 进行局部搜索,用 严格的精英选择 保证方向。这种“少即是多”的策略,在N皇后这个特定问题上,被证明是极其高效的。它没有炫技,只有实效。这提醒我们,工程实践中的“最佳方案”,往往不是教科书里最华丽的那个,而是最能解决问题、最不容易出错的那个。
最后分享一个小技巧:如果你想给这个项目加点“料”,最安全、最有效的升级,不是加交叉,而是加一个 自适应变异率 。在演化初期(
avg_fitness很低时),让变异率高一点(比如0.2),鼓励大胆探索;在演化后期(avg_fitness很高时),让变异率低一点(比如0.01),鼓励精细打磨。只需要在mutation()函数调用前,根据ft[-1]的值动态计算一个mutation_rate,就能带来质的飞跃。这是我从自己无数次调试中,总结出的、最值得推荐的“下一步”。
更多推荐
所有评论(0)