Skip to content

Commit c92e238

Browse files
author
HongLI3
committed
增加复杂度分析、数据结构、数组与链表、修改计组笔记
1 parent e4bbbcc commit c92e238

File tree

5 files changed

+410
-2
lines changed

5 files changed

+410
-2
lines changed

docs/复杂度分析.md

+361
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,361 @@
1+
# 复杂度分析
2+
3+
## 算法设计
4+
5+
在算法设计中,我们先后追求以下两个层面的目标。
6+
7+
1. 找到问题解法:算法需要在规定的输入范围内可靠地求得问题的正确解。
8+
2. 寻求最优解法:同一个问题可能存在多种解法,我们希望找到尽可能高效的算法。
9+
10+
也就是说,在能够解决问题的前提下,算法效率已成为衡量算法优劣的主要评价指标,它包括以下两个维度。
11+
12+
1. 时间效率:算法运行时间的长短。
13+
2. 空间效率:算法占用内存空间的大小。
14+
15+
简而言之,我们的目标是设计“既快又省”的数据结构与算法。
16+
17+
## 算法评估
18+
19+
实际测试难以排除测试环境的干扰因素,又非常耗费资源。
20+
21+
因此我们可以考虑仅通过一些计算来评估算法的效率。这种估算方法被称为渐近复杂度分析(asymptotic complexity analysis),简称复杂度分析。
22+
23+
复杂度分析能够体现算法运行所需的时间和空间资源与输入数据大小之间的关系。它描述了随着输入数据大小的增加,算法执行所需时间和空间的增长趋势。这个定义有些拗口,我们可以将其分为三个重点来理解。
24+
25+
1. “时间和空间资源”分别对应时间复杂度(time complexity)和空间复杂度(space complexity)。
26+
2. “随着输入数据大小的增加”意味着复杂度反映了算法运行效率与输入数据体量之间的关系。
27+
3. “时间和空间的增长趋势”表示复杂度分析关注的不是运行时间或占用空间的具体值,而是时间或空间增长的“快慢”。
28+
29+
复杂度分析克服了实际测试方法的弊端,体现在以下几个方面。
30+
31+
1. 它无需实际运行代码,更加绿色节能。
32+
2. 它独立于测试环境,分析结果适用于所有运行平台。
33+
3. 它可以体现不同数据量下的算法效率,尤其是在大数据量下的算法性能。
34+
35+
## 迭代与递归
36+
37+
### 迭代(iteration)
38+
39+
迭代是一种重复执行某个任务的控制结构。在迭代中,程序会在满足一定的条件下重复执行某段代码,直到这个条件不再满足。
40+
41+
#### for 循环
42+
43+
for 循环是最常见的迭代形式之一,适合在预先知道迭代次数时使用。for 循环的代码更加紧凑。
44+
45+
```c
46+
/* for 循环 */
47+
int forLoop(int n) {
48+
int res = 0;
49+
// 循环求和 1, 2, ..., n-1, n
50+
for (int i = 1; i <= n; i++) {
51+
res += i;
52+
}
53+
return res;
54+
} /* 此求和函数的操作数量与输入数据大小n成正比,或者说成“线性关系”。
55+
实际上,时间复杂度描述的就是这个“线性关系”。*/
56+
```
57+
58+
#### while 循环
59+
60+
在 while 循环中,程序每轮都会先检查条件,如果条件为真,则继续执行,否则就结束循环。while 循环比 for 循环的自由度更高,更灵活。适合在不知道迭代次数时使用。
61+
在 while 循环中,我们可以自由地设计条件变量的初始化和更新步骤。
62+
63+
```c
64+
/* while 循环 */
65+
int whileLoop(int n) {
66+
int res = 0;
67+
int i = 1; // 初始化条件变量
68+
// 循环求和 1, 2, ..., n-1, n
69+
while (i <= n) {
70+
res += i;
71+
i++; // 更新条件变量
72+
}
73+
return res;
74+
}
75+
```
76+
77+
#### 嵌套循环
78+
79+
每一次嵌套都是一次“升维”,将会使时间复杂度提高至"平方关系"、“立方关系”、“四次方关系”,以此类推。
80+
81+
### 递归(recursion)
82+
83+
递归(recursion)是一种算法策略,通过函数调用自身来解决问题。它主要包含两个阶段。
84+
85+
1. 递:程序不断深入地调用自身,通常传入更小或更简化的参数,直到达到“终止条件”。
86+
2. 归:触发“终止条件”后,程序从最深层的递归函数开始逐层返回,汇聚每一层的结果。
87+
88+
而从实现的角度看,递归代码主要包含三个要素。
89+
90+
1. 终止条件:用于决定什么时候由“递”转“归”。
91+
2. 递归调用:对应“递”,函数调用自身,通常输入更小或更简化的参数。
92+
3. 返回结果:对应“归”,将当前递归层级的结果返回至上一层。
93+
94+
```c
95+
/* 递归 */
96+
int recur(int n) {
97+
// 终止条件
98+
if (n == 1)
99+
return 1;
100+
// 递:递归调用
101+
int res = recur(n - 1);
102+
// 归:返回结果
103+
return n + res;
104+
}
105+
```
106+
107+
虽然从计算角度看,迭代与递归可以得到相同的结果,但它们代表了两种完全不同的思考和解决问题的范式。
108+
109+
迭代:“自下而上”地解决问题。从最基础的步骤开始,然后不断重复或累加这些步骤,直到任务完成。即f(n) = 1 + 2 + 3 + ... + n
110+
111+
递归:“自上而下”地解决问题。将原问题分解为更小的子问题,这些子问题和原问题具有相同的形式。接下来将子问题继续分解为更小的子问题,直到基本情况时停止(基本情况的解是已知的)。
112+
即f(n) = n + f( n - 1)
113+
114+
#### 调用栈
115+
116+
递归函数每次调用自身时,系统都会为新开启的函数分配内存,以存储局部变量、调用地址和其他信息等。这将导致两方面的结果。
117+
118+
1. 函数的上下文数据都存储在称为“栈帧空间”的内存区域中,直至函数返回后才会被释放。因此,递归通常比迭代更加耗费内存空间。
119+
2. 递归调用函数会产生额外的开销。因此递归通常比循环的时间效率更低。
120+
121+
在触发终止条件前,同时存在n个未返回的递归函数,我们称之为递归深度为 n。在实际中,编程语言允许的递归深度通常是有限的,过深的递归可能导致栈溢出错误。
122+
123+
#### 尾递归
124+
125+
如果函数在返回前的最后一步才进行递归调用,则该函数可以被编译器或解释器优化,使其在空间效率上与迭代相当。这种情况被称为尾递归(tail recursion)。
126+
127+
1. 普通递归:当函数返回到上一层级的函数后,需要继续执行代码,因此系统需要保存上一层调用的上下文。
128+
2. 尾递归:递归调用是函数返回前的最后一个操作,这意味着函数返回到上一层级后,无须继续执行其他操作,因此系统无须保存上一层函数的上下文。
129+
130+
```c
131+
/* 尾递归 */
132+
int tailRecur(int n, int res) {
133+
// 终止条件
134+
if (n == 0)
135+
return res;
136+
// 尾递归调用
137+
return tailRecur(n - 1, res + n);
138+
}
139+
```
140+
141+
1. 普通递归:求和操作是在“归”的过程中执行的,每层返回后都要再执行一次求和操作。
142+
2. 尾递归:求和操作是在“递”的过程中执行的,“归”的过程只需层层返回。
143+
144+
此外应有图
145+
146+
#### 递归树
147+
148+
当处理与“分治”相关的算法问题时,递归往往比迭代的思路更加直观、代码更加易读。
149+
以“斐波那契数列”为例。给定一个斐波那契数列0,1,1,2,3,5,...,求该数列的第n个数字。
150+
151+
```c
152+
/* 斐波那契数列:递归 */
153+
int fib(int n) {
154+
// 终止条件 f(1) = 0, f(2) = 1 ||为“或”逻辑
155+
if (n == 1 || n == 2)
156+
return n - 1;
157+
// 递归调用 f(n) = f(n-1) + f(n-2)
158+
int res = fib(n - 1) + fib(n - 2);
159+
// 返回结果 f(n)
160+
return res;
161+
}
162+
```
163+
164+
观察以上代码,我们在函数内递归调用了两个函数,这意味着从一个调用产生了两个调用分支这样不断递归调用下去,最终将产生一棵层数为 n 的递归树(recursion tree)。
165+
166+
此处应有图。
167+
168+
### 两者对比
169+
170+
| | 迭代 | 递归 |
171+
|------|---------------------|--------------------------------|
172+
| 实现方式 | 循环结构 | 函数调用自身 |
173+
| 时间效率 | 效率通常较高,无函数调用开销 | 每次函数调用都会产生开销 |
174+
| 内存使用 | 通常使用固定大小的内存空间 | 累积函数调用可能使用大量的栈帧空间 |
175+
| 适用问题 | 适用于简单循环任务,代码直观、可读性好 | 适用于子问题分解,如树、图、分治、回溯等,代码结构简洁、清晰 |
176+
177+
### 使用迭代模拟递归
178+
179+
尽管迭代和递归在很多情况下可以互相转化,但不一定值得这样做,有以下两点原因。
180+
181+
1. 转化后的代码可能更加难以理解,可读性更差。
182+
2. 对于某些复杂问题,模拟系统调用栈的行为可能非常困难。
183+
184+
```c
185+
/* 使用迭代模拟递归 */
186+
int forLoopRecur(int n) {
187+
int stack[1000]; // 借助一个大数组来模拟栈
188+
int top = -1; // 栈顶索引
189+
int res = 0;
190+
// 递:递归调用
191+
for (int i = n; i > 0; i--) {
192+
// 通过“入栈操作”模拟“递”
193+
stack[1 + top++] = i;
194+
}
195+
// 归:返回结果
196+
while (top >= 0) {
197+
// 通过“出栈操作”模拟“归”
198+
res += stack[top--];
199+
}
200+
// res = 1+2+3+...+n
201+
return res;
202+
}
203+
```
204+
## 时间复杂度
205+
206+
### 统计随时间而增长的趋势
207+
208+
时间复杂度分析统计的不是算法运行时间,而是算法运行时间随着数据量变大时的增长趋势。
209+
210+
```c
211+
// 算法 A 的时间复杂度:常数阶 算法 A 只有1个打印操作,算法运行时间不随着 n 增大而增长。我们称此算法的时间复杂度为“常数阶”。
212+
void algorithm_A(int n) {
213+
printf("%d", 0);
214+
}
215+
// 算法 B 的时间复杂度:线性阶 算法 B 中的打印操作需要循环n次,算法运行时间随着 n 增大呈线性增长。此算法的时间复杂度被称为“线性阶”。
216+
void algorithm_B(int n) {
217+
for (int i = 0; i < n; i++) {
218+
printf("%d", 0);
219+
}
220+
}
221+
// 算法 C 的时间复杂度:常数阶 算法 C 中的打印操作需要循环1000000次,虽然运行时间很长,但它与输入数据大小n无关。因此 C 的时间复杂度和 A 相同,仍为“常数阶”。
222+
void algorithm_C(int n) {
223+
for (int i = 0; i < 1000000; i++) {
224+
printf("%d", 0);
225+
}
226+
}
227+
```
228+
229+
时间复杂度分析特点:
230+
231+
1. 时间复杂度能够有效评估算法效率。例如,算法 B 的运行时间呈线性增长,在 n > 1 时比算法 A 更慢,在 n > 1000000 时比算法 C 更慢。
232+
事实上,只要输入数据大小 n 足够大,复杂度为“常数阶”的算法一定优于“线性阶”的算法,这正是时间增长趋势的含义。
233+
2. 时间复杂度也存在一定的局限性。例如,尽管算法 A 和 C 的时间复杂度相同,但实际运行时间差别很大。同样,尽管算法 B 的时间复杂度比 C 高,但在输入数据大小 n 较小时,算法 B 明显优于算法 C 。
234+
对于此类情况,我们时常难以仅凭时间复杂度判断算法效率的高低。
235+
236+
当然,尽管存在上述问题,复杂度分析仍然是评判算法效率最有效且常用的方法。
237+
238+
### 渐近上界函数(数学)
239+
240+
给定一个输入大小为 n 的函数:
241+
242+
```c
243+
void algorithm(int n) {
244+
int a = 1; // +1
245+
a = a + 1; // +1
246+
a = a * 2; // +1
247+
// 循环 n 次
248+
for (int i = 0; i < n; i++) { // +1(每轮都执行 i ++)
249+
printf("%d", 0); // +1
250+
}
251+
}
252+
```
253+
设算法的操作数量是一个关于输入数据大小 n 的函数,记为T(n) ,则以上函数的操作数量为:T(n) = 3 + 2n
254+
255+
若存在正实数 a 和实数n_0,使得对于所有的 n > n_0,均有 T(n) <= c * f(n),则可认为 f(n) 给出了 T(n) 的一个渐近上界,记为 T(n) = O(f(n)) 。
256+
257+
寻找上界函数技巧:忽略常数项、忽略系数、只取最高次的项。
258+
259+
### 常见时间复杂度类型
260+
当 n 趋于无穷时,常数阶 < 对数阶 < 线性阶 < 线性对数阶 < 平方阶 < 指数阶 < 阶乘阶
261+
262+
1. 常数阶 O(1) 操作数与输入量无关
263+
2. 线性阶 O(n) 通常出现在单层循环中
264+
3. 平方阶 O(n^2) 通常出现在嵌套循环中
265+
4. 指数阶 O(2^n) 常出现于递归函数中。指数阶增长非常迅速,在穷举法(暴力搜索、回溯等)中比较常见。对于数据规模较大的问题,指数阶是不可接受的,通常需要使用动态规划或贪心算法等来解决。
266+
5. 对数阶 O(log n) 对数阶常出现于递归函数、基于分治策略的算法中。体现了“一分为多”和“化繁为简”的算法思想。它增长缓慢,是仅次于常数阶的理想的时间复杂度。
267+
由于换底公式,底数可以在不影响复杂度的前提下转换。因此我们通常会省略底数。
268+
6. 线性对数阶 O(n log n) 常出现于嵌套循环中,两层循环的时间复杂度分别为 O(log n) 和 O(n)。主流排序算法的时间复杂度通常为 O(n log n) ,例如快速排序、归并排序、堆排序等。
269+
7. 阶乘阶 O(n!) 当 n>=4 时恒有 n! > 2^n ,所以阶乘阶比指数阶增长得更快,在 n 较大时也是不可接受的。
270+
271+
### 最差、最佳、平均时间复杂度
272+
273+
算法的时间效率往往不是固定的,而是与输入数据的分布有关。假设输入一个长度为 n 的数组 nums ,其中 nums 由从 1 至 n 的数字组成,每个数字只出现一次;但元素顺序是随机打乱的,任务目标是返回元素 1 的索引。我们可以得出以下结论。
274+
275+
1. 当 nums = [?, ?, ..., 1] ,即当末尾元素是 1 时,需要完整遍历数组,达到最差时间复杂度O(n)。“最差时间复杂度”对应函数渐近上界,使用大O记号表示。
276+
2. 当 nums = [1, ?, ?, ...] ,即当首个元素为 1 时,无论数组多长都不需要继续遍历,达到最佳时间复杂度Ω(1) 。“最佳时间复杂度”对应函数渐近下界,用Ω记号表示.
277+
278+
最差时间复杂度更为实用,因为它给出了一个效率安全值,让我们可以放心地使用算法。
279+
最差时间复杂度和最佳时间复杂度只出现于“特殊的数据分布”,这些情况的出现概率可能很小,并不能真实地反映算法运行效率。
280+
281+
相比之下,平均时间复杂度可以体现算法在随机输入数据下的运行效率,用 Θ 记号来表示。
282+
283+
对于部分算法,我们可以简单地推算出随机数据分布下的平均情况。比如上述示例,由于输入数组是被打乱的,因此元素 出现在任意索引的概率都是相等的,那么算法的平均循环次数就是数组长度的一半 n/2 ,平均时间复杂度为 Θ(n/2)= Θ(n)。
284+
但对于较为复杂的算法,计算平均时间复杂度往往比较困难,因为很难分析出在数据分布下的整体数学期望。在这种情况下,我们通常使用最差时间复杂度作为算法效率的评判标准。
285+
286+
## 空间复杂度
287+
288+
空间复杂度(space complexity)用于衡量算法占用内存空间随着数据量变大时的增长趋势。这个概念与时间复杂度非常类似,只需将“运行时间”替换为“占用内存空间”。
289+
290+
### 算法相关空间
291+
292+
算法在运行过程中使用的内存空间主要包括以下几种。
293+
294+
- 输入空间:用于存储算法的输入数据。 一般情况下,空间复杂度的统计范围是“暂存空间”加上“输出空间”。
295+
- 暂存空间:用于存储算法在运行过程中的变量、对象、函数上下文等数据。 可细分为以下三个部分。
296+
- 暂存数据:用于保存算法运行过程中的各种常量、变量、对象等。
297+
- 栈帧空间:用于保存调用函数的上下文数据。系统在每次调用函数时都会在栈顶部创建一个栈帧,函数返回后,栈帧空间会被释放。
298+
- 指令空间:用于保存编译后的程序指令,在实际统计中通常忽略不计。
299+
- 输出空间:用于存储算法的输出数据。
300+
301+
### 只关注最差空间复杂度
302+
303+
与时间复杂度不同的是,我们通常只关注最差空间复杂度。这是因为内存空间是一项硬性要求,我们必须确保在所有输入数据下都有足够的内存空间预留。
304+
305+
```c
306+
void algorithm(int n) {
307+
int a = 0; // O( 1 )
308+
int b[10000]; // O( 1 )
309+
if (n > 10)
310+
int nums[n] = {0}; // O( n )
311+
}
312+
```
313+
314+
观察以上代码,发现最差空间复杂度中的“最差”有两层含义。
315+
316+
1. 以最差输入数据为准:当 n < 10 时,空间复杂度为 O(1) ;但当 时,初始化的数组 nums 占用 O(n) 空间,因此最差空间复杂度为 O(n)。
317+
2. 以算法运行中的峰值内存为准:例如,程序在执行最后一行之前,占用 O(1) 空间;当初始化数组 nums 时,程序占用 O(n) 空间,因此最差空间复杂度为 O(n)。
318+
319+
在递归函数中,需要注意统计栈帧空间。观察以下代码:
320+
321+
```C
322+
int func() {
323+
// 执行某些操作
324+
return 0;
325+
}
326+
/* 函数 loop() 和 recur() 的时间复杂度都为 O(n),但空间复杂度不同。*/
327+
/* 循环的空间复杂度为 O(1) */
328+
void loop(int n) {
329+
for (int i = 0; i < n; i++) {
330+
func();
331+
}
332+
}
333+
/* 递归的空间复杂度为 O(n) */
334+
void recur(int n) {
335+
if (n == 1) return;
336+
return recur(n - 1);
337+
}
338+
// 总计 O(n)?
339+
```
340+
341+
### 常见空间复杂度类型
342+
343+
1. 常数阶 O(1) 常数阶常见于数量与输入数据大小 n 无关的常量、变量、对象。需要注意的是,在循环中初始化变量或调用函数而占用的内存,在进入下一循环后就会被释放,因此不会累积占用空间,空间复杂度仍为 O(1)
344+
2. 线性阶 O(n) 线性阶常见于元素数量与 n 成正比的数组、链表、栈、队列等
345+
3. 平方阶 O(n^2) 平方阶常见于矩阵和图
346+
4. 指数阶 O(2^n) 指数阶常见于二叉树。层数为 n 的“满二叉树”的节点数量为 (2^n) -1,占用 O(2^n) 空间
347+
5. 对数阶 O(log n) 对数阶常见于分治算法。例如归并排序,输入长度为 n 的数组,每轮递归将数组从中点处划分为两半,形成高度为 log n 的递归树,使用 O(log n) 栈帧空间。
348+
再例如将数字转化为字符串,输入一个正整数 n ,它的位数为 (log_10 n) + 1 ,即对应字符串长度为 (log_10 n) + 1 ,因此空间复杂度为 O(log n)。
349+
350+
351+
## 权衡时间与空间
352+
353+
降低时间复杂度通常需要以提升空间复杂度为代价,反之亦然。我们将牺牲内存空间来提升算法运行速度的思路称为“以空间换时间”;反之,则称为“以时间换空间”。
354+
355+
选择哪种思路取决于我们更看重哪个方面。在大多数情况下,时间比空间更宝贵,因此“以空间换时间”通常是更常用的策略。当然,在数据量很大的情况下,控制空间复杂度也非常重要。
356+
357+
## QA
358+
359+
尾递归的空间复杂度是 O(1) 吗?理论上,尾递归函数的空间复杂度可以优化至 O(1) 。不过绝大多数编程语言(例如 Java、Python、C++、Go、C# 等)不支持自动优化尾递归,因此通常认为空间复杂度是 O(n)
360+
361+
函数和方法这两个术语的区别是什么? 函数(function)可以被独立执行,所有参数都以显式传递。在一些语言中函数是一等公民,可以赋值,可以作为参数传递,可以作为返回值。方法(method)与一个对象关联,被隐式传递给调用它的对象,能够对类的实例中包含的数据进行操作。

0 commit comments

Comments
 (0)