一、简介
动态规划是一种用于解决具有重叠子问题和最优子结构性质的复杂问题的算法设计技巧。它将问题分解为更小的子问题,并通过保存这些子问题的解来避免重复计算。
一个问题必须拥有重叠子问题和最优子结构才能使用动态规划去解决。
1、具有重叠子问题:如果问题可以被分解为多个相同或相似的子问题,并且这些子问题的结果可以被重复利用,如斐波那契数列和背包问题。
2、最优子结构:如果一个问题的最优解可以由其子问题的最优解来构建,那么该问题有最优子结构的性质,如最短路径问题和最长公共子序列问题。
步骤:①找出最优解的性质,并刻画其结构特征;②递归地定义最优值;③以自底向上的方式计算最优值;④根据计算最优值时得到的信息,构造最优解。
二、通过经典问题--斐波那契数列来看动态规划
斐波那契数列定义:F(0) =F(1) = 1, F(n) = F(n-1) + F(n-2) (当n>=2)
2.1 递归
int F(n)
{
if(n==0 || n == 1) return 1;
else return F(n-1) + F(n-2);
}
时间复杂度:O(2^N)
空间复杂度:O(N)
在计算斐波那契数列第n项时,递归方法会不断重复计算相同的子问题。例如:计算F(5)时需要F(4)和F(3)的值,在计算F(4)时需要计算F(3)和F(2)的值,其中F(3)就被计算了两遍,所以简单的递归会造成大量重复计算。为了避免重复计算,可以使用动态规划来优化这个问题,通过保存已经计算过的子问题的结果(即记忆化),我们可以显著提高算法的效率。
2.2 动态规划:自顶向下(递归+记忆化)
int dp[n];
我们可以使用一个一维数组来保存已经计算过的结果,-1表示还没有被计算过。
int F(n)
{
if(n == 0 || n == 1) return 1;
//如果已经计算过,直接返回
if(dp[n]!=-1) return dp[n];
dp[n] = F(n-1) + F(n-2);
return dp[n];
}
时间复杂度:O(N)
空间复杂度:O(N)
2.3 动态规划:自底向上(迭代法)
也可以使用递推的方法,从基础情况F(0) = 1, F(1) = 1开始,逐步计算到F(N)。
int F(n)
{
if(n == 0 || n == 1) return 1;
int f1 = 1, f2 = 1;
for(int i = 2; i <=n; ++i)
{
int temp = f1 + f2;
f2 = f1;
f1 = temp;
}
return f1;
}
时间复杂度:O(N)
空间复杂度:O(1)
三、备忘录方法
备忘录方法是动态规划算法的变形。区别是备忘录方法的递归方式是自顶向下的,且备忘录方法为每个解过的子问题建立了备忘录以备需要时查看,避免了相同子问题的重复求解。
备忘录方法为每个子问题建立一个记录项,初始化时,该记录项存入一个特殊的值,表示该子问题尚未求解。在求解过程中,对每个待求的子问题,首先查看其相应的记录项。若记录项中存储的是初始化时存入的特殊值,则表示该子问题是第一次遇到,此时计算出该子问题的解,并保存在其相应的记录项中,以备以后查看。若记录项中存储的已不是初始化时存入的特殊值,则表示该子问题已被计算过,其相应的记录项中存储的是该子问题的解答。此时,只要从记录项中取出该子问题的解答即可,而不必重新计算。
四、例题(C++,随缘更新)
1、独立任务最优调度问题
题目来源:计算机算法设计与分析(第五版)王晓东
问题描述:用用2台处理机A和B处理n个作业。设第i个作业交给机器A处理时需要时间a,若由机器B 来处理,则需要时间b。由于各作业的特点和机器的性能关系,很可能对于某些i,有a>b,而对于某些j,有a<b。既不能将一个作业分开由2台机器处理,也没有一台机器能同时处理2个作业。设计一个动态规划算法,使得这2台机器处理完这个作业的时间最短(从任何一台机器开工到最后一台机器停工的总时间)。
输入示例:
6
2 5 7 10 5 2
3 8 4 11 3 4
输出示例:
15
问题分析:该问题属于二维动态规划问题,核心任务是通过动态规划找到一组作业分配方案,使得两台机器的总处理时间最短。
动态规划思路:
(1)定义状态:用dp[i][j]表示前i个作业中分配后机器A处理时间为j时,机器B处理的最小处理时间。
(2)状态转移方程:
i.分配给机器A:当前状态的处理时间将由前i-1个作业中,机器A的处理时间为t得来,即:dp[i][t+a[i-1]] = min(dp[i][t+a[i-1]],dp[i-1][t])
ii.分配给机器B:当前状态的处理时间将由前i-1个作业中,给机器A分配j个作业的情况转移而来,即:dp[i][t] = min(dp[i][t],dp[i-1][t] + b[i-1])
(3)初始状态:dp[0][0] = 0表示没有作业时,机器A和机器B的处理时间都为0
动态规划表构建:通过二维数组dp[i][t]来存储中间计算结果,逐步从小问题构造大问题的解,最终结果将通过遍历dp[n][t]得到
示例中的动态规划表: (X代表最大时间)
算法实现:
#include <iostream>
#include <algorithm>
using namespace std;
// 动态规划算法,求解最优调度方案
int minTime(int n, int a[], int b[]) {
//求机器A的最大处理时间,即所有作业都交给A处理
int totalA = 0;
for(int i = 0; i < n; ++i)
{
totalA+=a[i];
}
//一共有n个作业,则有已处理0~n个作业,一共n+1种情况,同理机器A的处理时间也有totalA+1种情况
int dp[n+1][totalA+1];
// 初始化 dp 数组为较大值
for (int i = 0; i <= n; ++i) {
for (int j = 0; j < totalA+1; ++j) {
dp[i][j] = totalA;
}
}
// 初始状态,没有任务时,A和B的时间都是0
dp[0][0] = 0;
// 动态规划,遍历每个任务
for (int i = 1; i <= n; ++i) {
for (int t = 0; t < 11totalA+1; ++t) {
//if语句:确保在动态规划状态转移过程中,只处理已经有解的状态
if (dp[i-1][t] != totalA) {
//在已经分配了i-1个作业,机器A的处理时间为t的情况下
// 将第i个作业分配给机器A
dp[i][t + a[i-1]] = min(dp[i][t + a[i-1]], dp[i-1][t]);
// 将第i个作业分配给机器B
dp[i][t] = min(dp[i][t], dp[i-1][t] + b[i-1]);
}
}
}
// 找到两台机器处理完所有作业的最短时间
int min_time = totalA;
for (int t = 0; t < totalA+1; ++t) {
//机器A和机器B的最大处理时间即为总处理时间
int max_time = max(t, dp[n][t]);
//找最小总处理时间
min_time = min(min_time, max_time);
}
return min_time;
}
int main() {
int n;
cin >> n;
int a[n], b[n];
for(int i=0;i<n;i++)//输入机器A的时间
{
cin >> a[i];
}
for(int i=0;i<n;i++)//输入机器B的时间
{
cin >> b[i];
}
int result = minTime(n, a, b);
cout << result << endl;
return 0;
}
时间复杂度:O(N*T)
空间复杂度:O(N*T)
2、石子合并问题
题目来源:计算机算法设计与分析(第五版)王晓东
问题描述:在一个圆形操场的四周摆放着n堆石子。现要将石子有次序地合并成一堆,规定每次只能选相邻的2堆石子合并成新的一堆,并将新的一堆石子数记为该次合并的得分试设计一个算法,计算出将n堆石子合并成一堆的最小得分和最大得分。
数据输入:第1行是正整数n(I≤n≤100),表示有n堆石子。第2行有n个数,分别表示每堆石子的个数。
结果输出:第1行的数是最小得分,第2行中的数是最大得分。
示例:
输入示例:
4
4 4 5 9
输出示例:
43
54
样例解释:每次合并后的石子数量就是你的得分,最后算总和
最低:1、(4,4,5,9)将前第一堆和第二堆合并得到(8,5,9)
2、(8,5,9)将前两个合并得到(13,9)
3、最后合并得到22,总分为:8+13+22=43
最高:1、(4,4,5,9)先将5和9合并得到(4,4,14)
2、将4和13合并得到(4,18)
3、最后合并得到22,总分为14+18+22=54
题目分析:这是一个经典的区间动态规划问题,题目中说的是圆形操场,即石子是环形排列的,我们可以将问题转化为线性问题,通过将石子序列展开两遍,即变成一条长度为2n的序列,从中截取长度为n的区间来模拟环形结构
动态规划状态定义:
(1)dp_min[i][j]表示将第i堆石子与第j堆石子合并成一堆的最小得分
(2)dp_max[i][j]表示将第i堆石子与第j堆石子合并成一堆的最大得分
初始条件:单个堆石子的得分为0,即dp_min[i][i] = dp_max[i][i] = 0;
状态转移方程:
dp_min[i][j] = min(dp_min[i][j], dp_min[i][k] + dp_min[k+1][j] + sum[i][j]);
dp_max[i][j] = max(dp_max[i][j], dp_max[i][k] + dp_max[k+1][j] + sum[i][j]);
#include <iostream>
using namespace std;
// 辅助函数计算石子堆的总和
int getSum(int pre_sum[], int i, int j, int n) {
if (i <= j)
return pre_sum[j + 1] - pre_sum[i];
return pre_sum[n] - pre_sum[i] + pre_sum[j + 1];
}
// 动态规划算法,计算最小得分和最大得分
void stoneMerge(int num[], int n, int& minScore, int& maxScore) {
int pre_sum[2*n];
pre_sum[0] = 0;
for(int i = 1; i < 2*n; ++i)
{
pre_sum[i] = pre_sum[i-1] + num[i-1];
}
int dp_min[n*2][n*2];
int dp_max[n*2][n*2];
for(int i = 0; i < 2*n; ++i)
{
for(int j = 0; j < 2*n; ++j)
{
dp_max[i][j] = 0;
if(j == i)
{
dp_min[i][j] = 0;
}
else
{
dp_min[i][j] = 200;
}
}
}
// 开始动态规划,枚举区间长度 len
for (int len = 2; len <= n; ++len) {
for (int i = 0; i + len - 1 < 2 * n; ++i) {
int j = i + len - 1;
// 枚举合并位置 k
for (int k = i; k < j; ++k) {
int sum_val = getSum(pre_sum, i, j, n);
dp_min[i][j] = min(dp_min[i][j], dp_min[i][k] + dp_min[k + 1][j] + sum_val);
dp_max[i][j] = max(dp_max[i][j], dp_max[i][k] + dp_max[k + 1][j] + sum_val);
}
}
}
// 取最优结果,最小得分和最大得分
minScore = INT_MAX;
maxScore = 0;
for (int i = 0; i < n; ++i) {
minScore = min(minScore, dp_min[i][i + n - 1]);
maxScore = max(maxScore, dp_max[i][i + n - 1]);
}
}
int main() {
int n;
cin >> n;
int num[2*n];
for (int i = 0; i < n; ++i) {
cin >> num[i];
num[i+n] = num[i];
}
int minScore, maxScore;
stoneMerge(num, n, minScore, maxScore);
cout << minScore << endl;
cout << maxScore << endl;
return 0;
}
3、数字三角形问题
题目来源:计算机算法设计与分析(第五版)王晓东
问题描述:给定一个由n行数字组成的数字三角形。试设计一个算法,计算出从三角形的顶至底的一条路径,使该路径经过的数字总和最大。
算法设计:对于给定的由n行数字组成的数字三角形,计算从三角形的项至底的路径经过的数字和的最大值。
数据输入:第1行是数字三角形的行数1≤n≤100。接下来n行是数字三角形各行中的数字。所有数字在0-99之间。
输入示例:
5
7
3 8
8 1 0
2 7 4 4
4 5 2 6 5
输出示例:
30
题目分析:这道题可以通过自底向上的方式解决。
状态定义:我们可以用一个二维数组dp[i][j]来存储三角形的数字,并且用它来存储自底向上计算时的中间结果
状态转移方程:对于三角形的每一个节点都有两个子节点,那么从当前节点到三角形底部的最大路径和即为,它的两个子节点的最大路径和中最大的一个加上当前节点的值,即
dp[i][j] = dp[i][j] + max(dp[i-1][j], dp[i-1][j+1])
#include <iostream>
using namespace std;
int main(int argc, char** argv) {
int n;
cin >> n;
int dp[n][n];
//输入数字三角形
for(int i = 0; i < n; ++i)
{
for(int j = 0; j <= i; ++j)
{
cin >> dp[i][j];
}
}
//自底向上计算
for(int i = n-2; i >= 0; --i)
{
for(int j = 0; j <= i; ++j)
{
dp[i][j] = dp[i][j] + max(dp[i+1][j],dp[i+1][j+1]);
}
}
cout << dp[0][0] << endl;
return 0;
}
时间复杂度:O(N^2)
空间复杂度:O(N^2)