LeeCode练习-数组(7): 组合总和

原题说明:给定一个无重复元素的数组 candidates 和一个目标数 target ,找出 candidates 中所有可以使数字和为 target 的组合。candidates 中的数字可以无限制重复被选取。

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

 

原题链接:https://leetcode-cn.com/problems/combination-sum


 

题目分析

这道题目一拿到手,想到的就是两个递归和动态规划。委实说,到写这篇博客之时,我对这些算法还是分不太清楚。希望每周一次的算法/数据结构学习能改善这样的局面。

这道题目我是查看了题解和一些人给的代码。尽管如此,我还是能以自己的理解来表达这个问题。

官方的题解和大多数人的评论都是回溯算法,那么关于这个算法,截止写这篇博客时我不甚了解,这里主要是给出怎么运用这样的算法。我有看到一些人说,回溯算法是一种思想,可能实现方式是依靠递归?

下面进入正题,这次的博客将按照从无到有的顺序介绍代码。

 

首先,这里先引一段网友给出的思路:

1. 定义全局结果数组
2. 返回全局结果数组
3. 调用递归函数
4. 定义递归函数

1) 参数,动态变化,一般为分支结果、限制条件等
2) 终止条件,将分支结果添加到全局数组
3) 调用递归逐步产生结果,回溯搜索下一结果
4) 剪枝条件

我按照自己的理解、并参考了大家的代码,完成了解题。

 

第一&二步,定义全局结果数组

public ArrayList<ArrayList<Integer>> easyversion(int[] candidates, int target) {
	ArrayList<ArrayList<Integer>> res1 = new ArrayList<ArrayList<Integer>>();		
	return res1;
}

这里$res1$就是结果数组,当然数据结构不一定是List形式。我有看到一些题解将这个参数作为全局变量,这样就可以避免在每次调用递归函数的时候,多传入一个固定的变量了。

 

第三步,调用递归函数

public ArrayList<ArrayList<Integer>> easyversion(int[] candidates, int target) {
	ArrayList<ArrayList<Integer>> res1 = new ArrayList<ArrayList<Integer>>();
	backtracking_easy(candidates, 0, target, res1, new ArrayList<Integer>());    	
	return res1;
}

第三行就是递归函数。这里我回想起之前听过的课程,其实对于函数接口的设计有两种方案:其一就是事先想好,都需要实现哪些方法,然后在设计的时候就完成编写;其二就是将一些反复出现的操作封装起来,设计好输入和输出的形式,之后再编写方法。

其实对于第三行代码, new ArrayList<Integer>()在这里其实是没有意义的,但是考虑到递归函数调用的统一,需要有这个参数传递。

 

第四步,定义递归函数

图1展示的是本题的回溯过程(以$[1,2,3,4,5,6]$为给定数组,$target=6$)

 

STEP1. 参数动态变化(分支结构和限制条件)

private void backtracking_easy(int[] nums, int start, int target, ArrayList<ArrayList<Integer>> res, ArrayList<Integer> list) {	
    if(target<0) {

	}
	else if(target==0) { 

	}
	else {

	}		
}

 这里先说明递归函数传入的各个参数的含义。$nums$是提供的数组,$start$是当前遍历的起点,$target$是当前的目标值,$res1$是全局结果的返回值,$list$是当前存储组合的参数。

从图1中可以看到,回溯的思路,就是穷尽组合时,不断计算组合的加和。这个时候$target$不断变化,直到满足条件($target==0$)。因此这也就是递归的分支和限制条件。

分别就三种条件讨论:

$target<0$。说明上一层加入的新元素使得组合总数超过了目标值,因此要返回上一层并剪枝(移除新加入的元素),之后在考虑加入下一个元素;

$target==0$。说明正好满足条件,加入新元素之后的$list$可以append到$res1$中。再返回上一层,同样也要剪枝并考虑加入下一元素;

$target>0$。说明达到目标值仍然需要加入元素。$start$代表的索引就是刚加入的元素位置,应当以此为起点,继续加入该元素。

STEP2. 终止条件设定

private void backtracking_easy(int[] nums, int start, int target, ArrayList<ArrayList<Integer>> res, ArrayList<Integer> list) {
	if(target<0) {
		return;
	}
	else if(target==0) { 
		res.add(new ArrayList<>(list));
		return;
	}
	else {
		for(int i = start; i < nums.length; i++) {

		}
	}		
}

 因为要求是找到所有组合,所以这里就是循环遍历。循环变量$i$的初始条件在$target>$已经提到了。这里的终止条件本来有两个(参考其它的答案),其一是元素的遍历不能发生数组越界$i<nums.length$,其二是组合的加和不超过目标值$target>0$。逐个分析一下:

第一点是为了保证数组的元素能都被遍历,穷尽所有的组合可能;

第二点是为了能够加入新的元素,逐渐接近目标值。但是因为之前有分支结果讨论过了,所以这点在这里就不必出现。

STEP3. 调用递归函数 

private void backtracking_easy(int[] nums, int start, int target, ArrayList<ArrayList<Integer>> res, ArrayList<Integer> list) {
	if(target<0) {
		return;
	}
	else if(target==0) { 
		res.add(new ArrayList<>(list));
		return;
	}
	else {
		for(int i = start; i < nums.length; i++) {
			//target -= nums[i]
			list.add(nums[i]);
			backtracking_easy(nums, i, target - nums[i], res, list);
		}
	}		
}

 在调用下一层递归函数之前,需要在当前存储的参数$list$中append新的加入的元素。若之后加和满足目标值,则可以将加入新元素之后的list再append到$res1$中;或再往其中加入新的元素、再返回(剪枝);或返回(剪枝)。

之后就可以调用递归函数,进行递归(其实我认为这个函数不是递归函数,而是因为发生“自己调用自己”所以递归了)。

这里要注意的是,如果目标值先减去新加入的元素、再作为输入的参数加入到递归函数中,会产生错误的结果。

所以要将新的目标值放在递归的函数中,作为参数进行传递。

STEP4. 剪枝

private void backtracking_easy(int[] nums, int start, int target, ArrayList<ArrayList<Integer>> res, ArrayList<Integer> list) {
	if(target<0) {
		return;
	}
	else if(target==0) { 
		res.add(new ArrayList<>(list));
		return;
	}
	else {
		for(int i = start; i < nums.length; i++) {
			list.add(nums[i]);
			backtracking_easy(nums, i, target - nums[i], res, list);
			list.remove(list.size()-1);//remove the last element
		}
	}		
}

每次调用下层递归函数返回之后,只能有两种情况:$target<0$和$target==0$不管哪种情况,都说明当前加入的元素不满足组合加和的要求。因此需要从$list$中弹出当前元素,加入下个元素,直到遍历完数组中所有的元素。

 

以下是全部的代码

 1 public ArrayList<ArrayList<Integer>> easyversion(int[] candidates, int target) {
 2     ArrayList<ArrayList<Integer>> res1 = new ArrayList<ArrayList<Integer>>();
 3     
 4     if(candidates == null && candidates.length < 1)
 5         return res1;
 6     
 7     Arrays.sort(candidates);
 8     backtracking_easy(candidates, 0, target, res1, new ArrayList<Integer>());        
 9     return res1;
10 }
11 
12 private void backtracking_easy(int[] nums, int start, int target, ArrayList<ArrayList<Integer>> res, ArrayList<Integer> list) {
13     if(target<0) {
14         return;
15     }
16     else if(target==0) { 
17         res.add(new ArrayList<>(list));
18         return;
19     }
20     else {
21         for(int i = start; i < nums.length; i++) {
22             list.add(nums[i]);
23             backtracking_easy(nums, i, target - nums[i], res, list);
24             list.remove(list.size()-1);//remove the last element
25         }
26     }        
27 }

 在主函数部分,多出的代码首先是确保边界条件,还有就是对给定的数组进行排序,提高效率。对于数组中重复的元素,其实可以看做是再加入一个已有的元素,所以并不会有影响。

 

下面是一个简单的递归实例说明,给定的数组是$[2,1,3]$(故意没有排序的),目标值是6。


  

总结:

  • 感觉算法好神奇,就是一行行的语言,但是可视化之后的呈现是那么丰富。
  • 对于算法的结果,还是强调在一定范围内容用实例多测试
  • 目前感觉,对于递归(于我而言还是很抽象的),可以从一定的阶段推导一下。 
posted @ 2019-12-24 10:07  李嘉图正在调试  阅读(467)  评论(0编辑  收藏  举报