迭代是一种重复执行某个任务的控制结构。在迭代中,程序会在满足一定的条件下重复执行某段代码,直到这个条件不再满足。
for
循环是最常见的迭代形式之一,适合预先知道迭代次数时使用。
Python:
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
Go:
/* for 循环 */
func forLoop(n int) int {
res := 0
// 循环求和 1, 2, ..., n-1, n
for i := 1; i <= n; i++ {
res += i
}
return res
}
与 for
循环类似,while
循环也是一种实现迭代的方法。在 while
循环中,程序每轮都会先检查条件,如果条件为真则继续执行,否则就结束循环。
Python:
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
Go:
/* while 循环 */
func whileLoop(n int) int {
res := 0
// 初始化条件变量
i := 1
// 循环求和 1, 2, ..., n-1, n
for i <= n {
res += i
// 更新条件变量
i++
}
return res
}
在 while
循环中,由于初始化和更新条件变量的步骤是独立在循环结构之外的,因此它比 for
循环的自由度更高。总的来说,for
循环的代码更加紧凑,while
循环更加灵活,两者都可以实现迭代结构。选择使用哪一个应该根据特定问题的需求来决定。
我们可以在一个循环结构内嵌套另一个循环结构,以 for
循环为例:
Python:
def nested_for_loop(n: int) -> str:
"""双层 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
Go:
/* 双层 for 循环 */
func nestedForLoop(n int) string {
res := ""
// 循环 i = 1, 2, ..., n-1, n
for i := 1; i <= n; i++ {
for j := 1; j <= n; j++ {
// 循环 j = 1, 2, ..., n-1, n
res += fmt.Sprintf("(%d, %d), ", i, j)
}
}
return res
}
递归是一种算法策略,通过函数调用自身来解决问题。它主要包含两个阶段。
而从实现的角度看,递归代码主要包含三个要素。
Python:
def recur(n: int) -> int:
"""递归"""
# 终止条件
if n == 1:
return 1
# 递:递归调用
res = recur(n - 1)
# 归:返回结果
return n + res
Go:
/* 递归 */
func recur(n int) int {
// 终止条件
if n == 1 {
return 1
}
// 递:递归调用
res := recur(n - 1)
// 归:返回结果
return n + res
}
递归函数每次调用自身时,系统都会为新开启的函数分配内存,以存储局部变量、调用地址和其他信息等。这将导致两方面的结果。
如果函数在返回前的最后一步才进行递归调用,则该函数可以被编译器或解释器优化,使其在空间效率上与迭代相当。这种情况被称为尾递归。
Python:
def tail_recur(n, res):
"""尾递归"""
# 终止条件
if n == 0:
return res
# 尾递归调用
return tail_recur(n - 1, res + n)
Go:
/* 尾递归 */
func tailRecur(n int, res int) int {
// 终止条件
if n == 0 {
return res
}
// 尾递归调用
return tailRecur(n-1, res+n)
}
个人理解尾递归就是最后一步递归调用一结束那么所有的函数就结束了,不需要再挨着挨着返回上一层函数。
典型问题:斐波那契数列
Python:
def fib(n: int) -> int:
if n == 1 or n == 2:
return 1
return fib(n - 1) + fib(n - 2)
Go:
func fib(n int) int {
if n == 1 || n == 2 {
return 1
}
return fib(n-1) + fib(n-2)
}
在函数内递归调用了两个函数,这意味着从一个调用产生了两个调用分支。这样不断递归调用下去,最终将产生一个层数为 n 的递归树。
时间复杂度分析统计的不是算法运行时间,而是算法运行时间随着数据量变大时的增长趋势。
“时间增长趋势”这个概念比较抽象,我们通过一个例子来加以理解。假设输入数据大小为 n ,给定三个算法函数 A
、B
和 C
:
Python:
# 算法 A 的时间复杂度:常数阶
def algorithm_A(n: int):
print(0)
# 算法 B 的时间复杂度:线性阶
def algorithm_B(n: int):
for _ in range(n):
print(0)
# 算法 C 的时间复杂度:常数阶
def algorithm_C(n: int):
for _ in range(1000000):
print(0)
// 算法 A 的时间复杂度:常数阶
func algorithm_A(n int) {
fmt.Println(0)
}
// 算法 B 的时间复杂度:线性阶
func algorithm_B(n int) {
for i := 0; i < n; i++ {
fmt.Println(0)
}
}
// 算法 C 的时间复杂度:常数阶
func algorithm_C(n int) {
for i := 0; i < 1000000; i++ {
fmt.Println(0)
}
}
A
只有 1 个打印操作,算法运行时间不随着 n 增大而增长。我们称此算法的时间复杂度为“常数阶”。B
中的打印操作需要循环 n 次,算法运行时间随着 n 增大呈线性增长。此算法的时间复杂度被称为“线性阶”。C
中的打印操作需要循环 1000000 次,虽然运行时间很长,但它与输入数据大小 n 无关。因此 C
的时间复杂度和 A
相同,仍为“常数阶”。时间复杂度的公式是: T(n) = O( f(n) ),时间复杂度分析本质上是计算“操作数量函数T(n)的渐近上界,而O 表示正比例关系,这个公式的全称是:算法的渐进时间复杂度。
若存在正实数c和实数n₀,使得对于所有的n > n₀,均有T(n)≤c f(n),则可认为f(n)给出了T(n)的一个渐近上界,记为T(n)=O(f(n))。计算渐近上界就是寻找一个函数f(n),使得当n趋向于无穷大时,T(n)和f(n)处于相同的增长级别,仅相差一个常数项c的倍数。
那该怎样确定渐近上界f(n)呢?总体分为两步:首先统计操作数量,然后判断渐近上界。
第一步:统计操作数量
针对代码,逐行从上到下计算即可。然而,由于上述c· f(n)中的常数项c可以取任意大小,因此操作数量T(n)中的各种系数、常数项都可以被忽略。根据此原则,可以总结出以下计数简化技巧。
1.忽略T(n)中的常数项。因为它们都与n无关,所以对时间复杂度不产生影响。
2.省略所有系数。例如,循环2n 次、5n+1次等,都可以简化记为n次,因为n前面的系数对时间复杂度没有影响。
3.循环嵌套时使用乘法。总操作数量等于外层循环和内层循环操作数量之积,每一层循环依然可以分别套用第1点和第2点的技巧。
给定一个函数,我们可以用上述技巧来统计操作数量。
Python:
def algorithm(n: int):
a = 1 # +0(技巧 1)
a = a + n # +0(技巧 1)
# +n(技巧 2)
for i in range(5 * n + 1):
print(0)
# +n*n(技巧 3)
for i in range(2 * n):
for j in range(n + 1):
print(0)
Go:
func algorithm(n int) {
a := 1 // +0(技巧 1)
a = a + n // +0(技巧 1)
// +n(技巧 2)
for i := 0; i < 5 * n + 1; i++ {
fmt.Println(0)
}
// +n*n(技巧 3)
for i := 0; i < 2 * n; i++ {
for j := 0; j < n + 1; j++ {
fmt.Println(0)
}
}
}
以下公式展示了使用上述技巧前后的统计结果,两者推出的时间复杂度都为O(n²) 。
第二步:判断渐近上界
时间复杂度由多项式T(n)中最高阶的项来决定。这是因为在n趋于无穷大时,最高阶的项将发挥主导作用,其他项的影响都可以被忽略。
常见的时间复杂度量级有:
上面从上至下依次的时间复杂度越来越大,执行的效率越来越低。
常数阶的操作数量与输入数据大小n无关,即不随着n的变化而变化。
在以下函数中,尽管操作数量 size
可能很大,但由于其与输入数据大小n无关,因此时间复杂度仍为 O(1) :
Python:
def constant(n: int) -> int:
"""常数阶"""
count = 0
size = 100000
for _ in range(size):
count += 1
return count
Go:
/* 常数阶 */
func constant(n int) int {
count := 0
size := 100000
for i := 0; i < size; i++ {
count++
}
return count
}
上述代码在执行的时候,它消耗的时候并不随着某个变量的增长而增长,那么无论这类代码有多长,即使有几万几十万行,都可以用O(1)来表示它的时间复杂度。
线性阶的操作数量相对于输入数据大小n以线性级别增长。线性阶通常出现在单层循环中:
Python:
def linear(n: int) -> int:
"""线性阶"""
count = 0
for _ in range(n):
count += 1
return count
Go:
/* 线性阶 */
func linear(n int) int {
count := 0
for i := 0; i < n; i++ {
count++
}
return count
}
这段代码,for循环里面的代码会执行n遍,因此它消耗的时间是随着n的变化而变化的,因此这类代码都可以用O(n)来表示它的时间复杂度。
Python:
def logarithmic(n: int) -> int:
i = 1
while i < n:
i *= 2
return i
Go:
func logarithmic(n int) int {
i := 1
for i < n {
i *= 2
}
return i
}
从上面代码可以看到,在while循环里面,每次都将 i 乘以 2,乘完之后,i 距离 n 就越来越近了。我们试着求解一下,假设循环x次之后,i 就大于 2 了,此时这个循环就退出了,也就是说 2 的 x 次方等于 n,那么 x = log2^n
也就是说当循环 log2^n 次以后,这个代码就结束了。根据换底公式,2也可以为其他常熟值,故这个代码的时间复杂度为:O(logn)
线性对数阶O(nlogn) 其实非常容易理解,将时间复杂度为O(logn)的代码循环n遍的话,那么它的时间复杂度就是 n * O(logn),也就是了O(nlogn)。
就拿上面的代码加一点修改来举例:
Python:
def linear_log(n: int):
for m in range(1, n):
i = 1
while i < n:
i *= 2
Go:
func linearLog(n int) {
for m := 1; m < n; m++ {
i := 1
for i < n {
i *= 2
}
}
}
平方阶的操作数量相对于输入数据大小n以平方级别增长。平方阶通常出现在嵌套循环中,外层循环和内层循环都为O(n),因此总体为 O(n²) :
Python:
def quadratic(n: int) -> int:
"""平方阶"""
count = 0
# 循环次数与数组长度成平方关系
for i in range(n):
for j in range(n):
count += 1
return count
Go:
/* 平方阶 */
func quadratic(n int) int {
count := 0
// 循环次数与数组长度成平方关系
for i := 0; i < n; i++ {
for j := 0; j < n; j++ {
count++
}
}
return count
}
如果将其中一层循环的n改成m,即:
Python:
def quadratic(n: int) -> int:
"""平方阶"""
count = 0
# 循环次数与数组长度成平方关系
for i in range(n):
for j in range(m):
count += 1
return count
Go:
/* 平方阶 */
func quadratic(n int) int {
count := 0
// 循环次数与数组长度成平方关系
for i := 0; i < n; i++ {
for j := 0; j < m; j++ {
count++
}
}
return count
}
那它的时间复杂度就变成了 O(m*n)。
生物学的“细胞分裂”是指数阶增长的典型例子:初始状态为 1 个细胞,分裂一轮后变为 2 个,分裂两轮后变为 4 个,以此类推,分裂n轮后有 2^n个细胞。
Python:
def exponential(n: int) -> int:
"""指数阶(循环实现)"""
count = 0
base = 1
# 细胞每轮一分为二,形成数列 1, 2, 4, 8, ..., 2^(n-1)
for _ in range(n):
for _ in range(base):
count += 1
base *= 2
# count = 1 + 2 + 4 + 8 + .. + 2^(n-1) = 2^n - 1
return count
Go:
/* 指数阶(循环实现)*/
func exponential(n int) int {
count, base := 0, 1
// 细胞每轮一分为二,形成数列 1, 2, 4, 8, ..., 2^(n-1)
for i := 0; i < n; i++ {
for j := 0; j < base; j++ {
count++
}
base *= 2
}
// count = 1 + 2 + 4 + 8 + .. + 2^(n-1) = 2^n - 1
return count
}
阶乘通常使用递归实现。如下代码所示,第一层分裂出n个,第二层分裂出n−1 个,以此类推,直至第n层时停止分裂:
Python:
def factorial_recur(n: int) -> int:
"""阶乘阶(递归实现)"""
if n == 0:
return 1
count = 0
# 从 1 个分裂出 n 个
for _ in range(n):
count += factorial_recur(n - 1)
return count
Go:
/* 阶乘阶(递归实现) */
func factorialRecur(n int) int {
if n == 0 {
return 1
}
count := 0
// 从 1 个分裂出 n 个
for i := 0; i < n; i++ {
count += factorialRecur(n - 1)
}
return count
}
算法的时间效率往往不是固定的,而是与输入数据的分布有关。假设输入一个长度为n的数组 nums
,其中 nums
由从 1 至n的数字组成,每个数字只出现一次;但元素顺序是随机打乱的,任务目标是返回元素 1 的索引。
Python:
def random_numbers(n: int) -> list[int]:
"""生成一个数组,元素为: 1, 2, ..., n ,顺序被打乱"""
# 生成数组 nums =: 1, 2, 3, ..., n
nums = [i for i in range(1, n + 1)]
# 随机打乱数组元素
random.shuffle(nums)
return nums
def find_one(nums: list[int]) -> int:
"""查找数组 nums 中数字 1 所在索引"""
for i in range(len(nums)):
# 当元素 1 在数组头部时,达到最佳时间复杂度 O(1)
# 当元素 1 在数组尾部时,达到最差时间复杂度 O(n)
if nums[i] == 1:
return i
return -1
Go:
/* 生成一个数组,元素为 { 1, 2, ..., n },顺序被打乱 */
func randomNumbers(n int) []int {
nums := make([]int, n)
// 生成数组 nums = { 1, 2, 3, ..., n }
for i := 0; i < n; i++ {
nums[i] = i + 1
}
// 随机打乱数组元素
rand.Shuffle(len(nums), func(i, j int) {
nums[i], nums[j] = nums[j], nums[i]
})
return nums
}
/* 查找数组 nums 中数字 1 所在索引 */
func findOne(nums []int) int {
for i := 0; i < len(nums); i++ {
// 当元素 1 在数组头部时,达到最佳时间复杂度 O(1)
// 当元素 1 在数组尾部时,达到最差时间复杂度 O(n)
if nums[i] == 1 {
return i
}
}
return -1
}
值得说明的是,我们在实际中很少使用最佳时间复杂度,因为通常只有在很小概率下才能达到,可能会带来一定的误导性。而最差时间复杂度更为实用,因为它给出了一个效率安全值,让我们可以放心地使用算法。
既然时间复杂度不是用来计算程序具体耗时的,那么我也应该明白,空间复杂度也不是用来计算程序实际占用的空间的。
空间复杂度是对一个算法在运行过程中临时占用存储空间大小的一个量度,同样反映的是一个趋势。
推算方法
而与时间复杂度不同的是,我们通常只关注最差空间复杂度。这是因为内存空间是一项硬性要求,我们必须确保在所有输入数据下都有足够的内存空间预留。
观察以下代码,最差空间复杂度中的“最差”有两层含义。
Python:
def algorithm(n: int):
a = 0 # O(1)
b = [0] * 10000 # O(1)
if n > 10:
nums = [0] * n # O(n)
Go:
func algorithm(n int) {
a := 0 // O(1)
b := make([]int, 10000) // O(1)
var nums []int
if n > 10 {
nums := make([]int, n) // O(n)
}
fmt.Println(a, b, nums)
}
在递归函数中,需要注意统计栈帧空间。例如在以下代码中:
Python:
def function() -> int:
# 执行某些操作
return 0
def loop(n: int):
"""循环 O(1)"""
for _ in range(n):
function()
def recur(n: int) -> int:
"""递归 O(n)"""
if n == 1: return
return recur(n - 1)
Go:
func function() int {
// 执行某些操作
return 0
}
/* 循环 O(1) */
func loop(n int) {
for i := 0; i < n; i++ {
function()
}
}
/* 递归 O(n) */
func recur(n int) {
if n == 1 {
return
}
recur(n - 1)
}
空间复杂度比较常用的有:O(1)、O(logn)、O(n)、O(n²)、O(2^n),从低到高排列,我们下面来看看:
常数阶常见于数量与输入数据大小n无关的常量、变量、对象。
需要注意的是,在循环中初始化变量或调用函数而占用的内存,在进入下一循环后就会被释放,因此不会累积占用空间,空间复杂度仍为O(1) :
Python:
def function() -> int:
"""函数"""
# 执行某些操作
return 0
def constant(n: int):
"""常数阶"""
# 常量、变量、对象占用 O(1) 空间
a = 0
nums = [0] * 10000
node = ListNode(0)
# 循环中的变量占用 O(1) 空间
for _ in range(n):
c = 0
# 循环中的函数占用 O(1) 空间
for _ in range(n):
function()
Go:
/* 函数 */
func function() int {
// 执行某些操作...
return 0
}
/* 常数阶 */
func spaceConstant(n int) {
// 常量、变量、对象占用 O(1) 空间
const a = 0
b := 0
nums := make([]int, 10000)
ListNode := newNode(0)
// 循环中的变量占用 O(1) 空间
var c int
for i := 0; i < n; i++ {
c = 0
}
// 循环中的函数占用 O(1) 空间
for i := 0; i < n; i++ {
function()
}
fmt.Println(a, b, nums, c, ListNode)
}
线性阶常见于元素数量与n成正比的数组、链表、栈、队列等:
Python:
def linear(n: int):
"""线性阶"""
# 长度为 n 的列表占用 O(n) 空间
nums = [0] * n
# 长度为 n 的哈希表占用 O(n) 空间
hmap = dict[int, str]()
for i in range(n):
hmap[i] = str(i)
Go:
/* 线性阶 */
func spaceLinear(n int) {
// 长度为 n 的数组占用 O(n) 空间
_ = make([]int, n)
// 长度为 n 的列表占用 O(n) 空间
var nodes []*node
for i := 0; i < n; i++ {
nodes = append(nodes, newNode(i))
}
// 长度为 n 的哈希表占用 O(n) 空间
m := make(map[int]string, n)
for i := 0; i < n; i++ {
m[i] = strconv.Itoa(i)
}
}
下面的这种情况也是一个例子,此函数的递归深度为n ,即同时存在n个未返回的 linear_recur()
函数,使用O(n)大小的栈帧空间:
Python:
def linear_recur(n: int):
"""线性阶(递归实现)"""
print("递归 n =", n)
if n == 1:
return
linear_recur(n - 1)
Go:
/* 线性阶(递归实现) */
func spaceLinearRecur(n int) {
fmt.Println("递归 n =", n)
if n == 1 {
return
}
spaceLinearRecur(n - 1)
}
平方阶常见于矩阵和图,元素数量与 n成平方关系:
Python:
def quadratic(n: int):
"""平方阶"""
# 二维列表占用 O(n^2) 空间
num_matrix = [[0] * n for _ in range(n)]
Go:
/* 平方阶 */
func spaceQuadratic(n int) {
// 矩阵占用 O(n^2) 空间
numMatrix := make([][]int, n)
for i := 0; i < n; i++ {
numMatrix[i] = make([]int, n)
}
}
如下所示,该函数的递归深度为n,在每个递归函数中都初始化了一个数组,长度分别为n、n -1、…、2、1,平均长度为n/2,因此总体占用O(n^2)空间:
Python:
def quadratic_recur(n: int) -> int:
"""平方阶(递归实现)"""
if n <= 0:
return 0
# 数组 nums 长度为 n, n-1, ..., 2, 1
nums = [0] * n
return quadratic_recur(n - 1)
Go:
/* 平方阶(递归实现) */
func spaceQuadraticRecur(n int) int {
if n <= 0 {
return 0
}
nums := make([]int, n)
fmt.Printf("递归 n = %d 中的 nums 长度 = %d \n", n, len(nums))
return spaceQuadraticRecur(n - 1)
}
指数阶常见于二叉树。观察图,高度为n的“满二叉树”的节点数量为2n-1,占用O(2n)空间:
Python:
def build_tree(n: int) -> TreeNode | None:
"""指数阶(建立满二叉树)"""
if n == 0:
return None
root = TreeNode(0)
root.left = build_tree(n - 1)
root.right = build_tree(n - 1)
return root
Go:
/* 指数阶(建立满二叉树) */
func buildTree(n int) *treeNode {
if n == 0 {
return nil
}
root := newTreeNode(0)
root.left = buildTree(n - 1)
root.right = buildTree(n - 1)
return root
}
例如将数字转化为字符串,输入一个正整数n,它的位数为log₁₀n+1,即对应字符串长度为log₁₀n +1,因此空间复杂度为O(log₁₀n+1) = O(logn)。
降低时间复杂度通常需要以提升空间复杂度为代价,反之亦然。我们将牺牲内存空间来提升算法运行速度的思路称为“以空间换时间”;反之,则称为“以时间换空间”。