算法与数据结构——笔记总结-复杂度(一)(基于c++代码演示)

#再研算法-有感!!!

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();
}

在这种情况下,函数的操作数量与 n^{2}成正比,或者说算法运行时间和输入数据大小 𝑛 成“平方关系”。 我们可以继续添加嵌套循环,每一次嵌套都是一次“升维”,将会使时间复杂度提高至“立方关系”、“四次方 关系”、以此类推。

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;
}
}
}

以下公式展示了使用上述技巧前后的统计结果,两者推出的时间复杂度都为 𝑂(n^{2}) 。

𝑇(𝑛) = 2𝑛(𝑛 + 1) + (5𝑛 + 1) + 2                    完整统计 (‑.‑|||)

                               𝑇(𝑛) = 2n^{2}+ 7𝑛 + 3

𝑇(𝑛) = n^{2} + 𝑛                                                 偷懒统计 (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 算法》

  • 25
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值