数据结构笔记--算法基础
系列文章目录:
算法分析
算法满足的五个基本特性:
- 输入:算法有零个或多个输入。
- 输出:算法有一个或多个输出。
- 确定性:算法的每一步骤都有确定的含义,不会出现二义性。
- 可行性:算法的每一步骤都是可行的,也就是说,每一步都能够通过执行有限次数完成。
- 有穷性:算法在执行有限步骤后,会自动结束而不会出现无限循环。
评价算法优劣的四个标准:
- 正确性:算法能够正确地解决实际问题。
- 可读性:算法应具有良好的可读性,以便于理解和维护。
- 健壮性:算法应能够对输入的非法数据进行检查,并作出相应的处理,而不是产生错误的结果。
- 效率与低存储量需求:算法应尽量减少计算时间和占用存储空间。
时间复杂度
Donald Ervin Knuth said: “Programs must be written for people to read, and only incidentally for machines to execute.”
引入
程序执行的总时间与程序中语句的执行次数成正比,算法的时间复杂度就是用来分析程序中语句的执行次数(频数)的。
例如以下这个程序:
1 |
|
那么这个代码的每条语句执行的次数为:
语句 | 频数 |
---|---|
for(int i=1; i<=9; i++) |
10 |
for(int j=1; j<=i; j++) |
45 |
printf("%d*%d=%d\t", j, i, i*j); |
45 |
printf("\n"); |
9 |
加起来一共是10+45+45+9=99
次。
但这个99次并不是时间复杂度,因为时间复杂度只关注频数最高的一条语句,也就是printf("%d*%d=%d\t", j, i, i*j);
这条语句,它的频数为45,所以时间复杂度为O(1)
。
好了,我们现在来真正地了解一下时间复杂度。
定义
引入渐近符号
渐进符号是函数的阶的规范描述。简单来说,渐进符号忽略了一个函数中增长较慢的部分以及各项的系数(在时间复杂度相关分析中,系数一般被称作「常数」),而保留了可以用来表明该函数增长趋势的重要部分。
大 $\Theta$ 符号
大 $\Theta$ 符号表示一个函数的上下界,即一个函数的「渐进界」。例如,如果 $f(n) = \Theta(g(n))$,那么 $f(n)$ 的增长速度与 $g(n)$ 的增长速度相同。
也就是说,我们可以找到两个常数 $c_1$ 和 $c_2$,使得对于所有足够大的 $n$,有 $c_1 \cdot g(n) \leq f(n) \leq c_2 \cdot g(n)$。
例如: $3n^2+n-1=\Theta(n^2)$ 这里的 $c_1=1$,$c_2=3$,$n$ 为任意大的正整数。
大 O 符号
$\Theta$ 符号同时给了我们一个函数的上下界,如果只知道一个函数的渐进上界而不知道其渐进下界,可以使用 O 符号。$f(n)=O(g(n))$,当且仅当 $\exists c,n_0$,使得 $\forall n \ge n_0,0\le f(n)\le c\cdot g(n)$ 。
研究时间复杂度时通常会使用 O 符号,因为我们关注的通常是程序用时的上界,而不关心其用时的下界。
需要注意的是,这里的「上界」和「下界」是对于函数的变化趋势而言的,而不是对算法而言的。算法用时的上界对应的是「最坏时间复杂度」而非大 O 记号。所以,使用 $\Theta$ 记号表示最坏时间复杂度是完全可行的,甚至可以说 $\Theta$ 比 O 更加精确,而使用 O 记号的主要原因,一是我们有时只能证明时间复杂度的上界而无法证明其下界(这种情况一般出现在较为复杂的算法以及复杂度分析),二是 O 在电脑上输入更方便一些。
大 Ω 符号
同样的,我们使用 $\Omega$ 符号来描述一个函数的渐进下界。$f(n)=\Omega(g(n))$,当且仅当 $\exists c,n_0$,使得 $\forall n \ge n_0,0\le c\cdot g(n)\le f(n)$。
小 o 符号
如果说 O 符号相当于小于等于号,那么 o 符号就相当于小于号。
小 o 符号大量应用于数学分析中,函数在某点处的泰勒展开式拥有皮亚诺余项,使用小 o 符号表示严格小于,从而进行等价无穷小的渐进分析。
$f(n)=o(g(n))$,当且仅当对于任意给定的正数 $c$,$\exists n_0$,使得 $\forall n \ge n_0,0\le f(n)< c\cdot g(n)$。
小 ω 符号
小 ω 符号相当于大于等于号。
$f(n)=\omega(g(n))$,当且仅当对于任意给定的正数 $c$,$\exists n_0$,使得 $\forall n \ge n_0,c\cdot g(n)< f(n)$。
常见性质
- $f(n) = \Theta(g(n))\iff f(n)=O(g(n))\land f(n)=\Omega(g(n))$
- $f_1(n) + f_2(n) = O(\max(f_1(n), f_2(n)))$
- $f_1(n) \times f_2(n) = O(f_1(n) \times f_2(n))$
- $\forall a \neq 1, \log_a{n} = O(\log_2 n)$。由换底公式可以得知,任何对数函数无论底数为何,都具有相同的增长率,因此渐进时间复杂度中对数的底数一般省略不写。
定义部分转载至 OI Wiki 复杂度简介
计算方法
时间复杂度有三种: 最好时间复杂度、最坏时间复杂度、平均时间复杂度。我们一般只考虑最坏时间复杂度(即大O)。
常数时间复杂度
常数时间复杂度,即 $O(1)$,表示算法的执行时间与输入数据的大小无关,无论输入数据的大小如何,算法的执行时间都是固定的。
例如,以下代码的时间复杂度为 $O(1)$:
1 | for(int i = 0; i < 10; i++) { |
这段代码中,无论输入数据的大小如何,printf("%d+%d+%d=%d\n",i,j,k,i+j+k);
这条语句都会被执行 1000 次,因此时间复杂度为 $O(1)$。
线性时间复杂度
线性时间复杂度,即 $O(n)$,表示算法的执行时间与输入数据的大小成正比。
例如,以下代码的时间复杂度为 $O(n)$:
1 | while(n) |
这段代码中,printf("%d\n",n);
这条语句会执行 $\frac{n}{2}$ 次,因此时间复杂度为 $O(n)$。
对数时间复杂度
对数时间复杂度,即 $O(\log n)$,表示算法的执行时间与输入数据的大小成对数关系。
例如,以下代码的时间复杂度为 $O(\log n)$:
1 | while(n) |
这段代码中,printf("%d\n",n);
这条语句会执行 $\log_2 n$ 次,因此时间复杂度为 $O(\log n)$。
线性对数时间复杂度
线性对数时间复杂度,即 $O(n \log n)$,表示算法的执行时间与输入数据的大小成线性对数关系。
例如,以下代码的时间复杂度为 $O(n \log n)$:
1 | for (int i = 1; i <= n; i++) |
这段代码中,printf("i = %d, j = %d\n", i, j);
这条语句会执行 $n \log_2 n$ 次,因此时间复杂度为 $O(n \log n)$。
平方时间复杂度
平方时间复杂度,即 $O(n^2)$,表示算法的执行时间与输入数据的大小成平方关系。
例如,以下代码的时间复杂度为 $O(n^2)$:
1 | for (int i = 1; i <= n; i++) |
很显然,这段代码中,printf("i = %d, j = %d\n", i, j);
时间复杂度为 $O(n^2)$。
立方时间复杂度
立方时间复杂度,即 $O(n^3)$,表示算法的执行时间与输入数据的大小成立方关系。
例如,以下代码的时间复杂度为 $O(n^3)$:
1 | for (int i = 1; i <= n; i++) |
很显然,这段代码中,printf("i = %d, j = %d, k = %d\n", i, j, k);
时间复杂度为 $O(n^3)$。
指数时间复杂度
指数时间复杂度,即 $O(2^n)$,表示算法的执行时间与输入数据的大小成指数关系。
例如,以下代码的时间复杂度为 $O(2^n)$:
1 | int count = 0; |
阶乘时间复杂度
阶乘时间复杂度,即 $O(n!)$,表示算法的执行时间与输入数据的大小成阶乘关系。
例如,以下代码的时间复杂度为 $O(n!)$:
1 | int count = 0; |
主定理 Master Theorem
我们可以使用 Master Theorem 来快速求得关于递归算法的复杂度。 Master Theorem 递推关系式如下
- Case 1: 如果 $f(n)=O(n^{log_b a - \epsilon})$ ,其中 $\epsilon > 0$ ,那么 $T(n)=\Theta(n^{log_b a})$ 。
- Case 2: 如果 $f(n)=\Theta(n^{log_b a}log^{k}n)$ ,其中 $k \geq 0$ ,那么 $T(n)=\Theta(n^{log_b a}log^{k+1}n)$ 。
- Case 3: 如果 $f(n)=\Omega(n^{log_b a + \epsilon})$ ,并且 $af(\frac{n}{b}) \leq cf(n)$ ,其中 $c < 1$ ,那么 $T(n)=\Theta(f(n))$ 。
下面举几个例子来说明主定理如何使用。
例1: $T(n) = 2T(\frac{n}{2}) + \sqrt n$
例2: $ T(n) = T(\frac{n}{10}) + n \log_2 \sqrt{n} $
课后练习
求以下代码的时间复杂度:
Question A
1 | int fun(int n) |
Question B
1 | int sum=0; |
Question C
1 | for(int i=1; i<=n; i++) |
Question D
1 | int f(int n) |
Question E
1 | k=0; |
空间复杂度
一个算法或程序的空间复杂度定性地描述该算法或程序运行所需要的存储空间大小。空间复杂度是相应计算问题的输入值的长度的函数,它表示一个算法完全执行所需要的存储空间大小。
和时间复杂度类似,空间复杂度也只考虑最大(最坏)的情况,通常也使用大O记号来渐进地表示,例如 $O(n)$、 $O(n \log n)$ 、 $O(2^n)$ 等;其中n用来表示输入的长度,该值可以影响算法的空间复杂度。
常见空间复杂度等级有:
空间复杂度 | 描述 | 常见场景说明 |
---|---|---|
常数空间 | 变量计数器、交换操作、不依赖输入长度的算法 | |
对数空间 | 递归算法如二分查找、平衡树操作、分治类算法 | |
线性空间 | 使用数组、哈希表、队列、递归线性深度等 | |
二次空间 | 矩阵操作、动态规划二维数组、图的邻接矩阵等 | |
指数空间 | 枚举所有子集/状态、暴力搜索、子集和问题等 | |
阶乘空间 | 排列问题、全排列搜索树、旅行商问题等 |
就像时间复杂度的计算不考虑算法所使用的空间大小一样,空间复杂度也不考虑算法运行需要的时间长短。
计算方法
容易看出的这里就不再赘述了,我们来看看一些比较难分析的情况。
对数空间复杂度
1 | int binarySearch(int l, int r, int target) |
这个是个用栈来进行二分查找的算法,其调用最多的栈深度为 $\log n$,所以空间复杂度为 $O(\log n)$。
指数空间复杂度
一般出现在需要存储所有子集或状态集合的情况中。
1 | vector<string> res; |
这是有个用于求字符串所有子集的算法,其空间复杂度为 $O(2^n)$。
阶乘空间复杂度
1 | vector<vector<int>> res; |
这是用于求整数序列全排列的算法,其空间复杂度为 $O(n!)$。
课后练习
求下面算法的空间复杂度:
Question A
这是一个求组合总和的个数的算法(顺序不同视为不同组合):
1 | int combinationSum4(vector<int>& nums, int target) |
Question B
这是个给定整数数组nums
找出所有子集的异或和的最大值的算法:
1 | int subsetXORSumMax(vector<int>& nums) |