#再研算法-有感!!!
1 复杂度:
1、时间复杂度 (time complexity)和 空间复杂度 (space complexity)
“随着输入数据大小的增加”意味着复杂度反映了算法运行效率与输入数据体量之间的关系。
“时间和空间的增长趋势”表示复杂度分析关注的不是运行时间或占用空间的具体值,而是时间或空间 增长的“快慢”。
1、复杂度分析克服了实际测试方法的弊端
1)它独立于测试环境,分析结果适用于所有运行平台。(比如在某台计算机中,算法 A 的 运行时间比算法 B 短;但在另一台配置不同的计算机中,我们可能得到相反的测试结果。)
2)它可以体现不同数据量下的算法效率,尤其是在大数据量下的算法性能。
综上所述,深入学习数据结构与算法之前,先对复杂度分析建立初步的了解,具体算法复杂度融入在后续章节中详细介绍。
2 迭代与递归
1、迭代
for循环
for 循环是最常见的迭代形式之一,适合预先知道迭代次数时使用。 以下函数基于 for 循环实现了求和 1 + 2 + ⋯ + 𝑛 ,求和结果使用变量 res 记录。
/* for 循环 */
int forLoop(int n) {
int res = 0;
// 循环求和 1, 2, ..., n-1, n
for (int i = 1; i <= n; ++i) {
res += i;
}
return res;
}
此求和函数的操作数量与输入数据大小 𝑛 成正比,或者说成“线性关系”。实际上,时间复杂度描述的就是 这个“线性关系”。
while
与 for 循环类似,while 循环也是一种实现迭代的方法。在 while 循环中,程序每轮都会先检查条件,如果条 件为真则继续执行,否则就结束循环。下面,我们用 while 循环来实现求和 1 + 2 + ⋯ + 𝑛 。
/* while 循环 */
int whileLoop(int n) {
int res = 0;
int i = 1; // 初始化条件变量
// 循环求和 1, 2, ..., n-1, n
while (i <= n) {
res += i;
i++; // 更新条件变量
}
return res;
}
while比 for 循环的自由度更高。例如在以下代码中,条件变量 𝑖 每轮进行了两次更新,实现求和 1 + 4 + ⋯ + 2𝑛 。这种情况就不太方便用 for 循环实现。
/* while 循环(两次更新) */
int whileLoopII(int n) {
int res = 0;
int i = 1; // 初始化条件变量
// 循环求和 1, 4, ...
while (i <= n) {
res += i;
// 更新条件变量
i++;
i *= 2;
}
return res;
}
总的来说,for 循环的代码更加紧凑,while 循环更加灵活,两者都可以实现迭代结构。选择使用哪一个应该根据特定问题的需求来决定。
嵌套循环
在一个循环结构内嵌套另一个循环结构,以 for 循环为例:
/* 双层 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();
}
在这种情况下,函数的操作数量与 成正比,或者说算法运行时间和输入数据大小 𝑛 成“平方关系”。 我们可以继续添加嵌套循环,每一次嵌套都是一次“升维”,将会使时间复杂度提高至“立方关系”、“四次方 关系”、以此类推。
2 递归
递归 (recursion)是一种算法策略,通过函数调用自身来解决问题。它主要包含两个阶段。
1. 递:程序不断深入地调用自身,通常传入更小或更简化的参数,直到达到“终止条件”。
2. 归:触发“终止条件”后,程序从最深层的递归函数开始逐层返回,汇聚每一层的结果。
而从实现的角度看,递归代码主要包含三个要素。
1. 终止条件:用于决定什么时候由“递”转“归”。
2. 递归调用:对应“递”,函数调用自身,通常输入更小或更简化的参数。
3. 返回结果:对应“归”,将当前递归层级的结果返回至上一层。
普通递归
观察以下代码,我们只需调用函数 recur(n) ,就可以完成 1 + 2 + ⋯ + 𝑛 的计算:
/* 递归 */
int recur(int n) {
// 终止条件
if (n == 1)
return 1;
// 递:递归调用
int res = recur(n - 1);
// 归:返回结果
return n + res;
}
来张图更直观:
以上述的求和函数为例,设问题 𝑓(𝑛) = 1 + 2 + ⋯ + 𝑛 。 ‧
迭代:在循环中模拟求和过程,从 1 遍历到 𝑛 ,每轮执行求和操作,即可求得 𝑓(𝑛) 。
递归:将问题分解为子问题 𝑓(𝑛) = 𝑛+𝑓(𝑛−1) ,不断(递归地)分解下去,直至基本情况 𝑓(0) = 0 时终止。
调用栈
递归函数每次调用自身时,系统都会为新开启的函数分配内存,以存储局部变量、调用地址和其他信息等。 这将导致两方面的结果。函数的上下文数据都存储在称为“栈帧空间”的内存区域中,直至函数返回后才会被释放。因此,递归通常比迭代更加耗费内存空间。
递归调用函数会产生额外的开销。因此,递归通常比循环的时间效率更低。
在实际中,编程语言允许的递归深度通常是有限的,过深的递归可能导致栈溢出报错。
尾递归
尾递归:递归调用是函数返回前的最后一个操作,这意味着函数返回到上一层级后,无需继续执行其他操作,因此系统无需保存上一层函数的上下文。 以计算 1 + 2 + ⋯ + 𝑛 为例,我们可以将结果变量 res 设为函数参数,从而实现尾递归。
/* 尾递归 */
int tailRecur(int n, int res) {
// 终止条件
if (n == 0)
return res;
// 尾递归调用
return tailRecur(n - 1, res + n);
}
如图:
尾递归:求和操作是在“递”的过程中执行的,“归”的过程只需层层返回。
递归树
当处理与“分治”相关的算法问题时,递归往往比迭代的思路更加直观、代码更加易读。
以“斐波那契数列” 为例。 给定一个斐波那契数列 0, 1, 1, 2, 3, 5, 8, 13, … ,求该数列的第 𝑛 个数字。 设斐波那契数列的第 𝑛 个数字为 𝑓(𝑛) ,易得两个结论。数列的前两个数字为 𝑓(1) = 0 和 𝑓(2) = 1 。数列中的每个数字是前两个数字的和,即 𝑓(𝑛) = 𝑓(𝑛 − 1) + 𝑓(𝑛 − 2) 。 按照递推关系进行递归调用,将前两个数字作为终止条件,便可写出递归代码。调用 fib(n) 即可得到斐波那 契数列的第 𝑛 个数字。
/* 斐波那契数列:递归 */
int fib(int n) {
// 终止条件 f(1) = 0, f(2) = 1
if (n == 1 || n == 2)
return n - 1;
// 递归调用 f(n) = f(n-1) + f(n-2)
int res = fib(n - 1) + fib(n - 2);
// 返回结果 f(n)
return res;
}
观察以上代码,我们在函数内递归调用了两个函数,这意味着从一个调用产生了两个调用分支。如图 2‑6 所 示,这样不断递归调用下去,最终将产生一个层数为 𝑛 的「递归树 recursion tree」。
2 时间复杂度
时间复杂度分析统计的不是算法运行时间,而是算法运行时间随着数据量变大时的增长趋势。
时间复杂度分析统计的不是算法运行时间,而是算法运行时间随着数据量变大时的增长趋势。
“时间增长趋势”这个概念比较抽象,我们通过一个例子来加以理解。假设输入数据大小为 𝑛 ,给定三个算法 函数 A、B 和 C :
// 算法 A 的时间复杂度:常数阶
void algorithm_A(int n) {
cout << 0 << endl;
}
// 算法 B 的时间复杂度:线性阶
void algorithm_B(int n) {
for (int i = 0; i < n; i++) {
cout << 0 << endl;
}
}
// 算法 C 的时间复杂度:常数阶
void algorithm_C(int n) {
for (int i = 0; i < 1000000; i++) {
cout << 0 << endl;
}
}
算法 A 只有 1 个打印操作,算法运行时间不随着 𝑛 增大而增长。我们称此算法的时间复杂度为“常数 阶”。
算法 B 中的打印操作需要循环 𝑛 次,算法运行时间随着 𝑛 增大呈线性增长。此算法的时间复杂度被称 为“线性阶”。
算法 C 中的打印操作需要循环 1000000 次,虽然运行时间很长,但它与输入数据大小 𝑛 无关。因此 C 的时间复杂度和 A 相同,仍为“常数阶”。
计算方法:
针对代码,逐行从上到下计算即可。然而,由于上述 𝑐 ⋅ 𝑓(𝑛) 中的常数项 𝑐 可以取任意大小,因此操作数量 𝑇(𝑛) 中的各种系数、常数项都可以被忽略。根据此原则,可以总结出以下计数简化技巧。
1. 忽略 𝑇(𝑛) 中的常数项。因为它们都与 𝑛 无关,所以对时间复杂度不产生影响。
2. 省略所有系数。例如,循环 2𝑛 次、5𝑛 + 1 次等,都可以简化记为 𝑛 次,因为 𝑛 前面的系数对时间复 杂度没有影响。
3. 循环嵌套时使用乘法。总操作数量等于外层循环和内层循环操作数量之积,每一层循环依然可以分别 套用第 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;
}
}
}
以下公式展示了使用上述技巧前后的统计结果,两者推出的时间复杂度都为 𝑂() 。
𝑇(𝑛) = 2𝑛(𝑛 + 1) + (5𝑛 + 1) + 2 完整统计 (‑.‑|||)
𝑇(𝑛) = 2+ 7𝑛 + 3
𝑇(𝑛) = + 𝑛 偷懒统计 (o.O)
常见类型
设输入数据大小为 𝑛 ,常见的时间复杂度类型如图所示(按照从低到高的顺序排列)。
𝑂(1) < 𝑂(log 𝑛) < 𝑂(𝑛) < 𝑂(𝑛 log 𝑛) < 𝑂(𝑛2 ) < 𝑂(2𝑛) < 𝑂(𝑛!)
常数阶 < 对数阶 < 线性阶 < 线性对数阶 < 平方阶 < 指数阶 < 阶乘阶
3 空间复杂度
空间复杂度(space complexity)用于衡量算法占用内存空间随着数据量变大时的增长趋势。这个概念与时 间复杂度非常类似,只需将“运行时间”替换为“占用内存空间”。
算法相关空间
算法在运行过程中使用的内存空间主要包括以下几种。
输入空间:用于存储算法的输入数据。
暂存空间:用于存储算法在运行过程中的变量、对象、函数上下文等数据。
输出空间:用于存储算法的输出数据。
一般情况下,空间复杂度的统计范围是“暂存空间”加上“输出空间”。 暂存空间可以进一步划分为三个部分。
暂存数据:用于保存算法运行过程中的各种常量、变量、对象等。
栈帧空间:用于保存调用函数的上下文数据。系统在每次调用函数时都会在栈顶部创建一个栈帧,函数 返回后,栈帧空间会被释放。
指令空间:用于保存编译后的程序指令,在实际统计中通常忽略不计。
在分析一段程序的
空间复杂度时,我们通常统计暂存数据、栈帧空间和输出数据三部分。
推算方法
空间复杂度的推算方法与时间复杂度大致相同,只需将统计对象从“操作数量”转为“使用空间大小”。 而与时间复杂度不同的是,我们通常只关注最差空间复杂度。这是因为内存空间是一项硬性要求,我们必须 确保在所有输入数据下都有足够的内存空间预留。
观察以下代码,最差空间复杂度中的“最差”有两层含义。
void algorithm(int n) {
int a = 0; // O(1)
vector<int> b(10000); // O(1)
if (n > 10)
vector<int> nums(n); // O(n)
}
1. 以最差输入数据为准:当 𝑛 < 10 时,空间复杂度为 𝑂(1) ;但当 𝑛 > 10 时,初始化的数组 nums 占 用 𝑂(𝑛) 空间;因此最差空间复杂度为 𝑂(𝑛) 。
2. 以算法运行中的峰值内存为准:例如,程序在执行最后一行之前,占用 𝑂(1) 空间;当初始化数组 nums 时,程序占用 𝑂(𝑛) 空间;因此最差空间复杂度为 𝑂(𝑛) 。
在递归函数中,需要注意统计栈帧空间。例如在以下代码中:
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);
}
1. 函数 loop() 在循环中调用了 𝑛 次 function() ,每轮中的 function() 都返回并释放了栈帧空间,因此 空间复杂度仍为 𝑂(1) 。
2. 递归函数 recur() 在运行过程中会同时存在 𝑛 个未返回的 recur() ,从而占用 𝑂(𝑛) 的栈帧空间。
常见类型
设输入数据大小为 𝑛 ,图 2‑16 展示了常见的空间复杂度类型(从低到高排列)。
𝑂(1) < 𝑂(log 𝑛) < 𝑂(𝑛) < 𝑂(𝑛2 ) < 𝑂(2𝑛)
常数阶 < 对数阶 < 线性阶 < 平方阶 < 指数阶
参考《Hello 算法》