惯用的 Haskell 中如何实现动态规划算法?

Posted

技术标签:

【中文标题】惯用的 Haskell 中如何实现动态规划算法?【英文标题】:How are Dynamic Programming algorithms implemented in idiomatic Haskell? 【发布时间】:2011-06-26 00:03:59 【问题描述】:

Haskell 和其他函数式编程语言是围绕不维护状态的前提构建的。我对函数式编程的工作原理和其中的概念仍然很陌生,所以我想知道是否可以以 FP 方式实现 DP 算法。

可以使用哪些函数式编程结构来做到这一点?

【问题讨论】:

标题有点傻——当然答案是“是”(参见“相关”问题)。也许考虑将其修改为更集中(和“驯服”)的语言。快乐的函数式编码。 函数式语言不鼓励或禁止可变/隐式状态。 Haskell 为您提供了维护显式状态的方法。 【参考方案1】:

执行此操作的常用方法是通过惰性记忆。在某种意义上,递归斐波那契函数可以被认为是动态规划,因为它计算重叠子问题的结果。我意识到这是一个乏味的例子,但这里有一个味道。它使用data-memocombinators 库进行惰性记忆。

import qualified Data.MemoCombinators as Memo

fib = Memo.integral fib'
    where
    fib' 0 = 0
    fib' 1 = 1
    fib' n = fib (n-1) + fib (n-2)

fib 是记忆的版本,fib' 只是“蛮力”问题,但使用记忆的fib 计算其子问题。其他 DP 算法也是以同样的风格编写的,使用不同的记忆结构,但同样的想法是用简单的函数方式计算结果并记忆。

编辑:我终于屈服了,决定提供一个可记忆的类型类。这意味着现在记忆更容易了:

import Data.MemoCombinators.Class (memoize)

fib = memoize fib'
    where
    fib' :: Integer -> Integer  -- but type sig now required 
    ...

不需要跟随类型,你可以memoize 任何东西。如果您喜欢,您仍然可以使用旧方式。

【讨论】:

我对这个问题的解释是“鉴于记忆涉及维护全局状态,你如何用纯粹的函数式语言记忆?”。说“只使用记忆”并没有说明它实际上是如何工作的,这肯定是 OP 所要求的。 嗯,你可能是对的。我感觉很懒,所以我会为明确提出的问题保留一个解释。 (搜索者更容易找到,我更有可能解决真正的问题) @Gabe 使用the source卢克!你是怎么做DP的?记忆。你如何做记忆?见源。但是没有必要重新发明***,除非那是你特别感兴趣的事情。 @Dan:按照你的逻辑,几乎所有关于 SO 的答案都可以简化为“只需谷歌一下!”或“只需阅读源代码!”,所以我不太相信这样的答案。 @Gabe searching for Memoization in Haskell 产生与实现记忆更直接相关的问题。 @Luqui 我已经 posted a question 请求有关此软件包如何工作的详细信息。我很感兴趣,但不能完全理解它。【参考方案2】:

Rabhi 和 Lapalme 的算法:一种函数式编程方法对此有一个很好的章节,说明了一些正在使用的 FP 概念,即高阶函数懒惰的评价。我认为我可以重现其高阶函数的简化版本。

它的简化在于它只适用于将 Int 作为输入并产生 Int 作为输出的函数。因为我们以两种不同的方式使用 Int,所以我将为它们创建同义词“Key”和“Value”。但是不要忘记,因为这些是同义词,所以完全可以使用键和值,反之亦然。它们仅用于提高可读性。

type Key = Int
type Value = Int

dynamic :: (Table Value Key -> Key -> Value) -> Key -> Table Value Key
dynamic compute bnd = t
 where t = newTable (map (\coord -> (coord, compute t coord)) [0..bnd])

让我们稍微剖析一下这个函数。

首先,这个函数有什么作用?从类型签名我们可以看出它以某种方式操纵表格。实际上,第一个参数“compute”是一个函数(因此 dynamic 是一个“高阶”函数),它从表中产生某种值,第二个参数只是某种上限,告诉我们在哪里停止。作为输出,“动态”函数为我们提供了某种表格。如果我们想得到一些对 DP 友好的问题的答案,我们运行“动态”,然后从我们的表中查找答案。

要使用这个函数来计算斐波那契,我们可以像这样运行它

fib = findTable (dynamic helper n) n
 where
  helper t i =
    if i <= 1
       then i
       else findTable t (i-1) + findTable t (i-2)

暂时不要太担心理解这个 fib 函数。随着我们探索“动态”,它会变得更加清晰。

其次,我们需要了解哪些先决条件才能理解此函数?我假设您或多或少熟悉语法,即 [0..x] 到表示从 0 到 x 的列表,类型签名中的 -> 像 Int -> Table -> ... 与匿名函数中的 -> 像 \coord -> ... 如果你对这些不满意,他们可能挡路。

另一个需要解决的先决条件是这个查找表。我们不想担心它是如何工作的,但让我们假设我们可以从键值对列表中创建它们并在其中查找条目:

newTable :: [(k,v)] -> Table v k
findTable :: Table v k -> k -> v

这里要注意三点:

为简单起见,我们没有使用 Haskell 标准库中的等效项 如果您要求 findTable 从表中查找不存在的值,它将崩溃。如果需要,我们可以使用更高级的版本来避免这种情况,但这是另一个帖子的主题 奇怪的是,我没有提到任何类型的“向表中添加值”功能,尽管书籍和标准 Haskell 库都提供了这种功能。为什么不呢?

最后,这个函数实际上是如何工作的?这是怎么回事?我们可以放大一点函数的内容,

t = newTable (map (\coord -> (coord, compute t coord)) [0..bnd])

并有条不紊地撕开它。从外到内,我们得到了 t = newTable (...),这似乎告诉我们我们正在从某种列表中构建一个表。无聊的。清单呢?

map (\coord -> (coord, compute t coord)) [0..bnd]

这里我们有更高阶的 map 函数从 0 到 bnd 并生成一个新列表作为结果。要计算新列表,它使用函数 \coord -> (coord, compute t coord)。 请记住上下文:我们正在尝试从键值对构建一个表,因此如果您研究元组,第一部分 coord 必须是键,第二部分计算 t coord 必须是值。第二部分是事情变得令人兴奋的地方。让我们再放大一点

compute t coord

我们正在从键值对构建一个表,我们插入这些表的值来自运行“计算 t 坐标”。我之前没有提到的是,compute 将一个表和一个键作为输入,并告诉我们应该将什么值插入表中,换句话说,我们应该将什么值与该键关联。然后,将其带回动态编程的想法是,计算函数使用表中的先前值来计算我们应该插入的新值。

仅此而已!为了在 Haskell 中进行动态编程,我们可以通过使用从表中查找先前值的函数将值连续插入单元格来构建某种表。简单,对吧?...或者是吗?

也许你和我有类似的经历。所以我想分享一下我目前在这个功能上的进展。当我第一次阅读这个功能时,它似乎有一种直观的感觉,我并没有多想。然后我仔细阅读并做了一种双重考虑,等等什么?!这怎么可能起作用?在这里再看一下这段 sn-p 代码。

compute t coord

为了计算给定单元格的值并填充表格,我们传入 t,这是我们首先尝试创建的表格。如果正如您所指出的那样,函数式编程是关于不变性的,那么使用我们尚未计算的值的这种业务如何可能起作用?如果你有一点 FP,你可能会像我一样问自己,“这是一个错误吗?”,这不应该是“折叠”而不是“地图”吗?

这里的关键是惰性求值。可以从自身的位中创建不可变价值的一点点魔法都归结为懒惰。作为一个长期持有黄带的 Haskeller,我仍然觉得懒惰的概念有点莫名其妙。所以我得让其他人来接管这里。

与此同时,我只是告诉自己这没关系。我满足于将表格想象成一个点,上面有很多箭头。以fib为例:

o
|
|--0--> 1
|
|--1--> 1
|
|--2--> 2
|
|--3--> 2
.
.
.

我们还没有看到的表格部分是未被发现的领域。当我们第一次浏览列表时,一切都没有被发现

o
.
.
.

当我们想要计算第一个值时,我们不需要知道更多关于表的信息,因为 i

  helper t i =
    if i <= 1
       then i
       else findTable t (i-1) + findTable t (i-2)


o
|
|--0--> 1
.
.
.

当我们想要计算连续值时,我们总是只回顾表中已经发现的部分(动态编程,嘿嘿!)。要记住的关键是我们在这里 100% 使用不可变值,除了懒惰之外没有花哨的技巧。 “t”实际上是指表,而不是“在迭代 42 时处于当前状态的表”。只是当我们实际请求它时,我们才发现表中的位告诉我们对应于 42 的值是什么。

希望与 *** 上的其他人一起,你会比我走得更远,不要含糊地说“嗯,是的,懒惰什么的”这真的没什么大不了的 :-)

【讨论】:

【参考方案3】:

如果你想使用带有 2 或 3 个参数的 DP(例如,在处理字符串时),你可以使用不可变数组:

import Data.Array.IArray

answer :: String -> Int
answer s = table ! (1, l)
  where
    l = length s

    --signatyres are needed, because GHC doesn't know what kind of Array we need
    --string is stored in Array because we need quick access to individual chars
    a :: Array Int Char
    a = listArray (1, l) s

    table :: Array (Int, Int) Int
    table = listArray ((1, 1), (l, l)) [f i j | i <- [1..l], j <- [1..l]]

    f i j |    i    >     j    = 0
          |    i    ==    j    = 1
          | (a ! i) == (a ! j) = 2 + table ! (i+1, j-1)
          | otherwise          = maximum [table ! (i+1, j), table ! (i, j-1)]

这段代码解决了以下任务:给定一个字符串 S,找到 S 的最大长度子序列,这将是一个回文(子序列不需要是连续的)。

基本上,'f' 是递归函数,而数组 'table' 是所有可能值的矩阵。因为 Haskell 是惰性的,只需要计算 'f' 的答案值。换句话说,这是带有记忆的递归。所以使用 Data.Memocombinators,它是一样的,但已经由其他人编写了:)

【讨论】:

【参考方案4】:

由于惰性,haskell 中的动态编程可以优雅地表达,请参阅this page 上的第一个示例

【讨论】:

很好的例子。您能否解释该页面上! 运算符的含义?它是某种数组索引运算符吗?我不熟悉它。 hackage.haskell.org/package/array-0.5.0.0/docs/Data-Array.html 这是数组的“此索引处的元素”运算符。 虽然这在理论上可以回答问题,it would be preferable 在这里包含答案的基本部分,并提供链接以供参考。 jelv.is/blog/Lazy-Dynamic-Programming,那为什么不直接包含这个链接呢。【参考方案5】:

动态编程算法通常利用将问题简化为更简单问题的想法。它的问题可以表述为一些基本事实(例如,从正方形单元格到自身的最短路径长度为 0)加上一组循环规则,这些规则准确地显示了如何减少问题 "find shortest path from cell (i,j)(0,0)" 到问题"找到从单元格(i-1,j)(i,j-1)(0,0) 的最短路径;选择最好的"。 AFAIK 这可以很容易地用函数式程序表达;不涉及任何状态。

【讨论】:

动态编程确实将问题划分为子问题。然而,动态规划是建立在重叠子问题的思想之上的。此逻辑不适用于查找两个字符串之间的距离。 我怀疑最初的问题是问你如何记忆中间结果;如果不这样做可能会导致(否则)多项式 DP 算法需要指数时间。 我不知道 Haskell 无法记忆函数(即缓存中间结果)有什么原因,但没有实现。自动这样做很困难,因为运行时系统不容易知道哪些值值得缓存以及缓存多长时间。 这个问题微妙之处的经典例子是:sum [1..10^9] / length [1..10^9]。如果列表未共享,该程序将在几秒钟内运行。如果它是共享的,它可能会在完成之前耗尽内存。 @ulidtko 使用Data.MemoCombinators查看luqui的回答【参考方案6】:

通过查看答案,如果我们谈论的是递归 + 缓存或简单的动态编程 (DP),我会感到有点奇怪。

因为如果只是 DP,下面的代码就是这样做的,https://jelv.is/blog/Lazy-Dynamic-Programming/

basic a b = d m n
  where (m, n) = (length a, length b)
        d i 0 = i
        d 0 j = j
        d i j
          | a !! (i - 1) ==  b !! (j - 1) = ds ! (i - 1, j - 1)
          | otherwise = minimum [ ds ! (i - 1, j)     + 1
                                , ds ! (i, j - 1)     + 1
                                , ds ! (i - 1, j - 1) + 1
                                ]

        ds = Array.listArray bounds
               [d i j | (i, j) <- Array.range bounds]
        bounds = ((0, 0), (m, n))

而且这个 DP 版本和其他语言并没有太大的不同,因为如果我在 javascript 中尝试它,它会有点冗长,但以类似的方式编写。

function levenshtein(str1, str2) 
    const m = str1.length + 1
    const n = str2.length + 1
    const mat = new Array(m).fill(0).map(() => 
        new Array(n).fill(0)
    )
    
    for (let i = 0; i < m; i++) 
        mat[i][0] = i
    
    for (let j = 0; j < n; j++) 
        mat[0][j] = j
    
    
    for (let i = 1; i < m; i++) 
        const ic = str1[i-1]
        for (let j = 1; j < n; j++) 
            const jc = str2[j-1]
            if (ic == jc) 
                mat[i][j] = mat[i-1][j-1]
             else 
                mat[i][j] = Math.min(
                    mat[i-1][j],
                    mat[i][j-1],
                    mat[i-1][j-1]
                ) + 1
            
        
    

    return mat[m-1][n-1]

所以我想知道问题是否与使用递归 + 缓存有关?

【讨论】:

以上是关于惯用的 Haskell 中如何实现动态规划算法?的主要内容,如果未能解决你的问题,请参考以下文章

惯用高效的 Haskell 追加?

学习惯用 Haskell 的资源(eta 缩减、符号中缀运算符、库等)[关闭]

用于简化递归的惯用 Haskell 代码

动态规划 - 矩阵链乘法

动态规划分析总结——怎样设计和实现动态规划算法

PHP实现动态规划之背包问题