还记得八皇后的解法吗
“还记得八皇后的解法吗?”
“上个世纪的事情,不记得了。”
“…… 现在回忆一下?”
“开会,回头说。”
“ fuck u ”
“ u shit ”
我有一个C++基友,这么称呼是因为他入行时用的是C++。虽然在游走于腾讯、金山之后,如今已经十八般武艺略懂了,但说起来还是C++的标签最刺眼。
当你有一个C++基友时,QQ里的日常,难免就会碰到上面那种聊天记录了。
八皇后是一个古老的经典问题:如何在一张国际象棋的棋盘上,摆放8个皇后,使其任意两个皇后互相不受攻击。
该问题由一位德国国际象棋排局家 Max Bezzel 于 1848年提出。严格来说,那个年代,还没有“德国”这个国家,彼时称作“普鲁士”。
Max Bezzel
1850年,Franz Nauck 给出了第一个解,并将其扩展成了“ n皇后 ”问题,即在一张 n x n 的棋盘上,如何摆放 n 个皇后,使其两两互不攻击。
历史上,八皇后问题曾惊动过“数学王子”高斯(Gauss),而且正是上面这个 Franz Nauck 写信找高斯请教的。
高斯和八皇后问题
在那天被基友问到时,我并非真的不记得了,而是我压根就没有做过。但我第一次遇见这个问题时,确实是在上个世纪,那是在小学微机教室里,参加市级计算机奥林匹克小学组竞赛的培训课上。
我还记得初次看到这个问题的第一反应——怎么可能摆8个!要知道我初学国际象棋时,经常为了简化局面,早早地就伺机兑掉皇后,因为皇后的威力实在是溢出了我童年的脑袋。一个皇后感觉就可以 hold 住全场了,怎么还可以摆8个互不干扰的呢?这肯定无解。
所以说,童言无忌这个说法,是有必要的。
一晃好多年。如今基友问过来了,我琢磨着是该补上这份跨世纪的作业了。
给老爸拨了个电话——
“喂,爸,家里的国际象棋放哪了?”
“…… 压箱底了吧,得找找。怎么突然问这,你要研究西西里防御?”
“我现在开局都不走 e4 了,要研究也是后翼弃兵。”
“别特么瞎扯,给你根杆子你就爬啊,快说,有什么屁事?”
“我要研究八皇后问题。”
“讲中文!”
“我有个问题想研究一下,要在国际象棋棋盘上摆放八个皇后,并且互相不受攻击,求摆法。”
“哦,这样啊…… 那你要国际象棋干嘛?”
“我想在国际象棋上试着摆摆啊。”
“国际象棋没有八个皇后,你要国际象棋干嘛?”
“呃…… 那我可以拿八个兵当皇后做试验。”
“那你直接画个棋盘摆八个硬币不是一回事?非要用国际象棋?脱裤子放屁,多此一举!”
“…… ……”
“老子懒得翻箱子跟你找了,你干脆去买四副国际象棋,然后就有八个皇后了。还有事吗?”
“没…… 没了,爸。”
“早点休息,多喝水,别熬夜。天气冷了,注意加衣服……”
“好,好好。”
—— 对方挂断,通话结束。
我默默地打开了淘宝,搜索“国际象棋”,准备买 4 副……
转念一想,还是算了,自己画吧。
转念二想,懒得画了,就在脑子里摆摆看吧。
首先,做点分析工作。
虽然还不知道最终的答案长什么样,有多少个,但利用国际象棋的规则,可以知道的是,最终8个皇后的分布必定是:
每行有且只有1个,每列有且只有1个。
因为如果有某一行(或列)空置的话,则必然导致另有一行(或列)存在2个皇后。这显而易见的结果背后,有一个数学概念叫做“抽屉原则”。
借助这个“抽屉”,接下来要做的就是一行一行地找出8个位置。
当然,按一列一列来做也可以,但在处理图形图像等信息时,优先水平方向似乎更符合人的思维惯性,据说这是因为人的两只眼睛是水平的。
(跑题了……)
心算8皇后感觉有点累,我打算简化问题。
从2皇后开始,不过2皇后无解得太昭然若揭了。
换成3皇后,无解得也是一目了然。
进而思考4皇后的情况(在4X4的棋盘上放4个皇后)。于是,有点思考的空间了。
一开始,棋盘是空的,第1个皇后可以任意放置,但为了思考方便,最好是按照秩序来尝试,于是先将第1个皇后放置在棋盘第1行第1列格子里。
BTW,如果你觉得图中的皇后图标长得很像 ROLEX 的 Logo,是因为我用的就是 ROLEX 的 Logo 。
毕竟,他们长得实在是太像了。
第1行已经有了皇后,下一步是寻找第2行皇后的位置。在这之前,需要计算出第2行此时未被第1个皇后攻击的棋格分布。
上图中呈现的是整个棋盘的状态,但此时关注的重点在第2行。接下来,将第2个皇后放置于第2行第3列棋格中。
现在,第1行和第2行都有皇后了,重新计算棋盘状态,以寻找第3行的皇后位置。
经过计算,第3行的所有棋格已经全部处于第1个和第2个皇后的联合攻击范围内了,虽然第4行还有空位,但已经无关紧要,当前尝试可以宣告 Game Over 了。
换句话说,刚才的第2个皇后位置不对。调整一下,将第2个皇后从第3列挪到第4列再试试。
调整之后,继续更新棋盘状态。
此时,第3行有一个可用的空位,于是将第3个皇后放在这个棋格中。
然后再次更新棋盘状态。
Oops,又遇到了类似的情况,第4行已经没有棋格可以用了,所以,刚才的第3个皇后位置不对。
但第3行只有一个空位可以用,而这唯一的一个空位又是错误的,这说明,问题还是出在第2个皇后的位置上。
再进一步回溯分析,可以发现,第2行可用的棋格已经都尝试过了,然而都不对。
所以,问题其实出在第1个皇后的位置上。也就是说,第一步将皇后放置于第1行第1列的摆法就错了。
知错就改,善莫大焉。将第1个皇后挪到第1行第2列,重头再来。
继续,更新棋盘状态。
根据上图,将第2个皇后置于第2行第4列。
继续,更新棋盘状态。
看上去不错,接着,将第3个皇后置于第3行第1列。
继续,更新棋盘状态。
咦,似乎成了。
BINGO!4皇后的第一个解,找到了。
现在,回顾上面的整个过程,做点抽象,引入一点计算机的思维,就可以得出解题流程了。
步骤清楚了,现在需要思考的就是过程中很关键的一步——根据已放置的皇后计算下一行棋格状态的逻辑实现。
这里需要回到国际象棋的规则本身了。
一个皇后在棋盘上的攻击范围如下图所示:
对这个图做点数学上的抽象分析:棋盘本身是一个标准的坐标平面,每个棋格都有着很明显的坐标位置。
所以,上图可以转换成下面的模型:
受皇后攻击的点,按照和皇后(Q点)的相对位置,可以分成4类:
- 横向(A1)
- 纵向(A2)
- 正斜(A3)
- 反斜(A4)
横向攻击其实不用考虑,因为解题的思路本身就是按行来推进的,先天就过滤掉横向攻击点了。
纵向攻击很容易判断,Q点 和 A2点 的 x坐标 相等,就处于攻击范围内。
不那么直观的是两条斜线的情况,需要算一下。
将正斜线攻击(A3类点)和反斜线攻击(A4类点)的坐标转换一下,表示成基于Q点的偏移——
Q:( x0, y0 )
正斜线 A3:( x0 + m, y0 + m )
反斜线 A4:( x0 - m, y0 + m )
通过观察不难得出规律——
正斜线上的点: (x0 + m) – x0 = (y0 + m) – y0
即:A3点的横坐标值 - Q点的横坐标值 = A3点的纵坐标值 – Q点的纵坐标值
反斜线上的点: x0 + y0 = (x0 – m) + (y0 + m)
即:Q点横坐标值 + Q点纵坐标值 = A4点横坐标值 + A4点纵坐标值
自此,通过皇后所在的棋格判断棋盘上另一处方格是否处于被攻击状态的逻辑就全部搞清楚了。
流程和方法都有了,是时候写代码实现具体程序了。
用什么语言来做这事呢?
QBasic,C,C#,Java,Python,Lua,JavaScript,PHP, ……
我在脑袋里慢慢遍历着我所精通的20门语言,俗话说艺不压身,但俗话却没说选择困难症,哎……
(以上这段纯属虚构)
最终,我决定用最近的新欢—— Go 语言来写这个程序。
延续之前的思路,依然将重心放到4皇后的情况,直译上面的分析过程,然后代码差不多长这样:
// 4皇后 package main import ( "fmt" ) func main() { // 定义4个皇后,初始化坐标为[-1,-1],即未放置于棋格中。 var ( queen1 = [2]int{-1, -1} queen2 = [2]int{-1, -1} queen3 = [2]int{-1, -1} queen4 = [2]int{-1, -1} ) // 放置第1个皇后 for i := 0; i < 4; i++ { // 遍历棋盘上的第一行方格(rank1) queen1[0] = i queen1[1] = 0 // 更新第2行棋格状态(此时已放置1个皇后) rank2 := render(queen1) // 放置第2个皇后 for i := 0; i < 4; i++ { if !rank2[i] { queen2[0] = i queen2[1] = 1 // 更新第3行棋格状态(此时已放置2个皇后) rank3 := render(queen1, queen2) // 放置第3个皇后 for i := 0; i < 4; i++ { if !rank3[i] { queen3[0] = i queen3[1] = 2 // 更新第4行棋格状态(此时已放置3个皇后) rank4 := render(queen1, queen2, queen3) // 放置第4个皇后 for i := 0; i < 4; i++ { if !rank4[i] { queen4[0] = i queen4[1] = 3 // 到此,4个皇后均成功置于棋盘中 fmt.Println("solution:", queen1, queen2, queen3, queen4) } } } } } } } } // 根据已放置的皇后,更新下一行棋格的状态 // 返回一个含4个bool类型元素的数组,true表示受攻击的,false表示未受攻击。 func render(queens ...[2]int) [4]bool { // 国际象棋棋盘中的一行,在英文中叫做:rank var rank [4]bool // 获取已放置的皇后的数量,可以得到下一行的索引 y := len(queens) // 遍历下一行的棋格 for x := 0; x < 4; x++ { for _, queen := range queens { // 通过已放置的皇后的棋格坐标来判断攻击范围 if x-queen[0] == y-queen[1] || // 正斜攻击 x == queen[0] || // 纵向攻击 x+y == queen[0]+queen[1] { // 反斜攻击 rank[x] = true // 一旦判断出该棋格受到攻击,则不用再计算后面的皇后对其影响 break } } } return rank }
运行后结果如下:
共2种解,并分别给出了每种解法的4皇后的坐标分布。
说明一下:这里我用到一个包含两整型元素的数组来表示皇后的坐标,每一个中括号里面的第1个数字表示 x轴 坐标(对应棋盘上的列),第2个数字表示 y轴 坐标(对应棋盘上的行)。
现在,4皇后已经解决了,那8皇后呢?
很简单,我只需要将 main 函数里面的 for 循环再写4套,就搞定了,复制粘贴可是基本功啊。
(开个玩笑~)
虽然照着上面的代码,写8套循环也确实可以得到正确的结果,但应该没有人有勇气公开地这么干吧。
所以,上面的代码充其量只能算是个草稿,接下来需要把它改成像样的程序。
通过前面的分析以及上面的代码,可以很明显地看出4层循环体里的代码逻辑是一样的。
当循环遇上重复时…… 递归,就要来了。
但在递归之前,先做点小调整。
增加一个const n,用于定义棋盘的规格,避免直接使用字面量“4”;
将用于存储皇后坐标的4个 array 合成1个 slice,这样就不用做固定次数的初始化了,而且对 slice 的操作也使得代码看上去更讨巧一点。
然后,将之前代码中,main 函数里的多重循环部分,精简成一个递归的形式函数调用:
// 放置下一个皇后 // 函数的参数为已放置的皇后的坐标集 func place(queens [][2]int) { // 获取已放置的皇后数量 y := len(queens) // 当已放置的皇后数量未达到n个之前,继续求解动作 if y < n { // 计算下一行的棋格状态 nextRank := render(queens) for x := 0; x < n; x++ { // 当遍历到下一行的可用棋格时 if !nextRank[x] { // 放置一个皇后 queens = append(queens, [2]int{x, y}) // 然后继续尝试下一个皇后的放置 place(queens) // 当上一句的递归调用结束时,表示本次求解过程的结束 // 此时,无论求解是否成功,均需要还原本次的状态(即拿起皇后,准备尝试下一次放置) queens = queens[:y] } } } else { // 当n个皇后均已放置时,表示一次求解的完成 // TODO } }
之前代码中,用于“根据已放置的皇后计算下一行棋格状态”的 render 函数,无须调整。
最后,我觉得应该增加一点可视化的工作,将结果直观的打印出来,虽然这不是解题的必要,但数据可视化绝对是一种人文关怀。
加个打印结果的函数:
// 打印结果 // 参数说明 - index:当前解法的序号;solution:皇后分布的坐标 func visualize(index int, solution [][2]int) { fmt.Println("Solution ", index) fmt.Println(strings.Repeat("-", 2*n-1)) for y := 0; y < n; y++ { for x := 0; x < n; x++ { if x == solution[y][0] && y == solution[y][1] { fmt.Print("Q ") } else { fmt.Print("* ") } } println() } fmt.Println(strings.Repeat("-", 2*n-1)) }
函数 visualize 的调用,自然应该发生在 place 函数体的 else 部分,并且顺便记录一下解法的数量(加一个统计变量 total,统计解法总数)
else { // 当n个皇后均已放置时,表示一次求解的完成 total++ visualize(total, queens) }
最终的代码长这样:
// 8 QUEENS PUZZLE package main import ( "fmt" "strings" ) // 棋盘规格 const n int = 4 // 统计解法总数 var total int func main() { // 用于记录已放置的皇后 var queens [][2]int // 递归求解 place(queens) } // 放置下一个皇后 // 函数的参数为已放置的皇后的坐标集 func place(queens [][2]int) { // 获取已放置的皇后数量 y := len(queens) // 当已放置的皇后数量未达到n个之前,继续求解动作 if y < n { // 计算下一行的棋格状态 nextRank := render(queens) for x := 0; x < n; x++ { // 当遍历到下一行的可用棋格时 if !nextRank[x] { // 放置一个皇后 queens = append(queens, [2]int{x, y}) // 然后继续尝试下一个皇后的放置 place(queens) // 当上一句的递归调用结束时,表示本次求解过程的结束 // 此时,无论求解是否成功,均需要还原本次的状态(即拿起皇后,准备尝试下一次放置) queens = queens[:y] } } } else { // 当n个皇后均已放置时,表示一次求解的完成 total++ visualize(total, queens) } } // 根据已放置的皇后,更新下一行棋格的状态 // 返回一个含4个bool类型元素的数组,true表示受攻击的,false表示未受攻击。 func render(queens [][2]int) [n]bool { var rank [n]bool y := len(queens) for x := 0; x < n; x++ { for _, queen := range queens { if x-queen[0] == y-queen[1] || x == queen[0] || x+y == queen[0]+queen[1] { rank[x] = true break } } } return rank } // 打印结果 // 参数说明 - index:当前解法的序号;solution:皇后分布的坐标 func visualize(index int, solution [][2]int) { fmt.Println("Solution ", index) fmt.Println(strings.Repeat("-", 2*n-1)) for y := 0; y < n; y++ { for x := 0; x < n; x++ { if x == solution[y][0] && y == solution[y][1] { fmt.Print("Q ") } else { fmt.Print("* ") } } println() } fmt.Println(strings.Repeat("-", 2*n-1)) }
运行结果如下:
将 const n int = 4 改成 const n int = 8 .
终于,得到八皇后的答案了。
共92种互不相同的解。
拿到结果了,可以再研究研究过程了。去掉可视化工作,只计算解法数量,然后看看程序的性能。
注释掉 visualize 函数的调用,并将 main 函数改造一下,统计程序运行的时间:
func main() { start := time.Now() // 用于记录已放置的皇后 var queens [][2]int // 递归求解 place(queens) end := time.Now() elapsed := end.Sub(start) fmt.Println("Total:", total) fmt.Println("Elapsed:", elapsed) }
在我老旧的一款ThinkPad E系列笔记本上
运行结果如下:
998微秒,不到1毫秒,看上去不错。
至此,八皇后的问题彻底完结。
事实上,n 皇后的问题也顺便完结了。
将常量 n 改成9,试试看:
共352种互不相同的解,耗时1.99毫秒。
n = 10 时:
8.99毫秒算出724种互不相同的解。
就这样吧……
后 记
本来以为这个问题就算研究完了,直到有一天和老爸的另一次通话——
“你上次找老子要国际象棋的那个问题,后来想出来没有?”
“爸,那小儿科我当天挂完电话,分分钟就解出来了。”
“滚远点,怕不是买了4副象棋吧?”
“怎么可能,我可以心算8盘棋。”
“滚远点,你那个问题我后来也想了的,很简单的问题啊。”
“啊?”(What??? 老爸也解八皇后?)
“你题目只说了摆八个皇后,没说不让摆其它的棋子,对吧?你用其它的兵啊、马啊等棋子把八个皇后隔开,就可以做到互不攻击了。”
“@#¥%&*……”
“还有事吗?”
“没没,没了,爸。”(持续晕眩中)
“早点休息,多喝水,天气冷了注意加衣服,少熬夜。就这。”
——对方挂断通话
附:老爸的解法
本文已独家授权给脚本之家(ID:jb51net)公众号发布