【问题标题】:Functional Programming Performance函数式编程性能
【发布时间】:2014-07-22 20:07:58
【问题描述】:

我最近在Codeforces 开始使用 Scala 解决一些编程挑战,以锻炼函数式编程技能。这样做我遇到了一个特殊的挑战,我无法以尊重给定的 1000 毫秒执行时间限制的方式解决; Painting Fence 问题。

我尝试了各种不同的方法,从直接递归解决方案开始,尝试使用流而不是列表的类似方法,并最终尝试通过更多地使用索引来减少列表操作。我最终在较大的测试中遇到了堆栈溢出异常,我可以使用 Scala 的TailCall. 修复这些异常。但是,尽管该解决方案正确地解决了问题,但在 1000 毫秒内完成太慢了。除此之外,还有一个 C++ 实现,相比之下,它的速度可笑

这是我的 scala 代码,您可以将其粘贴到 REPL 中,包括需要 >1000 毫秒的示例:

import scala.util.control.TailCalls._

def solve(l: List[(Int, Int)]): Int = {

  def go(from: Int, to: Int, prevHeight: Int): TailRec[Int] = {
    val max = to - from
    val currHeight = l.slice(from, to).minBy(_._1)._1
    val hStrokes = currHeight - prevHeight
    val splits = l.slice(from, to).filter(_._1 - currHeight == 0).map(_._2)
    val indices = from :: splits.flatMap(x => List(x, x+1)) ::: List(to)
    val subLists = indices.grouped(2).filter(xs => xs.last - xs.head > 0)

    val trampolines = subLists.map(xs => tailcall(go(xs.head, xs.last, currHeight)))
    val sumTrampolines = trampolines.foldLeft(done(hStrokes))((b, a) => b.flatMap(bVal =>
      a.map(aVal => aVal + bVal)))
    sumTrampolines.flatMap(v => done(max).map(m => Math.min(m, v)))
  }
  go(0, l.size, 0).result
}

val lst = (1 to 5000).toList.zipWithIndex
val res = solve(lst)

为了比较,这里有一个 C++ 示例,实现了 Bugman 编写的相同内容(包括一些我在上面的 Scala 版本中没有包含的来自控制台的读/写):

#include <iostream>
#include <cstdio>
#include <cstdlib>
#include <algorithm>
#include <vector>
#include <string>
#include <set>
#include <map>
#include <cmath>
#include <memory.h>
using namespace std;
typedef long long ll;

const int N = 1e6+6;
const int T = 1e6+6;

int a[N];
int t[T], d;

int rmq(int i, int j){
    int r = i;
    for(i+=d,j+=d; i<=j; ++i>>=1,--j>>=1){
        if(i&1) r=a[r]>a[t[i]]?t[i]:r;
        if(~j&1) r=a[r]>a[t[j]]?t[j]:r;
    }
    return r;
}

int calc(int l, int r, int h){
    if(l>r) return 0;

    int m = rmq(l,r);
    int mn = a[m];
    int res = min(r-l+1, calc(l,m-1,mn)+calc(m+1,r,mn)+mn-h);
    return res;
}

int main(){
    //freopen("input.txt","r",stdin);// freopen("output.txt","w",stdout);

    int n, m;

    scanf("%d",&n);
    for(int i=0;i<n;++i) scanf("%d",&a[i]);

    a[n] = 2e9;
    for(d=1;d<n;d<<=1);
    for(int i=0;i<n;++i) t[i+d]=i;
    for(int i=n+d;i<d+d;++i) t[i]=n;
    for(int i=d-1;i;--i) t[i]=a[t[i*2]]<a[t[i*2+1]]?t[i*2]:t[i*2+1];

    printf("%d\n",calc(0,n-1,0));

    return 0;
}

至少在我介绍显式尾调用之前,在我看来,更实用的风格比更命令式的解决方案更自然地解决问题。所以我真的很高兴能更多地了解在编写函数式代码时应该注意什么才能获得可接受的性能。

【问题讨论】:

  • 其中一个痛点是val indices计算——once I optimized this line alone,在我的机器上总时间从1.360s下降到0.672s。我相信如果您以更明智的方式使用集合,您将获得更好的结果。
  • @om-nom-nom:这在一般情况下是行不通的。

标签: scala scala-collections


【解决方案1】:

如此严重地依赖索引可能不是真正惯用的函数式风格,将索引和列表结合起来会导致性能不太理想。

这是一个无索引的实现:

import scala.util.control.TailCalls._

def solve(xs: Vector[Int]): Int = {
  def go(xs: Vector[Int], previous: Int): TailRec[Int] = {
    val min = xs.min

    splitOn(xs, min).foldLeft(done(min - previous)) {
      case (acc, part) => for {
        total <- acc
        cost  <- go(part, min)
      } yield total + cost
    }.map(math.min(xs.size, _))
  }

  go(xs, 0).result
}

不过,这还不是全部——我已将拆分部分分解为一个名为 splitOn 的方法,该方法采用序列和分隔符。因为这是一个非常简单和通用的操作,所以它是一个很好的优化候选者。以下是快速尝试:

def splitOn[A](xs: Vector[A], delim: A): Vector[Vector[A]] = {
  val builder = Vector.newBuilder[Vector[A]]
  var i = 0
  var start = 0

  while (i < xs.size) {
    if (xs(i) == delim) {
      if (i != start) {
        builder += xs.slice(start, i)
      }
      start = i + 1
    }
    i += 1
  }

  if (i != start) builder += xs.slice(start, i)

  builder.result
}

虽然这个实现是必要的,但从外部看,该方法是完美的——它没有副作用等。

这通常是提高函数式代码性能的好方法:我们将程序分成通用部分(在分隔符上拆分列表)和针对问题的逻辑。因为前者非常简单,所以我们可以要求将其视为(并对其进行测试)一个黑盒,同时保持我们用于解决问题的代码的简洁性和功能性。

在这种情况下,性能仍然不是很好——这个实现在我的机器上大约是你的两倍——但我认为在使用TailCalls 蹦床时你不会比这更好。

【讨论】:

  • 非常感谢@TravisBrown 的回答,解决了很多问题。然而,这让我想知道函数式编程是否是一种在编码挑战中竞争的好方法。虽然我确实看到了函数式编程在解决方案的组合能力方面的好处,但在我看来,如果简单的自然表达通常不会表现得足够好,那么经常听到的关于算法可以更自然地表达的说法只是半真半假。我认为我们得出的最终解决方案虽然非常好,但对于经验不足的 FP 程序员来说,不一定比 C 解决方案更容易理解。
  • 作为参考,我发现*.com/questions/13942106/… 讨论了类似的主题。
  • @Marc:问题的很大一部分在于,由于 Scala 语言和编译器的限制,我们必须在这里取消递归——这是一个巨大的性能损失,你可能会赢不要经常遇到这种情况。
  • @Marc:在编码挑战中,不同语言的解决方案的时间要求通常会有所不同。例如。 HackerRank 目前允许 C/C++ 解决方案 2 秒,Java 4 秒,Scala 7 秒,Ruby 10 秒,依此类推:hackerrank.com/environment 但是you 的时间是 produce i> 解决方案是相同的。我想说,如果你想,坚持你最有效率的语言/范式(这些可能因问题而异),但如果你想学习 ,坚持你正在学习的东西,可能会传递你不知道如何有效解决的问题(还)。