【问题标题】:How do purely functional languages handle index-based algorithms?纯函数式语言如何处理基于索引的算法?
【发布时间】:2022-04-05 20:51:12
【问题描述】:

我一直在努力学习函数式编程,但我仍然难以像函数式程序员那样思考。一种这样的挂断是如何实现高度依赖循环/执行顺序的索引繁重的操作。

例如,考虑以下 Java 代码:

public class Main {
    public static void main(String[] args) {
        List<Integer> nums = Arrays.asList(1,2,3,4,5,6,7,8,9);
        System.out.println("Nums:\t"+ nums);
        System.out.println("Prefix:\t"+prefixList(nums));
    }
  
    private static List<Integer> prefixList(List<Integer> nums){
      List<Integer> prefix = new ArrayList<>(nums);
      for(int i = 1; i < prefix.size(); ++i)
        prefix.set(i, prefix.get(i) + prefix.get(i-1));
      return prefix;
    }
}
/*
System.out: 
Nums:   [1, 2, 3, 4, 5, 6, 7, 8, 9]
Prefix: [1, 3, 6, 10, 15, 21, 28, 36, 45]
*/

这里,在prefixList函数中,首先克隆了nums列表,然后对其进行了迭代操作,其中索引i上的值依赖于索引i-1(即需要执行顺序) .然后返回这个值。

这在函数式语言(Haskell、Lisp 等)中会是什么样子?我一直在学习 monad,认为它们在这里可能是相关的,但我的理解仍然不是很好。

【问题讨论】:

  • 如下所述,这不是一个真正需要索引的示例。事实上,许多常用算法不需要索引。尽管如此,还是有一些:例如,计算序列0, a[0], a[a[0]], a[a[a[0]]], ... 需要随机访问(假设所有元素都是有效索引)。在那些罕见的情况下,我们求助于...数组,例如Data.Vector。 Haskell 可以在需要时强制使用——我们只是很少需要这样做。
  • 您的问题是关于用任何函数式语言实现您的算法,还是专门关于仅使用 ADT(例如 Haskell 的 List 是如何作为链表实现的)?有些函数式语言的值类型不是 ADT,参见 Swift 的 Array 或 Haskell 的 Vector(如上所述),而后者不支持 O(1) 更新。
  • 您的实现已经完美运行。 prefixList() 不依赖于在其参数中传递的任何其他数据,并且除了其返回值之外没有任何影响。这就是纯函数的本质。或者当然有些语言比其他语言更容易编写函数式代码,我认为 Java 的 10 个 lambdas 中大约有 4.5 个。
  • @Feuermurmel 我的问题与任何算法的关系都比我作为示例提供的前缀更相关。我正在尝试学习如何应用思维模式来摆脱总是使用索引。
  • @AaronC 这当然是一个公平的目标,尝试根据整个列表的转换来实现列表算法,而不是通过索引显式访问单个元素!困扰我的是这个标题,因为它暗示函数式语言本身不能处理基于索引的算法。

标签: haskell functional-programming lisp


【解决方案1】:

信不信由你,这个函数实际上是 built-in 到 Haskell。

> scanl (+) 0 [1..9]
[0,1,3,6,10,15,21,28,36,45]

因此,广泛的答案通常是:我们可以使用方便的与列表相关的原语来构建它们,而不是手动编写循环。人们喜欢说递归是 FP 中最接近命令式编程中“for 循环”的类比。虽然这可能是真的,但一般的函数式程序使用的显式递归比一般的命令式程序使用的 for 循环要少很多。我们所做的大部分工作(尤其是列表)都是由mapfilterfoldl 以及Data.ListData.FoldableData.Traversable 中的所有其他(高度优化的)好东西组成的,以及base 的其余部分。

至于我们如何实现这些功能,您可以查看scanl的源代码。出于效率原因,GHC 上的写法略有不同,但基本要点是这样的

scanl :: (b -> a -> b) -> b -> [a] -> [b]
scanl _ q [] = [q]
scanl f a (b:xs) = a : scanl f (f a b) xs

您不需要索引。您需要在构建列表时跟踪单个先前的值,我们可以使用函数参数来做到这一点。如果您确实有需要随机访问各种索引的算法,我们有Data.Vector 。但 99% 的情况下,答案是“停止考虑索引”。

【讨论】:

  • '99% 的时间,答案是“停止考虑索引”'是不现实的。人们在 C / Python / Matlab 等中使用数组做的许多事情。对于模拟或数据科学,可以用 Haskell 列表操作来表达。我仍然完全同意应该克服对带有索引的数组的痴迷,但这通常需要对思维进行一些更根本的改变。 cs.ox.ac.uk/seminars/2418.html
  • @leftaroundabout 我不同意。我想说,人们使用索引所做的 99.5% 的事情对于转换为非索引形式来说都是微不足道的。最后的0.5%确实很有意思,值得关注。从我观察到的经验和代码来看,我们很少使用索引做真正有趣的事情。大多数时候它只是无聊地按顺序循环数据。它们太无聊了,我们甚至没有注意到我们正在做它们。然而,你说得对,最后 0.5% 通常需要从根本上改变观点。
  • @CortAmmon 我的理解是 FP 是关于减少状态以增加并行性并降低复杂性。密码算法被设计成难以并行化,状态通常是设计的一个组成部分。所以使用 FP 将是一个方形钉...
  • @Aron True,使用 FP 解决该问题可能是一种反模式。但是编写一个调用 C 函数来进行加密的 FP 程序是很常见的,我认为这就是 Cort 在他们最后一句话中的建议。
  • @CortAmmon 好吧,如果你也算上任何现代语言可以用某种基于范围的 for 循环/隐式向量化等真正琐碎的事情,那也许是真的。但是现在任何人仍然使用索引来完成这种任务,值得用他们的代码的物理打印输出来拍脑袋。 不容易转换为递归 Haskell-on-lists 的“0.5%”代码仍然包含许多对数组非常简单的内容,例如基于所有元素更新多维数组中的单元格它的邻居。不需要人为的加密示例。
【解决方案2】:

这不是一个索引繁重的操作,实际上你可以使用scanl1 :: (a -&gt; a -&gt; a) -&gt; [a] -&gt; [a] 的单行来做到这一点:

prefixList = scanl1 (+)

确实,对于Nums 的列表,我们得到:

Prelude> prefixList [1 .. 9]
[1,3,6,10,15,21,28,36,45]

scanl1 将原始列表的第一项作为累加器的初始值,并产生它。然后每次它获取累加器和给定列表的下一项,并将它们相加为新的累加器,并产生新的累加器值。

通常不需要索引,但枚举列表就足够了。命令式编程语言通常使用带有索引的for 循环,但在许多情况下,这些可以被foreach 循环替换,因此不考虑索引。在 Haskell 中,这通常也有助于使算法更加惰性。

如果您确实需要随机访问查找,您可以使用arrayvector packages 中定义的数据结构。

【讨论】:

    【解决方案3】:

    这不是 Haskell 的答案,但您标记了问题 lisp,所以这里是 Racket 中的答案:这既完全实用,也表明您不需要索引来解决这个问题。

    这个函数的作用是获取一个数字流并返回它的前缀流。这都是偷懒做的。

    (define (prefix-stream s)
      (let ps-loop ([st s]
                    [p 0])
        (if (stream-empty? st)
            empty-stream
            (let ([np (+ p (stream-first st))])
              (stream-cons 
                  np 
                  (ps-loop 
                      (stream-rest st)
                      np))))))
    

    现在

    > (stream->list (prefix-stream (in-range 1 10)))
    '(1 3 6 10 15 21 28 36 45)
    

    当然你也可以这样做:

    > (prefix-stream (in-naturals))
    #<stream>
    

    这显然不是可以转换为列表的流,但可以查看其中的一部分:

    (stream->list (stream-take (prefix-stream (in-naturals)) 10))
    '(0 1 3 6 10 15 21 28 36 45)
    > (stream->list (stream-take (stream-tail (prefix-stream (in-naturals)) 1000) 10))
    '(500500 501501 502503 503506 504510 505515 506521 507528 508536 509545)
    

    (注意in-naturals 认为自然是从 0 开始的,这是正确的。)

    【讨论】:

      【解决方案4】:

      在 Clojure 中,这可以写成:

      (defn prefix-list [nums]
        (loop [i 1
               prefix nums]
          (if (= i (count nums))
            prefix
            (recur (inc i) (assoc prefix i (+ (get prefix i) (get prefix (dec i))))))))
      

      (prefix-list [1 2 3 4 5 6 7 8 9]) 返回[1 3 6 10 15 21 28 36 45]

      在 Clojure 中,数据通常是不可变的(无法修改)。在这种情况下,函数 assoc 接受一个向量,并返回一个与原始向量一样的新向量,但 ith 元素已更改。这听起来可能效率低下,但底层数据结构允许在接近恒定的时间内完成更新 (O(log32(n)))。

      正如其他人指出的那样,可以在不使用索引向量的情况下对这个特定问题进行编码,但我正在努力提供一种解决方案,该解决方案在使用索引数组时对您的原始 Java 代码是正确的。

      【讨论】:

        【解决方案5】:

        我知道你问过函数式语言,但我只是想顺便提一下,Python 作为一种多范式语言,也有它作为很好的高阶 itertools.accumulate 函数。

        Accumulate 接受一个集合并返回其部分和的迭代器,或任何自定义二进制函数。与您的示例等效的功能 Python 代码将是:

        from itertools import accumulate
        print(list(accumulate(range(1, 10))))
        

        一般来说,Python itertoolsfunctools 标准库模块为函数式编程提供了出色的工具。

        【讨论】:

        【解决方案6】:

        其他人指出,您的特定示例可以使用 scanl 之类的函数很好地处理,因此让我们看看“严重依赖循环/执行顺序的索引繁重的操作”这个更广泛的问题。我们可以将问题分解为三个问题:

        1. 索引
        2. 循环
        3. 突变

        只要索引的概念有意义,就支持对数据结构进行索引。例如,ListVector 都支持索引。正如其他人所指出的,如果您的索引是随机的,Vector 的性能会更好,但这种情况非常罕见。

        命令式循环可以直接用递归函数替换(参见Wikipedia),尽管在 Prelude 和标准库中实现了如此多的“高阶函数”(将函数作为参数的函数),因此您很少需要显式递归. scanl 就是一个例子。它允许您通过将其分配给预先编写的函数来避免显式递归调用。然而,该函数是defined recursively。编译器在生成机器代码时可能会将该递归优化为循环。

        最后,您可能有一个数字数组,并且真的非常想通过一系列步骤改变数组中的值。这个线程中的每个人,包括我,都会非常努力地说服你摆脱这种情况,让你以更“实用”的方式思考。但是如果我们失败了,那么您可以使用state thread monad。这为您提供了一种方法,可以在函数内本地(可怕)改变数据结构,同时仅使用不可变(不可怕)数据结构与函数外部的事物进行交互,从而确保函数是 referentially transparent

        【讨论】:

          【解决方案7】:

          此功能也称为“累积和”,另一种 Python 方法是使用 numpy(用 C 编写,速度超快):

          nums = np.array([1, 2, 3, 4, 5, 6, 7, 8, 9]) # or np.arange(1, 10) or np.arange(9)+1
          print(nums.cumsum())
          

          输出:

          [1, 3, 6, 10, 15, 21, 28, 36, 45]
          

          【讨论】:

            【解决方案8】:

            Elixir 中有内置函数,类似于 Haskell 的:Enum.scan/2Enum.scan/3

            Enum.scan(1..9, 0, fn element, acc -> element + acc end) |> IO.inspect()
            # yields:
            # [1, 3, 6, 10, 15, 21, 28, 36, 45]
            

            当您第一次从 OO 切换到函数式思维方式时,索引可能很难放手,但我发现几乎所有我以前会寻求索引解决方案的问题(例如 for next loop) 在mapreduce 函数或某种形式的尾递归中有一个优雅的对应物。

            例如,您可以使用尾递归和手动累加器构建自己的函数来处理此操作:

            defmodule Foo do
              def bar(nums, acc \\ [])
            
              # We've reached the end of input: return the accumulator (reversed)
              def bar([], acc), do: Enum.reverse(acc)
            
              # The very first call is special because we do not have a previous value
              def bar([x | tail], []) do
                bar(tail, [x])
              end
            
              # All other calls land here
              def bar([x | tail], [prev | _] = acc) do
                bar(tail, [x + prev | acc])
              end
            end
            
            
            nums =   [1, 2, 3, 4,  5,  6,  7,  8,  9]
            prefix = Foo.bar(nums)
            IO.inspect(prefix)
            
            # Yields the same result:
            # [1, 3, 6, 10, 15, 21, 28, 36, 45]
            

            【讨论】:

              猜你喜欢
              • 1970-01-01
              • 2020-05-12
              • 1970-01-01
              • 1970-01-01
              • 1970-01-01
              • 1970-01-01
              • 2019-03-27
              • 2010-10-30
              • 2013-03-27
              相关资源
              最近更新 更多