算法效率评估
在能够解决问题的前提下,算法效率已成为衡量算法优劣的主要评价指标。主要包括以下两个维度。
时间效率:算法运行速度的快慢(即算法运行时间的长短)。
空间效率:算法占用内存空间的大小。
目标是设计 “既快又省” 的数据结构与算法。
效率评估方法
1、实际测试
给定两种算法,在计算机上分别运行它们,记录时间和空间使用情况。
缺陷
- 难以排除测试环境的干扰因素。不同的测试环境可能会得到相反的结果。
- 展开完整测试非常消耗资源。完整测试需要在不同数据量下开展,非常浪费资源。
2、理论估算
复杂度分析(complexity analysis) 描述了随着输入数据大小的增加,算法执行所需时间和空间的增长趋势。 - “时间和空间资源”分别对应时间复杂度(time complexity) 和空间复杂度(space complexity)。
- “随着输入数据大小的增加”意味着复杂度反映了算法运行效率与输入数据体量之间的关系。
- “时间和空间的增长趋势”表示复杂度分析关注的不是运行时间或占用空间的具体值,而是时间或空间增长的快慢。
复杂度分析相对于实际测试的优点 - 仅需理论分析,绿色节能。
- 独立于运行环境,适用于各类平台。
- 能够体现不同数据量下的算法效率。
迭代
迭代(iteration):在满足特定条件下重复执行某段代码,直到该条件不再满足。
迭代实现方式
- for循环
for循环适合预先知道迭代次数时使用。例求解1到n之和:
def for_loop(n:int)->int:
""" for循环"""
res = 0
# 循环求和1,2,...,n-1,n
for i in range(1, n+1): # range(a,b)是左闭右开区间[a,b),故此处是n+1.
res += i
return res
# 输出
res_for = for_loop(5)
print("for_loop:", res_for)
# for_loop: 15
- while循环
while循环与for循环类似,在while循环中,程序每轮都会先检查条件,如果条件为真则继续执行,否则就结束循环。求和代码如下
def while_loop(n:int)->int:
""" while循环"""
res = 0
i = 1 # 初始化条件变量
while i <= n:
res += i # 累加
i += 1 # 迭代 更新条件变量
return res
# 输出
res_while = while_loop(5)
print("while_loop:", res_while)
# while_loop: 15
- 求和函数的流程框图如图
由于初始化和更新条件变量的步骤是独立在循环结构之外的,while比for循环的自由度更高(for循环更紧凑,while循环更灵活)。例如条件变量每轮进行了多次更新,这种情况while循环比for循环方便多了。
def while_loop_ii(n:int)->int:
"""while循环(两次更新)"""
res = 0
i = 1 # 初始化条件变量
# 循环求和1,4,10,22,...
while i <= n:
res += i # 累加
# 更新条件变量
i += 1
i *= 2
# print("res:", res)
# print("i:", i)
return res
# 输出
res_while_2 = while_loop_ii(10)
print("while_loop_ii:", res_while_2)
# while_loop_ii: 15
while适合无法确定循环次数的情况,如:
s = ""
while s != "quit":
s = input("请输入: ")
print("已输入 quit")
- 嵌套循环
可以在一个循环结构内嵌套另一个循环结构,以for循环为例:生成一个包含数字对的字符串,其中第一个数字在1到n之间循环,第二个数字也在1到n之间循环,总循环了n2次,即算法运行时间和输入数据大小n成“平方关系”。
def nested_for_loop(n:int)->int:
"""双层for循环"""
res = "" # 初始化结果字符串为空
# 循环i=1,2,...,n-1,n
for i in range(1, n+1):
# 循环j=1,2,...,n-1,n
for j in range(1, n+1):
# 将生成的数字对添加到结果字符串中
res += f"({i}, {j})"
return res
# 输出
res_nested_for_loop = nested_for_loop(3)
print("res_nested_for_loop:", res_nested_for_loop)
# res_nested_for_loop: (1, 1)(1, 2)(1, 3)(2, 1)(2, 2)(2, 3)(3, 1)(3, 2)(3, 3)
- 嵌套循环的流程框图如图
递归
递归(recursion)通过函数调用自身来解决问题,包含两个阶段:
1.递: 函数不断深入地调用自身,通常传入更小或更简化的参数,直到达到“终止条件”。
2.归: 触发“终止条件”后,程序从最深层的递归函数开始逐层返回,汇聚每一层的结果。
递归代码主要包含三个要素:
1.终止条件: 用于决定什么时候由“递”转“归”。
2.递归调用: 对应“递”,函数调用自身,通常输入更小或更简化的参数。
3.返回结果: 对应“归”,将当前递归层级的结果返回至上一层。
- 例:求和问题:输入n,求和1+2+3+…+n-1+n
def recur(n:int)->int:
"""递归"""
# 终止条件
if n == 1:
return 1
# 递: 递归调用
res = recur(n-1) # 递归
# 归:返回结果
return n + res # 求和操作
# 输出
recur0 = recur(5)
print("recur:", recur0)
# recur: 15
- 递归过程图,每层返回后,都要执行一次求和操作。
迭代与递归的区别
迭代与递归可以得到相同的结果,但是它们代表了两种不同的思考和解决问题的范式。
迭代:“自下而上” 地解决问题。从最基础的步骤开始,然后不断重复或累加这些步骤,直到任务完成。
递归:“自上而下” 地解决问题。将原问题分解为更小的子问题,这些子问题和原问题具有相同的形式。接下来将子问题继续分解为更小的子问题,知道基本情况时停止(进本情况的解是已知的)。
- 以上述求和函数为例,设问题f(n)=1+2+3+…+n-1+n。
迭代: 在循环中模拟求和过程,从1遍历到n,每轮执行求和操作,依次解决f(1),f(2),…,f(n-1),f(n)。
递归: 将问题分解为子问题f(n)=n+f(n-1),不断(递归地)分解下去,直至基本情况f(1)=1时终止。
递归调用栈
递归函数每次调用自身时,系统都会为新开启的函数分配内存,以存储局部变量、调用地址和其他信息等。
- 函数的上下文数据都存储在称为“栈帧空间”的内存区域中,直至函数返回后才会被释放。因此,递归
通常比迭代更加耗费内存空间。 - 递归调用函数会产生额外的开销。因此递归通常比循环的时间效率更低。
递归深度: 在触发终止条件前,同时存在n个未返回的递归函数,递归深度为n,如图。
实际中编程语言允许的递归深度通常是有限的,过深的递归深度可能导致栈溢出错误。
尾递归
尾递归(tailrecursion): 如果函数在返回前的最后一步才进行递归调用,则该函数可以被编译器或解释器优化,使其在空间效率上与迭代相当。
- 普通递归: 当函数返回到上一层级的函数后,需要继续执行代码,因此系统需要保存上一层调用的上下文。
- 尾递归: 递归调用是函数返回前的最后一个操作,这意味着函数返回到上一层级后,无须继续执行其他操作,因此系统无须保存上一层函数的上下文。
def tail_recur(n:int, res:int)->int:
"""尾递归"""
# 终止条件
if n == 0:
return res
# 尾递归调用
return tail_recur(n - 1, n + res)
# 输出
tail_recur0 = tail_recur(5, 0)
print("tail_recur:", tail_recur0)
# tail_recur: 15
尾递归的执行过程如图所示,与普通递归相比,两者的求和操作的执行点是不同的。
- 普通递归:求和操作是在“归”的过程中执行的,每层返回后都要再执行一次求和操作。
- 尾递归:求和操作是在“递”的过程中执行的,“归”的过程只需层层返回。
许多编译器或解释器并不支持尾递归优化,如python默认不支持尾递归优化,因此即使函数是尾递归形式,仍然可能会遇到栈溢出问题。
递归树(recursion tree)
在处理与“分治”相关的算法问题时,递归往往比迭代的思路更加直观、代码更加易读。
- 求斐波那契数列。给定一个斐波那契数列0,1,1,2,3,5,8,13,…,求该数列的第n个数字。
解题思路: 设第n个数字为f(n),已知数列的前两个数字为f(1)=0和f(2)=1,且数列中每个数字是前两个数字的和,即f(n)=f(n-1)+f(n-2)。
按照递推关系进行递归调用,将前两个数字作为终止条件,便可写出递归代码。
def fib(n:int)->int:
"""斐波那契数列:递归"""
# 终止条件 f(1)=0, f(2)=1
if n == 1 or n == 2:
return n - 1
# 递归调用 f(n) = f(n-1) + f(n-2)
res = fib(n - 1) + fib(n - 2)
# 返回结果 f(n)
return res
# 输出
fib0 = fib(8)
print("fib:", fib0)
# fib: 13
代码中函数内递归调用了两个函数,即从一个调用产生了两个调用分支。如图所示,不断递归调用,最终产生一颗层数为n的递归树。
迭代vs.递归
特点 | 迭代 | 递归 |
---|---|---|
实现方式 | 循环结构 | 函数调用自身 |
时间效率 | 效率通常较高 | 每次函数调用都会产生开销 |
内存使用 | 通常使用固定大小的内存空间 | 累积函数调用可能使用大量的栈帧空间 |
代码风格 | 代码直观、可读性好 | 代码结构简洁、清晰。 |
适用问题 | 适用于简单循环任务 | 适用于子问题分解,如树、图、分治、回溯等 |