2.1 算法效率评估
在算法设计中,我们先后追求以下两个层面的目标。
- 找到问题解法:算法需要在规定的输入范围内可靠地求得问题的正确解。
- 寻求最优解法:同一个问题可能存在多种解法,我们希望找到尽可能高效的算法。
也就是说,在能够解决问题的前提下,算法效率已成为衡量算法优劣的主要评价指标,它包括以下两个维度。
- 时间效率:算法运行时间的长短。
- 空间效率:算法占用内存空间的大小
2.1.1 实际测试
实际测试具有较大的局限性。存在很多方面干扰,使得计算机不可能每个算法都运行来测试。
2.1.2 理论估值
渐近复杂度分析(asymptotic complexity analysis),简称复杂度分析。
- “时间和空间资源”分别对应时间复杂度(time complexity)和空间复杂度(space complexity)。
- “随着输入数据大小的增加”意味着复杂度反映了算法运行效率与输入数据体量之间的关系。
- “时间和空间的增长趋势”表示复杂度分析关注的不是运行时间或占用空间的具体值,而是时间或空间增长的“快慢”
2.2 迭代与递归
两种基本的程序控制结构:迭代、递归。
2.2.1 迭代
==迭代(iteration)==是一种重复执行某个任务的控制结构。在迭代中,程序会在满足一定的条件下重复执行某段代码,直到这个条件不再满足。
1. for 循环
for
循环是最常见的迭代形式
下面基于for实现 1+ 2 + 3 +···+n, res
输出结果
int forloop(int n){
int res = 0;
for ( int i = 1; i <= n; ++i)
{
res += i;
}
return res;
}
2. while 循环
与上方问题一致
int whileloop(int n){
int res = 0;
int i = 1;
while (i <= n)
{
res += i;
i++;
}
return res;
}
while
循环比 for
循环的自由度更高。在 while
循环中,我们可以自由地设计条件变量的初始化和更新步骤。
例如在以下代码中,条件变量 i 每轮进行两次更新,这种情况就不太方便用 for
循环实现:
int whileLoopII(int n) {
int res = 0;
int i = 1;
while (i <= n) {
res += i;
i++;
i *= 2;
}
return res;
}
3.嵌套循环
/* 双层 for 循环 */
string nestedForLoop(int n) {
ostringstream res;
// 循环 i = 1, 2, ..., n-1, n
for (int i = 1; i <= n; ++i) {
// 循环 j = 1, 2, ..., n-1, n
for (int j = 1; j <= n; ++j) {
res << "(" << i << ", " << j << "), ";
}
}
return res.str();
}
![[Pasted image 20240804161517.png]]
2.2.2 递归
==递归(recursion)==是一种算法策略,通过函数调用自身来解决问题。它主要包含两个阶段。
- 递:程序不断深入地调用自身,通常传入更小或更简化的参数,直到达到“终止条件”。
- 归:触发“终止条件”后,程序从最深层的递归函数开始逐层返回,汇聚每一层的结果。
而从实现的角度看,递归代码主要包含三个要素。
- 终止条件:用于决定什么时候由“递”转“归”。
- 递归调用:对应“递”,函数调用自身,通常输入更小或更简化的参数。
- 返回结果:对应“归”,将当前递归层级的结果返回至上一层
int recur(int n){
if(n == 1)
return 1;
int res = recur(n - 1)
return n + res;
}
![[Pasted image 20240804161809.png]]
虽然从计算角度看,迭代与递归可以得到相同的结果,但它们代表了两种完全不同的思考和解决问题的范式。
- 迭代:“自下而上”地解决问题。从最基础的步骤开始,然后不断重复或累加这些步骤,直到任务完成。
- 递归:“自上而下”地解决问题。将原问题分解为更小的子问题,这些子问题和原问题具有相同的形式。接下来将子问题继续分解为更小的子问题,直到基本情况时停止(基本情况的解是已知的)。
以上述求和函数为例,设问题 f(n)=1+2+⋯+n 。
- 迭代:在循环中模拟求和过程,从 1 遍历到 n ,每轮执行求和操作,即可求得 f(n) 。
- 递归:将问题分解为子问题 f(n)=n+f(n−1) ,不断(递归地)分解下去,直至基本情况 f(1)=1 时终止
1. 调用栈¶
递归函数每次调用自身时,系统都会为新开启的函数分配内存,以存储局部变量、调用地址和其他信息等。这将导致两方面的结果。
- 函数的上下文数据都存储在称为“栈帧空间”的内存区域中,直至函数返回后才会被释放。因此,递归通常比迭代更加耗费内存空间。
- 递归调用函数会产生额外的开销。因此递归通常比循环的时间效率更低。
如图 2-4 所示,在触发终止条件前,同时存在 n 个未返回的递归函数,递归深度为 n 。
在实际中,编程语言允许的递归深度通常是有限的,过深的递归可能导致栈溢出错误。
2.尾递归
int tailRecur(int n, int res)
{
if (n == 0)
return res;
return tailRecur(n - 1, res + n);
}
![[Pasted image 20240804162446.png]]
- 普通递归:当函数返回到上一层级的函数后,需要继续执行代码,因此系统需要保存上一层调用的上下文。
- 尾递归:递归调用是函数返回前的最后一个操作,这意味着函数返回到上一层级后,无须继续执行其他操作,因此系统无须保存上一层函数的上下文。
3.递归树
int fib(int n)
{
if(n == 1 || n == 2)
return n - 1;
int res = fib(n - 1) + fib(n - 2)
return res;
}
观察以上代码,我们在函数内递归调用了两个函数,这意味着从一个调用产生了两个调用分支。如图 2-6 所示,这样不断递归调用下去,最终将产生一棵层数为 n 的递归树(recursion tree)。
![[Pasted image 20240804162811.png]]
2.2.3 两者对比
![[Pasted image 20240804163117.png]]
/* 使用迭代模拟递归 */
int forLoopRecur(int n) {
// 使用一个显式的栈来模拟系统调用栈
stack<int> stack;
int res = 0;
// 递:递归调用
for (int i = n; i > 0; i--) {
// 通过“入栈操作”模拟“递”
stack.push(i);
}
// 归:返回结果
while (!stack.empty()) {
// 通过“出栈操作”模拟“归”
res += stack.top();
stack.pop();
}
// res = 1+2+3+...+n
return res;
}
用迭代来表示递归
- 转化后的代码可能更加难以理解,可读性更差。
- 对于某些复杂问题,模拟系统调用栈的行为可能非常困难。
2.3 时间复杂度
我们将线性阶的时间复杂度记为 O(n) ,这个数学符号称为大 O 记号(big-O notation),表示函数 T(n) 的渐近上界(asymptotic upper bound)。
`若存在正实数 c 和实数 n0 ,使得对于所有的 n>n0 ,均有 T(n)≤c⋅f(n) ,则可认为 f(n) 给出了 T(n) 的一个渐近上界,记为 T(n)=O(f(n))
2.3.1推算方法
1. 第一步:统计操作数量¶
针对代码,逐行从上到下计算即可。然而,由于上述 c⋅f(n) 中的常数项 c 可以取任意大小,因此操作数量 T(n) 中的各种系数、常数项都可以忽略。根据此原则,可以总结出以下计数简化技巧。
- 忽略 T(n) 中的常数项。因为它们都与 n 无关,所以对时间复杂度不产生影响。
- 省略所有系数。例如,循环 2n 次、5n+1 次等,都可以简化记为 n 次,因为 n 前面的系数对时间复杂度没有影响。
- 循环嵌套时使用乘法。总操作数量等于外层循环和内层循环操作数量之积,每一层循环依然可以分别套用第
1.
点和第2.
点的技巧。
给定一个函数,我们可以用上述技巧来统计操作数量:
void algorithm(int n) {
int a = 1; // +0(技巧 1)
a = a + n; // +0(技巧 1)
// +n(技巧 2)
for (int i = 0; i < 5 * n + 1; i++) {
cout << 0 << endl;
}
// +n*n(技巧 3)
for (int i = 0; i < 2 * n; i++) {
for (int j = 0; j < n + 1; j++) {
cout << 0 << endl;
}
}
}
以下公式展示了使用上述技巧前后的统计结果,两者推算出的时间复杂度都为 O(n2) 。
完整统计偷懒统计完整统计偷懒统计
T(n)=2n(n+1)+(5n+1)+2=2n2+7n+3 完整统计
T(n)=n2+n 偷懒统计 (o.O)
2. 第二步:判断渐近上界¶
时间复杂度由 T(n) 中最高阶的项来决定。这是因为在 n 趋于无穷大时,最高阶的项将发挥主导作用,其他项的影响都可以忽略。
表 2-2 展示了一些例子,其中一些夸张的值是为了强调“系数无法撼动阶数”这一结论。当 n 趋于无穷大时,这些常数变得无足轻重。
![[Pasted image 20240804164735.png]]
2.3.2 常见类型¶
设输入数据大小为 n ,常见的时间复杂度类型如图 2-9 所示(按照从低到高的顺序排列)。
![[Pasted image 20240804164828.png]]
![[Pasted image 20240804164835.png]]
一些例子
1.指数阶
/* 指数阶(循环实现) */
int exponential(int n) {
int count = 0, base = 1;
// 细胞每轮一分为二,形成数列 1, 2, 4, 8, ..., 2^(n-1)
for (int i = 0; i < n; i++) {
for (int j = 0; j < base; j++) {
count++;
}
base *= 2;
}
// count = 1 + 2 + 4 + 8 + .. + 2^(n-1) = 2^n - 1
return count;
}
![[Pasted image 20240804165504.png]]
在实际算法中,指数阶常出现于递归函数中。例如在以下代码中,其递归地一分为二,经过 n 次分裂后停止:
/* 指数阶(递归实现) */
int expRecur(int n) {
if (n == 1)
return 1;
return expRecur(n - 1) + expRecur(n - 1) + 1;
}
2.对数阶
与指数阶相反,对数阶反映了“每轮缩减到一半”的情况。设输入数据大小为 n ,由于每轮缩减到一半,因此循环次数是 log2n ,即 2n 的反函数。
图 2-12 和以下代码模拟了“每轮缩减到一半”的过程,时间复杂度为 O(log2n) ,简记为 O(logn) :
/* 对数阶(循环实现) */
int logarithmic(int n) {
int count = 0;
while (n > 1) {
n = n / 2;
count++;
}
return count;
}
![[Pasted image 20240804165752.png]]
`O(logn) 的底数是多少?
`准确来说,“一分为 m”对应的时间复杂度是 O(logmn) 。而通过对数换底公式,我们可以得到具有不同底数、相等的时间复杂度:
`O(logmn)=O(logkn/logkm)=O(logkn)
也就是说,底数 m 可以在不影响复杂度的前提下转换。因此我们通常会省略底数 m ,将对数阶直接记为 O(logn)
3. 线性对数阶 O(nlogn)¶
线性对数阶常出现于嵌套循环中,两层循环的时间复杂度分别为 O(logn) 和 O(n) 。相关代码如下:
/* 线性对数阶 */
int linearLogRecur(int n) {
if (n <= 1)
return 1;
int count = linearLogRecur(n / 2) + linearLogRecur(n / 2);
for (int i = 0; i < n; i++) {
count++;
}
return count;
}
![[Pasted image 20240804165847.png]]
4. 阶乘阶 O(n!)¶
阶乘阶对应数学上的“全排列”问题。给定 n 个互不重复的元素,求其所有可能的排列方案,方案数量为:
n!=n×(n−1)×(n−2)×⋯×2×1
阶乘通常使用递归实现。如图 2-14 和以下代码所示,第一层分裂出 n 个,第二层分裂出 n−1 个,以此类推,直至第 n 层时停止分裂:
/* 阶乘阶(递归实现) */
int factorialRecur(int n) {
if (n == 0)
return 1;
int count = 0;
// 从 1 个分裂出 n 个
for (int i = 0; i < n; i++) {
count += factorialRecur(n - 1);
}
return count;
}
![[Pasted image 20240804165943.png]]
请注意,因为当 n≥4 时恒有 n!>2n ,所以阶乘阶比指数阶增长得更快,在 n 较大时也是不可接受的
2.3.3最差、最佳、平均时间复杂度
算法的时间效率往往不是固定的,而是与输入数据的分布有关。假设输入一个长度为 n 的数组 nums
,其中 nums
由从 1 至 n 的数字组成,每个数字只出现一次;但元素顺序是随机打乱的,任务目标是返回元素 1 的索引。我们可以得出以下结论。
- 当
nums = [?, ?, ..., 1]
,即当末尾元素是 1 时,需要完整遍历数组,达到最差时间复杂度 O(n) 。 - 当
nums = [1, ?, ?, ...]
,即当首个元素为 1 时,无论数组多长都不需要继续遍历,达到最佳时间复杂度 Ω(1) 。
“最差时间复杂度”对应函数渐近上界,使用大 O 记号表示。相应地,“最佳时间复杂度”对应函数渐近下界,用 Ω 记号表示:
/* 生成一个数组,元素为 { 1, 2, ..., n },顺序被打乱 */
vector<int> randomNumbers(int n) {
vector<int> nums(n);
// 生成数组 nums = { 1, 2, 3, ..., n }
for (int i = 0; i < n; i++) {
nums[i] = i + 1;
}
// 使用系统时间生成随机种子
unsigned seed = chrono::system_clock::now().time_since_epoch().count();
// 随机打乱数组元素
shuffle(nums.begin(), nums.end(), default_random_engine(seed));
return nums;
}
/* 查找数组 nums 中数字 1 所在索引 */
int findOne(vector<int> &nums) {
for (int i = 0; i < nums.size(); i++) {
// 当元素 1 在数组头部时,达到最佳时间复杂度 O(1)
// 当元素 1 在数组尾部时,达到最差时间复杂度 O(n)
if (nums[i] == 1)
return i;
}
return -1;
}
2.4空间复杂度
空间复杂度(space complexity)用于衡量算法占用内存空间随着数据量变大时的增长趋势。这个概念与时间复杂度非常类似,只需将“运行时间”替换为“占用内存空间”。
2.4.1 算法相关空间¶
算法在运行过程中使用的内存空间主要包括以下几种。
- 输入空间:用于存储算法的输入数据。
- 暂存空间:用于存储算法在运行过程中的变量、对象、函数上下文等数据。
- 输出空间:用于存储算法的输出数据。
一般情况下,空间复杂度的统计范围是“暂存空间”加上“输出空间”。
暂存空间可以进一步划分为三个部分。
- 暂存数据:用于保存算法运行过程中的各种常量、变量、对象等。
- 栈帧空间:用于保存调用函数的上下文数据。系统在每次调用函数时都会在栈顶部创建一个栈帧,函数返回后,栈帧空间会被释放。
- 指令空间:用于保存编译后的程序指令,在实际统计中通常忽略不计。
![[Pasted image 20240804171207.png]]
/* 结构体 */
struct Node {
int val;
Node *next;
Node(int x) : val(x), next(nullptr) {}
};
/* 函数 */
int func() {
// 执行某些操作...
return 0;
}
int algorithm(int n) { // 输入数据
const int a = 0; // 暂存数据(常量)
int b = 0; // 暂存数据(变量)
Node* node = new Node(0); // 暂存数据(对象)
int c = func(); // 栈帧空间(调用函数)
return a + b + c; // 输出数据
}
2.4.2 推算方法
空间复杂度的推算方法与时间复杂度大致相同,只需将统计对象从“操作数量”转为“使用空间大小”。
而与时间复杂度不同的是,我们通常只关注最差空间复杂度。这是因为内存空间是一项硬性要求,我们必须确保在所有输入数据下都有足够的内存空间预留。
观察以下代码,最差空间复杂度中的“最差”有两层含义。
- 以最差输入数据为准:当 n<10 时,空间复杂度为 O(1) ;但当 n>10 时,初始化的数组
nums
占用 O(n) 空间,因此最差空间复杂度为 O(n) 。 - 以算法运行中的峰值内存为准:例如,程序在执行最后一行之前,占用 O(1) 空间;当初始化数组
nums
时,程序占用 O(n) 空间,因此最差空间复杂度为 O(n)
函数 loop()
和 recur()
的时间复杂度都为 O(n) ,但空间复杂度不同。
- 函数
loop()
在循环中调用了 n 次function()
,每轮中的function()
都返回并释放了栈帧空间,因此空间复杂度仍为 O(1) 。 - 递归函数
recur()
在运行过程中会同时存在 n 个未返回的recur()
,从而占用 O(n) 的栈帧空间
int func() {
// 执行某些操作
return 0;
}
/* 循环的空间复杂度为 O(1) */
void loop(int n) {
for (int i = 0; i < n; i++) {
func();
}
}
/* 递归的空间复杂度为 O(n) */
void recur(int n) {
if (n == 1) return;
return recur(n - 1);
}
2.5 小结
1. 重点回顾¶
算法效率评估
- 时间效率和空间效率是衡量算法优劣的两个主要评价指标。
- 我们可以通过实际测试来评估算法效率,但难以消除测试环境的影响,且会耗费大量计算资源。
- 复杂度分析可以消除实际测试的弊端,分析结果适用于所有运行平台,并且能够揭示算法在不同数据规模下的效率。
时间复杂度
- 时间复杂度用于衡量算法运行时间随数据量增长的趋势,可以有效评估算法效率,但在某些情况下可能失效,如在输入的数据量较小或时间复杂度相同时,无法精确对比算法效率的优劣。
- 最差时间复杂度使用大 O 符号表示,对应函数渐近上界,反映当 n 趋向正无穷时,操作数量 T(n) 的增长级别。
- 推算时间复杂度分为两步,首先统计操作数量,然后判断渐近上界。
- 常见时间复杂度从低到高排列有 O(1)、O(logn)、O(n)、O(nlogn)、O(n2)、O(2n) 和 O(n!) 等。
- 某些算法的时间复杂度非固定,而是与输入数据的分布有关。时间复杂度分为最差、最佳、平均时间复杂度,最佳时间复杂度几乎不用,因为输入数据一般需要满足严格条件才能达到最佳情况。
- 平均时间复杂度反映算法在随机数据输入下的运行效率,最接近实际应用中的算法性能。计算平均时间复杂度需要统计输入数据分布以及综合后的数学期望。
空间复杂度
- 空间复杂度的作用类似于时间复杂度,用于衡量算法占用内存空间随数据量增长的趋势。
- 算法运行过程中的相关内存空间可分为输入空间、暂存空间、输出空间。通常情况下,输入空间不纳入空间复杂度计算。暂存空间可分为暂存数据、栈帧空间和指令空间,其中栈帧空间通常仅在递归函数中影响空间复杂度。
- 我们通常只关注最差空间复杂度,即统计算法在最差输入数据和最差运行时刻下的空间复杂度。
- 常见空间复杂度从低到高排列有 O(1)、O(logn)、O(n)、O(n2) 和 O(2n) 等。
2. Q & A¶
Q:尾递归的空间复杂度是 O(1) 吗?
理论上,尾递归函数的空间复杂度可以优化至 O(1) 。不过绝大多数编程语言(例如 Java、Python、C++、Go、C# 等)不支持自动优化尾递归,因此通常认为空间复杂度是 O(n) 。
Q:函数和方法这两个术语的区别是什么?
函数(function)可以被独立执行,所有参数都以显式传递。方法(method)与一个对象关联,被隐式传递给调用它的对象,能够对类的实例中包含的数据进行操作。
下面以几种常见的编程语言为例来说明。
- C 语言是过程式编程语言,没有面向对象的概念,所以只有函数。但我们可以通过创建结构体(struct)来模拟面向对象编程,与结构体相关联的函数就相当于其他编程语言中的方法。
- Java 和 C# 是面向对象的编程语言,代码块(方法)通常作为某个类的一部分。静态方法的行为类似于函数,因为它被绑定在类上,不能访问特定的实例变量。
- C++ 和 Python 既支持过程式编程(函数),也支持面向对象编程(方法)。
Q:图解“常见的空间复杂度类型”反映的是否是占用空间的绝对大小?
不是,该图展示的是空间复杂度,其反映的是增长趋势,而不是占用空间的绝对大小。
假设取 n=8 ,你可能会发现每条曲线的值与函数对应不上。这是因为每条曲线都包含一个常数项,用于将取值范围压缩到一个视觉舒适的范围内。
在实际中,因为我们通常不知道每个方法的“常数项”复杂度是多少,所以一般无法仅凭复杂度来选择 n=8 之下的最优解法。但对于 n=85 就很好选了,这时增长趋势已经占主导了。