算法中的大杀器,回溯。

什么是回溯?

回溯本质上就是一种枚举,遍历所有的可能情况,找到所有可能的答案,寻找出唯一解或最优解。

从初始状态经过不确定的步数,到有条件的目标状态
每个状态有一种或多种方式到下一个状态(状态转移)
在过程中进行一定的判定,对一定不可能到目标状态的提前结束枚举(剪枝)。

相较于贪心,贪心是可以寻找到最优方案去执行,去进行状态的转移。
而回溯则找不到这样的方案状态,只能去枚举所有的情况。

回溯步骤里,相当于对每次节点进行存档操作,这一条线执行完毕则读档回到上一节点,因而显然回溯与递归是密不可分的。

什么时候用回溯?

  • 是否有其他解法?
  • 求唯一解
    • 如果是判断解是否存在,通常很容易超时
  • n 的范围,一般小于等于 100
  • 条件的复杂度,越复杂度越容易剪枝,n 的范围也可以适当放大,高超的剪枝下,往往可以大大降低时间复杂度。

例题:

leedcode篇:

1.组合。

[https://leetcode.cn/problems/combinations/]

分析:

分析下题目:
初始状态:[] 空集合
目标状态:长度为 k 的组合,并且包含的数字只能在 1-n 内,且数字不能重复
状态转移:选择 1-n 的数字加入的集合中(每一次选择不能与之前相同)
要求:列举出所有方案,不能重复([1,2] 和 [2,1] 认为是相同的方案)

显然我们可以通过一个for循环去把每个元素放入集合。
由于每次需将 1 - n 放入集合中,那么我们每次开始枚举的位置需为前一元素的下一个位置。

class Solution {
public:
    vector<vector<int>> combine(int n, int k) {
        vector<vector<int>> ans;
        vector<int> combine;
        auto traceback = [&] (auto& self){
            if(combine.size() == k){
                ans.push_back(combine);
                return;
            }
            int lower = combine.size() == 0 ? 1 : combine.back() + 1;
            for(int i = lower; i <= n; i++){
                combine.push_back(i);//改变状态,放入元素
                self(self);
                combine.pop_back();//还原状态,取出元素
            } 
        };
        traceback(traceback);
        return ans;

    }
};

2.子集

[https://leetcode.cn/problems/subsets/description/]

分析:

子集可以看做是否选择某个元素,因此对于每个位置,可以有两种状态转移的方式:选择、不选择
状态是:当前子集、枚举到第几个元素
目标状态:枚举完所有元素
状态转移:选择、不选择
目标状态:得到所给集合的所有子集

我们便可以在放入元素前进行一次递归,放入元素后再进行一次递归,分别表示不选择这个元素和选择这个元素。

class Solution {
public:
    vector<vector<int>> subsets(vector<int>& nums) {
        vector<vector<int>> ans;
        vector<int> subset;
        auto traceback = [&](auto &self,int pos){
            if(pos == nums.size()){
                ans.push_back(subset);
                return;
            }
            self(self,pos + 1);// 不选择
            subset.push_back(nums[pos]);
            self(self,pos + 1);// 选择
            subset.pop_back();
        };
        traceback(traceback,0);
        return ans;
    }
};

3.全排列(I,II)。

[https://leetcode.cn/problems/permutations/description/]

分析:

两题背景相似,索性放在一起去分析了。

首先我们可以利用c++ algorithm库里的全排列函数next_permutation去实现。
需要注意的是next_permutation是根据字典序进行排序的,即会给出下一个字典序排列,所以为获取所有元素需要先对数组进行从小到大的排序。

class Solution {
public:
    vector<vector<int>> permute(vector<int>& nums) {
        vector<vector<int>> ans;
        sort(nums.begin(),nums.end());
        do{
            ans.push_back(nums);
        } while(next_permutation(nums.begin(),nums.end()));
        return ans;
    }
};
class Solution {
public:
    vector<vector<int>> permuteUnique(vector<int>& nums) {
        sort(nums.begin(),nums.end());
        vector<vector<int>> ans;
        do{
            ans.push_back(nums);
        } while( next_permutation(nums.begin(),nums.end()) );
        return ans;
    }
};

第二种做法就是回溯了。
全排列I,我们可以对每个使用过的第i个元素打出标记,保证未来遍历时不会重复使用,每次回溯后记得还原现场。

class Solution {
public:
    vector<vector<int>> permute(vector<int>& nums) {
        vector<vector<int>> ans;
        vector<int> tmp;
        int n = nums.size();
        vector<bool> used(n);
        auto traceback = [&](auto&self){
            if(tmp.size() == n){
                ans.push_back(tmp);
                return;
            }
            for(int i = 0; i < n; i++){
                if(used[i]) continue;
                tmp.push_back(nums[i]);
                used[i] = true;
                self(self);
                used[i] = false;
                tmp.pop_back();
            }
        };
        traceback(traceback);
        return ans;

    }
};

而 全排列II,则可能会包含重复元素,为了避免重复序列多次记录,我们可以类似于I,给用过的元素也打出标记。由于元素范围为 - 10 到 +10只需要坐标偏移处理标记即可。

class Solution {
public:
    vector<vector<int>> permuteUnique(vector<int>& nums) {
        int n = nums.size();
        vector<vector<int>> ans;
        vector<int> perm;
        vector<bool> used(n);
        auto traceback = [&](auto&self){
            if(perm.size() == n){
                ans.push_back(perm);
                return;
            }
            vector<bool> used2(21);
            for(int i = 0; i < n; i++){
                if(used[i]) continue;
                if(used2[nums[i] + 10]) continue;

                used[i] = true;
                used2[ nums[i] + 10 ] = true;
                perm.push_back(nums[i]);

                self(self);

                perm.pop_back();
                used[i] = false;
            }
        };
        traceback(traceback);
        return ans;
    }
};

或者说,我们可以记录下上一次所用到的元素,如果重复用到了就略过。

class Solution {
public:
    vector<vector<int>> permuteUnique(vector<int>& nums) {
        sort(nums.begin(),nums.end());
        int n = nums.size();
        vector<vector<int>> ans;
        vector<int> perm;
        vector<bool> used(n);
        auto traceback = [&](auto&self){
            if(perm.size() == n){
                ans.push_back(perm);
                return;
            }
            int last = -100;
            for(int i = 0; i < n; i++){
                if(used[i]) continue;
                if(nums[i] == last) continue;

                used[i] = true;
                perm.push_back(nums[i]);
                last = nums[i];

                self(self);

                perm.pop_back();
                used[i] = false;
            }
        };
        traceback(traceback);
        return ans;
    }
};

4.组合总和。

[https://leetcode.cn/problems/combination-sum/]
[https://leetcode.cn/problems/combination-sum-ii/description/]

分析:

初始状态:[] 空集合
目标状态:和为目标target的组合
状态转移:选择 candidate中的元素加入的集合中(每一次选择可以与之前相同)
要求:列举出所有方案,不能重复([1, 1, 2] 和 [2, 1, 1] 认为是相同的方案)

那么我们回溯函数的编写方式就显而易见了:
只需记录上次枚举的元素坐标和所求之和

class Solution {
public:
    vector<vector<int>> combinationSum(vector<int>& candidates, int target) {
        vector<vector<int>> ans;
        vector<int> comb;
        auto traceback = [&](auto & self,int lower,int sum){
            if(sum == target){
                ans.push_back(comb);
                return;
            }
            for(int i = lower; i < candidates.size(); i++){
                if(sum + candidates[i] > target) continue;
                comb.push_back(candidates[i]);
                self(self, i, sum + candidates[i]);
                comb.pop_back();
            }
        };
        traceback(traceback, 0, 0);
        return ans;
    }
};

初始状态:[] 空集合
目标状态:和为目标target的组合
状态转移:选择 candidate中的元素加入的集合中(每一次选择不可与之前相同)
要求:列举出所有方案,不能重复([1, 1, 2] 和 [2, 1, 1] 认为是相同的方案)

与上道题相比,我们所变化的只是状态转移方式
需记录的便是上次枚举的元素坐标下一位和所求之和,同时,为避免不同坐标的数据相同,我们类似于第三题思路,记录出上一次最后一位使用的元素。
代码如下:

class Solution {
public:
    vector<vector<int>> combinationSum2(vector<int>& candidates, int target) {
        sort(candidates.begin(),candidates.end());
        vector<vector<int>> ans;
        vector<int> comb;
        auto traceback = [&](auto &self, int lower, int sum){
            if(sum == target){
                ans.push_back(comb);
                return;
            }
            int last = 0;
            for(int i = lower; i < candidates.size(); i++){
                if(sum + candidates[i] > target) continue;
                if(last == candidates[i]) continue;

                comb.push_back(candidates[i]);
                last = candidates[i];

                self(self, i + 1, sum + candidates[i]);
                
                comb.pop_back();
            }
        };
        traceback(traceback, 0, 0);
        return ans;
        
    }
};

5.电话号码的字母总和。

[https://leetcode.cn/problems/letter-combinations-of-a-phone-number/description/]

分析:

初始状态:[] 空集合
目标状态:用到每一位数字中不同个字母的组合
状态转移:选择每一位手机按键数字所代表的字母,将字母加入集合
要求:列举出所有方案,不能重复
我们可以将手机各数字键位先预先存储起来,然后去枚举每一位数字,在得到所需字母即可。
回溯函数内所需传入的就是自身和枚举到的位置。

class Solution {
public:
    vector<string> letterCombinations(string digits) {
        vector<string> ans;
        string comb;
        vector<string> phone = {"", "", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz"};
        auto traceback = [&](auto &self,int pos){
            if(pos == digits.size()){
                if(comb.size()) ans.push_back(comb);
                return;
            }
            for(auto c : phone[digits[pos] - '0']){
                comb += c;
                self(self, pos + 1);
                comb.pop_back();
            }
        };
        traceback(traceback,0);
        return ans;

    }
};

6.复原IP地址。

[https://leetcode.cn/problems/restore-ip-addresses/description/]

分析:

初始状态:[] 空集合
目标状态:所组成的含四个数字的IP组合
状态转移:每一个数字与先前数字拼接或单独成数(注意:每个整数位于 0 到 255 之间组成,且不能含有前导 0)
要求:列举出所有方案,不能重复

那么我们可以就单独成数和拼接两操作分开进行回溯。
单独成数就是将这一位数字放入组合
而拼接则是在组合不空且前一位数字不为0下进行。
由于目标状态对于元素个数有要求,那我们就可以传入位置作为最终终止条件。

class Solution {
public:
    vector<string> restoreIpAddresses(string s) {
        vector<string> ans;
        vector<int> ip;
        auto traceback = [&](auto &self, int pos){
            if (pos == s.size()) {
                if (ip.size() == 4) {
                    string ip_str;
                    for (int i = 0; i < 3; i ++) {
                        ip_str += to_string(ip[i]) + ".";
                    }
                    ip_str += to_string(ip[3]);
                    ans.push_back(ip_str);
                }
                return;
            }

            // 组合成数
            if( ip.size()  && ip.back() != 0){
                int next = ip.back() * 10 + s[pos] - '0';
                if(next <= 255){
                    ip.back() = next;
                    self(self, pos + 1);
                    ip.back() /= 10;
                }
            }

            // 单独成数
            if(ip.size() < 4){
                ip.push_back(s[pos] - '0');
                self(self, pos + 1);
                ip.pop_back();
            }

        };
        traceback(traceback, 0);
        return ans;
        
    }
};

特别的,在c++20标准下,我们可以利用format进行格式化输出,方便输出

class Solution {
public:
    vector<string> restoreIpAddresses(string s) {
        vector<string> ans;
        vector<int> ip;
        auto traceback = [&](auto &self, int pos){
            if(pos == s.size()){
                if(ip.size() == 4)
                    //简便化输出  
                    ans.push_back(format("{}.{}.{}.{}", ip[0], ip[1], ip[2], ip[3]));
                return;
            }

            if( ip.size()  && ip.back() != 0){
                int next = ip.back() * 10 + s[pos] - '0';
                if(next <= 255){
                    ip.back() = next;
                    self(self, pos + 1);
                    ip.back() /= 10;
                }
            }

            // 单独成数
            if(ip.size() < 4){
                ip.push_back(s[pos] - '0');
                self(self, pos + 1);
                ip.pop_back();
            }

        };
        traceback(traceback, 0);
        return ans;
    }
};

7.解数独。

[https://leetcode.cn/problems/sudoku-solver/description/]

分析:

对于一个数独,最核心的操作,便是判断填入数字的可行性。
由于数独的规则要求,我们开 行上的数字判断,列上的数字判断,以及每个3 * 3 方格的数字判断。
对于给定数独做好预处理
回溯的终止可以依赖于枚举完81个格子,便于操作。

class Solution {
public:
    void solveSudoku(vector<vector<char>>& board) {
        auto rows = vector(9, vector(9,0));
        auto cols = vector(9, vector(9,0));
        auto cells = vector(3, vector(3, vector(9, 0)));
        for(int i = 0; i < 9; i++){
            for(int j = 0; j < 9; j++){
                if (board[i][j] == '.') continue;
                int digit = board[i][j] - '1';
                rows[i][digit] = 1;
                cols[j][digit] = 1;
                cells[i/3][j/3][digit] = 1;
            }
        }
        auto traceback = [&](auto&self,int pos){
            if(pos == 81){
                return true;
            }
            int x = pos / 9, y = pos % 9;
            if(board[x][y] != '.') return self(self, pos + 1);
            for (int d = 0; d < 9; d ++) {
                if (rows[x][d] || cols[y][d] || cells[x / 3][y / 3][d]) continue;
                board[x][y] = d + '1';
                rows[x][d] = 1;
                cols[y][d] = 1;
                cells[x / 3][y / 3][d] = 1;
                if (self(self, pos + 1)) return true;
                board[x][y] = '.';
                rows[x][d] = 0;
                cols[y][d] = 0;
                cells[x / 3][y / 3][d] = 0;
            }
            return false;
        };
        traceback(traceback, 0);
    }
};

8.N皇后。

[https://leetcode.cn/problems/n-queens/description/]

分析:

非常经典的一道题目。
由于皇后攻击的特性,我们类似于解数独的思路,记录 行的判断,列的判断 ,对角线的判断,反对角线的判断。
不妨将行的判断作为终止条件,我们所需记录的便少一份工作量。
观察数学规律,对角线上的元素 col + row 始终是个定值, 反对角线上的元素 row - col 也始终是个定值
由于棋盘为n行n列,所以对角线数组的大小就可以确定为 2 * n。
反对角线上 row - col 可能会为负值,那么我们就可以对他做出坐标偏移,row - col + n 的大小便是 2 * n。

class Solution {
public:
    vector<vector<string>> solveNQueens(int n) {
        vector<vector<string>> ans;
        vector<string> board(n,string(n,'.'));
        vector<int> col(n, 0), dg(2 * n, 0), bdg(2 * n, 0);
        auto traceback = [&](auto &self, int row){
            if(row == n){
                ans.push_back(board);
                return;
            }
            for(int j = 0; j < n; j++){
                if(col[j] || dg[row + j] || bdg[row - j + n] ) continue;
                board[row][j] = 'Q';
                col[j] = dg[row + j] = bdg[row - j + n] = 1;
                self(self, row + 1);
                col[j] = dg[row + j] = bdg[row - j + n] = 0;
                board[row][j] = '.';
            }
        };
        traceback(traceback, 0);
        return ans;
        
    }
};

9.24点游戏。

[https://leetcode.cn/problems/24-game/description/]

分析:

每次随意选两张牌进行随意计算,再将计算结果放回原数组。问题就在于如何正确的回溯状态?
倘若选择后就删去这两张牌,显然坐标会发生偏移且回溯不易。
那么我们不妨采取一个假装删除的思想,将被删元素打上标记,回溯之后状态的还原只需要将标记删除即可。
经过分析,最后所得到的应该为7个数字,对应的used数组也应为7个元素。
那么终止状态就为 nums.size() == 7,且最后一位数字为 24。
需要注意的,/为实数除法,nums应为一个double类型的数组,所以最后一位与24的比较也应为 二者相减能否小于一个极小的精度。

class Solution {
public:
    bool judgePoint24(vector<int>& cards) {
        // 分装计算函数
        auto cal = [&](double a, char op, double b){
            if(op == '+') return a + b;
            if(op == '-') return a - b;
            if(op == '*') return a * b;
            return a / b;
        };
        vector<double> nums(4, 0);
        for(int i = 0; i < 4; i++) nums[i] = cards[i];
        vector<int> used(4, 0);
        auto traceback = [&](auto &self){
            if(nums.size() == 7){
                return abs(nums.back() - 24 ) < 1e-6;
            }
            for(int i = 0; i < nums.size(); i++){
                if(used[i]) continue;
                for(int j = 0; j < nums.size(); j++){
                    if(i == j || used[j]) continue;
                    used[i] = used[j] = 1;
                    for(char c : "+-*/"){
                        nums.push_back(cal(nums[i], c, nums[j]));
                        // 最后一位打入未使用的标记
                        used.push_back(0);
                        if(self(self)) return true;
                        used.pop_back();
                        nums.pop_back();
                    }
                    used[i] = used[j] = 0;
                }
            }
            return false;
        };
        return traceback(traceback);
    }
};

蓝桥杯篇

回溯类型的题目在蓝桥杯中的比重还是很重的。作为一个重要手段,不会的题目回溯搜索也是可以骗出一部分分数的,hh。
所以来跟随笔者进行下学习一些典型题目吧。

1.飞机降落

[https://www.lanqiao.cn/problems/3511/learning/?page=1&first_category_id=1&name=飞机降落]

分析:

初看 感觉似乎可以贪心去排序解决,但确实没有好的贪心思路。
注意到数据范围很小,最多也才10架飞机,就可以直接回溯解决问题,尝试找到可行所有方案。
首先来想,飞机的着陆顺序,可以理解为一个全排列问题,按照不同的次序去尝试安排。
分装个函数 判断方案是否可行,剪枝条件很清晰,如果后一架飞机等不到前一架飞机的降落,那么这个状态便是不合法的,去不断更新最终时间即可。

void solve(){
  int n;
  cin >> n;
  vector<tuple<int,int,int>> planes(n);
  for(auto &[t,d,l]:planes) cin >> t >> d >> l;
  auto can_land = [&](){
    int last = 0;
    for(int i = 0; i < n; i++){
      auto [t, d, l] = planes[i];
      if(t + d < last) return false;
      last = max(last ,t) + l;
    }
    return true;
  };
  sort(planes.begin(), planes.end());
  do{
    if(can_land()) {
      cout << "YES" << endl;
      return;
    }
  } while(next_permutation(planes.begin(),planes.end()));
  cout << "NO" << endl;
}

利用全排列的方式省心,但时间效率上会差一些,枚举了许多不可能情况。
那么我们可以自己手动写下这个全排列回溯。
由于剪枝条件便是:先前飞机成功降落的最终时间与当前飞机开始降落的时间去进行比较,那么我们可以传入 cnt, last分别记录 飞机数量和最后时长。 退出条件自然也就是枚举完所有的飞机。

void solve(){
  int n;
  cin >> n;
  vector<tuple<int,int,int>> planes(n);
  for(auto &[t,d,l]:planes) cin >> t >> d >> l;
  // 记录飞机使用情况
  vector<int> used(n);
  auto traceback = [&](auto&self, int cnt, int last){
    if(cnt == n) return true;
    for(int i = 0; i < n; i++){
      if(used[i]) continue;
      auto[t, d, l] = planes[i];
      if(t + d < last) continue;
      used[i] = 1;
      if(self(self, cnt + 1, max(t, last) + l) ) return true;
      used[i] = 0;
    }
    return false;
  };
  cout << ((traceback(traceback, 0, 0)) ? "YES" : "NO") << endl;
}

2.九宫幻方

[https://www.lanqiao.cn/problems/100/learning/?page=1&first_category_id=1&problem_id=100]

分析:

一道回溯思想的模板题目,理解之前题目的解决方案后这题会让你体验ac的快乐,hh。
幻方问题,类似于数独题目,都是向棋盘空方格中填入数字,从而达到某种规则,这样的题目还有很多,我们慢慢来看,通一道会百道嘛。
由于规则便是行,列,对角线的和均相等,那么我们可以把规则分装成一个判断函数,方便编写(不会因为找bug焦头烂额)。
由于棋盘中会填入0 - 9的数字,我们可以为这些数字打上标记,避免重复使用。
回溯的退出条件自然就是枚举的数量为9(这也是棋盘类问题的经典手段), 注意由于可行的方案可能不止一种, 所以我们退出前利用计数器记录方案,答案记录第一种情况即可,避免重复覆盖产生问题。

#include <bits/stdc++.h>
using namespace std;
int main() {
    cin.tie(0)->sync_with_stdio(0);
    vector<vector<int>> board(4, vector<int>(4)); // 使用1-based索引
    vector<int> used(10, 0); // 标记数字是否使用过
    for (int i = 1; i <= 3; i++) {
        for (int j = 1; j <= 3; j++) {
            cin >> board[i][j];
            if (board[i][j] != 0) used[board[i][j]] = 1;
        }
    }

    vector<vector<int>> ans;
    int cnt = 0;
    // 记录规则
    auto check = [&]() {
        int sum = board[1][1] + board[2][2] + board[3][3];
        if (sum != board[1][3] + board[2][2] + board[3][1]) return false;

        for (int i = 1; i <= 3; i++) {
            int row = 0, col = 0;
            for (int j = 1; j <= 3; j++) {
                row += board[i][j];
                col += board[j][i];
            }
            if (row != sum || col != sum) return false;
        }
        return true;
    };

    auto traceback = [&](auto &self,int pos) {
        if (pos == 9) {
            if (check()) {
                cnt++;
                if (cnt == 1) {
                    for (int i = 1; i <= 3; i++) {
                        vector<int> row;
                        for (int j = 1; j <= 3; j++) {
                            row.push_back(board[i][j]);
                        }
                        ans.push_back(row);
                    }
                }
            }
            return;
        }

        int i = (pos - 1) / 3 + 1;
        int j = (pos - 1) % 3 + 1;

        if (board[i][j] != 0) {
            // 进入下一个位置
            self(self, pos + 1);
        } else {
            for (int num = 1; num <= 9; num++) {
                if (!used[num]) {
                    used[num] = 1;
                    board[i][j] = num;
                    self(self, pos + 1);
                    board[i][j] = 0;
                    used[num] = 0;
                }
            }
        }
    };

    traceback(traceback, 1);

    if (cnt == 1) {
        for (auto &row : ans) {
            for (int x : row) {
                cout << x << " ";
            }
            cout << endl;
        }
    } else {
        cout << "Too Many" << endl;
    }

    return 0;
}

3.数字接龙

[https://www.lanqiao.cn/problems/19712/learning/?page=1&first_category_id=1&problem_id=19712]

分析:

如果去luogu上看这道题,你会发现它是道错题,因为在极端数据下无法剪枝(n = 10, k = 0),但其中的思路还是值得学习下的。
规则一可以行走八个方向,我们不妨就按题目顺序将方向定义下来,为防止走出界限,再定义一个inside函数判断是否出界。(迷宫问题的经典操作)
规则二可以作为一个剪枝条件,不满足的返回false。
规则三也好解决,记录下每个方格是否走过即可。
规则四如何处理?如何找到交叉项?

我们想到如果将棋盘成比例放大一倍,那么原先位置中间会出现空格,这便是交叉位置,走过后将交叉位置也标注走过即可。

思路有了,我们去编写代码, 注意进入回溯前,先把(0, 0)位置标记为走过,而初始位置需要为0才可继续进行回溯,否则不满足条件2.

#include<bits/stdc++.h>
using namespace std;
vector<pair<int, int>> dir = {
  {-1, 0}, {-1, 1}, {0, 1}, {1, 1}, {1, 0}, {1, -1}, {0, -1}, {-1, -1}
};
int main() {
  cin.tie(0)->sync_with_stdio(0);
  int n, k;
  cin >> n >> k;
  auto maze = vector(n * 2, vector(n * 2, 0));
  for (int i = 0; i < n; i ++) {
    for (int j = 0; j < n; j ++) {
      cin >> maze[i * 2][j * 2];
    }
  }
  auto vis = vector(n * 2, vector(n * 2, 0));
  string ans;
  auto inside = [&](int x, int y) {
    return x >= 0 && x < n * 2 && y >= 0 && y < n * 2;
  };
  auto traceback = [&](auto &self, int x, int y) {
    if (x == n * 2 - 2 && y == n * 2 - 2) {
      if ((int)ans.size() == n * n - 1) return true;
      return false;
    }
    for (int d = 0; d < 8; d ++) {
      auto [dx, dy] = dir[d];
      // 对角线格被走过
      int nx = x + dx, ny = y + dy;
      // 下一个格被走过
      int nnx = nx + dx, nny = ny + dy;
      // 越界
      if (!inside(nnx, nny)) continue;
      // 走过了
      if (vis[nx][ny] || vis[nnx][nny]) continue;
      // 不符合规则2
      if (maze[nnx][nny] != ((int)ans.size() + 1) % k) continue;

      vis[nx][ny] = vis[nnx][nny] = 1;
      ans += d + '0';

      if (self(self, nnx, nny)) return true;

      vis[nx][ny] = vis[nnx][nny] = 0;
      ans.pop_back();
    }
    return false;
  };
  vis[0][0] = 1;
  if (maze[0][0] == 0 && traceback(traceback, 0, 0)) {
    cout << ans << endl;
  } else {
    cout << -1 << endl;
  }
}

4.路径之谜

[https://www.lanqiao.cn/problems/89/learning/?page=1&first_category_id=1&problem_id=89]

分析:

给出的箭数其实就可看作步数,利用数组先记录住。
迷宫类问题,先把方向,越界判断,标记数组给编写了。
注意从西到东的箭数,实际记录的是人物在每一列的步数,因为只有在进入这一列时才会放箭。
同理从北到南的箭数,实际记录的是人物在每一行的步数,因为只有在进入这一行时才会放箭。
回溯终止条件,便为到达终点后且箭数用完(步数走完)。
然后如何剪枝?
显然的,走过的不走,无法走的(这一行或这一列上箭数为0)不走 这两个是显而易见的。
倘若走到某个位置前发现该位置某个方向上步数只剩下1,那就意味着他不能走回头路,进了这格步数用尽就一定是不可以再回来了,那么我们可以统计该位置前的步数是否走完,如果没走完就直接终止,因为不能走回头路。

那么我们就可以编写出相应的代码了, 注意(0, 0) 位置标记走过,两个方向箭数少一(初始位置也是会进行射箭的)。

#include <bits/stdc++.h>
using namespace std;

vector<pair<int, int>> dir = {{1, 0}, {0, 1}, {0, -1}, {-1, 0}};
int main() {
  cin.tie(0)->sync_with_stdio(0);
  int n;
  cin >> n;
  vector<int> row(n), col(n);
  for (auto &x : col) cin >> x;
  for (auto &x : row) cin >> x;
  auto vis = vector(n, vector(n, 0));
  vector<int> ans;
  auto inside = [&](int x, int y) {
    return x >= 0 && x < n && y >= 0 && y < n;
  };
  auto traceback = [&](auto &self, int pos) {
    int x = pos / n, y = pos % n;
    if (x == n - 1 && y == n - 1) {
      if (row[n - 1] == 0 && col[n - 1] == 0) {
        return true;
      }
      return false;
    }
    for (auto [dx, dy] : dir) {
      int nx = x + dx, ny = y + dy;
      if (!inside(nx, ny)) continue;
      if (vis[nx][ny]) continue;
      if (row[nx] == 0 || col[ny] == 0) continue;
      if (row[nx] == 1 && accumulate(row.begin(), row.begin() + nx, 0) != 0) continue;
      if (col[ny] == 1 && accumulate(col.begin(), col.begin() + ny, 0) != 0) continue;
      vis[nx][ny] = 1;
      row[nx] --;
      col[ny] --;
      ans.push_back(nx * n + ny);
      if (self(self, nx * n + ny)) return true;
      vis[nx][ny] = 0;
      row[nx] ++;
      col[ny] ++;
      ans.pop_back();
    }
    return false;
  };
  row[0] --;
  col[0] --;
  vis[0][0] = 1;
  ans.push_back(0);
  traceback(traceback, 0);
  for (auto x : ans) cout << x << ' ';
}

5.01游戏

[https://www.lanqiao.cn/problems/17100/learning/?page=1&first_category_id=1&problem_id=17100]

分析:

规则有四条,除第一条初始条件外都有些复杂,无妨,越复杂剪枝的点也就越多。
棋盘类问题,先把方向,越界判断给编写了,由于此题天然就进行了标记,我们不需要再编写标记数组。
套路性的,模板性的写完后,我们去想想递归的退出用到哪个规则进行最后一次剪枝。
天然的,只有规则四是填完所有内容才可判断的,那我们不妨就将条件四先编写出来。
规则二三均可在填写过程中进行操作, 分装函数判断能否填入相应的"0 1" 字符。
按照模式化的思考,将关键部分分装函数出来处理,我们可以顺利编写出代码。

#include <bits/stdc++.h>
using namespace std;
vector<pair<int, int>> dir ={
  {-1, 0}, {1, 0}, {0, -1}, {0, 1}
};
int main()
{
  int n;
  cin >> n;
  vector<string> board(n);
  for(auto &line : board){
    cin >> line;
  }
  auto inside = [&](int x, int y){
    return  x >= 0 && x < n && y >= 0 && y < n;
  };
  // 条件四
  auto check = [&](){
    set<int> rows, cols;
    for(int i = 0; i < n; i++){
      int row = 0;
      // 把每一行映射成一个值
      for(int j = 0; j < n; j++){
        if(board[i][j] == '1') row |= (1 << j);
      }
      // 发生重复,剪枝非法状态
      if(rows.find(row) != rows.end()) return false;
      rows.insert(row);
    }
    for(int j = 0; j < n; j++){
      int col = 0;
      // 把每一行映射成一个值
      for(int i = 0; i < n; i++){
        if(board[i][j] == '1') col |= (1 << i);
      }
      // 发生重复,剪枝非法状态
      if(cols.find(col) != cols.end()) return false;
      cols.insert(col);
    }
    return true;
  };

  auto can = [&](int x, int y, char c){
    // 条件二的剪枝
    for(auto [dx, dy] : dir){
      int nx = x + dx, ny = y + dy;
      int cnt = 0;
      while(inside(nx, ny) && board[nx][ny] == c){
        cnt++;
        // 发生重复出现相同数字两次
        if(cnt == 2) return false;
        nx += dx, ny += dy;
      }
    }
    // 枚举到最后一行
    if(x == n - 1){
      int cntZero = c == '0';
      // 枚举前 n - 1 行的0个数
      for(int i = 0; i < n - 1; i++){
        if(board[i][y] == '0') cntZero ++;
      }
      if(cntZero * 2 != n) return false;
    }
    if(y == n - 1){
      int cntZero = c == '0';
      // 枚举前 n - 1 列的0个数
      for(int j = 0; j < n - 1; j++){
        if(board[x][j] == '0') cntZero ++;
      }
      if(cntZero * 2 != n) return false;
    }
    return true;
  };
  auto traceback = [&](auto &self, int pos){
    if(pos == n * n) return check();
    int x = pos / n, y = pos % n;
    if(board[x][y] != '_'){
      // 1 0 0 0 1
      if(!can(x, y, board[x][y])) return false;
      return self(self, pos + 1);
    }
    for(char c = '0'; c <= '1'; c++){
        if(can(x, y, c)) {
          board[x][y] = c;
          if(self(self, pos + 1)) return true;
          board[x][y] = '_';
        }
    }
    return false;
  };
  traceback(traceback, 0);
  for(auto &line:board) cout << line << endl;

  return 0;
}

6.像素放置

[https://www.lanqiao.cn/problems/3508/learning/?page=1&first_category_id=1&problem_id=3508]

分析:

问题类似于开了部分方格的扫雷,要求把雷都给标记出来。
我们可以先利用cnt数组将每个点附近的雷数记录下来
知每格方格的数字记录它以自身为中心的九宫格内的雷数,那我们去进行更新操作和还原操作时,一定会将这个九宫格进行不断操作。那我们就可以先存一个方向数组,记录走九宫格移动的坐标。
而能否放置’0’ 或 ‘1’ 则取决于其周围一圈的情况,这天然的是个剪枝条件,不合法直接退出。
自然的:当一个点放置以后,它其实就是它左上角那个点最后一次的更新机会,倘若这个点都无法让左上角满足条件,那么这条路径自然是非法的,剪枝。
边缘条件如何处理?
如果到最后一行,那么它实际上就是它左侧那个点的最后一次更新机会,我们去判断剪枝。
如果到最后一列,那么它实际上就是它上方那个点的最后一次更新机会,我们去判断剪枝。
如果到最后一个点, 也就是(n-1, m-1)时,它最后的更新机会其实就是它本身。
由于相关放置,更新,还原逻辑相对复杂,我们可以去分装函数一一编写,使得代码更加条理。
下面我们来编写代码,注意 n 行 m 列,我们根据pos所得到的x, y均只与 m 有关联。

#include <bits/stdc++.h>
using namespace std;
vector<pair<int, int>> dir = {
  {-1, -1}, {-1, 0}, {-1, 1},
  {0, -1}, {0, 0}, {0, 1},
  {1, -1}, {1, 0}, {1, 1}  
};
int main()
{
  int n, m;
  cin >> n >> m;
  // cnt记录每个点周围及其自身的炸弹数量, 初始化为 -1,便于日后操作判断是否有雷
  auto cnt = vector(n, vector<int>(m, -1));
  for(int i = 0; i < n; i++){
    for(int j = 0; j < m; j++){
      char ch;
      cin >> ch;
      if(isdigit(ch)) cnt[i][j] = ch - '0';
    }
  }
  vector<string> board(n, string(m, '0'));
  auto inside = [&](int x, int y){
    return x >= 0 && x < n && y >= 0 && y < m;
  };

  auto can = [&](int x, int y, int c){
    if(x > 0 && y > 0 && cnt[x - 1][y - 1] > c) return false;
    if(x == n - 1 && y > 0 && cnt[x][y - 1] > c) return false;
    if(y == m - 1 && x > 0 && cnt[x - 1][y] > c) return false;
    if(x == n - 1 && y == m - 1 && cnt[x][y] > c) return false;
    if(c == 1){
      for(auto &[dx, dy] : dir){
        int nx = x + dx, ny = y + dy;
        if(!inside(nx, ny)) continue;
        if(cnt[nx][ny] == -1) continue;
        if(cnt[nx][ny] == 0) return false;
      }
    }
    return true;
  };

  auto update = [&](int x, int y){
    board[x][y] = '1';
    for(auto &[dx, dy] : dir){
      int nx = x + dx, ny = y + dy;
      if(!inside(nx, ny)) continue;
      if(cnt[nx][ny] == -1) continue;
      cnt[nx][ny]--;
    }
  };

  auto rollback = [&](int x, int y){
    board[x][y] = '0';
    for(auto &[dx, dy] : dir){
      int nx = x + dx, ny = y + dy;
      if(!inside(nx, ny)) continue;
      if(cnt[nx][ny] == -1) continue;
      cnt[nx][ny]++;
    }
  };

  auto traceback = [&](auto&self, int pos){
    if(pos == n * m) return true;
    int x = pos / m, y = pos % m;
    if(can(x, y, 0)){
      if(self(self, pos + 1)) return true;
    }
    if(can(x, y, 1)){
      update(x, y);
      if(self(self, pos + 1)) return true;
      rollback(x, y);
    }
    return false;
  };
  traceback(traceback, 0);
  for(auto&line : board) cout << line << endl;

  // 请在此输入您的代码
  return 0;
}

传送门:[https://www.luogu.com.cn/problem/P9237]
luogu上也收录了这道题,如果我们直接提交会发现有两个点超时了,那这就需要我们去进一步剪枝,以加速代码的运行速度。
上述剪枝操作,我们相当于是到了每个九宫格的最后一点才开始判断剪枝,那我们能不能更早的去进行剪枝?
我们去尝试,能不能对每个点的九宫格情况进行预处理?有了预处理,剪枝时直接判断就可以了,效率会大大提高。
我们考虑开一个三维数组,表示第n行第m列元素的九个方向的临界值。
我们来在代码中去理解思路。

    auto limit = vector(n, vector(m, vector<int>(9)));
    for(int i = 0; i < n; i++){
        for(int j = 0; j < m; j++){
            for(int d = 0; d < 9; d++){
                auto [dx, dy] = dir[d];
                int nx = i + dx, ny = j + dy;
                if(!inside(nx, ny)) continue;
                int c = 0;
                // 我们的方向是有次序的,对于下一个点来说,上一个点指下来的的方向,反方向上上一个点也在影响着这个点。
                // 比如 上一个点以 dir[7] 的正下方方向指下来,对于下一个点来说 这可以视为dir[1] 的正上方方向指出,那么它还可以遍历的方向就是 3 - 9.
                // 每一次对x方向取反方向 实际上映射下来就是 dir[9 - x]
                for(int dd = 9 - d; dd < 9; dd++){
                    auto [ddx, ddy] = dir[dd];
                    int nnx = nx + ddx, nny = ny + ddy;
                    if(inside(nnx, nny)) c++;
                }
                limit[i][j][d] = c;
            }
        }
    }

我们去把这段逻辑融入原代码中

#include <bits/stdc++.h>
using namespace std;
vector<pair<int, int>> dir = {
  {-1, -1}, {-1, 0}, {-1, 1},
  {0, -1}, {0, 0}, {0, 1},
  {1, -1}, {1, 0}, {1, 1}  
};
int main()
{
  int n, m;
  cin >> n >> m;
  // cnt记录每个点周围及其自身的炸弹数量, 初始化为 -1,便于日后操作判断是否有雷
  auto cnt = vector(n, vector<int>(m, -1));
  for(int i = 0; i < n; i++){
    for(int j = 0; j < m; j++){
      char ch;
      cin >> ch;
      if(isdigit(ch)) cnt[i][j] = ch - '0';
    }
  }
  vector<string> board(n, string(m, '0'));
  auto inside = [&](int x, int y){
    return x >= 0 && x < n && y >= 0 && y < m;
  };
      auto limit = vector(n, vector(m, vector<int>(9)));
    for(int i = 0; i < n; i++){
        for(int j = 0; j < m; j++){
            for(int d = 0; d < 9; d++){
                auto [dx, dy] = dir[d];
                int nx = i + dx, ny = j + dy;
                if(!inside(nx, ny)) continue;
                int c = 0;
                // 我们的方向是有次序的,对于下一个点来说,上一个点指下来的的方向,反方向上上一个点也在影响着这个点。
                // 比如 上一个点以 dir[7] 的正下方方向指下来,对于下一个点来说 这可以视为dir[1] 的正上方方向指出,那么它还可以遍历的方向就是 3 - 9.
                // 每一次对x方向取反方向 实际上映射下来就是 dir[9 - x]
                for(int dd = 9 - d; dd < 9; dd++){
                    auto [ddx, ddy] = dir[dd];
                    int nnx = nx + ddx, nny = ny + ddy;
                    if(inside(nnx, nny)) c++;
                }
                limit[i][j][d] = c;
            }
        }
    }
  auto can = [&](int x, int y, int c){
    // 新逻辑筛选,我们所有的点就都可以进行剪枝操作。
    if(c == 0){
        for(int d = 0; d < 9; d ++){
            auto [dx, dy] = dir[d];
            int nx = x + dx, ny = y + dy;
            if(!inside(nx, ny)) continue;
            if(cnt[nx][ny] == -1) continue;
            //超过临界值的情况非法
            if(cnt[nx][ny] > limit[x][y][d]) return false;
        }
    }
    if(c == 1){
      for(auto &[dx, dy] : dir){
        int nx = x + dx, ny = y + dy;
        if(!inside(nx, ny)) continue;
        if(cnt[nx][ny] == -1) continue;
        if(cnt[nx][ny] == 0) return false;
      }
    }
    return true;
  };

  auto update = [&](int x, int y){
    board[x][y] = '1';
    for(auto &[dx, dy] : dir){
      int nx = x + dx, ny = y + dy;
      if(!inside(nx, ny)) continue;
      if(cnt[nx][ny] == -1) continue;
      cnt[nx][ny]--;
    }
  };

  auto rollback = [&](int x, int y){
    board[x][y] = '0';
    for(auto &[dx, dy] : dir){
      int nx = x + dx, ny = y + dy;
      if(!inside(nx, ny)) continue;
      if(cnt[nx][ny] == -1) continue;
      cnt[nx][ny]++;
    }
  };

  auto traceback = [&](auto&self, int pos){
    if(pos == n * m) return true;
    int x = pos / m, y = pos % m;
    if(can(x, y, 0)){
      if(self(self, pos + 1)) return true;
    }
    if(can(x, y, 1)){
      update(x, y);
      if(self(self, pos + 1)) return true;
      rollback(x, y);
    }
    return false;
  };
  traceback(traceback, 0);
  for(auto&line : board) cout << line << endl;

  // 请在此输入您的代码
  return 0;
}

修改后的代码成功ac了,我们用回溯剪枝解决掉了一道标签为插头dp的紫题,还是比较有成就感的,hh。

7.点亮

[https://www.lanqiao.cn/problems/2227/learning/?page=1&first_category_id=1&problem_id=2227]

分析:

各个规则我们其实在别的题目也见过类似,这题就相当于先前规则的综合性题目。
灯泡不可以互相照射,就类似于N皇后问题,灯泡不可以相互攻击。
棋盘上的数字表示周围四个格子的灯泡数量又类似于简化版的01游戏,只对四个位置进行标记。
所有无数字的位置都要被点亮,是否被点亮就需要做出标记,作为回溯的推出条件。

由于会发生阻挡,所以我们无法像N皇后那样一行一行,一列一列的枚举,那么我们就只能一个点一个点的去枚举,我们可以对点去记录多少个灯泡照射到,避免原先被其他照亮的格子被错误熄灭,最后判断条件自然是格子被灯泡照亮的个数是否大于0。

那么该如何填出每个格周围应有的灯泡?
我们不妨就按照下右左上的顺序去操作
当前白色格子为(x,y),周围有黑格子时,当不放灯泡时需要确保黑格子的需求数 ≤ 其剩余可用相邻格子的最大可能数。按下、右、左、上的顺序检查四个方向:
方向 方向索引 i 剩余可用格子数 3 - i 最大允许需求 3 - i
下 0 3 3
右 1 2 2
左 2 1 1
上 3 0 0
对于四个位置,我们可以采取一定的顺序,(i, j) 不放灯泡前提下向各个方向枚举判断时应有:
下cnt[i+1][j] <= 3, 右cnt[i][j+1] <= 2, 左cnt[i][j-1] <= 1, 上cnt[i-1][j] <= 0.
按照 下右左上的顺序去写dir函数
那么就有
下cnt[i+1][j] <= 3 - d[0], 右cnt[i][j+1] <= 3 - d[1], 左cnt[i][j-1] <= 3 - d[2], 上cnt[i-1][j] <= 3 - d[3].
那么我们放置灯泡的剪枝条件也就有了

#include <bits/stdc++.h>
using namespace std;

vector<pair<int, int>> dir = {
  {1, 0}, {0, 1}, {0, -1}, {-1, 0}
};
int main() {
  cin.tie(0)->sync_with_stdio(0);
  int n;
  cin >> n;
  vector<string> maze(n);
  for (auto &line : maze) cin >> line;
  auto cnt = vector(n, vector(n, 0));
  auto lighted = vector(n, vector(n, 0));
  for (int i = 0; i < n; i ++) {
    for (int j = 0; j < n; j ++) {
      if (isdigit(maze[i][j])) {
        cnt[i][j] = maze[i][j] - '0';
      }
    }
  }

  auto inside = [&](int x, int y) {
    return x >= 0 && x < n && y >= 0 && y < n;
  };

  auto can_move_on = [&](int x, int y) {
    for (auto [dx, dy] : dir) {
      int nx = x + dx, ny = y + dy;
      while (inside(nx, ny)) {
        if (isdigit(maze[nx][ny])) break;
        if (maze[nx][ny] == 'X') break;
        if (maze[nx][ny] == 'O') return false;
        nx += dx, ny += dy;
      }
    }
    for (auto [dx, dy] : dir) {
      int nx = x + dx, ny = y + dy;
      if (!inside(nx, ny)) continue;
      if (isdigit(maze[nx][ny]) && cnt[nx][ny] == 0) return false;
    }
    return true;
  };
  auto can_move_off = [&](int x, int y) {
    for (int i = 0; i < 4; i ++) {
      int nx = x + dir[i].first, ny = y + dir[i].second;
      if (!inside(nx, ny)) continue;
      if (isdigit(maze[nx][ny]) && cnt[nx][ny] >= 4 - i) return false;
    }
    return true;
  };
  auto update = [&](int x, int y) {
    maze[x][y] = 'O';
    for (auto [dx, dy] : dir) {
      int nx = x + dx, ny = y + dy;
      if (!inside(nx, ny)) continue;
      if (isdigit(maze[nx][ny])) cnt[nx][ny]--;
    }
    lighted[x][y] ++;
    for (auto [dx, dy] : dir) {
      int nx = x + dx, ny = y + dy;
      while (inside(nx, ny)) {
        if (isdigit(maze[nx][ny]) || maze[nx][ny] == 'X') break;
        lighted[nx][ny] ++;
        nx += dx, ny += dy;
      }
    }
  };
  auto rollback = [&](int x, int y) {
    maze[x][y] = '.';
    for (auto [dx, dy] : dir) {
      int nx = x + dx, ny = y + dy;
      if (inside(nx, ny) && isdigit(maze[nx][ny])) cnt[nx][ny]++;
    }
    lighted[x][y] --;
    for (auto [dx, dy] : dir) {
      int nx = x + dx, ny = y + dy;
      while (inside(nx, ny)) {
        if (isdigit(maze[nx][ny]) || maze[nx][ny] == 'X') break;
        lighted[nx][ny] --;
        nx += dx, ny += dy;
      }
    }
  };
  auto check = [&]() {
    for (int i = 0; i < n; i ++) {
      for (int j = 0; j < n; j ++) {
        if (maze[i][j] == '.' && lighted[i][j] == 0) return false;
        if (isdigit(maze[i][j]) && cnt[i][j] != 0) return false;
      }
    }
    return true;
  };
  auto traceback = [&](auto &self, int pos) {
    if (pos == n * n) return check();
    int x = pos / n, y = pos % n;
    if (maze[x][y] != '.') return self(self, pos + 1);
    if (can_move_on(x, y)) {
      update(x, y);
      if (self(self, pos + 1)) return true;
      rollback(x, y);
    }
    if (can_move_off(x, y)) {
      if (self(self, pos + 1)) return true;
    }
    return false;
  };
  traceback(traceback, 0);
  for (auto &line : maze) cout << line << endl;
}

题目的分析练习就到这里告一段落。
总而言之,回溯类型的题目是有思路可寻的,都可被分装成5个函数去完成

  • traceback 回溯主函数
  • can 是否可以到达下一个状态或者 illegality 是否是非法 (剪枝)
  • update 更新状态
  • rollback 回滚状态
  • check 检测最终状态是否合法
    可以说,回溯的技巧和延伸回溯类型的题目核心便是剪枝,回溯。
    感谢大家观看,码字不易,觉得有用的话还请点点收藏,点点赞吧。
    也欢迎大家来分享讨论,一起交流提高~。
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值