对动态规划的理解和解题思路

1,786 阅读6分钟

动态规划的题目,大部分都是基于一个数组的。此时状态转移其实也就是从前一个元素转移到后一个元素。对于一些非显示有数组的题目,可以自己构造数组(比如整数拆分题目,自己构造从1到n的数组)。状态转移方程怎么列? 不纠结这个! 一般来说都是利用前面N个元素的状态值,求取第N+1个元素的状态值。

这里引入一个状态值的概念。数组中的每一个元素都会有状态值,而一个元素的状态值基本上就是包含该元素并且是以这个元素为最后一个元素的题设所求。比如最长上升子序列,第k个元素的状态值就是包括第k个元素,并且以第k个元素结束的最长上升子序列;又比如三角形最小路径和,某个元素的状态值就是从最下面一层到该元素的最小路径和。后文的叙述都将以元素+状态值展开。

动态规划的一大特点就是利用之前已经求得的值。因此在求第N+1个元素的状态值时,可以假设前面N个元素的状态值都已经求得了。一般来说求N+1个元素的状态值基本上都会用到之前元素的状态值。因此需要用一个额外的数组存储前面元素的状态值。

只和前一个元素的状态值有关

这类题目,每一个元素的状态值都是可以直接通过前一个元素的状态值计算出来。

最大子序列号

leetcode原题[最大子序和]

  • 题目描述
给定一个整数数组 nums ,找到一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和
  • 例子
输入: [-2,1,-3,4,-1,2,1,-5,4]
输出: 6
解释: 连续子数组 [4,-1,2,1] 的和最大,为 6。
  • 题解

对于第N+1个元素,其状态值是包含该元素并且以该元素为结尾的题设所求,也就是最大连续子数组的和。怎么利用前面N个元素已经求得的状态值? 这个还算比较明显:设第N个元素的状态和为s,如果s大于0,那么第N+1元素的状态值就是s+arr[n+1];否则就arr[n+1]。

这题目求的是数组所有状态值的最大值,所以用一个临时变量保存这些状态值的最大值即可。

  • AC代码
class Solution {
public:
    int maxSubArray(vector<int>& nums) {
        if(nums.empty())
            return 0;

        int max_sum = nums[0];
        int cur_sum = nums[0];
        for(int i = 1; i < nums.size(); ++i)
        {
            if(cur_sum < 0)
                cur_sum = nums[i];
            else
                cur_sum += nums[i];

            max_sum = std::max(max_sum , cur_sum);
        }

        return max_sum;
    }
};

和前面两个元素的状态值有关

前面例子中,每一个元素的状态值都是由前面一个元素的状态值确定的。经典的爬楼梯题目中,元素的状态值是由前面两个元素的状态值决定的。

爬楼梯

leetcode原题[跑楼梯]

  • 题目描述
假设你正在爬楼梯。需要 n 阶你才能到达楼顶。
每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?
  • 例子
输入: 3
输出: 3
解释: 有三种方法可以爬到楼顶。
1.  1 阶 + 1 阶 + 1 阶
2.  1 阶 + 2 阶
3.  2 阶 + 1 阶
  • 题解

OK,假设第N+1个元素的状态值为跳到第N+1个台阶的总方法数(也就是题设所求了)。此时,前面N个台阶的状态值都已经求得。怎么利用这个?目光回到题目:第N+1个台阶可以从第N-1个台阶跳2个台阶得到,也可以从第N个台阶跳一个台阶得到。换言之,第N+1个元素的状态值是第N-1个元素的状态值+第N个元素的状态值。也就是其状态值由前面两个元素共同确定。

  • AC代码
class Solution {
public:
    int climbStairs(int n) {
        if(n == 1)
            return 1;
        else if (n == 2)
            return 2;

        int a = 1, b = 2, c;
        for(int i = 3; i <= n; ++i)
        {
            c = a + b;
            a = b;
            b = c;
        }

        return c;
    }
};

打家劫舍

leetcode原题[打家劫舍]

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

给定一个代表每个房屋存放金额的非负整数数组,计算你 不触动警报装置的情况下 ,一夜之内能够偷窃到的最高金额。
  • 例子
例子一:
输入:[1,2,3,1]
输出:4
解释:偷窃 1 号房屋 (金额 = 1) ,然后偷窃 3 号房屋 (金额 = 3)。
     偷窃到的最高金额 = 1 + 3 = 4 。


例子二:
输入:[2,7,9,3,1]
输出:12
解释:偷窃 1 号房屋 (金额 = 2), 偷窃 3 号房屋 (金额 = 9),接着偷窃 5 号房屋 (金额 = 1)。
     偷窃到的最高金额 = 2 + 9 + 1 = 12 。
  • 题解

先确定状态值,假定状态值为题设所求:能偷窃到的最高金额。对于第N个元素,其状态值是包含其在内的N个元素能偷到的最大值。对于第N+1元素,其状态值是包含其在内的N+1个元素能偷到的最大值。

先假设已经求得了数组长度只有N时,每个元素的状态值。现在在数组的后面再追加一个元素,再问整个数组能偷到的最大值。明显:最后的最大值要么是包含新加的这个元素,要么不包含。 对于前者计算方法是第N-1个元素的状态值+第N+1个元素的值,对于后者直接就是第N个元素的状态值。 由于本题中,每个元素只有一个状态值,那么第N+1个元素的状态值就是前面两种计算结果的最大值。

  • AC代码

对于这道题目,可以用一个额外的数组保存每个元素的状态值。代码如下:

class Solution {
public:
    int rob(vector<int>& nums) {
        if(nums.empty())
            return 0;

        std::vector<int> vec(nums.size(), 0);
        vec[0] = nums[0];
        if(nums.size() > 1)
            vec[1] = std::max(nums[0], nums[1]);


        for(int i = 2; i < nums.size(); ++i)
        {
            vec[i] = std::max(vec[i-1], nums[i] + vec[i-2]);
        }


        return vec.back();
    }
};

和前面的多个元素的状态值有关

最长上升序列

leetcode原题最长上升子序列

  • 题目描述
给你一个整数数组 nums ,找到其中最长严格递增子序列的长度。

子序列是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。例如,[3,6,2,7] 是数组 [0,3,1,6,2,2,7] 的子序列。
  • 例子
示例一:
输入:nums = [10,9,2,5,3,7,101,18]
输出:4
解释:最长递增子序列是 [2,3,7,101],因此长度为 4 

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

示例三:
输入:nums = [7,7,7,7,7,7,7]
输出:1
  • 题解

首先确定状态值,假定就是所求:包含该元素的最长上升序列。比如上面的示例一中的对于元素5,包含元素5的最长上升序列是[2, 5]。

如果元素3之前的所有元素都已经求得了状态值,现在求元素3的状态值,那么就变得很简单:求在元素3前面并且其值小于3的所有元素的最大值。一个简单的遍历即可,最终题目所求就是这些元素中最大的状态值。

  • AC代码

对于这道题目,需要一个额外的数组保存已经求过元素的状态值。AC代码如下:

class Solution {
public:
    int lengthOfLIS(vector<int>& nums) {
        if(nums.empty())
            return 0;

        int max_len = 1;
        std::vector<int> vec(nums.size(), 1);//保存每个元素的状态值
        for(int i = 1; i < nums.size(); ++i)
        {
            for(int j = i-1; j >= 0; --j)
            {
                if(nums[i] > nums[j] && vec[i] <= vec[j])
                    vec[i] = vec[j] + 1;
            }

            max_len = std::max(max_len, vec[i]);
        }

        return max_len;
    }
};

最大整除子集

leetcode原题最大整除子集

  • 题目描述
给出一个由无重复的正整数组成的集合,找出其中最大的整除子集,子集中任意一对 (Si,Sj) 都要满足:Si % Sj = 0 或 Sj % Si = 0。

如果有多个目标子集,返回其中任何一个均可。
  • 例子
示例一:
输入: [1,2,3]
输出: [1,2] (当然, [1,3] 也正确)

示例二:
输入: [1,2,4,8]
输出: [1,2,4,8]
  • 题解

同样,对于每个元素假定其状态值为包含该元素的最大整除子集。如果前面N元素已经求得其状态值,那么对于N+1个元素,只需在前面N个元素中找出能整除第N+1个元素的所有元素,然后取这些元素的最大状态值即可。这题目和前面的最大整除子集比较类似。

  • AC代码

由于题目所求的不是一个长度值而是序列,因此需要还需要额外的空间存储这个信息。AC代码如下:

class Solution {
public:
    vector<int> largestDivisibleSubset(vector<int>& nums) {
        std::vector<int> ret;
        if(nums.empty())
            return ret;

        std::sort(nums.begin(), nums.end());

        //first保存的长度,second保存的是上一个位置。如果长度为1,那么second的值就是本身了
        std::vector<std::pair<int, int>> vec(nums.size(), std::make_pair(1, 0));

        int max_len_index = 0;
        for(int i = 1; i < nums.size(); ++i)
        {
            vec[i].second = i; //设置为本身
            for(int j = i - 1; j >= 0; --j)
            {
                if(nums[i]%nums[j] == 0)
                {
                    if(vec[i].first <= vec[j].first)
                    {
                        vec[i].first = vec[j].first + 1;
                        vec[i].second = j;
                    }
                }
            }

            if(vec[i].first > vec[max_len_index].first)
                max_len_index = i;

        }

        for(int i = max_len_index; i >= 0; )
        {
            ret.push_back(nums[i]);
            if(vec[i].second == i) //前面已经没有除数了
                break;
            else
                i = vec[i].second;
        }

        std::reverse(ret.begin(), ret.end());
        return ret;
    }
};

整数拆分

leetcode原题整数拆分

  • 题目描述
给定一个正整数 n,将其拆分为至少两个正整数的和,并使这些整数的乘积最大化。 返回你可以获得的最大乘积。
  • 例子
示例一:
输入: 2
输出: 1
解释: 2 = 1 + 1, 1 × 1 = 1。

示例二:
输入: 10
输出: 36
解释: 10 = 3 + 3 + 4, 3 × 3 × 4 = 36。
  • 题解

这道题目是没有给出数组的,因此需要自己构造一个数组。构造的数组就是从1到n的数组,数组的每一个元素存储的是状态值。

  • AC代码
class Solution {
public:
    int integerBreak(int n) {

        std::vector<int> vec{0, 1, 1, 2, 4}; //预保存前面几个元素的状态值
        vec.insert(vec.end(), 58, 0);
        for(int i = 5; i <= n; ++i)
        {
            //遍历前面一半元素的状态值,看看是否能有更大的状态值
            for(int j = 1; j <= i - j; ++j) 
            {
                int simple = (i - j) * j;
                int max_val = std::max(simple, vec[i-j]*j);
                vec[i] = std::max(vec[i], max_val);
            }
        }

        return vec[n];
    }
};

一个元素具有多个状态值

前面介绍的题目类型都是一个元素只有一个状态值,但其实一个元素也可能有多个状态值。多个状态值主要是一个元素可能具有多个情况的取值,这个是与题目紧密相关的。主要是出现在不同情况下,对于每一种情况都需要记录一个状态值(值得注意的是这个状态值仍然是一堆候选状态值的最值)。比如两个数组的最长重复子数组题目,对于数组B的元素b来说,它可以对应数组A的多个不同的位置,也就是多个不同的情况了,这些不同位置的状态值都需要记录起来,也就是记录了多个状态值。

和前一个元素的多个状态值有关

后续的元素在计算自己的状态值时,需要对前面元素的所有状态值都算一下,每个计算的结果都是自己的其中一个状态值。前面的题目其实也是有计算多个值,但最后这个元素的状态值取了这些计算结果的最值。但一些题目当前元素的最值不一定就是后续元素的最值的中间计算过程,因此需要保留所有的状态值。

摆动序列

leetcode原题 摆动序列

  • 题目描述
如果连续数字之间的差严格地在正数和负数之间交替,则数字序列称为摆动序列。第一个差(如果存在的话)可能是正数或负数。少于两个元素的序列也是摆动序列。

例如, [1,7,4,9,2,5] 是一个摆动序列,因为差值 (6,-3,5,-7,3) 是正负交替出现的。相反, [1,4,7,2,5] 和 [1,7,4,5,5] 不是摆动序列,第一个序列是因为它的前两个差值都是正数,第二个序列是因为它的最后一个差值为零。

给定一个整数序列,返回作为摆动序列的最长子序列的长度。 通过从原始序列中删除一些(也可以不删除)元素来获得子序列,剩下的元素保持其原始顺序。
  • 例子
示例一:
输入: [1,7,4,9,2,5]
输出: 6 
解释: 整个序列均为摆动序列。


示例二:
输入: [1,17,5,10,13,15,10,5,16,8]
输出: 7
解释: 这个序列包含几个长度为 7 摆动序列,其中一个可为[1,17,10,13,10,16,8]。


示例三:
输入: [1,2,3,4,5,6,7,8,9]
输出: 2
  • 题解

对于每一个数来说,前面都可能有其他的数比它大或者比它小。也就是说它可以充当某个序列差为负数的部分,也可以充当另外一个序列差为正数的部分。此时就需要同时记录两种不同情况的状态值。当然对于每种情况来说,都需要遍历该元素之前的元素,然后计算得到它的候选状态值并记录状态值的最值。

  • AC代码
class Solution {
public:
    int wiggleMaxLength(vector<int>& nums) {
        if(nums.size() <= 1)
            return nums.size();

        //first表示本数大于前面的数,second表示本数小于前面的数。其值都是长度
        std::vector<std::pair<int, int>> vec(nums.size(), std::make_pair(1, 1));

        int max_len = 0;
        for(int i = 1; i < nums.size(); ++i)
        {
            for(int j = i-1; j >= 0; --j)
            {
                if(nums[i] > nums[j])//元素i充当差为正数部分的情况
                {
                    //记录此时能达到的最大长度
                    vec[i].first  = std::max(vec[i].first, vec[j].second+1);
                }
                else if(nums[i] < nums[j])//元素i充当差为负数部分的情况
                {
                    //记录此时能达到的最大长度
                    vec[i].second = std::max(vec[i].second, vec[j].first+1);
                }
            }

            max_len = std::max(max_len, vec[i].first);
            max_len = std::max(max_len, vec[i].second);
        }

        return max_len;
    }
};

和前面多个元素的多个状态值有关

最长重复子数组

leetcode 原题最长重复子数组

  • 题目描述
给两个整数数组 A 和 B ,返回两个数组中公共的、长度最长的子数组的长度。
  • 例子
输入:
A: [1,2,3,2,1]
B: [3,2,1,4,7]
输出:3
解释:
长度最长的公共子数组是 [3, 2, 1] 。

  • 题解

这题目初看可能会比较让人摸不到头脑。但如果结合本文前面的题型一路走过来,思路就会慢慢浮现:对于数组B,每一个元素都有好几个状态值,每个状态值是该元素对应A中的某个元素的题设所求。

比如说B[1],它可以对应A[1]的题设所求,也就是以B[1]结尾的最长重复子数组,并且要求B[1]对应A[1]。因为有多个状态值,所以B[1]也会对应A[3],A[4]等等。

前文有一个很重要的特点就是,当求N+1的时候,前面N个元素的状态值都已经求得了,直接使用即可。这里也是如此。比如说B[1]对应A[1],那么此时可以假设B[0]对应的A[0]也已经求过了,直接使用这个状态值的结果。

  • AC代码
class Solution {
public:
    int findLength(vector<int>& A, vector<int>& B) {
        //用一个二维数组存储状态值。mat[row][col]表示,数组A的第row+1个元素对应数组B的第col+1个元素的状态值。
        std::vector<std::vector<int>> mat(A.size(), std::vector<int>(B.size(), 0));

        int max_len = 0;
        for(int row = 0; row < A.size(); ++row)
        {
            for(int col = 0; col < B.size(); ++col)
            {
                //A[row]和B[col]相匹配
                if(A[row] != B[col])//不相等,直接设置为0
                    continue;

                //看看前面是否还有前缀匹配的。
                if(row > 0 && col > 0)
                    mat[row][col] = mat[row-1][col-1] + 1; //直接利用前面已经求得的结果
                else
                    mat[row][col] = 1;

                max_len = std::max(max_len, mat[row][col]);
            }
        }

        return max_len;
    }
};

目标和

  • 题目描述
给定一个非负整数数组,a1, a2, ..., an, 和一个目标数,S。现在你有两个符号 + 和 -。对于数组中的任意一个整数,你都可以从 + 或 -中选择一个符号添加在前面。

返回可以使最终数组和为目标数 S 的所有添加符号的方法数。
  • 例子
示例:
输入:nums: [1, 1, 1, 1, 1], S: 3
输出:5
解释:

-1+1+1+1+1 = 3
+1-1+1+1+1 = 3
+1+1-1+1+1 = 3
+1+1+1-1+1 = 3
+1+1+1+1-1 = 3

一共有5种方法让最终目标和为3。
  • 题解

这题目是背包问题的一种。同样,这里用状态值的方法求解:每一个元素都有多个状态值,其状态值就是以该元素结尾的题设所求。这里就是包含该元素并且以该元素为最后一个元素的和。明显随着前面取不同的符号(不同的情况),每个元素都会有多个状态值。题设是求的是方法数,但还有一个重要的目标和。因此对于每一个元素都会保存目标和以及对应的方法数,这样才是一个完整的状态值,可以供后面元素使用的状态值。

  • AC代码
class Solution {
public:
    int findTargetSumWays(vector<int>& nums, int S) {
        int sum = std::accumulate(nums.begin(), nums.end(), 0);
        if(sum < S || (sum + S )%2 == 1 )
            return 0;

        //前面补0是为了方面在最前面插入负号
        nums.insert(nums.begin(), 0);

        //每一个元素用一个map存储状态值
        //map的key是能到达的sum,value则是到达该和有几种途径
        std::vector<std::unordered_map<int, int>> vec(nums.size());
        vec[0].emplace(0, 1); //到达0有一种方法


        for(int i = 1; i < nums.size(); ++i)
        {
            for(auto &e : vec[i-1])
            {
                vec[i][e.first + nums[i]] += e.second;
                vec[i][e.first - nums[i]] += e.second;
            }
        }

        return vec.back()[S];
    }
};

PS:本文是提供一种解决动态规划题目的思路,然后顺着思路解决各类题目。因此对于一些题目,文中给出的解法可能并不是最优解。

第一时间接收最新文章,请关注同名微信公众号: 代码的色彩