文章目录
前言
Leetcode算法系列:https://leetcode-cn.com/study-plan/algorithms/?progress=njjhkd2
简单总结一下递归回溯相关的算法题:
回溯算法:
回溯思想个人觉得还是挺好理解,但目前理解的还是不够深|><|。以下摘自百度百科:
回溯算法的基本思想是:从一条路往前走,能进则进,不能进则退回来,换一条路再试。八皇后问题就是回溯算法的典型,第一步按照顺序放一个皇后,然后第二步符合要求放第2个皇后,如果没有位置符合要求,那么就要改变第一个皇后的位置,重新放第2个皇后的位置,直到找到符合条件的位置就可以了。回溯在迷宫搜索中使用很常见,就是这条路走不通,然后返回前一个路口,继续下一条路。回溯算法说白了就是穷举法。不过回溯算法使用剪枝函数,剪去一些不可能到达 最终状态(即答案状态)的节点,从而减少状态空间树节点的生成。
回溯法是一个既带有系统性又带有跳跃性的的搜索算法。它在包含问题的所有解的解空间树中,按照深度优先的策略,从根结点出发搜索解空间树。算法搜索至解空间树的任一结点时,总是先判断该结点是否肯定不包含问题的解。如果肯定不包含,则跳过对以该结点为根的子树的系统搜索,逐层向其祖先结点回溯。否则,进入该子树,继续按深度优先的策略进行搜索。回溯法在用来求问题的所有解时,要回溯到根,且根结点的所有子树都已被搜索遍才结束。而回溯法在用来求问题的任一解时,只要搜索到问题的一个解就可以结束。这种以深度优先的方式系统地搜索问题的解的算法称为回溯法,它适用于解一些组合数较大的问题。
一、合并两个有序链表(简单,可略过)
题目链接:https://leetcode-cn.com/problems/merge-two-sorted-lists/
题目描述:将两个升序链表合并为一个新的 升序 链表并返回。新链表是通过拼接给定的两个链表的所有节点组成的。
迭代遍历
直接采用归并思想既可。参考算法如下:
public ListNode mergeTwoLists(ListNode list1, ListNode list2) {
ListNode realh=new ListNode(0);//自定义一个头节点。
ListNode tmp=realh;
while(list1!=null&&list2!=null){
if(list1.val<list2.val){
tmp.next=list1;
list1=list1.next;
}
else{
tmp.next=list2;
list2=list2.next;
}
tmp=tmp.next;
}
tmp.next=list1==null?list2:list1;
return realh.next;
}
一开始没有想到的递归解法
当发现也可以采用递归来做的时候,试了一下,先做终止条件判断,递归主体判断当前两个结点值之间的大小,值小的结点需要返回,注意要在返回之前完成下一轮的递归合并。参考算法如下:
public ListNode mergeTwoLists(ListNode list1, ListNode list2) {
if(list1==null)
return list2;
if(list2==null)
return list1;
if(list1.val<list2.val){
list1.next=merge(list1.next,list2);
return list1;
}
else{
list2.next=merge(list1,list2.next);
return list2;
}
}
二、反转链表
题目链接:https://leetcode-cn.com/problems/reverse-linked-list/
题目描述:给你单链表的头节点 head ,请你反转链表,并返回反转后的链表。
迭代遍历(头插法):
参考算法如下:
public ListNode reverseList(ListNode head) {
if(head==null)
return head;
ListNode realh=new ListNode(0),tmp;//仍建立一个头节点来保存最终的链表
while(head!=null){
tmp=head;//tmp 为待插入的结点,插入位置为 realh 的next处
head=head.next; //插入之前需要保证下一次迭代元素访问的正确性
tmp.next=realh.next;
realh.next=tmp;
}
return realh.next;
}
递归:
递归稍微有点难理解,可参考题解。下面算法的大体思路为:递归终止条件有两个,当前结点为 null 或当前结点的 next 为 null。1,当前结点为 null 仅针对 head 为一个空链表的情况,应返回 null;2,当前结点的 next 为 null 才是递归的终止条件。在对链表的递归遍历中,每一层递归中,我们都可以顺序的得到链表的每一个结点。
下面代码做的是从从后往前将链表逆序,由于我们的终止条件,第一次调整指针是在倒数第二个结点 a处(假设 a->next = b; b->next = null),此时可采用操作: a->next->next=a; a->next =null; 来将当前仅有两个元素的链表逆序。在原链表中依次往前,对倒数第三个结点、第四个…也是如此,最后返回结点 b 为新链表的第一个结点。
public ListNode reverseList(ListNode head) {
if(head==null||head.next==null)
return head;
ListNode tmp=reverseList(head.next); //tmp 为原链表最后一个结点,新链表的第一个结点。
head.next.next=head; //这两行代码从倒数第二个结点处开始调整结点的指向关系,一步步进行逆序。
head.next=null;
return tmp;
}
目前仅有的粗浅印象为:从后往前对链表结点进行操作需要将递归语句放在操作语句的前面;从前往后按照前后顺序操作,需要将操作语句放在递归语句的前面。
三、组合
题目链接:https://leetcode-cn.com/problems/combinations/
题目描述:给定两个整数 n 和 k,返回范围 [1, n] 中所有可能的 k 个数的组合。
你可以按 任何顺序 返回答案。
回溯:
可参考对应的官方题解,初始的算法如下:
1:算法的退出条件代码块b放在保存合适的组合情况代码块a后面的原因在于代码块a在代码块c的前面,即先进行访问、判定操作,后进行递归遍历。对于 n,k = 4,2 的情况,在 start 为 5 的递归遍历中,代码块 a 先判断的是包含 4 的情况,然后再准备添加 5。 若将代码块 b 放在 a 的前面,需要更改 b 中的 if 语句为 if(start>end+1)。
//对 n 个数的组合,假设每个数均有两种状态,选取和不选取,每一种成功的情况发生在选取的数有 k 个的情况,保存每一个可能的情况。
List<List<Integer>> ans=new LinkedList<>();
List<Integer> tmp=new LinkedList<>();
public List<List<Integer>> combine(int n, int k) {
solve(1,n,k);
return ans;
}
public void solve(int start,int end,int k){
if(tmp.size()==k){ //碰到每一种成功的情况时应该保存 a
ans.add(new LinkedList<>(tmp));
return;
}
//start == end,此时表示到达最后一个元素处 b
if(start>end)
return; //设置算法的退出条件
tmp.add(start); //选取当前值 start 的情况 c
solve(start+1,end,k);
tmp.remove(tmp.size()-1); //不选取的情况
solve(start+1,end,k);
}
当 n=4,k=2 时的执行结果如下(在 solve 方法的第一行添加 System.out.println(tmp);):
[]
[1]
[1, 2]
[1]
[1, 3]
[1]
[1, 4]
[1]
[]
[2]
[2, 3]
[2]
[2, 4]
[2]
[]
[3]
[3, 4]
[3]
[]
[4]
[]
2:进一步修改,添加剪枝条件并在此时可以删除代码块 b 的算法参考如下:
现在我们知道,代码块 a 中判断的元素范围不包括当前的 start ,而 end-start+1 表示还未添加进 tmp 集合的的剩余元素,如果 tmp 中元素数量加上剩余元素数量之和小于 k 的话,绝对不可能出现成功的情况,所以程序可直接返回,不需要进行冗余的判断。
并且在此时,代码块 b 可以删除,它不会再被执行。原因在于 b 针对的是当前 tmp 元素数量达不到 k ,且即将要扩大区间到 n+1 的时候,作为此时的边界条件,程序应该返回。
而新加的代码块 d,目的是为了避免没必要的递归运算,即剪枝。那么 b 表示的返回条件是否被 d 囊括呢?答案是肯定的。假设对于 n=4,k=2 的情况,当前 tmp 中的元素为 1,start 为 5 时,此时在 d 中程序直接就返回了(即代码块 b 的作用)。
b 针对的边界条件:如果当前 tmp 元素数量达不到 k ,且即将要扩大区间到 n+1 的时候,此时 end-start+1 的值为 0,而 tmp.size() 又是小于 k 的,在 d 中程序也就直接返回了。 所以代码块 b 可删除掉。
List<List<Integer>> ans=new LinkedList<>();
List<Integer> tmp=new LinkedList<>();
public List<List<Integer>> combine(int n, int k) {
solve(1,n,k);
return ans;
}
public void solve(int start,int end,int k){
if(tmp.size()+(end-start+1)<k) d
return;
if(tmp.size()==k){ //碰到每一种成功的情况时应该保存 a
ans.add(new LinkedList<>(tmp));
return;
}
/*
//start == end,此时表示到达最后一个元素处 b
if(start>end)
return; //设置算法的退出条件
*/
tmp.add(start); //选取当前值 start 的情况 c
solve(start+1,end,k);
tmp.remove(tmp.size()-1); //不选取的情况
solve(start+1,end,k);
}
四、全排列
题目链接:https://leetcode-cn.com/problems/permutations/
题目描述:给定一个不含重复数字的数组 nums ,返回其所有可能的全排列 。你可以 按任意顺序 返回答案。
回溯(交换):
先将初始数组保存到 tmp 列表中,方便直接采用 swap 方法来交换。大体思想是:假设数组元素为{1,2,3,4},全排列的顺序为:先得到以 1 开头的所有排列,然后是 2,3,4;假如在以 1 开头的排列中,先得到其余的 2,3,4开头的排列;假如在以1 ,2 开头的排列,分别得到 3,4 开头的排列;假如在以 1,2,3开头的排列中,最后一个数只能是 4。
ArrayList<Integer> tmp=new ArrayList<>();
List<List<Integer>> ans=new LinkedList<List<Integer>>();
//用交换的方法做!
public List<List<Integer>> permute(int[] nums) {
for (int num : nums)
tmp.add(num);
solve(0,nums.length-1);
return ans;
}
public void solve(int cur,int end) {
if (cur == end) {// cur 表示当前的元素下标
ans.add(new ArrayList<>(tmp));
return;
}
else{
for(int i=cur;i<=end;i++){
Collections.swap(tmp,cur,i);//交换
solve(cur+1,end);
Collections.swap(tmp,cur,i);//复位
}
}
}
回溯:
原文链接为对应题解:https://leetcode-cn.com/problems/permutations/solution/hui-su-suan-fa-python-dai-ma-java-dai-ma-by-liweiw/ 不同于交换,这里为依次选择每一个元素。见下图:
对应的代码为(稍加修改后):
LinkedList<Integer> tmp=new LinkedList<>();
List<List<Integer>> ans=new LinkedList<>();
boolean[] flag;
public List<List<Integer>> permute(int[] nums) {
int len=nums.length;
flag=new boolean[len];//由于每次选择一个元素之后,该元素就不能再被选取了,所以需要标记状态
solve(nums,len-1);
return ans;
}
public void solve(int[] nums,int end) {
if(tmp.size()==end+1){//得到一次排列时,保存结果
ans.add(new LinkedList<>(tmp));
return;
}
for(int i=0;i<=end;i++){//依次选择每一个元素,由于有标记数组存在,所以此处循环每次都从下标 0 开始。
if(flag[i]==false){
tmp.addLast(nums[i]);
flag[i]=true;
solve(nums,end);
flag[i]=false;
tmp.removeLast();
}
}
}
五、字母大小写全排列
题目链接:https://leetcode-cn.com/problems/letter-case-permutation/
题目描述:给定一个字符串 s ,通过将字符串 s 中的每个字母转变大小写,我们可以获得一个新的字符串。
返回 所有可能得到的字符串集合 。以 任意顺序 返回输出。
回溯:
这道题和求组合的思路有些类似,组合是一个元素选或不选,本体是一个字母是否发生的大小写转换,然后给出所有的字符串集合。参考算法如下:
List<String> ans=new LinkedList<>();
public List<String> letterCasePermutation(String s) {
char[] ss=s.toCharArray();
int start=0,end=ss.length-1;
solve(ss,start,end);
return ans;
}
public void solve(char[] ss,int start,int end){
if(start>end){
ans.add(new String(ss));
return;
}
if(ss[start]>='0'&&ss[start]<='9')
solve(ss,start+1,end); //数字跳过
else{
change(ss,start);
solve(ss,start+1,end);
change(ss,start);
solve(ss,start+1,end);
}
}
public void change(char[] ss, int i){
//char tmp='a'-'A';
if(ss[i]<='z'&&ss[i]>='a')
ss[i]-=('a'-'A');
else if(ss[i]<='Z'&&ss[i]>='A')
ss[i]+=('a'-'A');
}
总结
完