算法,蒜鸟蒜鸟-P3-理解“递归、回溯与分治”

欢迎来到啾啾的博客🐱。
记录学习点滴。分享工作思考和实用技巧,偶尔也分享一些杂谈💬。
有很多很多不足的地方,欢迎评论交流,感谢您的阅读和评论😄。

博客头像3.0.png

引言

让我们从“思考模式”的层面出发,去理解更为复杂和抽象的问题。

  • 深入理解递归的本质,学会如何写出优雅的递归函数。
  • 掌握回溯 (Backtracking) 这一强大的搜索模式,解决排列、组合、子集等经典问题。
  • 最终,挑战算法面试中的“珠穆朗玛峰”——动态规划 (Dynamic Programming),学会用 DP 思维解决最优化问题。(见下P)

1 回溯 (Backtracking)

回溯算法本质上就是一种特殊的深度优先搜索(DFS)

回溯的口诀:一条路走到黑,发现不对就撤退。

这是一种通过“试错”来寻找所有可能解的暴力搜索算法,但它比纯粹的暴力聪明,因为它懂得“剪枝”,即发现某条路肯定不对后,就不会再继续往下走了。

几乎所有的回溯问题,都可以套用一个非常经典的三段式代码结构,也就是“选择 -> 递归 -> 撤销选择”。
或者说是“可选列表 -> 当前路径 -> 结束条件”。

// result 用来存放最终结果
// path 用来记录当前已经走过的路径
function backtrack(可选列表, 当前路径) {
    if (满足结束条件) {
        将当前路径加入结果;
        return;
    }

    for (在可选列表里做选择) {
        // 1. 做选择 (Choose)
        将当前选择加入路径;

        // 2. 递归 (Explore)
        // 进入下一层决策树
        backtrack(新的可选列表, 当前路径);

        // 3. 撤销选择 (Un-choose)
        // 这是回溯的精髓!
        // 将刚才的选择从路径中移除,以便尝试其他选择
        将当前选择从路径中移除;
    }
}

回溯本身的思路并不难理解,难的是代码实现。
路径怎么表示?可选列表怎么维护?结束条件是什么?
下面我们用示例“全排列”进行讲解。

1.1 LeetCode 46. 全排列

给定一个不含重复数字的数组 nums ,返回其 所有可能的全排列 。你可以 按任意顺序 返回答案。

class Solution {
    /**
    排列问题,采用回溯
    
    采用“可选列表->递归->撤销选择”三步走

    1、可选列表怎么维护
    在这道题中是还没有被选中的数字,采用一个boolean[] used进行标识
    2、当前路径怎么表示
    使用List<Integer表示路径>
    3、结束条件是什么
    所有数字都被选择时结束
     */
    public List<List<Integer>> permute(int[] nums) {
        List<List<Integer>> resultList = new ArrayList<>();
        int length = nums.length;
        if(length == 0){
            return resultList;
        }
        boolean[] used = new boolean[length];
        List<Integer> path = new ArrayList<>();
        backtrack(nums,used,resultList,path);
        return resultList;
    }

    /**
    回调,DFS递归
     */
    private void backtrack(int[] nums,boolean[] used,List<List<Integer>> resultList,List<Integer> path){
        // 4.结束条件
        if(path.size() == nums.length){
            resultList.add(new ArrayList<>(path));
            return;
        }

        // 循环遍历数组,开始选择
        for(int i = 0; i < nums.length ; i++){
            // 3.已经选择过了
            if(used[i]){
                continue;
            }
            // 1.选择可选列表
            used[i] = true;
            path.add(nums[i]);

            // 2.递归,选择不同路径
            backtrack(nums,used,resultList,path);

            // 5.撤销选择
            used[i] = false;

            // 6.移除最后一个,较new Integer(nums[i])省内存
            path.remove(path.size() -1);
        }
    }
}

1.2 LeetCode 77. 组合

回溯算法能解决的问题非常多,除了“全排列”,还有“组合”、“子集”、“棋盘问题(N皇后)”等等。它们的核心思想都是一样的,只是在“剪枝”和“结束条件”上略有不同。

比如77的组合问题。
在“组合”问题里,[1, 2]和 [2, 1]被认为是同一种组合。
我们需要对“选择 -> 递归 -> 撤销选择”进行调整,在选择时,强制要求从小到大选择来避免重复。

只需要对回溯方法做一点点改动,首先,我们不需要used数组了,因为从小到大往后选,避免了重复使用。
比如:

class Solution {
    public List<List<Integer>> combine(int n, int k) {
        List<List<Integer>> resultList = new ArrayList<>();
        if(n<k){
            return resultList;
        }
        List<Integer> path = new ArrayList<>();
        backTrack(n,k,resultList,path);
        return resultList;
    }

    //回溯
    public void backTrack(int n, int k,List<List<Integer>> resultList,List<Integer> path){
        // 终止条件
        if(path.size() == k){
            resultList.add(new ArrayList(path));
            return;
        }

        // 循环取数
        for(int i =1 ;i<= n;i++){

            // 强制从小到大
            if(path.size() != 0 &&  i <= path.get(path.size() -1)){
                continue;
            }
            path.add(i);
            // 递归
            backTrack(n,k,resultList,path);

            // 撤销操作
            path.remove(path.size() -1);
        }
    }
}

同时,我们需要一个 startIndex ,告诉递归从那里开始循环以避免无效循环的浪费。
更近一步,如果 n=20, k=10,我们当前的 path 里已经有8个数字了,我们还需要 10 - 8 = 2 个数字。
此时,如果 for 循环的 i 已经走到了 19,那么我们最多还能选择 19 和 20 这两个数。
如果 i 走到了 20,我们最多只能再选 20 这一个数,已经凑不够我们需要的2个数了。所以,i=20 的这个分支就可以直接剪掉。

即,缩小可选列表。
path 还需要 k - path.size() 个数。
从 i 到 n,我们还剩下 n - i + 1 个数可选。
如果 n - i + 1 < k - path.size(),说明剩下的数已经不够凑了,可以直接 break 循环。

优化后代码如下:

class Solution {
    public List<List<Integer>> combine(int n, int k) {
        List<List<Integer>> resultList = new ArrayList<>();
        if(n<k){
            return resultList;
        }
        List<Integer> path = new ArrayList<>();
        backTrack(1,n,k,resultList,path);
        return resultList;
    }

    //回溯 
    public void backTrack(int startIndex,int n, int k,List<List<Integer>> resultList,List<Integer> path){
        // 终止条件
        if(path.size() == k){
            resultList.add(new ArrayList(path));
            return;
        }
        // 循环取数 (n - (k - path.size()) + 1) 是 i 的一个理论上限
        for(int i = startIndex ;i<= n - (k -path.size()) + 1;i++){
            // 强制从小到大
            path.add(i);
            // 递归
            backTrack(i + 1,n,k,resultList,path);
            // 撤销操作
            path.remove(path.size() -1);
        }
    }
}

2 分治

与回溯这种递归寻找所有可能性的算法不同,分治是一种收敛的思维模型。
可以不断缩小问题规模来高效寻找唯一解。

口诀:分而治之,合而为一。 分、治、合。

它将一个难以直接解决的大问题,分割成两个或多个规模较小的、与原问题形式相同的子问题,然后递归地去解决这些子问题,最后将子问题的解合并,得到原问题的解。

比如:

  • 归并排序 (Merge Sort): 将数组从中间一分为二,分别对左右两半进行排序(递归),然后将两个有序的子数组合并成一个大的有序数组。
  • 快速排序 (Quick Sort): 选取一个基准值,将数组分成“小于基准”和“大于基准”的两部分,然后对这两部分递归地进行排序。
  • 二分查找 (Binary Search): 这是一种最简单、也最高频的分治应用。

需要注意二分查找和P1的双指针的区别,左右指针是一步步收缩,而二分查找是跳跃,它每一次都直接跳到当前搜索范围的正中间去进行判断,然后扔掉一半的范围。

2.1 LeetCode704. 二分查找

二分查找有着标准的模板,最不容易出错的“闭区间”写法。

  • 搜索区间是[left, right],两端都包含。
  • right 初始化为 nums.length - 1。
  • 循环条件是 while (left <= right)。
class Solution {
    /**
    最经典的二分查找,找有序数组中的目标值
     */ 
    public int search(int[] nums, int target) {
        int left = 0;
        int right = nums.length -1;

        // 循环条件 left <= right
        while(left <= right){
            
            //中点计算 
            int mid = left + (right-left)/2;

            if(nums[mid] == target){
                return mid;
            }

            if(nums[mid] < target){
                left = mid +1;
            }else{
                right = mid -1;
            }
        }
        return -1;
    }
}

另外78. 子集也是很经典的回溯题。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

tataCrayon|啾啾

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

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

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

打赏作者

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

抵扣说明:

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

余额充值