进一步理解动态规划

理解动态规划、BFS和DFS一文中,只是初步讲解了一下动态规划,理解的并不到位,这里再加深理解一下。html

本文主要参考什么是动态规划一文。python

1、前言

1.一、算法问题的求解过程

相似于机器学习的步骤,对同一个问题,能够用不一样的模型建模,而后对于肯定的模型,能够用不一样的算法求解。程序员

通常的算法问题求解步骤,分为两步:算法

  • 一、问题建模:
    对于同一个问题,能够有不一样的模型。
  • 二、问题求解:
    对于特定的模型,选出一个合适的算法(时间复杂度和空间复杂度知足要求),求解问题。

对应到动态规划算法上,具体分为这两步:网络

  • 一、问题建模:[最优子结构][边界][状态转移方程]
  • 二、用动态规划算法求解问题。

1.二、动态规划的思想

大事化小,小事化了。把一个复杂的问题分阶段进行简化,逐步化简成简单的问题。app

1.三、动态规划的步骤

1.3.1 问题建模机器学习

  • 一、 根据问题,找到【最优子结构】
    把原问题从大化小的第一步,找到比当前问题要小一号的最好的结果,而通常状况下当前问题能够由最优子结构进行表示。
  • 二、肯定问题的【边界】
    根据上述的最优子结构,一步一步从大化小,最终能够获得最小的,能够一眼看出答案的最优子结构,也就是边界。
  • 三、经过上述两步,经过分析最优子结构与最终问题之间的关系,咱们能够获得【状态转移方程】

1.3.2 问题求解的各个方法(从暴力枚举 逐步优化到动归)函数

  • 暴力枚举:
    下面的楼梯问题,国王与金矿问题,还有最少找零硬币数问题,均可以经过多层嵌套循环遍历全部的可能,将符合条件的个数统计起来。只是时间复杂度是指数级的,因此通常 不推荐。性能

  • 递归:
    一、既然是从大到小,不断调用状态转移方程,那么就能够用递归。
    二、递归的时间复杂度是由阶梯数和最优子结构的个数决定的。不一样的问题,用递归的话可能效果会大不相同。
    三、在阶梯问题,最少找零问题中,递归的时间复杂度和空间复杂度都比动归方法的差, 可是在国王与金矿的问题中,递归的时间复杂度和空间复杂度都比动归方法好。这是须要注意的。学习

每一种算法都没有绝对的好与坏,关键看应用场景。、

上面这句话说的很好,不止于递归和动归,通常的算法也是,好比通常的排序算法,在不一样的场景中,效果也大不相同。

  • 备忘录算法:
    一、在阶梯数N比较多的时候,递归算法的缺点就显露出来了:时间复杂度很高。若是画出递归图(像二叉树同样),会发现有不少不少重复的节点。然而传统的递归算法并不能识别节点是否是重复的,只要不到终止条件,它就会一直递归下去。
    二、为了不上述状况,使递归算法可以不重复递归,就把已经获得的节点都存起来,下次再遇到的时候,直接用存起来的结果就好了。这就是备忘录算法。
    三、备忘录算法的时间复杂度和空间复杂度都获得了简化。

  • 正经的动归算法:
    一、上述的备忘录算法,尽管已经不错了,可是依然仍是从最大的问题,遍历获得全部的最小子问题,空间复杂度是O(N)。
    二、为了再次缩小空间复杂度,咱们能够自底向上的构造递归问题,经过分析最优子结构与最终问题之间的关系,咱们能够获得【状态转移方程】
    而后从最小的问题不断往上迭代,即便一直到最大的原问题,也是只依赖于前面的几个最优子结构。这样,空间复杂度就大大简化。也就获得了正经的动归算法。

下面经过几个例题,来具体了解动归问题。

2、例题

例1:Climbing Stairs

leetcode原题:你正在爬一个有n个台阶的楼梯,每次只能上 1个 或者 2个台阶,那么到达顶端共有多少种不一样的方法?

1.一、 创建模型

  • 最终问题F(N):
    假设从0到达第N个台阶的方法共有F(N)个。
  • 最优子结构F(N-1),F(N-2):
    到达N个台阶,有两种可能,第一种多是从第 N-1 个台阶上1个台阶到达终点,第二种多是从第 N-2 个台阶上2个台阶到达终点。
  • 最优子结构与最终问题之间的关系:
    按照上述表达,那么能够概括出F(N) = F(N-1) + F(N-2) (n>=3)

结束条件为F(1) = 1,F(2) = 2

1.二、 问题求解

1.2.一、 解法1:递归

先用比较容易理解的递归求解(结束条件已知,递归公式已知,能够直接写代码了)

class Solution:
    def climbStairs(self, n):
        """
        :type n: int
        :rtype: int
        """
        if n == 1:
            return 1
        elif n == 2:
            return 2
        else:
            return self.climbStairs(n-1) + self.climbStairs(n-2)

回想前面所说,递归的时间复杂度是由阶梯数和最优子结构的个数决定的。这里的阶梯数是 N ,最优子结构个数是 2 。若是想象成一个二叉树,那么就能够认为是一个高度为N-1,节点个数接近 2 的 N-1 次方的树,所以此方法的时间复杂度能够近似的看做是O(2N) 。

1.2.二、 解法2:备忘录算法

参考什么是动态规划中递归的图,发现有不少相同的参数被重复计算,重复的太多了。

因此这里咱们想到了把重复的参数存储起来,下次递归遇到时就直接返回该参数的结果,也就是备忘录算法了,这里须要用到一个哈希表,解决方法就是对类用init进行初始化。

class Solution:
    def __init__(self):
        self.map = {}
        
    def climbStairs(self, n):
        """
        :type n: int
        :rtype: int
        """
        
        if n == 1:
            return 1
        if n == 2:
            return 2
        if n in self.map:
            return self.map[n]
        else:
            value  =  self.climbStairs(n-1) + self.climbStairs(n-2)
            self.map[n] = value
            return value

这里哈希表里存了 N-2 个结果,时间复杂度和空间复杂度都是O(N)。程序性能获得了明显优化。

1.2.三、 解法3:动态规划

以前都是自顶向下的求解,考虑一下自底向上的求解过程。从F(1)和F(2)边界条件求,可知F(3) = F(1)+F(2)。不断向上,可知F(N)只依赖于前两个状态F(N-1)和F(N-2)。因而咱们只须要保留前两个状态,就能够求得F(N)。相比于备忘录算法,咱们再一次简化了空间复杂度。

这就是动态规划了。(具体的细节看漫画比较好理解。)

具体代码实现中,能够令F(N-2)=a,F(N-1)=b,则temp等于a+b,而后把a向前挪一步等于b,b向前挪一步等于temp。那么下一次迭代时,temp就依然等于a+b。

代码以下:

class Solution:
    def climbStairs(self, n):
        """
        :type n: int
        :rtype: int
        """
        if n == 1:
            return 1
        if n == 2:
            return 2
        a = 1
        b = 2
        for i in range(3,n+1):
            temp = a + b
            a = b
            b = temp
        return temp

例2: Making change using the fewest coins.

参考Dynamic Programming中,用最少的硬币数目找零钱的一个例子。

问题描述:
假设你是一家自动售货机制造商的程序员。你的公司正设法在每一笔交易 找零时都能提供最少数目的硬币以便工做能更加简单。已知硬币有四种(1美分,5美分,10美分,25美分)。假设一个顾客投了1美圆来购买37美分的物品 ,你用来找零的硬币的最小数量是多少?
(这个问题用贪心算法也能解,具体细节看参考文献)

2.一、 创建模型

就以动归做为解题的算法来创建模型吧。

  • 边界:当须要找零的面额正好等于上述的四种整额硬币时,返回1便可
  • 最优子结构:回想找到最优子结构的方法,就是日后退一步,可以获得的最好的结果。这里有四个选择,1 + mincoins(63-1),1 + mincoins(63-5),1 + mincoins(63-10) 或者 1 + mincoins(63-25),这四个选择能够认为是63的最优子结构。
  • 状态转移方程:按照 上述的最优子结构,mincoins(63)也就等于上述四个最优子结构的最小值。因而,方程能够表示为:

2.二、 问题求解

模型已经获得,接下来就运用算法进行求解。
这里依然能够按照例1的解法,由模型,很天然的想到用递归求解。

2.2.一、解法1,递归

边界条件已知,模型已知,能够直接写代码了。

def recMC(coinValueList,change):
    minCoins = change
    if change in coinValueList:
        return 1
    else:
        for i in [c for c in coinValueList if c <= change]:
            numCoins = 1 + recMC(coinValueList,change-i)
        if numCoins < minCoins:
            minCoins = numCoins
    return minCoins
print(recMC([1,5,10,25],63)

可是,对于每个大于25的数目,都有四个最优子结构,而后对于每一个最优子结构,还有大量相同重复的参数(具体细节看参考)。因此这个解法并不合适。

2.2.二、解法2,动态规划

首先要有自底向上的思想,从change等于1时,开始往上迭代,参考最优子结构,记录下来最少硬币数。一直迭代到63。

1==>1
2==>min(2-1) + 1 = 2
3==>min(3-1) + 1 = 3
4==>min(4-1) + 1 = 4
5==>min(min(5-1) + 1 = 5, min(5-5) + 1 = 1)= 1
6==>min(min(6-1) + 1 = 2, min(6-5) + 1 = 2)= 2
7==>min(min(7-1) + 1 = 3, min(7-5) + 1 = 3)= 3

由此能够推下去,每个change对应的最少硬币数,均可以由前面的若干个最优子结构(有几个最优子结构,由change是多少决定,change大于5就有两个子结构,大于10就有三个。。)获得。这样一直迭代到63,那么就能够获得63的最少硬币数。

所以,须要一个循环来从头至尾遍历。
须要必定须要一个map来记录部分结果。
每个change,咱们能够根据上面的式子遍历最优子结构,并将每一个子结构的结果都添加到一个list中,在遍历完最有子结构之后,选择最小的那一个,添加到map中去。

求解一个新的 i 的最优解的过程是很方便的,从最优子结构中挑选最小的值而后加1便可。
最优子结构的值,能够用minCoin[i-j]获得。其中j为有效硬币面额。

实现代码:

def dpMakeChange(coinValueList,change):
    minCoins = { }

    for cents in range(change+1):
        #cents小于等于1时,coinCount会为空,无法执行min。
        #所以这里先填上
        if cents <= 1:
            minCoins[cents] = cents
            continue
        #遍历cents的每一个最优子结构而且添加到list中,等待筛选
        coinCount = [ ]
        for j in coinValueList:
            if cents >= j:
                coinCount.append(minCoins[cents - j] + 1)
        minCoins[cents] = min(coinCount)
    return minCoins[change]

result = dpMakeChange([1,5,10,25],63)
print(result)

固然这个函数是有瑕疵的,由于这个函数只告诉咱们最少的硬币数,并不能告诉咱们应该找零的面额。因此咱们能够扩展一下函数,跟踪记录咱们使用的硬币便可。具体细节能够看参考。

例3: 国王与金矿问题

只讲一下大体的思路。
问题中须要注意的地方:

  • 国王与金矿的问题中,由于每一个金矿须要的人不一样,所含金矿数量也不一样。为了简化问题,这里第 i 个金矿所含的金矿数量和所须要的工人都是 特定不变的。
  • 在实现自底向上的递推时,由于问题的参数有两个,那么存在两个输入维度。为此,能够画一个表格来作分析。
  • 在实现自底向上的递推时,为了比较快的找到规律,最好把从边界不断地往上迭代,结合最优子结构和存储的结果,慢慢的找到规律。

3.一、问题建模

这里着重讲解一下最后一点,也就是动态规划最重要的地方。

最优子结构:对于5个金矿,10个工人的状况,日后退一步存在两种状况。(第五个金矿的金矿数量为350,所需工人为3人)

  • 状况1:国王选择不挖第五个金矿,那么此时最大化的金矿数量就是在有4个金矿,10个工人的状况下,可以挖到的最多金矿数量。
  • 状况2:国王选择挖第五个金矿,那么此时用3个工人挖得350的金矿数量是已知的,还剩4个金矿与7个工人。
    那么最优解至关于在4个金矿与7个工人的状况下可以挖得的最多金矿数量 + 350。

最优子结构与最终问题之间的关系:5个金矿10个工人的最优选择,就是上述两个最优子结构的最大值。

因而咱们能够获得状态转移方程:

最重要的状态转移方程已经获得,至于剩下的边界条件,现实中会遇到的各类特殊状况,这里就不赘述了。细节参考漫画。

3.二、问题求解

3.2.1 解法一、递归

程序 :把状态转移方程翻译成递归程序,递归的结束条件就是方程式中的边界便可。
复杂度:由于每一个状态有两个最优子结构,因此递归的执行流程相似于一个高度为N的二叉树。因此方法的时间复杂度是O(2N)。

3.2.2 解法二、备忘录算法

程序:在简单递归的基础上,增长一个HashMap备忘录,用来存储中间的结果,HashMap的Key是一个包含金矿数N和工人数W的对象,Value是最优选择得到的黄金数。
复杂度:时间复杂度和空间复杂度相同,都等于被网络中不一样Key的数量。

3.2.3 解法三、动态规划

为了实现自底向上的迭代,对于参数有两个的问题,咱们能够先画要一个表格来作分析。根据状态转移方程,咱们能够方便的画出表格。注意,必定是要根据状态转移方程来求的。

因为咱们在求解每一个格子的数值时,结合状态转移方程,发现除了第一行之外,每个格子均可以由前一行的格子中的一个或者两个格子推导而来。
从总体上来讲,每一行的值均可以由前一行来求得。

因而,咱们在写代码的时候,也能够像画表格同样,从左至右,从上到下一个一个的推出最终结果。反映到程序上就是:

for i in range(金矿数):
    for j in range(工人数目):
         状态转移方程

另外,由上可知,咱们并不须要存储整个表格,只须要存储前一行的结果便可推出新的一行。

代码这里就不写了。

注意:

  • 这里动态规划的时间复杂度是O(n*w),空间复杂度是O(w)。在n=5,w=1000是,显然要计算5000次,开辟1000单位的空间。
  • 可是若是用简单递归算法的话,时间复杂度是O(2N),须要计算32次 ,开辟5单位(递归深度)的空间。
  • 这是因为动态规划方法的时间和空间都和w成正比,而简单递归却和w无关,因此当工人数量不少的时候,动态规划反而不如递归。

因此说,每一种算法没有绝对的好与坏,关键要看应用场景。

总结:

我的以为, 动态规划算法最重要的有两点

  • 建模:必定要找对最优子结构,而后分析最优子结构与最终问题的关系,从而获得状态转移方程。
  • 问题求解:先手动的自底向上的,运用状态转移方程迭代一下,一直到最终问题,从而肯定程序的主体部分。

至于,模型中的边界问题,特殊状况等,就是须要多敲代码来慢慢考虑的了。

相关文章
相关标签/搜索