递归的威力,以project euler 31为例

2014-04-27 by 煎挠橙

最近刚开始算法学习,对递归思想非常着迷,做project euler时遇到题目就想用递归处理一下,但总的来说吃力不讨好。倒是今天给我找到一个绝佳的例子,相比于暴力破解,用递归重写后的代码轻巧了许多,可读性也增强了。

题目

project euler 31题:

In England the currency is made up of pound, £, and pence, p, and there are eight coins in general circulation:

1p, 2p, 5p, 10p, 20p, 50p, £1 (100p) and £2 (200p).

It is possible to make £2 in the following way: 1×£1 + 1×50p + 2×20p + 1×5p + 1×2p + 3×1p

How many different ways can £2 be made using any number of coins?

暴力破解1

看到题目最自然的想法是穷举一下所有的组合,检测和是不是200。

这是非常原始的暴力破解方法,好处是不需要进一步的思考,非常容易写,且保证能得到正确结果。这几点好处——尤其是保证能得到正确结果是后面优化的基石——使得这种粗陋的代码也有写出来的价值。

当然,缺点是效率不理想,一般不会作为最终版本来使用。这里还好,只用了三四分钟,有时则可能要几个月,那就不得不立刻开始优化了。

代码:

def coin():
    counter = 0
    for i100 in range(3):
        item100 = 100 * i100
        print "100:", i100
        for i50 in range(5):
            item50 = 50 * i50
            print "50:", i50
            for i20 in range(11):
                item20 = 20 * i20
                for i10 in range(21):
                    item10 = 10 * i10
                    for i5 in range(41):
                        item5 = 5 * i5
                        for i2 in range(101):
                            item2 = 2 * i2
                            for i in range(201):
                                if item100 + item50 + item20 + item10 + item5 + item2 + i == 200:
                                    counter += 1
    return counter

上面的代码没有考虑只取200分的情况,所以得到的结果加上1才是正确答案。

暴力破解2

project euler前面的题目都比较简单,根据经验,算法得当的话运算时间都可以轻松压在毫秒级,上面的代码算了几分钟是不可忍受的。

问题显然在于计算了太多本不需要的计算的可能,所谓优化,不过是不需要算的部分直接不算了,具体到这个问题上,上面是先列举可能,再测试是否符合要求,其实可以反过来处理,你不是要200吗,那就从里面往外减,什么时候减没了算完。

所以代码可以写成:

def coin2():
    counter = 0
    item200 = 200
    while item200 >= 0:
        item100 = item200
        while item100 >= 0:
            item50 = item100
            while item50 >= 0:
                item20 = item50
                while item20 >= 0:
                    item10 = item20
                    while item10 >= 0:
                        item5 = item10
                        while item5 >= 0:
                            item2 = item5
                            while item2 >= 0:
                                counter += 1
                                item2 -= 2
                            item5 -= 5
                        item10 -= 10
                    item20 -= 20
                item50 -= 50
            item100 -= 100
        item200 -= 200
    return counter

好消息是效率提升不少,现在只需要16ms就可以搞定。

但依旧是8曾循环嵌套,烧脑子可读性差容易出错就不说了,这里问题简单些只要8层就能解决问题,可如果遇到更多的呢?你能写出来解释器也吃不消吧。

递归

这种问题很适合用递归来处理,先不多说,看代码:

def coin3(l=[200, 100, 50, 20, 10, 5, 2, 1], target=200):
    counter = 0
    if len(l) == 1:
        return 1
    while target >= 0:
        counter += coin3(l[1:], target)
        target -= l[0]
    return counter

刚写出来的时候我也不相信,之前繁琐的代码竟然可以变的如此漂亮,但算出来的答案是一样的,不骗你。唯一的缺点是用递归慢一些。

那这是怎么正常工作的?

设想我们有一个一般化的处理方案,函数coin(l, target),吃进去一个包含了所有可取的硬币额度和一个最终需要的总数,吐出来的就是所有可能的组合方案,没错这就是我们要的。

但这个函数怎么写呢?下面一步很关键。

主要用到了分而治之的战略思想。先考虑一个简单的例子,coin([5, 2, 1], 10),很容易发现有10种可能,问题在于使用了怎样的解题思路。

一个保证不漏掉可能的思路是先考虑到底有取了多少个5分,显然只能是0或1或2,如果是0的话,问题被简化为求coin([2, 1], 10),同理,如果是1的话问题简化为求coin([2, 1], 5);最后只需要把几种情况下的可能加起来就是最终结果了。

这就是递归处理的思路,每次只处理一个候选项,并将简化了的问题通过自己调用自己的方式解决,一路下来正确答案就自己跳出来了。

唯一需要留心的是这种递归调用终结在什么地方,稍微思考一下就会发现,是整个问题最基础最简单的情形,这样解决起来也不费什么功夫了。

再看一眼

其实递归的思路和第二版的暴力破解是一样的,但得益于函数可以自我调用这一特点,代码被大大简化了。

就好比没有循环之前你想求1到100的求和只能老老实实一步一步去写,用上循环则只需要三行。

用递归的好处在于,问题被一般化了,8曾循环是这样写,100层还是这样写(只要不爆栈的话...)。代码思路变得更加清晰,可读性更好,出错的机会就小了。

但递归这种思想和人脑的工作方式有悖,所以理解也好、使用也罢并不是那么自然,需要训练。

不管如何这都是一种强大的处理问题的思路,值得好好斟酌一番。

一点补充

上面的代码其实还取了一个巧,因为最后一个选项是1,所以不管前面怎么分配,到最后一步时总能找到一个符合要求的结果,所以索性不算了。如果要处理更一般的情况,则还需要一些改动。


Comments