[Golang] 《算法导论》动态规划(Dynamic Programming)理解 (一)

本篇内容为阅读《算法导论》动态规划算法设计时的一些理解和记录。建议大家去看原书,真的好。

动态规划有点像分治法,都是通过合并原问题的子问题的解来得到原问题的解。不同的是分治法将原问题划分为不相交的子问题,递归地解决子问题,然后组合它们的解来得到原问题的解。而动态规划需要原问题划分为有重叠的子问题,即其子问题又需要共同的子子问题。

当划分的子问题有重叠时,使用分治法会导致重叠子问题的重复计算。动态规划实际上就是通过存储子问题的解来避免这样的重复计算,这是动态规划的基本思想。

动态规划一般用于求一个最优解的问题,解决这样的问题包含四步:

  • 归纳出一个最优解的结构。
  • 递归定义一个最优解的值。
  • 计算一个最优解的值。(一般使用从底向上的方式)
  • 从结算中构建出最优解。

这样直白的语言概括其实很难理解的,还需要根据例子来理解,《算法导论》中给出了几个例子,我们也借用他来记录一下。

例一

将一根长度为 n 的铁棒进行分割,不同长度可以卖不同的价格(遵循一个价格表 P ),问可以卖出的最大的价格是多少?(一个最优解的问题)

设长度为 n 的铁棍通过切割最多能卖出的价格为 rn,第一次分割的长度为 i ,其余部分为 n-i。其中 i 的范围是 [1,n],那我们可以表示出 rn

rn = max (pi + rn-i)

注意i 的范围是 [1,n],这里的max要求的是例如p1 + rn-1,p2 + rn-2,... 之类的最大值。

很显然,这是个递归,我们很容易写出代码(Go):

func cutRod(p []int, n int) int {
  if n == 0 {
     return 0
  }
  q := math.MinInt
  for i := 1; i <= n; i++ {
     q = max(q, p[i]+cutRod(p, n-i))
  }
  return q
}

实际运行中,我们会发现,效率非常非常低。。。为什么呢?因为计算中存在了大量的冗余,例如 n = 3 的计算过程需要 cutRod(p, 0),cutRod(p, 1),cutRod(p, 2)。而计算 cutRod(p, 2) 需要 cutRod(p, 1)cutRod(p, 0)。计算 cutRod(p, 1) 需要 cutRod(p, 0)。这个过程中,很多相同的函数计算了多次。可以证明的是,该算法复杂度是指数级别的。

如何进行优化呢? 直观的,我们存储每个函数的执行结果,当发现执行的相同的函数时,直接返回值就好,这是个空间换时间的考量。先贴代码(Go):

func cutRod(p []int, n int) int {
   var r []int
   for i := 0; i < n; i++ {
      r = append(r, math.MinInt)
   }
   return cutRodAux(p, n, r)

}

func cutRodAux(p []int, n int, r []int) int {
   if r[n] >= 0 {
      return r[n]
   }
   q := math.MinInt
   if n == 0 {
      q = 0
   } else {
      for i := 1; i <= n; i++ {
         q = max(q, p[i]+cutRodAux(p, n-i, r))
      }
   }
   r[n] = q
   return q
}

我们额外开辟了一个数组 r 用来记录已经计算过的函数的值,其中 r[n] 代表长度为 n 的铁棒经过切割最多能卖出的价格。

我们这个方法是自上而下计算的,通俗一点,就是采用递归的方式,我想要算 A,在计算过程中我发现需要 B 的结果,所以我压栈,然后去算 B

递归是有成本消耗的。我们可以选用自下而上的方式来避免这个消耗,即我已经知道 B 的结果了,我发现只需要一步就能算出 A,所以我只花了一步得到了A的结果。

自下而上可以直接使用循环的方式实现,先贴代码(Go):

func cutRod(p []int, n int) int {
   var r []int
   r = append(r, 0)
   for j := 1; j <= n; j++ {
      q := math.MinInt
      for i := 1; i <= j; i++ {
         q = max(q, p[i]+r[j-i])
      }
      r[j] = q
   }
   return r[n]
}

形式上看起来是不是更简单!自下而上中“最下面”的base case一般可以直接看出来,如这个例子中的 r[0],显然等于 0。我们根据这个base case,不断得到 r[1]r[2]...,可以一直计算正无穷,然而我们只需要计算到 n 即可满足我们算法要求,也就是外层循环计算到 n 的原因。

自上而下就像深度优先搜索自下而上就像逆拓扑排序

当我们考虑一个动态规划问题时,我们应该理解所涉及的子问题的集合,以及子问题之间如何相互依赖。

之后我们将继续介绍其他动态规划问题的案例,同时将探索什么样的问题适合用动态规划解决。

你可能感兴趣的:(算法动态规划golang)