16

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

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

这显然很容易使用有状态的方法,您创建一个长度为 n 个元素的数组并用值填充它。您甚至可以使用一种技术上的无状态方法,该方法涉及将结果累积为递归传递。我的问题是如何通过使用惰性评估和打结以无状态的方式做到这一点。


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

f(i,j) = 0 当 i 等于 j 并且 f(i,j) = 1 + f(i+2,j) 和 f(i-3,j) 的最小值

wherei+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

4 回答 4

9

问题是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

这段代码确实有效。

于 2013-11-23T12:01:34.023 回答
9

由于您试图在两个维度上解决此问题,并且对于所描述的问题之外的其他问题,让我们探索一些更通用的解决方案。我们正在尝试解决有向图上的最短路径问题

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

下图是有问题的,并且介绍了一般解决问题的几乎所有困难:

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

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

广度优先搜索

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

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)

重用stepWill 的答案中的函数作为示例问题的定义,我们可以得到从 11 层建筑的 4 层到 5 层的最短路径长度fmap length $ shortestPath (step 11) 4 5。这返回Just 3

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

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

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

对于非有限图,只有在不存在 from to 的有限路径但确实存在 from ito的j非有限路径时,此函数才应准确终止。ij

动态规划

动态规划解决方案的泛化方式不同。让我们探讨一下原因。

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

instance 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 53。不幸的是,对于我们有问题的问题,shortestPath' problematic 1 4堆栈溢出。如果我们为数字添加更多代码Natural

fromInt :: 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 100Falseproblematic 1 4 > fromInt 100True

接下来,为了探索动态规划,我们需要介绍一些动态规划。由于我们将构建所​​有子问题的解决方案表,因此我们需要知道顶点可以采用的可能值。这给了我们一个稍微不同的界面:

shortestPath'' :: (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 = []

如果我们试图从1to 4,我们有两个选择:

  • go to并采取从to 到2的最短路径24
  • go to并采取从to 到3的最短路径34

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

  • go to并采取从to 到3的最短路径34

我们希望将最短路径的两个探索组合34表中的同一个条目中。如果我们想避免循环,这确实有点微妙。我们真正面临的问题是:

  • go to2并采取最短路径 from 2to 4that don't visit1
  • go to3并采取最短路径 from 3to 4that don't visit1

选择后2

  • go to并3采取最短路径从不访问或3412

这两个关于如何得到的问题34两个略有不同的答案。它们是两个不同的子问题,不能放在表中的同一位置。回答第一个问题最终需要确定您无法4从 from获得2。回答第二个问题很简单。

我们可以为每个可能的先前访问过的顶点集制作一堆表格,但这听起来效率不高。我几乎已经说服自己,我们不能仅使用惰性来将可达性作为一个动态编程问题。

广度优先搜索

在研究具有可达性或循环检测的动态规划解决方案时,我意识到一旦我们在选项中看到了一个节点,无论我们是否遵循该节点,以后访问该节点的路径都不会是最优的。如果我们重新考虑problematic'

如果我们试图从1to 4,我们有两个选择:

  • go to并采取从to2的最短路径而不访问, , 或24123
  • go to并采取从to3的最短路径而不访问, , 或34123

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

-- 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 5isJust 3lengthShortestPath problematic 1 4is Nothing

在最坏的情况下,generations需要 O(v*log v) 的空间和 O(v*e*log v) 的时间。

于 2013-11-23T17:53:20.427 回答
4

站在一层楼的地板in,找出到达地板所需的最少步数j,其中

step n i = [i-3 | i-3 > 0] ++ [i+2 | i+2 <= n]

因此我们有一棵树。我们需要以广度优先的方式搜索它,直到我们得到一个持有 value 的节点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其输入:pd

    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)

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


一些解释似乎是有序的。

首先,您的问题没有二维,因为目标楼层j是固定的。

正如您最近的编辑所表明的那样,您似乎想要的是memoization 。记忆对于递归解决方案很有用;您的函数确实是递归的 - 将其参数分析为子案例,根据在更接近基本案例(此处, )的子案例(此处i+2和)上调用自身的结果综合其结果。i-3i==j

因为算术是严格的,所以在步骤树中存在任何无限路径(从一层到另一层)的情况下,您的公式是发散的。chaosmasttter 的答案,通过使用惰性算术,将其自动转换为广度优先搜索算法,该算法仅在树中没有有限路径时才发散,就像我上面的第一个解决方案一样(除了它不检查超出范围的索引)。但它仍然是递归的,所以确实需要记忆。

首先处理它的常用方法是通过“遍历列表”来引入共享(效率低下,因为顺序访问;对于有效的记忆解决方案,请参阅 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代码正在做的事情。它所做的是生成一个queue,在其末尾添加并从其前面“删除”;最后,它只是 Prolog 的一种差异列表技术,维护和更新其结束指针的开放式列表,尾递归模 cons的自上而下的列表构建- 在概念上所有相同的东西。AFAIK于 1974 年首次描述(尽管未命名) 。


1完全基于维基百科的代码。

于 2013-11-23T11:59:55.717 回答
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)
于 2013-11-23T19:14:30.800 回答