在上一篇文章中,我主要表达了这样一个观点:影响程序效率的关键之一是算法,而算法的选择与优化,和是否多一个赋值少一个判断的关系不大。关于算法的选择,我谈到其理论上的复杂度,并不直接反映出效率。因为在实际运用时,数据的规模,特征等等都会涉及到算法的实际效果。一个时间复杂度低的算法并不代表任何情况下的效率都高。这是“实际”和“理论”的区别之一。现在我打算来谈一下另一个比较“实际”的东西:编译器对于程序效率的影响。
那么我们先来看这样一段代码,假设有一个保存着整数的单向链表,要求您写一个函数进行求和,您会怎么写呢?如果我们用F#,那么最容易实现的自然是递归方式:
let rec sum ls =
match ls with
| [] -> 0
| x :: xs -> x + (sum xs)
这段F#代码使用了模式匹配:如果是一个空链表,那么其结果自然等于零,否则就把它第一个元素加上剩余元素之和。这段代码显然非常简单,通过声明式的方法“表达”了函数的逻辑,没有任何一句多余的代码。不过您一定发现了,这个函数的实现中使用了递归,因此对于长度为n的链表来说,这个函数的时间复杂度为O(n),空间复杂度也是O(n)——空间的开销在函数的调用栈上。一般来说,递归总是难逃调用栈的累积,因此它的空间复杂度总是难以做到O(1)的常数级别。调用栈的堆积,使得程序在执行访问的内存地址跨度不断增大,容易导致程序的局部性(Locality)不佳,性能较差——关于代码局部性的影响,我们下篇文章中再进行讨论。
当然,有些朋友可能会说,为什么要用递归呢?用普通的一个for或while循环,然后不断累加不也可以吗?当然可以,而且这么做的话空间复杂度便是O(1)了,时间复杂度虽然还是O(n),但是经过上面的描述,我们可以知道它的实际执行效率会比递归的方式要好。不过,for/while都是命令式编程的方式,不适合函数式编程的“声明”风格,因此在实际应用中我们往往会写这样的sum函数:
let sum ls =
let rec sum' ls acc =
match ls with
| [] -> acc
| x :: xs -> sum' xs (acc + x)
sum' ls 0
这个sum函数中定义了一个辅助函数sum',这个辅助函数会多一个参数作为“累加器”,其中依旧使用了模式匹配的语法,在遇到空数组时直接返回累加器的值,否则就把链表的第一个元素加至累加器中,再递归调用sum'辅助函数。没错,这里还是用了递归。这样看起来,它的执行效果应该和前一种实现没有多大差别?
那么实际结果又是如何呢?如果您有条件不妨可以自己尝试一下——我这里贴一下由.NET Reflector反编译为C#后的sum'函数代码:
[Serializable]
internal class sum'@9 : OptimizedClosures.FSharpFunc<FSharpList<int>, int, int>
{
public override int Invoke(FSharpList<int> ls, int acc)
{
while (true)
{
FSharpList<int> list = ls;
if (!(list is FSharpList<int>._Cons))
{
return acc;
}
FSharpList<int>._Cons cons = (FSharpList<int>._Cons)list;
FSharpList<int> xs = cons.get_Tail();
int x = cons.get_Head();
acc += x;
ls = xs;
}
}
}