LeetCode-题目详解(十一):回溯算法【递归回溯、迭代回溯】【DFS是一个劲往某一个方向搜索;回溯算法建立在DFS基础之上,在搜索过程中,达到结束/裁剪条件后,恢复状态,回溯上一层,再次搜索】

本文主要是介绍LeetCode-题目详解(十一):回溯算法【递归回溯、迭代回溯】【DFS是一个劲往某一个方向搜索;回溯算法建立在DFS基础之上,在搜索过程中,达到结束/裁剪条件后,恢复状态,回溯上一层,再次搜索】,希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!

这里写目录标题

  • 一、概述
    • 1、深度优先遍历(DFS) 和回溯算法区别
    • 2、 何时使用回溯算法
    • 3、回溯算法步骤
    • 4、回溯问题的类型
  • 二、LeetCode案例
    • 39. 组合总和
    • 40. 组合总和II
    • 77. 组合
    • 216. 组合总和 III
    • 46. 全排列
    • 47. 全排列 II
    • 剑指 Offer 38. 字符串的排列
    • 剑指 Offer II 079. 所有子集
    • 90. 子集 II
    • 剑指 Offer II 085. 生成匹配的括号【22. 括号生成】
    • 剑指 Offer II 086. 分割回文子字符串【131. 分割回文串】
    • 79. 单词搜索【剑指 Offer 12. 矩阵中的路径】
    • 面试题13. 机器人的运动范围
    • 面试题 08.10. 颜色填充

一、概述

1、深度优先遍历(DFS) 和回溯算法区别

DFS 是一个劲的往某一个方向搜索,而回溯算法建立在 DFS 基础之上的,但不同的是在搜索过程中,达到结束条件后,恢复状态,回溯上一层,再次搜索。因此回溯算法与 DFS 的区别就是有无状态重置

「回溯法」实际上一个类似枚举的搜索尝试过程,主要是在搜索尝试过程中寻找问题的解,当发现已不满足求解条件时,就「回溯」返回,尝试别的路径。

回溯法是一种算法思想,而递归是一种编程方法,回溯法可以用递归来实现。

回溯法的整体思路是:搜索每一条路,每次回溯是对具体的一条路径而言的。对当前搜索路径下的的未探索区域进行搜索,则可能有两种情况:

  • 当前未搜索区域满足结束条件,则保存当前路径并退出当前搜索;
  • 当前未搜索区域需要继续搜索,则遍历当前所有可能的选择:如果该选择符合要求,则把当前选择加入当前的搜索路径中,并继续搜索新的未探索区域。

上面说的未搜索区域是指搜索某条路径时的未搜索区域,并不是全局的未搜索区域。

回溯法搜所有可行解的模板一般是这样的:

res = []
path = []def backtrack(未探索区域, res, path):if 未探索区域满足结束条件:res.add(path) # 深度拷贝returnfor 选择 in 未探索区域当前可能的选择:if 当前选择符合要求:path.add(当前选择)backtrack(新的未探索区域, res, path)path.pop()

2、 何时使用回溯算法

当问题需要 “回头”,以此来查找出所有的解的时候,使用回溯算法。即满足结束条件或者发现不是正确路径的时候(走不通),要撤销选择,回退到上一个状态,继续尝试,直到找出所有解为止

3、回溯算法步骤

  1. 画出递归树,找到状态变量(回溯函数的参数),这一步非常重要※
  2. 根据题意,确立结束条件
  3. 找准选择列表(与函数参数相关),与第一步紧密关联※
  4. 判断是否需要剪枝
  5. 作出选择,递归调用,进入下一层
  6. 撤销选择

4、回溯问题的类型

在这里插入图片描述
注意:子集、组合与排列是不同性质的概念。子集、组合是无关顺序的,而排列是和元素顺序有关的,如 [1,2] 和 [2,1] 是同一个组合(子集),但 [1,2] 和 [2,1] 是两种不一样的排列!!!!因此被分为两类问题

二、LeetCode案例

39. 组合总和

给定一个无重复元素的数组 candidates 和一个目标数 target ,找出 candidates 中所有可以使数字和为 target 的组合。

candidates 中的数字可以无限制重复被选取。

说明:

  • 所有数字(包括 target)都是正整数。
  • 解集不能包含重复的组合。

示例 1:

输入:candidates = [2,3,6,7], target = 7,
所求解集为:
[[7],[2,2,3]
]

示例 2:

输入:candidates = [2,3,5], target = 8,
所求解集为:
[[2,2,2,2],[2,3,3],[3,5]
]

提示:

  • 1 <= candidates.length <= 30
  • 1 <= candidates[i] <= 200
  • candidate 中的每个元素都是独一无二的。
  • 1 <= target <= 500

在这里插入图片描述

思路分析:根据示例 1:输入: candidates = [2, 3, 6, 7],target = 7。

  • 候选数组里有 2,如果找到了组合总和为 7 - 2 = 5 的所有组合,再在之前加上 2 ,就是 7 的所有组合;
  • 同理考虑 3,如果找到了组合总和为 7 - 3 = 4 的所有组合,再在之前加上 3 ,就是 7 的所有组合,依次这样找下去。

基于以上的想法,可以画出如下的树形图。建议大家自己在纸上画出这棵树,这一类问题都需要先画出树形图,然后编码实现。

编码通过 深度优先遍历 实现,使用一个列表,在 深度优先遍历 变化的过程中,遍历所有可能的列表并判断当前列表是否符合题目的要求,成为「回溯算法」(个人理解,非形式化定义)。

在这里插入图片描述

  • 以 target = 7 为 根结点 ,创建一个分支的时 做减法 ;
  • 每一个箭头表示:从父亲结点的数值减去边上的数值,得到孩子结点的数值。边的值就是题目中给出的 candidate 数组的每个元素的值;
  • 减到 00 或者负数的时候停止,即:结点 00 和负数结点成为叶子结点;
  • 所有从根结点到结点 00 的路径(只能从上往下,没有回路)就是题目要找的一个结果。

这棵树有 44 个叶子结点的值 00,对应的路径列表是 [[2, 2, 3], [2, 3, 2], [3, 2, 2], [7]],而示例中给出的输出只有 [[7], [2, 2, 3]]。即:题目中要求每一个符合要求的解是 不计算顺序 的。下面我们分析为什么会产生重复。

产生重复的原因是:在每一个结点,做减法,展开分支的时候,由于题目中说 每一个元素可以重复使用,我们考虑了 所有的 候选数,因此出现了重复的列表。

一种简单的去重方案是借助哈希表的天然去重的功能,但实际操作一下,就会发现并没有那么容易。

可不可以在搜索的时候就去重呢?答案是可以的。遇到这一类相同元素不计算顺序的问题,我们在搜索的时候就需要 按某种顺序搜索。具体的做法是:每一次搜索的时候设置 下一轮搜索的起点 begin,请看下图。

在这里插入图片描述
即:从每一层的第 22 个结点开始,都不能再搜索产生同一层结点已经使用过的 candidate 里的元素。

如果题目要求,结果集不计算顺序,此时需要按顺序搜索,才能做到不重不漏。「力扣」第 47 题( 全排列 II )、「力扣」第 15 题( 三数之和 )也使用了类似的思想,使得结果集没有重复。

在这里插入图片描述

回溯算法模板【Python】:

class Solution:def combinationSum(self, candidates: List[int], target: int) -> List[List[int]]:self.result = []self.path = []self.back_tracking(candidates, target, 0, 0)return self.resultdef back_tracking(self, nums, target, total, start):if total > target:returnif total == target:curr_path = self.path[:]    # 对于python来说,必不可少self.result.append(curr_path)returnfor i in range(start, len(nums)):self.path.append(nums[i])total += nums[i]self.back_tracking(nums, target, total, i)self.path.pop()total -= nums[i]

回溯算法模板【C++】:

class Solution {
public:vector<vector<int>> result;vector<int> path;vector<vector<int>> combinationSum(vector<int>& candidates, int target) {backTracking(candidates, target, 0, 0);return result;}void backTracking(vector<int>& nums, int target, int total, int start){if(total > target){return;}if(total == target){result.push_back(path);return;}for(int i = start; i < nums.size(); i++){path.push_back(nums[i]);total += nums[i];backTracking(nums, target, total, i);path.pop_back();total -= nums[i];}}
};

class Solution:def combinationSum(self, candidates: List[int], target: int) -> List[List[int]]:result = []# 回溯算法def dfs(nums, temp_target, path):# 递归结束条件if temp_target < 0:return# 逻辑主体部分if temp_target == 0:result.append(path)# 递归for idx, num in enumerate(nums):dfs(nums[idx:], temp_target - num, path + [num])dfs(candidates, target, [])return result
class Solution:def combinationSum(self, candidates: List[int], target: int) -> List[List[int]]:self.result = []self.dfs(candidates, target, [])return self.resultdef dfs(self, nums, target, path):if sum(path) > target:returnif sum(path) == target:self.result.append(path)returnfor idx, num in enumerate(nums):self.dfs(nums[idx:], target, path + [num])

剪枝提速

  • 根据上面画树形图的经验,如果 target 减去一个数得到负数,那么减去一个更大的树依然是负数,同样搜索不到结果。基于这个想法,我们可以对输入数组进行排序,添加相关逻辑达到进一步剪枝的目的;
  • 排序是为了提高搜索速度,对于解决这个问题来说非必要。但是搜索问题一般复杂度较高,能剪枝就尽量剪枝。实际工作中如果遇到两种方案拿捏不准的情况,都试一下。

回溯算法模板【Python】:

class Solution:def combinationSum(self, candidates: List[int], target: int) -> List[List[int]]:self.result = []self.path = []candidates.sort()   # 排序不是必须操作self.back_tracking(candidates, target, 0, 0)return self.resultdef back_tracking(self, nums, target, total, start):if total > target:returnif total == target:curr_path = self.path[:]    # 对于python来说,必不可少self.result.append(curr_path)returnfor i in range(start, len(nums)):self.path.append(nums[i])total += nums[i]self.back_tracking(nums, target, total, i)self.path.pop()total -= nums[i]

回溯算法模板【C++】:

class Solution {
public:vector<vector<int>> result;vector<int> path;vector<vector<int>> combinationSum(vector<int>& candidates, int target) {sort(candidates.begin(), candidates.end()); // 排序不是必须操作backTracking(candidates, target, 0, 0);return result;}void backTracking(vector<int>& nums, int target, int total, int start){if(total > target){return;}if(total == target){result.push_back(path);return;}for(int i = start; i < nums.size(); i++){path.push_back(nums[i]);total += nums[i];backTracking(nums, target, total, i);path.pop_back();total -= nums[i];}}
};

class Solution:def combinationSum(self, candidates: List[int], target: int) -> List[List[int]]:result = []# 回溯算法def dfs(nums, temp_target, path):# 递归结束条件【剪枝:如果当前temp_target < 0,没必要枚举了,直接return】if temp_target < 0:return# 逻辑主体部分if temp_target == 0:result.append(path)# 递归for idx, num in enumerate(nums):# 在搜索的时候就去重:这一类相同元素不计算顺序的问题,我们在搜索的时候就按某种顺序搜索。# 从每一层的第 2 个结点开始,都不能再搜索产生同一层结点已经使用过的 candidate 里的元素。dfs(nums[idx:], temp_target - num, path + [num])candidates.sort()   # 排序不是必须操作dfs(candidates, target, [])return result

40. 组合总和II

给定一个数组 candidates 和一个目标数 target ,找出 candidates 中所有可以使数字和为 target 的组合。

candidates 中的每个数字在每个组合中只能使用一次。

说明:

  • 所有数字(包括目标数)都是正整数。
  • 解集不能包含重复的组合。

示例 1:

输入: candidates = [10,1,2,7,6,1,5], target = 8,
所求解集为:
[[1, 7],[1, 2, 5],[2, 6],[1, 1, 6]
]

示例 2:

输入: candidates = [2,5,2,1,2], target = 5,
所求解集为:
[[1,2,2],[5]
]

一句话题解:按顺序搜索,设置合理的变量,在搜索的过程中判断是否会出现重复集结果。

与第 39 题(组合之和)的差别:

  • 第 39 题:candidates 中的数字可以无限制重复被选取;
  • 第 40 题:candidates 中的每个数字在每个组合中只能使用一次。

相同点是:相同数字列表的不同排列视为一个结果。

为了避免产生重复解,本题candidates务必排序。

如何去掉重复的集合(重点),为了使得解集不包含重复的组合。有以下 2 种方案:

  • 使用 哈希表 天然的去重功能,但是编码相对复杂;
  • 这里我们使用和第 39 题和第 15 题(三数之和)类似的思路:不重复就需要按 顺序 搜索, 在搜索的过程中检测分支是否会出现重复结果 。注意:这里的顺序不仅仅指数组 candidates 有序,还指按照一定顺序搜索结果。

在这里插入图片描述
在这里插入图片描述
由第 39 题我们知道,数组 candidates 有序,也是 深度优先遍历 过程中实现「剪枝」的前提。

将数组先排序的思路来自于这个问题:去掉一个数组中重复的元素。很容易想到的方案是:先对数组 升序 排序,重复的元素一定不是排好序以后相同的连续数组区域的第 1 个元素。也就是说,剪枝发生在:同一层数值相同的结点第 2、3 … 个结点,因为数值相同的第 1 个结点已经搜索出了包含了这个数值的全部结果,同一层的其它结点,候选数的个数更少,搜索出的结果一定不会比第 1 个结点更多,并且是第 1 个结点的子集。

class Solution:def combinationSum2(self, nums: List[int], target: int) -> List[List[int]]:self.result = []nums.sort() # 排序是必须操作self.dfs(nums, target, [])return self.result# 回溯算法def dfs(self, nums, target, path):# 递归结束条件【剪枝:如果当前total > target,没必要枚举了,直接return】if target < 0:return# 逻辑主体部分if target == 0:self.result.append(path)# 递归for idx, num in enumerate(nums):# 在搜索的时候就去重:这一类相同元素不计算顺序的问题,我们在搜索的时候就按某种顺序搜索。# 从每一层的第 2 个结点开始,都不能再搜索产生同一层结点已经使用过的 candidate 里的元素。# 而且重复的元素要跳过,防止产生重复数组if idx > 0 and nums[idx] == nums[idx - 1]:continueself.dfs(nums[idx + 1:], target - num, path + [num])    

回溯算法模板【Python】:

class Solution:def combinationSum2(self, nums: List[int], target: int) -> List[List[int]]:self.result = []self.path = []nums.sort() # 排序是必须操作self.back_tracking(nums, target, 0, 0)return self.result# 回溯算法def back_tracking(self, nums, target, total, start):# 递归结束条件【剪枝:如果当前total > target,没必要枚举了,直接return】if total > target:return# 逻辑主体部分if total == target:curr_path = self.path[:]    # 深拷贝self.result.append(curr_path)# 递归for i in range(start, len(nums)):# 在搜索的时候就去重:这一类相同元素不计算顺序的问题,我们在搜索的时候就按某种顺序搜索。# 从每一层的第 2 个结点开始,都不能再搜索产生同一层结点已经使用过的 candidate 里的元素。# 而且重复的元素要跳过,防止产生重复数组if i > start and nums[i] == nums[i - 1]:continueself.path.append(nums[i])total += nums[i]self.back_tracking(nums, target, total, i + 1)self.path.pop()total -= nums[i]

回溯算法模板【C++】:

class Solution {
public:vector<vector<int>> result; // 存放组合集合vector<int> path;           // 符合条件的组合vector<vector<int>> combinationSum2(vector<int>& candidates, int target) {sort(candidates.begin(), candidates.end());backTracking(candidates, target, 0, 0);return result;}void backTracking(vector<int>& nums, int target, int total, int start){// 递归结束条件【剪枝:如果当前total < 0,没必要枚举了,直接return】if(total > target){return;}// 逻辑主体部分if(total == target){result.push_back(path);}// 递归for(int i = start; i < nums.size(); i++){// 要对同一树层使用过的元素进行跳过if(i > start && nums[i] == nums[i - 1]){continue;}//记忆【将当前元素加入到path中】path.push_back(nums[i]);total += nums[i];backTracking(nums, target, total, i + 1);// 回溯【将当前元素从path删除】path.pop_back();total -= nums[i];}}
};
class Solution {
public:vector<vector<int>> result; // 存放组合集合vector<int> path;           // 符合条件的组合vector<vector<int>> combinationSum2(vector<int>& candidates, int target) {sort(candidates.begin(), candidates.end());backTracking(candidates, target, 0, 0);return result;}void backTracking(vector<int>& nums, int target, int total, int start){// 递归结束条件【剪枝:如果当前total < 0,没必要枚举了,直接return】if(total > target){return;}std::cout << "total = " << total << "; ";std::cout << "path = [";for(auto num: path) {std::cout << num;}std::cout << "]" << std::endl;// 逻辑主体部分if(total == target){result.push_back(path);}// 递归for(int i = start; i < nums.size(); i++){// 要对同一树层使用过的元素进行跳过if(i > start && nums[i] == nums[i - 1]){continue;}//记忆【将当前元素加入到path中】path.push_back(nums[i]);total += nums[i];backTracking(nums, target, total, i + 1);// 回溯【将当前元素从path删除】path.pop_back();total -= nums[i];}

这篇关于LeetCode-题目详解(十一):回溯算法【递归回溯、迭代回溯】【DFS是一个劲往某一个方向搜索;回溯算法建立在DFS基础之上,在搜索过程中,达到结束/裁剪条件后,恢复状态,回溯上一层,再次搜索】的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!



http://www.chinasem.cn/article/1128821

相关文章

MySQL数据库双机热备的配置方法详解

《MySQL数据库双机热备的配置方法详解》在企业级应用中,数据库的高可用性和数据的安全性是至关重要的,MySQL作为最流行的开源关系型数据库管理系统之一,提供了多种方式来实现高可用性,其中双机热备(M... 目录1. 环境准备1.1 安装mysql1.2 配置MySQL1.2.1 主服务器配置1.2.2 从

Linux kill正在执行的后台任务 kill进程组使用详解

《Linuxkill正在执行的后台任务kill进程组使用详解》文章介绍了两个脚本的功能和区别,以及执行这些脚本时遇到的进程管理问题,通过查看进程树、使用`kill`命令和`lsof`命令,分析了子... 目录零. 用到的命令一. 待执行的脚本二. 执行含子进程的脚本,并kill2.1 进程查看2.2 遇到的

MyBatis常用XML语法详解

《MyBatis常用XML语法详解》文章介绍了MyBatis常用XML语法,包括结果映射、查询语句、插入语句、更新语句、删除语句、动态SQL标签以及ehcache.xml文件的使用,感兴趣的朋友跟随小... 目录1、定义结果映射2、查询语句3、插入语句4、更新语句5、删除语句6、动态 SQL 标签7、ehc

详解SpringBoot+Ehcache使用示例

《详解SpringBoot+Ehcache使用示例》本文介绍了SpringBoot中配置Ehcache、自定义get/set方式,并实际使用缓存的过程,文中通过示例代码介绍的非常详细,对大家的学习或者... 目录摘要概念内存与磁盘持久化存储:配置灵活性:编码示例引入依赖:配置ehcache.XML文件:配置

从基础到高级详解Go语言中错误处理的实践指南

《从基础到高级详解Go语言中错误处理的实践指南》Go语言采用了一种独特而明确的错误处理哲学,与其他主流编程语言形成鲜明对比,本文将为大家详细介绍Go语言中错误处理详细方法,希望对大家有所帮助... 目录1 Go 错误处理哲学与核心机制1.1 错误接口设计1.2 错误与异常的区别2 错误创建与检查2.1 基础

k8s按需创建PV和使用PVC详解

《k8s按需创建PV和使用PVC详解》Kubernetes中,PV和PVC用于管理持久存储,StorageClass实现动态PV分配,PVC声明存储需求并绑定PV,通过kubectl验证状态,注意回收... 目录1.按需创建 PV(使用 StorageClass)创建 StorageClass2.创建 PV

Python版本信息获取方法详解与实战

《Python版本信息获取方法详解与实战》在Python开发中,获取Python版本号是调试、兼容性检查和版本控制的重要基础操作,本文详细介绍了如何使用sys和platform模块获取Python的主... 目录1. python版本号获取基础2. 使用sys模块获取版本信息2.1 sys模块概述2.1.1

一文详解Python如何开发游戏

《一文详解Python如何开发游戏》Python是一种非常流行的编程语言,也可以用来开发游戏模组,:本文主要介绍Python如何开发游戏的相关资料,文中通过代码介绍的非常详细,需要的朋友可以参考下... 目录一、python简介二、Python 开发 2D 游戏的优劣势优势缺点三、Python 开发 3D

Redis 基本数据类型和使用详解

《Redis基本数据类型和使用详解》String是Redis最基本的数据类型,一个键对应一个值,它的功能十分强大,可以存储字符串、整数、浮点数等多种数据格式,本文给大家介绍Redis基本数据类型和... 目录一、Redis 入门介绍二、Redis 的五大基本数据类型2.1 String 类型2.2 Hash

Java中的.close()举例详解

《Java中的.close()举例详解》.close()方法只适用于通过window.open()打开的弹出窗口,对于浏览器的主窗口,如果没有得到用户允许是不能关闭的,:本文主要介绍Java中的.... 目录当你遇到以下三种情况时,一定要记得使用 .close():用法作用举例如何判断代码中的 input