(六)算法与数据结构 | 动态规划


1. 动态规划简介

下面加粗字体段落摘录自维基百科,动态规划( D y n a m i c   P r o g r a m m i n g , D P {\rm Dynamic\ Programming,DP} )是一种在数学、管理科学、计算机科学、经济学和生物信息学中使用的,通过把原问题分解为相对简单的子问题的方式求解复杂问题的方法。动态规划常常适用于有重叠子问题最优子结构性质的问题。
重叠子问题是指使用动态规划求解问题时,该问题可以被分解为若干子问题,通过子问题的解得到整个问题的解;最优子结构是指使用动态规划求解最优化问题时,该问题的优化可以通过子问题的优化构造出来。看到这里,联想到递归适用于具有重叠子问题的情景,但由于递归通常会包含许多重复的计算,因此通常采用剪枝简化计算,而动态规划是递归中常用的剪枝方法;联想到贪心适用于具有最优子结构的情景,由子问题的最优得到整个问题的最优。贪心和动态规划的最大区别是贪心使用贪心选择机制使得每个子问题只被选择一次,由此可能并不能得到最优解;而动态规划可能多次使用子问题的结果,从而得到整体最优。
通常许多子问题非常相似,为此动态规划试图仅解决每个问题一次,从而减少计算量:一旦某个给定子问题的解已经算出,则将其记忆化存储,以便下次需要同一个子问题解之时直接查表。
记忆化是动态规划中非常重要的一个步骤,通常使用一维或二维数组存储子问题的解。现在举一个简单的例子:如果存在三个连续的状态 i 1 i-1 i i i + 1 i+1 ,当前状态的求解依赖于前面所有状态的解,即求 i i 的解时需要 i 1 i-1 的解和当前 i i 的值。如果每次都从头开始计算,就会造成额外不必要的计算量。这里通常使用数组存储之前计算过的结果,如求状态 i i 的解时使用数组存储的 i 1 i-1 的解、求状态 i + 1 i+1 的解时使用数组存储的 i i 的解……
所以,动态规划的首要步骤是:确定数组所表示的含义。确定数组所表示的含义通常通过求解的内容决定。如题目需要求解到达最终状态时所能产生的解的数目,则数组表示到达当前状态时得到的解的数目。此外,上面提到,当前状态的求解依赖于以前状态,这里我们需要明确时怎么依赖的。即求解当前状态是如何使用以前状态的解的,我们称之为状态转移,即需要定义状态转移方程。字面意思,很好理解,就是使用方程通过以前状态转移到当前状态。常用的状态转移方程的形式是maxmin,求和等。
由上可知,解决动态规划的步骤时:首先,判断问题的性质,是否满足最优子结构或重复子问题的形式;其次,定义一维或二维dp数组并确定其表达的含义,并给数组赋定初值;最后,确定每个状态之间的转移方式,即定义状态转移方程。下面将通过几道经典的动态规划例题,由浅入深了解动态规划在求解相关问题时的核心思想。


2. 动态规划经典例题

本文主要使用动态规划的方法解决问题,具体其他方法可参考题末的 L e e t C o d e {\rm LeetCode} 官方给出的题解。

2.1 爬楼梯

题目来源 70.爬楼梯
题目描述 假设共有 n n 个台阶( n n 为正整数),每次只能上升 1 1 个或 2 2 个台阶。问共有多少种方法到达台阶顶部。
如输入n=2,则输出为 2 2 。解释:第一种走法,先上升 1 1 个台阶,再上升 1 1 个台阶;第二种走法,直接上升 2 2 个台阶。如输入n=3,则输出为 3 3 。解释:第一种走法,先上升 1 1 个台阶,然后上升 1 1 个台阶,再上升 1 1 个台阶;第二种走法,先上升 1 1 个台阶,再上升 2 2 个台阶;第三种走法,先上升 2 2 个台阶,再上升 1 1 个台阶。
由上面的举例我们可以得到:想要达到第 2 2 个台阶,我们只能经由第 1 1 个台阶;想要达到第 3 3 个台阶,我们只能经由第 1 1 个台阶或 2 2 个台阶,这取决于我们每次上升的阶数;则想要达到第 n n 个台阶,我们只能经由第 n 1 n-1 个台阶或第 n 2 n-2 个台阶。
根据上面解决动态规划步骤:首先,判断问题性质,发现问题中存在重叠子问题;其次,定义dp数组,根据题目要求,则明确dp[i]表示共有dp[i]种方法到达第i个台阶,并初始化前两个状态;最后,定义状态转移方程,由于当前第i个状态通过第i-1i-2个状态确定,易得到状态转移方程为dp[i]=dp[i-1]+dp[i-2]。由状态转移方程可知,我们需要定义dp[1]dp[2]的值。最后 C {\rm C++} 代码如下:

int climbStairs(int n) {
	// dp数组
	vector<int> dp(n + 1, -1);
	dp[1] = 1;
	dp[2] = 2;
	for (int i = 3; i <= n; ++i) {
		// 状态转移方程
		dp[i] = dp[i - 1] + dp[i - 2];
	}
	return dp[n];
}

其他题解 官方题解

2.2 最大子序和

题目来源 53.最大子序和
题目描述 给定一个整数数组nums,找到一个具有最大和连续子数组(子数组至少包含一个元素) ,返回其最大和。
如输入[-2,1,-3,4,-1,2,1,-5,4],则输出为 6 6 。解释:具有最大和的连续子数组为[4,-1,2,1],其和为 6 6
根据题目要求,我们可以设数组dp的某项值dp[i]表示当前第i的加入后连续子数组的最大和。我们可以发现,加上当前数字dp[i]后,最大和要么来自nums[i]本身(此时前面的连续子序数组和小于零);要么来自加上nums[i]后的连续子数组(此时前面的连续子数组和大于等于零)。所以,我们可以通过声明一个变量out用于暂存最大和,在遍历每个nums[i]时判断是否需要更新最大和。最后 C {\rm C++} 代码如下:

int maxSubArray(vector<int>& nums) {
	if (nums.size() == 0) {
		return 0;
	}
	int out = INT_MIN;
	int n = -1;
	for (int i = 0; i < nums.size(); i++) {
		// 加上当前数字nums[i]后,连续子序数组的和是否需要更新
		n = max(nums[i], n + nums[i]);
		// out变量值用于暂存连续子数组的最大和
		out = max(n, out);
	}
	return out;
}

对于上一道题目,程序中的n值和out值的变化情况如下:在这里插入图片描述

图1:值变化情况

其他方法 官方题解

2.3 最低票价

题目来源 983.最低票价
题目描述 给定两个数组dayscosts分别表示一年中需要外出旅行的日子和三种通行证的价格,为:

  • 为期一天的通行证售价为costs[0]
  • 为期七天的通行证售价为costs[1]
  • 为期三十天的通行证售价为costs[2]

现在编写程序求完成days数组中所有给定日子所需的最低票价。
如输入days=[1,4,6,7,8,20]costs=[2,7,15],则输出为 11 11 。解释:这里有一种可行购买方法。在第一天,购买一天的通行证,花费 2 2 元;在第二天,购买七天的通行证,花费 7 7 元;在第二十天,购买一天的通行证,花费 2 2 元。总共需要 11 11 元。可能存在很多种购买方法,而程序返回的是完成所有日子的所需最低票价。
根据题目要求,我们设数组dp的某项值dp[i]表示当前第i所需的最低票价,而数组长度设定为 366 366 。由题,我们在某一天i仅有两种操作:购买通行证或不购买通行证,采取哪种操作需要根据前面的状态而定。如果我们在当天购买通行证,同时又有三种情况(三种通行证):购买一天,则dp[i]=dp[i-1]+costs[0],购买七天,则dp[i]=dp[i-7]+costs[1],购买三十天,则dp[i]=dp[i-30]+costs[2],我们需要在这三种情况采取所需最低票价的操作;如果不购买通行证,则dp[i]=dp[i-1],即为前一天产生的费用。同时定义访问数组travel用于表示该元素是否在days数组中。根据上述思路得到的 C {\rm C++} 代码如下:

int mincostTickets(vector<int>& days, vector<int>& costs) {
    // travel数组用于标识是否为旅游日期,并初始化
    vector<bool> travel(366, false);
    for (int day : days) {
        travel[day] = true;
    }
    // 动态规划数组,并初始化
    vector<int> dp(366, 0);
    // 根据最后一个日期确定循环边界
    int n = days[days.size() - 1];
    for (int i = 1; i <= n; ++i) {
        // 旅行
        if (travel[i]) {
            // 1天
            int a = dp[i - 1] + costs[0];
            // 7天,如果i-7小于零,则说明当前日期小于7,取当前天数的费用
            int b = i - 7 >= 0 ? dp[i - 7] + costs[1] : costs[1];
            // 30天,同理
            int c = i - 30 >= 0 ? dp[i - 30] + costs[2] : costs[2];
            // dp[i]赋值
            dp[i] = min(a, min(b, c));
        }
        // 不旅行
        else
        {
            dp[i] = dp[i - 1];
        }
    }
    // 返回
    return dp[n];
}

其他方法 官方题解

2.4 最大正方形

题目来源 221.最大正方形
题目描述 给定一个二维数组,其元素只含 0 0 1 1 ,编写程序返回只包含 1 1 的最大正方形的面积。
如输入为
在这里插入图片描述
则最大正方形为黑体部分,其面积为 4 4
根据数据形式,我们定义数组dp的某项值dp[i][j]表示以 ( i , j ) (i,j) 右下角的正方形的边长。这里使用右下角的原因是我们的遍历正方形的方式是以从上到下从左到右的顺序(也可以将 ( i , j ) (i,j) 视为正方形的左上角、左下角等,这时需要我们改变遍历方式,其原理一致)。在遍历正方形时,如果当前位置 ( i , j ) (i,j) 的值为 0 0 ,可以直接跳过该位置;如果当前位置 ( i , j ) (i,j) 的值为 1 1 ,我们需要判断这个位置的加入是否能够增大上一状态正方形的面积。由于遍历顺序是从上到小、从左到右,上一状态的正方形的右下角只能在当前位置的左上方正上方正左方,即dp[i-1][j-1]dp[i-1][j]dp[i][j-1]。要使当前位置加入后能够构成有效正方形,则要求该正方形不能在上述三个方式受到元素 0 0 的限制。如下图:在这里插入图片描述

图2:最大正方形

如上图,黑色位置为 ( i , j ) (i,j) ,红色、黄色、蓝色分别为以相应位置为右下角的最大正方形,其面积分别为 16 16 25 25 9 9 0 0 为限制相应正方形面积的位置(如左下角那个 0 0 限制了以红色位置为右下角的正方形的最大面积)。类似于木桶短板效应,以当前黑色位置为右下角的正方形的边长受限于三个方向最小的正方形的边长。由此,我们可以得到状态转移方程为 d p [ i ] [ j ] = m i n ( d p [ i 1 ] [ j 1 ] , d p [ i 1 ] [ j ] , d p [ i ] [ j 1 ] ) + 1 dp[i][j]=min(dp[i-1][j-1],dp[i-1][j],dp[i][j-1])+1

根据上述思路得到的 C {\rm C++} 代码如下:

// f[i][j]表示以(i,j)为右下角的正方形的最大边长
// 状态转移方程:f[i][j]=min(f[i-1][j],f[i][j-1],f[i-1][j-1])+1
int maximalSquare(vector<vector<char>>& matrix) {
    // 定义矩阵宽高并判断是否合法
    if (!matrix.size() || !matrix[0].size()) {
        return 0;
    }
    int r = matrix.size(), c = matrix[0].size();
    // 定义结果变量
    int maxS = 0;
    // 定义dp数组
    vector<vector<int>> dp(r, vector<int>(c, 0));
    // 遍历数组
    for (int i = 0; i < r; ++i) {
        for (int j = 0; j < c; ++j) {
            // 处理为'1'的地方
            if (matrix[i][j] == '1') {
                // 边界
                if (i == 0 || j == 0) {
                    dp[i][j] = 1;
                }
                // 状态转移方程
                else
                {
                    dp[i][j] = min(dp[i - 1][j], min(dp[i][j - 1], dp[i - 1][j - 1])) + 1;
                }
            }
            // 由于dp数组的值不一定是递增的,因为位置'0'的dp数组值初始化为0
            maxS = max(maxS, dp[i][j]);
        }
    }
    // 返回结果
    return maxS * maxS;
}

其他方法 官方题解

此外,由于dp[i][j]的值仅与dp[i-1],我们可以考虑将二维数组的第二维优化,具体的方法这里不细说,可以参考这里

2.5 编辑距离

题目来源 72.编辑距离
题目描述 给定两个单词 w o r d 1 word1 w o r d 2 word2 ,计算将 w o r d 1 word1 转换成 w o r d 2 word2 所需要的最少操作数。其中操作包括以下三种:插入一个字符;删除一个字符;替换一个字符。
如输入word1="horseword2="ros则输出为 3 3 。解释:将word1转换为word2的最少操作步骤为:① h o r s e r o r s e horse→rorse (将h替换为r);② r o r s e r o s e rorse→rose (删除r);③ r o s e r o s rose→ros (删除e),共 3 3 步。
首先来分析问题,解决两个字符串的动态规划问题时,通用做法是设置两个指针i和j分别指向两个字符串的末尾,然后一步步往往走,从而缩小问题规模。则根据题意,我们可以得到如下伪代码:

if(s1[i] == s2[j]):
	什么也不做,同时指针i和j向前移动;
else:
	在上述三种操作中选择一种,即选择插入、删除或替换;

现在就将问题中的求最少操作数转换为使得else中语句的执行次数最少。则接下来需要解决的问题是选择哪一种操作才能使得最终操作数最少,我们首先想到的是暴力搜索所有操作的可能,然后求最小值。为了便于理解题意,我们先使用递归的方法解决该题。上面伪代码已经描述了递归的迭代条件,现在寻找递归出口:如果某个字符串遍历完成则可以返回另一个操作数的长度为操作数(将另一个字符串全部删除)。则递归的代码如下:

int fun(string s1, string s2, int m, int n) {
    // 递归出口,如果某个字符串的索引为-1表示已经遍历完成。
    // 返回的操作数是将另一个字符串当前索引的前面字母删除共m/n+1个。
    if (m == -1) {
        return n + 1;
    }
    if (n == -1) {
        return m + 1;
    }
    // 什么也不做
    if (s1[m] == s2[n]) {
        return fun(s1, s2, m - 1, n - 1); 
    }
    // 取三者操作之中操作数最小的那个
    else
    {
        /*
            这里以前者到后者为例:
            后者的删除操作对应于前者的插入操作;
            后者的插入操作对应于前者的删除操作;
            替换操作二者均满足。
            以下注释针对前者。
        */
        return min(
            // 插入操作
            fun(s1, s2, m, n - 1) + 1,
            // 删除操作
            min(fun(s1, s2, m - 1, n) + 1,
            	// 替换操作
            	fun(s1, s2, m - 1, n - 1) + 1)
        );
    }
}
int minDistance(string word1, string word2) {
    int len1 = word1.length();
    int len2 = word2.length();
    if (len1 * len2 == 0) {
        return len1 + len2;
    }
    return fun(word1, word2, len1 - 1, len2 - 1);
}

分析上述递归过程,其中存在大量的重复操作。例如从fun(i,j)fun(i-1,j-1),可以通过先插入、后删除,或先删除、后插入或直接得到。如果在递归程序中发现一条重复路径,则整个程序存在大量的重复路径。这里我们考虑使用动态规划优化上述程序。上面程序出现重复路径的关键是没有保存中间计算结果,则我们考虑使用二维数组保存中间计算结果。这里使用二维数组的原因是我们需要同时保存两个字符串的情况。则我们明确dp[i][j]表示s1 i i 个字符s2 j j 个字符间的编辑距离。则对应于上面递归程序中的三种操作,dp[i][j]的值可由dp[i-1][j]dp[i][j-1]dp[i-1][j-1]得到:

  • dp[i][j]dp[i-1][j]得到,对于s1的第 i i 个字符,我们通过在s2插入插入一个相同字符得到,即dp[i][j]=dp[i-1][j]+1
  • dp[i][j]dp[i][j-1]得到,对于s2的第 j j 个字符,我们通过在s1插入插入一个相同字符得到,即dp[i][j]=dp[i][j-1]+1
  • dp[i][j]dp[i-1][j-1]得到,如果s1s2对应字符相同,则dp[i][j]==dp[i-1][j-1];否则dp[i][j]==dp[i-1][j-1]+1

状态转移方程可写为:
d p [ i ] [ j ] = { d p [ i 1 ] [ j 1 ]   i f   s 1 [ i ] = = s 2 [ j ] m i n ( d p [ i 1 ] [ j ] , d p [ i ] [ j 1 ] , d p [ i 1 ] [ j 1 ] ) + 1   o t h e r w i s e dp[i][j]= \begin{cases} dp[i-1][j-1]& \ if\ s1[i]==s2[j]\\ min(dp[i-1][j],dp[i][j-1],dp[i-1][j-1])+1& \ otherwise \end{cases}

有了状态转移方程,我们现在来确定数组dp初值。对比递归出口,且由于数组下标为正,可以得到需要确定的初值是dp[i][0]dp[0][j]。二者的含义分别是当前字符串和一个空串的编辑距离,易得到dp[i][0]=idp[0][j]=j。综上,使用动态规划优化后的代码为:

int minDistance2(string word1, string word2) {
    int len1 = word1.length();
    int len2 = word2.length();
    if (len1 * len2 == 0) {
        return len1 + len2;
    }
    // 定义dp二维数组
    vector<vector<int>> dp(len1 + 1, vector<int>(len2 + 1, 0));
    // 给初始情况赋值
    for (int i = 1; i <= len1; ++i) {
        dp[i][0] = i;
    }
    for (int j = 1; j <= len2; ++j) {
        dp[0][j] = j;
    }
    // 自下而上求解
    for (int i = 1; i <= len1; ++i) {
        for (int j = 1; j <= len2; ++j) {
        	// word1和word2的第i和第j个字符的情况
            if (word1[i - 1] == word2[j - 1]) {
                dp[i][j] = dp[i - 1][j - 1];
            }
            else
            {
                dp[i][j] = min(
                    dp[i - 1][j] + 1,
                    min(dp[i][j - 1] + 1, dp[i - 1][j - 1] + 1)
                );
            }
        }
    }
    // 返回word1和word2间的编辑距离
    return dp[len1][len2];
}

仔细对比递归和动态规划的方法,递归是一种自上而下的方法,动态规划是一种自下而上的方法。
其他方法 官方题解


3. 0-1背包

3.1 背包问题

定义来自维基百科背包问题是一种组合优化和 N P {\rm NP} 完全问题。该类问题可以描述为:给定一组物品,每种物品都有自己的重量和价值,在限定的总重量内,我们如何选择,才能使得物品的总价值最高。数学定义如下:假定有 N N 种物品,物品 j j 的重量为 w j w_j 、价值为 v j v_j 。我们假定所有物品的重量和价值都是非负的,背包所能承受的最大重量为 W W
如果限定每种物品只能选择 0 0 个或 1 1 个,则问题称为 0 1 0–1 背包问题。其数学公式为: m a x j = 1 n v j x j        s . t .   j = 1 n w j x j W ,   x j { 0 , 1 } {\rm max}\sum_{j=1}^{n}v_jx_j\ \ \ \ \ \ s.t.\ \sum_{j=1}^{n}w_jx_j≤W,\ x_j∈\{0,1\}

如果限定物品 j j 最多只能选择 b j b_j 个,则问题称为有界背包问题。其数学公式为: m a x j = 1 n v j x j        s . t .   j = 1 n w j x j W ,   x j { 0 , 1 , . . . , b j } {\rm max}\sum_{j=1}^{n}v_jx_j\ \ \ \ \ \ s.t.\ \sum_{j=1}^{n}w_jx_j≤W,\ x_j∈\{0,1,...,b_j\}

如果不限定每种物品的数量,则问题称为无界背包问题。本文暂时只介绍 0 1 0–1 背包问题,其他类的问题可以参考dd大牛的《背包九讲》,里面几乎包括了所有类的背包问题。

3.2 0-1背包问题

0 1 0–1 背包问题是其他类背包问题的基础,考虑物品仅有两种操作:放或者不放。首先,由于需要同时考虑物品以及背包的总价值,我们定义二维数组 f [ i ] [ j ] f[i][j] 表示前 i i 件物品放入容量为 j j 的背包所能产生的最大价值。因为物品存在放于不放两种操作,我们分别就这两种操作来判断最大价值的来源。如果放入当前物品,则放入后的价值可以表示为 f [ i 1 ] [ j v [ i ] ] + w [ i ] f[i-1][j-v[i]]+w[i] ,具体解释为:当前状态由不放该物品时且背包容量减去当前物品体积的状态转移过来;如果不放入当前物品,不放入的价值可以表示为 f [ i 1 ] [ j ] f[i-1][j] ,具体解释为:当前状态由不放该物品时的状态转移过来。则我们从上述两种情况下取最大值,即得到状态转移方程: f [ i ] [ j ] = m a x ( f [ i 1 ] [ j v [ i ] ] + w [ i ] , f [ i 1 ] [ j ] ) f[i][j]={\rm max}(f[i-1][j-v[i]]+w[i],f[i-1][j])

对于数组的初始化,当背包容量为 0 0 或物品数量为 0 0 时,显然此时的总价值为 0 0 。最后 f [ N ] [ W ] f[N][W] 表示前 N N 件物品放入容量为 W W 的背包所能产生的最大价值,即为所求。

int maxValue(vector<int>& v, vector<int>& w) {
	// 定义动态规划数组并初始化,由于初始化时N和W为0,这里多添加一维
	vector<vector<int>> f(N + 1, vector<int>(W + 1, 0));
	// 用于遍历所有物品
	for (int i = 1; i <= N; ++i) {
		// 用于遍历背包容量
		for (int j = 1; j <= W; ++j) {
			// 背包容量足
			if (j >= v[i]) {
				// max的第一个参数表示放第i个物品,第二个参数表示不放
				f[i][j] = max(f[i - 1][j - v[i]] + w[i], f[i - 1][j]);
			}
			// 背包容量不足
			else {
				f[i][j] = f[i - 1][j];
			}
		}
	}
	// 返回遍历完N件物品后的最后价值
	return f[N][W];
}

上述程序时间复杂度为 O ( V W ) O(VW) ,空间复杂度为 O ( V W ) O(VW) ,我们考虑对上述程序优化。时间复杂度上,由于我们必须遍历所有的物品和背包容量,两种循环保持不变;则我们考虑对空间复杂度优化。对于状态转移方程,我们观察到状态 i i 仅依赖于 i 1 i-1 ,考虑优化掉二维数组的第一维,则状态转移方程变为: f [ j ] = m a x ( f [ j v [ i ] ] + w [ i ] , f [ j ] ) f[j]={\rm max}(f[j-v[i]]+w[i],f[j])

其中, f [ j ] f[j] 的含义是背包容量为 j j 时产生的最大价值。在内层循环开始之前 f [ j ] f[j] 存储的是 i 1 i-1 状态的值,在内层循环结束后 f [ j ] f[j] 存储的是 i i 状态的值。现在我们需要 f [ j ] = f [ i 1 ] [ j ] f[j]=f[i-1][j] f [ j v [ i ] ] = f [ i 1 ] [ j v [ i ] ] f[j-v[i]]=f[i-1][j-v[i]] 的值完成状态转移。如果内层循环依然从左往右遍历,当前计算的 f [ j ] f[j] 值就会将上一次计算的值覆盖(而现在仍然在同一外层循环中,这样做肯定不能得到正确的状态转移),即实际转移方程为: f [ j ] = m a x ( f [ i ] [ j v [ i ] ] + w [ i ] , f [ i ] [ j ] ) f[j]={\rm max}(f[i][j-v[i]]+w[i],f[i][j])

为了避免以上情况,我们考虑将内层循环逆序。具体地,如果我们将背包容量由大到小遍历,即 j j 的值由大到小。则 f [ j ] f[j] f [ j v [ i ] ] f[j-v[i]] 表示的就是前一状态的背包容量,即上述的 f [ i 1 ] [ j ] f[i-1][j] f [ i 1 ] [ j v [ i ] ] f[i-1][j-v[i]] 表,即上一层结果的值。所以,空间优化后的代码为:

int maxValue(vector<int>& v, vector<int>& w){
	// 定义动态规划数组并初始化
	vector<int> h(W + 1, 0);
	// 用于遍历所有物品
	for(int i = 1; i <= N; ++i){
		// 用于遍历背包容量,循环下界保证背包剩余容量充足
		for(int j = W; j >= v[i]; --j){
			f[j] = max(f[j], f[j - v[i]] + w[i]);
		}
	}
	// 返回遍历完N件物品后的最大价值
	return f[W];
}

我们以图来模拟上述一维数组填表的过程。其中 N = 4 N=4 W = 10 W=10 ,物品重量信息为 v = [ 0 , 1 , 3 , 4 , 5 ] v=[0,1,3,4,5] 、价值信息为 w = [ 0 , 2 , 5 , 6 , 7 ] w=[0,2,5,6,7] 在这里插入图片描述

图3:值变化情况

在动态转移数组初始化时注意,对于两种不同的问题初始化方法有所不同。当问题要求将背包装满时,只有背包容量为 0 0 、总价值为 0 0 的情况才满足满背包的条件(由于后续状态通过前面状态转移,必须使前面状态满足问题条件);当问题没有要求将背包装满时,所有情况的背包容量均满足条件。第一种情况的返回值需要我们遍历数组,而第二种情况直接取数组最后一个元素。


4. 总结

动态规划是算法与数据结构中的一个大类,它可以通过以空间换时间的策略降低程序的复杂度,它通常作为递归的优化方法。对于动态规划,需要注意的几点是:

  • 使用动态规划求解的题目的特点是在求解过程中含有重叠子问题(这类问题一般是求某个情景下的和)或最优子结构(这类问题一般是求某个情景下的最值);
  • 一般情况下,可以直接根据题目问题直接定义动态规划数组,即以dp[i]dp[i][j]表示求解问题的过程解;
  • 一般情况下,如果题目数据给定是二维的,则动态规划数组一般为二维的,然后尝试通过改变状态转移方式优化为一维数组
  • 在求状态转移方程时,可以通过填表绘图的方式得到;
  • 根据初始情景下的状态确定动态规划数组的初值。

最后,给出 L e e t C o d e {\rm LeetCode} 中关于动态规划的题目,截止目前共有 200 + 200+ 道,可见其重要性。


参考

  1. https://leetcode-cn.com/tag/dynamic-programming/.


猜你喜欢

转载自blog.csdn.net/Skies_/article/details/105530748