探究一个问题:全体数独题的总解数是多少?

问题:全体数独题的总解数是多少?

这个问题,在最初写 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

 

posted on 2021-10-23 22:05  readalps  阅读(411)  评论(0编辑  收藏  举报

导航