剑指offer总结——动态规划篇

前言

什么是动态规划?

根据百度百科,动态规划就是把多阶段过程转化为一系列单阶段问题,利用各阶段之间的关系,对其逐个求解的算法。动态规划最经典的应用是解决背包问题和最短路径问题。剑指offer中也有一些题都可以运用这种解法。

这一章就从剑指offer中基础的题目入手,对动态规划算法深入了解一下。

7、斐波那契数列

大家都知道斐波那契数列,现在要求输入一个整数n,请你输出斐波那契数列的第n项(从0开始,第0项为0)。 n<=39

思路分析:

这是动态规划最最最基础的题目。
根据斐波那契规则,第n个数等于n-1和n-2之和,第0项是0,第一项是1。
斐波那契数列是:0 1 1 2 3 5 8 11 19 ……

可以用一个数组,存储满足n<=39所有的斐波那契数列,输出最后一项就好了。因为这里的n比较小,这种方法也是适用的。但是如果n很大的话,需要的存储空间就比较大了。所以需要对它进行优化,不需要数组,只需要设置一些变量即可计算出最后一项。

代码

c++:

class Solution {
public:
    int Fibonacci(int n) {
        if(n<2)return n;
        else if(n==2)return 1;
        int a=1,b=1;
        for(int i=3;i<=n;i++)
        {
            int tmp=n2;
            b=a+b;
            a=tmp;
        }
        return b;
    }
};

python:

# -*- coding:utf-8 -*-
class Solution:
    def Fibonacci(self, n):
        if n==0:
            return 0
        elif n==1:
            return 1
        else:
            a,b = 0,1
            while n>1:
                a,b = b,a+b
                n=n-1
            return b

8、跳台阶

一只青蛙一次可以跳上1级台阶,也可以跳上2级。求该青蛙跳上一个n级的台阶总共有多少种跳法(先后次序不同算不同的结果)。

思路分析:

这一题的思路和上一题是一样的。设该青蛙跳上一个n级的台阶总共有 f(n) 种跳法,可以确定这样一个关系式:f(n) = f(n-1) + f(n-2)

简单解释一下:青蛙跳上一个n级的台阶的时候,前一步的状态要么是在n-2级台阶上,要么是在n-1级台阶上。把前一步的所有状态跳法加起来就是当前状态的跳法。

找到这个关系式之后,可以看出来这题是可以用递归来做的,只是用递归做有一个缺陷,重复计算太多了:
f(n) = f(n-1) + f(n-2)
f(n-1) = f(n-2) + f(n-3)
f(n-2) = f(n-3) + f(n-4)
……
一直到计算到f(3) = f(2) + f(1) =1+2=3

重复计算太多,可以用备忘录方法

备忘录方法是动态规划算法的一个变形。备忘录方法也用一个表格来保存已解决的子问题的答案,在下次需要解决此问题时,只要简单地查看该子问题的解答,而不必重新计算。与动态规划算法不同的是,备忘录方法的递归方式是自顶向下的,而动态规划算法则是自底向上递归的。因此,备忘录方法的控制结构与直接递归方法的控制结构相同,区别在于备忘录方法为每个解过的子问题建立了备忘录以备需要时查看,避免了相同子问题的重复求解。

简单的说,就是用一个表格记录下所有f(n)的值,每次用到f(n)时,先查一下备忘录,有的话直接用就行,省去了计算。

接下来说动态规划。为什么说备忘录方法的递归方式是自顶向下的,而动态规划算法则是自底向上递归的。这是因为备忘录方法从f(n)推导到f(1),而动态规划算法则是从f(1)推导到f(n),参见上一题。

动态规划算法的核心: 最优子结构、边界条件、状态转移方程

以这一题为例:

最优子结构:f(n)的最优子结构是f(n-1)和f(n-2)
边界条件:f(1)和f(2),它们的值分为1和2
状态转移方程: f ( n ) = f ( n − 1 ) + f ( n − 2 ) f(n) = f(n-1) + f(n-2) f(n)=f(n1)+f(n2)

这一题的解法和上一题是相同的(状态转移方程都是一样的)。

代码

c++

class Solution {
public:
    int jumpFloor(int number) {
        if(number<3)return number;
        int a=1,b=2;
        for(int i=3;i<=number;i++)
        {
            int tmp=b;
            b=a+b;
            a=tmp;
        }
        return b;
    }
};

现在来一道附加题

附加题、斐波那契数

求第n位斐波那契数mod10000的大小。其中n的大小高达1000000000,

思路分析

这题和第7题差别不大,区别是这题的n特别大,我们需要对动态规划再进行一次优化。用到的方法叫矩阵快速幂。

矩阵快速幂

矩阵的快速幂是用来高效地计算矩阵的高次方的。

先来看如何计算一个数的n次方。
比方说计算 A 156 A^{156} A156

传统方法是直接计算:A * A * A * A * A * A * A ……
为了减少连乘的次数,我们可以把156转化为二进制数:10011100。而 A 156 A^{156} A156次方可以写成 ( A 4 ) ∗ ( A 8 ) ∗ ( A 16 ) ∗ ( A 128 ) (A^4)*(A^8)*(A^{16})*(A^{128}) (A4)(A8)(A16)(A128)。4、8、16、128对应的正是10011100中的1,也就是说 156 = 4 + 8 + 16 + 128 156=4+8+16+128 156=4+8+16+128

我们可以这样理解,对10011100的每一位数字进行判断,每次判断A就多一个平方,判断顺序为从右到左,值为1则留下与其他数相乘。10011100有8位数字,我们只需要判断8次。

核心代码如下:

ll pow(ll x, ll y)  //位运算
{
    ll res = 1;
    while(y) {
        if (y&1)  res *= x ;   //res才是最终我们要的结果.
        x *= x ;     //一个中间转移量. y每右移一次, x 就多一个平方.
        y=y>>1;	//y右移
    }
    return res;
}

知道了如何计算一个数的n次方,再来看如何计算矩阵的n次方。

先定义矩阵和矩阵乘法:

struct node {//定义矩阵 
	int mat[15][15];
}x,y; 

node mul(node x,node y){//矩阵乘法 
	node tmp;
	for(int i=0;i<len;i++){
		for(int j=0;j<len;j++){
			tmp.mat [i][j]=0;
			for(int k=0;k<len;k++){
				tmp.mat [i][j]+=(x.mat [i][k]*y.mat [k][j])%mod;
			}
			tmp.mat [i][j]=tmp.mat[i][j]%mod;
		}
	}
	return tmp;
}

矩阵快速幂:

node matpow(node x,node y,int num){//矩阵快速幂 
	while(num){
		if(num&1){
			y=mul(y,x);
		}
		x=mul(x,x);
		num=num>>1;
	}
	return y;
} 

在这个函数中,x是最初的矩阵,num是它的幂,如果只是单纯计算次方,那么y矩阵的初值就是单位矩阵。

问题来了,这和计算斐波那契数有什么关系呢?

已知斐波那契数列的状态转移方程是 f(n) = f(n-1) + f(n-2)
我们可以把他写成这种矩阵形式:
( f ( n ) f ( n − 1 ) ) = ( 1 1 1 0 ) ∗ ( f ( n − 1 ) f ( n − 2 ) ) \binom{f(n)}{f(n-1)}=\begin{pmatrix}1 & 1\\ 1 & 0\end{pmatrix}*\binom{f(n-1)}{f(n-2)} (f(n1)f(n))=(1110)(f(n2)f(n1))
将斐波那契数列的连加转变为连乘:
( f ( n ) f ( n − 1 ) ) = ( 1 1 1 0 ) ∗ ( f ( n − 1 ) f ( n − 2 ) ) = ( 1 1 1 0 ) 2 ∗ ( f ( n − 2 ) f ( n − 3 ) ) = … … \binom{f(n)}{f(n-1)}=\begin{pmatrix}1 & 1\\ 1 & 0\end{pmatrix}*\binom{f(n-1)}{f(n-2)}=\begin{pmatrix}1 & 1\\ 1 & 0\end{pmatrix}^2*\binom{f(n-2)}{f(n-3)}=…… (f(n1)f(n))=(1110)(f(n2)f(n1))=(1110)2(f(n3)f(n2))=
可以推导出:
( f ( n ) f ( n − 1 ) ) = ( 1 1 1 0 ) n − 1 ∗ ( f ( 1 ) f ( 0 ) ) \binom{f(n)}{f(n-1)}=\begin{pmatrix}1 & 1\\ 1 & 0\end{pmatrix}^{n-1}*\binom{f(1)}{f(0)} (f(n1)f(n))=(1110)n1(f(0)f(1))

T = ( 1 1 1 0 ) , A ( n ) = ( f ( n ) f ( n − 1 ) ) T=\begin{pmatrix}1 & 1\\ 1 & 0\end{pmatrix},A(n)=\binom{f(n)}{f(n-1)} T=(1110)A(n)=(f(n1)f(n))

则有: A ( n ) = T n − 1 ∗ A ( 1 ) A(n)=T^{n-1} * A(1) A(n)=Tn1A(1)

根据矩阵快速幂求出 T n − 1 T^{n-1} Tn1 即可求出 A ( n ) A(n) A(n)。这里的 T T T 就是转移矩阵(一定是常数矩阵),也可以叫做构造矩阵,为了解决这道题构造出的矩阵。此处 A ( 1 ) A(1) A(1) 叫初始矩阵,可以根据边界条件确定。

代码

#include<cstdio>
#include<iostream>
#include<cstring>
#include<stdlib.h>
 
using namespace std;
const int N=2;
const int mod=10000;
int res[N][N];
int temp[N][N];
int a[N][N];
void Mul(int a[][N],int b[][N])///矩阵乘法
{
    memset(temp,0,sizeof(temp));
    for(int i=0;i<N;i++)
        for(int j=0;j<N;j++)
            for(int k=0;k<N;k++)
                temp[i][j]=(temp[i][j]+a[i][k]*b[k][j])%mod;
    for(int i=0;i<N;i++)
        for(int j=0;j<N;j++)
            a[i][j]=temp[i][j];
}
void fun(int a[][N],int n)
{
    memset(res,0,sizeof(res));
    for(int i=0;i<N;i++)
        res[i][i]=1;
    while(n){
        if(n&1){
            Mul(res,a);
        }
        Mul(a,a);
        n>>=1;
    }
}
int main()
{
    int n;
    while(~scanf("%d",&n)&&n!=-1){
        a[0][0]=1;
        a[1][0]=1;
        a[0][1]=1;
        a[1][1]=0;
        if(n==0||n==1)printf("%d\n",n);
        else{
            fun(a,n);
            printf("%d\n",res[0][1]);
        }
    }
}

9、变态跳台阶

一只青蛙一次可以跳上1级台阶,也可以跳上2级……它也可以跳上n级。求该青蛙跳上一个n级的台阶总共有多少种跳法。

思路分析

f(n)=f(1)+f(2)+f(3)+…+f(n-1)+1
f(n-1)=f(1)+f(2)+f(3)+…f(n-2)+1

分析得出状态转移方程: f ( n ) = 2 f ( n − 1 ) f(n)=2f(n-1) f(n)=2f(n1)

需要构造转移矩阵吗?继续推理下去会发现这题并不需要构造转移矩阵。

f ( n ) = 2 f ( n − 1 ) f(n)=2f(n-1) f(n)=2f(n1)
f ( n − 1 ) = 2 f ( n − 2 ) f(n-1)=2f(n-2) f(n1)=2f(n2)
… … ……
f ( 2 ) = 2 f ( 1 ) f(2)=2f(1) f(2)=2f(1)
f ( 1 ) = 1 f(1)=1 f(1)=1

f(n)就是 2 n − 1 2^{n-1} 2n1
可以看出,仅仅是运用动态规划的思想就可以轻松解决问题。

代码略。

10、矩形覆盖

我们可以用 2 ∗ 1 2*1 21的小矩形横着或者竖着去覆盖更大的矩形。请问用n个 2 ∗ 1 2*1 21的小矩形无重叠地覆盖一个 2 ∗ n 2*n 2n的大矩形,总共有多少种方法?

思路分析

依旧是动态规划题,穿了一层外套而已。

先求状态转移方程
n=1时,f(1)=1
n=2时,f(2)=2
n=3时,f(3)=4,分析一下f(3),会发现f(3)是由f(1)和f(2)组合而成,f(3)=f(1)+f(2)。
很容易找到其状态转移方程是 f(n)=f(n-1)+f(n-2)

代码

c++:

class Solution {
public:
    int rectCover(int number)
    {
        int f1 = 1;
	    int f2 = 2;
        if(number<=0)return 0;
        else if(number<3)return number;
        
	    while (3 <= number--)
	    {
		    int sum = f1 + f2;
		    f1 = f2;
		    f2 = sum;
	    }
	    return f2;
    }
};

如果用矩阵快速幂进行化简,要先求出它的转移矩阵。

根据
( f ( n ) f ( n − 1 ) ) = ( 1 2 1 0 ) ∗ ( f ( n − 1 ) f ( n − 2 ) ) \binom{f(n)}{f(n-1)}=\begin{pmatrix}1 & 2\\ 1 & 0\end{pmatrix}*\binom{f(n-1)}{f(n-2)} (f(n1)f(n))=(1120)(f(n2)f(n1))

求出转移矩阵 T = ( 1 2 1 0 ) T=\begin{pmatrix}1 & 2\\ 1 & 0\end{pmatrix} T=(1120)

A ( n ) = T n − 1 ∗ A ( 1 ) A(n)= T^{n-1} * A(1) A(n)=Tn1A(1)

这也是比较简单的转移矩阵,很容易求出。

这几题都是练手的题,非常简单。接下来从一维进化到二维,进入正题。

背包问题

背包问题指这样一类问题,题意往往可以抽象成:给定一组物品,每种物品都有自己的重量和价格,在限定的总重量内,我们如何选择,才能使得物品的总价格最高。

背包问题可以分为8种类型,这八种问题分别为:0/1背包问题、完全背包问题、多重背包问题、混合三种背包问题、二维费用背包问题、分组背包问题、有依赖的背包问题、求背包问题的方案总数。

先看最基础的0/1背包问题

0/1背包问题

有一个包和n个物品,包的容量为m,每个物品都有各自的体积和价值,问当从这n个物品中选择多个物品放在包里而物品体积总数不超过包的容量m时,能够得到的最大价值是多少?(对于每个物品不可以取多次,最多只能取一次,之所以叫做01背包,0表示不取,1表示取)

这个问题有多种演变形式:

投资分配问题:

现有数量为a(万元)的资金,计划分配给n 个工厂,用于扩大再生产。假设:xi 为分配给第i 个工厂的资金数量(万元);gi(xi)为第i个工厂得到资金后提供的利润值(万元)。问题:如何确定各工厂的资金数,使得总的利润为最大。

挖金矿问题:

有一个国家发现了a座金矿,每座金矿的黄金储量不同,需要参与挖掘的工人数也不同。参与挖矿工人的总数是n人。每座金矿要么全挖,要么不挖。要求用程序求解出,要想得到尽可能多的黄金,应该选择挖取哪几座金矿?

还有货物装载问题、下料问题等等。本质都是0/1背包问题。

以挖金矿问题为例,假设有5座金矿,100名工人,金矿的收益和所需要的工人数量如表所示:

金矿价值(万)所需工人
金矿1$1620
金矿2$2025
金矿3$3030
金矿4$5040
金矿5$5545

再次说明,动态规划算法的核心是: 最优子结构、边界条件、状态转移方程

挖金矿问题的状态转移方程是什么?

设100名工人挖5座金矿的最大价值的表达式是F(5,100),那么100名工人挖4座金矿的最大价值的表达式就是F(4,100)。

F(5,100)和F(4,100)有什么关系呢?
有两种情况:

  1. 分配这100人先去挖4座金矿,可能会剩下一些人,但是剩下的人不足以挖最后一座金矿的时候,有 F(5,100)=Fi(4,100)i 表示剩下的那个金矿。
  2. 当100名工人先挖剩下的一座金矿,先假设那个金矿需要20人,那么还剩下80人,再分配80人去挖那4座金矿。有 F(5,100)=Fi(4,80) + G(i)。 G(i)就是剩下的那个金矿的价值。

很容易得到:F(5,100) = max { Fi(4,100) , Fi(4,80) + G(i) }

这里有两个小问题,一是F(5,100)有没有可能等于Fi(4,100) + G(i)。二是这5座金矿要分成4和1,一共有5种分法,是不是都要考虑?

第一个问题仔细想一下是没有必要担心的,Fi(4,100) + G(i)就等于Fi(4,80) + G(i) 。
第二个问题也不用多考虑,无论怎么分成4和1,Fi(4,100)和Fi(4,80) + G(i)已经是他的最佳子结构,F(5,100) = max { Fi(4,100) , Fi(4,80) + G(i) }对于i=1,2,3,4,5来说都是相等的。

前面说过,动态规划算法则是自底向上递归的,而这个式子F(5,100) = max { Fi(4,100) , Fi(4,80) + G(i) }是自顶向下的。如何从这个式子写出动态规划算法是一个难点。

先从边界条件入手,既然是自底向上,我们就先算当只有金矿1的时候,1到100个人能取得多大价值,再加上金矿2,1到100人能取得多大价值,一直加到5个金矿的时候,1到100人能有取得多大价值。相当于列一个金矿总价值表F(n,v),表的规模是5*100。很显然,只有金矿1(工人20,价值16)的时候,1-19都是0,20-100都是16。

总价值表F(n,v),n是金矿标号,v是人数。

总价值表12341920100
金矿1000000161616
金矿2
金矿3
金矿4
金矿5

根据F(n,v) = max { Fi(n-1,v) , Fi(n-1,v-vi) + G(i) },依次算出加入金矿2,金矿3,金矿4,金矿5后的表值,找到最大值,问题就解决了。

写代码的时候还可以进行空间优化,上述状态表示,我们需要用二维数组,但事实上我们只需要一维的滚动数组就可以递推出最终答案。用f[ v ]来保存每层递归的值即可。

代码如下:

#include<Windows.h>
#include <iostream>
#include <stdint.h>
using namespace std;
const int maxn = 1e4;
int f[maxn];
int w[maxn],val[maxn];

void solve(int n,int m){
	memset(f,0,sizeof(f));
	for(int i = 1;i <= n;i++){
		for(int v = m;v > 0;v--){
			if(v >= w[i])
				f[v] = max(f[v],f[v-w[i]]+val[i]);//要看懂这里
		}
	}
	printf("%d\n",f[m]);
}
int main(){
	int n,m;
	n=5;//金矿数
	m=100;//人数
	w[1]=20,w[2]=25,w[3]=30,w[4]=40,w[5]=45;//所需人数 
	val[1]=16,val[2]=20,val[3]=30,val[4]=50,val[5]=55;//金矿价值 
	solve(n,m);
	return 0;
} 

完全背包问题

有N种物品和一个容量为V的背包,每种物品都有无限件可用。第i种物品的费用是w[i],价值是val[i]。求解将哪些物品装入背包可使这些物品的费用总和不超过背包容量,且价值总和最大。

思路分析

这个问题同01背包问题不一样的地方在于每种物品都有无限件可用,01背包问题的物品只能用一次。所以它的状态转移方程有一个小变化。

01背包问题的状态转移方程:
F(n,v) = max { Fi(n-1,v) , Fi(n-1,v-vi) + G(i) }

完全背包问题的状态转移方程:
F(n,v) = max { Fi(n-1,v) , Fi(n,v-vi) + G(i) }

在代码中循环的顺序由于状态转移方程的变化也有改变:

代码:

void solve_2(int n,int m){
	memset(f,0,sizeof(f));
	for(int i = 1;i <= n;i++){
		for(int v = w[i];v <= m;v++)
		{
			f[v] = max(f[v],f[v-w[i]]+val[i]);
		}
	}
	printf("%d\n",f[m]);
}

多重背包问题

有N种物品和一个容量为V的背包。第ii种物品最多有p[i]件可用,每件费用是w[i],价值是v[i]。求解将哪些物品装入背包可使这些物品的费用总和不超过背包容量,且价值总和最大。

思路分析

多重背包和01背包、完全背包的区别:多重背包中每个物品的个数都是给定的,每个物品大于等于1个,但是都有限,会用完。

最容易想到的思路是把他转化为01背包问题,把限制数目的物品拆分成单独的一件件物品,这样它的解法就和01背包一致。不过当单个物品的数量 M M M很多时,拆分成单独的一件件物品计算算法就显得很笨重了。我们可以把物品拆分成 1 , 2 , 4 , . . . , 2 k − 1 , M − 2 k + 1 1,2,4,...,2^{k-1},M−2k+1 1,2,4,...,2k1,M2k+1,其中 2 k − 1 < = M < 2 k 2^{k-1}<=M<2^k 2k1<=M<2k。比如M=13,可以拆成数量分别是1,2,4,6的物品。你会发现1,2,4,6这四个数字任意组合可以凑出1到13所有数字。这样做已经够简单了,再继续化简也是可以的,但是没必要。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值