算法合集:回溯

说到回溯的算法题,就像是——好像会写但好像又不会写。回溯倒像是一种暴力枚举,另一种形式的dp递推——推不动回去不就是了嘛,搭配上剪枝或能比较快速的推动算法。

一、简单回溯——子集类回溯:选?不选?

诸如子集,全排列这种类型的题目就属于简单类型的回溯——只需要考虑当下是选还是不选
听上去挺抽象的,结合例题就很好理解

1、例题

1)例题一:LeetCode 77 组合
说白了从1 ~ n,选取k个数,返回所有组合
对一个链表,用递归进行维护,当长度小于k时表示还可以继续选数,当长度等于k时则返回

List<List<Integer>> ans;
List<Integer> set;
public List<List<Integer>> combine(int n, int k) {
    this.ans = new ArrayList<>();
    this.set = new ArrayList<>();
    getAns(k, 1);
    return ans;
}

private void getAns(int k, int index) {
    if (k == 0) {
        ans.add(new ArrayList<>(set));
        return;
    }
    // 选
    set.add(index);
    getAns(k - 1, index + 1);
    set.remove(set.size() - 1); // 还原
    // 不选
    getAns(k, index + 1);
}

两个关键点的理解点

  • 利用递归体的栈空间,使得选完之后可以返回原先index的位置,以进行其他的处理
  • 选完之后要还原来消除影响

这题还有一个关键的加速步骤——剪枝

若剩下所有的数都放进去了也占不满k个,则直接返回

private void getAns(int k, int n, int index) {
    if (k == 0) {
        ans.add(new ArrayList<>(set));
        return;
    }
    if (k > n - index + 1) // 剪枝
        return;
    // 选
	set.add(index);
	getAns(k - 1, n, index + 1);
	set.remove(set.size() - 1);
	// 不选
	getAns(k, n, index + 1);
}

2)例题二:LeetCode 46 全排列(数字无重复)
全排列需要讲所有数都要用上,所以此时不再是一直向后寻找待选择的对象,而是每次递归时都要寻找还没有使用过的数
下面提供基于位运算的解法,不了解位运算的可以参考我的另一篇博客:算法合集:位运算——就两个数字还想劝退我?
另外在注释中用boolean数组进行解释

int valid = 0; // boolean[] valid
List<List<Integer>> ans;
List<Integer> set;
public List<List<Integer>> permute(int[] nums) {
    this.ans = new ArrayList<>();
    this.set = new ArrayList<>();
    this.valid = (int)Math.pow(2, nums.length) - 1; // this.valid = new boolean[nums.length]
    getAns(nums, 0, nums.length);
    return ans;
}

private void getAns(int[] nums, int turn, int n) {
    if (turn == n) {
        ans.add(new ArrayList<>(set));
        return;
    }
    for (int i = 0; i < n; i++) // 每层递归都遍历一边来寻找未使用的数
        if (((valid >> i) & 1) == 1) { // if (valid[i] == false),表示还没用过该数
            valid ^= 1 << i; // valid[i] = true
            set.add(nums[i]);
            getAns(nums, turn + 1, n);
            set.remove(set.size() - 1);
            valid ^= 1 << i; // valid[i] = false,还原
        }
}

3)例题三:LeetCode 47 全排列Ⅱ(数字可以重复)
与上一题不同的是,有重复的数字且不能返回相同的答案。若要加入“判定答案是否相同”的方法体无疑复杂度太大了。
可以考虑以下想法:

如果数组是有序的
那么当上一个数与当前的数相同时,就可以来判断是否会产生重复答案
产生重复答案的情况是:上一个数与当前的数相同,并且上一个数没有被选择时,若仍选择当前的数,就会产生重复

来证明一下:
考虑这个例子

[1, 1, 2]

我们以第一个1打头时
证明图1
当以红1打头时,红1已经被选择了,所以即使后面出现了蓝1,也可以直接选而不会重复
若以第二个1打头时
证明图2
当轮到蓝1打头时,如果红1没有被选,那么就不能去选红1,因为红1已经代表1作为开头过了,选择蓝1的话只会重复地又1开头

  1. 首先,对有序数组,当上一个数与当前的数相同时才会触发——产生相同答案的情况
  2. nums[i - 1] = nums[i] = x时,对于数x来说,当index = i - 1时,x第一次出现,并被答案数组收集
  3. index = i时,由于x在上一步已经出现过,并且已经作为答案被收集了,所以nums[i - 1]没被选,再选则nums[i]会产生重复答案

下面提供基于位运算的解法,另外在注释中用boolean数组进行解释

List<List<Integer>> ans;
List<Integer> set;
int valid; // boolean[] valid
public List<List<Integer>> permuteUnique(int[] nums) {
    Arrays.sort(nums);
    this.ans = new ArrayList<>();
    this.set = new ArrayList<>();
    this.valid = (int)Math.pow(2, nums.length) - 1; // this.valid = new boolean[nums.length]
    getAns(nums, 0, nums.length);
    return ans;
}

private void getAns(int[] nums, int count, int n) {
    if (count == n) {
        ans.add(new ArrayList<>(set));
        return;
    }
    for (int i = 0; i < n; i++)
        if (((valid >> i) & 1) == 1) { // if (valid[i] == false),还没使用过
        	/*
        	 * if (i > 0 && nums[i] == nums[i - 1] && valid[i - 1] == false)
        	 * 		continue;
        	 * 当前数与上一个数相同,并且上一个数未被使用,则跳过当前
        	 */
            if (i > 0 && nums[i] == nums[i - 1] && ((valid >> (i - 1)) & 1) == 1)
                continue;
            valid ^= 1 << i; // valid[i] = true
            set.add(nums[i]);
            getAns(nums, count + 1, n);
            set.remove(set.size() - 1); // 还原
            valid ^= 1 << i; // valid[i] = false
        }
} 

2、实战题目

LeetCode 39 组合总数
LeetCode 40 组合总数Ⅱ
LeetCode 216 组合总数Ⅲ
LeetCode 78 子集
LeetCode 90 子集Ⅱ

二、复杂回溯——判断选择是否合法?

相比于简单回溯,复杂回溯仍是“选”与“不选”的问题,但多了一些处理与选择上的要求

1、例题

1)LeetCode 282 给表达式添加运算符
对于每次选择

不选:当前的数字不与前面的数字分隔开,也就是不添加运算符,直接进行下一次递归
选:先添加+-*等运算符,后添加当前数字,再进行进行递归

为了防止重复的操作,每次修改运算符时,不需要将运算符及其后的所以数字都删除,只需要直接找到运算符的位置修改即可:
s = "123"举例,假设当前递归到i = 1,也就是数字2的位置
在这里插入图片描述
此时StringBuilder = "1",也就是说,需要对当前的2进行处理

  • 若不选,直接添加2后进行下一次递归
  • 若选,则分别添加三个运算符

我们先插入一个'.'为运算符占位,并记录下这个'.'插入的位置,再添加数字
在这里插入图片描述
此时只需要修改.,变成+-*即可,而不用重复的删除添加数字了

builder.setCharAt(operIndex, '+');
builder.setCharAt(operIndex, '-');
builder.setCharAt(operIndex, '*');

接下来是递归参数的维护
维护一个long curcur表示当前已参与运算的式子的结果,再维护一个 long pre表示上一层的递归的数字,用于乘法的维护
因为我们知道*的优先级高于-+
所以当添加*时:
参数解释
乘法的函数体i + 1targetn可以先不看)

builder.setCharAt(operIndex, '*');
getAns(i + 1, pre * val, cur - pre + pre * val, target, n); // 进行下一次递归

pre * val:由于乘法的优先级最高,所以本次的val需要和上一层的pre形成一个整体传给下一层,作为下一层的pre
cur - pre + pre * val:由于乘法会使得上一层的pre和当前的val形成一个整体,所以cur的维护需要减去pre,再将pre * val作为整体进行添加

对比加减法的函数体来看

builder.setCharAt(operIndex, '+');
getAns(i + 1, val, cur + val, target, n);
builder.setCharAt(operIndex, '-');
getAns(i + 1, -val, cur - val, target, n);

最后是题解代码

List<String> ans;
StringBuilder builder;
char[] arr;
public List<String> addOperators(String num, int target) {
    this.ans = new ArrayList<>();
    this.builder = new StringBuilder();
    this.arr = num.toCharArray();
    getAns(0, 0L, 0L, target, num.length());
    return ans;
}

private void getAns(int index, long pre, long cur, int target, int n) {
    if (index == n) {
        if (cur == target)
            ans.add(builder.toString());
        return;
    }
    int operIndex = builder.length();
    if (index > 0)
        builder.append('.'); // 占位
    long val = 0;
    for (int i = index; i < n; i++) {
        val = val * 10 + (arr[i] - '0');
        // 直接添加数字,到后面再修改前面的oper
        builder.append(arr[i]);
        // 若index = 0,不能添加oper
        if (index == 0) {
            getAns(i + 1, val, val, target, n);
        } else {
            builder.setCharAt(operIndex, '+');
            getAns(i + 1, val, cur + val, target, n);
            builder.setCharAt(operIndex, '-');
            getAns(i + 1, -val, cur - val, target, n);
            builder.setCharAt(operIndex, '*');
            getAns(i + 1, pre * val, cur - pre + pre * val, target, n);
        }
        if (val == 0) // 排除连着的0的情况
            break;
    }
    builder.setLength(operIndex); // 还原
}

2)LeetCode 301 删除无效括号
我们知道有效的括号组合一定是count('(') = count(')'),即左右括号数量相等
所以可以先遍历字符串,找出哪种的括号多了,记录下需要删除的数量
比如:

s = "((())",此时需要删除一个左括号

但有例外:

s = ")(",此时左右括号相等,但s是不合法的,需要删除左右括号各一个
s = ")((",此时需要删除两个左括号和一个右括号

所以先计算至少需要删除的数量,再循环增加数量

this.arr = s.toCharArray();
int countL = 0, countR = 0; // 得到左右括号各自的数量
for (int i = 0; i < arr.length; i++) {
    if (arr[i] == '(') countL++;
    else if (arr[i] == ')') countR++;
}
// 得到左右括号需要删除的数量
int deleteL = Math.max(0, countL - countR), deleteR = Math.max(0, countR - countL);
// 循环增加需要删除的数量,进行递归
for (int i = 0; i <= Math.min(countL, countR); i++)
    getAns(0, deleteL + i, deleteR + i, 0, 0, true, new StringBuilder());

接下来是递归方法体的设计

getAns(int i, int deleteL, int deleteR, int countL, int countR, boolean pre, StringBuilder builder)
  • i对应s的下标,当i == s.length()时即可收集答案
  • deleteLdeleteR表示还需要删除的左右括号的数量
  • countLcountR表示递归到现在所遇到的左右括号的数量,均从0开始
  • pre表示上一个括号是否有被删除,用于剪枝

删:删除当前的括号。
也就是builder不添加此次的括号,并且deleteLdeleteR相应的减少
不删:不删除当前的括号
builder添加此次括号,deleteLdeleteR不变

// 删
if (arr[i] == '(' && (pre || arr[i - 1] != '(') && deleteL > 0)
    getAns(i + 1, deleteL - 1, deleteR, countL, countR, true, builder);
else if (arr[i] == ')' && (pre || arr[i - 1] != ')') && deleteR > 0)
    getAns(i + 1, deleteL, deleteR - 1, countL, countR, true, builder);
// 不删
builder.append(arr[i]);
if (arr[i] == '(')
    getAns(i + 1, deleteL, deleteR, countL + 1, countR, false, builder);
else if (arr[i] == ')')
    getAns(i + 1, deleteL, deleteR, countL, countR + 1, false, builder);
else    
    getAns(i + 1, deleteL, deleteR, countL, countR, false, builder);
builder.deleteCharAt(builder.length() - 1);

剪枝

  • countR > countL,也就是当前遇到的右括号数量 > 左括号数量,直接返回
  • arr.length - i < deleteL + deleteR还需要删除的括号数量 > 还没遍历到的括号数量,直接返回
  • 去重,pre || arr[i - 1] != arr[i],如果上一层的括号与本层的括号种类相同,并且上一层括号没有被删,则此时再删除当前括号会重复:比如s = "(()"删除第一个左括号而不删第二个左括号,其效果与不删除第一个左括号但删除第二个左括号相同
List<String> ans;
char[] arr;
public List<String> removeInvalidParentheses(String s) {
    this.ans = new ArrayList<>();
    this.arr = s.toCharArray();
    int countL = 0, countR = 0;
    for (int i = 0; i < arr.length; i++) {
        if (arr[i] == '(') countL++;
        else if (arr[i] == ')') countR++;
    }
    int deleteL = Math.max(0, countL - countR), deleteR = Math.max(0, countR - countL);
    for (int i = 0; i <= Math.min(countL, countR); i++) {
        getAns(0, deleteL + i, deleteR + i, 0, 0, true, new StringBuilder());
        // 如果当前的countL和countR找到了答案,则可以返回了
        // 如果没找到答案,则要增加countL和countR,如:s = ")(("
        if (ans.size() != 0) 
            break;
    }
    return ans;
}

private void getAns(int i, int deleteL, int deleteR, int countL, int countR, boolean pre, StringBuilder builder) {
    if (countR > countL || arr.length - i < deleteL + deleteR)
        return;
    if (i == arr.length) {
        ans.add(builder.toString());
        return;
    }
    // 删
    if (arr[i] == '(' && (pre || arr[i - 1] != '(') && deleteL > 0)
        getAns(i + 1, deleteL - 1, deleteR, countL, countR, true, builder);
    else if (arr[i] == ')' && (pre || arr[i - 1] != ')') && deleteR > 0)
        getAns(i + 1, deleteL, deleteR - 1, countL, countR, true, builder);
    // 不删
    builder.append(arr[i]);
    if (arr[i] == '(')
        getAns(i + 1, deleteL, deleteR, countL + 1, countR, false, builder);
    else if (arr[i] == ')')
        getAns(i + 1, deleteL, deleteR, countL, countR + 1, false, builder);
    else    
        getAns(i + 1, deleteL, deleteR, countL, countR, false, builder);
    builder.deleteCharAt(builder.length() - 1);
}

2、实战题目

LeetCode 1980 找出不同的二进制字符串
LeetCode 131 分割回文串
LeetCode 132 分割回文串Ⅱ
LeetCode 140 单词拆分Ⅱ
LeetCode 93 复原IP地址
LeetCode 638 大礼包

三、硬核大题

篇幅原因,单独出一篇来讲解
LeetCode 52 N皇后Ⅱ
LeetCode 51 N皇后
LeetCode 37 解数独

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值