浅析回溯法

浅析回溯法

想必大家都玩过一些智力游戏,比如解数独,华容道,魔方,甚至围棋这些。 让我们用计算机去解决这些问题,并且使得时间尽可能短,一种可能的方法是回溯法,现在已经有用机器学习中的对抗学习(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, 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} 106 可以认为是相等。

  • 进行除法运算时,除数不能为 0,如果遇到除数为 0 的情况,则这种可能性可以直接排除。由于列表中存储的数字是浮点数,因此判断除数是否为 0 时应考虑精度误差,这道题中,当一个数字的绝对值小于 1 0 − 6 10^{-6} 106 时,可以认为该数字等于 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,使用列的下标即可明确表示每一列。

如何表示两个方向的斜线呢?对于每个方向的斜线,需要找到斜线上的每个位置的行下标与列下标之间的关系。

fig1

方向一的斜线为从左上到右下方向,同一条斜线上的每个位置满足行下标与列下标之差相等,例如 (0,0)和 (3,3)在同一条方向一的斜线上。因此使用行下标与列下标之差即可明确表示每一条方向一的斜线。

fig2

方向二的斜线为从右上到左下方向,同一条斜线上的每个位置满足行下标与列下标之和相等,例如 (3,0) 和 (1,2) 在同一条方向二的斜线上。因此使用行下标与列下标之和即可明确表示每一条方向二的斜线。

每次放置皇后时,对于每个位置判断其是否在三个集合中,如果三个集合都不包含当前位置,则当前位置是可以放置皇后的位置。


Bibliography

  1. https://deepmind.com/research/case-studies/alphago-the-story-so-far

  2. AlphaGo百度百科

每次放置皇后时,对于每个位置判断其是否在三个集合中,如果三个集合都不包含当前位置,则当前位置是可以放置皇后的位置。


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

  1. https://deepmind.com/research/case-studies/alphago-the-story-so-far

  2. AlphaGo百度百科

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
•Alpha-Beta剪枝(Alpha-Beta pruning) 对于一般的最大最小搜索,即使每一步只有很少的下法,搜索的位置也会增长非常快;在大多数的中局棋形中,每步平均有十个位置可以下棋,于是假设搜索九步(程序术语称为搜索深度为九),就要搜索十亿个位置(十的九次方),极大地限制了电脑的棋力。于是采用了一个方法,叫“alpha-beta剪枝”,它大为减少了检测的数目,提高电脑搜索的速度。各种各样的这种算法用于所有的强力Othello程序。(同样用于其他棋类游戏,如国际象棋和跳棋)。为了搜索九步,一个好的程序只用搜索十万到一百万个位置,而不是没用前的十亿次。 •估值 这是一个程序中最重要的部分,如果这个模块太弱,则就算算法再好也没有用。我将要叙述三种不同的估值函数范例。我相信,大多数的Othello程序都可以归结于此。 棋格表:这种算法的意思是,不同的棋格有不同的值,角的值大而角旁边的格子值要小。忽视对称的话,棋盘上有10个不同的位置,每个格子根据三种可能性赋值:黑棋、白棋和空。更有经验的逼近是在游戏的不同阶段对格子赋予不同的值。例如,角在开局阶段和中局开始阶段比终局阶段更重要。采用这种算法的程序总是很弱(我这样认为),但另一方面,它很容易实现,于是许多程序开始采用这种逼近。 基于行动力的估值:这种更久远的接近有很强的全局观,而不像棋格表那样局部化。观察表明,许多人类玩者努力获得最大的行动力(可下棋的数目)和潜在行动力(临近对手棋子的空格,见技巧篇)。如果代码有效率的话,可以很快发现,它们提高棋力很多。 基于模版的估值 :正如上面提及的,许多中等力量的程序经常合并一些边角判断的知识,最大行动力和潜在行动力是全局特性,但是他们可以被切割成局部配置,再加在一起。棋子最少化也是如此。这导致了以下的概括:在估值函数中仅用局部配置(模版),这通常用单独计算每一行、一列、斜边和角落判断,再加在一起来实现。 估值合并:一般程序的估值基于许多的参数,如行动力、潜在行动力、余裕手、边角判断、稳定子。但是怎么样将他们合并起来得到一个估值呢?一般采用线性合并。设a1,a2,a3,a4为参数,则估值s:=n1*a1+n2*a2+n3*a3+n4*a4。其中n1,n2,n3,n4为常数,术语叫“权重”(weight),它决定了参数的重要性,它们取决于统计值。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值