探究一个问题:全体数独题的总解数是多少?
问题:全体数独题的总解数是多少?
这个问题,在最初写 SudokuSolver 1.0 时,就已经想到了。在 SudokuSolver 1.0:用C++实现的数独解题程序 【二】 的末尾专门提到了全空数独题,并指出:它的解是最多的,事实上任意一个数独题的解都是它的解。
这里要探究的问题实际就是全空数独题有多少个解的问题。前两天在 SudokuSolver 2.4 的基础上实现了一个 runrrun <sum> 的命令,比如 runrun 10000 是让程序接到命令开始找到一万个新解(或找到全体剩余的解)才停止回到交互状态准备接受下一个命令。这个新版的程序陆续跑了几天,最新采样的结果是:
Order please: runrun 41000000 ... Run-run time: 5749367 milliseconds; current solutions: 41000000 steps: 0 # 1 # 120797048 total solutions: 0 # 700123572. Order please: show 123 456 789 456 789 123 789 123 456 245 867 931 967 312 548 831 945 672 572 698 314 618 534 297 394 271 865 Fine [steps:120797048, solution sum:700123572]. Order please:
从输出的信息看,当时求得的总解数已经超过 7 亿,而且这些解的前三行填值完全一样,即:
123 456 789 456 789 123 789 123 456
由此也可大致推断最终的总解数会是一个非常大的数,为说明方便,把这个数记作 δ。
另外,还可以看到 steps 统计值发生了溢出,即超出了 232 被截断为 120797048。这在意料之中,步数比解数增长更快,更容易溢出。为了应对溢出问题,改进的程序在 m_steps 基础上又增加了两个分量:m_bigSteps 和 m_bigbigSteps,因此总步数会等于 m_bigbigSteps × 264 + m_bigSteps × 232 + m_steps;同样,在 m_soluSum 基础上增加了 m_bigSum,总解数会等于 m_bigSum × 232 + m_soluSum。
δ 的上界估算
在进一步探讨改进程序之前,先用数学方法大致估算一下 δ 的上界。
假设全体解中第一行填值为 123 456 789 的总解数为 S。记 A = {1,2,3,4,5,6,7,8,9},则 A 到自身的双射会有 9! 种,考虑其中任意一个非恒等变换,比如 (1,2,3,4,5,6,7,8,9) -> (2,3,4,5,6,7,8,9,1),会得到 S 个第一行填值为 234 567 891 的解,因此 δ = 9! · S。
9! = 362,880,假设用程序对如下的数独题(后面简记为 1R 数独题)
123 456 789 000 000 000 000 000 000 000 000 000 000 000 000 000 000 000 000 000 000 000 000 000 000 000 000
求其总解数 S 需要一年的时间,那么对全空数独题用程序求解其总解数 δ 则需要 9! = 362,880 年。
继续估算 S 的上界。在 1R 数独题中,逐个考察第 1 宫中的 6 个空位:[2,1] 位置上有 6 个候选值;选定 [2,1] 位置取值后,[2,2] 位置上有 5 个候选值;依此类推,即如下所示:
123 456 789 654 000 000 321 000 000
这里加粗的数字不是表示对应空位上的填值,而是对应空位上填值的可能个数。这说明第 1 宫里的 6 个空位的整体填值会有 6! 种可能。同样,考察第 1 列,又会得到如下的情形:
123 456 789 654 000 000 321 000 000 600 000 000 500 000 000 ① 400 000 000 300 000 000 200 000 000 100 000 000
即第 1 列的第 2 节和第 3 节构成的 6 个空位整体会有 6! 种填值可能。
继续依次考察第 2 行的 6 个空位,[2,4] 位置上的值不能为 4、5、6,也不能为第 2 行第一节里的三个数 a、b、c,但这时会出现不确定的情形,比如 a、b、c 都选自 {4,5,6},则 [2,4] 位置上的值会有 6 种可能,而如果 a、b、c 都选自 {7,8,9},则[2,4] 位置上的值只有 3 种可能,为估算 S 的上界,取其最大的可能数,依次类推,于是有:
123 456 789 654 654 321 321 000 000
同样地,考察第 2 宫和第 3 宫,会有:
123 456 789 654 654 321 321 321 321
继续依次考察第 4 宫、第 2 列、第 3 列,会有:
123 456 789 654 654 321 321 321 321 665 000 000 543 000 000 421 000 000 333 000 000 222 000 000 111 000 000
继续依次考察第 4 行、第 4 列、第 5 宫、第 5 行、第 6 行、第 5 列、第 6 列、第 7 列、第 7 行、第 8 行、第 9 行,会有:
123 456 789 654 654 321 321 321 321 665 654 321 543 543 321 421 421 321 333 333 321 222 222 221 111 111 111
这样就得到了 S 的一个上界,即:
S < (6! · 6! · 6! · 3! · 3!) · (6! · 3! · 3! · 6!) · (5! · 4! · 3! · 3!) · (3! · 3! · 3! · 2! · 2!)
= 6!5 · 5! · 4! · 3!9 · 2!2 = 7205 · 120 · 24 · 69 · 4
= 193,491,763,200,000 · 116,095,057,920
= 22,463,437,455,746,924,544,000,000
于是
δ = 9! · S < 362,880 · 22,463,437,455,746,924,544,000,000
= 8,151,532,183,941,443,978,526,720,000,000
对比一下这些大数:
264 = 18,446,744,073,709,551,616
S < 22,463,437,455,746,924,544,000,000
296 = 79,228,162,514,264,337,593,543,950,336
δ < 8,151,532,183,941,443,978,526,720,000,000
可以看到 S 比 大很多数量级,因此上述的改进程序在m_soluSum 基础上增加一个 m_bigSum 是不够的,还需增加一个 m_bigbigSum(因为 S < 296);而如果直接对全空数独题用程序去累计求 δ 的值,则还需要增加 m_bigbigbigSum(因为 δ > 296)。
具体估算一下程序对 1R 数独题直接求 S 的值的时间。先看一下求一万个解需要多长时间(对 1R 数独题求解):
Run-run time: 2337 milliseconds; current solutions: 10000
steps: 0 # 0 # 445674
total solutions: 0 # 0 # 70003.
就用 2 秒一万算,一小时 1800 万,一天 4.32 亿,一年是:
432,000,000 · 365 = 157,680,000,000
而 S ≈ 22,463,437,455,746,924,544,000,000
S 还是太大了。由上面的 ① 可以构造如下一个数独题:
123 456 789 456 000 000 789 000 000 200 000 000 300 000 000 500 000 000 600 000 000 800 000 000 900 000 000
即第 1 行、第 1 宫、第 1列是满填状态,其余都为空位,把这个数独题称作 1R1B1C 数独题。假设这个题有 H 个解,由上述分析,有:
H = S / (6! · 6!) ≈ 43,332,248,178,524,160,000
而
264 = 18,446,744,073,709,551,616
296 = 79,228,162,514,264,337,593,543,950,336
虽然 H < 296,但是 2 秒一万的求解速度还是太慢了,要求出 H 的具体值大约需要:
43,332,248,178,524,160,000 / 157,680,000,000 ≈ 274,811,315.19 年
约 2.75 亿年啊!
这个问题还得寻求更好的数学方法来求解。
最后附上 SudokuSolver 2.5 的新增代码实现:
CQuizDealer 类声明部分的修改
增加 runrun 接口:
void runrun(ulong newsum = 0);
新增步数和解数统计相关成员:
ulong m_soluSum; ulong m_bigSum; ulong m_bigbigSum; ulong m_steps; ulong m_bigSteps; ulong m_bigbigSteps;
incSteps 接口实现修改以及新增 incSolutions接口:
inline void incSteps() { if (m_steps != 0xFFFFFFFF) ++m_steps; else { m_steps = 0; if (m_bigSteps != 0xFFFFFFFF) printf("bigSteps:%u\n", ++m_bigSteps); else { m_bigSteps = 0; printf("bigbigSteps:%u\n", ++m_bigbigSteps); } } } inline void incSolutions() { ++s_soluSum; if (m_soluSum != 0xFFFFFFFF) ++m_soluSum; else { m_soluSum = 0; if (m_bigSum != 0xFFFFFFFF) printf("current big solution sum:%u\n", ++m_bigSum); else { m_bigSum = 0; printf("current big-big solution sum:%u\n", ++m_bigbigSum); } } }
其中 s_soluSum 是个静态变量:
static ulong s_soluSum = 0;
相应接口实现小修改
parse 接口实现末尾的:
m_soluSum++;
换成
incSolutions();
adjust 接口实现内部也有一处同样的替换。
runrun 接口实现
1 void CQuizDealer::runrun(ulong newsum) 2 { 3 if (m_state == STA_UNLOADED) { 4 printf("Quiz not loaded yet.\n"); 5 return; 6 } 7 s_soluSum = 0; 8 if (newsum == 0) 9 newsum = 0x10000; 10 clock_t begin = clock(); 11 while (m_state != STA_INVALID && s_soluSum < newsum) { 12 if (m_state == STA_LOADED) 13 parse(); 14 else if (m_state == STA_VALID) 15 adjust(); 16 else if (m_state == STA_DONE) { 17 if (m_stkSnap.empty()) 18 break; 19 m_state = STA_VALID; 20 nextGuess(); 21 } 22 } 23 std::cout << "Run-run time: " << clock() - begin << " milliseconds; current solutions: " << s_soluSum << std::endl; 24 std::cout << " steps: " << m_bigbigSteps << " # " << m_bigSteps << " # " << m_steps << std::endl; 25 std::cout << " total solutions: " << m_bigbigSum << " # " << m_bigSum << " # " << m_soluSum << ".\n"; 26 }
前期版本的两处 bug 修改
一处在 showQuiz 接口的如下位置:
if (m_state == STA_DONE) { printf("\nFine [steps:%u, solution sum:%u].\n", m_steps, m_soluSum); return; }
其中的“Fine”,之前是“Done”,“Done”会传递所有解都已求完的意思。
另一处也在 showQuiz 接口,位置如下:
u8 foremost = 81; ...
if (foremost != 81) printf("The foremost cell with %d candidate(s) at [%d,%d]\n", (int)least, (int)(foremost / 9 + 1), (int)(foremost % 9 + 1));
此前 foremost 的初始值为 0,后面输出“The foremost cell with ...”信息的条件是 foremost != 0,会导致下标为 0 的 [1,1] 空位如果正好是最少候选值空位时,这条信息不会输出。比如:
Steps:22 Candidates: [1,1]: 6 9 [1,2]: 4 9 [1,5]: 2 4 6 8 [1,6]: 2 4 6 7 8 [1,7]: 1 6 8 [1,8]: 1 4 6 9 [1,9]: 4 7 8 [2,2]: 3 4 9 [2,4]: 4 6 7 9 [2,6]: 4 6 7 [2,7]: 6 [2,9]: 3 4 7 [3,1]: 3 6 9 [3,4]: 4 6 9 [3,6]: 4 6 8 [3,8]: 4 6 9 [3,9]: 3 4 8 [4,4]: 1 [6,5]: 4 6 [6,6]: 4 6 [7,1]: 1 3 [7,5]: 2 3 4 8 [7,6]: 1 2 4 8 [7,7]: 1 2 8 [7,8]: 1 4 [8,1]: 1 5 9 [8,2]: 2 9 [8,4]: 1 6 7 [8,5]: 2 6 8 [8,6]: 1 2 6 7 8 [8,7]: 1 2 6 8 [8,9]: 5 8 [9,1]: 1 3 5 [9,2]: 2 3 [9,4]: 1 4 6 [9,5]: 2 3 4 6 [9,8]: 1 4 6 [9,9]: 4 5 The foremost cell with 1 candidate(s) at [2,7] At guess level 4 [3,3] 1 Run time: 152 milliseconds; steps: 22, solution sum: 0. Order please: step 005 300 000 801 050 620 072 010 500 486 195 372 219 873 456 753 200 981 067 500 009 004 000 030 008 009 700 Steps:23 Candidates: [1,1]: 6 9 [1,2]: 4 9 [1,5]: 2 4 6 8 [1,6]: 2 4 6 7 8 [1,7]: 1 8 [1,8]: 1 4 9 [1,9]: 4 7 8 [2,2]: 3 4 9 [2,4]: 4 7 9 [2,6]: 4 7 [2,9]: 3 4 7 [3,1]: 3 6 9 [3,4]: 4 6 9 [3,6]: 4 6 8 [3,8]: 4 9 [3,9]: 3 4 8 [6,5]: 4 6 [6,6]: 4 6 [7,1]: 1 3 [7,5]: 2 3 4 8 [7,6]: 1 2 4 8 [7,7]: 1 2 8 [7,8]: 1 4 [8,1]: 1 5 9 [8,2]: 2 9 [8,4]: 6 7 [8,5]: 2 6 8 [8,6]: 1 2 6 7 8 [8,7]: 1 2 8 [8,9]: 5 8 [9,1]: 1 3 5 [9,2]: 2 3 [9,4]: 4 6 [9,5]: 2 3 4 6 [9,8]: 1 4 6 [9,9]: 4 5 At guess level 4 [3,3] 1 Order please:
22 步时,输出各空位候选值分布情况部分带有一行 The foremost cell 的信息;而在 23 步时,输出各空位候选值分布情况部分就没有带。
其他小修改
// 1.0 2021/9/20 // 2.0 2021/10/2 // 2.1 2021/10/4 // 2.2 2021/10/10 // 2.3 2021/10/17 // 2.4 2021/10/19 #define STR_VER "Sudoku Solver 2.5 2021/10/23 by readalps\n\n" void showOrderList() { ...
printf("runrun <sum>: run till the end or certain new solutions met\n"); printf("bye: quit\n"); } void dealOrder(std::string& strOrder) { ...
else if (matchPrefixEx(strOrder, "runrun ", strEx)) { ulong newsum = strtoul(strEx.c_str(), 0, 10); CQuizDealer::instance()->runrun(newsum); } else showOrderList(); }
"No alarms, no surprises" -- OK Computer