代码随想录第27天 | 回溯算法part03

代码随想录算法训练营第27天 | 回溯算法part03

● 39. 组合总和
● 40.组合总和II
● 131.分割回文串

题目一 39. 组合总和

给定一个无重复元素的数组 candidates[] 和一个目标数 target ,找出 candidates 中所有可以使数字和为 target 的组合。
candidates 中的数字可以无限制重复被选取。 所有数字(包括 target)都是正整数。解集不能包含重复的组合。 (1 <= candidates[i] <= 200)

示例 :

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

与总和3的区别就在于本题可以重复使用数字(0肯定不行),但是因为target是个确定的正整数,因此还是有一个隐形上限。
因为给定的是无重复元素数组,因此省去去重的麻烦。

本题的关键是想清楚如何解决重复选数字的问题。
重复选数字,意味着纵向遍历时,取过一次后这个数还会在接下来需要选择数字的数组内。
比如[2,3,5]中先取了2,那么下一次递归需要操作的数组不是[3,5],而还是[2,3,5]。
本题仍然选的是组合而不是排列,因此如图所示,选5之后就不能再选2,只能选5或者3.

未剪枝的实现如下。注意使用java时,path使用的是类队列的linkedlist结构,而ans是二重可变数组,因此ans.add的时候记得对path进行转换。

List<List<Integer>> ans = new ArrayList<>();
LinkedList<Integer> path = new LinkedList<>();
public List<List<Integer>> combinationSum(int[] candidates, int target) 
{
	recursion(target, candidates, 0, 0);
	return ans;
}
public void recursion(int target, int[] candidates,int sum, int start)
{
	//end
	if(sum > target)
	{
		return;
	}
	if(sum == target)
	{
		ans.add( new ArrayList(path) );//记得转换
		return;
	}
	//every
	for(int i=start; i<candidates.length; i++)
	{
		sum += candidates[i];
		path.add( candidates[i] );
		// 关键点:不用i+1了,表示可以重复读取当前的数
		recursion(target, candidates, sum, i);
		sum -= candidates[i];
		path.removeLast();

	}
}

剪枝优化如下。

上面的版本一的代码大家可以看到,对于sum已经大于target的情况,其实是依然进入了下一层递归,只是下一层递归结束判断的时候,会判断sum > target的话就返回。

其实如果已经知道下一层的sum会大于target,就没有必要进入下一层递归了。
那么可以在for循环的搜索范围上做做文章了。
对总集合排序之后,如果下一层的sum(就是本层的 sum + candidates[i])已经大于target,就可以结束本轮for循环的遍历

注意需要对数组进行升序排序,保证先从最小的情况开始考虑,否则可能会错过结果。比如5,2,3
for循环剪枝代码如下,多了对取值大小的比较:

for (int i = startIndex; i < candidates.size() && sum + candidates[i] <= target; i++)
// 剪枝优化
public List<List<Integer>> combinationSum(int[] candidates, int target) {
	List<List<Integer>> res = new ArrayList<>();
	
	Arrays.sort(candidates); // 先进行排序
	
	backtracking(res, new ArrayList<>(), candidates, target, 0, 0);
	return res;
}
public void backtracking(List<List<Integer>> res, List<Integer> path, int[] candidates, int target, int sum, int idx) 
{
	// 找到了数字和为 target 的组合
	if (sum == target) {
		res.add(new ArrayList<>(path));
		return;
	}

	for (int i = idx; i < candidates.length; i++) 
	{
		// 如果 sum + candidates[i] > target 就终止遍历
		if (sum + candidates[i] > target) 
			break;
		path.add(candidates[i]);
		backtracking(res, path, candidates, target, sum + candidates[i], i);
		path.remove(path.size() - 1); // 回溯,移除路径 path 最后一个元素
	}
}

题目二 40.组合总和II

给定一个数组 candidates 和一个目标数 target ,找出 candidates 中所有可以使数字和为 target 的组合。
candidates 中的每个数字在每个组合中只能使用一次。
说明: 所有数字(包括目标数)都是正整数。解集不能包含重复的组合。
示例 输入: candidates = [2,5,2,1,2], target = 5,所求解集为:

[
  [1,2,2],
  [5]
]

这道题目和 组合总和1 有如下区别:
本题candidates 中的每个数字在每个组合中只能使用一次,本题数组candidates的元素是有重复的。
也就是说,如果给定数组中有两个1,我们也只能认定它们是数值上相等但不重复的两个数字,只能分别用一次。
而 组合总和1 是无重复和相等元素的数组candidates.
最后本题和上题要求一样,解集不能包含重复的组合。

因此本题涉及到去重。而且要在搜索的过程中就去掉重复组合,防止超时。
所谓去重,其实就是使用过的元素不能重复选取。 这么一说好像很简单!
都知道组合问题可以抽象为树形结构,那么“使用过”在这个树形结构上是有两个维度的,一个维度是同一树枝上使用过,一个维度是同一树层上使用过。没有理解这两个层面上的“使用过” 是造成大家没有彻底理解去重的根本原因。

前面我们提到:要去重的是==“同一树层上的使用过”==,而不是不同层的使用。
比如[1,1,2]中第一层选1,第二层再选1(另一个1),这是成立的;如果同一层选了第一个1,而选择另外一个1,也会是重复,需要去重。
如何判断同一树层上元素(相同的元素)是否使用过了呢?就是对使用过的元素使用boolean值进行标记。
本题直接使用一个和目标数组长度相同的数组boolean[] used进行标记。如果用过就是false,没用过就是true。
如果candidates[i] == candidates[i - 1] 并且 used[i - 1] == false,就说明:前一个树枝,使用了candidates[i - 1],也就是说同一树层使用过candidates[i - 1],即使它们是相等但不同的元素,也不能再用了。
此时for循环里就应该做continue的操作。

注意标记的过程是在递归中的,因此回溯时应当进行逆操作。比如从false变为true,那回溯时就再变为false。

List<List<Integer>> ans = new ArrayList<>();
LinkedList<Integer> path = new LinkedList<>();
boolean[] used;
private int sum = 0;
public List<List<Integer>> combinationSum2(int[] candidates, int target) 
{
	used = new boolean[candidates.length];
	Arrays.fill(used, false);
	Arrays.sort(candidates);
	recursion(target ,candidates, 0);
	return ans;
}
public void recursion(int target, int[] candidates, int start)
{
	//end
	if(sum == target)
	{
		ans.add(new ArrayList(path) );
		return;
	}
	//every
	for(int i=start; i< candidates.length && sum + candidates[i] <= target; i++)
	{
		if(i > 0 && candidates[i] == candidates[i-1] && used[i-1] == false)
		{
			continue;
		}
		sum += candidates[i];
		path.add(candidates[i]);
		used[i] = true;
		recursion(target, candidates, i + 1);
		used[i] = false;
		sum -= candidates[i];
		path.removeLast();
	}
}
  • 时间复杂度: O( n ∗ 2 n n * 2^n n2n)
  • 空间复杂度: O(n)

或者直接用startIndex来去重也是可以的,不用used数组了。

class Solution {
  List<List<Integer>> res = new ArrayList<>();
  LinkedList<Integer> path = new LinkedList<>();
  int sum = 0;
  
  public List<List<Integer>> combinationSum2( int[] candidates, int target ) {
    //为了将重复的数字都放到一起,所以先进行排序
    Arrays.sort( candidates );
    backTracking( candidates, target, 0 );
    return res;
  }
  
  private void backTracking( int[] candidates, int target, int start ) {
    if ( sum == target ) {
      res.add( new ArrayList<>( path ) );
      return;
    }
    for ( int i = start; i < candidates.length && sum + candidates[i] <= target; i++ ) {
      //正确剔除重复解的办法
      //跳过同一树层使用过的元素
      if ( i > start && candidates[i] == candidates[i - 1] ) {
        continue;
      }

      sum += candidates[i];
      path.add( candidates[i] );
      // i+1 代表当前组内元素只选取一次
      backTracking( candidates, target, i + 1 );

      int temp = path.getLast();
      sum -= temp;
      path.removeLast();
    }
  }
}

题目三 131.分割回文串

给定一个字符串 s,将 s 分割成一些子串,使每个子串都是回文串。
返回 s 所有可能的分割方案。
示例: 输入: “aab” 输出: [ [“aa”,“b”], [“a”,“a”,“b”] ]

本题是切割问题,涉及到两个关键问题:

  1. 切割问题,有不同的切割方式
  2. 判断回文

相信这里不同的切割方式可以搞懵很多同学了。
这种题目,想用for循环暴力解法,可能都不那么容易写出来,所以要换一种暴力的方式,就是回溯。
回溯究竟是如何切割字符串呢?
我们来分析一下切割,其实切割问题类似组合问题
例如对于字符串abcdef:

  • 组合问题:选取一个a之后,在bcdef中再去选取第二个,选取b之后在cdef中再选取第三个…。
  • 切割问题:切割一个a之后,在bcdef中再去切割第二段,切割b之后在cdef中再切割第三段…。
    所以切割问题,也可以抽象为一棵树形结构.只不过这个结构选择的不是元素,而是切割位点

递归用来纵向遍历,for循环用来横向遍历,切割线(就是图中的红线)切割到字符串的结尾位置,说明找到了一个切割方法。
此时可以发现,切割问题的回溯搜索的过程和组合问题的回溯搜索的过程是差不多的。

因此递归函数同样需要一个数start,不过这里的start用于标记切割位点,而不是元素。
如果start比字符串长度大,证明已经找到了一组解决方案,将这个path放入ans中。
在递归函数中还需要调用一个识别回文子串的函数issymmetry()。逻辑比较简单,两个指针分别从两边向中间移动比较元素。这里还是双指针的思想。

for (int i = start; i < s.length(); i++)循环中,我们定义了起始位置start,那么 [start, i] 就是要截取的子串。

首先判断这个子串是不是回文,如果是回文,就加入在LinkedList<string> path中,path用来记录切割过的回文子串。
本题还有细节,例如:切割过的地方不能重复切割所以递归函数需要传入i + 1

List<List<String>> ans = new ArrayList<>();
LinkedList<String> path = new LinkedList<>();

public List<List<String>> partition(String s) 
{
	recursion(s, 0);
	return ans;
}
public void recursion(String s, int start)
{
	//end
	if(start >= s.length())
	{
		ans.add(new ArrayList(path) );
		return;
	}
	//every
	for(int i = start; i<s.length(); i++)
	{
		if(issymmetry(s, start, i) == true)
		{
			String str = s.substring(start, i+1 );//substring返回子串
			path.add(str);
		}else
		{
			continue;
		}
		recursion(s, i+1);
		path.removeLast();
	}
}
private boolean issymmetry(String s, int begin, int end)
{
	for(int i = begin, j=end; i<j; i++,j--)
	{
		if(s.charAt(i) != s.charAt(j))
		{
			return false;
		}
	}
	return true;
}
  • 时间复杂度: O( n ∗ 2 n n * 2^n n2n)
  • 空间复杂度: O( n 2 n^2 n2)
  • 16
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值