算法练习第20天|回溯算法 77.组合问题 257. 二叉树的所有路径

发布于:2024-04-18 ⋅ 阅读:(22) ⋅ 点赞:(0)

1.什么是回溯算法?

回溯法也可以叫做回溯搜索法,它是一种搜索的方式。其本质是穷举,穷举所有可能,然后选出我们想要的答案。

2.为什么要有回溯算法?

那么既然回溯法并不高效为什么还要用它呢?

因为有的问题能暴力搜出来就不错了,撑死了再剪枝一下,还没有更高效的解法。比如下面这几类问题:

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

 3.如何理解回溯算法?

回溯法解决的问题都可以抽象为树形结构,是的,是所有回溯法的问题都可以抽象为树形结构!

因为回溯法解决的都是在集合中递归查找子集,集合的大小就构成了树的宽度,递归的深度就构成了树的深度。所以回溯和递归是分不开的

递归就要有终止条件,所以必然是一棵高度有限的树(N叉树)。

4.回溯算法模板 

类似递归算法的三部曲,回溯算法也有三部曲。

  • 第一步:确认回溯函数的参数及返回值。返回值类型一般为void。但是参数不想二叉树递归那样好确定,所以一般先写逻辑,根据回溯代码逻辑的需要再添加相应参数。回溯函数大致长这样:
void backtracking(参数)
  • 第二步:确认回溯函数的终止条件。既然回溯函数的问题可以等效为树形结构,那么就会遍历树形结构就一定会有终止条件。因此回溯也有终止条件。一般来说搜到叶子节点了,也就找到了满足条件的一条答案,把这个答案存放起来,并结束本层递归。所以终止条件的伪代码如下:
if (终止条件) {
    存放结果;
    return;
}
  • 第三步:确认单层回溯的遍历过程

由于回溯一般是在集合中进行递归搜索,集合的大小构成了树的宽度,递归的深度构成了树的深度。如图所示:

回溯函数遍历过程伪代码如下:

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

for循环就是遍历集合区间,可以理解一个节点有多少个孩子,这个for循环就执行多少次。

backtracking这里自己调用自己,实现递归。

 整体框架如下:

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

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

 力扣题目77.组合

77. 组合 - 力扣(LeetCode)icon-default.png?t=N7T8https://leetcode.cn/problems/combinations/description/

题目描述:

给定两个整数 n 和 k,返回范围 [1, n] 中所有可能的 k 个数的组合。

你可以按 任何顺序 返回答案。

示例 1:

输入:n = 4, k = 2
输出:
[
  [2,4],
  [3,4],
  [2,3],
  [1,2],
  [1,3],
  [1,4],
]

示例 2:

输入:n = 1, k = 1
输出:[[1]]

思路分析:

最直接的方法就是简单粗暴的双层循环(假设n=4,k=2):

int n = 4;
for (int i = 1; i <= n; i++) {
    for (int j = i + 1; j <= n; j++) {
        cout << i << " " << j << endl;
    }
}

这种k为2时就只用双层循环就行了,但是如果k=50,100?自己要写50层、100层循环就不太现实了。所以这个时候就可以使用回溯了。过程示意如图所示:

图中可以发现n相当于树的宽度,k相当于树的深度

那么如何在这个树上遍历,然后收集到我们要的结果集呢?

图中每次搜索到了叶子节点,就找到了一个结果

相当于只需要把达到叶子节点的结果收集起来,就可以求得 n个数中k个数的组合集合。

回溯解法:

来遍历的过程中,会找到复合要求的子集合,还要有记录这些子集合的结果大集合,所以下定义这两个记录结果的vector:

vector<vector<int>> result;  //用于存放组合的集合
vector<int> path; //用于存放复合条件的当前组合

 下面按照回溯三部曲来进行回溯函数的实现:

  • 第一步,确认回溯函数的参数以及返回值。

        返回值为void,函数的参数为n,k。初次之外,为了更有逻辑的进行穷举,我们再设置一个startIndex,表示本次回溯从【1,2,..., n】的哪里开始遍历元素。所以回溯函数长这样:

void backtracking(int n, int k, int startIndex){
}
  •  第二步,确认回溯的终止条件。根据题意,当记录当前组合的path有了k个元素,即本次回溯就可以终止了,要保存当前结果然后返回。

        具体回溯终止条件长这样: 

//回溯第二步:确认回溯函数的终止条件
if(path.size() == k)  //取得一个k长的组合
{
    result.push_back(path);
    return;
}
  •  第三步,确认单层回溯函数的遍历过程,即再回溯中需要做哪些事情?

 根据上述伪代码,需要做三件事:1.在当前层处理节点,即将没遍历过程的元素记录一下;2.递归,从刚刚记录过的元素的下一个元素继续进行该过程,直到条件满足代码返回该递归处;3.弹出刚才最后记录的元素,相当于组合的结果返回来到了上一层的分支处(即通过绿色剪头回溯到示意图中的第二层):

 代码如下:

//我要从startIndex往后开始遍历,将得到的节点元素存放在path中
for(int i = startIndex; i <= n; ++i)
{
     //处理节点
     path.push_back(i);
     //递归回溯函数,开始找下一个元素并添加到path中
     backtracking(n, k, i+1);
     //回溯,返回上一层
     path.pop_back();
}

整体代码如下:

class Solution {
public:
    vector<vector<int>> result;  //用于存放组合的集合
    vector<int> path; //用于存放复合条件的组合
    //回溯第一步:确认回溯函数的参数及返回值,
    //startIndex用来记录本层递归的中,集合从哪里开始遍历(集合就是[1,...,n] )
    void backtracking(int n, int k, int startIndex)
    {
        //回溯第二步:确认回溯函数的终止条件
        if(path.size() == k)  //取得一个k长的组合
        {
            result.push_back(path);
            return;
        }    

        //回溯第三步:确认单层回溯的遍历过程。
        //我要从startIndex往后开始遍历,将得到的节点元素存放在path中
        for(int i = startIndex; i <= n; ++i)
        {
            //处理节点
            path.push_back(i);
            //递归回溯函数,开始找下一个元素并添加到path中
            backtracking(n, k, i+1);
            //回溯,返回上一层
            path.pop_back();
        }
    }

    //力扣提供的接口函数
    vector<vector<int>> combine(int n, int k) {
        backtracking(n,k,1);
        return result;
    }
};

从上述代码来理解,回溯算法的第三步中的递归,会一直执行,直到满足组合满足要求,然后会逐层进行回溯。

同样的回溯思想也可以解下面这道题。 

257. 二叉树的所有路径

257. 二叉树的所有路径 - 力扣(LeetCode)icon-default.png?t=N7T8https://leetcode.cn/problems/binary-tree-paths/description/

题目描述:

给你一个二叉树的根节点 root ,按 任意顺序 ,返回所有从根节点到叶子节点的路径。

叶子节点 是指没有子节点的节点。

示例 1:

输入:root = [1,2,3,null,5]
输出:["1->2->5","1->3"]

示例 2:

输入:root = [1]
输出:["1"]

思路分析:

因为要记录根节点到叶子节点的路径,所以二叉树的遍历方式应该为前序遍历,这样才是正确的路径顺序。遍历和和回溯的过程如下图所示,数字表示先后步骤。

下面我们先使用递归的方式,来做前序遍历,然后在递归中使用回溯

递归+回溯解法:

/**
 * Definition for a binary tree node.
 * struct TreeNode {
 *     int val;
 *     TreeNode *left;
 *     TreeNode *right;
 *     TreeNode() : val(0), left(nullptr), right(nullptr) {}
 *     TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
 *     TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
 * };
 */
class Solution {
public:

    void traversal(TreeNode* cur, vector<int>& path, vector<string> & result)
    {
        //中
        path.push_back(cur->val);
        // 递归终止条件:这才到了叶子节点
        if (cur->left == NULL && cur->right == NULL) {
            string sPath;
            for (int i = 0; i < path.size() - 1; i++) {
                sPath += to_string(path[i]);
                sPath += "->";
            }
            sPath += to_string(path[path.size() - 1]);
            result.push_back(sPath);
            return;
        }

        if (cur->left) { // 左 
            traversal(cur->left, path, result);
            path.pop_back(); // 回溯
        }
        if (cur->right) { // 右
            traversal(cur->right, path, result);
            path.pop_back(); // 回溯
        }

    }

    vector<string> binaryTreePaths(TreeNode* root) {
        vector<string> result;  //记录路径的集合
        vector<int> path;  //记录路径中的元素
        if (root == nullptr) return result;
        traversal(root, path, result);
        return result;
    }

不使用额外的递归函数的写法:

class Solution {
public:
    vector<string> result;  //记录路径的集合
    vector<int> path;  //记录路径中的元素
    //前序递归第一步:确认递归函数的参数和返回值
    vector<string> binaryTreePaths(TreeNode* root) {
        
        if (root == nullptr) return result;

        path.push_back(root->val);  //中,先记录一下当前节点元素

        //递归函数第二步:确认递归终止条件。找到叶子节点才算遍历结束
        if(root->left == nullptr && root->right == nullptr) 
        {
            //一旦找到叶子节点,我们就需要打印该路径
            string singlePath;
            for(int i = 0; i < path.size()-1; i++)  //从前往后提取路径“1-》2-》3”
            {
                singlePath += to_string(path[i]);  //元素数字转字符串
                singlePath += "->";
            }
            singlePath += to_string(path[path.size()-1]); //最后一个元素
            result.push_back(singlePath);  //记录该路径
            return result;
        } 

        //递归第三步:确认单层递归逻辑。处了记录当前节点元素,接下来就是递归遍历左右子树了。
        //但是为了更方便的生成结果所需的字符串,我们将记录当前节点的步骤放在了函数的开头。
        //如果我们在这里记录当前节点元素,那么上面的递归终止条件返回的路径结果将会缺少最后一个元素
        //左
        if(root->left)
        {
            binaryTreePaths(root->left); //递归遍历左子树
            path.pop_back();  //回溯,返回上一层对应的根节点,准备向右子树遍历
        }
        //右
        if(root->right)
        {
            binaryTreePaths(root->right); //递归遍历右子树
            path.pop_back();  //回溯
        }
            
        return result;

    }

};


网站公告

今日签到

点亮在社区的每一天
去签到