懒惰地将结结合起来进行一维动态规划

时间:2013-11-23 06:13:11

标签: algorithm haskell dynamic-programming lazy-evaluation tying-the-knot

几年前,我参加了一个算法课程,我们给出了以下问题(或类似的问题):

  

n楼层的楼层,电梯一次只能上升2层,一次下3层。使用动态编程编写一个函数,该函数将计算电梯从楼层i到楼层j所需的步数。

使用有状态方法显然很容易,你创建一个数组n个元素长并用值填充它。你甚至可以使用一种技术上非有状态的方法,它涉及累积结果递归传递它。我的问题是如何通过使用惰性评估和打结来以非有状态的方式执行此操作。


我想我已经设计了正确的数学公式:

f(i,j) = 0 when i is equal to j and f(i,j) = 1 + min of f(i+2,j) and f(i-3,j)

其中i+2i-3在允许的值范围内。

不幸的是我不能让它终止。如果我先放置i+2个案例,然后选择一个偶数楼层,我可以让它来评估目标等级以下的均匀楼层,但就是这样。我怀疑它直接射向最高的平坦地板,其他一切,下降3级,然后重复,永远在最顶层的几层之间振荡。

所以它可能以深度优先的方式探索无限空间(或有限但有环)。我想不出如何以广泛的方式探索这个空间而不使用其间有效模仿有状态方法的大量数据结构。


虽然这个简单的问题令人失望,但我怀疑在一维中看到了一个解决方案我可能能够使它适用于问题的二维变化。


编辑:很多答案试图以不同的方式解决问题。问题本身对我来说并不感兴趣,问题在于使用的方法。 Chaosmatter创建minimal函数的方法可以比较潜在的无限数字,这可能是朝着正确方向迈出的一步。不幸的是,如果我尝试创建一个表示100层楼的列表,结果需要很长时间才能计算,因为子问题的解决方案不会被重用。

我尝试使用自引用数据结构,但它没有终止,存在某种无限循环。我会发布我的代码,这样你就可以理解我的目标。如果有人能够在自引用数据结构上使用动态编程实际解决问题,我会改变接受的答案,使用懒惰来避免多次计算事物。

levels = go [0..10]
  where
    go [] = []
    go (x:xs) = minimum
      [ if i == 7
          then 0
          else 1 + levels !! i
        | i <- filter (\n -> n >= 0 && n <= 10) [x+2,x-3] ]
      : go xs

您可以看到1 + levels !! i如何尝试引用之前计算的结果以及filter (\n -> n >= 0 && n <= 10) [x+2,x-3]如何尝试将i的值限制为有效值。正如我所说的,这实际上并不起作用,它只是演示了方法,我想通过它来解决这个问题。其他解决方法是对我来说很有意思。

4 个答案:

答案 0 :(得分:9)

由于您试图在两个维度上解决此问题,并且除了上述问题之外的其他问题,让我们探索一些更通用的解决方案。我们正在尝试解决有向图上的shortest path problem

我们对图形的表示当前类似于a -> [a],其中函数返回可从输入到达的顶点。任何实现还需要我们可以比较以查看两个顶点是否相同,因此我们需要Eq a

以下图表存在问题,并且几乎解决了解决问题的所有困难:

problematic 1 = [2]
problematic 2 = [3]
problematic 3 = [2]
problematic 4 = []

当试图从1到4时,必须检测到一个涉及2和3的循环,以确定没有从1到4的路径。

广度优先搜索

如果应用于有限图的一般问题,将提出的算法具有在时间和空间上无限制的最坏情况性能。我们可以修改他的解决方案,通过添加循环检测来攻击仅包含有限路径和有限循环的图形的一般问题。他的原始解决方案和此修改都会在无限图中找到有限路径,但两者都无法可靠地确定无限图中两个顶点之间没有路径。

acyclicPaths :: (Eq a) => (a->[a]) -> a -> a -> [[a]]
acyclicPaths steps i j = map (tail . reverse) . filter ((== j).head) $ queue
  where
    queue = [[i]] ++ gen 1 queue
    gen d _ | d <= 0 = []
    gen d (visited:t) = let r = filter ((flip notElem) visited) . steps . head $ visited 
                        in map (:visited) r ++ gen (d+length r-1) t

shortestPath :: (Eq a) => (a->[a]) -> a -> a -> Maybe [a]
shortestPath succs i j = listToMaybe (acyclicPaths succs i j)

重复使用Will的答案中的step函数作为示例问题的定义,我们可以通过{{1}获得11层楼的4层到5层的最短路径长度}。这将返回fmap length $ shortestPath (step 11) 4 5

让我们考虑一个带v顶点和e边的有限图。具有v个顶点和e个边的图可以通过大小为n~O(v + e)的输入来描述。该算法的最坏情况图是有一个无法到达的顶点Just 3,其余的顶点和边用于创建从j开始的最大数量的非循环路径。这可能类似于包含所有不是ii的顶点的集团,其边缘从j到其他每个顶点都不是{{1} }}。具有e边的集团中的顶点数是O(e ^(1/2)),因此该图具有e~O(n),v~O(n ^(1/2))。在确定i无法访问之前,此图表将具有O((n ^(1/2))!)路径。

此函数所需的内存为O((n ^(1/2))!),因为它只需要每个路径的队列不断增加。

此功能对于这种情况所需的时间是O((n ^(1/2))!* n ^(1/2))。每次扩展路径时,都必须检查新节点是否已经在路径中,这需要O(v)~O(n ^(1/2))时间。如果我们有j并使用j或类似的结构来存储被访问的顶点,则可以将其改进为O(log(n ^(1/2)))。

对于非有限图,只有当不存在从Ord aSet a的有限路径时,此函数才能完全终止,但确实存在一条非有限路径。 ij

动态编程

动态编程解决方案并没有以相同的方式推广;让我们探讨原因。

首先,我们将使chaosmasttter的解决方案与我们的广度优先搜索解决方案具有相同的界面:

i

这适用于电梯问题,jinstance Show Natural where show = show . toNum infinity = Next infinity shortestPath' :: (Eq a) => (a->[a]) -> a -> a -> Natural shortestPath' steps i j = go i where go i | i == j = Zero | otherwise = Next . foldr minimal infinity . map go . steps $ i 。不幸的是,对于我们有问题的问题,shortestPath' (step 11) 4 5溢出了堆栈。如果我们为3数字添加更多代码:

shortestPath' problematic 1 4

我们可以询问最短路径是否短于某个上限。在我看来,这真的展示了懒惰评估发生了什么。 NaturalfromInt :: Int -> Natural fromInt x = (iterate Next Zero) !! x instance Eq Natural where Zero == Zero = True (Next a) == (Next b) = a == b _ == _ = False instance Ord Natural where compare Zero Zero = EQ compare Zero _ = LT compare _ Zero = GT compare (Next a) (Next b) = compare a b problematic 1 4 < fromInt 100False

接下来,为了探索动态编程,我们需要引入一些动态编程。由于我们将构建一个解决所有子问题的表,我们需要知道顶点可以采用的可能值。这为我们提供了一个稍微不同的界面:

problematic 1 4 > fromInt 100

我们可以使用此TrueshortestPath'' :: (Ix a) => (a->[a]) -> (a, a) -> a -> a -> Natural shortestPath'' steps bounds i j = go i where go i = lookupTable ! i lookupTable = buildTable bounds go2 go2 i | i == j = Zero | otherwise = Next . foldr minimal infinity . map go . steps $ i -- A utility function that makes memoizing things easier buildTable :: (Ix i) => (i, i) -> (i -> e) -> Array i e buildTable bounds f = array bounds . map (\x -> (x, f x)) $ range bounds 。这仍然无法检测周期...

动态编程和周期检测

循环检测对于动态编程是有问题的,因为当从不同路径接近子问题时,子问题是不相同的。考虑我们shortestPath'' (step 11) (1,11) 4 5问题的变体。

shortestPath'' problematic (1,4) 1 4 < fromInt 100

如果我们尝试从problematic转到problematic' 1 = [2, 3] problematic' 2 = [3] problematic' 3 = [2] problematic' 4 = [] ,我们有两种选择:

  • 转到1并选择从42的最短路径
  • 转到2并选择从43的最短路径

如果我们选择探索3,我们将面临以下选项:

  • 转到4并选择从23的最短路径

我们希望将从34的最短路径的两次探索合并到表中的相同条目中​​。如果我们想避免周期,这实际上是一些更微妙的东西。我们遇到的问题确实存在:

  • 转到3并从42的最短路径访问2
  • 转到4并从13的最短路径访问3

选择4

  • 转到1,然后选择233
  • 的最短路径4

关于如何从12的这两个问题有两个略有不同的答案。它们是两个不同的子问题,不能适应表中的相同位置。回答第一个问题最终需要确定您无法从3到达4。回答第二个问题很简单。

我们可以为每个可能的先前访问过的顶点组制作一堆表,但这听起来效率不高。我几乎让自己相信,只使用懒惰,我们无法将触及能力作为动态编程问题。

广度优先搜索redux

在开发具有可达性或周期检测的动态编程解决方案时,我意识到一旦我们在选项中看到一个节点,访问该节点的后续路径就不会是最佳的,无论我们是否遵循该节点。如果我们重新考虑4

如果我们尝试从2转到problematic',我们有两种选择:

  • 转到1并选择从42的最短路径,无需访问241
  • 转到2并选择从33的最短路径,无需访问341

这为我们提供了一种很容易找到最短路径长度的算法:

2

正如预期的那样,3-- Vertices first reachable in each generation generations :: (Ord a) => (a->[a]) -> a -> [Set.Set a] generations steps i = takeWhile (not . Set.null) $ Set.singleton i: go (Set.singleton i) (Set.singleton i) where go seen previouslyNovel = let reachable = Set.fromList (Set.toList previouslyNovel >>= steps) novel = reachable `Set.difference` seen nowSeen = reachable `Set.union` seen in novel:go nowSeen novel lengthShortestPath :: (Ord a) => (a->[a]) -> a -> a -> Maybe Int lengthShortestPath steps i j = findIndex (Set.member j) $ generations steps i lengthShortestPath (step 11) 4 5Just 3

在最坏的情况下,lengthShortestPath problematic 1 4需要的空间为O(v * log v),时间为O(v * e * log v)。

答案 1 :(得分:8)

问题是min需要完全评估对f的两次调用, 所以,如果其中一个无限循环min将永远不会返回。 因此,您必须创建一个新类型,编码f返回的数字为零或零的后继。

data Natural = Next Natural 
             | Zero

toNum :: Num n => Natural -> n
toNum Zero     = 0
toNum (Next n) = 1 + (toNum n)

minimal :: Natural -> Natural -> Natural
minimal Zero _            = Zero
minimal _ Zero            = Zero
minimal (Next a) (Next b) = Next $ minimal a b

f i j | i == j = Zero
      | otherwise = Next $ minimal (f l j) (f r j)
      where l = i + 2
            r = i - 3

此代码确实有效。

答案 2 :(得分:4)

站在i楼层的n楼层,找到前往楼层所需的最少步骤j,其中

step n i = [i-3 | i-3 > 0] ++ [i+2 | i+2 <= n]
因此我们有一棵树。我们需要以广度优先的方式搜索它,直到我们得到一个保持值为j的节点。它的深度是步数。我们建立一个队列,带有深度级别,

solution n i j = case dropWhile ((/= j).snd) queue
                   of []        -> Nothing
                      ((k,_):_) -> Just k
  where
    queue = [(0,i)] ++ gen 1 queue

函数gen d p从输出队列的生产点返回p个切口的d输入:

    gen d _ | d <= 0 = []
    gen d ((k,i1):t) = let r = step n i1 
                       in map (k+1 ,) r ++ gen (d+length r-1) t

使用TupleSections这里没有结,只是核心运动,即(乐观的)前进生产和节俭探索。工作正常没有打结因为我们只寻找第一个解决方案。如果我们正在搜索其中的几个,那么我们需要以某种方式消除这些周期。

使用循环检测:

solutionCD1 n i j = case dropWhile ((/= j).snd) queue
                    of []        -> Nothing
                       ((k,_):_) -> Just k
  where
    step n i visited =    [i2 | let i2=i-3, not $ elem i2 visited, i2 > 0] 
                       ++ [i2 | let i2=i+2, not $ elem i2 visited, i2 <=n]
    queue = [(0,i)] ++ gen 1 queue [i]
    gen d _ _ | d <= 0 = []
    gen d ((k,i1):t) visited = let r = step n i1 visited
                               in map (k+1 ,) r ++ 
                                  gen (d+length r-1) t (r++visited)

e.g。 solution CD1 100 100 7立即运行,生成Just 31visited列表几乎是队列本身的实例化前缀的副本。它可以作为Map维护,以提高时间复杂度(因为它,sol 10000 10000 7 => Just 3331在Ideone上需要1.27秒。)


有些解释似乎是有序的。

首先,关于您的问题没有2D,因为目标楼层j已修复。

您似乎想要的是 memoization ,正如您最新的编辑所示。记忆对递归解决方案很有用;你的函数确实是递归的 - 将其论证分析为子案例,将其结果与在子案例(此处为i+2i-3)上调用自身的结果进行综合,这些案例更接近基本案例(这里,i==j)。

因为算术是严格,所以在步骤树中存在任何无限路径(从一楼到另一层)时,你的公式会发散。 The answer by chaosmasttter,通过使用 lazy 算术代替,将其自动转换为广度优先搜索算法,只有在树中没有有限路径时才会发散,就像我上面的第一个解决方案一样(保存)因为它没有检查越界指数)。但它仍然是递归,所以确实需要进行memoization。

首先处理它的常用方法是通过“浏览列表”引入共享(效率低,因为顺序访问;对于有效的memoization解决方案see hackage):

f n i j = g i
  where
    gs = map g [0..n]              -- floors 1,...,n  (0 is unused)
    g i | i == j = Zero
        | r > n  = Next (gs !! l)  -- assuming there's enough floors in the building
        | l < 1  = Next (gs !! r)
        | otherwise = Next $ minimal (gs !! l) (gs !! r)
      where r = i + 2
            l = i - 3

未经测试。

我的解决方案是 corecursive 。它不需要记忆(只需要小心重复),因为它是生成性的,就像动态编程一样。它从其起始案例即起始楼层开始离开。外部访问器选择适当的生成结果。

它确实打结 - 它通过使用它来定义queue - queue位于等式的两边。我认为这是结点绑定的简单情况,因为它只是以伪装的方式访问先前生成的值。

第二种类型的结,更复杂的一种,通常是在某些数据结构中放置一些尚未定义的值,并将其返回到由代码的某些后续部分定义(例如,反向链接指针)在双重链接的循环列表中);这确实我的 1 代码正在做什么。它做的是生成队列,在其末尾添加并从其前面“删除”;最后,它只是Prolog的差异列表技术,开放式列表及其结束指针的维护和更新,自上而下的列表构建tail recursion modulo cons - 在概念上完全相同。首先描述(虽然未命名)in 1974,AFAIK。


1 完全基于Wikipedia的代码。

答案 3 :(得分:3)

其他人已回答您关于动态编程的直接问题。然而,对于这种问题,我认为贪婪的方法是最好的。它的实现非常简单。

f i j :: Int -> Int -> Int
f i j = snd $ until (\(i,_) -> i == j) 
                    (\(i,x) -> (i + if i < j then 2 else (-3),x+1))
                    (i,0)