java子函数的_函数式编程(Java描述)——Java里函数的记忆化

上一篇文章我介绍了尾调用及抽象递归,今天将向大家介绍函数的记忆化处理。96bfa493d95839fbccd62407f1fec7c6.png

斐波那契数列的几种算法

在介绍记忆化之前,我们先来看一个函数,计算斐波那契数列:

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);

}

运行结果:

4c1641910ec70ad9b79a974864b8507a.png

可以看出两次运行的时间一样长(具体时间跟你的计算机性能有关)。

现在,把这个函数记忆化:

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);

}

运算结果:

f9b118e23a088568aafde76ddf5d99d6.png

可以看出,第二次计算所用的时间为0。

多参函数记忆化处理

不过上述的自动记忆化只会操作含有一个参数的函数,但如果你要操作的函数有两个、三个……参数呢?实际上,这不是什么问题,所有的参数都可以看成一个元组对待(Java不支持元组类型,你只能自己去定义),又或者可以使用柯里化技术:

例如有一个含有两个参数的函数:

public static Function> f2 = x->y->x+y;

对它的记忆化也一样:

public static Function> f2memorize = Memoizer

.memoize(x -> Memoizer.memoize(y -> x + y));

我们在这里对每一个柯里化产生的函数都做记忆化处理。三个参数、四个参数的也是同样的处理。

只有纯函数才可以适用缓存技术。一个函数经过记忆化后仍然是纯函数,虽然它保存了状态,但它总会为相同的参数返回相同的值。只有在函数对同样一组参数总是返回相同结果的前提下,我们才可以放心地使用缓存起来的结果。

下一篇,如何在Java里编写更加函数式的数据结构。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值