【问题标题】:Dynamic programming in the functional paradigm功能范式中的动态编程
【发布时间】:2011-10-02 02:36:06
【问题描述】:

我正在查看 Project Euler 上的 Problem thirty one,它询问,使用任意数量的 1 便士、2 便士、5 便士、10 便士、20 便士、50 便士、1 英镑(100 便士)的硬币有多少种不同的方法可以赚取 2 英镑) 和 2 英镑 (200 便士)。

有递归解决方案,例如 Scala 中的这个(感谢 Pavel Fatin)

def f(ms: List[Int], n: Int): Int = ms match {
  case h :: t =>
    if (h > n) 0 else if (n == h) 1 else f(ms, n - h) + f(t, n)
  case _ => 0
} 
val r = f(List(1, 2, 5, 10, 20, 50, 100, 200), 200)

虽然它运行得足够快,但效率相对较低,调用了大约 560 万次 f 函数。

我看到其他人用 Java 动态编程的解决方案(感谢来自葡萄牙的 wizeman)

final static int TOTAL = 200;

public static void main(String[] args) {
    int[] coins = {1, 2, 5, 10, 20, 50, 100, 200};
    int[] ways = new int[TOTAL + 1];
    ways[0] = 1;

    for (int coin : coins) {
        for (int j = coin; j <= TOTAL; j++) {
            ways[j] += ways[j - coin];
        }
    }

    System.out.println("Result: " + ways[TOTAL]);
}

这样效率更高,只通过内循环 1220 次。

虽然我显然可以使用 Array 对象将其或多或少逐字翻译成 Scala,但是否有一种惯用的函数式方法可以使用不可变数据结构来做到这一点,最好具有类似的简洁性和性能?

我曾尝试递归更新List,但在决定我可能只是以错误的方式接近它之前陷入困境。

【问题讨论】:

  • 我看了 Scala 版本 1 分钟,知道它是如何工作的。看了Java一5分钟,还是不知道怎么用。对我来说,这是一个很好的例子,表明函数式并不比命令式复杂。
  • @huynhjl 但是,除了查看函数式和迭代式算法之外,您还需要查看规范和优化的算法。

标签: java scala functional-programming dynamic-programming


【解决方案1】:

每当根据前一个元素计算数据列表的某些部分时,我都会想到Stream 递归。不幸的是,这样的递归不会发生在方法定义或函数内部,所以我必须将一个函数变成一个类才能使其工作。

class IterationForCoin(stream: Stream[Int], coin: Int) {
  val (lower, higher) = stream splitAt coin
  val next: Stream[Int] = lower #::: (higher zip next map { case (a, b) => a + b })
}
val coins = List(1, 2, 5, 10, 20, 50, 100, 200)
val result = coins.foldLeft(1 #:: Stream.fill(200)(0)) { (stream, coin) =>
  new IterationForCoin(stream, coin).next
} last

lowerhigher 的定义不是必需的——我可以轻松地将它们替换为 stream take coinstream drop coin,但我认为这样更清晰(也更有效)。

【讨论】:

  • 流真的是在这里做的伎俩,还是更高和更低被重用的事实(所以这是防止重复工作的“存储”技巧?)
  • @PeteyPabPro 两者。 Stream 可以对数据进行递归,从而可以进行重用。
  • @DanielCSobral 但是可以创建一个没有Stream 的稍微修改的版本,对吗? (见下面我的回答)。
  • 可能也很有趣,看看使用惰性数据结构进行动态编程是多么优雅:haskell.org/haskellwiki/Dynamic_programming_example
【解决方案2】:

我对 Scala 了解不多,无法对此进行具体评论,但将 DP 解决方案转换为递归解决方案的典型方法是记忆化(使用 http://en.wikipedia.org/wiki/Memoization)。这基本上是为域的所有值缓存函数的结果

我也发现了这个http://michid.wordpress.com/2009/02/23/function_mem/。高温

【讨论】:

  • 虽然它有效并且是我最喜欢的解决方案,但我发现它存在巨大的性能问题。在数组上使用 while 循环是迄今为止最有效的解决方案(在 Scala 2.8.1 上),我说的是(至少)这里的十个因素。
  • @Raphael:当运行时间只有几毫秒时,十的几个因素并不重要!
  • @Gareth 我可能应该改变这个问题,以便它询问如何制作更大的东西,比如 10 英镑。动态解决方案通过 7620 循环立即完成。递归解决方案在 10 分钟后仍在运行,我怀疑计算所需的货币价值比宇宙的生命周期更长。
  • @Luigi:Raphael 和我正在将动态编程与递归加记忆进行比较,而不是递归本身。
  • 我使用(元组)整数作为映射键。就我而言,我们谈论 90 分钟与“12 小时后中止”,而不是毫秒。分析编码显示 很多 的(取消)装箱,但大部分时间都花在从用于存储的哈希图中检索值。我猜它们被非常分散地存储在堆上,没有利用访问的局部性(就像数组上的循环一样)。
【解决方案3】:

函数式动态编程实际上可以在惰性语言中非常漂亮,例如 Haskell(Haskell wiki 上有 an article on it)。这是该问题的动态规划解决方案:

import Data.Array

makeChange :: [Int] -> Int -> Int
makeChange coinsList target = arr ! (0,target)
  where numCoins = length coinsList
        coins    = listArray (0,numCoins-1) coinsList
        bounds   = ((0,0),(numCoins,target))
        arr      = listArray bounds . map (uncurry go) $ range bounds
        go i n   | i == numCoins = 0
                 | otherwise     = let c = coins ! i
                                   in case c `compare` n of
                                        GT -> 0
                                        EQ -> 1
                                        LT -> (arr ! (i, n-c)) + (arr ! (i+1,n))

main :: IO ()
main = putStrLn $  "Project Euler Problem 31: "
                ++ show (makeChange [1, 2, 5, 10, 20, 50, 100, 200] 200)

诚然,这使用 O(cn) 内存,其中 c 是硬币的数量,n 是目标(而不是Java 版本的 O(n) 内存);要做到这一点,您必须使用一些捕获可变状态的技术(可能是STArray)。但是,它们都在 O(cn) 时间内运行。这个想法是直接递归地编码递归解决方案almost,但不是在go中递归,而是在数组中查找答案。我们如何构造数组?通过在每个索引上调用 go。由于 Haskell 是惰性的,它只在被要求时才进行计算,因此动态编程所需的求值顺序都是透明处理的。

感谢 Scala 的别名参数和lazy vals,我们可以在 Scala 中模仿这个解决方案:

class Lazy[A](x: => A) {
  lazy val value = x
}

object Lazy {
  def apply[A](x: => A) = new Lazy(x)
  implicit def fromLazy[A](z: Lazy[A]): A = z.value
  implicit def toLazy[A](x: => A): Lazy[A] = Lazy(x)
}

import Lazy._

def makeChange(coins: Array[Int], target: Int): Int = {
  val numCoins = coins.length
  lazy val arr: Array[Array[Lazy[Int]]]
    = Array.tabulate(numCoins+1,target+1) { (i,n) =>
        if (i == numCoins) {
          0
        } else {
          val c = coins(i)
          if (c > n)
            0
          else if (c == n)
            1
          else
            arr(i)(n-c) + arr(i+1)(n)
        }
      }
  arr(0)(target)
}

// makeChange(Array(1, 2, 5, 10, 20, 50, 100, 200), 200)

Lazy 类对仅按需评估的值进行编码,然后我们构建一个包含它们的数组。这两种解决方案都几乎立即适用于 10000 的目标值,尽管如果再大得多,您将遇到整数溢出或(至少在 Scala 中)堆栈溢出。

【讨论】:

    【解决方案4】:

    好的,这是 Pavel Fatin 代码的记忆版本。我正在使用 Scalaz memoization 的东西,虽然编写自己的 memoization 类真的很简单。

    import scalaz._
    import Scalaz._
    
    val memo = immutableHashMapMemo[(List[Int], Int), Int]
    def f(ms: List[Int], n: Int): Int = ms match {
      case h :: t =>
        if (h > n) 0 else if (n == h) 1 else memo((f _).tupled)(ms, n - h) + memo((f _).tupled)(t, n)
      case _ => 0
    } 
    val r = f(List(1, 2, 5, 10, 20, 50, 100, 200), 200)
    

    【讨论】:

    • @Luigi 你使用的堆栈太小了。 :-) 溢出与记忆无关——因为有两个对f 的调用,其中一个永远不会是尾递归的。在这种情况下,考虑第一次调用将使用相同的ms 参数递归,每次将n 减少h,直到n == hh &gt; n。首次调用时,n 将以 750 开头,h 以 1 开头,因此它将递归 750 次。我试过十磅没有问题,但我通常设置-Xss1m
    【解决方案5】:

    为了完整起见,这里是上面答案的一个轻微变体,不使用Stream

    object coins {
      val coins = List(1, 2, 5, 10, 20, 50, 100, 200)
      val total = 200
      val result = coins.foldLeft(1 :: List.fill(total)(0)) { (list, coin) =>
        new IterationForCoin(list, coin).next(total)
      } last
    }
    
    class IterationForCoin(list: List[Int], coin: Int) {
      val (lower, higher) = list splitAt coin
      def next (total: Int): List[Int] = {
        val listPart = if (total>coin) next(total-coin) else lower
        lower ::: (higher zip listPart map { case (a, b) => a + b })
      }
    }
    

    【讨论】:

      猜你喜欢
      • 2011-08-18
      • 1970-01-01
      • 2016-03-16
      • 2021-10-23
      • 1970-01-01
      • 2010-10-02
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      相关资源
      最近更新 更多