【动态规划】深入动态规划 非连续子序列问题

文章目录


前言

什么是动态规划中的非连续子序列问题?

动态规划中的非连续子序列问题是指在一个给定的序列中,寻找满足特定条件的非连续子序列,以达到某种最优目标。

例如,在最长递增子序列(LIS)问题中,给定一个整数序列,需要找出一个子序列,使得该子序列中的元素是递增的,且子序列的长度最长。这个子序列中的元素在原序列中不一定是连续的。

解决这类问题通常使用动态规划的方法。以最长递增子序列问题为例,定义状态dpi表示以第i个元素结尾的最长递增子序列的长度。初始时,每个dpi都设为 1,因为单个元素本身就是一个长度为 1 的递增子序列。然后,对于每个i,遍历0到i - 1的元素j,如果numsj < numsi,说明可以将numsi添加到以numsj结尾的递增子序列后面,更新dpi = max(dpi, dpj + 1)。最后,在所有的dpi中找出最大值,就是整个序列的最长递增子序列的长度。通过这种方式,利用已解决的子问题的解来构建更大问题的解,避免了大量的重复计算,提高了算法效率。

下面,本文将以例题的形式为大家详细展开讲述动态规划里的非连续子序列问题!

例题

一、最长递增子序列

  1. 题目链接:最长递增子序列
  2. 题目描述:

给你一个整数数组 nums ,找到其中最长严格递增子序列的长度。 子序列是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。例如,3,6,2,7 是数组 0,3,1,6,2,2,7 的子序列。

示例 1:

输入:nums = 10,9,2,5,3,7,101,18

输出:4

解释:最长递增子序列是 2,3,7,101,因此长度为 4 。

示例 2:

输入:nums = 0,1,0,3,2,3

输出:4

示例 3:

输入:nums = 7,7,7,7,7,7,7

输出:1

提示:1 <= nums.length <= 2500

-10^4 <= numsi <= 10^4

  1. 解法(动态规划):
    算法思路:
    状态表示: 对于线性 dp ,我们可以用「经验 + 题目要求」来定义状态表示:
    i. 以某个位置为结尾,巴拉巴拉;
    ii. 以某个位置为起点,巴拉巴拉。这里我们选择比较常用的方式,以某个位置为结尾,结合题目要求,定义⼀个状态表示:
    dpi 表示:以 i 位置元素为结尾的「所有子序列」中,最长递增子序列的长度。
    状态转移方程: 对于 dpi ,我们可以根据「⼦序列的构成方式」,进行分类讨论:
    i. 子序列长度为 1 :只能自己玩了,此时 dpi = 1 ;
    ii. 子序列长度大于 1 : numsi 可以跟在前面任何⼀个数后面形成子序列。 设前面的某⼀个数的下标为 j ,其中 0 <= j <= i - 1 。 只要 numsj < numsi , i 位置元素跟在 j 元素后⾯就可以形成递增序列,长度 为 dpj + 1 。 因此,我们仅需找到满足要求的最大的 dpj + 1 即可。 综上, dpi = max(dpj + 1, dpi) ,其中 0 <= j <= i - 1 && numsj < numsi
    初始化: 所有的元素「单独」都能构成⼀个递增⼦序列,因此可以将 dp 表内所有元素初始化为 1 。 由于用到前面的状态,因此我们循环的时候从第⼆个位置开始即可。
    填表顺序: 显而易见,填表顺序「从左往右」。
    返回值: 由于不知道最长递增子序列以谁结尾,因此返回 dp 表里面的「最大值」

4 代码示例:

clike 复制代码
 public int lengthOfLIS(int[] nums) {
        int n =nums.length;
        int[] dp = new int[n];
        int ret = 1;
        for(int i = 0;i<n;i++)  dp[i] = 1;
        for(int i = 1;i<n;i++){
            for(int j = 0;j<i;j++){
                if(nums[j] < nums[i]){
                    dp[i] = Math.max(dp[j] + 1,dp[i]);
                }
            }
            ret = Math.max(dp[i],ret);
        }
        return ret;
    }

二、摆动序列

  1. 题目链接:摆动序列
  2. 题目描述:

如果连续数字之间的差严格地在正数和负数之间交替,则数字序列称为 摆动序列 。第⼀个差(如果 存在的话)可能是正数或负数。仅有⼀个元素或者含两个不等元素的序列也视作摆动序列。

• 例如, 1, 7, 4, 9, 2, 5 是⼀个 摆动序列 ,因为差值 (6, -3, 5, -7, 3) 是正负交替出现的。

• 相反,1, 4, 7, 2, 51, 7, 4, 5, 5 不是摆动序列,第⼀个序列是因为它的前两个差值都是正数,第二个序列是因为它的最后⼀个差值为零。 子序列可以通过从原始序列中删除⼀些(也可以不删除)元素来获得,剩下的元素保持其原始顺序。 给你⼀个整数数组 nums ,返回 nums 中作为 摆动序列 的 最长子序列的⻓度 。

示例 1:

输入:nums = 1,7,4,9,2,5

输出:6

解释:整个序列均为摆动序列,各元素之间的差值为 (6, -3, 5, -7, 3) 。

示例 2:

输入:nums = 1,17,5,10,13,15,10,5,16,8

输出:7

解释:这个序列包含几个长度为 7 摆动序列。 其中⼀个是 1, 17, 10, 13, 10, 16, 8 ,各元素之间的差值为 (16, -7, 3, -3, 6, -8) 。

示例 3:

输入:nums = 1,2,3,4,5,6,7,8,9

输出:2

  1. 解法(动态规划):

    算法思路:

    状态表示: 对于线性 dp ,我们可以用「经验 + 题目要求」来定义状态表示:

    i. 以某个位置为结尾,巴拉巴拉;

    ii. 以某个位置为起点,巴拉巴拉。 这里我们选择比较常用的方式,以某个位置为结尾,结合题目要求,定义⼀个状态表示:

    dpi 表示「以 i 位置为结尾的最长摆动序列的长度」。

    但是,问题来了,如果状态表示这样定义的话,以 i 位置为结尾的最长摆动序列的长度我们没法从之前的状态推导出来。因为我们不知道前⼀个最⻓摆动序列的结尾处是递增的,还是递减的。因 此,我们需要状态表⽰能表⽰多⼀点的信息:要能让我们知道这⼀个最⻓摆动序列的结尾是递增的 还是递减的。 解决的⽅式很简单:搞两个 dp 表就好了。

    fi 表示:以 i 位置元素为结尾的所有的子序列中,最后⼀个位置呈现「上升趋势」的最长摆动序列的长度;

    gi 表示:以 i 位置元素为结尾的所有的子序列中,最后⼀个位置呈现「下降趋势」的最长摆动序列的长度。

    状态转移⽅程: 由于⼦序列的构成⽐较特殊, i 位置为结尾的子序列,前一个位置可以是 0, i - 1 的任意 位置,因此设 j 为 0, i - 1 区间内的某⼀个位置。 对于 fi ,我们可以根据「子序列的构成方式」,进行分类讨论:

    i. 子序列长度为 1 :只能自己玩了,此时 fi = 1 ;

    ii. 子序列长度大于 1 :因为结尾要呈现上升趋势,因此需要 numsj < numsi 。在满足这个条件下, j 结尾需要呈现下降状态,最⻓的摆动序列就是 gj + 1 。 因此我们要找出所有满足条件下的最⼤的 gj + 1 。 综上, fi = max(gj + 1, fi) ,注意使⽤ gj 时需要判断。

    对于 gi ,我们可以根据「子序列的构成⽅式」,进⾏分类讨论:

    i. ⼦序列长度为 1 :只能自己玩了,此时 gi = 1 ;

    ii. ⼦序列长度大于 1 :因为结尾要呈现下降趋势,因此需要 numsj > numsi 。在满足这个条件下, j 结尾需要呈现上升状态,因此最长的摆动序列就是 fj + 1 。 因此我们要找出所有满⾜条件下的最⼤的 fj + 1 。 综上, gi = max(fj + 1, gi) ,注意使⽤ fj 时需要判断。

    初始化: 所有的元素「单独」都能构成⼀个摆动序列,因此可以将 dp 表内所有元素初始化为 1 。

    填表顺序: 毫无疑问是「从左往右」。

    返回值: 应该返回「两个 dp 表里面的最大值」,我们可以在填表的时候,顺便更新⼀个「最大值」。

  2. 代码示例:

clike 复制代码
  public int wiggleMaxLength(int[] nums) {
        int n = nums.length;
        int[] f = new int[n];
        int[] g = new int[n];
        int ret = 1;
        for(int i = 0;i<n;i++) g[i] = f[i] = 1;
        for(int i = 1;i<n;i++){
            for(int j = 0;j<i;j++){
                if(nums[j]>nums[i]) f[i] = Math.max(f[i] , g[j]+1);
                if(nums[j]<nums[i]) g[i] = Math.max(f[j]+1,g[i]);
            }
            ret = Math.max(ret,Math.max(f[i],g[i]));
        }
        return ret;
    }

三、最长递增子序列的个数

  1. 题目链接:最长递增子序列的个数
  2. 题目描述:

给定⼀个未排序的整数数组 nums , 返回最长递增子序列的个数 。 注意 这个数列必须是严格递增的。

示例 1:

输入: 1,3,5,4,7

输出: 2 解释:

有两个最长递增子序列,分别是 1, 3, 4, 71, 3, 5, 7

示例 2:

输⼊: 2,2,2,2,2

输出: 5 解释:最长递增子序列的长度是1,并且存在5个子序列的长度为1,因此输出5。

  1. 解法(动态规划):

    算法思路:

    状态表示: 先尝试定义⼀个状态:以 i 为结尾的最长递增子序列的「个数」。那么问题就来了,我都不知道 以 i 为结尾的最⻓递增子序列的「长度」是多少,我怎么知道最长递增子序列的个数呢? 因此,我们解决这个问题需要两个状态,一个是「长度」,⼀个是「个数」:

    leni 表示:以 i 为结尾的最长递增子序列的⻓度;

    counti 表示:以 i 为结尾的最长递增子序列的个数。

    状态转移方程: 求个数之前,我们得先知道长度,因此先看 leni

    i. 在求 i 结尾的最长递增序列的⻓度时,我们已经知道 0, i - 1 区间上的 lenj信息,用 j 表示 0, i - 1 区间上的下标;

    ii. 我们需要的是递增序列,因此 0, i - 1 区间上的 numsj 只要能和 numsi

    构成上升序列,那么就可以更新 dpi 的值,此时最长长度为 dpj + 1 ;

    iii. 我们要的是 0, i - 1 区间上所有情况下的最⼤值。 综上所述,对于 leni ,我们可以得到状态转移⽅程为:leni = max(lenj + 1, leni) ,其中 0 <= j < i ,并且 numsj < numsi 。 在知道每⼀个位置结尾的最⻓递增⼦序列的⻓度时,我们来看看能否得到 counti

    i. 我们此时已经知道 leni 的信息,还知道 0, i - 1 区间上的 countj 信 息,用 j 表示 0, i - 1 区间上的下标;

    ii. 我们可以再遍历⼀遍 0, i - 1 区间上的所有元素,只要能够构成上升序列,并且上

    升序列的长度等于 dpi ,那么我们就把 counti 加上 countj 的值。这样循 环⼀遍之后, counti 存的就是我们想要的值。

    综上所述,对于 counti ,我们可以得到状态转移⽅程为:

    counti += countj ,其中 0 <= j < i ,并且 numsj < numsi && dpj + 1 == dpi

    初始化: ◦ 对于 leni ,所有元素自己就能构成⼀个上升序列,直接全部初始化为 1 ; ◦ 对于 counti ,如果全部初始化为 1 ,在累加的时候可能会把「不是最大长度的情况」累加进去,因此,我们可以先初始化为 0 ,然后在累加的时候判断⼀下即可。具体操作情况看代 码~

    填表顺序: 毫无疑问是「从左往右」。

    返回值: 用 manLen 表⽰最终的最长递增子序列的长度。 根据题目要求,我们应该返回所有长度等于 maxLen 的子序列的个数。

  2. 代码示例:

clike 复制代码
   public int findNumberOfLIS(int[] nums) {
        // 1. 创建 dp 表
        // 2. 初始化
        // 3. 填表
        // 4. 返回值
        int n = nums.length;
        int[] len = new int[n];
        int[] count = new int[n];
        for (int i = 0; i < n; i++) len[i] = count[i] = 1;
        int retlen = 1, retcount = 1;
        for (int i = 1; i < n; i++) {
            for (int j = 0; j < i; j++) {
                if (nums[j] < nums[i]) {
                    if (len[j] + 1 == len[i]) // 计数
                        count[i] += count[j];
                    //重新计数
                    else if (len[j] + 1 > len[i]){
                        len[i] = len[j] + 1;
                        count[i] = count[j];
                    }
                }
            }
            if (retlen == len[i]) retcount += count[i];
            else if (retlen < len[i]) // 重新计数
            {
                retlen = len[i];
                retcount = count[i];
            }
        }
        return retcount;
    }        

四、最长数对链

  1. 题目链接:最长数对链
  2. 题目描述:

给你⼀个由 n 个数对组成的数对数组 pairs ,其中 pairsi = lefti, righti 且 lefti < righti 。 现在,我们定义⼀种跟随关系,当且仅当 b < c 时,数对 p2 = c, d 才可以跟在 p1 = a, b 后⾯。我们用这种形式来构造数对链 。 找出并返回能够形成的 最长数对链的长度 。 你不需要用到所有的数对,你可以以任何顺序选择其中的⼀些数对来构造。

示例 1:

输入:pairs = \[1,2, 2,3, 3,4]

输出:2

解释:最长的数对链是 1,2 -> 3,4

示例 2:

输入:pairs = \[1,2,7,8,4,5]

输出:3

解释:最长的数对链是 1,2 -> 4,5 -> 7,8

3.解法(动态规划):

算法思路:这道题目让我们在数对数组中挑选出来⼀些数对,组成⼀个呈现上升形态的最长的数对链。像不像我们整数数组中挑选⼀些数,让这些数组成⼀个最长的上升序列?因此,我们可以把问题转化成我们之前的⼀个模型: 最长递增子序列。因此我们解决问题的⽅向,应该在「最长递增子序列」这个模型上。 不过,与整形数组有所区别。在用动态规划结局问题之前,应该先把数组排个序。因为我们在计算dpi 的时候,要知道所有左区间比 pairsi 的左区间小的链对。排完序之后,只用「往前遍历⼀遍」即可。

状态表示:dpi 表示以 i 位置的数对为结尾时,最长数对链的长度。

状态转移方程: 对于 dpi ,遍历所有 0, i - 1 区间内数对用 j 表⽰下标,找出所有满足pairsj1 < pairsi0 的 j 。找出里面最大的 dpj ,然后加上 1 ,就是以 i 位置为结尾的最长数对链。

初始化: 刚开始的时候,全部初始化为 1 。

填表顺序: 根据「状态转移⽅程」,填表顺序应该是「从左往右」。

返回值: 根据「状态表示」,返回整个 dp 表中的最大值。

  1. 代码示例:
clike 复制代码
 public int findLongestChain(int[][] pairs) {
        Arrays.sort(pairs, (a, b) -> a[0] - b[0]);
        int n = pairs.length;
        int[] dp = new int[n];
        for(int i = 0;i<n;i++) dp[i] = 1;
        int ret = 1;
        for(int i = 0; i<n; i++){
            for(int j = 0; j < i;j++){
                if(pairs[j][1] < pairs[i][0]){
                    dp[i] = Math.max(dp[j]+1,dp[i]);
                }
                ret = Math.max(ret,dp[i]);
            }
        }
        return ret;
    }

五、最长定差子序列

  1. 题目链接:最长定差子序列
  2. 题目描述:

给你⼀个整数数组 arr 和⼀个整数 difference,请你找出并返回 arr 中最长等差子序列的长度,该子序列中相邻元素之间的差等于 difference 。

子序列 是指在不改变其余元素顺序的情况下,通过删除⼀些元素或不删除任何元素而从 arr 派生出来的序列。

示例 1:

输入:arr = 1,2,3,4, difference = 1

输出:4

解释:最长的等差子序列是 1,2,3,4

示例 2:

输入:arr = 1,3,5,7, difference = 1

输出:1 解释:最长的等差子序列是任意单个元素。

示例 3:

输入:arr = 1,5,7,8,5,3,4,2,1, difference = -2

输出:4

解释:最长的等差子序列是 7,5,3,1

提示: 1 <= arr.length <= 10^5

-10^4 <= arri, difference <= 10^4

  1. 解法(动态规划): 算法思路:

    这道题和 300. 最⻓递增⼦序列 有⼀些相似,但仔细读题就会发现,本题的 arr.lenght 高达10^5 ,使用 O(N^2) 的 lcs 模型⼀定会超时。 那么,它有什么信息是 300. 最长递增子序列 的呢?是定差。之前,我们只知道要递增,不知道前 ⼀个数应当是多少;现在我们可以计算出前⼀个数是多少了,就可以用数值来定义 dp 数组的值,并形成状态转移。这样,就把已有信息有效地利用了起来。

    状态表示:dpi 表示:以 i 位置的元素为结尾所有的子序列中,最长的等差子序列的长度。

    状态转移⽅程: 对于 dpi ,上⼀个定差子序列的取值定为 arri - difference 。只要找到以上⼀个数字为结尾的定差子序列长度的 dparr\[i - difference] ,然后加上 1 ,就是以 i 为结尾的定差子序列的⻓度。 因此,这里可以选择使用哈希表做优化。我们可以把「元素, dpj 」绑定,放进哈希表中。甚至不用创建 dp 数组,直接在哈希表中做动态规划。

    初始化: 刚开始的时候,需要把第一个元素放进哈希表中, hasharr\[0] = 1 。

    填表顺序: 根据「状态转移方程」,填表顺序应该是「从左往右」。

    返回值: 根据「状态表示」,返回整个 dp 表中的最⼤值。

  2. 代码示例:

clike 复制代码
 public int longestSubsequence(int[] arr, int difference) {
        // 创建⼀个哈希表,在哈希表中做 dp
        Map<Integer, Integer> hash = new HashMap<Integer, Integer>();
        //arr[i], dp[i]
        int ret = 1;
        for (int a : arr) {
            hash.put(a, hash.getOrDefault(a - difference, 0) + 1);
            ret = Math.max(ret, hash.get(a));
        }
        return ret;
    }

六、最长的斐波那契子序列的长度

  1. 题目链接:最长的斐波那契子序列的长度
  2. 题目描述:

如果序列 X_1, X_2, ..., X_n 满足下列条件,就说它是斐波那契式 的:

◦ n >= 3

◦ 对于所有 i + 2 <= n,都有 X_i + X_{i+1} = X_{i+2}给定⼀个严格递增的正整数数组形成序列 arr ,找到 arr 中最长的斐波那契式的子序列的长度。如果 ⼀个不存在,返回 0 。 (回想⼀下,⼦序列是从原序列 arr 中派生出来的,它从 arr 中删掉任意数量的元素(也可以不删),而不改变其余元素的顺序。例如, 3, 5, 83, 4, 5, 6, 7, 8 的⼀个子序列)

示例 1:

输入: arr = 1,2,3,4,5,6,7,8

输出: 5

解释: 最长的斐波那契式子序列为 1,2,3,5,8

示例 2:

输入: arr = 1,3,7,11,12,14,18

输出: 3

解释: 最长的斐波那契式子序列有 1,11,123,11,14 以及 7,11,18

提示:3 <= arr.length <= 1000

1 <= arri < arri + 1 <= 10^9

  1. 解法(动态规划):

    算法思路:

    状态表示: 对于线性 dp ,我们可以用「经验 + 题目要求」来定义状态表示:

    i. 以某个位置为结尾,巴拉巴拉;

    ii. 以某个位置为起点,巴拉巴拉。 这里我们选择比较常用的方式,以某个位置为结尾,结合题目要求,定义一个状态表示:

    dpi 表示:以 i 位置元素为结尾的「所有子序列」中,最长的斐波那契子数列的长度。 但是这里有⼀个非常致命的问题,那就是我们⽆法确定 i 结尾的斐波那契序列的样子。这样就会导 致我们无法推导状态转移方程,因此我们定义的状态表示需要能够确定⼀个斐波那契序列。 根据斐波那契数列的特性,我们仅需知道序列里面的最后两个元素,就可以确定这个序列的样⼦。 因此,我们修改我们的状态表示为:

    dpij 表示:以 i 位置以及 j 位置的元素为结尾的所有的子序列中,最长的斐波那契子序列的长度。规定⼀下 i < j 。

    状态转移方程: 设 numsi = b, numsj = c ,那么这个序列的前⼀个元素就是 a = c - b 。我们根 据 a 的情况讨论:

    i. a 存在,下标为 k ,并且 a < b :此时我们需要以 k 位置以及 i 位置元素为结尾的 最⻓斐波那契⼦序列的⻓度,然后再加上 j 位置的元素即可。于是 dpij =

    dpki + 1 ;

    ii. a 存在,但是 b < a < c :此时只能两个元素自己玩了, dpij = 2 ;

    iii. a 不存在:此时依旧只能两个元素自己玩了, dpij = 2 。 综上,状态转移⽅程分情况讨论即可。 优化点:我们发现,在状态转移方程中,我们需要确定 a 元素的下标。因此我们可以在 dp 之 前,将所有的「元素 + 下标」绑定在⼀起,放到哈希表中。

    初始化: 可以将表里面的值都初始化为 2 。

    填表顺序:

    a. 先固定最后⼀个数;

    b. 然后枚举倒数第⼆个数。

    返回值: 因为不知道最终结果以谁为结尾,因此返回 dp 表中的最大值 ret 。 但是 ret 可能小于 3 ,小于 3 的话说明不存在。 因此需要判断⼀下。

  2. 代码示例:

clike 复制代码
  public int lenLongestFibSubseq(int[] nums) {
            Map<Integer,Integer> hash  =new HashMap<>();
            int n = nums.length;
            for(int i = 0;i<n;i++) hash.put(nums[i] , i);
            int[][] dp  = new int[n][n];
            for(int i =0;i<n;i++){
                for(int j =0;j<n;j++){
                    dp[i][j] = 2;
                }
            }
            int ret = 2;
            for(int j = 2; j< n;j++){
                for(int i = 1;i<j;i++){
                    int a =nums[j] - nums[i];
                    if(a<nums[i] && hash.containsKey(a)){
                        dp[i][j]  = dp[hash.get(a)][i] + 1;
                    }
                    ret = Math.max(dp[i][j] , ret);
                } 
            }
            return ret<3?0:ret;
        }

七、最长等差数列

  1. 题目链接:最长等差数列
  2. 题目描述

给你⼀个整数数组 nums,返回 nums 中最⻓等差子序列的长度。 回想⼀下,nums 的子序列是⼀个列表 numsi1,numsi2, ..., numsik ,且 0 <= i1 < i2 < ... < ik<= nums.length - 1。并且如果 seqi+1 - seqi( 0 <= i < seq.length - 1) 的值都相同,那么序 列 seq 是等差的。

示例 1: 输入:nums = 3,6,9,12

输出:4 解释: 整个数组是公差为 3 的等差数列。

示例 2:

输入:nums = 9,4,7,2,10

输出:3

解释:最长的等差⼦序列是 4,7,10

示例 3:

输入:nums = 20,1,15,3,10,5,8

输出:4 解释:最长的等差子序列是 20,15,10,5

提示:2 <= nums.length <= 1000

0 <= numsi <= 500

  1. 解法(动态规划):

    算法思路:

    状态表示: 对于线性 dp ,我们可以⽤「经验 + 题目要求」来定义状态表示:

    i. 以某个位置为结尾,巴拉巴拉;

    ii. 以某个位置为起点,巴拉巴拉。 这⾥我们选择比较常用的⽅式,以某个位置为结尾,结合题目要求,定义⼀个状态表⽰:

    dpi 表示:以 i 位置元素为结尾的「所有子序列」中,最⻓的等差序列的长度。 但是这里有⼀个非常致命的问题,那就是我们⽆法确定 i 结尾的等差序列的样⼦。这样就会导致 我们无法推导状态转移方程,因此我们定义的状态表示需要能够确定⼀个等差序列。 根据等差序列的特性,我们仅需知道序列里面的最后两个元素,就可以确定这个序列的样子。因此,我们修改我们的状态表示为:

    dpij 表示:以 i 位置以及 j 位置的元素为结尾的所有的子序列中,最长的等差序列的长度。规定⼀下 i < j 。

    状态转移方程: 设 numsi = b, numsj = c ,那么这个序列的前⼀个元素就是 a = 2 * b - c 。我们 根据 a 的情况讨论:

    a. a 存在,下标为 k ,并且 a < b :此时我们需要以 k 位置以及 i 位置元素为结尾的最 ⻓等差序列的长度,然后再加上 j 位置的元素即可。于是 dpij = dpki + 1 。这⾥因为会有许多个 k ,我们仅需离 i 最近的 k 即可。因此任何最⻓的都可以以 k 为结尾;

    b. a 存在,但是 b < a < c :此时只能两个元素自己玩了, dpij = 2 ;

    c. a 不存在:此时依旧只能两个元素自己玩了, dpij = 2 。 综上,状态转移方程分情况讨论即可。 优化点:我们发现,在状态转移⽅程中,我们需要确定 a 元素的下标。因此我们可以将所有的元素 + 下标绑定在⼀起,放到哈希表中,这里有两种策略:

    a. 在 dp 之前,放⼊哈希表中。这是可以的,但是需要将下标形成⼀个数组放进哈希表中。这样时间复杂度较高,我帮⼤家试过了,超时。

    b. ⼀边 dp ,⼀边保存。这种方式,我们仅需保存最近的元素的下标,不用保存下标数组。但是用这种方法的话,我们在遍历顺序那里,先固定倒数第⼆个数,再遍历倒数第⼀个数。这样就可以在 i 使用完时候,将 numsi 扔到哈希表中。

    初始化: 根据实际情况,可以将所有位置初始化为 2 。

    填表顺序:

    a. 先固定倒数第二个数;

    b. 然后枚举倒数第⼀个数。

    返回值: 由于不知道最长的结尾在哪里,因此返回 dp 表中的最大值

  2. 代码示例:

clike 复制代码
  public int longestArithSeqLength(int[] nums) {
        // 优化
        Map<Integer, Integer> hash = new HashMap<Integer, Integer>();
        hash.put(nums[0], 0);
        // 建表
        int n = nums.length;
        int[][] dp = new int[n][n];
        for (int i = 0; i < n; i++) // 初始化
            Arrays.fill(dp[i], 2);
        int ret = 2;
        for (int i = 1; i < n; i++) // 固定倒数第⼆个数
        {
            for (int j = i + 1; j < n; j++) // 枚举倒数第⼀个数
            {
                int a = 2 * nums[i] - nums[j];
                if (hash.containsKey(a)) {
                    dp[i][j] = dp[hash.get(a)][i] + 1;
                    ret = Math.max(ret, dp[i][j]);
                }
            }
            hash.put(nums[i], i);
        }
        return ret;
    }

八、等差数列划分II - 子序列

  1. 题目链接:等差数列划分II - 子序列
  2. 题目描述:

给你⼀个整数数组 nums ,返回 nums 中所有 等差子序列的数目。 如果⼀个序列中至少有三个元素 ,并且任意两个相邻元素之差相同,则称该序列为等差序列。

◦ 例如,1, 3, 5, 7, 97, 7, 7, 73, -1, -5, -9 都是等差序列。

◦ 再例如,1, 1, 2, 5, 7 不是等差序列。 数组中的子序列是从数组中删除⼀些元素(也可能不删除)得到的⼀个序列。

◦ 例如,2,5,101,2,1,2,4,1,5,10 的⼀个子序列。 题目数据保证答案是⼀个 32-bit 整数。

示例 1:

输入:nums = 2,4,6,8,10

输出:7

解释:所有的等差子序列为:

2,4,6

4,6,8

6,8,10

2,4,6,8

4,6,8,10

2,4,6,8,10

2,6,10

示例 2:

输入:nums = 7,7,7,7,7

输出:16

解释:数组中的任意子序列都是等差子序列。

提示:1 <= nums.length <= 1000

-231 <= numsi <= 231 - 1

  1. 解法(动态规划):

    算法思路:

    状态表示:对于线性 dp ,我们可以⽤「经验 + 题目要求」来定义状态表示:

    i. 以某个位置为结尾,巴拉巴拉;

    ii. 以某个位置为起点,巴拉巴拉。 这里我们选择比较常用的方式,以某个位置为结尾,结合题目要求,定义⼀个状态表示:

    dpi 表示:以 i 位置元素为结尾的「所有子序列」中,等差子序列的个数。 但是这⾥有⼀个⾮常致命的问题,那就是我们⽆法确定 i 结尾的等差序列的样子。这样就会导致 我们无法推导状态转移方程,因此我们定义的状态表示需要能够确定⼀个等差序列。 根据等差序列的特性,我们仅需知道序列里面的最后两个元素,就可以确定这个序列的样子。因此,我们修改我们的状态表示为:dpij 表示:以 i 位置以及 j 位置的元素为结尾的所有的⼦序列中,等差子序列的个 数。规定⼀下 i < j 。

    状态转移⽅程: 设 numsi = b, numsj = c ,那么这个序列的前⼀个元素就是 a = 2 * b - c 。我们 根据 a 的情况讨论:

    a. a 存在,下标为 k ,并且 a < b :此时我们知道以 k 元素以及 i 元素结尾的等差序列 的个数 dpki ,在这些子序列的后⾯加上 j 位置的元素依旧是等差序列。但是这里会多 出来⼀个以 k, i, j 位置的元素组成的新的等差序列,因此 dpij = dpki + 1 ;

    b. 因为 a 可能有很多个,我们需要全部累加起来。 综上, dpij += dpki + 1 。 优化点:我们发现,在状态转移⽅程中,我们需要确定 a 元素的下标。因此我们可以在 dp 之前,将 所有元素 + 下标数组绑定在⼀起,放到哈希表中。这里为何要保存下标数组,是因为我们要统计个数,所有的下标都需要统计。

    初始化: 刚开始是没有等差数列的,因此初始化 dp 表为 0 。

    填表顺序:

    a. 先固定倒数第⼀个数;

    b. 然后枚举倒数第二个数。

    返回值: 我们要统计所有的等差子序列,因此返回 dp 表中所有元素的和。

  2. 代码示例:

clike 复制代码
  public int numberOfArithmeticSlices(int[] nums) {
             // 优化
        Map<Long, List<Integer>> hash = new HashMap<>();
        int n = nums.length;
        for (int i = 0; i < n; i++) {
            long tmp = (long) nums[i];
            if (!hash.containsKey(tmp))
                hash.put(tmp, new ArrayList<Integer>());
            hash.get(tmp).add(i);
        }
        int[][] dp = new int[n][n];
        int sum = 0;
        for (int j = 2; j < n; j++) // 固定倒数第⼀个数
            for (int i = 1; i < j; i++) // 枚举倒数第⼆个数
            {
                long a = 2L * nums[i] - nums[j];
                if (hash.containsKey(a)) {
                    for (int k : hash.get(a))
                        if (k < i) dp[i][j] += dp[k][i] + 1;
                        else break; // ⼩优化
                }
                sum += dp[i][j];
            }
        return sum;
    }

结语

本文到这里就结束了,主要通过几道非连续子序列算法题,介绍了这种类型动态规划的做题思路,带大家深入了解了动态规划中非连续子序列这一类型。

以上就是本文全部内容,感谢各位能够看到最后,如有问题,欢迎各位大佬在评论区指正,希望大家可以有所收获!创作不易,希望大家多多支持!

最后,大家再见!祝好!我们下期见!

相关推荐
用户35218024547528 分钟前
当 Prompt 学会"热更新":Spring Boot × Nacos3 AI 实战
java·spring boot·ai编程
vivo互联网技术34 分钟前
CVPR 2026 | 全新强化学习框架 BeautyGRPO:重塑真实人像
算法·大模型·cvpr·影像
Darling噜啦啦2 小时前
列表转树算法深度解析:从 Map 到 Reduce 的两种实现,面试高频考点
数据结构·算法·面试
东坡白菜4 小时前
破局全栈:一个前端开发的Java入门实战记录(1)
java·全栈
唐青枫4 小时前
Java Tomcat 实战指南:从 Servlet 容器到 Spring Boot 部署
java
wsaaaqqq4 小时前
roudan:自由选择实体、灵活操作数据、快速写入数据库的 Java 框架
java
用户497863050735 小时前
(一)小红的数组操作
算法·编程语言
怕浪猫7 小时前
Electron 系列文章封面图
算法·架构·前端框架
plainGeekDev8 小时前
null 判断 → Kotlin 可空类型
android·java·kotlin
糖拌西瓜皮8 小时前
Java开发者视角:深入理解Node.js异步编程模型
java·后端·node.js