Leetcode-77. Combinations

公众号每天发布一篇关于Leetcode解题技巧的文章,尝试从多角度、不同方法对题目进行解析。欢迎大家关注!

在这里插入图片描述

题目描述(中等难度)


给定 n ,k ,表示从 { 1, 2, 3 … n } 中选 k 个数,输出所有可能,并且选出数字从小到大排列,每个数字只能用一次。

解法一 回溯法

这种选数字很经典的回溯法问题了,先选一个数字,然后进入递归继续选,满足条件后加到结果中,然后回溯到上一步,继续递归。直接看代码吧,很好理解。

import java.util.ArrayList;
import java.util.List;

public class Combinations1 {
	
	public static List<List<Integer>> combine(int n,int k){
		
		List<List<Integer>> ans=new ArrayList<>();
		ArrayList<Integer> temp=new ArrayList<>();
		getAns(1,n,k,temp,ans);
		return ans;
		
	}

	private static void getAns(int start, int n, int k, ArrayList<Integer> temp, List<List<Integer>> ans) {
		//如果temp里的数字够了K个,就把它加入到结果中
		if(temp.size()==k) {
			ans.add(new ArrayList<Integer>(temp));
			return;
		}
		for(int i=start;i<=n;i++) {
			//将当前数字加入到temp
			temp.add(i);
			//进入递归
			getAns(i+1,n,k,temp,ans);
			//将当前数字删除,进入下次for循环
			temp.remove(temp.size()-1);
		}
	}
	public static void main(String args[]) {
		int n=4;
		int k=2;
		List<List<Integer>> ans=combine(n,k);
		System.out.println(ans);
	}
}

一个 for 循环,添加,递归,删除,很经典的回溯框架了。不过,基于上面的代码我们还可以进行优化。for 循环里 i 从 start 到 n,其实没必要到 n。比如,n = 5,k = 4,temp.size( ) == 1,此时代表我们还需要(4 - 1 = 3)个数字,如果 i = 4 的话,以后最多把 4 和 5 加入到 temp 中,而此时 temp.size() 才等于 1 + 2 = 3,不够 4 个,所以 i 没必要等于 4,i 循环到 3 就足够了。

所以 for 循环的结束条件可以改成, i <= n - ( k - temp.size ( ) ) + 1,k - temp.size ( ) 代表我们还需要的数字个数。因为我们最后取到了 n,所以还要加 1。

import java.util.ArrayList;
import java.util.List;

public class Combinations2 {
	public static List<List<Integer>> Combinations(int n,int k){
	List<List<Integer>>ans=new ArrayList<>();
	ArrayList<Integer>temp=new ArrayList<>();
	getAns(1,n,k,temp,ans);
	return ans;
	}

	private static void getAns(int start, int n, int k, ArrayList<Integer> temp, List<List<Integer>> ans) {
		if(temp.size()==k) {
			ans.add(new ArrayList<>(temp));
			return ;
		}
		for(int i=start;i<=n-(k-temp.size())+1;i++) {
			temp.add(i);
			getAns(i+1,n,k,temp,ans);
			temp.remove(temp.size()-1);
		}
	}
	public static void main(String args[]) {
		int n=4;
		int k=2;
		List<List<Integer>> ans=Combinations(n,k);
		System.out.println(ans);
	}
}

虽然只改了一句代码,速度却快了很多。

解法二 迭代

参考这里,完全按照解法一回溯的思想改成迭代。我们思考一下,回溯其实有三个过程。

  • for 循环结束,也就是 i == n + 1,然后回到上一层的 for 循环
  • temp.size() == k,也就是所需要的数字够了,然后把它加入到结果中。
  • 每个 for 循环里边,进入递归,添加下一个数字.
import java.util.ArrayList;
import java.util.List;

public class Combinations3 {
	
	public static List<List<Integer>> combine(int n,int k){
		List<List<Integer>>ans=new ArrayList<>();
		List<Integer>temp=new ArrayList<>();
		
		for(int i=0;i<k;i++) {
			temp.add(0);
		}
		
		int i=0;
		while(i>=0) {
			temp.set(i, temp.get(i)+1);
			if(temp.get(i)>n) {
				i--;
			}else if(i==k-1) {
				ans.add(new ArrayList<>(temp));
			}else {
				i++;
				temp.set(i, temp.get(i-1));
			}
		}
		return ans;
		
		
	}
	
	public static void main(String args[]) {
		int n=4;
		int k=2;
		List<List<Integer>> ans=combine(n,k);
		System.out.println(ans);
	}
}
解法三 迭代法2

解法二的迭代法是基于回溯的思想,还有一种思想,参考这里。找 k 个数,我们可以先找出 1 个的所有结果,然后在 1 个的所有结果再添加 1 个数,变成 2 个,然后依次迭代,直到有 k 个数。

比如 n = 5, k = 3

第 1 次循环,我们找出所有 1 个数的可能 [ 1 ],[ 2 ],[ 3 ]。4 和 5 不可能,解法一分析过了,因为总共需要 3 个数,4,5 全加上才 2 个数。

第 2 次循环,在每个 list 添加 1 个数, [ 1 ] 扩展为 [ 1 , 2 ],[ 1 , 3 ],[ 1 , 4 ]。[ 1 , 5 ] 不可能,因为 5 后边没有数字了。 [ 2 ] 扩展为 [ 2 , 3 ],[ 2 , 4 ]。[ 3 ] 扩展为 [ 3 , 4 ];

第 3 次循环,在每个 list 添加 1 个数, [ 1,2 ] 扩展为[ 1,2,3], [ 1,2,4], [ 1,2,5];[ 1,3 ] 扩展为 [ 1,3,4], [ 1,3,5];[ 1,4 ] 扩展为 [ 1,4,5];[ 2,3 ] 扩展为 [ 2,3,4], [ 2,3,5];[ 2,4 ] 扩展为 [ 2,4,5];[ 3,4 ] 扩展为 [ 3,4,5];

最后结果就是,[[ 1,2,3], [ 1,2,4], [ 1,2,5],[ 1,3,4], [ 1,3,5], [ 1,4,5], [ 2,3,4], [ 2,3,5],[ 2,4,5], [ 3,4,5]]。

上边分析很明显了,三个循环,第一层循环是 1 到 k ,代表当前有多少个数。第二层循环就是遍历之前的所有结果。第三次循环就是将当前结果扩展为多个。

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;

public class Combinations4 {
	
	public static List<List<Integer>>combine(int n,int k){
		if(n==0 || k==0 || k>n) return Collections.emptyList();
		List<List<Integer>> res=new ArrayList<List<Integer>>();
		
		//个数为1的所有可能性
		for(int i=1;i<=n+1-k;i++) {
			res.add(Arrays.asList(i));//[[1],[2],[3]]
		}
		
		for (int i = 2; i <= k; i++) {
	        List<List<Integer>> tmp = new ArrayList<List<Integer>>();
	        //第二层循环,遍历之前所有的结果
	        for (List<Integer> list : res) {
	            //第三次循环,对每个结果进行扩展
	            //从最后一个元素加 1 开始,然后不是到 n ,而是和解法一的优化一样
	            //(k - (i - 1) 代表当前已经有的个数,最后再加 1 是因为取了 n
	            for (int m = list.get(list.size() - 1) + 1; m <= n - (k - (i - 1)) + 1; m++) {
	                List<Integer> newList = new ArrayList<Integer>(list);
	                newList.add(m);
	                tmp.add(newList);
	            }
	        }
	        res = tmp;
	    }
	    return res;
	}
	public static void main(String args[]) {
		int n=4;
		int k=2;
		List<List<Integer>> ans=combine(n,k);
		System.out.println(ans);
	}
}
解法四 递归

参考这里。基于这个公式 C ( n, k ) = C ( n - 1, k - 1) + C ( n - 1, k ) 所用的思想,这个思想之前刷题也用过,但忘记是哪道了。

从 n 个数字选 k 个,我们把所有结果分为两种,包含第 n 个数和不包含第 n 个数。这样的话,就可以把问题转换成

  • 从 n - 1 里边选 k - 1 个,然后每个结果加上 n
  • 从 n - 1 个里边直接选 k 个。

把上边两个的结果合起来就可以了。

import java.util.Arrays;
import java.util.LinkedList;
import java.util.List;

public class Combinations5 {
	public static List<List<Integer>> combine(int n, int k) {
		
	    if (k == n || k == 0) {
	        List<Integer> row = new LinkedList<>();
	        for (int i = 1; i <= k; ++i) {
	            row.add(i);
	        }
	        return new LinkedList<>(Arrays.asList(row));
	    }
	    
	    // n - 1 里边选 k - 1 个
	    List<List<Integer>> result = combine(n - 1, k - 1);
	    //每个结果加上 n
	    result.forEach(e -> e.add(n));
	    
	    //把 n - 1 个选 k 个的结果也加入
	    result.addAll(combine(n - 1, k));
	    return result;
	}
	public static void main(String args[]) {
		int n=4;
		int k=2;
		List<List<Integer>> ans=combine(n,k);
		System.out.println(ans);
	}
}
解法五 动态规划

参考这里,既然有了解法四的递归,那么一定可以有动态规划。递归就是压栈压栈压栈,然后到了递归出口,开始出栈出栈出栈。而动态规划一个好处就是省略了出栈的过程,我们直接从递归出口网上走。

public List<List<Integer>> combine(int n, int k) { 
    List<List<Integer>>[][] dp = new List[n + 1][k + 1];
    //更新 k = 0 的所有情况
    for (int i = 0; i <= n; i++) {
        dp[i][0] = new ArrayList<>();
        dp[i][0].add(new ArrayList<Integer>());
    }
    // i 从 1 到 n
    for (int i = 1; i <= n; i++) {
        // j 从 1 到 i 或者 k
        for (int j = 1; j <= i && j <= k; j++) { 
            dp[i][j] = new ArrayList<>();
            //判断是否可以从 i - 1 里边选 j 个
            if (i > j){
                dp[i][j].addAll(dp[i - 1][j]);
            } 
            //把 i - 1 里边选 j - 1 个的每个结果加上 i
            for (List<Integer> list: dp[i - 1][j - 1]) {
                List<Integer> tmpList = new ArrayList<>(list);
                tmpList.add(i);
                dp[i][j].add(tmpList);
            } 
        }
    }
    return dp[n][k];
}

这里遇到个神奇的问题,提一下,开始的的时候,最里边的 for 循环是这样写的

for (List<Integer> list: dp[i - 1][j - 1]) {
    List<Integer> tmpList = new LinkedList<>(list);
    tmpList.add(i);
    dp[i][j].add(tmpList);
}

就是 List 用的 Linked,而不是 Array,看起来没什么大问题,在 leetcode 上竟然报了超时。看了下 java 的源码。

//ArrayList
public boolean add(E e) {
    ensureCapacityInternal(size + 1);  // Increments modCount!!
    elementData[size++] = e;
    return true;
}
//LinkedList
public boolean add(E e) {
    linkLast(e);
    return true;
}
void linkLast(E e) {
    final Node<E> l = last;
    final Node<E> newNode = new Node<>(l, e, null);
    last = newNode;
    if (l == null)
        first = newNode;
    else
        l.next = newNode;
    size++;
    modCount++;
}

猜测原因可能是因为 linked 每次 add 的时候,都需要 new 一个节点对象,而我们进行了很多次 add,所以这里造成了时间的耗费,导致了超时。所以刷题的时候还是优先用 ArrayList 吧。

接下来就是动态规划的常规操作了,空间复杂度的优化,我们注意到更新 dp [ i ] [ * ] 的时候,只用到dp [ i - 1 ] [ * ] 的情况,所以我们可以只用一个一维数组就够了。和72题解法二,以及5题,10题,53题等等优化思路一样,这里不详细说了。

public List<List<Integer>> combine(int n, int k) {
    List<List<Integer>>[] dp = new ArrayList[k + 1]; 
    // i 从 1 到 n
    dp[0] = new ArrayList<>();
    dp[0].add(new ArrayList<Integer>());
    for (int i = 1; i <= n; i++) {
        // j 从 1 到 i 或者 k
        List<List<Integer>> temp = new ArrayList<>(dp[0]);
        for (int j = 1; j <= i && j <= k; j++) {
            List<List<Integer>> last = temp;
            if(dp[j]!=null){ 
                temp = new ArrayList<>(dp[j]);
            } 
            // 判断是否可以从 i - 1 里边选 j 个
            if (i <= j) {
                dp[j] = new ArrayList<>();
            }
            // 把 i - 1 里边选 j - 1 个的每个结果加上 i
            for (List<Integer> list : last) {
                List<Integer> tmpList = new ArrayList<>(list);
                tmpList.add(i);
                dp[j].add(tmpList);
            }
        }
    }
    return dp[k];
}

开始的时候直接用了动态规划,然后翻了一些 Discuss 感觉发现了新世界,把目前为止常用的思路都用到了,回溯,递归,迭代,动态规划,这道题也太经典了!值得细细回味。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

安替-AnTi

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

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

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

打赏作者

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

抵扣说明:

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

余额充值