【问题标题】:Functionally solving questions: how to use Haskell?从功能上解决问题:如何使用 Haskell?
【发布时间】:2013-12-11 08:21:21
【问题描述】:

我正在尝试解决 H99 中的一个问题: 将列表分成两部分;第一部分的长度已给出。

不要使用任何预定义的谓词。

例子:

> (split '(a b c d e f g h i k) 3)
( (A B C) (D E F G H I K))

我可以很快找到解决方案:

split'::[a]->Int->Int->[a]->[[a]]
split' [] _ _ _     = []
split' (x:xs) y z w = if y == z then [w,xs] else split' xs y (z+1) (w++[x])

split::[a]->Int->[[a]]
split x y = split' x y 0 []

我的问题是,我所做的只是以递归格式重写循环版本。这是你在 Haskell 中做事的正确方式吗?不就是和命令式编程一样吗?

编辑:另外,你通常如何避免这里的额外功能?

【问题讨论】:

    标签: haskell recursion functional-programming


    【解决方案1】:

    您通常可以将命令式解决方案转换为 Haskell,这很方便,但您是对的,您通常希望找到更自然的递归语句。特别是对于这一点,根据基本情况和归纳情况进行推理可能非常有帮助。那么你的基本情况是什么?为什么,当分割位置为0时:

    split x 0 = ([], x)
    

    可以通过将列表的第一个元素附加到用 n-1 拆分的结果上来构建归纳案例:

    split (x:xs) n = (x:left, right)
      where (left, right) = split xs (n-1)
    

    这可能表现不佳(它可能没有你想象的那么糟糕),但它说明了我第一次遇到问题并想从功能上解决问题时的思考过程。

    编辑:另一个更依赖 Prelude 的解决方案可能是:

    split l n = (take n l, drop n l)
    

    【讨论】:

    • 哦,“Un-haskellish”评论是针对 OP 原始解决方案的,我喜欢你的 :) 这也是我第一次写的方式,但我认为尾递归对于这类函数很重要
    • 我同意尾递归很重要,但我认为初学者首先是为了优雅,在担心性能之前应该掌握基础知识。
    • 我很好奇 Platform/GHC 有什么,它是 (take n l, drop n l) 解决方案。不知道为什么,但值得一提的是,takedrop 的 Platform/GHC 版本具有列表融合优化,所以重点是元组的两个元素都是“好生产者”,因此它们可以融合到与他们的消费者相同的循环。 (这也是为什么 Haskell 初学者不应该陷入尾递归的另一个原因——在 Haskell 中,由于惰性和融合优化,我们经常遇到“尾递归不好,非尾递归好”的情况......
    • @jozefg 是的,但是在向新手解释事物时,您经常不得不与尾递归必然使用常量的误解(通常来自以前接触过严格的函数式语言,如 ML 或 Scheme)作斗争空间和非尾递归必然使用线性空间。正如你我所知,这些陈述在 Haskell 中通常都不正确。但我经常看到新人向后弯腰写一个尾递归版本的东西,最好写成foldr——我最喜欢的例子之一是find
    • @bheklilr 是的,但是尾递归在这里适用,因为即使是尾递归解决方案也会在无限列表上终止(它在 Int 而不是列表上递归)
    【解决方案2】:

    实际上和命令式编程不一样,每个函数调用都避免了任何副作用,它们只是简单的表达式。但我对你的代码有一个建议

    split :: Int -> [a] -> ([a], [a])
    split p xs = go p ([], xs)
      where go 0 (xs, ys) = (reverse xs, ys)
            go n (xs, y:ys) = go (n-1) (y : xs, ys)
    

    所以我们如何声明我们只返回两个东西 ([a], [a]) 而不是一个东西列表(这有点误导)并且我们已经将尾递归调用限制在本地范围内。

    我也在使用模式匹配,这是在 Haskell 中编写递归函数的一种更惯用的方法,当 go 用零调用时,则运行第一种情况。编写向下而不是向上的递归函数通常更令人愉快,因为您可以使用模式匹配而不是 if 语句。

    最后这更有效,因为++ 在第一个列表的长度上是线性的,这意味着您的函数的复杂性是二次的而不是线性的。与 Daniel 的解决方案不同,此方法也是尾递归的,这对于处理任何大型列表都很重要。

    TLDR:两个版本都是函数式风格,避免变异,使用递归而不是循环。但是我提供的版本更接近 Haskell 并且速度稍快。

    关于尾递归的一句话

    此解决方案使用尾递归这在 Haskell 中并不总是必不可少的,但在这种情况下,当您使用结果列表时很有帮助,但在其他时候实际上是一件坏事。例如,map 不是尾递归的,但如果是,你就不能在无限列表中使用它!

    在这种情况下,我们可以使用尾递归,因为整数总是有限的。但是,如果我们只使用列表的第一个元素,Daniel 的解决方案会快得多,因为它会延迟生成列表。另一方面,如果我们使用整个列表,我的解决方案会快得多。

    【讨论】:

    • 尾递归仅在 Haskell 中构建完整所需的值(如数字)时才有用。列表不是这样。您的版本过于严格。有保护的递归总是可取的,这是我的理解。看到reverse 真是太糟糕了,:) w.r.t.懒惰等
    • @WillNess 是的,这个解决方案在第一个列表中并不懒惰,但我在“尾递归的一个词”部分中解决了这个问题。
    • 是的,我现在看到了。 reverse 刚刚跳到我身上。 :) 我认为可以安全地设置经验法则,尾递归在 Haskell 中几乎总是错误的做法,除非它是正确的做法。最后,重要的是运行时行为——时间空间。受保护的记录更有可能在恒定空间中运行。
    【解决方案3】:
    split'::[a]->Int->([a],[a])
    
    split' [] _ = ([],[])
    split' xs 0 = ([],xs)
    split' (x:xs) n = (x:(fst splitResult),snd splitResult) 
                      where splitResult = split' xs (n-1)
    

    看来你已经展示了一个更好的解决方案的例子。

    我建议您阅读SICP。然后你得出的结论是额外的功能是正常的。还有广泛使用的方法来隐藏局部区域的功能。这本书对你来说可能看起来很无聊,但在前面几章中,她会习惯于用函数式方法解决问题。

    有些任务更需要递归方法。但是例如,如果您使用尾递归(经常被无缘无故地称赞),那么您会注意到这只是通常的迭代。通常带有隐藏迭代变量的“额外功能”(哦..单词变量不是很合适,可能是参数)。

    【讨论】:

    • 我认为“额外的功能是正常的”很容易掌握,不需要花一年时间学习一本冗长而密集的计算机科学书籍。
    • @DanielLyons 但这本书是为一年级学生准备的。还有初学者问题(似乎)。
    • 我喜欢 SICP 并且认为每个人都应该阅读它,但是学习惯用的 Scheme 使用块结构来制作隐藏的本地函数与手头的任务并不真正相关。此外,在惰性语言和严格语言中制作好的函数式程序的区别很大
    • @DanielLyons SICP 既有趣又好用,而且真的很容易上手。你不需要真正了解 Scheme 来阅读它,只需继续浏览这本书。当您看到(define (sqrt a b) ...) 时,它确实是您的想法。 :)
    • @WillNess 在这里提到 SICP 所做的一切都是毫无意义地闪烁帮派标志。问题和答案都与它无关。字典定义了每个单词,但这并不意味着它适用于每个问题和每个答案。坚持到底。​​span>
    猜你喜欢
    • 2020-11-19
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2021-07-03
    • 1970-01-01
    相关资源
    最近更新 更多