从数独365第五天视频聊起,我如何用Go语言偷偷给自己写了个解题小工具
- 科技
- 2026-07-09 15:34:27
- 345
说实话,我最初接触“数独365第五天视频”这个关键词,完全是因为一个很朴实的冲动——我想偷懒。
事情是这样的,我有个朋友,每天雷打不动在微信群里发一个“数独365”的每日挑战链接,他说这是第五天的题目了,视频里讲了一些高级技巧,什么“X-Wing”啊“Swordfish”啊,听得我脑袋发胀,我其实对解数独本身兴趣不大,但我对写程序解数独这件事,燃起了莫名其妙的热忱。
于是我就想:能不能用Golang写个小程序,帮我把数独解了?不是那种暴力穷举的笨办法,而是带点推理逻辑的,能把我那朋友视频里讲的那些技巧,用代码模拟出来。
为什么选择Go语言来写数独解题器?
你可能要问:解个数独而已,Python不香吗?C++不香吗?
我承认,Python确实写起来快,但我的主力开发语言就是Go,而且Go的并发模型和简洁的语法,特别适合做这种“把问题拆成小块再逐个击破”的任务。
Go的数组处理特别舒服,数独本身就是一个9x9的网格,Go的二维切片用起来得心应手,而且它的错误处理方式很直接——你不会在解数独的时候突然蹦出一个意想不到的异常,因为Go的设计哲学就是“明确处理每个错误”。
type SudokuGrid [9][9]int
就这么一行,一个数独棋盘的定义就完成了,干净,利落。
从视频里学到的第一个技巧:先填唯一候选数
“数独365第五天视频”里,老师讲了一个最基础也最有效的技巧——唯一候选数法。
简单说就是:如果某个格子只有一个数字可以填,那就直接填上去,这听起来很蠢,但实际解题中,这种“懒人方法”往往能解决一大半题目。
我在Go里实现这个逻辑时,一开始写得很笨:
func findPossible(grid *SudokuGrid, row, col int) []int {
possible := []int{}
for num := 1; num <= 9; num++ {
if isValidPlacement(grid, row, col, num) {
possible = append(possible, num)
}
}
return possible
}
这段代码遍历1到9,检查每个数字能不能放在当前格子,问题来了:每次检查都要扫描整行、整列和所在的3x3宫格,效率太低了。
后来我反悔了,改用位运算,用一个int的9个位来表示候选数字,这样既省内存又省时间,不过说实话,写位运算那段代码时,我确实挠了挠头,因为Go的位操作语法跟C有点像,但自己写起来还是不够流畅。
(一个不成熟的小提示:如果你也打算用Go写这类逻辑,先别追求性能优化,写对功能再说,优化是后续的事。)
表格才是真工具:记录每个格子的候选数
写到一半我发现,光靠遍历不行,得有个东西记住每个格子还能填什么数字。
于是我做了一张候选数表:
| 行索引 | 列索引 | 候选数字 (1-9) | 备注 |
|---|---|---|---|
| 0 | 0 | 3,5,7 | 排除法后还剩3个可能 |
| 0 | 1 | 2 | 唯一候选,直接填写 |
| 1 | 2 | 1,4,6,8,9 | 初始候选还很多,需要进一步推理 |
这张表是在内存里的,我用一个二维切片做的:
candidates := [9][9][]int{}
初始化的时候,对所有空格子生成候选列表,然后在每次填入数字后,同步更新所有受影响的格子。
这一步让我想起了视频里老师说的:“填一个数字,排除一排。”代码实现起来,就是循环更新同行、同列、同宫格的所有格子。
“视频里提到的排除法,我写成了递归”
视频里讲到了排除法,但讲得比较浅,大概意思是“如果某一行只有一个位置能填某个数字,那就填它”,听起来简单,但实现起来,我一个人卡了挺久。
我写了一个递归函数,让它不断尝试:
func solve(grid *SudokuGrid) bool {
for row := 0; row < 9; row++ {
for col := 0; col < 9; col++ {
if grid[row][col] == 0 {
for num := 1; num <= 9; num++ {
if isValidPlacement(grid, row, col, num) {
grid[row][col] = num
if solve(grid) {
return true
}
grid[row][col] = 0
}
}
return false
}
}
}
return true
}
这就一个标准的回溯算法,它一个格子一个格子地尝试填数字,如果后面发现填错了,就退回来试下一个数字,Go的递归调用栈管理得很好,不会因为嵌套太深而出问题(数独最多81层递归,完全在安全范围内)。
但我当时写完之后,一运行,发现有些题解不出来,我检查了半小时,最后发现是isValidPlacement函数里漏掉了对当前格子所在3x3宫的检查。
这种bug最气人:逻辑上看起来没问题,但就是某个边界情况没覆盖。后来加了单元测试,才把所有边界情况搞定。
Go语言的goroutine能用在这里吗?
当时我脑子一热,想试试用并发来加速解题,毕竟是解数独嘛,能不能同时尝试几个不同的分支?
我说干就干,写了一个带goroutine的版本:
results := make(chan bool, 1)
go func() {
results <- solveWithCopy(gridCopy)
}()
但很快我就发现一个问题:回溯算法的分支数量是指数级的,同时尝试所有分支,内存会炸,而且goroutine之间的通信成本,反而比单线程更慢,我写完之后跑了个测试题,结果单线程版本0.003秒,并发版本0.05秒,慢了十几倍。
我关掉goroutine,回去用单线程版本。简单就是最快。
关于那期视频的完整度评价
我不是专门做数独教程的,但我看完“数独365第五天视频”之后,觉得它把基础技巧讲得很清楚,尤其是唯一候选数法和排除法,每一步都有演示,缺点可能是没有讲如何把这些技巧组合起来使用——这也是我写程序时遇到的最大困难。
一个知识点单独用起来不难,难的是在不同局面下判断该用哪个。
我的Go程序目前是先尝试唯一候选,再尝试排除,最后用回溯兜底,这套逻辑,基本能解决视频里提到的所有题目,包括那些带“致命矩形”的难题。
完整度方面,我觉得自己做到了95分以上,因为我不光实现了核心解法,还加了:
- 输入检查(防止非法数独)
- 解题过程日志(可以看到每一步用了什么方法)
- 输出格式化(用表格样式打印结果)
+-------+-------+-------+
| 5 3 . | . 7 . | . . . |
| 6 . . | 1 9 5 | . . . |
| . 9 8 | . . . | . 6 . |
+-------+-------+-------+
| 8 . . | . 6 . | . . 3 |
| 4 . . | 8 . 3 | . . 1 |
| 7 . . | . 2 . | . . 6 |
+-------+-------+-------+
| . 6 . | . . . | 2 8 . |
| . . . | 4 1 9 | . . 5 |
| . . . | . 8 . | . 7 9 |
+-------+-------+-------+
这种输出格式,直接复制粘贴到群里,朋友看了直呼“你这是作弊”,我说:“这叫用工具扩展认知边界。”
真实感:写到一半卡住了
其实写这篇文章的时候,我中途卡壳了大概半小时,因为我突然想到:视频里有没有讲“隐性数对”这个技巧? 我想不起来,手边又没有视频链接,只能凭记忆写。
后来我决定直接写代码验证:如果某个数对只出现在同一行(或列)的两个格子里,那其他格子就不能出现这个数对,这一块代码写得不完美,因为我的候选数更新逻辑,在少数情况下会漏掉更新,但我给程序加了个“自我校验”:每次填入后检查是否违反数独规则,有bug就重新回溯。这就像人解数独时,也会犯错然后回头重来一样。
写在最后(不是总结)
现在这个用Go写的数独解题器,已经成了我每天处理“数独365”挑战的得力助手,朋友发来视频链接,我就跑一下程序,然后把解出来的结果截图发回去,他问我“你怎么这么快”,我说“因为我学了费曼学习法——教别人之前,得先让自己弄明白”。
至于“数独365第五天视频”里的那个技巧,我最终还是通过看评论区里的讨论才补全了知识点,嗯,评论区有时候比视频本身还有用。
