[LeetCode] 回溯题总结

写在前面

回溯法属于图的暴力深度优先搜索的一种应用,其特点是,维护一条深度遍历的路径,在路径的尽头(遍历完毕)判断条件是否满足,若是则找到一个组合,若否,则沿着递归深度的路径逐步回溯,回溯时调转遍历方向,递归至其他路径check是否满足条件,直至遍历完所有的路径则终止,递归深入+回溯退出

LeetCode中对回溯法的总结非常到位,见链接,引用如下:

回溯算法实际上一个类似枚举的搜索尝试过程,主要是在搜索尝试过程中寻找问题的解,当发现已不满足求解条件时,就 “回溯” 返回,尝试别的路径。回溯法是一种选优搜索法,按选优条件向前搜索,以达到目标。但当探索到某一步时,发现原先选择并不优或达不到目标,就退回一步重新选择,这种走不通就退回再走的技术为回溯法,而满足回溯条件的某个状态的点称为 “回溯点”。许多复杂的,规模较大的问题都可以使用回溯法,有“通用解题方法”的美称。

回溯算法的基本思想是:从一条路往前走,能进则进,不能进则退回来,换一条路再试。

重点批注:

  1. 题127虽然解法不是回溯解法,因为在此题中回溯解法不是最优,选BFS解最佳,另外题127和题126属于LT中经典且有一定难度的题,可重点关注;

经典解题场景

  • 子集问题

37. 解数独

原题链接

解题思路: 采用行填充的方式,先填充第1行,再填充第2行…,直至填充完第9行,在填充第i行时,若填充至第9列,则下一次将填充第i+1行第0列,OK,保持这个整个思路,接下来来计算按照规定动作写回溯算法的代码了,递归进入的时候修改board[i][j]值,递归退出时复原board[i][j]值。另外回溯函数还有一个特点,即函数返回值翻译递归的路径是否成功,若不成功,则需要回溯,调整搜索的方向

class Solution {
public:
    void solveSudoku(vector<vector<char>>& board) {
        int rows = board.size(), cols = board[0].size();
        helper(board, 0, 0);
    }
    bool isSudoku(vector<vector<char>>& board, int i, int j, char ch) {
        int r = i - i % 3, c = j - j % 3;
        for (int k = 0; k < 9; ++k) {
            if (board[i][k] == ch) return false;
        }
        for (int k = 0; k < 9; ++k) {
            if (board[k][j] == ch) return false;
        }
        for (int k = 0; k < 3; ++k) {
            for (int l = 0; l < 3; ++l) {
                if (board[r + k][c + l] == ch) return false;
            }
        }
        return true;
    }
    bool helper(vector<vector<char>>& board, int i, int j) {
        if (i >= 9) return true;
        if (j == 9) return helper(board, i + 1, 0);
        if (board[i][j] != '.') return helper(board, i, j + 1);
        for (char c = '1'; c <= '9'; ++c) {
            if (!isSudoku(board, i, j, c)) continue;
            board[i][j] = c;
            if (helper(board, i, j + 1)) return true;
            board[i][j] = '.';
        }
        return false;
    }
};

51. N皇后

n 皇后问题研究的是如何将 n 个皇后放置在 n×n 的棋盘上,并且使皇后彼此之间不能相互攻击。

给定一个整数 n,返回所有不同的 n 皇后问题的解决方案。

每一种解法包含一个明确的 n 皇后问题的棋子放置方案,该方案中 ‘Q’ 和 ‘.’ 分别代表了皇后和空位。

原题链接

解题思路: 本题的解题思路依然是遵照回溯算法的模板做题,放置的方法依然是逐行放置,理解回溯算法后解题比较简单,请看代码。

class Solution {
public:
    vector<vector<string>> solveNQueens(int n) {
        vector<vector<string>> res;
        vector<int> out(n, -1);
        helper(n, 0, out, res);
        return res;
    }
    void helper(int n, int r, vector<int> out, vector<vector<string>> &res) {
        if (r >= n) {
            vector<string> tmp(n, string(n, '.'));
            for (int i = 0; i < n; ++i) {
                tmp[i][out[i]] = 'Q';
            }
            res.push_back(tmp);
        }
        for (int k = 0; k < n; ++k) {
            if (!isValid(n, out, r, k)) continue;
            out[r] = k;
            helper(n, r + 1, out, res);
            out[r] = -1;
        }
    }
    bool isValid(int n, vector<int> out, int r, int c) {
        for (int k = r - 1; k >= 0; --k) {
            if (out[k] == c) return false;
        }
        for (int k = 1; r - k >=0 && c - k >= 0; ++k) {
            if (out[r - k] == c - k) return false;
        }
        for (int k = 1; r - k >= 0 && c + k < n; ++k) {
            if (out[r - k] == c + k) return false;
        }
        return true;
    }
};

127. 单词接龙

给定两个单词(beginWord 和 endWord)和一个字典,找到从 beginWord 到 endWord 的最短转换序列的长度。转换需遵循如下规则:

  • 每次转换只能改变一个字母。
  • 转换过程中的中间单词必须是字典中的单词。

原题链接

解题思路: 我们可以手动解一下此题,我们会尝试依次对word的第0-n-1位上用’a’-'z’来修改它,看它是否在字典中,以决定是否继续搜索,显然我们能够感知是一个图的遍历问题,那么接下来是选DFS还是选BFS,这里我们会联想到迷宫找出口的题,那么此题自然选BFS,OK至此此题基本就解出来了。但是BFS是近乎Brute Force解法,因此我们还需要剪枝,做两步剪枝:1)若修改后的word与原word相同,则此条路径break,这个不用解释,2)若搜索时修改后的word在字典中,可将字典中这个word删掉,这是为什么呢?因为下次搜索到此word时,又会把后来的路径重新走一次,显然此次找到的word路径比后面再次找到word的路径短,那如果我们删掉它,有没有可能导致更短的路径搜不到word呢?不会,因为第一次碰到word,这条路径是有word中最短的(这是根据BFS层层打开的特征推断的),这里我们可以用反证法证,假设有一条更短的路径(简称A)在word删除之后搜索到的,那请问如果存在这条更短路径,那这更短路径应该在更小的层(更早)被搜索到(推论1),而在word被删掉之后搜索掉路径A,这明显与推论1矛盾,因此2)解答完毕。

class Solution {
public:
    int ladderLength(string beginWord, string endWord, vector<string>& wordList) {
        unordered_set<string> st(wordList.begin(), wordList.end());
        if (!st.count(endWord)) return 0;
        queue<string> q{{beginWord}};
        int res = 0;
        while (!q.empty()) {
            ++res;
            for (int i = q.size(); i > 0; --i) {
                string word = q.front(); q.pop();
                if (word == endWord) return res;
                for (int j = 0; j < word.size(); ++j) {
                    string newWord = word;
                    for (char ch = 'a'; ch <= 'z'; ++ch) {
                        newWord[j] = ch;
                        if (st.count(newWord) && newWord != word) {
                            q.push(newWord);
                            st.erase(newWord);
                        }
                    }
                }
            }
        }
        return 0;
    }
};

97. 交错字符串

原题链接

解题思路: 打脸了打脸了,╮(╯▽╰)╭,这题是在动态规划笔记中补充的解法,在笔记中,我最初想的是回溯解此题,但是没找到合适的剪枝方法,在解完动态规划解法后,沿着博主的博客看到两层剪枝,即1)当其中一个字符串耗尽时,剩下的另一个字符串后半段和s3直接子串匹配,2)记录匹配错误的位置,虽然博主把他的解法归为BFS/DFS,但是回溯法不正是DFS的一种应用嘛,没等看完,立马捡起最初写的回溯代码,将剪枝加上,AC~,代码如下。

OK,我们回过头分析一下,为什么会想到这样的剪枝方式以及为什么这种剪枝能成立,剪枝1好理解不解释,这里解释剪枝2,当s1s2s3字符串比较长时,s1s2位置对(i,j)会在回溯中重复出现的占比非常大,这个我们在程序中加打印检验很容易观察出这个现象,此处我们从理论上解释为什么出出现这种情况,假设我们某次访问的位置对是(k1,r1),那下次回溯的路径中假设有(i,j),若此时我们在这条路径上判断出来(i,j)是假,那一定至少存在一条路径也穿过(i,j),严格意义上这条路径我们是不再需要判断的(剪枝),e.g.,若在回溯路径上某次访问的是位置对(k2,r2),这完全是有可能的,那这条路径也有可能走(i,j)。以实际数字举例,假设某个访问的是位置对(40,39),沿着这条路径走,会访问到(51,67),而在另一个路径中,假设某个访问位置对(45,65),沿着路径走,也会访问到(51,67),假设此时在前一条路径上我们已经判断出来(51,76)是假,那么实际上第二条路径就不必再穿过(51,76)了。

实际上,把这题画成网络交叉图,会更好路径,而每次访问的结果用交点表征。

在这里插入图片描述

class Solution {
public:
    bool isInterleave(string s1, string s2, string s3) {
        if (s1.size() + s2.size() != s3.size()) return false;
        return helper(s1, 0, s2, 0, s3, 0);
    }
    bool helper(string s1, int i, string s2, int j, string s3, int k) {
        int key = i * s3.size() + j;
        if (st.count(key)) return false;
        if (i == s1.size()) return s2.substr(j) == s3.substr(k);
        if (j == s2.size()) return s1.substr(i) == s3.substr(k);
        if (i < s1.size() && s1[i] == s3[k]) {
            if (helper(s1, i + 1, s2, j, s3, k + 1)) return true;
        }
        if (j < s2.size() && s2[j] == s3[k]) {
            if (helper(s1, i, s2, j + 1, s3, k + 1)) return true;
        }
        st.insert(key);
        return false;
    }
private:
    unordered_set<int> st;
};

78. 子集

原题链接

解题思路: 本题考查回溯算法,一般考察类似试探型解题的场景都应该想到是不是能用回溯算法解。本题按照回溯算法的模板写即可,由于求的是子集,那么对每一个出现的out都需要保存。

// 考察回溯法,按照模板写即可
// T:O(n), space:O(n)
class Solution {
public:
    vector<vector<int>> subsets(vector<int>& nums) {
        vector<vector<int>> res;
        vector<int> out;
        helper(nums, 0, out, res);
        return res;
    }
    void helper(vector<int>& nums, int i, vector<int> out, vector<vector<int>>& res) {
        res.push_back(out);
        if (i >= nums.size()) return;
        for (int j = i; j < nums.size(); ++j) {
            out.push_back(nums[j]);
            helper(nums, j + 1, out, res);
            out.pop_back();
        }
    }
};

90. 子集 II

原题链接

解题思路: 本题在题78的基础上增加了,序列元素可重复,并且依然要求子集不重新,那么这里需要考虑如何对子集进行去重,解好此点即可解此题,一个直接且偷懒的方法,去重最常规的方法是用集合去重,因为集合一个重要的特点是,集合内元素不重复,在向集合中添加新元素时,集合会对添加的元素自动去重,但是如果使用集合去重,会引入额外的空间复杂度,因此解法不优。那有木有在遍历过程中直接去重的方法?如果我们没有思路或者一下子思路打不开,可以尝试的手动解此题,首先此题的解题肯定是依据题78回溯代码改,那么在哪儿加去重代码,如何加,这应该是我们需要思考的,OK,我们可以手动模拟回溯过程,并画图,回溯算法的本质是图的遍历,因此我们需要画图的遍历。根据回溯算法定义,每个多叉树(图)的路径即为我们一个子集元素,我们观察标红的2,当我们获取了第一个2的全部路径后,第二个2的路径我们就可以舍弃掉了,OK,至此我们应该知道如何去重如何continue了。请看下面代码。

在这里插入图片描述

class Solution {
public:
    vector<vector<int>> subsetsWithDup(vector<int>& nums) {
        sort(nums.begin(), nums.end());
        vector<vector<int>> res;
        vector<int> out;
        subsetsWithDup(nums, 0, out, res);
        return res;
    }
    void subsetsWithDup(vector<int>& nums, int i, vector<int> out, vector<vector<int>>& res) {
        res.push_back(out);
        if (i >= nums.size()) return;     
        for (int j = i; j < nums.size(); ++j) {
            if (j > i && nums[j] == nums[j - 1]) continue;
            out.push_back(nums[j]);
            subsetsWithDup(nums, j + 1, out, res);
            out.pop_back();
        }
    }
};
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值