动态规划初级试炼场

Posted 小小算法

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了动态规划初级试炼场相关的知识,希望对你有一定的参考价值。

动态规划初级试炼场

友情提示:

本文中列举的部分例题,并不一定只能用动态规划求解,但文中只会给出问题的动态规划解法。

这篇文章首先会介绍一下下动态规划的一般解题思路,然后会按照这个思路讲解四个 上的经典动态规划题目,相信你会有所收获的!

动态规划解题一般分为三步:

  1. 表示状态

每一种状态都是使用数组的维数来表示的。

一般我们会先使用数组的第一维来表示阶段,然后再根据需要通过增加数组维数,来添加其它状态。

  1. 找出状态转移方程

状态转移指的是,当前阶段的状态如何通过之前计算过的阶段状态得到。

  1. 初始化边界

初始化边角状态,以及第一个阶段的状态。

简短的介绍结束了,下面将迎来我们的第一个问题。

53. 最大子序和[1]

给定一个整数数组 ,找到一个具有最大和的连续子数组(子数组最少包含一个元素),并返回其最大和。

示例:

输入: [-2,1,-3,4,-1,2,1,-5,4],
输出: 6
解释: 连续子数组 [4,-1,2,1] 的和最大,为 6。

思路分析

首先是对这个问题进行阶段划分。我们可以把这个长度为 的数组划分为 个阶段,例如第 个阶段代表前 个元素的最大子序和,我们用 来表示 。

使用数组的第一维来表示阶段:

表示第 个阶段,也就是前 个元素的最大子序和。

表示第 个阶段,也就是前 个元素的最大子序和。

......

仅仅表示了阶段 ,我们还是很难看出 之间的转移关系。因此我们需要再寻找对转移策略有影响的其它状态,我们发现 的转移策略与它们的结尾元素是否被选择有关,因此再添加一维来表示对应阶段结尾元素的选取状态。具体情况请往下看。

  1. 状态表示

表示前 个元素中,不选择第 个元素时的最大子序和

表示前 个元素中,选择第 个元素时的最大子序和

  1. 状态转移
  1. 边界处理

代码

int maxSubArray(vector<int>& nums) {
    int dp[100010][2];
    int len = nums.size();
    dp[0][0] = INT_MIN;
    dp[0][1] = nums[0];
    for (int i = 1; i < len; i ++) {
        dp[i][0] = max(dp[i-1][0], dp[i-1][1]);
        dp[i][1] = max(dp[i-1][1] + nums[i], nums[i]);
    }
    return max(dp[len-1][0], dp[len-1][1]);
}

另一种思路

在这我们换一种状态表示的方法,帮助大家开阔思路。我们通过动态规划的方式,计算出以每个元素作为结尾的最大子序和,然后在这些结果中选取最大的一个,作为最终的结果。

  1. 状态表示

表示以第 个元素作为结尾时的最大子序和

  1. 状态转移
  1. 边界处理

代码

int maxSubArray(vector<int>& nums) {
    int dp[100010];
    dp[0] = nums[0];
    for (int i = 1; i < nums.size(); i ++) {
        dp[i] = max(nums[i], nums[i] + dp[i-1]);
    }
    int ret = INT_MIN;
    for (int i = 0; i < nums.size(); i ++) {
        ret = max(ret, dp[i]);
    }
    return ret;
}

188. 买卖股票的最佳时机 IV[2]

给定一个数组,它的第 个元素是一支给定的股票在第 天的价格。设计一个算法来计算你所能获取的最大利润。你最多可以完成 笔交易。

注意: 你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。

示例 1:

输入: [2,4,1], k = 2
输出: 2
解释: 在第 1 天 (股票价格 = 2) 的时候买入,
在第 2 天 (股票价格 = 4) 的时候卖出,
这笔交易所能获得利润 = 4-2 = 2 。

思路分析

同样还是先划分阶段, 表示我们通过前 天的买卖获得的最大利润, 可以由 转移得到。但状态表示不足,我们无法得到它们之间的转移策略,因此我们需要再添加状态。

  1. 题目对交易次数有限制,因此我们需要添加一个状态,来表示对应阶段剩余的交易次数。
  2. 题目要求不能同时参与多笔交易,因此我们需要再添加一个状态,来表示对应阶段是否已经参与了交易,也就是是否持有股票。
  1. 状态表示

表示第 天剩余的交易次数为 ,并且未持有股票时获得的最大利润

表示第 天剩余的交易次数为 ,并且持有股票时获得的最大利润

  1. 状态转移

天未持有股票,有两种情况:

  1. 天未持有股票
  2. 天持有股票,但第 天卖出(卖出记为一次交易,所以前 天的剩余交易次数为

天持有股票,也有两种情况:

  1. 天就持有股票
  2. 天未持有股票,但第 天买入(买入不算完成一次交易)
  1. 边界处理
//剩余次数最多为 k,因此 k+1 天不能持有股票
for (int i = 0; i < 1005; i ++) {
    dp[i][k+1][1] = INT_MIN;
}
//第 1 天未持有股票,获利为 0
//第 1 天持有股票,也就是在第一天买入,获利为-prices[0]
for (int j = k; j >= 0; j --) {
    dp[0][j][0] = 0;
    dp[0][j][1] = -prices[0];
}

未经优化的 DP 代码(不能通过)

int maxProfit(int k, vector<int>& prices) {
    int len = prices.size();
    if (len == 0) {return 0;}
    int dp[1005][105][2];
    for (int i = 0; i < 1005; i ++) {
        dp[i][k+1][1] = INT_MIN;
    }
    for (int j = k; j >= 0; j --) {
        dp[0][j][0] = 0;
        dp[0][j][1] = -prices[0];
    }
    for (int i = 1; i < len; i ++) {
        for (int j = k; j >= 0; j --) {
            dp[i][j][1] = max(dp[i-1][j][1], dp[i-1][j][0] - prices[i]);
            dp[i][j][0] = max(dp[i-1][j][0], dp[i-1][j+1][1] + prices[i]);
        }
    }
    return dp[len-1][0][0];
}

上面的这个代码是不能通过的,因为有的测试用例 非常大,而我们的时间复杂度和空间复杂度都和 有关。

下面我将给出优化后的代码,但并不详细讲解优化。解释两点:

  1. 是交易次数,交易一次需要花费两天(买入再卖出),因此如果题目给出的 ,那就变成了 122. 买卖股票的最佳时机 II [3] 这个问题,那我们就可以用贪心来解决,时间复杂度为 .
  2. 用于表示阶段的第一维可以优化掉(直接删掉就可以了),从而数组变成了两维。

优化之后的代码(可通过)

int maxProfit(int k, vector<int>& prices) {
    int len = prices.size();
    if (k > len / 2) {
        int ret = 0;
        for (int i = 0; i < len-1; i ++) {
            if (prices[i+1] > prices[i]) {
                ret += prices[i+1] - prices[i];
            }
        }
        return ret;
    }
    int dp[10005][2];
    dp[k+1][1] = INT_MIN;
    for (int j = k; j >= 0; j --) {
        dp[j][0] = 0;
        dp[j][1] = -prices[0];
    }
    for (int i = 1; i < len; i ++) {
        for (int j = k; j >= 0; j --) {
            dp[j][1] = max(dp[j][1], dp[j][0] - prices[i]);
            dp[j][0] = max(dp[j][0], dp[j+1][1] + prices[i]);
        }
    }
    return dp[0][0];
}

5383. 给 N x 3 网格图涂色的方案数[4]

你有一个 n x 3 的网格图 ,你需要用 红,黄,绿 三种颜色之一给每一个格子上色,且确保相邻格子颜色不同(也就是有相同水平边或者垂直边的格子颜色不同)。

给你网格图的行数 。请你返回给 涂色的方案数。由于答案可能会非常大,请你返回答案对 取余的结果。

示例 1:

输入:n = 1
输出:12
解释:总共有 12 种可行的方法:

注意,这张图片代表的仅仅是一行中三个单元格的所有可能涂色方案。

思路分析

首先划分阶段, 表示前 行单元格的涂色方案总数, 可以由 转移得到。但状态表示不足,我们无法得到它们之间的转移策略,因此我们需要再添加状态。

题目要求相邻格子颜色不能相同,因此我们必须添加一维,来表示 末尾行的三个格子的涂色方案。

例如, 末尾(第 行)的三个格子涂色为 红 黄 绿 ,那 末尾(第 行)的三个格子就不能涂色为 红 黄 绿 或者 红 绿 红 等,因为这些格子的颜色会与第 行相邻格子的颜色出现相同的情况。

到这,我们其实还不明确每个阶段末尾那行的状态有哪些,也就是涂色方案有哪些,但我们知道它们是由红黄蓝三种颜色形成的排列组合,因此我们可能把它们的所有可能方案枚举出来,保存在数组 all 中。这里需要注意两点:

  1. 我们用 三个数字,分别表示 红 黄 绿 三种颜色。
  2. 题目要求相邻格子的颜色不能相同,因此我们需要在枚举的过程中添加一个判断,作为筛选。
vector<vector<int>> all;
for (int i = 0; i < 3; i ++) {
    for (int j = 0; j < 3; j ++) {
        for (int k = 0; k < 3; k ++) {
            // 第1个格子和第2个相邻或者第2和第3响铃
            if (i == j || j == k) {
                continue;
            }
            all.push_back({i, j, k});
        }
    }
}
  1. 状态表示

表示前 行格子中,第 行格子涂色方案为 的所有方案总数。

其中 表示的方案是 all[j] 中的颜色组合。

  1. 状态转移
f(n)(j) = 0;
for (int k = 0; k < len; k ++) {
    // isValid函数用于判断这两个状态是否存在相邻同色的元素
    if (isValid(all[j], all[k])) {
        f(n)(j) += f(n-1)(k);
    }
}
  1. 边界处理

第一行的三个格子的涂色方案可以为 all 中的任意涂色方案

for (int i = 0; i < all.size(); i ++) {
    f(0)(i) = 1;
}

代码

bool isValid(vector<int>& a, vector<int>& b) {
    for (int i = 0; i < 3; i ++) {
        if (a[i] == b[i]) {
            return false;
        }
    }
    return true;
}

int numOfWays(int n) {
    int mod = 1000000007;
    vector<vector<int>> all;
    for (int i = 0; i < 3; i ++) {
        for (int j = 0; j < 3; j ++) {
            for (int k = 0; k < 3; k ++) {
                if (i == j || j == k) {
                    continue;
                }
                all.push_back({i, j, k});
            }
        }
    }
    int len = all.size();
    int dp[5005][12];
    for (int i = 0; i < len; i ++) {
        dp[0][i] = 1;
    }
    for (int i = 1; i < n; i ++) {
        for (int j = 0; j < len; j ++) {
            dp[i][j] = 0;
            for (int k = 0; k < len; k ++) {
                if (isValid(all[j], all[k])) {
                    dp[i][j] = (dp[i][j] + dp[i-1][k]) % mod;
                }
            }
        }
    }
    // 最后的结果为最后一个阶段的所有状态之和
    int ret = 0;
    for (int i = 0; i < len; i ++) {
        ret = (ret + dp[n-1][i]) % mod;
    }
    return ret;
}

72. 编辑距离[5]

给你两个单词 ,请你计算出将 转换成 所使用的最少操作数 。

你可以对一个单词进行如下三种操作:

  1. 插入一个字符
  2. 删除一个字符
  3. 替换一个字符

示例 1:

输入:word1 = "horse", word2 = "ros"
输出:3
解释:
horse -> rorse (将 'h' 替换为 'r')
rorse -> rose (删除 'r')
rose -> ros (删除 'e')

思路分析

先划分阶段。这是双序列型动态规划问题, 表示第一个序列的前 个元素和第二个序列的前 个元素共同形成的一个状态。 这个阶段的状态可以由哪些阶段转换而来呢?

我们最终是将 转换为 ,我们分析其中任意的一个阶段 表示,处理到了 的第 个字符, 的第 个字符,这时 , , 都已经处理好了。

接下来我们就只需要判断 是否相等,以及在经过插入、删除和替换操作后,所获得的结果中选择最优的一个。

  1. 状态表示

表示处理到 的第 个字符和 的第 个字符时的最少操作次数。

  1. 状态转移
  1. 边界处理

初始化 为空串的情况.

for (int i = 0; i <= len1; i ++) {
    f(i)(0) = i;
}
for (int j = 0; j <= len2; j ++) {
    f(0)(j) = j;
}

代码

int minDistance(string word1, string word2) {
    int dp[1001][1001];
    int len1 = word1.size();
    int len2 = word2.size();
    for (int i = 0; i <= len1; i ++) {
        dp[i][0] = i;
    }
    for (int j = 0; j <= len2; j ++) {
        dp[0][j] = j;
    }
    for (int i = 1; i <= len1; i ++) {
        for (int j = 1; j <= len2; j ++) {
            if (word1[i-1] == word2[j-1]) {
                dp[i][j] = dp[i-1][j-1];
            } else{
                int temp[] = {dp[i][j-1], dp[i-1][j], dp[i-1][j-1]};
                dp[i][j] = (*min_element(temp, temp+3)) + 1;
            }
        }
    }
    return dp[len1][len2];
}

最后

动态规划的关键在于状态的表示,只要状态表示出来了,转移方程和边界处理也就能够比较容易的推出。在表示状态的时候一般先表示阶段,然后再看是否还需要添加其它的状态。

有时仅表示出阶段就足以解题,我们就不需要在添加其它状态,例如最后一个例题:72. 编辑距离[6]

有时表示的状态种类需要达到三维,甚至更多,例如第二个例题:188. 买卖股票的最佳时机 IV[7]

有时一种状态里面又包含大量的信息,需要我们先枚举出该状态的所有值,然后对每个值分别进行转移,例如倒数第二个例题:5383. 给 N x 3 网格图涂色的方案数[8]

尽管动态规划题型千变万化,但解题步骤是不变的,只要多多练习就可以熟练掌握的。

我也只是个菜鸡,只能总结这么多了,一起加油。

参考资料

[1]

53. 最大子序和: https://leetcode-cn.com/problems/maximum-subarray/

[2]

188. 买卖股票的最佳时机 IV: https://leetcode-cn.com/problems/best-time-to-buy-and-sell-stock-iv/

[3]

122. 买卖股票的最佳时机 II: https://leetcode-cn.com/problems/best-time-to-buy-and-sell-stock-ii/

[4]

5383. 给 N x 3 网格图涂色的方案数: https://leetcode-cn.com/problems/number-of-ways-to-paint-n-x-3-grid/

[5]

72. 编辑距离: https://leetcode-cn.com/problems/edit-distance/

以上是关于动态规划初级试炼场的主要内容,如果未能解决你的问题,请参考以下文章

洛谷试炼场 普及常见模板

洛谷普及试炼场之旅

洛谷试炼场 提高模板-nlogn数据结构

洛谷 试炼场 P1233 排队接水 (排序,贪心)

record洛谷试炼场_过程与递归函数_p1028

初级算法-动态规划