[面试算法系列]回溯法,看完这篇保证再也不怕面试遇到回溯法了

回溯法

回溯问题的本质实际上就是一个决策树的遍历问题。需要解决3个问题。

  1. 路径: 即已经做出的选择,路径是用于确定当前状态的
  2. 选择列表: 当前可以做的选择,有时选择列表并不显式作为参数传入,而是直接推导
  3. 结束条件: 到达决策树底层的标志,无法再做选择。多根据路径确定

代码框架:

vector<int> result;
void backtrack(路径,选择列表){
    if(符合结束条件){
        result.push_back(路径);
        return;
    }
    for(选择 : 选择列表){
        做选择;路径增加选择;选择列表删除选择;
        backtrack(路径,选择列表);
        撤销选择;路径取消选择;选择列表添加选择;
    }
}

回溯算法的框架时间复杂度为O(N!),且只要是使用回溯算法框架则无法避免穷举决策树,时间复杂度无法优化。这是一个暴力方法,一般时间复杂度都很高。

例题

46. 全排列

经典回溯方法例题,直接顺着写即可。

vector<vector<int>> res;
vector<vector<int>> permute(vector<int>& nums) {
    res = {};
    backtrack({}, nums);
    return res;
}
void backtrack(vector<int> road , vector<int>& nums){
    if(nums.size() == 0){
        res.push_back(road);
    }
    for(auto i = nums.begin() ; i!=nums.end() ; i++){
        int n = *i;
        road.push_back(*i);
        auto numspos = nums.erase(i);

        backtrack(road , nums);

        road.pop_back();
        nums.insert(numspos , n);
    }
}

优化思路: 直接在原数组中通过位置和索引存储信息,则空间复杂度仅有递归函数分配栈空间的占用。

51. N皇后

相对而言回溯法时间复杂度较高,但是在面对结构简单的题型时较易思考。时间复杂度O(N!)。

思路过程:显然本题需要使用枚举法完成,但是采用不懂的枚举思路,时间复杂度的规模也相差很大。本题中逐行遍历时间复杂度是相对最低的。

  • 子集枚举:可以把问题转化成「从 n2个格子中选一个子集,使得子集中恰好有 n 个格子,且任意选出两个都不在同行、同列或者同对角线」,这里枚举的规模是 2 n 2 2^{n^2} 2n2;
  • 组合枚举:可以把问题转化成「从 n2个格子中选择n 个,且任意选出两个都不在同行、同列或者同对角线」,这里的枚举规模是 C n 2 n C_{n^2}^n Cn2n;
  • 排列枚举:因为每行只能放置一个皇后,而所有行中皇后的列号正好构成一个 1到 n 的排列,所以我们可以把问题转化为一个排列枚举,规模是 n!
class Solution {
public:
    vector<vector<string>> res;

    vector<vector<string>> solveNQueens(int n) {
        res={};
        vector<vector<int>> vis(n, vector<int>(n));
        bracktrack(vis,n);
        return res;
    }
    void bracktrack(vector<vector<int>> & vis, int n){
        if(n==0){
            //制作答案 并退出
            vector<string> oneres;
            for(auto row : vis){
                string s = "";
                for(int i = 0 ; i < row.size(); ++i){
                    if(row[i] == 2) s+='Q';
                    else            s+='.';
                }
                oneres.push_back(s);
            }
            res.push_back(oneres);
            return;
        }
        int currow = vis.size() - n;
        auto curline = vis[currow];
        auto savervis = vis;//暂存一份
        for(int i = 0 ; i < curline.size(); i++){
            //此时该行不会有2的
            if(curline[i] == 1) continue;
            if(curline[i] == 0){
                //只要是0就说明是可行位
                vis = savervis;
                //当前位置
                int x = currow, y = i;
                //横向
                for(int z = 0; z < vis.size() ; z++){
                    //不用担心2变1  只有1与0变1
                    vis[x][z] = 1;                    
                } 
                //纵向
                for(int z= x;z<vis.size();z++){
                    vis[z][y] = 1;
                }
                //斜向
                for(int sx=x,sy=y; sx<vis.size()&&sx>=0&&sy<vis.size()&&sy>=0 ; sx++,sy++ ){
                    vis[sx][sy] = 1;
                }
                for(int sx=x,sy=y; sx<vis.size()&&sx>=0&&sy<vis.size()&&sy>=0 ; sx++,sy-- ){
                    vis[sx][sy] = 1;
                }
                vis[x][y] = 2;
                bracktrack(vis,n-1);                
            }
        }
        return;
    }
};
679. 24 点游戏

分析题意: 实际上仅有4个数字,3次运算。考虑到括号的原因,所以使用选取的方式从原数组只能搞选取2个数进行运算。计算时间复杂度尝试能否回溯法解决。

  1. 4C2 从原数组中选2个数字。6种
  2. 进行运算。 4种
  3. 3C2 3个数选2个 3种
  4. 进行运算。 4种
  5. 2选2 1种
  6. 进行运算。 4种

故总共27*32约1000余种情况需要遍历,则可以使用回溯法遍历所有情况,进行测试。

int ans = 0;
bool judgePoint24(vector<int>& nums) {
    //回溯法
    //3轮选数字,3轮运算 选数字和计算应该放在一块才对的,一轮搞定
    ans = 0;
    vector<double> fnums;
    for(auto i : nums){
        fnums.push_back((double)i);
    }
    bracktrack(fnums);
    if(ans) return true;
    return false;
}


void bracktrack(vector<double>& nums){
    //退出条件
    if(nums.size() == 1){
        // if(nums[0]<25 && nums[0]>23)
        //     cout<<nums[0]<<endl;
        if(abs(nums[0] - 24) < 0.000000001)  {
            ans++;
        }
    }
    vector<double> store = nums;//暂存
    //分组做选择
    //选2个数 选4种算法
    for(int i = 0 ; i < nums.size() ; i++){
        for(int j = i+1 ; j < nums.size() ; j++){
            
            //选数
            double num1 = nums[i], num2 = nums[j];
            //先删后面的  再删前面的
            nums.erase(nums.begin()+j);
            nums.erase(nums.begin()+i);
            
            //4种选择计算
            double numadd = num1 + num2;
            double numsub = num1 - num2;
            double nummul = num1 * num2;
            double numdiv = num1 / num2;
            //回溯步骤
            nums.push_back(numadd);
            bracktrack(nums);
            nums.erase(nums.begin() + (nums.size()-1));
            nums.push_back(numsub);
            bracktrack(nums);
            nums.erase(nums.begin() + (nums.size()-1));

            nums.push_back(-numsub);
            bracktrack(nums);
            nums.erase(nums.begin() + (nums.size()-1));

            nums.push_back(nummul);
            bracktrack(nums);
            nums.erase(nums.begin() + (nums.size()-1));
            nums.push_back(numdiv);
            bracktrack(nums);
            nums.erase(nums.begin() + (nums.size()-1));

            nums.push_back(1/numdiv);
            bracktrack(nums);
            nums.erase(nums.begin() + (nums.size()-1));

            //删掉的添加回去 先加前面再加后面
            nums = store;
        }
    }
    return ;    
}

注意与优化方向:

  • 实际上如果出现0,则没有计算除法的必要了。可以稍微减少计算量
  • 注意浮点数运算可能会导致微小误差,故小于1e-6可视为相等
  • 可以使用枚举类型或者宏定义ADD这些操作,适当优化代码可读性
491. 递增子序列

常规解法,回溯遍历后删除重复的。

    vector<vector<int>> res ;
    vector<vector<int>> findSubsequences(vector<int>& nums) {
        //回溯法
        res = {};
        backtrack({} , nums, 0);
        set<vector<int>> st(res.begin(),res.end());
        res.clear();
        res.assign(st.begin() , st.end());
        return res;
    }
    void backtrack(vector<int> road ,vector<int>  nums, int s){
        
        if(s >= nums.size()){
            if( road.size()>=2)
                res.push_back(road);
            return;
        }
        backtrack(road,nums,nums.size());//允许空选直接结束
        for(int i = s ; i < nums.size() ; i++){

            if(road.empty() || road[road.size()-1] <= nums[i]){
                road.push_back(nums[i]);
                backtrack(road,nums,i+1);
                road.pop_back();
            }
            // i = i + same;//same是与nums[i]相同的个数
            
        }        
    }

优化方法,在遍历的过程中直接搞定重复的内容。

  • 使序列合法的办法非常简单,即给「选择」做一个限定条件,只有当前的元素大于等于上一个选择的元素的时候才能选择这个元素,这样枚举出来的所有元素都是合法的

  • 那如何保证没有重复呢?我们需要给「不选择」做一个限定条件,只有当当前的元素不等于上一个选择的元素的时候,才考虑不选择当前元素,直接递归后面的元素。!否则必须选择! 因为如果有两个相同的元素,我们会考虑这样四种情况:

    前不,后不

    前选,后不

    前不,后选

    前选,后选

    把2 3合并成3,得到这个结论

class Solution {
public:
    vector<int> temp; 
    vector<vector<int>> ans;

    void dfs(int cur, int last, vector<int>& nums) {
        if (cur == nums.size()) {
            if (temp.size() >= 2) {
                ans.push_back(temp);
            }
            return;
        }
        if (nums[cur] >= last) {
            temp.push_back(nums[cur]);
            dfs(cur + 1, nums[cur], nums);
            temp.pop_back();
        }
        if (nums[cur] != last) {
            dfs(cur + 1, last, nums);
        }
    }

    vector<vector<int>> findSubsequences(vector<int>& nums) {
        dfs(0, INT_MIN, nums);
        return ans;
    }
};

回溯法具有两种思考方式:

  • 这一步选哪个
  • 这一个元素选不选

在本题中,由于假如考虑这一步选哪个的情况,需要做到如果选了一个数,且它的后面有相同的数,则全都必须选。要跳一些递归,导致较为复杂。但是假如看元素的话,则较为简单。只有下一位元素与当前最后一位不同,方才有不选它的选择。

78 子集

给定一组不含重复元素的整数数组 nums,返回该数组所有可能的子集(幂集)。

说明:解集不能包含重复的子集。

样例:

输入: nums = [1,2,3]
输出:
[
[3],
  [1],
  [2],
  [1,2,3],
  [1,3],
  [2,3],
  [1,2],
  []
]

排列组合类问题的常见解决思路

全排列/组合/子集问题,解空间均非常大,常用解决方案有3种.

  1. 递归
  2. 回溯
  3. 基于二进制位掩码和对应位掩码之间的映射字典生成排列/组合/子集

递归

假设输出数组为空.逐个读取原数组元素,与输出数组逐个结合并加入输出数组.

vector<vector<int>> subsets(vector<int>& nums) {
    vector<vector<int>> output = {{}};//init an empty vector
    vector<int> temp_vec = {};

    for (int i = 0 ; i < nums.size() ; i++ ){
        int temp_size = output.size();
        for (int j = 0; j < temp_size ; j++){//output is two dimensional 
            temp_vec=output[j];//vector = vector
            temp_vec.push_back(nums[i]);
            output.push_back(temp_vec);
        }
    }
    return output;    
}

复杂度分析

时间复杂度: O ( N ∗ 2 N ) O(N*2^N) O(N2N),nums中N个元素,此时output中有2^N个元素

空间复杂度: O ( N ∗ 2 N ) O(N*2^N) O(N2N)

回溯法

幂集是所有长度从0到n的子集的集合.遍历子集长度,通过回溯生成给定长度的子集。

回溯法选优搜索法,又称为试探法是一种探索所有潜在可能性找到解决方案的算法。如果当前方案不是正确的解决方案,或者不是最后一个正确的解决方案,则回溯法通过修改上一步的值继续寻找解决方案。

与DFS异同:

相同: 实现上也是遵循深度优先的,即一步一步往前探索。

不同点:

  1. 深度优先重点是遍历,本质是无序的。回溯法是有序的,每一步都是符合要求的次序。
  2. DFS所有节点只访问一次,回溯法可能存在没有访问的节点,也可能重复访问。
vector<int> curr = {};
vector<vector<int>> output = {};//init an empty vector

int n, k;
void back_track(int first , vector<int>& curr , vector<int>& nums){
    if (curr.size() == k){// if length of curr  equal to k then put curr into nums
        output.push_back(curr);
        return;
    }
    for (int i = first; i < n ; ++i){//first 
        //add i into curr vec
        curr.push_back(nums[i]);
        //repeat back_track
        back_track(i+1,curr,nums);
        //pop the last nums and continue the circle
        curr.pop_back();
    }
}

vector<vector<int>> subsets(vector<int>& nums) {
    n = nums.size();
    for (k = 0; k < n+1; ++k){//k is the length of subsets . it is a global var
        back_track(0,curr, nums);// start from zero travel all the num in nums
    }
        return output;    
}

时间复杂度: O ( N ∗ 2 N ) O(N*2^N) O(N2N)
空间复杂度: O ( N ∗ 2 N ) O(N*2^N) O(N2N)

二进制排序

每个子集都可以唯一映射到长度为n的位掩码中,0…0(全0)表示空子集,全1表示输入数组。只需要维护数组由全0至全1即可。

#include <math.h>

vector<vector<int>> subsets(vector<int> &nums){
    vector<vector<int>> sub = {};
    int pos = pow(2,nums.size());// 0..00 - 1..11     
    for (int i = 0 ; i < pos ; ++i)
    {
        vector<int> temp={};
        for(int dix=0; dix<nums.size(); dix++)
        {
            if((i >> dix) & 1) // 与运算
                temp.push_back(nums[dix]);
        }
        sub.push_back(temp);
    }
    return sub;

}

时间复杂度: O ( N ∗ 2 N ) O(N*2^N) O(N2N)
空间复杂度: O ( N ∗ 2 N ) O(N*2^N) O(N2N)

经典回溯例题

组合

组合总和

组合总和 II

组合总和 III

参考博客

labuladong

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值