动态规划(最优子结构和重叠子问题的比较)

图文

对动态规划算法不太了解的朋友可以看图文版的哦!有趣、透彻,缺点嘛。。。就是比较长,读起来比较浪费时间,不过很适合初学者。

动态规划算法一般都有下面两种实现方式,前者称为递归版本,后者称为迭代版本,且两个版本是可以相互转换的。

  • 直接自顶向下实现递归式,并将中间结果保存,这叫备忘录法;
  • 按照递归式自底向上地迭代,将结果保存在某个数据结构中求解。

编程有一个原则DRY=Don’t Repeat Yourself,就是说你的代码不要重复来重复去的,这个原则同样可以用于理解动态规划,动态规划除了满足最优子结构,它还存在子问题重叠的性质,我们不能重复地去解决这些子问题,所以我们将子问题的解保存起来,类似缓存机制,之后遇到这个子问题时直接取出子问题的解。

举个简单的例子,斐波那契数列中的元素的计算,很简单,我们写下如下的代码:

def fib(i):
    if i<2: return 1
    return fib(i-1)+fib(i-2)

好,来测试下,运行fib(10)得到结果69,不错,速度也还行,换个大的数字,试试100,这时你会发现,这个程序执行不出结果了,为什么?递归太深了!要计算的子问题太多了!

所以,我们需要改进下,我们保存每次计算出来的子问题的解,用什么保存呢?用Python中的dict!那怎么实现保存子问题的解呢?用Python中的装饰器!

修改刚才的程序,得到如下代码,定义一个函数memo返回我们需要的装饰器,这里用cache保存子问题的解,key是方法的参数,也就是数字n,值就是fib(n)返回的解。

from functools import wraps
 
def memo(func):
    cache={}
    @wraps(func)
    def wrap(*args):
        if args not in cache:
            cache[args]=func(*args)
        return cache[args]
    return wrap
 
@memo
def fib(i):
    if i<2: return 1
    return fib(i-1)+fib(i-2)

重新运行下fib(100),你会发现这次很快就得到了结果573147844013817084101,这就是动态规划的威力,上面使用的是第一种带备忘录的递归实现方式。

带备忘录的递归方式的优点就是易于理解,易于实现,代码简洁干净,运行速度也不错,直接从需要求解的问题出发,而且只计算需要求解的子问题,没有多余的计算。但是,它也有自己的缺点,因为是递归形式,所以有限的栈深度是它的硬伤,有些问题难免会出现栈溢出了。

于是,迭代版本的实现方式就诞生了!

迭代实现方式有2个好处:1.运行速度快,因为没有用栈去实现,也避免了栈溢出的情况;2.迭代实现的话可以不使用dict来进行缓存,而是使用其他的特殊cache结构,例如多维数组等更为高效的数据结构。

那怎么把递归版本转变成迭代版本呢?

这就是递归实现和迭代实现的重要区别:递归实现不需要去考虑计算顺序,只要给出问题,然后自顶向下去解就行;而迭代实现需要考虑计算顺序,并且顺序很重要,算法在运行的过程中要保证当前要计算的问题中的子问题的解已经是求解好了的。

斐波那契数列的迭代版本很简单,就是按顺序来计算就行了,不解释,关键是你可以看到我们就用了3个简单变量就求解出来了,没有使用任何高级的数据结构,节省了大量的空间。

def fib_iter(n):
    if n<2: return 1
    a,b=1,1
    while n>=2:
        c=a+b
        a=b
        b=c
        n=n-1
    return c

斐波那契数列的变种经常出现在上楼梯的走法问题中,每次只能走一个台阶或者两个台阶,广义上思考的话,动态规划也就是一个连续决策问题,到底当前这一步是选择它(走一步)还是不选择它(走两步)呢?

其他问题也可以很快地变相思考发现它们其实是一样的,例如求二项式系数C(n,k),杨辉三角(求从源点到目标点有多少种走法)等等问题。

二项式系数C(n,k)表示从n个中选k个,假设我们现在处理n个中的第1个,考虑是否选择它。如果选择它的话,那么我们还需要从剩下的n-1个中选k-1个,即C(n-1,k-1);如果不选择它的话,我们需要从剩下的n-1中选k个,即C(n-1,k)。所以,C(n,k)=C(n-1,k-1)+C(n-1,k)。

结合前面的装饰器,我们很快便可以实现求二项式系数的递归实现代码,其中的memo函数完全没变,只是在函数cnk前面添加了@memo而已,就这么简单!

from functools import wraps
 
def memo(func):
    cache={}
    @wraps(func)
    def wrap(*args):
        if args not in cache:
            cache[args]=func(*args)
        return cache[args]
    return wrap
 
@memo
def cnk(n,k):
    if k==0: return 1 #the order of `if` should not change!!!
    if n==0: return 0
    return cnk(n-1,k)+cnk(n-1,k-1)

它的迭代版本也比较简单,这里使用了defaultdict,略高级的数据结构,和dict不同的是,当查找的key不存在对应的value时,会返回一个默认的值,这个很有用,下面的代码可以看到。 如果不了解defaultdict的话可以看下Python中的高级数据结构

from collections import defaultdict
 
n,k=10,7
C=defaultdict(int)
for row in range(n+1):
    C[row,0]=1
    for col in range(1,k+1):
        C[row,col]=C[row-1,col-1]+C[row-1,col]
 
print(C[n,k]) #120

杨辉三角大家都熟悉,在国外这个叫Pascal Triangle,它和二项式系数特别相似,看下图,除了两边的数字之外,里面的任何一个数字都是由它上面相邻的两个元素相加得到,想想C(n,k)=C(n-1,k-1)+C(n-1,k)不也就是这个含义吗?

杨辉三角

所以说,顺序对于迭代版本的动态规划实现很重要,下面举个实例,用动态规划解决有向无环图的单源最短路径问题。假设有如下图所示的图,当然,我们看到的是这个有向无环图经过了拓扑排序之后的结果,从a到f的最短路径用灰色标明了。

拓扑排序

好,怎么实现呢?

我们有两种思考方式:

1.”去哪里?”:我们顺向思维,首先假设从a点出发到所有其他点的距离都是无穷大,然后,按照拓扑排序的顺序,从a点出发,接着更新a点能够到达的其他的点的距离,那么就是b点和f点,b点的距离变成2,f点的距离变成9。因为这个有向无环图是经过了拓扑排序的,所以按照拓扑顺序访问一遍所有的点(到了目标点就可以停止了)就能够得到a点到所有已访问到的点的最短距离,也就是说,当到达哪个点的时候,我们就找到了从a点到该点的最短距离,拓扑排序保证了后面的点不会指向前面的点,所以访问到后面的点时不可能再更新它前面的点的最短距离!这种思维方式的代码实现就是迭代版本。

[这里涉及到了拓扑排序,这里为了方便没看前面的童鞋理解,W直接使用的是经过拓扑排序之后的结果。]

def topsort(W):
    return W
 
def dag_sp(W, s, t):
    d = {u:float('inf') for u in W} #
    d[s] = 0
    for u in topsort(W):
        if u == t: break
        for v in W[u]:
            d[v] = min(d[v], d[u] + W[u][v])
    return d[t]
 
#邻接表
W={0:{1:2,5:9},1:{2:1,3:2,5:6},2:{3:7},3:{4:2,5:3},4:{5:4},5:{}}
s,t=0,5
print(dag_sp(W,s,t)) #7
图示计算过程

2.”从哪里来?”:我们逆向思维,目标是要到f,那从a点经过哪个点到f点会近些呢?只能是求解从a点出发能够到达的那些点哪个距离f点更近,这里a点能够到达b点和f点,f点到f点距离是0,但是a到f点的距离是9,可能不是最近的路,所以还要看b点到f点有多近,看b点到f点有多近就是求解从b点出发能够到达的那些点哪个距离f点更近,所以又绕回来了,也就是递归下去,直到我们能够回答从a点经过哪个点到f点会更近。这种思维方式的代码实现就是递归版本。

这种情况下,不需要输入是经过了拓扑排序的,所以你可以任意修改输入W中节点的顺序,结果都是一样的,而上面采用迭代实现方式必须要是拓扑排序了的,从中你就可以看出迭代版本和递归版本的区别了。

from functools import wraps
def memo(func):
    cache={}
    @wraps(func)
    def wrap(*args):
        if args not in cache:
            cache[args]=func(*args)
            # print('cache {0} = {1}'.format(args[0],cache[args]))
        return cache[args]
    return wrap
 
def rec_dag_sp(W, s, t):
    @memo
    def d(u):
        if u == t: return 0
        return min(W[u][v]+d(v) for v in W[u])
    return d(s)
 
#邻接表
W={0:{1:2,5:9},1:{2:1,3:2,5:6},2:{3:7},3:{4:2,5:3},4:{5:4},5:{}}
s,t=0,5
print(rec_dag_sp(W,s,t)) #7
图示计算过程

扩展内容:对DAG求单源最短路径的动态规划问题的总结,比较难理解,附上原文:

Although the basic algorithm is the same, there are many ways of finding the shortest path in a DAG, and, by extension, solving most DP problems. You could do it recursively, with memoization, or you could do it iteratively, with relaxation. For the recursion, you could start at the first node, try various “next steps,” and then recurse on the remainder, or (if you graph representation permits) you could look at the last node and try “previous steps” and recurse on the initial part. The former is usually much more natural, while the latter corresponds more closely to what happens in the iterative version.
Now, if you use the iterative version, you also have two choices: you can relax the edges out of each node (in topologically sorted order), or you can relax all edges into each node. The latter more obviously yields a correct result but requires access to nodes by following edges backward. This isn’t as far-fetched as it seems when you’re working with an implicit DAG in some nongraph problem. (For example, in the longest increasing subsequence problem, discussed later in this chapter, looking at all backward “edges” can be a useful perspective.)
Outward relaxation, called reaching, is exactly equivalent when you relax all edges. As explained, once you get to a node, all its in-edges will have been relaxed anyway. However, with reaching, you can do something that’s hard in the recursive version (or relaxing in-edges): pruning. If, for example, you’re only interested in finding all nodes that are within a distance r, you can skip any node that has distance estimate greater than r. You will still need to visit every node, but you can potentially ignore lots of edges during the relaxation. This won’t affect the asymptotic running time, though (Exercise 8-6).
Note that finding the shortest paths in a DAG is surprisingly similar to, for example, finding the longest path, or even counting the number of paths between two nodes in a DAG. The latter problem is exactly what we did with Pascal’s triangle earlier; the exact same approach would work for an arbitrary graph. These things aren’t quite as easy for general graphs, though. Finding shortest paths in a general graph is a bit harder (in fact, Chapter 9 is devoted to this topic), while finding the longest path is an unsolved problem (see Chapter 11 for more on this).

嘿嘿

参考

你可能感兴趣的:(动态规划(最优子结构和重叠子问题的比较))