【问题标题】:How recursion met the base case Haskell递归如何满足基本情况 Haskell
【发布时间】:2019-08-12 10:29:50
【问题描述】:

我试图理解这段代码,它返回传递给它的[a] 的所有可能组合:

-- Infinite list of all combinations for a given value domain
allCombinations :: [a] -> [[a]]
allCombinations []     = [[]]
allCombinations values = [] : concatMap (\w -> map (:w) values)
                                        (allCombinations values)

我在这里尝试了这个示例输入:

ghci> take 7 (allCombinations [True,False])
[[],[True],[False],[True,True],[False,True],[True,False],[False,False]]

在这里,我似乎无法理解,递归最终将如何停止并返回[ [ ] ],因为allCombinations 函数肯定没有任何指针在每次调用时在列表中移动,并且当它满足基本情况[ ] 时,它返回[ [ ] ]。据我说,它会调用allCombinations 函数无限并且永远不会自行停止。或者我可能错过了什么?

另一方面,在完成递归调用后返回执行所有计算后,take 仅返回来自final list 的第一个7 元素。那么实际上递归是如何在这里满足基本情况的呢?

其次这里concatMap的目的是什么,这里我们也可以使用Map函数只是将函数应用到列表中,内部函数我们可以排列列表吗? concatMap 到底在做什么。从定义上看,concatMap 告诉我们它首先映射函​​数,然后连接列表,正如我所见,我们已经在这里的函数内部这样做了?

任何有价值的意见将不胜感激?

【问题讨论】:

  • 只有在将空列表作为参数传递给allCombinations 时才会使用基本情况。它从不用于递归情况。
  • 即它不是基本情况,而是一种特殊情况,这不是递归而是核心递归,它从不停止。不错的代码/问题。 :)
  • 旁注:如果你使用值递归而不是函数递归,这个函数可能会变得更快:allCombinations values = let r = [] : concatMap (\w -> map (w:) values) r in r。尚未考虑过,但这是更强大的优化机会之一。
  • @luqui 实际上,我在(更新的)答案中提出了相反的主张。不过还没有测试过。

标签: list haskell recursion


【解决方案1】:

简短回答:它永远不会满足基本情况。

但是,它不需要。最常需要基本情况​​来停止递归,但是在这里您想返回一个无限列表,因此无需停止它。

另一方面,如果您尝试获取allCombination [] 的多个元素,则此功能会中断——请查看@robin 的答案以更好地了解原因。这是您在此处看到基本案例的唯一原因。

main 函数的工作方式是它以一个空列表开始,然后将参数列表中的每个元素附加到开头。 (:w) 递归地执行此操作。但是,仅此 lambda 将返回一个无限嵌套的列表。即:[],[[True],[False]],[[[True,True],[True,False] 等。Concatmap 在每一步都删除外部列表,并且由于它被递归调用,因此它只在最后返回一个列表列表。这可能是一个很难掌握的复杂概念,因此请寻找使用 concatMap 的其他示例,并尝试了解它们的工作原理以及为什么单独使用 map 是不够的。

这显然只适用于 Haskell 惰性求值。同样,您知道在foldr 中您需要将基本情况传递给它,但是当您的函数应该只接受无限列表时,您可以将undefined 作为基本情况,以更清楚地表明有限列表不应该使用。例如,foldr f undefined 可以用来代替 foldr f []

【讨论】:

  • 感谢您的详细解答。真正的困惑是第一个标记 [ ] 是如何进行递归计算的。你的意思是如果它的基本情况在无限列表的情况下未定义,它将把 [] 作为默认基本情况?所以它会在列表末尾附加 [ [ ] ] 来执行计算?如果它返回一个无限列表,它是如何将控制转移到 Take 函数以仅从无限列表中提取前 7 个元素的。我只是一个初学者,这就是为什么我试图从非常基本的层面理解这一点。 ://
  • 在 Haskell 中则相反。 take 具有控制权,它要求函数计算前 7 个值。我还编辑了我的答案以更好地解释基本情况
【解决方案2】:

@Lorenzo 已经解释了关键点——递归实际上永远不会结束,因此这会生成一个无限列表,由于 Haskell 的惰性,您仍然可以从中获取任何有限数量的元素。但我认为提供有关此特定功能及其工作原理的更多详细信息会有所帮助。

首先,定义开头的[] : 告诉您第一个元素总是[]。这当然是从values 的元素创建一个 0 元素列表的唯一方法。列表的其余部分是concatMap (\w -> map (:w) values) (allCombinations values)

concatMap f 就像您观察到的那样简单地组合concat . (map f):它将给定函数应用于列表的每个元素,并将结果连接在一起。这里的函数 (\w -> map (:w) values) 接受一个列表,并通过将 values 的每个元素添加到该列表中来生成列表列表。例如,如果values == [1,2],那么:

(\w -> map (:w) values) [1,2] == [[1,1,2], [2,1,2]]

如果我们 map 对列表列表执行该函数,例如

[[], [1], [2]]

然后我们得到(仍然使用values 作为[1,2]):

[[[1], [2]], [[1,1], [2,1]], [[1,2], [2,2]]] 

这当然是列表列表的列表 - 但随后 concatMapconcat 部分来拯救我们,将最外层展平,并产生如下列表列表:

[[1], [2], [1,1], [2,1], [1,2], [2,2]]     

我希望您可能已经注意到的一件事是,我开始使用的列表不是任意的。 [[], [1], [2]] 是起始列表 [1,2] 中大小为 0 或 1 的所有组合的列表。这其实是allCombinations [1,2]的前三个元素。

回想一下,我们在查看定义时“肯定”知道的是该列表的第一个元素将是[]。列表的其余部分是concatMap (\w -> map (:w) [1,2]) (allCombinations [1,2])。下一步是将其递归部分扩展为[] : concatMap (\w -> map (:w) [1,2]) (allCombinations [1,2])。外concatMap 然后可以看到它映射的列表的头部是[] - 生成一个以[1], [2] 开头的列表,并继续将12 附加到其他元素的结果——不管它们是什么。但是我们刚刚看到接下来的两个元素实际上是[1][2]。我们最终得到了

allCombinations [1,2] == [] : [1] : [2] : concatMap (\w -> map (:w) values) [1,2] (tail (allCombinations [1,2]))

tail 在评估过程中没有被严格调用,而是通过模式匹配来完成 - 我试图用文字来解释更多,而不是通过等式显式地沉闷)。

我们知道尾巴是[1] : [2] : concatMap ...。关键是,在流程的每个阶段,我们都知道列表的前几个元素是什么——它们恰好是所有 0 元素列表,其值取自 values,然后是所有 1-具有这些值的元素列表,然后是所有 2 元素列表,依此类推。一旦你开始了,这个过程必须继续,因为传递给concatMap 的函数确保我们只获得从到目前为止生成的每个列表中获得的列表,并将values 的每个元素附加到它们的前面。

如果您仍然对此感到困惑,请查看如何在 Haskell 中计算斐波那契数。获取所有斐波那契数的无限列表的经典方法是:

fib = 1 : 1 : zipWith (+) fib (tail fib)

这比allCombinations 的例子更容易理解,但本质上依赖于相同的东西——纯粹根据自身定义一个列表,但使用惰性求值来逐步生成尽可能多的列表,根据一个简单的规则。

【讨论】:

    【解决方案3】:

    不是基本情况,而是特殊情况,这不是递归而是corecursion,( *)从不停止。

    也许下面的重新表述会更容易理解:

    allCombs :: [t] -> [[t]]
    --        [1,2] -> [[]] ++ [1:[],2:[]] ++ [1:[1],2:[1],1:[2],2:[2]] ++ ...
    allCombs vals = concat . iterate (cons vals) $ [[]]
        where
        cons :: [t] -> [[t]] -> [[t]]
        cons vals combs = concat [ [v : comb | v    <- vals]
                                   |           comb <- combs ]
    
    -- iterate   :: (a     -> a    ) -> a     -> [a]
    -- cons vals ::  [[t]] -> [[t]]
    -- iterate (cons vals)           :: [[t]] -> [[[t]]]
    -- concat    ::                              [[ a ]] -> [ a ]
    -- concat . iterate (cons vals)                      :: [[t]]
    

    看起来不同,做同样的事情。不仅产生相同的结果,而且实际上正在做同样的事情来产生它们。(*)concatconcat 相同,您只需要稍微倾斜一下头就可以看到它。

    这也说明了为什么这里需要concat。每个step = cons vals 正在生成一批新的组合,每个step 应用程序的长度增加1,concat 将它们全部粘合到一个结果列表中。

    每个批次的长度是前一个批次的长度乘以n,其中nvals 的长度。这也表明需要对vals == [] 情况进行特殊处理,即n == 0 情况:0*x == 0,因此每个新批次的长度为0,因此从结果中获取更多值的尝试永远不会产生结果,即进入无限循环。到那时,该功能被称为非生产性

    顺便说一句,cons

    几乎一样
                       == concat [ [v : comb | comb <- combs]
                                   |           v    <- vals  ]
                       == liftA2 (:) vals combs
    
    liftA2 :: Applicative f => (a -> b -> r) -> f a -> f b -> f r
    

    因此,如果每个步骤结果的内部顺序对您来说并不重要(但请参阅帖子底部的重要警告),则可以将其编码为

    allCombsA :: [t] -> [[t]]
    --         [1,2] -> [[]] ++ [1:[],2:[]] ++ [1:[1],1:[2],2:[1],2:[2]] ++ ...
    allCombsA   []   =  [[]]
    allCombsA  vals  =  concat . iterate (liftA2 (:) vals) $ [[]]
    

    (*)其实,这里指的是稍微修改过的版本,

    allCombsRes vals = res
                 where res = [] : concatMap (\w -> map (: w) vals)
                                  res
    -- or:
    allCombsRes vals = fix $ ([] :) . concatMap (\w -> map (: w) vals)
    --  where
    --  fix g = x where x = g x     -- in Data.Function
    

    或者在伪代码中:

     Produce a sequence of values `res` by
          FIRST producing `[]`, AND THEN
          from each produced value `w` in `res`, 
              produce a batch of new values `[v : w | v <- vals]`
              and splice them into the output sequence
                   (by using  `concat`)
    

    所以res 列表是核心递归生成的,从它的起点[] 开始,根据前一个元素生成它的下一个元素——或者分批,如基于iterate 的版本,或者像这里一样一个接一个,通过反向指针将输入输入到 previously 产生的结果中(将其输出作为输入,俗话说——这当然有点欺骗性,因为我们以比生产它的速度来处理它,否则这个过程将停止生产,正如上面已经提到的)。

    但是。有时通过递归调用产生输入可能是有利的,在运行时创建一系列函数,每个函数将其输出沿链传递给其调用者。尽管如此,数据流是向上的,这与首先向下朝向基本情况的常规递归不同。

    刚才提到的优势与记忆保留有关。 corecursive allCombsRes 好像保留了一个指向它自己正在生成的序列的反向指针,因此不能动态地对序列进行垃圾收集。

    但是您的原始版本在运行时隐式创建的流生产者链意味着它们中的每一个可以在运行中进行垃圾收集,因为n = length vals 新元素是从每个下游产生的元素,所以整个过程就相当于只是k = ceiling $ logBase n i 嵌套循环,每个都有O(1)空间状态,产生第i个序列的元素。

    这比 corecursive/value-recursive allCombsResO(n) 内存要求要好得多,后者实际上将反向指针保留在 @987654350 的输出中@ 位置。在实践中,对数空间要求最有可能被视为或多或少 O(1) 的空间要求。

    这种优势只发生在您的版本中的生成顺序上,即cons vals,而不是liftA2 (:) vals,它必须回到其输入序列combs的开头(对于每个新的vvals) 中,因此必须保留,因此我们可以肯定地说,您问题中的表述相当巧妙。

    如果我们在进行无点重新制定——因为无点可以有时会有所启发——它是

    allCombsY values =  _Y $ ([] :) . concatMap (\w -> map (: w) values)
        where
        _Y g = g (_Y g)      -- no-sharing fixpoint combinator
    

    所以代码在fix-using 公式中更容易理解,然后我们只需将fix 与语义等效的_Y 切换,以提高效率,从问题中获取(等效的)原始代码.

    上述关于空间需求行为的声明are easily tested。我还没有这样做。

    另见:

    【讨论】:

    • 第一句话真的切中要害! “不是基本情况,而是特殊情况”。
    猜你喜欢
    • 2014-03-03
    • 2021-05-13
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2018-10-24
    相关资源
    最近更新 更多