题目描述(中等难度)
给定 n ,k ,表示从 { 1, 2, 3 … n } 中选 k 个数,输出所有可能,并且选出数字从小到大排列,每个数字只能用一次。
解法一 回溯法
这种选数字很经典的回溯法问题了,先选一个数字,然后进入递归继续选,满足条件后加到结果中,然后回溯到上一步,继续递归。直接看代码吧,很好理解。
publicList>combine(intn,intk){
List>ans=newArrayList<>();
getAns(1,n,k,newArrayList(),ans);
returnans;
}
privatevoidgetAns(intstart,intn,intk,ArrayListtemp,List>ans){
//如果 temp 里的数字够了 k 个,就把它加入到结果中
if(temp.size()==k){
ans.add(newArrayList(temp));
return;
}
//从 start 到 n
for(inti=start;i<=n;i++){
//将当前数字加入 temp
temp.add(i);
//进入递归
getAns(i+1,n,k,temp,ans);
//将当前数字删除,进入下次 for 循环
temp.remove(temp.size()-1);
}
}
一个 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。
publicList>combine(intn,intk){
List>ans=newArrayList<>();
getAns(1,n,k,newArrayList(),ans);
returnans;
}
privatevoidgetAns(intstart,intn,intk,ArrayListtemp,List>ans){
if(temp.size()==k){
ans.add(newArrayList(temp));
return;
}
for(inti=start;i<=n-(k-temp.size())+1;i++){
temp.add(i);
getAns(i+1,n,k,temp,ans);
temp.remove(temp.size()-1);
}
}
虽然只改了一句代码,速度却快了很多。
解法二 迭代
参考这里,完全按照解法一回溯的思想改成迭代。我们思考一下,回溯其实有三个过程。
for 循环结束,也就是 i == n + 1,然后回到上一层的 for 循环
temp.size() == k,也就是所需要的数字够了,然后把它加入到结果中。
每个 for 循环里边,进入递归,添加下一个数字
publicList>combine(intn,intk){
List>ans=newArrayList<>();
Listtemp=newArrayList<>();
for(inti=0;i
temp.add(0);
}
inti=0;
while(i>=0){
temp.set(i,temp.get(i)+1);//当前数字加 1
//当前数字大于 n,对应回溯法的 i == n + 1,然后回到上一层
if(temp.get(i)>n){
i--;
// 当前数字个数够了
}elseif(i==k-1){
ans.add(newArrayList<>(temp));
//进入更新下一个数字
}else{
i++;
//把下一个数字置为上一个数字,类似于回溯法中的 start
temp.set(i,temp.get(i-1));
}
}
returnans;
}
解法三 迭代法2
解法二的迭代法是基于回溯的思想,还有一种思想,参考这里。类似于46题的解法一,找 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 ,代表当前有多少个数。第二层循环就是遍历之前的所有结果。第三次循环就是将当前结果扩展为多个。
publicList>combine(intn,intk){
if(n==0||k==0||k>n)returnCollections.emptyList();
List>res=newArrayList>();
//个数为 1 的所有可能
for(inti=1;i<=n+1-k;i++)res.add(Arrays.asList(i));
//第一层循环,从 2 到 k
for(inti=2;i<=k;i++){
List>tmp=newArrayList>();
//第二层循环,遍历之前所有的结果
for(Listlist:res){
//第三次循环,对每个结果进行扩展
//从最后一个元素加 1 开始,然后不是到 n ,而是和解法一的优化一样
//(k - (i - 1) 代表当前已经有的个数,最后再加 1 是因为取了 n
for(intm=list.get(list.size()-1)+1;m<=n-(k-(i-1))+1;m++){
ListnewList=newArrayList(list);
newList.add(m);
tmp.add(newList);
}
}
res=tmp;
}
returnres;
}
解法四 递归
参考这里C(n-1k-1)%2BC(n-1k)>)。基于这个公式 C ( n, k ) = C ( n - 1, k - 1) + C ( n - 1, k ) 所用的思想,这个思想之前刷题也用过,但忘记是哪道了。
从 n 个数字选 k 个,我们把所有结果分为两种,包含第 n 个数和不包含第 n 个数。这样的话,就可以把问题转换成
从 n - 1 里边选 k - 1 个,然后每个结果加上 n
从 n - 1 个里边直接选 k 个。
把上边两个的结果合起来就可以了。
publicList>combine(intn,intk){
if(k==n||k==0){
Listrow=newLinkedList<>();
for(inti=1;i<=k;++i){
row.add(i);
}
returnnewLinkedList<>(Arrays.asList(row));
}
// n - 1 里边选 k - 1 个
List>result=combine(n-1,k-1);
//每个结果加上 n
result.forEach(e->e.add(n));
//把 n - 1 个选 k 个的结果也加入
result.addAll(combine(n-1,k));
returnresult;
}
解法五 动态规划
参考这里,既然有了解法四的递归,那么一定可以有动态规划。递归就是压栈压栈压栈,然后到了递归出口,开始出栈出栈出栈。而动态规划一个好处就是省略了出栈的过程,我们直接从递归出口网上走。
publicList>combine(intn,intk){
List>[][]dp=newList[n+1][k+1];
//更新 k = 0 的所有情况
for(inti=0;i<=n;i++){
dp[i][0]=newArrayList<>();
dp[i][0].add(newArrayList());
}
// i 从 1 到 n
for(inti=1;i<=n;i++){
// j 从 1 到 i 或者 k
for(intj=1;j<=i&&j<=k;j++){
dp[i][j]=newArrayList<>();
//判断是否可以从 i - 1 里边选 j 个
if(i>j){
dp[i][j].addAll(dp[i-1][j]);
}
//把 i - 1 里边选 j - 1 个的每个结果加上 i
for(Listlist:dp[i-1][j-1]){
ListtmpList=newArrayList<>(list);
tmpList.add(i);
dp[i][j].add(tmpList);
}
}
}
returndp[n][k];
}
这里遇到个神奇的问题,提一下,开始的的时候,最里边的 for 循环是这样写的
for(Listlist:dp[i-1][j-1]){
ListtmpList=newLinkedList<>(list);
tmpList.add(i);
dp[i][j].add(tmpList);
}
就是 List 用的 Linked,而不是 Array,看起来没什么大问题,在 leetcode 上竟然报了超时。看了下 java 的源码。
//ArrayList
publicbooleanadd(E e){
ensureCapacityInternal(size+1);// Increments modCount!!
elementData[size++]=e;
returntrue;
}
//LinkedList
publicbooleanadd(E e){
linkLast(e);
returntrue;
}
voidlinkLast(E e){
finalNodel=last;
finalNodenewNode=newNode<>(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题等等优化思路一样,这里不详细说了。
publicList>combine(intn,intk){
List>[]dp=newArrayList[k+1];
// i 从 1 到 n
dp[0]=newArrayList<>();
dp[0].add(newArrayList());
for(inti=1;i<=n;i++){
// j 从 1 到 i 或者 k
List>temp=newArrayList<>(dp[0]);
for(intj=1;j<=i&&j<=k;j++){
List>last=temp;
if(dp[j]!=null){
temp=newArrayList<>(dp[j]);
}
// 判断是否可以从 i - 1 里边选 j 个
if(i<=j){
dp[j]=newArrayList<>();
}
// 把 i - 1 里边选 j - 1 个的每个结果加上 i
for(Listlist:last){
ListtmpList=newArrayList<>(list);
tmpList.add(i);
dp[j].add(tmpList);
}
}
}
returndp[k];
}
总
开始的时候直接用了动态规划,然后翻了一些 Discuss 感觉发现了新世界,把目前为止常用的思路都用到了,回溯,递归,迭代,动态规划,这道题也太经典了!值得细细回味。
添加好友一起进步~
如果觉得有帮助的话,可以点击 这里 给一个 star 哦 ^^
如果想系统的学习数据结构和算法,强烈推荐一个我之前学过的课程,可以点击 这里 查看详情