(三)算法基础:递归

一.简介

        递归(英语:Recursion),在数学和计算机科学中是指在函数的定义中使用函数自身的方法,在计算机科学中还额外指一种通过重复将问题分解为同类的子问题而解决问题的方法。

        递归的基本思想是某个函数直接或者间接地调用自身,这样原问题的求解就转换为了许多性质相同但是规模更小的子问题。求解时只需要关注如何把原问题划分成符合条件的子问题,而不需要过分关注这个子问题是如何被解决的。当这个子问题被分解到一定程度时,往往无需求解可以直接获得答案,这便是递归的边界,也就是最简子问题的解,有了最简子问题的解,递归才可以一层层求解出原问题解。

        举个例子比如问题  f(n)  是我们要计算2^{_{n}},则这个问题可以递归为:  f(n) = 2 * f(n-1),即2^{n}=2*2^{n-1}    原问题被分解为子问题: f(n-1),如果我们求出来子问题,原问题就也求出来了。子问题只是规模更加小,但和原问题是一类问题,所以子问题的求解方法和原问题一致,递归这个解法即可,即 f(n-1) = 2 * f(n-2).一直递归下去,我们不知道 f(100),也就是2^{100}等于多少,但是递归到f(0), 我们知道 f(0) = 2^{0}=1,这就是递归的边界,这使得递归不会无限下去,当遇到边界后会停止。

        回顾这个递归求解过程,我们会发现我们并不需要在意原问题如果求解,我们没有直接求解问题2^{_{n}},我们只需要知道已知子问题的解,也就是已知了2^{n-1}等于多少时,2^{_{n}}等于多少这个问题?这时候问题就很简单了。而子问题递归到一定程度就不需要复杂求解了。

        当然,这个例子的问题非常简单,原问题和子问题直接可能并不会如此简单求解,但体现的是一样的递归思想。递归往往结构清晰,可读性强。但是递归由于不断调用自身,会一定程度降低效率。此外递归也可能会出现其它情况的原因导致低效。

二.要点

分解子问题
        使用递归,首先最基本的条件要找到正确合理的分解方法来把问题分解,不能分解为同类的子问题则无法递归。好的分解会使得算法复杂度更低,算法更加简洁。

利用子问题求解原问题
        首先必须声明,问题可以分解不等于可以递归求解,因为有些问题并不能用子问题的解来求解原问题,有时候找到利用子问题的解求解原问题的方法也会有难度,需要一定经验积累。

确定递归边界
        给递归设定一个边界,就是找到子问题小到一定程度后的解。一是解已知或者易知,二是要确保边界足够小,足够满足题目要求范围。

三.例题

3.1.斐波那契数列

斐波那契数 (通常用 F(n) 表示)形成的序列称为 斐波那契数列 。该数列由 0 和 1 开始,后面的每一项数字都是前面两项数字的和。也就是:

F(0) = 0,F(1) = 1
F(n) = F(n - 1) + F(n - 2),其中 n > 1

给定 n ,请计算 F(n) 。

示例 1:

输入:n = 2
输出:1
解释:F(2) = F(1) + F(0) = 1 + 0 = 1

示例 2:

输入:n = 3
输出:2
解释:F(3) = F(2) + F(1) = 1 + 1 = 2

示例 3:

输入:n = 4
输出:3
解释:F(4) = F(3) + F(2) = 2 + 1 = 3

提示:

  • 0 <= n <= 30

        分解子问题,利用子问题对原问题求解的方法题目已经给出:F(n) = F(n - 1) + F(n - 2),其中 n > 1。即可直接递归求解,递归边界为F(0) = 0,F(1) = 1。下面是求解代码:

n = int(input())

# 递归函数
def rec(n):
    if n == 0:
        return 0
    elif n == 1:
        return 1
    return rec(n-1) + rec(n-2)

print('ans:', rec(n))

        对于这段递归求解的代码,我们发现程序会对某些值重复计算,比如F(5)=F(4)+F(3)=F(3)+F(3)+F(2)。显然我们的程序会计算两次F(3),n越大,而且越分解,会出现越多的重复计算,这会使得效率大幅下降。对于这种问题最常见的优化方法是记忆已经计算的结果,避免重复计算,如下面代码。使用一个字典记录已经计算的,避免重复计算。

优化后:

n = int(input())

dic = {0: 0, 1: 1}  # 字典记忆结果

# 递归函数
def rec(n):
    if n in dic:  # 查询字典
        return dic[n]
    dic[n] = rec(n-1) + rec(n-2)  # 不存在于字典就会计算然后更新字典
    return dic[n]


print('ans:', rec(n))

        对于没有记忆的部分,遇到的第一次就会计算然后保存在字典,就不会计算第二次甚至更多,避免了重复计算。

3.2.统计好数字的数目

我们称一个数字字符串是 好数字 当它满足(下标从 0 开始)偶数 下标处的数字为 偶数 且 奇数 下标处的数字为 质数 (235 或 7)。

  • 比方说,"2582" 是好数字,因为偶数下标处的数字(2 和 8)是偶数且奇数下标处的数字(5 和 2)为质数。但 "3245" 不是 好数字,因为 3 在偶数下标处但不是偶数。

给你一个整数 n ,请你返回长度为 n 且为好数字的数字字符串 总数 。由于答案可能会很大,请你将它对 10e9 + 7 取余后返回 。

一个 数字字符串 是每一位都由 0 到 9 组成的字符串,且可能包含前导 0 。

示例 1:

输入:n = 1
输出:5
解释:长度为 1 的好数字包括 "0","2","4","6","8" 。

示例 2:

输入:n = 4
输出:400

示例 3:

输入:n = 50
输出:564908303

提示:

  • 1 <= n <= 1015

要求:

时间复杂度不超过O(log n)

        这道题目不难得出偶数下标的数位提供5种情况,奇数4种,把所有位数的情况相乘,然后对上限取模就得到了答案。由于下标从0开始,所以偶数下标有(n+1) // 2种,奇数下标有n // 2种。所以答案就是(5^{(n+1)//2} * 4^{n // 2}) \% (10^{9}+7)。但是,这道题的关键在于要求时间复杂度不超过O(log n),而直接用幂运算2**n实现2^{n}相当于n个2相乘,复杂度O(n)不满足要求,必然会超时。所以这道题核心在与实现快速计算幂。

        这里我们使用递归来快速计算幂,由公式2^{n}*2^{m}=2^{m+n}可得,我们可以通过将一个n次幂拆分为两个n//2次幂乘积,但是如果n为奇数,则无法拆分为整数,我们可以在原来基础上在乘2,即2^{7}可以拆为2^{3}*2^{3}*2。以2^{8}为例我们可以拆为2^{4}*2^{4}。一直递归,当次数为0时为边界条件,即任何n^{0}=1

        这样我们可以得到以下递归过程:

(1) 2^{8} = 2^{4}*2^{4}

(2) 2^{4} = 2^{2}*2^{2}

(3) 2^{2} = 2^{1}*2^{1}

(4) 2^{1} = 2^{0}*2^{0}*2

(5) 2^{0} = 1

        可以明显看到计算次数变少了,当n越大,效率差异越大,这时候满足了题目要求复杂度,代码如下:

mod = 10 ** 9 +7
# 递归快速幂
def quick(a, b):
    if b == 0:
        return 1
    if b % 2 == 0:
        return (quick(a, b//2) ** 2) % mod
    else:
        return (quick(a, b//2) ** 2 * a) % mod
print('ans:', quick(5, (n+1)//2) * quick(4, n//2) % mod)

四.后言

        递归是非常非常重要的算法,毫不夸张地说是最重要的算法,体现的是一种计算机算法思维,在后续很多算法中,都会用到递归的思想。本篇文章只是入门的介绍了这种算法,例题也都不会有很高的难度,后续会更新一些有一定难度的题目,非常典型值得学习的一些题目在本栏目后续博客中。

你可能感兴趣的:(算法学习,python,算法,开发语言)