动态规划(最高阶)

本文探讨了序列动态规划的典型问题,如最长上升子序列的递推分析、如何在打家劫舍系列中利用动态规划避免警报,以及打家劫舍plus的策略调整。通过实例和代码实现,理解如何根据子序列性质拆解问题,并优化时间复杂度。
摘要由CSDN通过智能技术生成


前言

上一篇文章 我们分析了矩阵类动态规划,说到这类动态规划通常在一个矩阵中进行,我们只需要考虑当前位置的信息即可,分析并定义状态的时候,也只需要分析当前位置和其相邻位置的关系,通常这样做就可以达到拆解问题的目的。

这次再来看一类动态规划问题,序列类动态规划问题,这类动态规划问题较为普遍,分析难度相比之前也略有提升,通常问题的输入参数会涉及数组或是字符串。

在开始之前,先解释一下子数组(子串)和子序列的区别,你可以看看下面这个例子:

输入数组:[1,2,3,4,5,6,7,8,9]
子数组:[2,3,4], [5,6,7], [6,7,8,9], ...
子序列:[1,5,9], [2,3,6], [1,8,9], [7,8,9], ...

可以看到的是,子数组必须是数组中的一个连续的区间,而子序列并没有这样一个要求

你只需要保证子序列中的元素的顺序和原数组中元素的顺序一致即可,例如,在原数组中,元素 1 出现在元素 9 之前,那么在子序列中,如果这两个元素同时出现,那么 1 也必须在 9 之前。

为什么要说这个?

不知道你有没有发现,这里的子数组的问题和我们前面提到的矩阵类动态规划的分析思路很类似,只需要考虑当前位置,以及当前位置和相邻位置的关系。

通过这样的分析就可以把之前讲的内容和今天要介绍的内容关联起来了,相比矩阵类动态规划,序列类动态规划最大的不同在于,对于第 i 个位置的状态分析,它不仅仅需要考虑当前位置的状态,还需要考虑前面 i – 1 个位置的状态,这样的分析思路其实可以从子序列的性质中得出。

对于这类问题的问题拆解,有时并不是那么好发现问题与子问题之间的联系,但是通常来说思考的方向其实在于 寻找当前状态和之前所有状态的关系,我们通过几个非常经典的动态规划问题来一起看看。

一、题目分析

1.最长上升子序列

题目描述:

给定一个无序的整数数组,找到其中最长上升子序列的长度。

示例

输入: [10,9,2,5,3,7,101,18]输出: 4 解释: 最长的上升子序列是 [2,3,7,101],它的长度是 4

说明:

  • 可能会有多种最长上升子序列的组合,你只需要输出对应的长度即可。
  • 你算法的时间复杂度应该为 O(n2) 。

进阶: 你能将算法的时间复杂度降低到 O(n log n) 吗?

题目解析

给定一个数组,求最长递增子序列。因为是子序列,这样对于每个位置的元素其实都存在两种可能,就是选和不选,如果我们用暴力的解法,枚举出所有的子序列,然后判断他们是不是递增的,选取最大的递增序列,这样做的话,时间复杂度是 O(2^n),显然不高效。

那这里我们就需要思考用动态规划进行优化,我们按之前的四个步骤来具体分析一下:

  1. 问题拆解
    我们要求解的问题是 “数组中最长递增子序列”,一个子序列虽然不是连续的区间,但是它依然有起点和终点,比如:
    [10,9,2,5,3,7,101,18]
    子序列 [2,3,7,18] 的起始位置是 2,终止位置是 18
    子序列 [5,7,101] 的起始位置是 5,终止位置是 101
    如果我们确定终点位置,然后去 看前面 i – 1 个位置中,哪一个位置可以和当前位置拼接在一起,这样就可以把第 i 个问题拆解成思考之前 i – 1 个问题,注意这里我们并不是不考虑起始位置,在遍历的过程中我们其实已经考虑过了。

  2. 状态定义
    问题拆解中我们提到 “第 i 个问题和前 i – 1 个问题有关”,也就是说 “如果我们要求解第 i 个问题的解,那么我们必须考虑前 i – 1 个问题的解”,我们定义 dp[i] 表示以位置 i 结尾的子序列的最大长度,也就是说 dp[i] 里面记录的答案保证了该答案表示的子序列以位置 i 结尾。

  3. 递推方程
    对于 i 这个位置,我们需要考虑前 i – 1 个位置,看看哪些位置可以拼在 i 位置之前,如果有多个位置可以拼在 i 之前,那么必须选最长的那个,这样一分析,递推方程就有了:
    dp[i] = Math.max(dp[j],…,dp[k]) + 1,
    其中 inputArray[j] < inputArray[i], inputArray[k] < inputArray[i]

  4. 实现
    在实现这里,我们需要考虑状态数组的初始化,因为对于每个位置,它本身其实就是一个序列,因此所有位置的状态都可以初始化为 1。

最后提一下,对于这道题来说,这种方法其实不是最优的,但是在这里的话就不展开讲了,理解序列类动态规划的解题思路是关键。

参考代码
public int lengthOfLIS(int[] nums) {
    if (nums == null || nums.length == 0) {
        return 0;
    }

    // dp[i] -> the longest length sequence from 0 - i, and must include nums[i]
    int[] dp = new int[nums.length];

    Arrays.fill(dp, 1);

    int max = 0;

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

        max = Math.max(max, dp[i]);
    }

    return max;
}

2.打家劫舍

题目描述

你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。

给定一个代表每个房屋存放金额的非负整数数组,计算你在不触动警报装置的情况下,能够偷窃到的最高金额。

示例 1:

输入: [1,2,3,1]输出: 4解释: 偷窃 1 号房屋 (金额 = 1) ,然后偷窃 3 号房屋 (金额 = 3)。
     偷窃到的最高金额 = 1 + 3 = 4

示例 2:


输入: [2,7,9,3,1]输出: 12解释: 偷窃 1 号房屋 (金额 = 2), 偷窃 3 号房屋 (金额 = 9),接着偷窃 5 号房屋 (金额 = 1)。
     偷窃到的最高金额 = 2 + 9 + 1 = 12
题目解析
  1. 问题拆解
    如果我们要求解抢完 n 个房子所获得的最大收入,因为题目的要求,我们可以思考第 i 个房子是否应该抢,如果要抢,那么第 i – 1 个房子就不能抢,我们只能考虑抢第 i – 2 个房子。如果不抢,那么就可以抢第 i – 1 个房子,这样一来,第 i 个房子就和第 i – 1 个房子,以及第 i – 2 个房子联系上了。

  2. 状态定义
    通过之前的问题拆解,我们知道,如果我们从左到右去抢房子,抢到当前房子可以获得的最大值其实是和抢到前两个房子可以获得的最大值有关,因此我们可以用 dp[i] 表示抢到第 i 个房子可以获得的最大值

  3. 递推方程
    如果我们抢第 i 个房子,那么我们就只能去考虑第 i – 2 个房子,如果不抢,那么我们可以考虑第 i – 1 个房子,于是递推方程就有了:
    dp[i] = Math.max(dp[i - 2] + nums[i], dp[i - 1])

  4. 实现
    因为第 i 个位置和前面的两个位置都有关,这个时候我们可以把状态多开一格,dp[0] 表示的是一个房子都不抢的状态,dp[1] 就是最左边的房子获得的最大价值,这个房子之前也没有其他的房子,直接抢即可。

代码实现
public int rob(int[] nums) {
    if (nums == null || nums.length == 0) {
        return 0;
    }

    int n = nums.length;

    int[] dp = new int[n + 1];

    dp[1] = nums[0];

    for (int i = 2; i <= n; ++i) {
        dp[i] = Math.max(dp[i - 1], dp[i - 2] + nums[i - 1]);
    }

    return dp[n];
}

3.打家劫舍plus

题目描述

你是一个专业的小偷,计划偷窃沿街的房屋,每间房内都藏有一定的现金。这个地方所有的房屋都 围成一圈,这意味着第一个房屋和最后一个房屋是紧挨着的。同时,相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。

给定一个代表每个房屋存放金额的非负整数数组,计算你在不触动警报装置的情况下,能够偷窃到的最高金额。

题目解析

前面那道题目的 follow up,问的是如果这些房子的排列方式是一个圆圈,其余要求不变,问该如何处理。

房子排列方式是一个圆圈意味着之前的最后一个房子和第一个房子之间产生了联系,这里有一个小技巧就是我们线性考虑 [0, n – 2] 和 [1, n – 1],然后求二者的最大值。

其实这么做的目的很明显,把第一个房子和最后一个房子分开来考虑。实现上面我们可以直接使用之前的实现代码。

这里有一个边界条件就是,当只有一个房子的时候,我们直接输出结果即可。

代码实现
public int rob(int[] nums) {
    if (nums == null || nums.length == 0) {
        return 0;
    }

    if (nums.length == 1) {
        return nums[0];
    }

    int n = nums.length;

    return Math.max(
                    robI(Arrays.copyOfRange(nums, 0, n - 1)),
                    robI(Arrays.copyOfRange(nums, 1, n))
                   );
}

public int robI(int[] nums) {
    if (nums == null || nums.length == 0) {
        return 0;
    }

    int n = nums.length;

    int[] dp = new int[n + 1];

    dp[1] = nums[0];

    for (int i = 2; i <= n; ++i) {
        dp[i] = Math.max(dp[i - 1], dp[i - 2] + nums[i - 1]);
    }

    return dp[n];
}

总结

序列类动态规划的系列问题还有很多,比如股票问题,这类问题通常会给你一个数组或者是字符串,在分析这些问题的时候,需要思考当前状态的选择是否要基于前面的状态,以及他们的关系是什么。

当然这里还有挺多的优化,比如动态规划的状态数组的空间优化,这些会在后面统一介绍,这里只需要熟悉动态规划的思考方向和方法即可。

转载:五分钟学算法

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值