纯函数式语言如何处理基于索引的算法?

Aar*_*onC 51 lisp haskell functional-programming

我一直在尝试学习函数式编程,但我仍然很难像函数式程序员一样思考。其中一个难题是如何实现强烈依赖于循环/执行顺序的索引密集型操作。

例如,考虑以下 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]
*/
Run Code Online (Sandbox Code Playgroud)

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

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

Sil*_*olo 59

不管你相信与否,该函数实际上是Haskell内置的。

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

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

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

scanl :: (b -> a -> b) -> b -> [a] -> [b]
scanl _ q [] = [q]
scanl f a (b:xs) = a : scanl f (f a b) xs
Run Code Online (Sandbox Code Playgroud)

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

  • @leftaroundabout 我不同意。我想说,人们使用指数所做的 99.5% 转换为非指数形式都是微不足道的。最后的0.5%确实很有趣,值得关注。从我的经验和我观察到的代码来看,我们很少用索引做真正有趣的事情。大多数时候,这只是无聊地按顺序循环数据。它们太无聊了,以至于我们甚至没有注意到我们在做它们。然而,你是对的,最后 0.5% 通常需要从根本上改变观点。 (20认同)
  • 99% 的情况下,答案是“停止考虑指数”,这是不现实的。人们在 C / Python / Matlab 等中使用数组做的许多事情(例如用于模拟或数据科学)都不能用 Haskell 列表操作来表达。我仍然完全同意应该克服对带有索引的数组的痴迷,但这通常需要更根本的思维改变。http://www.cs.ox.ac.uk/seminars/2418.html (14认同)
  • @CortAmmon 我的理解是 FP 是关于减少状态以增加并行性并降低复杂性。密码算法被设计为难以并行化,并且状态通常是设计的一个组成部分。所以使用 FP 将是一个方钉...... (2认同)
  • @Aron True,使用 FP 来解决该问题可能是一种反模式。但是编写一个调用 C 函数来执行一些加密操作的 FP 程序是很常见的,我认为这就是 Cort 在他们的最后一句话中所建议的。 (2认同)

Wil*_*sem 25

这不是一个索引繁重的操作,事实上您可以使用以下命令来完成此操作scanl1 :: (a -> a -> a) -> [a] -> [a]

prefixList = scanl1 (+)
Run Code Online (Sandbox Code Playgroud)

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

Prelude> prefixList [1 .. 9]
[1,3,6,10,15,21,28,36,45]
Run Code Online (Sandbox Code Playgroud)

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

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

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


ign*_*ens 14

这不是 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))))))
Run Code Online (Sandbox Code Playgroud)

所以现在

> (stream->list (prefix-stream (in-range 1 10)))
'(1 3 6 10 15 21 28 36 45)
Run Code Online (Sandbox Code Playgroud)

但当然你也可以这样做:

> (prefix-stream (in-naturals))
#<stream>
Run Code Online (Sandbox Code Playgroud)

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

(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)
Run Code Online (Sandbox Code Playgroud)

(请注意,in-naturals考虑自然数从 0 开始,这是正确且正确的。)


van*_*nyo 6

在 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))))))))
Run Code Online (Sandbox Code Playgroud)

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

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

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


yon*_*avi 5

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

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

from itertools import accumulate
print(list(accumulate(range(1, 10))))
Run Code Online (Sandbox Code Playgroud)

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

  • 该名称与 C++ 的 [`std::accumulate`](https://en.cppreference.com/w/cpp/algorithm/accumulate) 共享,具有相同的目的。 (2认同)