上一篇文章我介绍了尾调用及抽象递归,今天将向大家介绍函数的记忆化处理。
斐波那契数列的几种算法
在介绍记忆化之前,我们先来看一个函数,计算斐波那契数列:
public static int fibo(int num) {
if (num == 0 || num == 1)
return num;
else {
return fibo(num - 1) + fibo(num - 2);
}
}
现在让我们来测试一下:
for (int i = 0; i < 10; i++) {
System.out.print(fibo(i)+" ");
}
结果为:0 1 1 2 3 5 8 13 21 34
是的,很正确,那现在让我们把n改为7000,在运行一下。你觉得会是什么样的结果呢?
事实上,这段程序永远不会停下来,它没有抛出任何关于栈溢出的异常!这可能与你猜想的不同,这是因为这个函数每次被调用都会创建两个函数,计算fibo(n),需要调用2n次,我们知道,斐波那契数列递归的时间复杂度是(2^n),我们计算fibo(7000)需要大学2^7000单位个时间,不需要在意这个单位时间是多少了,它已经没意义了,我们所需要的时间比地球的生命还长。
我们现在尝试把这个函数改为尾递归的形式,因为每次产生两个递归,所以这里需要两个累加器:
private static BigInteger fibo0(BigInteger acc0, BigInteger acc1, BigInteger x) {
if (x.equals(BigInteger.ZERO)) {
return acc0;
} else if (x.equals(BigInteger.ONE)) {
return acc0.add(acc1);
} else {
return fibo0(acc1, acc0.add(acc1), x.subtract(BigInteger.ONE));
}
}
public static BigInteger fibo(int x) {
return fibo0(BigInteger.ZERO, BigInteger.ONE, BigInteger.valueOf(x));
}
现在函数每次递归调用就只会产生一个函数了,我们再次计算后发现结果足足有1463位之多。
遗憾的是,在我的电脑上,当N取值超过一万时,仍然会抛出栈溢出的异常。我们继续改进,使用上一章介绍的TailCall类优化:
private static TailCall fibo0(BigInteger acc0, BigInteger acc1, BigInteger x) {
if (x.equals(BigInteger.ZERO)) {
return ret(acc0);
} else if (x.equals(BigInteger.ONE)) {
return ret(acc0.add(acc1));
} else {
return sus(()->fibo0(acc1, acc0.add(acc1), x.subtract(BigInteger.ONE)));
}
}
public static BigInteger fibo(int x) {
return fibo0(BigInteger.ZERO, BigInteger.ONE, BigInteger.valueOf(x)).eval();
}
现在那怕n=20000,程序也能计算出接过来(当n为20000时,长度有4180位)。
记忆化
但上述代码存在一个明显的问题,如果要计算 f(n),就要计算f(n-1)、f(n-2),为了计算f(n-),就要计算f(n-2),f(n-3),但f(n-2)上一步我已经计算出来了,这里还要重复计算(实际上你要重复计算n次f(1),n-1次f(2)……)。你在不断的重复求值,请问还有更好的办法吗?
实际上,这个解决方法你一直再用:
public static int fibo(int n) {
if (n <= 2)
return 1;
else {
int num1 = 1;
int num2 = 1;
for (int i = 2; i < n - 1; i++) {
num2 = num1 + num2;
num1 = num2 - num1;
//这两行代码表示存储值,以便下一次使用。
}
return num1 + num2;
}
}
我们将这种将计算结果保存在内存中的技术称为记忆化!目前来说,函数式编程语言普遍都支持记忆特性当以后你重复计算时,它能立即返回结果。
假设我们有一个反复调用的函数,需要挖掘它的性能潜力。增加一个内部缓存是很容易想到的方案。每次我们根据一组特定参数求得结果之后,就用参数值做查找用的键,把结果缓存起来。以后当函数又遇到相同参数的时候,就不需要重新计算一遍了,可以直接返回缓存的结果。用更多的内存(我们现在可以说不缺内存了)去换取长期来说更高的效率(以空间换取时间)。
这是一个给定参数值,返回它2倍值的函数
public static Integer doubleValue(Integer i) {
return i << 1;//计算i*2
}
我们现在引入缓存技术:
public static Map cache = new ConcurrentHashMap<>();
public static Integer doubleValue(Integer i) {
if (cache.containsKey(i)) {
return cache.get(i);
} else {
Integer result = i << 1;
cache.put(i, result);
return result;
}
}
又或者,上面的函数可以这样写:
public static Integer doubleValue(Integer i) {
return cache.computeIfAbsent(i, x -> x << 1);
}
更加函数式的可以这样写:
public static Function doubleValue = i->cache.computeIfAbsent(i, x->x<<1);
不过,这种方法实现记忆化,你需要对每个方法重复修改。现在,我们来看个Groovy语言的例子:
def static isFactory = {x,y->x%y ==0} //这个函数判断y是否是x的因子。
现在,我们把它记忆化处理:
def static isFactoryByMemorize = isFactory.memorize();
好了,现在函数isFactoryByMemorize就是一个被记忆化了的函数,就这么简单。好在我们可以在Java上模拟出类似的功能。
public class Memoizer {
private final Map cache = new ConcurrentHashMap<>();
private Memoizer() {
}
//返回函数的记忆化版本,每个被记忆的函数都持有一个cache。
public static Function memoizer(Function f) {
return new Memoizer().doMemoize(f);
}
private Function doMemoize(Function f) {
return x -> cache.computeIfAbsent(x, f::apply);
}
}
测试
这是一个待测试的函数:
public static Function doubleValue = i -> {
try {
Thread.sleep(1000);//模拟耗时操作
} catch (InterruptedException e) {
e.printStackTrace();
}
return i << 1;
};
测试:
public static void main(String[] args) {
long start1 = System.currentTimeMillis();
Integer i1 = doubleValue.apply(4);
long end1 = System.currentTimeMillis() - start1;
long start2 = System.currentTimeMillis();
Integer i2 = doubleValue.apply(4);
long end2 = System.currentTimeMillis()-start2;
System.out.println(i1+"\t"+end1);
System.out.println(i2+"\t"+end2);
}
运行结果:
可以看出两次运行的时间一样长(具体时间跟你的计算机性能有关)。
现在,把这个函数记忆化:
public static Function doubleValueByMemorize = Memoizer.memoize(doubleValue);
测试:
public static void main(String[] args) {
long start1 = System.currentTimeMillis();
Integer i1 = doubleValueByMemorize.apply(4);
long end1 = System.currentTimeMillis() - start1;
long start2 = System.currentTimeMillis();
Integer i2 = doubleValueByMemorize.apply(4);
long end2 = System.currentTimeMillis()-start2;
System.out.println(i1+"\t"+end1);
System.out.println(i2+"\t"+end2);
}
运算结果:
可以看出,第二次计算所用的时间为0。
多参函数记忆化处理
不过上述的自动记忆化只会操作含有一个参数的函数,但如果你要操作的函数有两个、三个……参数呢?实际上,这不是什么问题,所有的参数都可以看成一个元组对待(Java不支持元组类型,你只能自己去定义),又或者可以使用柯里化技术:
例如有一个含有两个参数的函数:
public static Function> f2 = x->y->x+y;
对它的记忆化也一样:
public static Function> f2memorize = Memoizer
.memoize(x -> Memoizer.memoize(y -> x + y));
我们在这里对每一个柯里化产生的函数都做记忆化处理。三个参数、四个参数的也是同样的处理。
只有纯函数才可以适用缓存技术。一个函数经过记忆化后仍然是纯函数,虽然它保存了状态,但它总会为相同的参数返回相同的值。只有在函数对同样一组参数总是返回相同结果的前提下,我们才可以放心地使用缓存起来的结果。
下一篇,如何在Java里编写更加函数式的数据结构。