算法思想——LeetCode

算法思想

递归

递归指的是在函数的定义中使用函数自身的方法
递归需要注意的三个点:
1.明确递归终止条件;
2.给出递归终止时的处理办法;
3.提取重复的逻辑,缩小问题规模

楼梯问题

假设你正在爬楼梯。需要 n 阶你才能到达楼顶。
每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?


    /**
     *
     * 假设你正在爬楼梯。需要 n 阶你才能到达楼顶。
     * 每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?
     * 注意:给定 n 是一个正整数。
     *
     * 示例 1:
     * 输入: 2
     * 输出: 2
     * 解释: 有两种方法可以爬到楼顶。
     * 1.  1 阶 + 1 阶
     * 2.  2 阶
     * 
     * 示例 2:
     * 输入: 3
     * 输出: 3
     * 解释: 有三种方法可以爬到楼顶。
     * 1.  1 阶 + 1 阶 + 1 阶
     * 2.  1 阶 + 2 阶
     * 3.  2 阶 + 1 阶
     *
     */
    public static int solution1(int stairs) {
        if (stairs == 1) {
            return 1;
        } else if (stairs == NUM) {
            return 2;
        }

        return solution1(stairs - 1) + solution1(stairs - NUM);
    }

solution1最大的问题是大量的重复计算问题,优化一下。


    public static int solution2(int stairs) {
        if (stairs == 1) {
            return 1;
        } else if (stairs == NUM){
            return NUM;
        }
        int res = 2;
        int pre = 1;
        int tmp;
        for (int i = 3; i <= stairs; i++) {
            tmp = res;
            res = res + pre;
            pre = tmp;
        }

        return res;
    }

solution2并不是递归方式,最好的方法是将已经计算过的值缓存起来,后续需要就直接从缓存中读取即可


    public static int[] cache = new int[1];
    /**
     * 记忆法
     * @param stairs
     * @return
     */
    public static int solution3(int stairs) {
        if (stairs == 1) {
            return 1;
        }
        if (stairs == NUM) {
            return 2;
        }
        if (cache.length < stairs) {
            cache = new int[stairs + 1];
            cache[1] = 1;
            cache[2] = 2;
        }

        if (cache[stairs] != 0) {
            return cache[stairs];
        }

        int result = solution3(stairs - 1) + solution3(stairs - 2);
        cache[stairs] = result;

        return result;
    }

回溯法

枚举法的一种,但是,回溯法能够找到所有(或一部分)解的一般性算法,同时避免不必要的情况。一旦出现不正确的情况,就不再递归到下一层,而是回溯到上一层,是一种走不通就回退的方式。

78.子集Subsets

给定一组不含重复元素的整数数组 nums,返回该数组所有可能的子集(幂集)。
说明:解集不能包含重复的子集。

示例:
输入: nums = [1,2,3]
输出:
[
[3],
[1],
[2],
[1,2,3],
[1,3],
[2,3],
[1,2],
[]
]


    /**
     *
     * 回溯思想
     * 参考labuladong
     * 回溯算法的模板:
     * result = []
     * def backtrack(路径, 选择列表):
     *     if 满足结束条件:
     *         result.add(路径)
     *         return
     *     for 选择 in 选择列表:
     *         做选择
     *         backtrack(路径, 选择列表)
     *         撤销选择
     * @param nums
     * @return
     */
    public static List<List<Integer>> solution3(List<Integer> nums) {
        List<List<Integer>> res = new ArrayList<>();
        backTrack(res, 0, nums, new ArrayList<>());
        return res;
    }

    private static void backTrack(List<List<Integer>> res, int i, List<Integer> nums, List<Integer> track) {
        res.add(new ArrayList<>(track));
        for (int j = i; j < nums.size(); j++) {
            track.add(nums.get(j));
            backTrack(res, j + 1, nums, track);
            track.remove(track.size() - 1);
        }
    }

动态规划

宝石挑选

一共有n块宝石,每块宝石有其对应的价值,由于,某些宝石质量过于差劲,因此存在只有店家倒贴钱,才愿意带走的宝石,即价值可为负数。
可以免费带走一个连续区间的宝石,比如区间[1,3]或者区间[2,4]中的宝石。
如何能带走最大价值的宝石?

问题分析
首先,思考最暴力的解法。
枚举所有区间,暴力累加区间中宝石的价值,最后选取价值最大的区间,时间复杂度O(n^3)


    /**
     * 一共有n块宝石,每块宝石有其对应的价值,由于,某些宝石质量过于差劲,因此存在只有店家倒贴钱,才愿意带走的宝石,即价值可为负数。
     * 可以免费带走一个连续区间的宝石,比如区间[1,3]或者区间[2,4]中的宝石。
     * 如何能带走最大价值的宝石?
     *
     * input: [1, -2, 4, -1, 6, 2]
     * out: 11
     *
     * @return
     */
    public static int solution1(int[] jewels) {
        int max = 0;
        for (int i = 1; i < jewels.length; i++) {
            for (int j = 0; j < jewels.length; j++) {
                int k = i;
                int worth = 0;
                while (k > 0) {
                    if (j + k <= jewels.length) {
                        worth += jewels[j + k - 1];
                        k--;
                    } else {
                        break;
                    }
                }
                if (worth > max) {
                    max = worth;
                }
            }
        }
        return max;
    }

优化1.0
进行累加方式,固定从左到右计算区间中宝石的价值,可将时间复杂度优化到O(n^2)


   public static int solution2(int[] jewels) {
        int max = 0;
        for (int i = jewels.length - 1; i > 0; i--) {
            for (int j = 0; j < i; j++) {
                int worth = Arrays.stream(jewels, j, i + 1).sum();
                if (worth > max) {
                    max = worth;
                }
            }
        }
        return max;
    } 

优化2.0
观察O(n^2)的解法,发现用了O(n)的时间复杂度求出固定某个点为区间的右端点时的区间最大价值和,即O(n)的时间复杂度
「以 n 为区间右端点的区间最大和」,与「以 n - 1 为区间右端点的区间最大和」,这两者是否有关联呢?
为了描述方便,接下来我们用 f[i] 来代替「以 i 为区间右端点的区间最大和」,用 a[i] 来代替第 i 块宝石的价值。
不难发现,如果 f[n - 1] 为正数,则 f[n] 一定等于 f[n - 1] + a[n];如果 f[n - 1] 为负数,则 f[n] 一定等于 a[n]。因此我们可以推导出如下转移方程:
f[i] = max(f[i - 1] + a[i], a[i])
根据上述转移方程,我们可以在 O(n) 时间复杂度内求出最大的 f[i],即将此题时间复杂度优化到 O(n),而这个优化的过程就是「动态规划」的过程
在上述推导过程中,一共分为两步:

  1. 将整个问题划分为一个个子问题,并令 f[i] 为第 i 个子问题的答案
  2. 思考大规模的子问题如何从小规模的子问题推导而来,即如何由 f[i - 1] 推出 f[i]
    这两个步骤便是「动态规划」解题思路的核心所在,即确定动态规划时的「状态」与「转移方程」。

    /**
     * @param jewels
     * @return
     */
    public static int solution3(int[] jewels) {
        int max = 0;
        int mid = 0;

        for (int i = 0; i < jewels.length; i++) {
            mid += jewels[i];
            max = Math.max(mid, max);
            mid = mid < 0 ? 0 : mid;
        }
        return max;
    }

    /**
     * 获取对应区间
     * @param jewels
     * @return
     */
    public static int[] solution4(int[] jewels) {
        int[] dp = new int[2];
        int max = 0;
        int mid = 0;

        for (int i = 0; i < jewels.length; i++) {
            mid += jewels[i];
            if (max < mid) {
                max = mid;
                dp[1] = i;
            }
            if (mid < 0) {
                dp[0] = i + 1;
                mid = 0;
            }
        }
        return dp;
    }

对比三种解法,可以看出,第一和第二种解法基于问题解决问题,而基于动态规划方式思考,可以在不同角度上思考问题,而不是双眼紧盯着问题不放。
上述的宝石问题其实是LeetCode的最大子序和问题的不同表述方式——Maximum Subarray

动态规划概述

动态规划解题思路
动态规划主要分为两个核心部分,一是确定「DP 状态」,二是确定「DP 转移方程」。

DP 状态
「DP 状态」的确定主要有两大原则:
1.最优子结构
2.无后效性

最优子结构
我们仍以「宝石挑选」例题来讲解这两大原则,首先是「最优子结构」。
什么是「最优子结构」?将原有问题化分为一个个子问题,即为子结构。而对于每一个子问题,其最优值均由「更小规模的子问题的最优值」推导而来,即为最优子结构。
因此「DP 状态」设置之前,需要将原有问题划分为一个个子问题,且需要确保子问题的最优值由「更小规模子问题的最优值」推出,此时子问题的最优值即为「DP 状态」的定义。
例如在「宝石挑选」例题中,原有问题是「最大连续区间和」,子问题是「以 i 为右端点的连续区间和」。并且「以 i 为右端点的最大连续区间和」由「以 i - 1 为右端点的最大连续区间和」推出,此时后者即为更小规模的子问题,因此满足「最优子结构」原则。
由此我们才定义 DP 状态 f[i] 表示子问题的最优值,即「以 i 为右端点的最大连续区间和」。

无后效性
而对于「无后效性」,顾名思义,就是我们只关心子问题的最优值,不关心子问题的最优值是怎么得到的。
仍以「宝石挑选」例题为例,我们令 DP 状态 f[i] 表示「以 i 为右端点的最大连续区间和」,我们只关心「以 i 为右端点的区间」这个子问题的最优值,并不关心这个子问题的最优值是从哪个其它子问题转移而来。
即无论 f[i] 所表示区间的左端点是什么,都不会影响后续 f[i + 1] 的取值。影响 f[i + 1] 取值的只有 f[i] 的数值大小。
那怎样的状态定义算「有后效性」呢?
我们对「宝石挑选」例题增加一个限制,即小 Q 只能挑选长度 <= k 的连续区间。此时若我们定义 f[i] 表示「以 i 为右端点的长度 <= k 的最大连续区间和」,则 f[i + 1] 的取值不仅取决于 f[i] 的数值,还取决于 f[i] 是如何得到的。
因为如果 f[i] 取得最优值时区间长度 = k,则 f[i + 1] 不能从 f[i] 转移得到,即 f[i] 的状态定义有后效性。
最后概括一下,「最优子结构」就是「DP 状态最优值由更小规模的 DP 状态最优值推出」,此处 DP 状态即为子问题。而「无后效性」就是「无论 DP 状态是如何得到的,都不会影响后续 DP 状态的取值」。

DP 转移方程
有了「DP 状态」之后,我们只需要用「分类讨论」的思想来枚举所有小状态向大状态转移的可能性即可推出「DP 转移方程」。
我们继续以「宝石挑选」问题为例。
在我们定义「DP 状态」f[i] 之后,我们考虑状态 f[i] 如何从 f[1] ~ f[i - 1] 这些更小规模的状态转移而来。
仔细思考可以发现,由于 f[i] 表示的是连续区间的和,因此其取值只与 f[i - 1] 有关,与 f[1] ~ f[i - 2] 均无关。
我们再进一步思考,f[i] 取值只有两种情况,一是向左延伸,包含 f[i - 1],二是不向左延伸,仅包含 a[i],由此我们可以得到下述「DP 转移方程」:
f[i] = max(f[i - 1] + a[i])
注意,i属于[1, n],且f[0] = 0

动态规划问题类别
讲述完 DP 问题的解题思路后,我们来大致列举一下 DP 问题的类别。

DP 问题主要分为两大类,第一大类是 DP 类型,第二大类是 DP 优化方法。
动态规划问题类别
其中在 DP 类型部分,面试中最常考察的就是「线性 DP」,而在优化方法部分,最常见的是「RMQ 优化」,即使用线段树或其它数据结构查询区间最小值,来优化 DP 的转移过程

64 最小路径和-Minimum Path Sum


    /**
     *
     * 给定一个包含非负整数的 m x n 网格,请找出一条从左上角到右下角的路径,
     * 使得路径上的数字总和为最小。
     * 说明:每次只能向下或者向右移动一步。
     *
     * 示例:
     * 输入:
     * [
     *   [1,3,1],
     *   [1,5,1],
     *   [4,2,1]
     * ]
     * 输出: 7
     * 解释: 因为路径 1→3→1→1→1 的总和最小。
     *
     * 每一个格子的路径来源只有自己的上方和左方,状态方程为
     * f[i][j] = min(f[i - 1][j], f[i][j - 1])
     * 
     */
    public static int solution1(int[][] nums) {
        int[][] dp = new int[nums.length][nums[0].length];
        dp[0][0] = nums[0][0];
        for (int i = 1; i < nums.length; i++) {
            dp[i][0] = nums[i][0] + dp[i - 1][0];
        }

        for (int i = 1; i < nums[0].length; i++) {
            dp[0][i] = nums[0][i] + dp[0][i - 1];
        }

        for (int i = 1; i < nums.length; i++) {
            for (int j = 1; j < nums[0].length; j++) {
               dp[i][j] = nums[i][j] + Math.min(dp[i - 1][j], dp[i][j - 1]);
            }
        }

        return dp[nums.length - 1][nums[0].length - 1];
    }

152 乘积最大子数组-Maximum Product Subarray


    /**
     *
     * 给定一个整数数组 nums ,找出一个序列中乘积最大的连续子序列(该序列至少包含一个数)。
     *
     * 示例 1:
     * 输入: [2,3,-2,4]
     * 输出: 6
     * 解释: 子数组 [2,3] 有最大乘积 6。
     *
     * 示例 2:
     * 输入: [-2,0,-1]
     * 输出: 0
     * 解释: 结果不能为 2, 因为 [-2,-1] 不是子数组。
     * 状态方程 Math.max(f(n - 1), nums[i]) * nums[i]
     * 当然,乘法的原因,需要考虑其他情况
     *
     */
    public static int solution1(int[] nums) {
        int max = nums[0];
        int c = 1;
        int m = 0;
        for (int i = 0; i < nums.length; i++) {
            if (nums[i] < 0 && m != 0) {
                c = nums[i] * m;
            } else {
                c *= nums[i];
                m *= nums[i];
            }

            max = Math.max(max, c);
            m = c <= 0 ? c : m;
            c = c > 0 ? c : 1;
        }
        return max;
    }

    public static void main(String[] args) {
        // -1 , 2, 3, 0, -2, 4
        // -2, 0, -1
        // -2, 2, -3, 0, -3, -1
        // 2, 3, -2, 4
        int[] nums = {1 , 2, 3, 0, -2, 4};
        System.out.println(solution1(nums));
    }

最后总结一下 DP 问题的解题思路:

  • 确定「DP 状态」

    • 符合「最优子结构」原则:DP 状态最优值由更小规模的 DP 状态最优值推出
    • 符合「无后效性」原则:状态的得到方式,不会影响后续其它 DP 状态取值
  • 确定「DP 转移方程」

    • 分类讨论,细心枚举

遗传算法

遗传算法是一种特殊的搜索技巧,适合处理多变量与非线性问题。

PS:后续会完善各种算法及其对应的LeetCode题型的讲解总结,一边刷题,一边总结还是挺慢的,暂时只是将拙劣的解法写下来,mark一下,后续更改和修补一番

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值