65.9K
CodeProject 正在变化。 阅读更多。
Home

数独解题器和生成器

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.55/5 (38投票s)

2005年10月18日

CPOL

5分钟阅读

viewsIcon

441516

downloadIcon

26384

解题并生成数独。

引言

不久前,我的一个同事向我介绍了一款名为数独的游戏。我对此游戏一无所知,但很快就明白了规则,然后很快意识到,做一个程序来解决这个问题会比解决一个单一的谜题快得多!

数独规则

数独的规则很简单。你有一个 9x9 的棋盘,棋盘进一步划分为九个 3x3 的子正方形。在每个子正方形、每行和每列中,你必须唯一地填入数字 1-9。

在创建数独时,我们必须记住,它只能有一个解,否则它不被认为是真正的数独。

解决谜题

当类初始化并且设置了数独谜题进行求解时,我们可以让 `Solve()` 函数开始工作。在每次迭代中,我们希望找到棋盘上信息最多的位置。我们从一个包含该位置所有可能解的初始集合 `M` 开始。

// Set M of possible solutions
byte[] M = {0,1,2,3,4,5,6,7,8,9};

然后我们移除垂直方向上所有已使用的出现项

for(int a = 0; a < 9; a++)
    M[m_sudoku[a,x]] = 0;

以及水平方向上

for(int b = 0; b < 9; b++)
    M[m_sudoku[y,b]] = 0;

最后,我们移除子正方形中所有已使用的出现项。为了加快可行性测试并简化代码,我决定使用子正方形的查找表。首先,我们通过使用一个将位置映射到子正方形的表,从当前位置获取子正方形表的索引。

int squareIndex = m_subSquare[y,x];

然后我们使用一个子索引数组来获取二维数组中的实际位置。

EntryPoint p = m_subIndex[squareIndex,c];

最后一段代码片段用于一个移除正方形中所有出现项的循环。

for(int c = 0; c < 9; c++)
{
    EntryPoint p = m_subIndex[squareIndex,c];
    M[m_sudoku[p.x,p.y]] = 0;
}

然后我们计算集合 `M` 的基数。

int cM = 0;
for(int d = 1; d < 10; d++)
    cM += M[d] == 0 ? 0 : 1;

如果当前集合的基数小于之前的最小基数,则当前位置是迄今为止评估出的最佳位置。

if(cM < cMp)
{
    cMp = cM;
    Mp = M;
    xp = x;
    yp = y;
}

最小基数 `cMp` 最初设置为 `10`,如果它没有改变,我们可以确定棋盘上没有空位,并且可以成功退出。

if(cMp == 10)
    return true;

另一方面,如果最小集合的基数为 `0`,即有一个空的可能元素集合 `M`,我们可以确定没有解决方案,并且必须回溯。

if(cMp == 0)
    return false;

当所有基本情况都得到处理后,我们可以开始迭代过程,依次尝试 `M` 中的每个元素。

for(int i = 1; i < 10; i++)
{
    if(Mp[i] != 0)
    {
        m_sudoku[yp,xp] = Mp[i];
        if(Solve())
            return true;
    }
}

// Restore to original state.
m_sudoku[yp,xp] = 0;
return false;

该循环依次用 `M` 中的每个元素替换未使用的位置,并以递归方式尝试求解。当 `M` 被耗尽时,我们返回 `false`,表示没有解决方案。如果函数成功返回,则可以在 `Data` 属性中读取解决方案,如示例所示。

...
Sudoku s = new Sudoku();
s.Data = SudokuToSolveFor;
if(s.Solve())
    byte[,] SudokuSolved = s.Data;
else
    // No solution
...

生成数独

我很快意识到手动输入数独太无聊了,于是着手进行生成数独的任务。我的要求是,你应该能够指示需要填充多少个位置,并提供一个可能的起始模式。如果可能的起始模式第一次尝试不成功,它就可以被丢弃,并且可以生成一个全新的模式,否则我们可能会陷入一个没有解决方案的模式,考虑到整个数独空间的规模,这在复杂性方面是很糟糕的,程序会进行一定次数的重试。

函数 `Generate(int nodes, int numberOfTries = 1000000)` 是所有功能所在的地方。我们开始计算当前数据集已使用的位置数量,然后决定是重新开始还是,然后生成一个全新的数独。

int num = GetNumberSpots();

if(!IsSudokuFeasible() || num > nodes)
{
    // The supplied data is not feasible, clear data.
    // - or -
    // The supplied data has too many nodes set, clear data.
    return Tuple.Create(0L, false);
}

生成指定数量的位置,然后测试数独的唯一性。

do
{
    var originalData = Data;

    long tries = 0;
    for (; tries < numberOfTries; tries++)
    {
        // Try to generate spots
        if (Gen(spots - num))
        {
            // Test if unique solution.
            if (IsSudokuUnique())
            {
                return Tuple.Create(tries, true);
            }
        }

        // Start over.
        Data = originalData;
    }

    return Tuple.Create(tries, false);

该循环一直运行直到找到解决方案运行指定次数的迭代。如果我们想在搜索过程中中止,这里有改进的空间。`Gen(int spots)` 函数首先在 9x9 的棋盘上生成一个随机位置。为了在单元测试中实现确定性,随机生成器实现了 `IRandomizer` 接口,在生产环境中是非确定性的,但在单元测试中是确定性的。

do
{
    xRand = Randomizer.GetInt(9);
    yRand = Randomizer.GetInt(9);
} while(m_sudoku[yRand,xRand] != 0);

对于每个随机位置,我们必须检查可行值,这几乎与求解器中的方式相同。

// Set M of possible solutions
byte[] M = {0,1,2,3,4,5,6,7,8,9};

// Remove used numbers in the vertical direction
for(int a = 0; a < 9; a++)
    M[m_sudoku[a,xRand]] = 0;

// Remove used numbers in the horizontal direction
for(int b = 0; b < 9; b++)
    M[m_sudoku[yRand,b]] = 0;

// Remove used numbers in the sub square.
int    squareIndex = m_subSquare[yRand,xRand];
for(int c = 0; c < 9; c++)
{
    point p = m_subIndex[squareIndex,c];
    M[m_sudoku[p.x,p.y]] = 0;
}

int cM = 0;
// Calculate cardinality of M
for(int d = 1; d < 10; d++)
    cM += M[d] == 0 ? 0 : 1;

如果基数大于零,我们从可行集 `M` 中随机抽取一个样本。

if(cM > 0)
{
    int e = 0;

    do
    {
        // Randomize number from the feasible set M
        e =  Randomizer.GetInt(1,10);
    } while(M[e] == 0);

    // Set number in Sudoku
    m_sudoku[yRand,xRand] = (byte)e;
}

如果集合 `M` 为空,这就不可能是一个数独,我们重新开始该过程,直到找到一个非空的集合 `M`。当所有给定的位置都生成完毕后,我们在 `TestUniquness()` 函数中尝试唯一性。唯一性测试是通过尝试生成多个解决方案来实现的;一旦存在多个解决方案,生成的集合将不可行,然后生成一个新的解决方案。

... // same as in Solve()

int success = 0;
for(int i = 1; i < 10; i++)
{
    if(Mp[i] != 0)
    {
        m_sudoku[yp,xp] = Mp[i];

        switch(TestUniqueness())
        {
            case Ret.Unique:
                success++;
                break;

            case Ret.NotUnique:
                return Ret.NotUnique;

            case Ret.NoSolution:
                break;
        }

        // More than one solution found?
        if(success > 1)
            return Ret.NotUnique;
    }
}

...

switch(success)
{
    case 0:
        return Ret.NoSolution;

    case 1:
        return Ret.Unique;

    default:
        // Won't happen.
        return Ret.NotUnique;
}

示例应用

为了演示如何使用该类,我制作了一个使用 Windows Forms 的小型、基础的应用程序。通过这个应用程序,你可以生成、求解、打印、加载和保存数独。

历史

  • 2010 年 7 月 31 日 - 文章更新
    • 提交了一个存在了大约五年的 bug 修复。
    • 将解决方案迁移到 Visual Studio 2010 和 .NET 4.0。
    • 将解决方案拆分为三个程序集。
    • 添加了元组和可选参数 (C# 4.0)。
    • 添加了单元测试 (`Microsoft.VisualStudio.TestTools.UnitTesting`)。
  • 2005 年 10 月 18 日 - 文章提交
  • 2005 年 10 月 2 日 - Windows Forms 框架
  • 2005 年 9 月 25 日 - 数独类
© . All rights reserved.