动态规划设计方法

Posted Debroon

tags:

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

 


设计方法

  • 可用DP的题目:输入对象、所求的解带有从左至右的次序(如字符串字符、树的叶子),原问题分解成子问题满足无后效性
  • 最简单的算例:寻找子问题的重复性
  • 定义状态:确定优化目标(问题要求什么最多或者最少),想办法把当前局面描述出来(可能有多种模式)
  • 定义状态转移方程:如何决策出最优解(某个状态从哪里来?到哪里去?)
  • 确定边界条件:递归+备忘录的终止条件、递推式的初始条件或者边界条件
  • 如何定义 DP数组/函数 的含义来表述整个过程:正确的编写程序。

 
怎么用计算机科学的语言描述当前局面:

  • 设计状态,把面临的局面用变量x描述,局面要求的结果记为f(x)。

  • 设计状态转移方程,找出f(x)与哪些局面有关记为变量p。

状态x,从哪里来?到哪里去?

得到x的状态转移方程,状态x与相关局面p,结合再一起就是可以描述最优解的状态转移方程f(x)。
 


求解原理

假设我们有无限数量面值为 1、5、10 的 硬币,凑成 15 个硬币请问最少需要几个硬币 ?

15 的组合,

  • 15 = 10 + 5
  • 15 = 5 + 5 + 5
  • 15 = 10 + 1 + 1 + 1 + 1 +1
    ……

大家应该想到了,用 “贪心思想” 从高到低。尽量先用最大的10,再尽量用次大的 5,…

可如果我们有无限数量面值为 1、5、11 的 硬币,凑成 15 个硬币请问最少需要几个硬币 ?此次国家设计的硬币并没有按适合贪心的倍数规则来设计~

依然用 “贪心”,15 = 11 + 1 + 1 + 1 + 1 这样的组合花费了 5 枚。

可是,15 = 5 + 5 + 5 这样的组合花费了 3 枚,显然这才是最优解。

emmm… 咋办 ?枚举找到 15 的所有硬币组合吧,可以使用回溯算法吧(dfs)。

#include<iostream>
#include<vector>
using namespace std;


int sum = 0;
vector<int> sum_arr;  // 存储当前sum值,是哪些数组成
int min_sum = 99999;  // 存储目标结果,硬币最少组合数

void dfs(vector<int>input, int n){
	if(sum == n){
		min_sum = min_sum > sum_arr.size()?sum_arr.size():min_sum;  // 更新最少组合数
	}

	if(sum > n)       // 结束条件
		return;       

	for(int i=0; i<3; i++){              // 从选择列表里
	
		sum += input[i];                 // 做选择
		sum_arr.push_back(input[i]);     // 添加
		
		dfs(input, n);                   // 枚举下一个
		
		sum -= input[i];                 // 撤销当前选择
		sum_arr.pop_back();		         // 删除
	}
}

int main(){
	vector<int> input = {1, 5, 11};
	dfs(input, 15);
	cout << "硬币最少组合数:" << min_sum;  // 15 时,值为 3
}

但枚举时间复杂度太高,它把有用没用的信息都包含进去了。要提高算法效率,就少做无用功,我们只提取出有用的信息。

我们分析一下,15这个数可以从哪里来 ?

因为我们只有 1、5、11 的面值,所以 15 只能从 3 种面值过来也就是 14:(15-1)、10:(15-5)、4:(15-11)。

我们用计算机科学语言(变量、表达式、分支、函数)描述当前局面:

  • 设计状态,把面临的局面用状态x描述,对于状态x,在某个阶段把我们需要的答案记为f(x)。

    状态x:硬币问题中是硬币凑的数额

    某个阶段的答案f(x):硬币问题中是凑硬币的最少数量,硬币问题是求f(15)

  • 设计状态转移方程,找出f(x)与哪些局面有关记为变量p。目标记为f(T),所有阶段组合起来

    我们要推出目标f(T),就需要知道状态x是怎么变化的 — 请问状态x,从哪里来?到哪里去?

    比如 x=15 时,f(15) 可以写成:

    • f(14) +1,+1 是取了面值为 1 的硬币,现在变成凑 14;
    • f(10) +1,+1 是取了面值为 5 的硬币,现在变成凑 10;
    • f(4) +1,+1 是取了面值为 11 的硬币,现在变成凑 4;

     
    那么,f(15)的最优解即 f(15) = { min( f(14) , f(11) , f(4) ) + 1 },那么我们知道了f(15),可是不知道 f(14) , f(11) , f(4)。

    那么,我们再分析 f(14) , f(11) , f(4) 从哪里来 ?

    同理呀,这三个也都只能从 1、5、11 的面值取,所以一直分解即可。

    f(15) 与 f(14) , f(11) , f(4) 有关,如此反复,每个大问题的最优解可由小问题的最优解推出,用归纳思想就可以得到。

    与f(x)相关的状态有 f(x-1),f(x-5),f(x-11) ,那f(x)是从那个状态推过来的呢?

    最少硬币数的话,状态转移方程f(T):f(T) = min( f(x-1), f(x-5), f(x-11) ) + 1

动态规划的基本设计思路就是如此,我们分析的时候是从后往前,具体代码需要写成从前往后。

所以,我们还得从前往后推一遍,方便程序编写。

  1. 当 x = 0 时,很显然我们可以知道 f(0) = 0。因为不要凑钱了嘛,当然也不需要任何硬币了。注意这是很重要的一步,其后所有的结果都从这一步延伸开来。
  2. 当 x = 1 时,因为我们有 1 元的硬币,所以直接在第 1 步的基础上,加上 1 个 1 元硬币,得出 f(1) = 1。
  3. 当 x = 2 时,因为我们并没有 2 元的硬币,所以只能拿 1 元的硬币来凑。在第 2 步的基础上,加上 1 个 1 元硬币,得出 f(2) = 2。
  4. 当 x = 3 时,我们可以在第 3 步的基础上加上 1 个 1 元硬币,得到 3 这个结果,得出 f(3) = 3。
  5. 当 x = 4 时,我们可以在第 4 步的基础上加上 1 个 1 元硬币,得到 4 这个结果,得出 f(4) = 4。
  6. 当 x = 5 时,我们可以在第 5 步的基础上加上 1 个 1 元硬币,得到 5 这个结果,得出 f(5) = 5。但是,但其实我们有 5 元硬币,所以这一步的最优结果不是建立在第 5 步(前一步)的结果上得来的,而是应该建立在第 1 步上,加上 1 个 5 元硬币,得到 f(5) = 1
#include <iostream>
using namespace std;

int min( int x, int y, int z ) {           // 求三位数最小值
    return (x>y?y:x)>z?z:(x>y?y:x);
}

void DP( int nums ) {      
    int dp[1000] = {0};                    // 存储部分结果,实现记忆化搜索
    
    for( int x = 1; x <= nums; x ++ ) {    // x 从哪里来,从 1 考虑到 nums 递推实现
        if( x >= 11 )                      // 先试试 11,再试试 5,最后试试 1,贪心
             dp[x] = min( dp[x-1]+1, dp[x-5]+1, dp[x-11]+1 );
        else if( x >= 5 )
             dp[x] = min( dp[x-1]+1, dp[x-5]+1 );
        else if( x >= 1 )
             dp[x] = dp[x-1]+1;
         
        printf(" %-3d, 最少需要 %-3d 枚硬币组成 \\n", x, dp[x]);
    }
}

int main(){
    int nums;
    scanf("%d", &nums);                     // 输入测试数据,如 15
    DP(nums);
}

运行结果:

输入:
 15
 
输出:
 1  , 最少需要 1   枚硬币组成
 2  , 最少需要 2   枚硬币组成
 3  , 最少需要 3   枚硬币组成
 4  , 最少需要 4   枚硬币组成
 5  , 最少需要 1   枚硬币组成
 6  , 最少需要 2   枚硬币组成
 7  , 最少需要 3   枚硬币组成
 8  , 最少需要 4   枚硬币组成
 9  , 最少需要 5   枚硬币组成
 10 , 最少需要 2   枚硬币组成
 11 , 最少需要 1   枚硬币组成
 12 , 最少需要 2   枚硬币组成
 13 , 最少需要 3   枚硬币组成
 14 , 最少需要 4   枚硬币组成
 15 , 最少需要 3   枚硬币组成 

LeetCode相关的练习:

我们来对比一下,回溯和动态规划,只看代码量您可能感觉俩者差不多,但是从时间复杂度来说,动态规划是把一个平方复杂度问题,变成了一个线性复杂度问题。

其实回溯和动态规划都是在穷举,只是动态规划发现了硬币问题的最优子结构,解决了重叠子问题的计算,以前计算过的子问题,可以直接用,而不用像回溯一样重新计算。

  • 硬币问题的最优子结构:f(15) 与 f(14) , f(10) , f(4) 有关,当前问题的最优解可由过去问题的最优解推出。

  • 硬币问题的重叠子问题:动态规划用备忘录保存了子问题的解,避免重复计算。

    f(15) 的解来自 f(14) , f(10) , f(4)

    f(14) 的解来自 f(13),f(9),f(3)

    f(13) 的解来自 f(12),f(8),f(2)

    f(12) 的解来自 f(11),f(7),f(1)

    f(11) 的解来自 f(10),f(6),0

    f(10) 的解来自 f(9),f(5),\\

    …可以发现,f(10)、f(14) 都有计算 f(9),他们之间重叠子问题,如果是回溯的话,这个重叠的子问题会计算很多次,而动态规划用一个数组保存了这个值,只会计算一次。

动态规划:将原问题拆解为若干个子问题,同时保存子问题的答案,使得每个子问题的只求解一次,最终获得原问题的答案。

此外,并不是只有用递推关系式的方式实现的代码才是动态规划,只要子问题满足无后向性,用递归辅助备忘录的形式也是动态规划。

根据我玩 OJ(算法游戏)的经验,一些问题如果最优解递推关系式不明显,直接编写代码有困难,可以尝试用分治 + 穷举的方法,只要子问题满足无后向性要求,用辅助备忘录的形式消除子问题的重复求解开销,基本上都能满足 OJ 题目对运行时间的要求。

 


常见递推关系式

动态规划算法有三个要素:

  • 所有不同的子问题所组成的表(它包含的问题数目称为问题的大小,即 size);
  • 问题解决的依赖关系可以看成是一个图;
  • 填充子问题的顺序(实际上就是(2)所得到的图的一个拓扑排序)。

如果子问题的数组为 θ ( n t ) \\theta(n^{t}) θ(nt),且每个子问题需要依赖于 θ ( n e ) \\theta(n^{e}) θ(ne) 个其他子问题,则称这个问题为 t D e D \\frac{tD}{eD} eDtD 问题。

总结起来可得到四种典型的动态规划关系递推方程。
 


1 D 1 D \\frac{1D}{1D} 1D1D

定义一个实函数 w ( i , j ) ( 1 ≤ i < j ≤ n ) w(i,j)(1\\leq i< j\\leq n) w(i,j)(1i<jn),已知 D [ 0 ] D[0] D[0],状态转移方程:

  • E [ j ] = o p t 0 ≤ i < j { D [ i ] + w ( i , j ) } , i ≤ j ≤ n E[j]=\\underset {0\\leq i<j}{opt} \\{D[i]+w(i,j)\\},i\\leq j\\leq n E[j]=0i<jopt{D[i]+w(i,j)},ijn

P.S. opt 是最优关系,可能是 max,也可能是 min。

其中, D [ i ] D[i] D[i] 可以根据 E [ i ] E[i] E[i] 在常数时间内计算出来。

《算法导论》里的切分钢条,锯木头问题都是这种结构,具体的理解,您要在行动中来思考。
 


2 D 0 D \\frac{2D}{0D} 0D2D

已知 D [ i , 0 ] D[i,0] D[i,0] D [ 0 , j ] ( 0 ≤ i , j ≤ n ) D[0,j](0\\leq i,j\\leq n) D[0,j](0i,jn),状态转移方程为:

  • E [ i , j ] = o p t { D [ i − 1 , j ] + x i , D [ i , j − 1 ] + y i ,   D [ i − 1 , j − 1 ] + z i j } E[i,j]=opt\\{D[i-1,j]+x_{i},D[i,j-1]+y_{i}, ~D[i-1,j-1]+z_{ij}\\} E[i,j]=opt{D[i1,j]+xiD[i,j1]+yi, D[i1,j1]+zij}

其中, x i , y i , z i x_{i},y_{i},z_{i} xi,yi,zi 都是可以在常数时间内算出来的。

线性DP、串DP 问题,多是这种结构,具体的理解,您要在行动中来思考。
 


2 D 1 D \\frac{2D}{1D} 1D2Android在片段中动态更改部分布局的最佳方法

动态 Rstudio 代码片段

是否可以动态编译和执行 C# 代码片段?

支持动态或静态片段的不同屏幕尺寸?

Java语言基础之方法的设计

Android片段中的动态背景