算法套路十——回溯法之子集型回溯

算法套路十——回溯法之子集型回溯

回溯算法通常使用递归来实现。在回溯算法中,我们从根节点开始,递归地搜索解空间树,当搜索到某个节点时,我们需要判断该节点是否满足约束条件。如果满足约束条件,我们继续搜索该节点的子节点;如果不满足约束条件,我们就回溯到上一层节点。

回溯算法是一种暴力搜索方法,它通过搜索解空间树来寻找问题的解。因此回溯算法效率的关键在于剪枝,即在搜索过程中若能尽早地排除不满足约束条件的节点,就能减少搜索空间,从而提高搜索效率。

算法实例一:LeetCode17. 电话号码的字母组合

给定一个仅包含数字 2-9 的字符串,返回所有它能表示的字母组合。答案可以按 任意顺序 返回。给出数字到字母的映射如下(与电话按键相同)。注意 1 不对应任何字母。在这里插入图片描述

首先介绍何为回溯法,如下图所示:
在这里插入图片描述
通俗的理解就是通过回溯来遍历所有情况,使用一个数组记录每种情况,且在递归完成后将该数组的内容回溯到,如图中如果目前记录了"ad"的情况,在这种情况枚举完成后,将数组中的“d“删除使数组中元素重新为“a”即是一种回溯,这样可以继续往数组中添加“e”;
当“a”的所有情况枚举完后,将数组中的“a”也删除回溯到数组为空,枚举第一个元素为“b”的情况。这样不断回溯,不断记录,就会枚举完所有的情况。

因此对于本题我们采用回溯法,用path记录当前枚举的情况,ans记录答案。

  • 当前问题:前i-1位固定,枚举大于等于 i 位的所有字母组合
  • 当前操作:枚举第i位所有可能字母,并将枚举的字母加入path,并递归子问题,且在子问题枚举完成后,需要进行回溯——将当前枚举的第i位字母从path删除,并判断第i位是否所有情况都枚举完,若不是则继续枚举当前情况,若是则结束当前问题,返回上层递归函数。
  • 子问题:枚举大于等于i+1的字母组合情况
  • 边界条件:i是否是n,若是则将path记录的情况加入ans中。
# 定义数字到字母的映射关系
MAPPING = "", "", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz"
class Solution:
    def letterCombinations(self, digits: str) -> List[str]:
        n=len(digits)
        if n == 0:
            return []
        # 初始化结果数组和路径数组
        ans=[]
        path=[]
        # 定义递归函数 dfs,它将遍历所有数码所代表的字符组合,并将结果保存在 ans 数组中
        def dfs(i:int)->None:
            if i==n:
                ans.append("".join(path))
                return
            # 遍历当前数字所代表的所有字符
            for c in MAPPING[int(digits[i])]:
                path.append(c)  # 将当前字符加入路径
                dfs(i + 1)      # 递归进入下一位数字
                path.pop()      # 回溯,移除刚加入的字符

        # 从第 0 位数字开始递归处理
        dfs(0)
        
        return ans

算法实例二:LeetCode78. 子集

给你一个整数数组 nums ,数组中的元素 互不相同 。返回该数组所有可能的子集(幂集)。解集 不能 包含重复的子集。你可以按 任意顺序 返回解集。
在这里插入图片描述

子集型问题分两种思路,一种是从输入的思路来考虑,如本题考虑当前元素是选还是不选,第二种是从答案的思路来考虑,如考虑选哪个元素

思路一:输入的思路(判断数组每个位置选或不选,直到数组最后一个元素)

在这里插入图片描述

采用回溯法,用path记录当前数字选择的情况,ans记录答案。

  • 当前问题:数组前i-1个元素是否选择固定,枚举数组从第i个元素开始的所有元素是否选择
  • 当前操作:枚举子集选择第i位元素或不选第i位元素,若不选第i位元素,则直接递归子问题;若选择第i位元素,则需要先将当前元素加入path中,并递归子问题,在递归完成后,进行回溯,将第i位元素从path中删除
  • 子问题:数组前i-1个元素是否选择固定,枚举数组从第i+1个元素开始的所有元素是否选择
  • 边界条件:i是否是n,若是则将path记录的情况加入ans中并返回。
class Solution:
    def subsets(self, nums: List[int]) -> List[List[int]]:
        ans=[]
        path=[]
        n=len(nums)
        def dfs(i:int)->None:
            if i==n:
                ans.append(path.copy())# 固定答案
                return
            #不选 nums[i]
             dfs(i + 1)
            # 选 nums[i]
            path.append(nums[i])
            dfs(i + 1)
            path.pop() #恢复现场
        dfs(0)
        return ans    

思路二:答案的思路(枚举数组所有的选择情况,每次枚举都是答案)

采用回溯法,用path记录当前枚举的情况,ans记录答案。
在这里插入图片描述

  • 当前问题:枚举数组从第i位到最后的所有选择情况
  • 当前操作:每次枚举的path都是答案,故将当前枚举的path加入ans中,并判断是否枚举到最后一个元素,若是则返回;若不是则枚举j>=i,将数组第 j 位元素加入path中,并递归子问题,递归完成后,进行回溯,将数组第j位元素从path删除
  • 子问题:枚举数组从第 j+1 位到最后的所有选择情况
  • 边界条件:i是否是n,若是则将path记录的情况加入ans中并返回。
class Solution:
    def subsets(self, nums: List[int]) -> List[List[int]]:
        ans=[]
        path=[]
        n=len(nums)
        def dfs(i:int)->None:
            ans.append(path.copy())# 固定答案
            if i==n:
                return
            for j in range(i, n):  # 枚举选择的数字
                path.append(nums[j])
                dfs(j + 1)/#修改的值在当前dfs(j+1)内加入ans中,如本题即先选1
                path.pop() #恢复现场,恢复现场后的值将在下一次循环j的dfs(j+1)内加入ans中,即在下一次dfs时没有选择1,选择了2
        dfs(0)
        return ans   

总结:

虽然有两种思路,但可以看到仅仅在dfs递归函数中有代码修改,且一般退出递归的条件是一样的,只是在ans更新与何时递归有所不同

算法练习一:LeetCode784. 字母大小写全排列

给定一个字符串 s ,通过将字符串 s 中的每个字母转变大小写,我们可以获得一个新的字符串。返回 所有可能得到的字符串集合 。以 任意顺序 返回输出。在这里插入图片描述

思路一:输入的思路(判断字符串s每个字符是否转变大小写)

在这里插入图片描述

采用回溯法,用cur记录当前转变大小写的情况,ans记录答案。

  • 当前问题:判断字符串i之后的每个字符是否转变大小写
  • 当前操作:判断是否为数字,若是则i++,直到当前i不是数字,当前i位置为字符,则分为转变大小写与不转变大小写,并分别递归子问题
  • 子问题:判断字符串i+1之后的每个字符是否转变大小写
  • 边界条件:i是否是n,若是则将cur记录的情况加入ans中并返回。
func letterCasePermutation(s string) []string {
    n:=len(s)
    ans:=[]string{}
    cur:=[]byte(s)
    var dfs func(i int)
    dfs=func(i int){
        for i<n&&cur[i]>='0'&&cur[i]<='9'{
            i++
        }
        if i==n{
            ans=append(ans,string(cur))
            return
        }
        //不转变
        dfs(i+1)
        //转变
        cur[i] ^= 32//异或 32 可进行字母大小写转换
        dfs(i+1)
        cur[i] ^= 32
       
    }
    dfs(0)
    return ans
}

思路二:答案的思路(枚举选择哪个字母进行转变,每次枚举都是答案)

在这里插入图片描述
采用回溯法,用cur记录当前枚举的情况,ans记录答案。

  • 当前问题:枚举字符串i位后的每个字符的转变情况
  • 当前操作:首先将记录的cur字符串计入ans中,之后枚举j从i到字符最后,判断第j位是否是字符,若不是则继续循环,若是则进行转变、递归、回溯
  • 子问题:枚举字符串第j+1位之后的分割情况
  • 边界条件:i是否是n,若是则将cur记录的情况加入ans中并返回。
func letterCasePermutation(s string) []string {
    n:=len(s)
    ans:=[]string{}
    temp:=[]byte(s)
    var dfs func(i int)
    dfs=func(i int){
        ans=append(ans,string(temp))
        if i==n{
            return
        }
        for j:=i;j<n;j++{
            if temp[j]>='0'&&temp[j]<='9'{
                continue    
            }
            temp[j] ^= 32
            dfs(j+1)
            temp[j] ^= 32
        }
    }
    dfs(0)
    return ans
}

算法练习二:LeetCode2397. 被列覆盖的最多行数

给你一个下标从 0 开始的 m x n 二进制矩阵 mat 和一个整数 cols ,表示你需要选出的列数。
如果一行中,所有的 1 都被你选中的列所覆盖,那么我们称这一行 被覆盖 了。
请你返回在选择 cols 列的情况下,被覆盖 的行数 最大 为多少。在这里插入图片描述

思路一:输入的思路(判断矩阵每列选或不选,直到矩阵最后一列或者剩余可选列数为0)

采用回溯法,用selectCols来记录当前选择的列数,用lines函数来计算当前列数选择情况下的覆盖行数,maxAns记录答案。

  • 当前问题:矩阵前i-1个列是否选择固定,枚举矩阵第i列之后的每个列是否选择
  • 当前操作:分别枚举是否选择该列,若不选择则直接递归子函数,若选择则修改selectCols,并令numSelect–,之后递归子函数,递归完成后进行回溯
  • 子问题:矩阵前i个列是否选择固定,枚举矩阵第i+1列之后的每个列是否选择
  • 边界条件:首先判断剩余可选列数是否为0,若是的话则判断maxAns是否需要更新,若不是则判断i==n,若等于则结束函数;
func maximumRows(matrix [][]int, numSelect int) int {
    maxAns := -1  // 存储最大行数
    n := len(matrix[0])  // 矩阵列数
    selectCols := [13]int{}  // 记录选中的列的状态,最多只有 12 列,这里设置为长度为 13 的数组
    var dfs func(i int)
    dfs = func(i int) {
        if numSelect == 0 {  // 如果已经选择了 numSelect 列,则统计一下当前状况下有几行全为 0,并更新 maxAns 的值
            maxAns = max(maxAns, lines(matrix, selectCols))
            return
        } else if i == n {  // 如果已经遍历完了所有列,则返回
            return
        }
        // 不选择该列,直接进入下一列的处理
        dfs(i+1)
        // 选择该列,然后继续进行递归搜索
        selectCols[i] = 1
        numSelect--
        dfs(i+1)
        // 恢复现场
        selectCols[i] = 0
        numSelect++
    }
    dfs(0)
    return maxAns
}

// 统计在当前选择方案下有多少行全为 0
func lines(matrix [][]int, selectCols [13]int) int {
    ans := 0
    m := len(matrix)
    n := len(matrix[0])
    for i := 0; i < m; i++ {
        j := 0
        // 判断该行是否满足选择要求
        for ; j < n; j++ {
            if matrix[i][j] == 1 {
                if selectCols[j] == 1 {
                    continue
                }
                break
            }
        }
        if j == n {  // 如果当前行全为 0,增加计数器
            ans++
        }
    }
    return ans
}
func max(a,b int) int{if a>b{return a};return b}

思路二:答案的思路(枚举选择哪个列,需要满足矩阵最后一列或者剩余可选列数为0的要求)

采用回溯法,用selectCols来记录当前选择的列数,用lines函数来计算当前列数选择情况下的覆盖行数,maxAns记录答案。

  • 当前问题:枚举矩阵从第i位之后每列的选择情况
  • 当前操作:枚举j从i到n,并对每个j列进行选择并更新selectCols,递归,回溯
  • 子问题:枚举字符串第j+1位之后每列的选择情况
  • 边界条件:首先判断剩余可选列数是否为0,若是的话则判断maxAns是否需要更新,若不是则判断i==n,若等于则结束函数;
func maximumRows(matrix [][]int, numSelect int) int {
    maxAns:=-1
    n:=len(matrix[0])
    selectCols:=[13]int{}
    var dfs func(i int)
    dfs=func(i int){
        if numSelect==0{
            maxAns=max(maxAns,lines(matrix,selectCols))
            return
        }else if i==n{
            return
        }
        for j:=i;j<n;j++{
            selectCols[j]=1
            numSelect--
            dfs(j+1)
            selectCols[j]=0
            numSelect++
        } 
    }
    dfs(0)
    return maxAns
}
func lines(matrix [][]int,selectCols [13]int) int{
    ans:=0
    m:=len(matrix)
    n:=len(matrix[0])
    for i:=0;i<m;i++{
        j:=0
        for ;j<n;j++{
            if matrix[i][j]==1{
                if selectCols[j]==1{continue}
                break
            }
        }
        if j==n{
            ans++
        }
    }
    return ans
}
func max(a,b int) int{if a>b{return a};return b}

算法练习三:LeetCode1601. 最多可达成的换楼请求数目

我们有 n 栋楼,编号从 0 到 n - 1 。每栋楼有若干员工。由于现在是换楼的季节,部分员工想要换一栋楼居住。
给你一个数组 requests ,其中 requests[i] = [fromi, toi] ,表示一个员工请求从编号为 fromi 的楼搬到编号为 toi 的楼。
一开始 所有楼都是满的,所以从请求列表中选出的若干个请求是可行的需要满足 每栋楼员工净变化为 0 。意思是每栋楼 离开 的员工数目 等于 该楼 搬入 的员工数数目。比方说 n = 3 且两个员工要离开楼 0 ,一个员工要离开楼 1 ,一个员工要离开楼 2 ,如果该请求列表可行,应该要有两个员工搬入楼 0 ,一个员工搬入楼 1 ,一个员工搬入楼 2 。
请你从原请求列表中选出若干个请求,使得它们是一个可行的请求列表,并返回所有可行列表中最大请求数目。1 <= n <= 20,,1 <= requests.length <= 16 ,requests[i].length == 2 ,0 <= fromi, toi < n
在这里插入图片描述

思路一:输入的思路(判断每个换房请求选或不选,直到请求最后一列)

采用回溯法,用m记录reques的长度,selects来记录当前选择的换楼请求,用isSatisfy函数来判断当前请求选择情况下是否符合要求,maxAns记录答案。

  • 当前问题:矩阵前i-1个请求是否选择固定,枚举矩阵第i列之后的每个请求是否选择
  • 当前操作:枚举当前请求i与不选择当前请求,并记录、递归、回溯
  • 子问题:矩阵前i个请求是否选择固定,枚举矩阵第i+1列之后的每个请求是否选择
  • 边界条件:判断i是否等于m,若是则判断当前选择是否符合条件,若符合则更新maxAns
func maximumRequests(n int, requests [][]int) int {
    m:=len(requests)
    selects:=make([][]int,0)
    maxAns:=-1
    var dfs func(int) 
    dfs=func(i int) {
        if i==m{
            if isSatisfy(selects){
                maxAns=max(maxAns,len(selects))    
            }
            return
        }
        //不选
        dfs(i+1)
        //选
        selects=append(selects,append([]int{},requests[i]...))
        dfs(i+1)
        selects=selects[:len(selects)-1]
    }
    dfs(0)
    return maxAns
}
func max(a,b int)int{if(a>b){return a};return b}
func isSatisfy(selects [][]int) bool {
    cntFrom:=[21]int{}
    cntTo:=[21]int{}
    for _,request:=range selects{
        cntFrom[request[0]]++
        cntTo[request[1]]++
    }
    for i:=0;i<21;i++{
        if cntFrom[i]!=cntTo[i]{
            return false
        }
    }
    return true
}

思路二:答案的思路(枚举选换房请求的所有情况,每次枚举都判断是否更新maxAns)

采用回溯法,用m记录reques的长度,selects来记录当前选择的换楼请求,用isSatisfy函数来判断当前请求选择情况下是否符合要求,maxAns记录答案。

  • 当前问题:枚举从j到m的换房请求情况
  • 当前操作:枚举j从i到m,并记录,递归,回溯
  • 子问题:枚举从j+1到m的换房请求情况
  • 边界条件:判断是否符合要求,并判断是否需要更新maxAns;之后判断i==m,若等于则结束递归
func maximumRequests(n int, requests [][]int) int {
    m:=len(requests)
    selects:=make([][]int,0)
    maxAns:=-1
    var dfs func(int) 
    dfs=func(i int) {
        if isSatisfy(selects){
            maxAns=max(maxAns,len(selects))    
        }
        if i==m{
            return
        }
        for j:=i;j<m;j++{
            selects=append(selects,append([]int{},requests[j]...))
            dfs(j+1)
            selects=selects[:len(selects)-1]
        }
    }
    dfs(0)
    return maxAns
}
func max(a,b int)int{if(a>b){return a};return b}
func isSatisfy(selects [][]int) bool {
    cntFrom:=[21]int{}
    cntTo:=[21]int{}
    for _,request:=range selects{
        cntFrom[request[0]]++
        cntTo[request[1]]++
    }
    for i:=0;i<21;i++{
        if cntFrom[i]!=cntTo[i]{
            return false
        }
    }
    return true
}

算法进阶一:LeetCode131. 分割回文串

给你一个字符串 s,请你将 s 分割成一些子串,使每个子串都是 回文串 。返回 s 所有可能的分割方案。回文串 是正着读和反着读都一样的字符串。
在这里插入图片描述

思路一:输入的思路(判断字符串每个位置是否分割,直到字符串最后一个元素)

在这里插入图片描述

采用回溯法,用path记录当前分割情况,ans记录答案。

  • 当前问题:判断字符串i之后的每个位置是否进行分割,且使用start记录上一次分割后的开始字符,方便判断是否回文
  • 当前操作:首先判断i是否n-1即最后一位字符,若是则必须进行分割。否则枚举是否选择分割,若不分割,则直接调用下一个子问题;若分割,则利用start记录的上一个分割后的开始字符判断当目前字符是否为回文数,若不是则直接跳过,若是则将分割字符加入path中,并调用子问题,且令start为下一位字符i+1,递归完成后恢复现场
  • 子问题:判断字符串i+1之后的位置否进行分割,且使用start记录上一个分割的位置,方便判断是否回文
  • 边界条件:i是否为n,若是则将当前path加入ans中
func partition(s string) (ans [][]string) {
    path:=[]string{}
    n:=len(s)
    var  dfs func( int,int )
    dfs=func(i ,start int ){    
        if i==n{
            ans=append(ans,append([]string{},path...))
            return
        }
        // 不选 i 和 i+1 之间的逗号(i=n-1 时已到字符串末尾,必须进行分割)
        if i < n-1 {
            dfs(i+1,start)
        }
        // 选 i 和 i+1 之间的逗号
        if isPalindrome(s,start,i){
            path=append(path,s[start:i+1])
            dfs(i+1,i+1)
            path = path[:len(path)-1] // 恢复现场
        }
    }
    dfs(0,0)
    return 
}
func isPalindrome(s string, left, right int) bool {
    for left < right {
        if s[left] != s[right] {
            return false
        }
        left++
        right--
    }
    return true
}

思路二:答案的思路(枚举字符串下一个位置在哪分割,枚举到字符串最后一个元素)

采用回溯法,用path记录当前枚举的情况,ans记录答案。

  • 当前问题:枚举字符串第i位之后的分割情况
  • 当前操作:注意此题需分割到最后一个位置这样才能确实是否是分割串都是回文串,而不是每个节点都是答案。循环枚举j从i到n-1为分割结束位置,并判断s[i:j+1]是否为回文串,若是则path记录,递归j+1,回溯
  • 子问题:枚举字符串第j+1位之后的分割情况
  • 边界条件:i是否为n,若是则将当前path加入ans中。
func partition(s string) (ans [][]string) {
    path:=[]string{}
    n:=len(s)
    var  dfs func( int)
    dfs=func(i int){    
        if i==n{
            ans=append(ans,append([]string{},path...))
            return
        }
        for j:=i;j<n;j++{// 枚举子串的结束位置
            if isPalindrome(s,i,j){
                path=append(path,s[i:j+1])
                dfs(j+1)
                path=path[:len(path)-1]
            }
        }
    }
    dfs(0)
    return 
}
func isPalindrome(s string, left, right int) bool {
    for left < right {
        if s[left] != s[right] {
            return false
        }
        left++
        right--
    }
    return true
}
  • 4
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

Pistachiout

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

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

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

打赏作者

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

抵扣说明:

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

余额充值