LeetCode算法题5:递归和回溯1


前言

      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/ 不同于交换,这里为依次选择每一个元素。见下图:
little_ant_
      对应的代码为(稍加修改后):

    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');
    }

总结

      完

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值