由树形解空间入手,深入分析回溯、动态规划、分治算法的共同点和不同点

一、回溯、动态规划、分治

其实这三个算法就可以直接认为是一类算法,它们的共同点都是会涉及到递归

更需要清楚明白的一点是,它们的解,都是基于一颗递归形式的树上得来的!也就是——回溯、动态规划、分治的解空间是 一棵树!!!(正是由于这种解的形式,它们都和递归分不开,因为递归就是适合解决这种树结构的求解问题!)

为什么这么说呢,下面我们来逐个分析一下!!!

1.1:回溯

1.1.1:核心思想:构建决策树,遍历路径,收集答案

  • 回溯:可以看作是暴力穷举,递归(eg:全排列问题);可能会有很多重复子问题(很多重复计算,因为子问题之间都有关联)。它的本质就是遍历一颗 决策树 (每个节点都是在做决策),然后全部遍历完成后将叶子节点上的答案进行 收集 起来,就能得到最终答案

    🌸回溯算法和 DFS 算法的细微差别是:回溯算法是在遍历 「树枝」 ,DFS 算法是在遍历 「节点」

    🍀站在回溯树的一个节点上,你只需要思考 3 个问题:
    1、路径:也就是已经做出的选择
    2、选择列表:也就是你当前可以做的选择。
    3、结束条件:也就是到达决策树底层,无法再做选择的条件。

    🪧代码框架:

    result = []
    def backtrack(路径, 选择列表):
        if 满足结束条件:
            result.add(路径)
            return
        
        for 选择 in 选择列表:
            做选择
            backtrack(路径, 选择列表)
            撤销选择
    

    其核心就是 for 循环里面的递归, 在递归调用之前在这个决策节点上「做选择」,在递归调用之后「撤销选择」

具体举例解释(求数字1,2,3的全排列):
在这里插入图片描述
比如在上图这样一颗二叉树,就是我们这个由回溯法求解全排列问题的解空间,我们的解是从这颗三叉树中得来的(包括动态规划和分治算法,它们的解空间本质上也是这样一颗多叉树,但是像动态规划可能存在重复子问题呀,会使用dp备忘录数组进行剪枝)。

而各个算法的关键以及区分点也就是

  1. 如何去构建这样一给解空间(一颗多叉树)——递归函数的定义(实现)
  2. 这个多叉树的节点含义是什么
  3. 如何基于这个解空间获得问题的答案。

24/5/15日补充

最近又做了一些动态规划的题目,发现也没有必要把这三点分的那么快,即:这三个没有先后顺序,只是看待的一个角度。因为这三种但是联系很紧密的。

比如1,2,就是一个动态的过程,回溯就是是基于一边根据求解问题,构建解空间,在构建的过程中正好完成遍历,获得答案。

而动态规划呢,如何求解这个问题,构建原问题和最优子结构之间的关系(既是要想如何获得答案,同时这个过程也是在建立二叉树的过程:建立子问题之间的联系,哪个二叉树的边)。

分治呢,其实也是,你其实先想出来怎么根据子问题求解原问题,然后构建的一个解空间。

这些还得在之后做题中慢慢优化理解吧。


上面两点既是这三个算法在求解问题流程上的共同点,而不同点是在于这两个流程具体实施的做法不同, 同样,对于这三个算法,只需要弄懂了以上几点,对折三个算法的理解、区别也就了然了。

再回到上面那个全排列问题和那个图,我们来逐一击破

  1. 🚉🚉🚉想要求解[1,2,3] 这三个全排列问题,很容易其实能想到上面那个树形结构,基于暴力穷举的想法,那么我们的二叉树其实就能构建好,很自然而然就能想象到下面这个图形(即使没有学过数据结构,但你的思路和想法的本质也一定是这样的)
    在这里插入图片描述

  2. 🚉🚉🚉基于回溯算法,每个节点其实都在做决策(这也是为什么把由回溯算法生成的解空间的树称为决策树)

    🍀站在回溯树的一个节点上,你只需要思考 3 个问题:
    1、路径:也就是已经做出的选择
    2、选择列表:也就是你当前可以做的选择。
    3、结束条件:也就是到达决策树底层,无法再做选择的条件。在这里插入图片描述
    比如上面那个图的2节点,你的 “路径” 就是[2], “选择列表” 是[1,3],“结束条件” 为:该决策节点的选择列表为空/

  3. 🚉🚉🚉得到这样一颗决策树的解空间后,我们就需要获得问题的答案了,做法是:回溯算法的这个回溯递归函数backtrack 其实就像是一个指针,游走在这颗树上,遍历完每条路径到叶子节点,这个路径上的值即为一个解,把所有路径遍历完成,即将所有答案收集,得到了问题的最终解!

🍊🍊🍊! 注意:实际过程中,肯定不是像上面讲的那样,先把树构建好了,然后去在这棵树上求解,反而是2、3两步同时进行的过程,一边构建这个解空间树,一边求解的过程,上面那么讲也只是为了好理解。比如再回到全排列问题,这个回溯递归函数backtrack 像一个指针,在构建树,维护每个节点的属性(路径、选择列表)时,还需要保证走到叶子节点时收集由这条路径得到的答案。


1.1.2:求解答案:如何遍历树(和DFS的区别)

其实呢,弄懂上面三点,你对整个回溯算法的理解其实已经就差不多了,这时基于宏观理解上面,再深入到回溯算法本身的特性上,进一步,从第三点,遍历树,寻找答案,那么具体该如何遍历呢?

对于回溯算法决策树的遍历的框架如下:

void traverse(TreeNode* root) {
    for (TreeNode* child : root->childern) {
        // 前序位置需要的操作
        traverse(child);
        // 后序位置需要的操作
    }
}

在这里插入图片描述

这里有一点需要注意的是,这个遍历框架和DFS遍历树的框架不太一样,多叉树的DFS遍历代码如下:

/* 多叉树遍历框架 */
void traverse(TreeNode* root) {
    if (root == nullptr) return;
    // 前序位置
    for (TreeNode* child : root->children) {
        traverse(child);
    }
    // 后序位置

可以看到,DFS的前序遍历位置和后序位置在for循环遍历子节点外面,而回溯算法遍历树的前序位置和后续位置在遍历子节点的for循环之内。

其实在1.1.1小节讲到回溯算法的核心思想开头就已经提到:

🌸回溯算法和 DFS 算法的细微差别是:回溯算法是在遍历 「树枝」 ,DFS 算法是在遍历 「节点」

也就是说,回溯算法更关注的是边,它遍历的对象是边,而非节点,它所遍历的就是root节点下的边,而DFS遍历的就是root这个节点,两者关注点不一样,你想遍历哪个,就按照对应的代码进行书写。

在这里插入图片描述

⭐它们的区别可以这样反映的代码上,可以更直观的理解!


// DFS 算法,关注点在节点
void traverse(TreeNode* root) {
    if (root == nullptr) return;
    printf("进入节点 %s", root);
    for (TreeNode* child : root->children) {
        traverse(child);
    }
    printf("离开节点 %s", root);
}

// 回溯算法,关注点在树枝
void backtrack(TreeNode *root) {
    if (root == nullptr) return;
    for (TreeNode* child : root->children) {
        // 做选择
        printf("从 %s 到 %s", root, child);
        backtrack(child);
        // 撤销选择
        printf("从 %s 到 %s", child, root);
    }
}


到上面,我们基本上弄懂了了回溯的遍历思路以及和DFS遍历思路侧重点的不同,那么下面我们再回过头来看回溯算法的决策树和遍历过程:

⭐⭐⭐⭐⭐ 「路径」和「选择」是每个节点的属性,函数在树上游走要正确处理节点的属性,那么就要在这两个特殊时间点搞点动作:

  • 前序遍历的代码在进入某一个节点(做决策)之前的那个时间点执行:进行当前决策节点的 【做选择】
  • 后序遍历代码在离开某个节点之后的那个时间点执行:【撤销选择】,即撤销当前这个节点的当前这个选择(为了从选择列表中选取下一个选择做准备)
    在这里插入图片描述

那么现在,对核心框架中的这段代码我们应该有更好的理解了:

for 选择 in 选择列表:
    # 做选择
    将该选择从选择列表移除(表示做这个选择)
    路径.add(选择)
    
    #基于这个选择往后面的子节点进行树枝遍历
    backtrack(路径, 选择列表)
    
    # 撤销选择(为从选择列表中做出下一个选择做准备)
    路径.remove(选择)
    将该选择再加入选择列表

他是对于当前非结束决策节点的操作,本质是在决策树上遍历当前节点下面的边,从而在遍历(也是构建)决策树的过程中收集答案。

1.1.3:学习代码实现细节:例题:N排列

1:题目描述

题目链接:N排列
在这里插入图片描述

2:解题思路

其实整体的解题思路在上面解题思路中已经讲解了,这里以例题形式更详细解释一下详细代码的实现过程:

还是以[1,2,3]为例,整个解空间为以下树的形式
在这里插入图片描述

🪧 Tips: 这里的每个节点可以有一个used数组属性,其实这是【选择列表】的抽象,表示当前还有哪些元素没用,从而得到剩余集合:真正的选择列表。而【排列】也就是指遍历的到当前决策节点处得到的【路径】在这里插入图片描述
即:没有显式记录「选择列表」,而是通过 used 数组排除已经存在 track 中的元素,从而推导出当前的选择列表:在这里插入图片描述

3:⭐代码实现:回溯三部曲

🪧回顾:核心代码框架:

	result = []
	def backtrack(路径, 选择列表):
	    if 满足结束条件:
	        result.add(路径)
	        return
	    
	    for 选择 in 选择列表:
	        做选择
	        backtrack(路径, 选择列表)
	        撤销选择

有了解题思路,代码框架,我们具体实现代码时,其实就是对代码框架进行填充,完善,把不确定的部分给实现了,其实总结下来这个完善过程就是以下的递归三部曲:

  1. 递归函数的定义(明确这个递归函数的作用,包括参数:路径、选择列表)——明确决策节点的两个属性:路径、选择列表

      这里我们递归函数的定义就是对

    vector<vector<int>> result; //毋庸置疑,是用于在递归遍历过程中收集答案的
     oid backtrack(vector<int>& nums, vector<int>& track, vector<bool>& used)
    
  2. 递归终止条件: 选择列表为空,而我们的选择列表并不是一个真实存在的列表,而是numsused数组共同体现的一个东西,判断终止也可以用以下代码判断:路径的大小和nums大小相同,其实本质也就是说选择列表中的元素已经用完了

      // 触发结束条件
            if (track.size() == nums.size()) {
                res.push_back(track);
                return;
            }
    
  3. 求解答案单层搜索的逻辑(回溯遍历):这个其实就是指backtracking这个函数在这个树结构上“游走”的过程,即遍历树的过程,这在`1.1.2·小节中已经讲过,不再重复

      	for (int i = 0; i < nums.size(); i++) {
                // 排除不合法的选择
                if (used[i]) {
                    //nums[i] 已经在 track中,跳过
                    continue;
                }
                // 做选择
                track.push_back(nums[i]);
                used[i] = true;
                // 进入下一层决策树
                backtrack(nums, track, used);
                // 取消选择
                track.pop_back();
                used[i] = false;
            }
    

✅完整代码:

class Solution {
public:

    vector<vector<int>> result; //全局变量,存储在回溯过程中收集的最终结果

    //定义回溯代码(核心框架)
    void backtracking(vector<int>&nums, vector<bool>& used, vector<int>& track){
        
        //某条回溯路径的终止条件
        if(track.size() == nums.size() ){
            result.push_back(track);
            return;
        }

        //从选择列表里做选择
        for(int i = 0; i < nums.size(); i ++){
            if(used[i]){
                continue;
            }
            //做选择
            used[i] = true;
            track.push_back(nums[i]);
            //进入下一层决策树
            backtracking(nums, used, track);
            //撤销选择(为下一个选择做准备)
            used[i] = false;
            track.pop_back();
        }
    }

    vector<vector<int>> permute(vector<int>& nums) {
        result.clear();

        vector<int> track; //记录当前路径
        vector<bool> used (nums.size(), false); // 结合nums数组共同作用为【选择列表】,true代表当前元素已经被选择
        backtracking(nums, used, track); // 执行核心算法逻辑:回溯
        
        return result; //返回最终结果

    }
};

1.2:动态规划

1.2.1:核心思想

  • 动态规划:一般是来求最值/最优值问题;存在最优子结构(问题的最优解由子问题的最优解推导而来)即基于子问题最优解进行选择,由子问题 推导 出问题的最终最优解。

关于动态规划的思想篇我推荐看东哥的这篇:动态规划解题套路框架

当然,只有真正实践了,刷题了,才能更好的去理解一个算法,围绕动态规划的解题五部曲,我也写了几篇博客:

在这篇文章中,我依然想从这三个角度,来解释这些算法:

而各个算法的关键以及区分点也就是

  1. 如何去构建这样一给解空间(一颗多叉树)——递归函数的定义
  2. 这个多叉树的节点含义是什么
  3. 如何基于这个解空间获得问题的答案。

这里,我也会从一个例题入手:322. 零钱兑换去讲解,建立在我们已经有前面几篇博客的知识基础上,去从这三个角度去深挖动态规划的本质(这也是在前面几篇博客里没有讲到的)。
在这里插入图片描述
关于怎么做这个题,可以看我前面的题解博客,现在我们从上面三个角度,去反思一下:

  1. 动态规划的树形解空间:在这里插入图片描述
    和回溯算法一样,动态规划也是一个树形的解空间,它的构建方法是:由状态转移方程,将原问题分解成规模更小的子问题(最优子结构),由子问题去 推导(这个推导很值得深究) 出问题的解。

  2. 通过前面对回溯算法的学习,我们知道,回溯的决策树上的点代表一个选择(决策),它有两个属性:路径&选择列表。同样,对于动态规划的解空间,它的节点的含义很简单:基于原问题所划分的小规模子问题的解

  3. 如何基于这个解空间去获得问题的答案?说白了,就是我们获得了这样一棵树,答案也是我们通过处理这个树,取得来的。对于回溯,它的做法是收集决策树的子节点路径。而对于动态规划呢?其实也就是我们要深究上面第一点所说的这个【推导】的含义,具体是什么?其实就是状态转移方程(下面的节点推导出上面节点的值),但其本质呢?其实本质就是对树上节点的处理,这个处理,是基于节点的,对于动态规划,它的处理策略就是:在这里插入图片描述
    其实你看,就是那个状态转移方程,由子问题(子节点)推导而来。

1.2.2:分析:重叠子问题和子问题独立性?

OK,首先我们来说说子问题的重叠,动态规划的一大特点就是消除了子问题的重叠性,这个是毋庸置疑的。下面这张图其实是给出在暴力递归下的树形解空间:
在这里插入图片描述
可以看到,对于节点5,其实在构造这个抽象的树形解空间时,它被计算了多次,而动态规划对此的改进就是:备忘录、DP数组。本质就是在追求 “如何聪明地穷举” 。用空间换时间的思路。

那么子问题的独立性呢?我在不同的地方看到不同的解释,可能你在学习的时候也会有疑惑。后来我才发现,这两个“独立”并不是一个含义,接下来我将逐个讲解。

  • 子问题相互独立,是最优子结构的前提——>是可以用DP求解的前提

      这里我们用一个例子来说明:比如说,假设你考试,每门科目的成绩都是互相独立的。你的原问题是考出最高的总成绩,那么你的子问题就是要把语文考到最高,数学考到最高…… 为了每门课考到最高,你要把每门课相应的选择题分数拿到最高,填空题分数拿到最高…… 当然,最终就是你每门课都是满分,这就是最高的总成绩。
      得到了正确的结果:最高的总成绩就是总分。因为这个过程符合最优子结构,「每门科目考到最高」这些子问题是互相独立,互不干扰的。
      但是,如果加一个条件:你的语文成绩和数学成绩会互相制约,不能同时达到满分,数学分数高,语文分数就会降低,反之亦然。
      这样的话,显然你能考到的最高总成绩就达不到总分了,按刚才那个思路就会得到错误的结果。因为「每门科目考到最高」的子问题并不独立,语文数学成绩户互相影响,无法同时最优,所以最优子结构被破坏。
      回到凑零钱问题,为什么说它符合最优子结构呢?假设你有面值为 1, 2, 5 的硬币,你想求 amount = 11 时的最少硬币数(原问题),如果你知道凑出 amount = 10, 9, 6 的最少硬币数(子问题),你只需要把子问题的答案加一(再选一枚面值为 1, 2, 5 的硬币),求个最小值,就是原问题的答案。因为硬币的数量是没有限制的,所以子问题之间没有相互制约,是互相独立的。

  • 子问题不独立:这里的“独立”是指,子问题之间具有相互推导关系你看下面这个图dp[11]可以由下面的三个孩子根据状态方程推得,而dp[10]是也可以由它下面三个孩子根据状态方程推得…其实这个不独立主要是和下面要讲的分治算法作比较而提出的不独立,和上面那个独立是两个概念。这
    在这里插入图片描述

1.3:分治

1.3.1:核心思想

  • 分治:分治则是,原问题的解本身就是可以分解为与原问题相同但规模更小的子问题,然后 合并 子问题,从而得到原问题的解。

最典型的分治算法就是归并排序了,核心逻辑如下:

void sort(int[] nums, int lo, int hi) {
    int mid = (lo + hi) / 2;
    /****** 分 ******/
    // 对数组的两部分分别排序
    sort(nums, lo, mid);
    sort(nums, mid + 1, hi);
    /****** 治 ******/
    // 合并两个排好序的子数组
    merge(nums, lo, mid, hi);
}

「对数组排序」是一个可以运用分治思想的算法问题,只要我先把数组的左半部分排序,再把右半部分排序,最后把两部分合并,不就是对整个数组排序了吗?

同样的,我们再从三个视角对这个算法进行审视(以归并排序为例)在这里插入图片描述

  1. 分治算法的树形结构:和动态规划其实有的像的是,分治算法树形结构也是构建的核心也是:将原问题分解为规模更小的子问题,但是这个分并不基于什么状态转移方程,因为它的子问题之间没有什么关系(也就是上面在动态规划里讲的:子问题之间的推导关系)——所以由于这种情况的存在,分治也不存在什么DP里的重叠子问题!
  2. 每个节点的含义:这个就和动态规划一样了:原问题较小规模的子问题的解
  3. 如何基于这个树形解空间来求得问题的解?其实对于分治,很明显的一点就是:合并,没错。其实你看处理位置上,和动态规划一样,就是处理方法不一样。动态规划采用状态转移方程(即:推导)进行处理,而分治采用的是合并!在这里插入图片描述

1.3.2:例题:棋盘覆盖问题

1:题目描述

在这里插入图片描述

2:解题思路

这题就是我觉得比起知道用分治怎么解,更难的是想到应该用分治!

直接上图吧!

在这里插入图片描述
分析:

  1. 首先,当k>0时,将 2 k × 2 k 2^k×2^k 2k×2k的棋盘可以等分为四个规模更小且与原问题类型一致的问题:4个 2 k − 1 × 2 k − 1 2^{k-1}×2^{k-1} 2k1×2k1的棋盘覆盖问题,其中有三个不存在特殊方格,可以使用一个L型先进行覆盖,那么其覆盖到这3个子棋盘的会和的位置,作为该子问题的特殊方格
  2. 经过上面处理,我们成功地获得了4个 2 k − 1 × 2 k − 1 2^{k-1}×2^{k-1} 2k1×2k1的棋盘覆盖子问题。递归地使用这种分割策略(也就是分治里的【分】),直到棋盘为1×1则终止(递归必需的终止条件)
  3. 再合并结果(其实不用显示合并,只用在递归遍历时把结果填入,最终全部遍历完,就是已经是合并后的结果了)

实现:

  1. 明确递归函数的定义是什么,相信并且利用好函数的定义!
<
//tr,tc(top_row,top_col):代表棋盘左上角坐标
//sr,sc(special_row, special_col):代表特殊点坐标
//size:棋盘的大小

//递归
void chessBoard(int tr, int tc, int sr, int sc, int size);
  1. 递归函数的实现:这题很简单,就是怎么分!进行四个判断即可!
    每次判断是基于分割后的四个小方块进行判断,判断特殊方格是否在内。判断方法是根据分割后小棋盘左上角的坐标和特殊棋盘的坐标进行比较。若在里面则直接接着递归下去,如果不在呢,根据没有特殊方格的三个子棋盘的位置不同,将汇合处标记为特殊位置。

✅完整代码

#include <stdio.h>
#include <stdlib.h>
#include <cstring>    // Include for memset

int nCount = 0;
int Matrix[100][100];

void chessBoard(int tr, int tc, int sr, int sc, int size);

int main()
{
    int size,r,c,row,col;
    std::memset(Matrix,0,sizeof(Matrix));    // Use std:: prefix for memset
    scanf("%d",&size);
    scanf("%d%d",&row,&col);
    chessBoard(0,0,row,col,size);

    for (r = 0; r < size; r++)
    {
        for (c = 0; c < size; c++)
        {
            printf("%2d ",Matrix[r][c]);
        }
        printf("\n");
    }

    return 0;
}

void chessBoard(int tr, int tc, int sr, int sc, int size)
{
    //tr and tc represent the top left corner's coordinate of the matrix

    if (1 == size) return;

    int s,t;
    s = size/2; //The number of grid the matrix's edge
    t = ++ nCount;

    //locate the special  grid on bottom right corner
    if (sr < tr + s && sc < tc +s)
    {
        chessBoard(tr,tc,sr,sc,s);
    }
    else
    {
        Matrix[tr+s-1][tc+s-1] = t;
        chessBoard(tr,tc,tr+s-1,tc+s-1,s);
    }

    //locate the special  grid on bottom left corner
    if (sr < tr + s && sc >= tc + s )
    {
        chessBoard(tr,tc+s,sr,sc,s);
    }
    else
    {
        Matrix[tr+s-1][tc+s] = t;
        chessBoard(tr,tc+s,tr+s-1,tc+s,s);
    }

    //locate the special  grid on top right corner
    if (sr >= tr + s && sc < tc + s)
    {
        chessBoard(tr+s,tc,sr,sc,s);
    }
    else
    {
        Matrix[tr+s][tc+s-1] = t;
        chessBoard(tr+s,tc,tr+s,tc+s-1,s);
    }

    //locate the special  grid on top left corner
    if (sr >= tr + s && sc >= tc + s)
    {
        chessBoard(tr+s,tc+s,sr,sc,s);
    }
    else
    {
        Matrix[tr+s][tc+s] = t;
        chessBoard(tr+s,tc+s,tr+s,tc+s,s);
    }

}

二、总结

回溯、动态规划、分治可以认为是一类算法,其实不用严格的将其分为太开,它们的共同点是解空间都是一棵多叉树,获得解也是在这个多叉树上进行操作

只需要从这三个视角对其有一个直观认识即可

而各个算法的关键以及区分点也就是

  1. 如何去构建这样一给解空间(一颗多叉树)——递归函数的定义
  2. 这个多叉树的节点含义是什么
  3. 如何基于这个解空间获得问题的答案。
  • 22
    点赞
  • 20
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Yaoyao2024

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值