面试常考算法题---回溯法(学习笔记)

Posted 忆_恒心

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了面试常考算法题---回溯法(学习笔记)相关的知识,希望对你有一定的参考价值。

前言:

学习网站:

  • 代码随想录---算法讲解
  • LeetCode官网作为刷题的平台

回溯法小结

回溯算法可以解决的问题如下:

  • 组合问题:N个数里面按照一定规则找出k个数的集合
  • 排列问题:N个数按一定规则全排列,有几种排列方式
  • 切割问题:一个字符按一定规则有几种切割方式
  • 子集问题:一个N个数的集合里有多少符合条件的子集
  • 棋盘问题:N皇后,解数独

框架:

void backtracking(参数) 
    if (终止条件) 
        存放结果;
        return;
    

    for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) 
        处理节点;
        backtracking(路径,选择列表); // 递归
        回溯,撤销处理结果
    

组合问题

抽象成树结构

for循环横向遍历,递归纵向遍历回溯不断调整结果集

优化回溯

剪枝精髓是:for循环在寻找起点的时候要有一个范围,如果这个起点到集合终止之间的元素已经不够题目要求的k个元素了,就没有必要搜索了

  1. 已经选择的元素个数:path.size();
  2. 还需要的元素个数为: k - path.size();
  3. 在集合n中至多要从该起始位置 : n - (k - path.size()) + 1,开始遍历

为什么有个+1呢,因为包括起始位置,我们要是一个左闭的集合。

举个例子,n = 4,k = 3, 目前已经选取的元素为0(path.size为0),n - (k - 0) + 1 即 4 - ( 3 - 0) + 1 = 2。

组合总和

组合总和(一)

在组合问题上加了一个元素总和的限制,不可重复取

优化剪枝

本题的剪枝会好想一些,即:已选元素总和如果已经大于n(题中要求的和)了,那么往后遍历就没有意义了,直接剪掉

还可以再剪:

i <= 9 - (k - path.size()) + 1

组合总和(二)

可以重复取,回溯的时候不用startIndex+1

树形结构:

剪枝优化

for (int i = startIndex; i < candidates.size() && sum + candidates[i] <= target; i++)

组合总和(三)

集合里有重复,但要求解集不能包含重复的组合。

提到了树枝去重和树层去重

我在图中将used的变化用橘黄色标注上,可以看出在candidates[i] == candidates[i - 1]相同的情况下:

  • used[i - 1] == true,说明同一树枝candidates[i - 1]使用过
  • used[i - 1] == false,说明同一树层candidates[i - 1]使用过

多个集合求组合

回溯算法:电话号码的字母组合 (opens new window)中,开始用多个集合来求组合,还是熟悉的模板题目,但是有一些细节。

树形结构如下:

切割问题

回溯算法:分割回文串

难点二在于如何截取

    void backtracking(const string s, int startIndex)
        if(startIndex >= s.size())
            res.push_back(path);
            return ;
        
        for(int i = startIndex; i < s.size();  ++i)
            if(isPart(s, startIndex, i))
                string str = s.substr(startIndex, i - startIndex + 1);
                path.push_back(str);
                backtracking(s, i + 1);
                path.pop_back();
            else
                continue;
            
        
    

子集问题(一)

与组合问题的区别在于,在树形结构中子集问题是要收集所有节点的结果,而组合问题是收集叶子节点的结果

子集问题(二)

回溯算法:求子集问题! 对子集问题去重.

:fire:注意一定要先排序,这样才可以起到去重的作用

递增子序列

也是要去重的,但是不能用之前的方法先排序再去重(递增子序列会被破坏)

考虑用set

    void backtracking(vector<int>& nums, int startIndex)
        // 题目要求子序列的元素要大于2
        if(path.size() >= 2)
            res.push_back(path);
        
        unordered_set<int> uset;
        for(int i = startIndex; i < nums.size(); ++i)
            if((!path.empty() && nums[i] < path.back()) || uset.find(nums[i])!=uset.end())
                // 下一层的节点比上一层子序列的最后一个还小
                // 出现重复的情况
                continue;
            else
                uset.insert(nums[i]);
                path.push_back(nums[i]);
                backtracking(nums, i + 1);
                path.pop_back();
            
        
    

排列问题

排列是有序的,也就是说 [1,2] 和 [2,1] 是两个集合,这和之前分析的子集以及组合所不同的地方。

这时候就不再需要startIndex了

  • 每层都是从0开始搜索而不是startIndex
  • 需要used数组记录path里都放了哪些元素了

排列问题(二)

排列问题要要去重了,去重的原理和组合的是一样的,但是值得注意的是:

剪枝的位置有两个地方:

used[i - 1] == false也可以(之前的方式),used[i - 1] == true也可以!

树层上去重(used[i - 1] == false),的树形结构如下:

树枝上去重(used[i - 1] == true)的树型结构如下:

可以清晰的看到使用(used[i - 1] == false),即树层去重,效率更高!

class Solution 
private:
    vector<vector<int>> result;
    vector<int> path;
    void backtracking (vector<int>& nums, vector<bool>& used) 
        // 此时说明找到了一组
        if (path.size() == nums.size()) 
            result.push_back(path);
            return;
        
        for (int i = 0; i < nums.size(); i++) 
            // used[i - 1] == true,说明同一树枝nums[i - 1]使用过
            // used[i - 1] == false,说明同一树层nums[i - 1]使用过
            // 如果同一树层nums[i - 1]使用过则直接跳过
            if (i > 0 && nums[i] == nums[i - 1] && used[i - 1] == false) 
                continue;
            
            if (used[i] == false) 
                used[i] = true;
                path.push_back(nums[i]);
                backtracking(nums, used);
                path.pop_back();
                used[i] = false;
            
        
    
public:
    vector<vector<int>> permuteUnique(vector<int>& nums) 
        result.clear();
        path.clear();
        sort(nums.begin(), nums.end()); // 排序
        vector<bool> used(nums.size(), false);
        backtracking(nums, used);
        return result;
    
;

棋盘问题

N皇后问题

N皇后问题,经典的困难问题

分析易得:

树的高度就是矩阵的高度

树中每个节点的宽度就是矩阵的宽度

约束条件:

只要搜索到了树的叶子节点,说明就找到了皇后们的合理位置。

class Solution 
private:
vector<vector<string>> result;
// n 为输入的棋盘大小
// row 是当前递归到棋盘的第几行了
void backtracking(int n, int row, vector<string>& chessboard) 
    if (row == n) 
        result.push_back(chessboard);
        return;
    
    for (int col = 0; col < n; col++) 
        if (isValid(row, col, chessboard, n))  // 验证合法就可以放
            chessboard[row][col] = Q; // 放置皇后
            backtracking(n, row + 1, chessboard);
            chessboard[row][col] = .; // 回溯,撤销皇后
        
    

bool isValid(int row, int col, vector<string>& chessboard, int n) 
    int count = 0;
    // 检查列
    for (int i = 0; i < row; i++)  // 这是一个剪枝
        if (chessboard[i][col] == Q) 
            return false;
        
    
    // 检查 45度角是否有皇后
    for (int i = row - 1, j = col - 1; i >=0 && j >= 0; i--, j--) 
        if (chessboard[i][j] == Q) 
            return false;
        
    
    // 检查 135度角是否有皇后
    for(int i = row - 1, j = col + 1; i >= 0 && j < n; i--, j++) 
        if (chessboard[i][j] == Q) 
            return false;
        
    
    return true;

public:
    vector<vector<string>> solveNQueens(int n) 
        result.clear();
        std::vector<std::string> chessboard(n, std::string(n, .));
        backtracking(n, 0, chessboard);
        return result;
    
;

解数独问题

抽象成树结构的话,其实是比较大的,选取部分进行分析:

二维递归:

难点:

递归函数的返回值需要是bool类型,为什么呢?

因为解数独找到一个符合的条件(就在树的叶子节点上)立刻就返回,相当于找从根节点到叶子节点一条唯一路径,所以需要使用bool返回值。

递归终止条件?

本题递归不用终止条件,解数独是要遍历整个树形结构寻找可能的叶子节点就立刻返回。

如何进行二维递归?

一个for循环遍历棋盘的行,一个for循环遍历棋盘的列,一行一列确定下来之后,递归遍历这个位置放9个数字的可能性!

bool backtracking(vector<vector<char>>& board) 
    for (int i = 0; i < board.size(); i++)         // 遍历行
        for (int j = 0; j < board[0].size(); j++)  // 遍历列
            if (board[i][j] != .) continue;
            for (char k = 1; k <= 9; k++)      // (i, j) 这个位置放k是否合适
                if (isValid(i, j, k, board)) 
                    board[i][j] = k;                // 放置k
                    if (backtracking(board)) return true; // 如果找到合适一组立刻返回
                    board[i][j] = .;              // 回溯,撤销k
                
            
            return false;                           // 9个数都试完了,都不行,那么就返回false
        
    
    return true; // 遍历完没有返回false,说明找到了合适棋盘位置了

整体的实现代码:

class Solution 
private:
bool backtracking(vector<vector<char>>& board) 
    for (int i = 0; i < board.size(); i++)         // 遍历行
        for (int j = 0; j < board[0].size(); j++)  // 遍历列
            if (board[i][j] != .) continue;
            for (char k = 1; k <= 9; k++)      // (i, j) 这个位置放k是否合适
                if (isValid(i, j, k, board)) 
                    board[i][j] = k;                // 放置k
                    if (backtracking(board)) return true; // 如果找到合适一组立刻返回
                    board[i][j] = .;              // 回溯,撤销k
                
            
            return false;                           // 9个数都试完了,都不行,那么就返回false
        
    
    return true; // 遍历完没有返回false,说明找到了合适棋盘位置了

bool isValid(int row, int col, char val, vector<vector<char>>& board) 
    for (int i = 0; i < 9; i++)  // 判断行里是否重复
        if (board[row][i] == val) 
            return false;
        
    
    for (int j = 0; j < 9; j++)  // 判断列里是否重复
        if (board[j][col] == val) 
            return false;
        
    
    int startRow = (row / 3) * 3;
    int startCol = (col / 3) * 3;
    for (int i = startRow; i < startRow + 3; i++)  // 判断9方格里是否重复
        for (int j = startCol; j < startCol + 3; j++) 
            if (board[i][j] == val ) 
                return false;
            
        
    
    return true;

public:
    void solveSudoku(vector<vector<char>>& board) 
        backtracking(board);
    
;

重新安排行程

回溯算法:重新安排行程

class Solution 
private:
// unordered_map<出发机场, map<到达机场, 航班次数>> targets
unordered_map<string, map<string, int>> targets;
bool backtracking(int ticketNum, vector<string>& result) 
    if (result.size() == ticketNum + 1) 
        return true;
    
    for (pair<const string, int>& target : targets[result[result.size() - 1]]) 
        if (target.second > 0 )  // 记录到达机场是否飞过了
            result.push_back(target.first);
            target.second--;
            if (backtracking(ticketNum, result)) return true;
            result.pop_back();
            target.second++;
        
    
    return false;

public:
    vector<string> findItinerary(vector<vector<string>>& tickets) 
        targets.clear();
        vector<string> result;
        for (const vector<string>& vec : tickets) 
            targets[vec[0]][vec[1]]++; // 记录映射关系
        
        result.push_back("JFK"); // 起始机场
        backtracking(tickets.size(), result);
        return result;
    
;

性能分析

学习资料:

  1. 代码随想录
  2. LeetCode官网

以上是关于面试常考算法题---回溯法(学习笔记)的主要内容,如果未能解决你的问题,请参考以下文章

算法 ---- LeetCode回溯系列问题题解

算法 ---- LeetCode回溯系列问题题解

算法 ---- LeetCode回溯系列问题题解

leetcode算法题基础(四十二) 回溯算法总结

leetcode 37. 解数独----回溯篇1

解数独算法的实现——剪枝优化