浅析回溯法
想必大家都玩过一些智力游戏,比如解数独,华容道,魔方,甚至围棋这些。 让我们用计算机去解决这些问题,并且使得时间尽可能短,一种可能的方法是回溯法,现在已经有用机器学习中的对抗学习(GAN)以及强化学习(RL),著名的有AlphaGo以及OpenAI,后者甚至可以在最难的MOBA类游戏战胜人类。
推荐阅读Liweiwei1419大佬的「回溯算法入门级讲解」
679. 24点游戏
你有 4 张写有 1 到 9 数字的牌。你需要判断是否能通过 *,/,+,-,(,) 的运算得到 24。
示例 1:
输入: [4, 1, 8, 7]
输出: True
解释: (8-4) * (7-1) = 24
示例 2:
输入: [1, 2, 1, 2]
输出: False
注意:
`1. 除法运算符 / 表示实数除法,而不是整数除法。例如 4 / (1 - 2/3) = 12 。
- 每个运算符对两个数进行运算。特别是我们不能用 - 作为一元运算符。例如,[1, 1, 1, 1] 作为输入时,表达式 -1 - 1 - 1 - 1 是不允许的。
- 你不能将数字连接在一起。例如,输入为 [1, 2, 1, 2] 时,不能写成 12 + 12 。
暴力法
思路:因为此题规模不大,可以采用暴力法,我们先把4个数每一种排列考虑到,然后把每一种计算顺序考虑一下(最多三次运算),括号有两种情况,一个是a*(b-(c/d))
,另一种是(a+b)\*(c+d)
这里是举例子。最后是精度问题保证|res-24.0|<1e-5
即可。
回溯法
一共有四个数,三种操作。首先从四个数有序的选出两个数,有 4 × 3 = 12 4×3=12 4×3=12种方法,并选择加减乘除不同的,用得到的结果取代选出的两个数字。
在剩下3个数有序选择2个数字,有6种方法,并选择四种运算符之一。
最后剩下2个数字,有两种不同的顺序,并选择4种运算操作之一。因此共有 12 × 4 × 6 × 4 × 2 × 4 = 9216 12×4×6×4×2×4=9216 12×4×6×4×2×4=9216种选法。
实现时,有一些细节需要注意:
-
除法运算为实数除法,因此结果为浮点数,列表中存储的数字也都是浮点数。在判断结果是否等于 2424 时应考虑精度误差,这道题中,误差小于 1 0 − 6 10^{-6} 10−6 可以认为是相等。
-
进行除法运算时,除数不能为 0,如果遇到除数为 0 的情况,则这种可能性可以直接排除。由于列表中存储的数字是浮点数,因此判断除数是否为 0 时应考虑精度误差,这道题中,当一个数字的绝对值小于 1 0 − 6 10^{-6} 10−6 时,可以认为该数字等于 0。
还有一个可以优化的点。
加法和乘法都满足交换律,因此如果选择的运算操作是加法或乘法,则对于选出的 22 个数字不需要考虑不同的顺序,在遇到第二种顺序时可以不进行运算,直接跳过。
class Solution {
public:
static constexpr int TARGET = 24;
static constexpr double EPSILON = 1e-6;
static constexpr int ADD = 0, MULTIPLY = 1, SUBTRACT = 2, DIVIDE = 3;
bool judgePoint24(vector<int> &nums) {
vector<double> l;
for (const int &num : nums) {
l.emplace_back(static_cast<double>(num));//转为double
}
return solve(l);
}
bool solve(vector<double> &l) {
if (l.size() == 0) {
return false;
}
if (l.size() == 1) {
return fabs(l[0] - TARGET) < EPSILON;
}
int size = l.size();
for (int i = 0; i < size; i++) {
for (int j = 0; j < size; j++) {
if (i != j) {
vector<double> list2 = vector<double>();
for (int k = 0; k < size; k++) {
if (k != i && k != j) {
list2.emplace_back(l[k]);
}
}
for (int k = 0; k < 4; k++) {
if (k < 2 && i > j) {
continue;
}
if (k == ADD) {
list2.emplace_back(l[i] + l[j]);
} else if (k == MULTIPLY) {
list2.emplace_back(l[i] * l[j]);
} else if (k == SUBTRACT) {
list2.emplace_back(l[i] - l[j]);
} else if (k == DIVIDE) {
if (fabs(l[j]) < EPSILON) {
continue;
}
list2.emplace_back(l[i] / l[j]);
}
if (solve(list2)) {
return true;
}
list2.pop_back();
}
}
}
}
return false;
}
};
复杂度分析
-
时间复杂度: O ( 1 ) O(1) O(1)。一共有 9216 9216 9216 种可能性,对于每种可能性,各项操作的时间复杂度都是 O ( 1 ) O(1) O(1),因此总时间复杂度是 O ( 1 ) O(1) O(1)。
-
空间复杂度: O ( 1 ) O(1) O(1)。空间复杂度取决于递归调用层数与存储中间状态的列表,因为一共有 4 4 4 个数,所以递归调用的层数最多为 4 4 4,存储中间状态的列表最多包含 4 4 4个元素,因此空间复杂度为常数。
51. N皇后
学会使用编码,使访问指定元素的时间复杂度为 O ( 1 ) O(1) O(1)
方法一:基于集合的回溯
为了判断一个位置所在的列和两条斜线上是否已经有皇后,使用三个集合$ columns、diagonals_1 $ 和
diagonals
2
\textit{diagonals}_2
diagonals2 分别记录每一列以及两个方向的每条斜线上是否有皇后。
列的表示法很直观,一共有 N 列,每一列的下标范围从 0 到 N-1,使用列的下标即可明确表示每一列。
如何表示两个方向的斜线呢?对于每个方向的斜线,需要找到斜线上的每个位置的行下标与列下标之间的关系。
方向一的斜线为从左上到右下方向,同一条斜线上的每个位置满足行下标与列下标之差相等,例如 (0,0)和 (3,3)在同一条方向一的斜线上。因此使用行下标与列下标之差即可明确表示每一条方向一的斜线。
方向二的斜线为从右上到左下方向,同一条斜线上的每个位置满足行下标与列下标之和相等,例如 (3,0) 和 (1,2) 在同一条方向二的斜线上。因此使用行下标与列下标之和即可明确表示每一条方向二的斜线。
每次放置皇后时,对于每个位置判断其是否在三个集合中,如果三个集合都不包含当前位置,则当前位置是可以放置皇后的位置。
Bibliography
https://deepmind.com/research/case-studies/alphago-the-story-so-far
每次放置皇后时,对于每个位置判断其是否在三个集合中,如果三个集合都不包含当前位置,则当前位置是可以放置皇后的位置。
37. 解数独
非常经典的回溯法应用,而且具有一定复杂度。可以和状态压缩一起学习。
class Solution {
public:
int n = 3;//方格大小
int N = n*n;//横纵列的大小
int box_index = 0;
int count_tb = 0;// 用于统计调用栈数量
bool sudokuSolved = false;
vector<vector<int>> row_now, col_now, box_now;
vector<vector<char>> board;
int count_box(vector<int> a,int k)
{
return count(a.begin(),a.end(),k);
}
bool IsValidPlacement(int row, int col, int k)
{// Check if the placement of k is valid or not
int a = count_box(row_now[row],k);
int b = count_box(col_now[col],k);
box_index = (row/3)*3 + col/3;
int c = count_box(box_now[box_index],k);
return ( a + b + c == 0);
}
void placeNumber(int row, int col, int i)
{
box_index = (row/3)*3 + col/3;
board[row][col] = char(i+'0');
row_now[row].push_back(i);
col_now[col].push_back(i);
box_now[box_index].push_back(i);
}
void removeNumber(int row, int col)
{
box_index = (row/3)*3 + col/3;
board[row][col] = '.';
row_now[row].pop_back();
col_now[col].pop_back();
box_now[box_index].pop_back();
}
inline void placeNextNum(int row, int col)
{//从1-9不断往空格内填入新的数字,同时更新三个数组,否则回溯,调用trace_back
if(row==(N-1)&&row_now[row].size()==N)//到达最后一个方格,表示问题解决
{sudokuSolved = true;return;}
if(row_now[row].size()==N) {traceback(row+1,0);return;}
traceback(row,col+1);
}
inline void traceback(int row, int col)
{
count_tb ++;
if(board[row][col]=='.')
for(int i = 1; i<10;i++)
{
if(IsValidPlacement(row,col,i))
{
placeNumber(row, col ,i);
placeNextNum(row,col);
if(!sudokuSolved) removeNumber(row,col);
else return;
}
}
else traceback(row,col+1);
}
void solveSudoku(vector<vector<char>>& board) {
//先将每行,每列,每个格子内已有的数存到数组之中
this->board = board;//方便全局共享
row_now.resize(N); col_now.resize(N);box_now.resize(N);//对类内vector初始化
int box_index = 0;
for(int row = 0;row < 9;row++)
for(int col = 0;col < 9;col++)
{
box_index = (row/3)*3 + col/3;
if(board[row][col]!='.')
{
row_now[row].push_back(board[row][col]-'0');
col_now[col].push_back(board[row][col]-'0');
box_now[box_index].push_back(board[row][col]-'0');
}
}
traceback(0,0);
cout<<"After Calculating:"<<endl;
print_board(this->board);
board = this->board;
}
Bibliography
https://deepmind.com/research/case-studies/alphago-the-story-so-far