算法是解决现实问题的工具,也暗含着应对人生困扰的思想
- [1 算法定义](#1 算法定义)
-
- [1.1 示例:计算最大公约数(Greatest Common Divisor,GCD)](#1.1 示例:计算最大公约数(Greatest Common Divisor,GCD))
- [1.2 算法步骤](#1.2 算法步骤)
- [1.3 算法实现](#1.3 算法实现)
-
- [1.3.1 自然语言](#1.3.1 自然语言)
- [1.3.2 伪代码](#1.3.2 伪代码)
-
- [1.3.2.1 基本格式](#1.3.2.1 基本格式)
- [1.3.2.2 控制结构](#1.3.2.2 控制结构)
- [1.3.2.3 函数和过程](#1.3.2.3 函数和过程)
- [1.3.2.4 输入输出](#1.3.2.4 输入输出)
- [1.3.2.5 变量和赋值](#1.3.2.5 变量和赋值)
- [1.3.2.6 其他](#1.3.2.6 其他)
- [1.3.2.7 示例](#1.3.2.7 示例)
- [1.3.3 Python3代码](#1.3.3 Python3代码)
- [2 算法特性](#2 算法特性)
- [3 算法评价](#3 算法评价)
- [4 时间复杂度](#4 时间复杂度)
-
- [4.1 渐进分析](#4.1 渐进分析)
- [4.2 渐进分析的步骤](#4.2 渐进分析的步骤)
- [4.3 时间复杂度分类及示例](#4.3 时间复杂度分类及示例)
- [5 空间复杂度](#5 空间复杂度)
-
- [5.1 空间复杂度的组成](#5.1 空间复杂度的组成)
- [5.2 空间复杂度分类及示例](#5.2 空间复杂度分类及示例)
- [6 算法优化设计](#6 算法优化设计)
-
- [6.1 如何降低时间复杂度](#6.1 如何降低时间复杂度)
- [6.2 如何减少空间复杂度](#6.2 如何减少空间复杂度)
- [6.3 时间、空间复杂度的兼顾与取舍](#6.3 时间、空间复杂度的兼顾与取舍)
人无远虑,必有近忧。我们无时无刻不处在大大小小、形形色色的问题中。一部分感性的人,选择无视问题;一部分理性的人,选择解决问题。而那些选择感性解决的,或者理性面对的,前者往往身体吃不消,后者往往心里扛不住。
是问题,就有解决方法,这是问题最本质的特征。看清问题的本质,大部分问题都迎刃而解了。剩下的就是如何理性的解决问题,而这就涉及算法了。算法就是以目标为导向,仔细剖析问题,发现关键点,然后列出行动,一步步执行直到完成目标的整个过程。
唯一需要注意的是,很多人生的问题你只有一次解决的机会,没有预演,所以选对你自己的算法很关键。
1 算法定义
算法是一个明确的、有限的步骤集合,用于解决特定问题或完成某项任务。它通常由输入、处理步骤和输出构成。
下面通过一个根据欧几里得算法计算最大公约数的例子给出详细的说明:
1.1 示例:计算最大公约数(Greatest Common Divisor,GCD)
- 目标 :计算两个正整数 a a a 和 b b b 的最大公约数
- 剖析问题:
假设 d d d 是 a a a 和 b b b 的最大公约数,即 d = G C D ( a , b ) = G C D ( b , a ) d=GCD(a, b)=GCD(b, a) d=GCD(a,b)=GCD(b,a),不失一般性令 a ≥ b a \geq b a≥b,则可以写成如下的表示:
a = k 1 × d b = k 2 × d k 1 ≥ k 2 a=k_1 \times d\;\;\;\;b=k_2 \times d\;\;\;\; k_1\geq k_2 a=k1×db=k2×dk1≥k2其中 k 1 k_1 k1 和 k 2 k_2 k2 是整数。
令 a = q × b + r a=q\times b + r a=q×b+r,则有:
r = a − q × b = k 1 × d − q × k 2 × d = ( k 1 − q × k 2 ) × d r=a - q \times b = k_1 \times d - q \times k_2 \times d =(k_1 - q \times k_2)\times d r=a−q×b=k1×d−q×k2×d=(k1−q×k2)×d其中整数 q q q 是 a a a 除以 b b b 的商, r r r 是 a a a 除以 b b b 的余数。
根据上式,可以得出 r r r 也可以被 d d d 整除。因此 d d d 肯定也是 b b b 和 r r r 的最大公约数。即: G C D ( a , b ) = G C D ( b , r ) = G C D ( b , a m o d b ) = d GCD(a, b) = GCD(b, r) = GCD(b, a\mod b)=d GCD(a,b)=GCD(b,r)=GCD(b,amodb)=d,其中 m o d mod mod 为模运算,就是计算 a a a 除以 b b b 得到的余数。
- 问题关键点:
按照上面的逻辑将 a a a 替换成 b b b,将 b b b 替换成 a m o d b a\mod b amodb,直到 b = 0 b=0 b=0 时,此时的 a a a 就是所求的最大公约数。
- 算法预演:
[1] 以 48 48 48 和 18 18 18 为例:
- G C D ( 48 , 18 ) GCD(48, 18) GCD(48,18)
- 48 m o d 18 = 12 48 \mod 18 = 12 48mod18=12
- 下次计算 G C D ( 18 , 12 ) GCD(18, 12) GCD(18,12)
- G C D ( 18 , 12 ) GCD(18, 12) GCD(18,12)
- 18 m o d 12 = 6 18 \mod 12 = 6 18mod12=6
- 下次计算 G C D ( 12 , 6 ) GCD(12, 6) GCD(12,6)
- G C D ( 12 , 6 ) GCD(12, 6) GCD(12,6)
- 12 m o d 6 = 0 12 \mod 6 = 0 12mod6=0
- 下次计算 G C D ( 6 , 0 ) GCD(6, 0) GCD(6,0)
- G C D ( 6 , 0 ) GCD(6, 0) GCD(6,0)
- G C D ( 6 , 0 ) = 6 GCD(6, 0) = 6 GCD(6,0)=6
因此 G C D ( 48 , 18 ) = 6 GCD(48, 18)= 6 GCD(48,18)=6
[2] 以 17 17 17 和 37 37 37 为例:
- G C D ( 17 , 37 ) GCD(17, 37) GCD(17,37)
- 17 m o d 37 = 17 17 \mod 37 = 17 17mod37=17
- 下次计算 G C D ( 37 , 17 ) GCD(37, 17) GCD(37,17)
- G C D ( 37 , 17 ) GCD(37, 17) GCD(37,17)
- 37 m o d 17 = 3 37 \mod 17 = 3 37mod17=3
- 下次计算 G C D ( 17 , 3 ) GCD(17, 3) GCD(17,3)
- G C D ( 17 , 3 ) GCD(17, 3) GCD(17,3)
- 17 m o d 3 = 2 17 \mod 3 = 2 17mod3=2
- 下次计算 G C D ( 3 , 2 ) GCD(3, 2) GCD(3,2)
- G C D ( 3 , 2 ) GCD(3, 2) GCD(3,2)
- 3 m o d 2 = 1 3 \mod 2 = 1 3mod2=1
- 下次计算 G C D ( 2 , 1 ) GCD(2,1) GCD(2,1)
- G C D ( 2 , 1 ) GCD(2, 1) GCD(2,1)
- 2 m o d 1 = 0 2 \mod 1 = 0 2mod1=0
- 下次计算 G C D ( 1 , 0 ) GCD(1,0) GCD(1,0)
- G C D ( 1 , 0 ) GCD(1, 0) GCD(1,0)
- G C D ( 1 , 0 ) = 1 GCD(1, 0) = 1 GCD(1,0)=1
因此 G C D ( 17 , 37 ) = 1 GCD(17, 37)= 1 GCD(17,37)=1
1.2 算法步骤
- 输入:
两个正整数 a a a 和 b b b - 处理步骤
a a a 替换成 b b b,将 b b b 替换成 a m o d b a\mod b amodb,不断循环 - 输出
当 b = 0 b=0 b=0 时,此时的 a a a 就是所求的最大公约数
1.3 算法实现
算法步骤可以用自然语言、伪代码或编程语言来描述。本系列中的描述是"和"的关系,其中编程语言选择Python3【版本3.12】。
1.3.1 自然语言
- 输入: 两个正整数 a a a 和 b b b
- 步骤:
- 首先,检查 b b b 是否等于 0 0 0。如果 b b b 是 0 0 0,那么 a a a 就是这两个数的最大公约数,算法结束。
- 如果 b b b 不等于 0 0 0,则进行以下操作:
- 计算 a a a 除以 b b b 的余数,并将这个余数记为 r r r。
- 将 a a a 的值更新为 b b b,将 b b b 的值更新为 r r r。
- 重复上述步骤,直到 b b b 为 0 0 0。
- 输出: 当 b b b 为 0 0 0 时,当前的 a a a 就是两个数的最大公约数。
1.3.2 伪代码
伪代码是一种用于描述算法的高层次语言,它不依赖于特定的编程语言,旨在清晰、简洁地表达算法的逻辑和步骤。下面介绍下伪代码的一些格式和规范:
1.3.2.1 基本格式
- 结构化:使用缩进和层次结构表示代码的控制结构(如循环、条件判断等)。
- 清晰的命名:使用有意义的变量和函数名,便于理解。
- 简洁性:保持伪代码简短明了,避免复杂的语法。
1.3.2.2 控制结构
-
IF...THEN...ELSE:条件判断
IF condition THEN
// 执行某些操作
ELSE
// 执行其他操作
END IF -
FOR:循环迭代
yaml
FOR variable FROM start TO end DO
// 循环体
END FOR
- WHILE:条件循环
yaml
WHILE condition DO
// 循环体
END WHILE
- REPEAT...UNTIL:重复执行直到条件成立
yaml
REPEAT
// 执行某些操作
UNTIL condition
1.3.2.3 函数和过程
- FUNCTION:定义函数
yaml
FUNCTION function_name(parameters)
// 函数体
RETURN value
END FUNCTION
- PROCEDURE:定义过程(不返回值的函数)
yaml
PROCEDURE procedure_name(parameters)
// 过程体
END PROCEDURE
1.3.2.4 输入输出
- READ:输入数据
yaml
READ variable
- PRINT:输出数据
yaml
PRINT variable
1.3.2.5 变量和赋值
- SET 或 LET:赋值
yaml
SET variable = value
- VARIABLE:声明变量(可以省略)
1.3.2.6 其他
- // 或 #:注释
yaml
// 这是一个注释
- RETURN:返回值
yaml
RETURN value
- END:结束结构
yaml
END IF
END FOR
1.3.2.7 示例
下面给出欧几里得算法的伪代码:
python
FUNCTION gcd(a, b)
WHILE b ≠ 0 DO
r ← a MOD b // 计算余数
a ← b // 更新 a 为 b
b ← r // 更新 b 为余数 r
END WHILE
RETURN a // 返回 GCD
END FUNCTION
1.3.3 Python3代码
下面给出欧几里得算法的Python3代码:
python
def gcd(a, b):
while b != 0:
a, b = b, a % b
return a
# 示例
result = gcd(48, 18)
print("48 和 18 的最大公约数为:", result) # 输出: 48 和 18 的最大公约数为: 6
2 算法特性
在计算机科学中,算法的性质通常被概括为以下5个方面:
(1)确定性
定义:算法在相同的输入下,每次执行都会产生相同的输出。确定性保证了算法的可预测性和可重复性。换言之,相同是意义相同,并不是完全一样,例如有些随机算法。
(2)有限性
定义:算法在有限的步骤内终止,产生一个明确的结果。有限性确保算法不会进入无限循环。换言之,算法必须要有终止条件。
(3)具有输入和输出
定义:算法可以接受零个或多个输入,并产生一个或多个输出。输入是算法处理的数据,输出是算法的结果。
(4)可行性
定义:算法中的每一步都必须是可执行的。可行性意味着算法的每个操作(如加法、比较等)都能在有限时间内完成。换言之,算法是解决问题的,不是制造问题的。
(5)通用性
定义:算法应能解决特定类型的问题,而不仅仅是针对特定实例。通用性使得算法具有广泛的适用性,能够处理不同规模和类型的输入。换言之,算法解决众人的孤独问题,不是个别人的寂寞问题。
3 算法评价
可以从算法结果,算法实现、算法应用以及算法运行时消耗的时间和空间等方面进行评价。
(1)正确性
定义:算法正确性是指算法能够在所有合理的输入下返回正确的输出。"好算法":
- 验证输出:需要确保算法对于每种可能的输入都能产生期望的结果。可以通过数学证明、测试用例或边界条件分析等方法来验证。
- 边界条件:考虑算法在极端情况下的表现,例如空输入、极大或极小的输入值等。
(2)可读性和可维护性
定义:可读性是指代码的清晰程度,可维护性是指代码在未来的修改和扩展的难易程度。"好算法":
- 代码风格:使用一致的命名规则、注释和格式化,使代码易于理解。
- 模块化:将算法分解为小的、可重用的模块,提高代码的可读性和可维护性。
- 文档:提供清晰的文档和使用说明,以帮助其他开发人员理解算法的功能和使用方法。
(3)鲁棒性
定义:鲁棒性是指算法在面对不正常或异常输入时的表现。"好算法":
- 错误处理:算法是否能够处理错误情况,如无效输入或意外情况,而不崩溃或产生未定义行为。
- 恢复能力:算法在遇到错误时是否能够恢复到正常状态。
(4)时间效率
定义:算法在执行过程中所需时间的相对度量,通常关注的是算法在处理不同规模输入时的执行时间表现。时间效率的高低直接影响到算法的实际应用性能,尤其是在处理大规模数据时。"好算法":
- 时间高效性:算法在处理问题时所需的时间相对较少,尤其是当输入规模增大时,算法的执行时间增长幅度较小。
这种效率的衡量通常通过算法的时间复杂度来实现。
(5)空间存储
定义:算法在执行过程中所需内存空间的量度。它通常关注的是算法在处理输入数据时所需的存储空间,包括临时变量、数据结构、输入数据本身以及其他动态分配的内存。空间存储的评估对于理解算法的内存使用效率和资源管理至关重要。"好算法":
- 空间存储低:算法在执行过程中所需的内存空间相对较少,尤其是在处理问题时,算法使用的额外内存资源保持在较低的水平。
这种效率通常通过空间复杂度来衡量。
4 时间复杂度
时间复杂度是一个函数,表示算法执行时间与输入规模之间的关系。一般根据算法中语句总的执行次数来衡量时间复杂度。下面根据一段伪代码,计算其语句的运行次数:
yaml
FUNCTION bubble_sort(array)
n ← length(array)
FOR i FROM 0 TO n - 1 DO
FOR j FROM 0 TO n - i - 2 DO
IF array[j] > array[j + 1] THEN
SWAP(array[j], array[j + 1])
END IF
END FOR
END FOR
END FUNCTION
运行次数分析
- 外层循环:
外层循环的变量 i i i 从 0 0 0 到 n − 1 n−1 n−1,因此它执行 n n n 次。 - 内层循环:
对于每次外层循环的迭代 i i i,内层循环的变量 j j j 从 0 0 0 到 n − i − 2 n−i−2 n−i−2。这意味着内层循环的执行次数为 n − i − 1 n−i−1 n−i−1 次。 - 总的运行次数
总运行次数 = ∑ i = 0 n − 1 ( n − i − 1 ) = n ( n − 1 ) 2 总运行次数=\sum_{i=0}^{n-1}(n-i-1)=\frac{n(n-1)}{2} 总运行次数=i=0∑n−1(n−i−1)=2n(n−1)
4.1 渐进分析
渐进分析是描述随着算法处理数据量 n n n 的不断增大,算法运行所需要时间的增长模式。渐进分析是评估算法性能的重要工具,它可以刻画算法在输入规模增大时的运行时间是如何变化的。
记 n n n 为算法处理数据量的大小, T ( n ) T(n) T(n) 为算法处理大小为n的数据所需要的时间。下面说明渐进分析的三种情况的符号:
(1) O O O 符号(O-notation)--最坏情况
- 定义 : O O O 符号用于表示算法的上界,即算法在最坏情况下的运行时间。它是一个"最多"运行时间的估计。
- 形式 :如果存在正常数 c c c 和 n 0 n_0 n0,使得对于所有的 n ≥ n 0 n \geq n_0 n≥n0,有 T ( n ) ≤ c × f ( n ) T(n) \leq c \times f(n) T(n)≤c×f(n),则 T ( n ) = O ( f ( n ) ) T(n)=O(f(n)) T(n)=O(f(n))
- 示例 :某算法的 T ( n ) = 8 n + 5 T(n) = 8n + 5 T(n)=8n+5,令 n 0 = 5 n_0=5 n0=5,因为对于任意的 n ≥ n 0 n\geq n_0 n≥n0, 8 n + 5 ≤ 9 n 8n + 5\leq 9n 8n+5≤9n 永远成立,即 T ( n ) ≤ c n T(n) \leq c n T(n)≤cn,所以该算法的 T ( n ) = O ( n ) T(n)=O(n) T(n)=O(n)
(2)Ω 符号(Omega-notation)--最好情况
- 定义 : Ω Ω Ω 符号用于表示算法的下界,即算法在最好情况下的运行时间。它是一个"最少"运行时间的估计。
- 形式 :如果存在正常数 c c c 和 n 0 n_0 n0,使得对于所有的 n ≥ n 0 n \geq n_0 n≥n0,有 T ( n ) ≥ c × g ( n ) T(n) \geq c \times g(n) T(n)≥c×g(n),则 T ( n ) = Ω ( g ( n ) ) T(n)=Ω(g(n)) T(n)=Ω(g(n))
- 示例 :某算法的 T ( n ) = n 2 − n 2 T(n) = \frac{n^2-n}{2} T(n)=2n2−n,令 n 0 = 2 n_0=2 n0=2,因为对于任意的 n ≥ n 0 n\geq n_0 n≥n0, n 2 − n 2 ≥ 1 4 n 2 \frac{n^2-n}{2}\geq \frac{1}{4}n^2 2n2−n≥41n2永远成立,即 T ( n ) ≥ c n 2 T(n) \geq cn^2 T(n)≥cn2,所以该算法的 T ( n ) = Ω ( n 2 ) T(n)=Ω(n^2) T(n)=Ω(n2)
(3)Θ 符号(Theta-notation)--平均情况
- 定义 : Θ Θ Θ 符号用于表示算法的精确界限,即算法的平均情况运行时间。它同时表示上界和下界。
- 形式 :如果存在正常数 c 1 , c 2 c_1\;,c_2 c1,c2 和 n 0 n_0 n0,使得对于所有的 n ≥ n 0 n \geq n_0 n≥n0,有 c 1 × h ( n ) ≤ T ( n ) ≤ c 2 × h ( n ) c_1 \times h(n) \leq T(n) \leq c_2 \times h(n) c1×h(n)≤T(n)≤c2×h(n),则 T ( n ) = Θ ( h ( n ) ) T(n)=Θ(h(n)) T(n)=Θ(h(n))
- 示例 :某算法的 T ( n ) = n 3 + n 2 T(n) =n^3 + n^2 T(n)=n3+n2,令 n 0 = 2 n_0=2 n0=2,因为对于任意的 n ≥ n 0 n\geq n_0 n≥n0, 1 2 n 3 ≤ T ( n ) ≤ 3 2 n 3 \frac{1}{2} n^3 \leq T(n) \leq \frac{3}{2} n^3 21n3≤T(n)≤23n3永远成立,即 c 1 n 3 ≤ T ( n ) ≤ c 2 n 3 c_1n^3 \leq T(n) \leq c_2 n^3 c1n3≤T(n)≤c2n3,所以该算法的 T ( n ) = Ω ( n 3 ) T(n)=Ω(n^3) T(n)=Ω(n3)
4.2 渐进分析的步骤
进行渐进分析时,可以遵循以下步骤:
- 确定算法的运行时间和 n n n 的函数关系:分析算法的基本操作,通常是循环、条件判断和递归调用。
- 选择合适的函数:找出一个适当的函数 f ( n ) f(n) f(n) 来表示运行时间的增长率。
- 应用渐进符号:使用 O O O、 Ω Ω Ω 或 Θ Θ Θ 符号来描述算法的时间复杂度。
- 简化表达:忽略低阶项和常数因子,只关注主导项,以便得出简洁的复杂度表达。实际应用中,算法的复杂度一般采用 O O O 符号表示,即算法运行时间的最坏情况。
4.3 时间复杂度分类及示例
下面按照时间复杂度的增长速度从慢到快依此介绍:
- O ( 1 ) O(1) O(1) - 常数时间复杂度
- 定义:算法的执行时间不依赖于输入规模的变化,无论输入如何,执行时间始终保持不变。
- 示例:
python
def get_first_element(lst):
return lst[0] if lst else None
# 示例
print(get_first_element([1, 2, 3])) # 输出 1
在这个例子中,无论数组的大小如何,获取第一个元素所需的时间都是常数。
- O ( l o g n ) O(log n) O(logn) - 对数时间复杂度
- 定义:算法的执行时间随着输入规模的增加而对数增长,通常出现在分治法或二分查找中。
- 示例:
python
def binary_search(arr, target):
left, right = 0, len(arr) - 1
while left <= right:
mid = (left + right) // 2
if arr[mid] == target:
return mid
elif arr[mid] < target:
left = mid + 1
else:
right = mid - 1
return -1
# 示例
print(binary_search([1, 2, 3, 4, 5], 3)) # 输出 2
在这个例子中,随着输入数组大小的增加,查找次数以对数方式减增加。
- O ( n ) O(n) O(n) - 线性时间复杂度
- 定义:算法的执行时间与输入规模成正比,即输入规模增加时,执行时间线性增加。
- 示例:
python
def sum_list(lst):
total = 0
for num in lst:
total += num
return total
# 示例
print(sum_list([1, 2, 3, 4, 5])) # 输出 15
在这个例子中,查找目标元素需要遍历整个数组,执行时间与数组大小成正比。
- O ( n l o g n ) O(n log n) O(nlogn) - 线性对数时间复杂度
- 定义:算法的执行时间是线性和对数的乘积,常见于高效的排序算法,如归并排序和快速排序。
- 示例
python
def merge_sort(arr):
if len(arr) <= 1:
return arr
mid = len(arr) // 2
left = merge_sort(arr[:mid])
right = merge_sort(arr[mid:])
return merge(left, right)
def merge(left, right):
result = []
i = j = 0
while i < len(left) and j < len(right):
if left[i] < right[j]:
result.append(left[i])
i += 1
else:
result.append(right[j])
j += 1
result.extend(left[i:])
result.extend(right[j:])
return result
# 示例
print(merge_sort([5, 2, 4, 1, 3])) # 输出 [1, 2, 3, 4, 5]
在这个例子中,排序的复杂度是 O ( n l o g n ) O(n log n) O(nlogn),因为每次分割数组后需要线性时间合并。
- O ( n 2 ) O(n²) O(n2) - 平方时间复杂度
- 定义:算法的执行时间与输入规模的平方成正比,通常出现在嵌套循环中。
- 示例:
python
def bubble_sort(arr):
n = len(arr)
for i in range(n):
for j in range(0, n-i-1):
if arr[j] > arr[j+1]:
arr[j], arr[j+1] = arr[j+1], arr[j] # 交换
return arr
# 示例
print(bubble_sort([5, 2, 4, 1, 3])) # 输出 [1, 2, 3, 4, 5]
在这个例子中,外层循环和内层循环都是线性的,导致整体复杂度为 O ( n 2 ) O(n²) O(n2)。
- O ( 2 n ) O(2^n) O(2n) - 指数时间复杂度
- 定义:算法的执行时间随着输入规模的增加以指数方式增长,通常出现于某些递归算法。
- 示例:
python
def fibonacci(n):
if n <= 1:
return n
return fibonacci(n-1) + fibonacci(n-2)
# 示例
print(fibonacci(5)) # 输出 5
在这个例子中,计算 Fibonacci 数列的时间复杂度是 O ( 2 n ) O(2^n) O(2n),因为每个调用都产生两个新的调用。
- O ( n ! ) O(n!) O(n!) - 阶乘时间复杂度
- 定义:算法的执行时间随着输入规模的增加以阶乘方式增长,通常出现在排列和组合问题中。
- 示例:
python
def permute(nums):
result = []
backtrack(nums, [], result)
return result
def backtrack(nums, path, result):
if len(path) == len(nums):
result.append(path)
return
for i in range(len(nums)):
if nums[i] in path:
continue # 跳过已经使用的元素
backtrack(nums, path + [nums[i]], result)
# 示例
print(permute([1, 2, 3])) # 输出 [[1, 2, 3], [1, 3, 2], [2, 1, 3], [2, 3, 1], [3, 1, 2], [3, 2, 1]]
在这个例子中,产生所有排列的时间复杂度为 O ( n ! ) O(n!) O(n!),因为对每个元素都需要计算其后续元素的排列。
5 空间复杂度
空间复杂度是指算法在运行过程中所需的内存空间的总量。和时间复杂度类似,通常用 O O O符号表示,以描述其在最坏情况下的增长率。空间复杂度的计算通常关注算法中需要额外分配内存的部分,例如变量、数据结构、函数调用栈等。
5.1 空间复杂度的组成
空间复杂度通常由以下两部分组成:
- 固定部分:与输入规模无关的部分,包括常量空间、简单变量、常量数组等。这部分空间在算法执行时不随输入规模变化。
- 可变部分:与输入规模相关的部分,通常由动态分配的数组、链表、递归调用栈等组成。这部分空间随着输入规模的变化而变化。
5.2 空间复杂度分类及示例
以下是常见空间复杂度的分类,并按增长速度从小到大的排列顺序依次介绍:
- O ( 1 ) O(1) O(1) - 常数空间复杂度
- 示例:计算数组元素的总和
python
def sum_list(lst):
total = 0
for num in lst:
total += num
return total
# 示例
print(sum_list([1, 2, 3, 4, 5])) # 输出 15
说明:只使用了一个额外的变量 total,无论输入数组大小如何,所需的空间保持不变。
- O ( l o g n ) O(logn) O(logn) - 对数空间复杂度
- 示例:递归计算对数
python
def log_recursive(n):
if n <= 1:
return 0
return 1 + log_recursive(n // 2)
# 示例
print(log_recursive(16)) # 输出 4
说明:每次递归调用都将数组的搜索范围减半,使用的栈空间与递归深度相关,最坏情况下为 O ( l o g n ) O(logn) O(logn)。
- O ( n ) O(n) O(n) - 线性空间复杂度
- 示例:反转字符串
python
def reverse_string(s):
reversed_str = ""
for char in s:
reversed_str = char + reversed_str
return reversed_str
# 示例
print(reverse_string("hello")) # 输出 "olleh"
说明:创建了一个与输入字符串相同大小的新字符串,因此空间复杂度为 O ( n ) O(n) O(n)。
- O ( n l o g n ) O(nlogn) O(nlogn) - 线性对数空间复杂度
- 示例:快速傅里叶变换 (FFT)
python
def fft(x):
N = len(x)
if N <= 1:
return x
# 分离偶数和奇数索引
even = fft(x[0::2]) # 偶数部分
odd = fft(x[1::2]) # 奇数部分
# 计算旋转因子
T = [0] * (N // 2)
for k in range(N // 2):
angle = -2 * 3.141592653589793 * k / N # -2πk/N
T[k] = (odd[k][0] * (cos(angle)) - odd[k][1] * (sin(angle)),
odd[k][0] * (sin(angle)) + odd[k][1] * (cos(angle)))
# 合并结果
result = [0] * N
for k in range(N // 2):
result[k] = (even[k][0] + T[k][0], even[k][1] + T[k][1])
result[k + N // 2] = (even[k][0] - T[k][0], even[k][1] - T[k][1])
return result
def cos(angle):
return sum((-1)**n * (angle**(2*n) / factorial(2*n)) for n in range(10))
def sin(angle):
return sum((-1)**n * (angle**(2*n + 1) / factorial(2*n + 1)) for n in range(10))
def factorial(n):
if n == 0:
return 1
result = 1
for i in range(1, n + 1):
result *= i
return result
# 示例
x = [(0, 0), (1, 0), (2, 0), (3, 0)] # 输入为复数,以元组形式表示
result = fft(x)
print(result) # 输出 FFT 结果
说明:
(1)递归调用栈:深度为 O ( l o g n ) O(logn) O(logn)。
(2)临时数组:在每一层递归中,even 和 odd 数组会占用 O ( n ) O(n) O(n) 的空间。
总体空间复杂度:由于存在多个递归层次,最终空间复杂度 O ( n l o g n ) O(nlogn) O(nlogn)。
- O ( n 2 ) O(n^2) O(n2) - 平方空间复杂度
- 示例:创建一个 n × n n×n n×n 的二维数组(矩阵)
python
def create_matrix(n):
matrix = []
for i in range(n):
row = [0] * n # 创建一个包含 n 个零的行
matrix.append(row)
return matrix
# 示例
matrix = create_matrix(3)
for row in matrix:
print(row) # 输出 [[0, 0, 0], [0, 0, 0], [0, 0, 0]]
说明:创建了一个 n × n n×n n×n 的二维数组,空间复杂度为 O ( n 2 ) O(n^2) O(n2)。
6 算法优化设计
6.1 如何降低时间复杂度
- 选择合适的数据结构
- 哈希表:使用哈希表可以将查找时间降低到 O ( 1 ) O(1) O(1),而使用数组或列表可能需要 O ( n ) O(n) O(n) 的时间。
- 平衡树:平衡树(如 AVL 树、红黑树)可以在 O ( l o g n ) O(logn) O(logn) 的时间内进行插入、删除和查找操作。
- 优化算法设计
- 分治法:将问题分解为较小的子问题,分别解决,再合并结果。例如,归并排序和快速排序。
- 动态规划:将重复计算的子问题存储起来,避免重复计算,从而降低时间复杂度。例如,斐波那契数列可以使用动态规划将时间复杂度从 O ( 2 n ) O(2^n) O(2n) 降低到 O ( n ) O(n) O(n)。
- 贪心算法:通过局部最优解构建全局最优解,通常能在较短时间内找到解。例如,活动选择问题。
- 减少不必要的计算
- 提前终止:在某些情况下,可以通过提前判断条件来终止循环或递归,减少不必要的计算。
- 记忆化:在递归中使用记忆化技术以避免重复计算。可以通过字典或数组保存已经计算过的结果。
- 减少循环嵌套
- 合并循环:如果可能,将嵌套循环合并为一个循环,减少总的时间复杂度。例如,将两个循环合并为一个循环可以将 O ( n 2 ) O(n^2) O(n2) 降低到 O ( n ) O(n) O(n)。
- 使用排序和二分查找:在处理某些问题时,可以先对数据排序,然后使用二分查找来减少查找时间。
- 使用更高效的算法
- 查找和排序:选择更高效的排序算法(如快速排序、堆排序)以降低排序时间复杂度。
- 图算法:在处理图问题时,选择合适的图算法(如 Dijkstra、Kruskal 或 Prim)以降低时间复杂度。
- 并行和分布式计算
- 多线程或多进程:如果任务可以并行处理,使用多线程或多进程来分摊工作负载,从而提高速度。
- 分布式计算:在处理大数据集时,可以考虑分布式计算框架(如 Hadoop 或 Spark)来加速计算。
- 复杂性分析和测试
- 分析算法复杂性:在设计算法时,进行复杂性分析以评估算法的时间复杂度。
- 基准测试:对不同算法进行基准测试,选择在实际数据上表现良好的算法。
- 代码优化
- 减少函数调用:频繁的函数调用会增加时间开销,尽量减少不必要的函数调用。
- 使用原地算法:尽量在原数据上进行修改,避免不必要的数据复制。
6.2 如何减少空间复杂度
- 选择合适的数据结构
- 使用原地算法:在可能的情况下,尽量在原始数据结构上进行操作,避免创建额外的数据结构。例如,快速排序可以通过在数组上进行原地交换来减少空间使用。
- 使用链表而非数组:在某些情况下,链表可能会比数组更节省空间,特别是在频繁插入和删除的情况下。
- 优化递归算法
- 使用迭代替代递归:许多递归算法可以转换为迭代形式,从而减少栈空间的使用。例如,使用循环代替递归来计算斐波那契数列。
- 尾递归优化:某些编程语言支持尾递归优化,可以减少栈帧的使用。如果使用的语言支持此特性,尽量设计尾递归函数。
- 共享数据
- 复用数据结构:在算法的不同阶段复用相同的数据结构,避免不必要的内存分配。例如,在图算法中,可以在不同的遍历中重用相同的数组。
- 使用引用:在处理大对象时,使用引用而不是创建对象的副本。例如,在 Python 中,传递列表或字典时传递的是引用,避免了数据的复制。
- 数据压缩
- 压缩存储:使用压缩算法(如哈夫曼编码)来存储数据,以减少占用的空间。这在处理大量数据时尤其有效。
- 位图表示:对于某些问题,可以使用位图(位集)来表示状态(如布尔值),极大地节省空间。
- 减少数据冗余
- 消除重复数据:在存储时,确保不重复存储相同的数据。例如,使用集合来存储唯一元素。
- 使用稀疏表示:在处理稀疏矩阵或稀疏数据时,使用稀疏表示(如列表、字典)来节省空间。
- 动态规划中的空间优化
- 状态压缩:在动态规划中,许多状态仅依赖于前一状态,可以只存储必要的状态,从而减少空间复杂度。例如,在计算斐波那契数列时,只需存储前两个值。
- 按需计算:只在需要时计算和存储中间结果,避免存储所有可能的中间结果。
- 代码优化
- 局部变量:尽量使用局部变量而非全局变量,局部变量在使用后会被释放,从而节省空间。
- 避免不必要的对象创建:在循环或频繁调用的函数中,避免每次都创建新对象,而是重用已有对象。
- 复杂性分析
- 评估空间复杂度:在设计算法时,进行空间复杂度分析,识别可能的高内存使用点,并进行优化。
- 基准测试:对不同实现进行基准测试,选择在内存使用上表现良好的算法。
6.3 时间、空间复杂度的兼顾与取舍
原则1:兼顾主要着力于算法的优化
原则2:取舍主要看算法的应用场景
原则3:先兼顾,后取舍