![3c96b4e36ef46f991b479d99b69db8e9.png](https://i-blog.csdnimg.cn/blog_migrate/101919cd949182e9cd647aa52d4c5be1.jpeg)
今天我们来看一下,回溯法的使用方法,以及它在电脑编程里的源代码。
Part 1 回溯法的原理
什么是回溯法呢?回溯法是一种数独技巧,它的逻辑是这样的:先计算出盘面的候选数序列,然后存放到一旁等候使用。然后从r1c1开始的第一个可能的候选数开始按从左到右、从上到下的顺序挨个试数,下一个单元格也找出第一个可能的候选数开始试数,一直往下试数。直到发现重复的矛盾之后,步骤回退到原始设定,可以否定它了。然后继续从第二个数开始试数,重复上述步骤,直到完成题目。
这样的技巧呢,过程呈现了“自相似”的状态,所以在编程之中,会使用到递归(Recursion)的思想。那么,递归是什么?
Part 2 递归的理解
什么是递归?最暴力的理解就是理解成“函数(或方法)自己调用自己的过程”。那么具体一点的理解呢?
我们思考一下斐波那契数列的求法。斐波那契数列的基本算法,当然是每两项的和都是后一项的数值。我们可以先设定初始数值(第0、1项的结果为0和1),然后依次往后加,就可以累计得出数值结果。它的写法如下:
/// <summary>
/// 递归计算斐波那契数列第 <paramref name="n" /> 项。
/// </summary>
/// <param name="n">计算的项。</param>
static void fibonacci(int n)
{
// 递归出口。
// 如果 n 为 0 的时候返回 0;如果为 1 的时候返回 1,这就相当于下面这种写法。
if(n == 0 || n == 1) return n;
// 递归步骤。将 return 语句执行完毕后,同样就意味着斐波那契数列的求解已经完成。
return fibonacci(n - 1) + fibonacci(n - 2);
}
教程用于教学之用,为了读者方便理解,它的优化版本此处不予讨论。
那么这个算法为什么可以这么写呢?
递归还可以直接理解为“数学的递推公式”。将数列的递推公式看成一个函数,然后将其写进去,就OK了。至于它的原理……看下面吧。
大概就是,比如计算f(5)
,根据算法,会得到f(4)
和f(3)
这两个数,因为 return 语句里必须要先计算它们,才能得到f(5)
的结果。所以,我们需要计算f(4)
和f(3)
。
然后,因为f(3)
和f(4)
也是无法直接计算的数据数值,所以仍然需要计算。f(3) = f(1) + f(2)
,而f(4) = f(2) + f(2)
。所以,f(5)
就又被继续拆分为了f(1)
、f(2)
、f(2)
和f(2)
。前面两个数来自于f(3)
的拆分结果;而后面两个则来源于f(4)
的结果。
按照这么拆分下去,总归会将所有关于f(n)
拆为若干f(1)
和f(2)
的和。这个便是编程技巧 分治法(Divide & Conquer)的思想。
如果我们解决不了一个大型问题,但这个问题可以被拆解为更小规模的计算方式,较小规模的计算方式是可以直接计算的,并且拆分前后的计算模式和思维都完全相同,只是数值规模缩小了而已,这样就可以用分治法拆解大问题。这样一来就可以把超大规模的数据拆分为若干较小规模的数据,带入计算,得到结果后,根据题目要求最后整合起来,就是结果了。
斐波那契数列就是这样,大数值解决不了就倒着想,因为它的递推公式写起来非常轻松,所以我们可以通过递推公式,将较大项数据拆解为两个较小的数据,然后较小的数据如果还不行,就继续拆解(这个拆解的地方就体现在return语句上)。
一旦拆解到一定程度后,数值n
最终一定会被缩小到1或2,这个时候就可以通过if语句直接获取结果,return出来,宣布这一轮的迭代结束了(因为递归算法的自己调用自己的思维更像是层级形式的关系而不是链条形式调用的关系,所以return语句的理解也有点不同,它在递归之中表示“宣布当前的递归完毕”,而不是“函数逻辑已经完毕”,你可以大概看一下上面的代码,上面的if语句里也有return语句,虽然它只返回了一个数,但为什么写作return语句而不是给变量赋值临时存放什么的语句呢?这就是因为,当递归到n值小到已经可以直接得到f(0)=0和f(1)=1这样的结果的时候,我们宣布当前的递归结束,所以使用的是return语句)。
另外简单说一下递归函数的调用。因为是自身调用自身的关系,很多小伙伴刚入门递归的时候,可能会认为,自己调用自己会让函数内部的一些数据计算产生紊乱(因为调用同一个函数,可能他们比较害怕函数内的变量在两次甚至更多次不同层级的调用同一个函数的时候,它们会产生混乱)。其实不用担心,这一点你尽管放心,怎么递归都不会紊乱的。
这个简易的示例,当前的斐波那契数列看作是一个函数fibonacci(n)
,然后利用“数学化”的角度,写出递推公式:
这个写法则和上述的写法是完全一样的。这就是递归。
斐波那契数列是有通项公式的:。
那么总结起来,就是这样三种特征:
- 递归会将函数自身调用自身;
- 递归一般会有至少两个情况,类似于刚才最开始得到的递推公式那样;
- 递归会将程序划分为“可计算的”和“不可计算的”两个部分,不可计算的部分就将函数本身的规模减小,能计算的部分就放入到结果之中。两者相加作为整体的结果返回。
这里解释一下第二点和第三点。“递归一般会有至少两个情况”,指的是递归本身需要有执行“规模变小”的一部分,也有作出实际任务(这里即求和操作)的一部分。两个部分应当相加。因为规模变小是最终会变化得到递归结束的位置的。我们除了要让规模变小以外,也要让函数本身要知道,什么时候程序结束递归。这个条件称为递归出口,或者叫递归基,根据刚才我们给出的引例,我们就应当知道和明白,判定递归出口就是找出什么时候是操作结束的时候。
Part 3 回溯法的代码
我们已经了解了基本的递归思想,下面我们来看一下如何使用回溯。
我们设定回溯函数叫Backtracking(int n)
,n表示已经计算的单元格数。那么,我们从0开始计算,直到计算到81格,就表示计算完毕了。
// C Sharp
static void Backtracking(int n)
{
// 递归出口。递归出口一定要写在最开始。
if(n == 81)
{
ShowGrid(testGrid); // 显示盘面的结果。是一个函数。
return; // 退出函数。
}
//
// TODO: 如果没有到 81 格,又应该怎么递归计算。
//
}
那么我们开始继续解决回溯法的核心计算思路。
首先,我们要根据数字n得到所在的行和列的数值,然后调取当前数值下,是否存在这样的候选数(存在就可以开始回溯递推,不存在就表示一定会和所在区域有重复的数字)。
那么,我们定义两个变量来表示行和列。
int row = n / 9, col = n % 9;
使用除法便可计算出所在的行(范围为0到8);使用取余操作也可以计算出所在的列(范围为0到8)。为什么不是1到9而是0到8,不末尾加1呢?因为在计算机编程之中,数组是从0开头的。也就是说,我们可以在最开头定义一个盘面,为一个二维的数组。
接下来,计算出所在行列后,先验证当前单元格是否需要试数。我们一般都知道一点,计算机代码处理数值的时候,会把题目没有填数的单元格使用数字0进行占位。所以我们可以这么写:
// 有填入的数据在里面,当前单元格就不计算,转去递归计算下一个单元格。
if(testGrid[row, col] != 0)
{
Backtracking(n + 1); // 直接进入下一个单元格的判别。
return; // 因为这里调用了 `Backtracking(int)` 自己,
// 所以能执行到 return 语句这里,说明递归已经完成。
// 按道理,既然递归都完成了,说明已经得到了解。
// 既然得到解,就应该在前面提到的地方作出结论输出。
// 所以,这里不应再往下执行后续步骤,这里需要退出方法体。
}
然后,这里为了方便写代码,我们就直接从1到9挨个遍历填入进行计算。
for(int i = 0; i < 9; i++) // 计算 9 次。
{
testGrid[row, col]++; // 逐个计算。
if(IsValid(row, col)) // 如果所在行列填入后不矛盾就往下继续计算。
Backtracking(n + 1);
}
现在这里就不要用return;
语句结束了,因为下面是计算的判定。不然全部都走上面跳出循环了就不执行下面的语句了。
那写法就直接是以下这个语句即可。
testGrid[row, col] = 0; // 赋值为 0 即达到回溯效果。
所以总的代码是这样的:
/// <summary>
/// 数独求解。
/// </summary>
/// <param name="n">已经解出的单元格数(即确定值的个数)。</param>
static void Backtracking(int n)
{
// 递归出口。递归出口一定要写在最开始。
if(n == 81)
{
ShowGrid(testGrid); // 显示盘面的结果。是一个函数。
return; // 退出递归。
}
int row = n / 9, col = n % 9;
// 有填入的数据在里面,当前单元格不计算。
if(testGrid[row, col] != 0)
{
Backtracking(n + 1); // 直接进入下一个单元格的判别。
return; // 退出递归。
}
for(int i = 0; i < 9; i++) // 计算 9 次。
{
testGrid[row, col]++; // 逐个计算。
if(IsValid(row, col)) // 如果所在行列填入后不矛盾就往下继续计算。
Backtracking(n + 1);
}
testGrid[row, col] = 0; // 赋值为 0 即达到回溯效果。
}
然后看一下,如何解决里面的IsValid函数和ShowGrid函数。哦不,是C#叫方法。
/// <summary>
/// 显示盘面。
/// </summary>
static void ShowGrid()
{
for(int i = 0; i < 9; i++)
{
for(int j = 0; j < 9; j++)
Console.WriteLine(testGrid[i, j] + " ");
Console.WriteLine();
}
Console.WriteLine("******************************");
}
/// <summary>
/// 测试盘面所在行列下是否有重复的矛盾。
/// </summary>
static bool IsValid(int row, int col)
{
int number = testGrid[row, col];
int i, j;
// 检测行列是否有重复。
for(i = 0; i < 9; i++)
if(i != row && testGrid[i, col] == number || i != col && testGrid[row, i] == number)
return false;
// 检测宫内是否有重复。
for(i = row / 3 * 3; i < row / 3 * 3 + 3; i++)
for(j = col / 3 * 3; j < col / 3 * 3 + 3; j++)
if((i != row || j != col) && testGrid[i, j] == number)
return false;
return true; // 都不重复,返回 true。
}
最后为测试盘面写成一个二维数组即可。
// 测试盘面。
static int[,] testGrid = new int[9, 9]
{
{4, 2, 5, 0, 0, 8, 3, 6, 7},
{0, 6, 0, 0, 0, 4, 1, 2, 0},
{0, 8, 0, 0, 0, 2, 0, 0, 0},
{0, 4, 0, 0, 0, 5, 9, 1, 2},
{0, 0, 0, 0, 0, 0, 0, 0, 0},
{0, 1, 9, 4, 0, 0, 6, 7, 5},
{0, 3, 0, 0, 0, 0, 0, 8, 0},
{0, 0, 6, 0, 0, 0, 0, 9, 0},
{1, 9, 4, 0, 0, 0, 0, 5, 0}
};
// 主方法。
static void Main(string[] args)
{
ShowGrid(testGrid); // 显示初盘。
Backtracking(0); // 从没有填数的单元格开始。
// 反正填了数它会自己在方法里判断出来,就不用管它了。
Console.ReadLine();
}
计算候选数的代码可以自行思考。