【问题标题】:Avoiding stack overflow (with F# infinite sequences of sequences)避免堆栈溢出(使用 F# 无限序列)
【发布时间】:2010-10-28 07:00:47
【问题描述】:

我有我为 f# 中的 morris seq 编写的这个“学习代码”,它遭受了我不知道如何避免的堆栈溢出。 "morris" 返回一个无限的 "see and say" 序列(即 {{1}, {1,1}, {2,1}, {1,2,1,1}, {1,1,1 ,2,2,1}, {3,1,2,2,1,1},...})。

    let printList l =
        Seq.iter (fun n -> printf "%i" n) l
        printfn ""

    let rec morris s = 
        let next str = seq {
            let cnt = ref 1  // Stack overflow is below when enumerating
            for cur in [|0|] |> Seq.append str |> Seq.windowed 2 do
                if cur.[0] <> cur.[1] then
                    yield!( [!cnt ; cur.[0]] )
                    cnt := 0
                incr cnt
        }
        seq {
        yield s
        yield! morris (next s) // tail recursion, no stack overflow
        }

    // "main"
    // Print the nth iteration
    let _ =  [1] |> morris |> Seq.nth 3125 |> printList 

您可以使用 Seq.nth 完成第 n 次迭代,但您只能在遇到堆栈溢出之前完成此操作。我有一点递归是尾递归,它本质上构建了一组链接的枚举器。这不是问题所在。这是在第 4000 个序列上调用“枚举”的时候。请注意,对于 F# 1.9.6.16,之前的版本最高超过 14000)。这是因为链接序列的解析方式。序列是惰性的,因此“递归”是惰性的。也就是说,seq n 调用 seq n-1,后者调用 seq n-2 以此类推来获取第一项(第一个 # 是最坏的情况)。

我知道[|0|] |&gt; Seq.append str |&gt; Seq.windowed 2 让我的问题变得更糟,如果我消除它,我可以生成三倍的#。实际上,代码运行良好。 morris 的第 3125 次迭代长度将超过 10^359 个字符。

我真正要解决的问题是如何保留惰性 eval,并且对于我可以选择的迭代没有基于堆栈大小的限制。我正在寻找合适的 F# 习惯用法来根据内存大小进行限制。

2010 年 10 月更新

在更好地学习 F#,一点点 Haskell,思考和研究这个问题一年多之后,我终于可以回答我自己的问题了。但与难题一样,问题始于错误的问题。问题不在于序列的序列——这实际上是因为递归定义的序列。我的函数式编程技能现在稍微好一点,所以更容易看到下面的版本发生了什么,它仍然有一个 stackoverflow

let next str = 
    Seq.append str [0]
    |> Seq.pairwise
    |> Seq.scan (fun (n,_) (c,v) ->
            if (c = v) then (n+1,Seq.empty)
            else (1,Seq.ofList [n;c]) ) (1,Seq.empty)
    |> Seq.collect snd

let morris = Seq.unfold(fun sq -> Some(sq,next sq))

这基本上创建了一个非常长的 Seq 处理函数调用链来生成序列。 F#自带的Seq模块就是不使用栈就不能跟链的。它对追加和递归定义的序列进行了优化,但该优化仅在递归实现追加时才有效。

这样就可以了

let rec ints n = seq { yield n; yield! ints (n+1) }
printf "%A" (ints 0 |> Seq.nth 100000);;

这个会得到一个stackoverflow。

let rec ints n = seq { yield n; yield! (ints (n+1)|> Seq.map id) }
printf "%A" (ints 0 |> Seq.nth 100000);;

为了证明 F# 库是问题所在,我编写了自己的 Seq 模块,该模块使用延续实现了追加、成对、扫描和收集,现在我可以毫无问题地开始生成和打印 50,000 个 seq(它永远不会完成因为它的长度超过 10^5697 位)。

一些补充说明:

  • Continuations 是我一直在寻找的惯用语,但在这种情况下,它们必须进入 F# 库,而不是我的代码。我从 Tomas Petricek'sReal-World Functional Programming 一书中了解了 F# 中的延续。
  • 我接受的惰性列表答案是另一个成语;懒惰的评价。在我重写的库中,我还必须利用惰性类型来避免 stackoverflow。
  • 惰性列表版本是靠运气工作的(可能是设计使然,但这超出了我目前的能力范围)——它在构建和迭代时使用的活动模式匹配会导致列表在所需的递归得到之前计算值深,所以它很懒,但不是那么懒,它需要继续以避免堆栈溢出。例如,当第二个序列需要第一个序列中的一个数字时,它已经被计算出来了。换句话说,LL 版本对于序列生成并不是严格的 JIT 惰性,只是列表管理。

【问题讨论】:

  • 你的算法需要多长时间才能计算出第 60 个莫里斯元素?
  • 我不知道确切的时间。大概4分钟以上。我的一位同事所做的 c++ 版本是亚秒级的。我让它的功能越多,它就越慢。这都是对象的创建。上面的版本立即开始创建输出,即使是 14000。
  • 这个版本无论如何都不是很实用。我在 Haskell 中以纯粹的功能方式编写了这个,a) 更简洁(仅列表+模式匹配)和 b) 更快;-)
  • 我先创建了一个列表版本。它更快(60 次需要 34 秒?)但消耗了太多内存,我无法计算大于 64 次迭代的任何东西。我确实制作了上述功能的全功能版本(无可变版本),而且速度非常慢,到第 5 个序列时,每个 # 都需要几秒钟的时间来计算。 @Zifre - 感谢标签更改,就在今天早上,我在想标签可能是错误的,但没想到要修复它!
  • 当我看到这个问题时,有一秒钟,我以为你浏览这个网站的时间太长了,需要想办法避免它:)

标签: f# stack-overflow tail-recursion sequences


【解决方案1】:

你一定要看看

http://research.microsoft.com/en-us/um/cambridge/projects/fsharp/manual/FSharp.PowerPack/Microsoft.FSharp.Collections.LazyList.html

但我稍后会尝试发布更全面的答案。

更新

好的,下面有一个解决方案。它将莫里斯序列表示为 int 的 LazyLists 的 LazyList,因为我假设您希望它在“两个方向”上都是惰性的。

F# LazyList(在 FSharp.PowerPack.dll 中)具有三个有用的属性:

  • 它是惰性的(第 n 个元素的求值只有在第一次被要求时才会发生)
  • 它不会重新计算(重新计算同一对象实例上的第 n 个元素不会重新计算它 - 它会在第一次计算后缓存每个元素)
  • 您可以“忘记”前缀(当您“尾随”到列表中时,不再引用的前缀可用于垃圾回收)

第一个属性在 seq (IEnumerable) 中很常见,但其他两个属性是 LazyList 独有的,对于计算问题非常有用,例如这个问题中提出的问题。

废话不多说,代码:

// print a lazy list up to some max depth
let rec PrintList n ll =
    match n with
    | 0 -> printfn ""
    | _ -> match ll with
           | LazyList.Nil -> printfn ""
           | LazyList.Cons(x,xs) ->
               printf "%d" x
               PrintList (n-1) xs

// NextMorris : LazyList<int> -> LazyList<int>
let rec NextMorris (LazyList.Cons(cur,rest)) = 
    let count = ref 1
    let ll = ref rest
    while LazyList.nonempty !ll && (LazyList.hd !ll) = cur do
        ll := LazyList.tl !ll
        incr count
    LazyList.cons !count
        (LazyList.consf cur (fun() ->
            if LazyList.nonempty !ll then
                NextMorris !ll
            else
                LazyList.empty()))

// Morris : LazyList<int> -> LazyList<LazyList<int>>
let Morris s =
    let rec MakeMorris ll =
        LazyList.consf ll (fun () ->
            let next = NextMorris ll
            MakeMorris next
        )
    MakeMorris s

// "main"
// Print the nth iteration, up to a certain depth
[1] |> LazyList.of_list |> Morris |> Seq.nth 3125 |> PrintList 10
[1] |> LazyList.of_list |> Morris |> Seq.nth 3126 |> PrintList 10
[1] |> LazyList.of_list |> Morris |> Seq.nth 100000 |> PrintList 35
[1] |> LazyList.of_list |> Morris |> Seq.nth 100001 |> PrintList 35

更新2

如果你只想数数,那也没关系:

let LLLength ll =
    let rec Loop ll acc =
        match ll with
        | LazyList.Cons(_,rest) -> Loop rest (acc+1N)
        | _ -> acc
    Loop ll 0N

let Main() =
    // don't do line below, it leaks
    //let hundredth = [1] |> LazyList.of_list |> Morris |> Seq.nth 100
    // if we only want to count length, make sure we throw away the only
    // copy as we traverse it to count
    [1] |> LazyList.of_list |> Morris |> Seq.nth 100
        |> LLLength |> printfn "%A" 
Main()    

内存使用率保持平稳(我的盒子不到 16M)... 还没有完成运行,但我快速计算了第 55 个长度,即使在我的慢盒子上也是如此,所以我认为这应该可以正常工作。另请注意,我使用 'bignum's 作为长度,因为我认为这会溢出 'int'。

【讨论】:

  • 我需要再把这个分开一些。我实际上不想要缓存行为,所以如果我可以按照您的指示摆脱它,那么这个解决方案就是我所要求的。照原样,printfn "%A" ([1] |&gt; LazyList.of_list |&gt; Morris |&gt; Seq.nth 100 |&gt; Seq.length) 似乎将耗尽内存(测试仍在运行并且在 1.1gig;全部在第 2 代堆中)。我会按照你的建议去学习惰性列表。谢谢你写出来!
  • Seq.length 不适合这种情况,它会在使用枚举器时缓存整个列表。请参阅 UPDATE2,您需要一个“长度”函数,它可以在计数时丢弃列表。
  • 我唯一的失望是实现没有隐藏在序列后面。这是我要求的,再次感谢。
【解决方案2】:

我认为这里有两个主要问题:

  • 惰性是非常低效的,因此您可以预期惰性函数实现的运行速度会慢几个数量级。例如,here 描述的 Haskell 实现比我在下面给出的 F# 慢 2,400 倍。如果您想要一种解决方法,最好的办法可能是通过将计算捆绑在一起以按需生成批次来分摊计算。

  • Seq.append 函数实际上是从 IEnumerable 调用 C# 代码,因此,它的尾调用不会被消除,并且每次通过它时都会泄漏更多的堆栈空间。这会在您枚举序列时显示出来。

在计算第 50 个子序列的长度时,以下方法比您的实现快 80 倍以上,但可能对您来说还不够懒惰:

let next (xs: ResizeArray<_>) =
  let ys = ResizeArray()
  let add n x =
    if n > 0 then
      ys.Add n
      ys.Add x
  let mutable n = 0
  let mutable x = 0
  for i=0 to xs.Count-1 do
    let x' = xs.[i]
    if x=x' then
      n <- n + 1
    else
      add n x
      n <- 1
      x <- x'
  add n x
  ys

let morris =
  Seq.unfold (fun xs -> Some(xs, next xs)) (ResizeArray [1])

此函数的核心是对 ResizeArray 的折叠,如果您将结构用作累加器,则可以将其分解并在功能上使用而不会造成太多性能下降。

【讨论】:

  • 是的,还不够懒,因为我想要一个无限列表。这仍然让我的大脑弯曲思考,所以我不确定我是否能够解决 seq.append。就像我上面评论的那样,一位同事制作了一个懒惰的 c++ 版本,甚至超过了 100 个。最终有一小部分独特的序列是不影响其邻居的片段,因此您只需跟踪片段 # 并查找它生成的其他片段。 c++ 代码动态构建片段表,因此您不必以“1”开头。
  • 我的代码确实生成了一个无限序列。唯一的潜在问题是读取第 n 个子序列中的第一个元素会强制计算所有子序列直到并包括第 n 个。您可能可以进行相对较小的更改以命令式地按需计算所有内容,而不必遭受类似 Haskell 的性能。
  • 我的意思是一个惰性且无限的序列。我用let _ = morris |&gt; Seq.nth 3125 |&gt; printList 尝试了你的算法,但内存不足,因为它的长度为 10^359 个字符。我想我明白你的意思了,我的产量!不是尾递归,这可能是我的问题。
  • 仅供参考:VS2010 版本中的 Seq.append 不会调用 C# IEnumerable。查看 F# powerpack 附带的源代码,它现在专门针对产量进行了优化!递归调用
  • 只是抛开一些 Haskell FUD:链接的解决方案很慢,因为算法,而不是因为 Haskell 本身很慢。这是一个更快的方法:gist.github.com/1224319
【解决方案3】:

只需保存您查找的上一个元素。

let morris2 data = seq {
    let cnt = ref 0
    let prev = ref (data |> Seq.nth 0)

     for cur in data do
        if cur <> !prev then
            yield! [!cnt; !prev]
            cnt := 1
            prev := cur
        else
            cnt := !cnt + 1

    yield! [!cnt; !prev]
}

let rec morrisSeq2 cur = seq {
    yield cur
    yield! morrisSeq2 (morris2 cur)
}

【讨论】:

  • 是的,我明白了,正如我的问题中所述。你只是在延迟溢出。限制仍然是基于堆栈的,而是超过 14000。对我来说,你已经用 seq.nth 杀死了惰性 eval,所以我不得不重写一点来运行它。我希望它不仅能增加深度,还能让它因内存不足而不是堆栈溢出而失败。
猜你喜欢
  • 2016-07-12
  • 2020-03-08
  • 1970-01-01
  • 1970-01-01
  • 2010-11-30
  • 1970-01-01
  • 1970-01-01
  • 2011-09-21
  • 2015-11-14
相关资源
最近更新 更多