0 - 算法复杂度分析(涉及到很多递归的例子)


前言

今天开始就要开始数据结构与算法的笔记书写了。本系列博客一切参考均来自github上的hello算法和部分力扣上的算法题。大家也可以参考学习。下面我们就从算法复杂度的学习开始。

算法复杂度的概念引出源于一个问题,解决同一个问题的两个程序,如何判断这两个程序的优劣,以便我们选择更加高效的那个程序。也就是说,在能够解决问题的前提下,算法效率已成为衡量算法优劣的主要评价指标,它包括以下两个维度。

  • 时间效率:算法运行时间的长短。
  • 空间效率:算法占用内存空间的大小。
    从这两个角度分析也就引出了两个衡量算法好坏的指标:
  • 时间复杂度
  • 空间复杂度

另外,在进行算法复杂度的分析前我们还需要先了解一下算法中两种重要的编程思想:迭代与递归。在了解了递归后我们再对具体的迭代和递归程序进行复杂度分析。

一、迭代与递归

1 迭代

迭代(iteration)是一种重复执行某个任务的控制结构。在迭代中,程序会在满足一定的条件下重复执行某段代码,直到这个条件不再满足。

(1) for循环

for 循环是最常见的迭代形式之一,适合在预先知道迭代次数时使用。

例1:用for循环求和: 1 + 2 + . . . + n 1+2+...+n 1+2+...+n

def for_loop(n: int) -> int:
    """for 循环"""
    res = 0
    # 循环求和 1, 2, ..., n-1, n
    for i in range(1, n + 1):
        res += i
    return res

(2) while循环

与 for 循环类似,while 循环也是一种实现迭代的方法。在 while 循环中,程序每轮都会先检查条件,如果条件为真,则继续执行,否则就结束循环。

例2:用while循环求和: 1 + 2 + . . . + n 1+2+...+n 1+2+...+n

def while_loop(n: int) -> int:
    """while 循环"""
    res = 0
    i = 1  # 初始化条件变量
    # 循环求和 1, 2, ..., n-1, n
    while i <= n:
        res += i
        i += 1  # 更新条件变量
    return res
def sum(n:int):
    res = 0
    i = 1
    while 1:
        res = res + i
        if i == n:
            break
        i = i + 1
    return res

print(sum(100))   # 5050

2 递归

递归的基本思想就是套娃:将问题分解为规模更小的子问题来解决、分治思想;所以对于各种套娃的数据结构,递归算法可以秒杀一切。
递归(recursion)是一种算法策略,通过函数调用自身来解决问题。它主要包含两个阶段。

  • 递:程序不断深入地调用自身,通常传入更小或更简化的参数,直到达到“终止条件”。
  • 归:触发“终止条件”后,程序从最深层的递归函数开始逐层返回,汇聚每一层的结果。
    而从实现的角度看,递归代码主要包含三个要素。

(1)终止条件:用于决定什么时候由“递”转“归”。
(2)递归调用:对应“递”,函数调用自身,通常输入更小或更简化的参数。
(3)返回结果:对应“归”,将当前递归层级的结果返回至上一层。

【注】:对于返回结果这个返回结果不一定一定就是return,也可以是做了什么操作(做了什么修改)。没有return返回值是完全可以的,例如文件读取中,递归读取文件存进字典,并没有返回值,只是每次做了一个存的操作。关于这一点是需要注意一下的。
【注】:写每一个递归程序前都要好好想想上面的三要素每一步要干什么。另外写递归前一定要有一个整体观念,写之前就要想好这个递归函数要干什么,不然连要干什么都不知道,更不用谈递归调用了。

其实,在我看来,递归可以说是数据结构与算法里面最难啃但也是最重要的算法思想,掌握了递归,你会发现你的编程升华了。后面解释各种数据结构,递归算法都会在里面有涉及,特别是树等各种套娃的数据结构。
下面让我们对递归有一个初步认识!下面就让我们从求和的递归例子开始。

例3 用递归思想计算 求和: 1 + 2 + . . . + n 1+2+...+n 1+2+...+n

def recur(n: int) -> int:
    """递归"""
    # 终止条件
    if n == 1:
        return 1
    # 递:递归调用
    res = recur(n - 1)
    # 归:返回结果
    return n + res

下面的递归示意图很好的展示了上面这个求和递归过程的过程:
求和函数的递归过程

(1)调用栈(栈帧空间)、递归深度

递归函数每次调用自身时,系统都会为新开启的函数分配内存,以存储局部变量、调用地址和其他信息等。这将导致两方面的结果。函数的上下文数据都存储在称为“栈帧空间”的内存区域中,直至函数返回后才会被释放。因此,递归通常比迭代更加耗费内存空间。

递归调用函数会产生额外的开销。因此递归通常比循环的时间效率更低。如下图所示,在触发终止条件前,同时存在 n n n个未返回的递归函数,递归深度 n n n
在这里插入图片描述
在实际中,编程语言允许的递归深度通常是有限的,过深的递归可能导致栈溢出错误。一般情况发,发生栈溢出的bug,大概率都是递归逻辑有问题,递归终止条件没有触发,但也是要注意到递归深度这个注意项。
【注】:关于栈帧空间存函数这个事情我得要解释一下,存函数这个事情本质上等价与存与这个函数相关的一切变量,相关的变量才是重点。

(2)尾递归

尾递归别看名字很奇怪,其实只是一种递归编程的一种技巧而已。这种编程技巧在部分语言中会大幅减少内存空间的消耗相比与普通递归。但比较遗憾的是Python没有这个特性,就算你代码按照尾递归技巧来写,还是和普通递归一样。(但这种递归编程技巧却是我们经常用到的)怎么理解?继续往下看就有解释了。

先介绍一下尾递归的编程序思想吧!----- 其实就是将我们要的结果当成一个参数传进函数,放进参数里面存着操作。
这么做有什么区别,区别就是部分之前需要再归里面做的操作,现在直接在递里面做了;好处就是,部分语言你在递里面做了普通归里面操作,这部分的栈帧空间涉及到的变量内存就能里面释放了,如果和归那样,这部分内存会保持到递的过程全部结束,归的时候才会释放。白白浪费大量空间资源。很遗憾的是,Python不支持这个,但是这种技巧在我们编程的时候非常有用,反正我写递归是很喜欢用。下面看例子演示:

例4:用尾递归 计算 求和: 1 + 2 + . . . + n 1+2+...+n 1+2+...+n

def t_dfs_sum(n:int,res:int):
    if n == 0:
        return res
    ## 两种都可以当做递归终止条件
    # if n == 1:
    #     return res+1
    
    # 尾递归调用
    return t_dfs_sum(n-1, res+n)

res = 0
print(t_dfs_sum(100, res))   # 5050

尾递归的执行过程如下图所示。对比普通递归和尾递归,两者的求和操作的执行点是不同的。

  • 普通递归:求和操作是在“归”的过程中执行的,每层返回后都要再执行一次求和操作。
  • 尾递归:求和操作是在“递”的过程中执行的,“归”的过程只需层层返回。
    在这里插入图片描述

尾递归之所以在部分语言里面能够节省内存相比与普通递归,用这个求和的例子来说,由于其求和的过程(图中画线部分相加的操作)是在递的过程中就完成了,那工作完成了,这部分栈帧空间里面存的变量就能释放掉,归只要直接返回结果就行了(没有工作了)。而普通递归所有信息都要保存下来,在归里面才能找到进行操作(归里面还要进行求和操作当然你不能提取释放内存了)。

其实也很奇怪,以前学递归这一块,根本不理解这些,真的多反复学几遍,很多第一遍看不懂的就能顿悟了。

(3)递归树

当处理与“分治”相关的算法问题时,递归往往比迭代的思路更加直观、代码更加易读。以“斐波那契数列”为例。
在这里插入图片描述

设斐波那契数列的第 n n n个数字为 f ( n ) f(n) f(n) ,易得两个结论。

  • 数列的前两个数字为 f ( 1 ) = 0 ; f ( 2 ) = 1 f(1)=0;f(2)=1 f(1)=0;f(2)=1
  • 数列中的每个数字是前两个数字的和,即 f ( n ) = f ( n − 1 ) + f ( n − 2 ) f(n)=f(n-1)+f(n-2) f(n)=f(n1)+f(n2)

下面开始根据上面规律编写递归函数计算斐波那契数列 f i b ( n ) fib(n) fib(n).

例5:根据上面规律编写递归函数计算斐波那契数列 f i b ( n ) fib(n) fib(n).

def dfs_fib(n:int):
    if n == 1:
        return 0
    if n == 2:
        return 1
    
    return dfs_fib(n-1) + dfs_fib(n-2)
 
print(dfs_fib(3))    # 1
print(dfs_fib(4))    # 2
print(dfs_fib(5))    # 3
print(dfs_fib(6))    # 5
print(dfs_fib(7))    # 8
print(dfs_fib(8))    # 13

观察以上代码,我们在函数内递归调用了两个函数,这意味着从一个调用产生了两个调用分支。如下图所示,这样不断递归调用下去,最终将产生一棵层数为 n n n的递归树(recursion tree)。
在这里插入图片描述
从本质上看,递归体现了“将问题分解为更小子问题”的思维范式,这种分治策略至关重要。

  • 从算法角度看,搜索、排序、回溯、分治、动态规划等许多重要算法策略直接或间接地应用了这种思维方式。
  • 从数据结构角度看,递归天然适合处理链表、树和图的相关问题,因为它们非常适合用分治思想进行分析。

(4)用栈模拟递归的过程

实际上递归满足先进后出,后进先出的规律,本质上递归也是用栈实现的。可以说,一切可以用递归思路编写的程序都可以改成用栈来实现。因此,我们可以使用一个显式的栈来模拟调用栈的行为,从而将递归转化为迭代形式

例6.用栈模拟递归计算 1 + 2 + 3 + . . . + n 1+2+3+...+n 1+2+3+...+n

def stack_m_sum(n:int):
    """使用迭代模拟递归"""
    # 使用一个显式的栈来模拟系统调用栈
    res = 0
    stack = []
    # 递:递归调用
    for i in range(n,0,-1):
        # 通过“入栈操作”模拟“递”
        stack.append(i)
    # 归:返回结果
    while stack:
        # 通过“出栈操作”模拟“归”
        cur = stack.pop()
        res += cur
    # res = 1+2+3+...+n
    return res

print(stack_m_sum(100<
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值