本文是算法与数据结构的学习笔记第二篇,将持续更新,欢迎小伙伴们阅读学习 。有不懂的或错误的地方,欢迎交流
算法复杂度旨在计算在输入数据量 N N N 的情况下,算法的「时间使用」和「空间使用」情况;体现算法运行使用的时间和空间随「数据大小 N N N」而增大的速度。
算法复杂度主要可从时间 、空间 两个角度评价:
「输入数据大小」 N N N 指算法处理的输入数据量;根据不同算法,具有不同定义,例如:
接下来,将分别从概念定义、符号表示、分析法则、常见种类、示例解析、时空权衡等角度入手,介绍「时间复杂度」和「空间复杂度」。
根据定义,时间复杂度指输入数据大小为 N N N 时,算法运行所需花费的时间。需要注意:
根据输入数据的特点,时间复杂度具有「最差」、「平均」、「最佳」三种情况,分别使用 O O O, Θ \Theta Θ, Ω \Omega Ω三种符号表示。以下借助一个查找算法的示例题目帮助理解。
题目: 输入长度为 N N N 的整数数组
nums
,判断此数组中是否有数字 7,若有则返回true
,否则返回false
。
解题思路: 线性查找,即遍历整个数组,遇到 7,则返回true
。
C代码:#include
bool find_seven(int* nums, int length) { for (int i = 0; i < length; i++) { if (nums[i] == 7) { return true; } } return false; }
nums
有多少元素,线性查找的循环次数都为 1 次;nums
中所有数字都不为 7 ,此时线性查找会遍历整个数组,循环 N N N 次;大 O O O 是最常使用的时间复杂度评价符号,也称渐进上界,表明了 N N N 逐步增大时间资源开销 T ( N ) T(N) T(N) 的增长趋势。实际上,分析的结果为程序在一定时间范围内能够终止运行停供了保障 。程序可能提前结束,但绝不可能拖后。
以下借助一段程序帮助理解时间复杂度分析中,这里是计算 ∑ i = 1 N i 3 \sum\limits_{i = 1}^N {{i^3}} i=1∑Ni3 的一个简单的程序片段
int sum(int N){
int i, PartialSum;
PartialSum = 0;
for( i=1; i<=N; i++){
PartialSum += i * i * i;
}
return PartialSum;
}
对这个程序的分析很简单。声明不计入时间。第 4 行和第 8 行各占一个时间单元。第 6 行每执行一次占用四个时间单元(两次乘法,一次加法和一次赋值),而执行 N N N 次共占用 4 N 4N 4N 个时间单元。第 5 行在初始化 i i i,测试 i ≤ N i\le N i≤N 和对 i i i 的自增运算中隐含着开销。所有这些的总开销是初始化 1 个时间单元,所有的测试 N + 1 N+1 N+1 个时间单元,以及所有的自增运算 N N N 个时间单元,共 2 N + 2 2N+2 2N+2。我们忽略调用函数和返回值的开销,得到总量是 6 N + 4 6N+4 6N+4,因此我们说该程序是 O ( N ) O(N) O(N)。分析如下图所示:
如果我们每次分析一个程序都要演示所有这些工作,那么这项任务很快就会变成不可行的工作。幸运的是,由于我们有了大 O O O 的结果,因此就存在许多可以采取的捷径并且不影响最后的结果。例如,第 6 行(每次执行时)显然是 O ( 1 ) O(1) O(1) 语句,因此精确计算它究竟是二、三还是四个时间单元是愚蠢;这无关紧要。第 4 行与 for 循环相比显然是不重要的,所以在这里花费时间也是不明智的。这使得我们得到若干一般法则。
for( i=0; i
for( i=0; i
if(Condition){
S1;
}
else{
S2;
}
一个 if/ise 语句的运行时间从不超过判断再加上 S1
和 S2
中运行时间长者的总的运行时间。算法的时间复杂度最后表示出来一定是一个自变量为输入规模 N N N 的一元函数,根据从小到大排列,常见的算法时间复杂度主要有: O ( 1 ) < O ( log N ) < O ( N ) < O ( N log N ) < O ( N 2 ) < O ( N c ) < O ( 2 N ) < O ( N ! ) O(1)
下面是几个不同复杂度的C代码示例:
常数级 O ( 1 ) O(1) O(1):
运行次数与 N N N 大小呈常数关系,即不随输入数据大小 N N N 的变化而变化。
对于以下代码,无论 a a a 取多大,都与输入数据大小 N N N 无关,因此时间复杂度仍为 O ( 1 ) O(1) O(1) 。
int algorithm(int N) {
int count = 0;
int a = 10000;
for (int i = 0; i < a; i++) {
count += 1;
}
return count;
}
线性级 O ( N ) O(N) O(N):
运行次数与 N N N 大小呈线性关系,时间复杂度为 O ( N ) O(N) O(N) 。
以下代码是单层循环,运行了 N N N 次,所以时间负责度是 O ( N ) O(N) O(N)。
int algorithm(int N) {
int count = 0;
for (int i = 0; i < N; i++) {
count += 1;
}
return count;
}
平方级 O ( N ) O(N) O(N):
以两层循环为例,若两层循环相互独立,都与 N N N 呈线性关系,因此总体与 N N N 呈平方关系,时间复杂度为 O ( N 2 ) O(N^2) O(N2)
多项式级 O ( N c ) O(N^c) O(Nc):
其中, c c c为常数,聪明的你一定能猜到 O ( N 3 ) O(N^3) O(N3) 时间复杂度的程序该怎么写。
指数级 O ( 2 N ) O(2^N) O(2N):
生物学科中的 “细胞分裂” 即是指数级增长。初始状态为 1 个细胞,分裂一轮后为 2 个,分裂两轮后为 4 个,……,分裂 N N N 轮后有 2 N 2^N 2N 个细胞。
算法中,指数级常出现于递归,算法代码与原理图如下所示。
int algorithm(int N) {
if (N <= 0) {
return 1;
}
int count_1 = algorithm(N - 1);
int count_2 = algorithm(N - 1);
return count_1 + count_2;
}
对数级 O ( log N ) O(\log N) O(logN) :
对数阶与指数阶相反,指数阶为 “每轮分裂出两倍的情况” ,而对数阶是 “每轮排除一半的情况” 。对数阶常出现于「二分法」、「分治」等算法中,体现着 “一分为二” 或 “一分为多” 的算法思想。
int algorithm(int N) {
int count = 1;
while(count
count
初始值为1,不断自乘 2 逼近 N N N,设循环次数为 m m m,则输入数据大小 N N N 与 2 m 2^m 2m 呈线性关系,两边同时取 log 2 \log_2 log2 对数,则得到循环次数 m m m 与 log 2 N \log_2N log2N 呈线性关系,即时间复杂度为 O ( log N ) O(\log N) O(logN)。
线性对数级 O ( N log N ) O(N\log N) O(NlogN):
两层循环相互独立,第一层和第二层时间复杂度分别为 O ( log N ) O(\log N) O(logN) 和 O ( N ) O(N) O(N) ,则总体时间复杂度为 O ( N log N ) O(N\log N) O(NlogN) ;
int algorithm(int N) {
int count = 0;
int i = N;
while (i > 1) {
i = i / 2;
for (int j = 0; j < N; j++) {
count += 1;
}
}
return count;
}
线性对数阶常出现于排序算法,例如「快速排序」、「归并排序」、「堆排序」等,其时间复杂度原理如下图所示。
阶乘级 O ( N ! ) O(N!) O(N!):
阶乘级对应数学上常见的 “全排列” 。即给定 N N N 个互不重复的元素,求其所有可能的排列方案,则方案数量为: N × ( N − 1 ) × ( N − 2 ) × ⋯ × 2 × 1 = N ! N×(N−1)×(N−2)×⋯×2×1=N! N×(N−1)×(N−2)×⋯×2×1=N!如下图与代码所示,阶乘常使用递归实现,算法原理:第一层分裂出 N N N 个,第二层分裂出 N − 1 N−1 N−1 个,…… ,直至到第 N N N 层时终止并回溯。
int algorithm(int N) {
if (N <= 0) {
return 1;
}
int count = 0;
for (int i = 0; i < N; i++) {
count += algorithm(N - 1);
}
return count;
}
空间复杂度涉及的空间类型有:
通常情况下,空间复杂度指在输入数据大小为 N N N 时,算法运行所使用的「暂存空间」+「输出空间」的总体大小。
而根据不同来源,算法使用的内存空间分为三类:
指令空间:
编译后,程序指令所使用的内存空间。
数据空间:
算法中的各项变量使用的空间,包括:声明的常量、变量、动态数组、动态对象等使用的内存空间。
栈帧空间:
程序调用函数是基于栈实现的,函数在调用期间,占用常量大小的栈帧空间,直至返回后释放。如以下代码所示,在循环中调用函数,每轮调用 test()
返回后,栈帧空间已被释放,因此空间复杂度仍为 O ( 1 ) O(1) O(1) 。
int test() {
return 0;
}
void algorithm(int N) {
for (int i = 0; i < N; i++) {
test();
}
}
算法中,栈帧空间的累计常出现于递归调用。如以下代码所示,通过递归调用,会同时存在 N N N 个未返回的函数 algorithm()
,此时累计使用 O ( N ) O(N) O(N) 大小的栈帧空间。
int algorithm(int N) {
if (N <= 1) {
return 1;
}
return algorithm(N - 1) + 1;
}
通常情况下,空间复杂度统计算法在 “最差情况” 下使用的空间大小,以体现算法运行所需预留的空间量,使用符号 O O O 表示。
最差情况有两层含义,分别为「最差输入数据」、算法运行中的「最差运行点」。例如以下代码:
输入整数 N N N ,取值范围 N ≥ 1 N≥1 N≥1 ;
nums
的长度恒定为 10 ,空间复杂度为 O ( 10 ) = O ( 1 ) O(10)=O(1) O(10)=O(1) ;当 N > 10 N>10 N>10 时,数组 nums
长度为 N N N ,空间复杂度为 O ( N ) O(N) O(N) ;因此,空间复杂度应为最差输入数据情况下的 O ( N ) O(N) O(N) 。int* nums = (int*)malloc(10 * sizeof(int));
时,算法仅使用 O ( 1 ) O(1) O(1) 大小的空间;而当执行 nums = (int*)malloc(N * sizeof(int));
时,算法使用 O ( N ) O(N) O(N) 的空间;因此,空间复杂度应为最差运行点的 O ( N ) O(N) O(N) 。void algorithm(int N) {
int num = 5; // O(1)
int* nums = (int*)malloc(10 * sizeof(int)); // O(1)
if (N > 10) {
free(nums); // 释放原来分配的内存
nums = (int*)malloc(N * sizeof(int)); // O(N)
}
}
根据从小到大排列,常见的算法空间复杂度有: O ( 1 ) < O ( l o g N ) < O ( N ) < O ( N 2 ) < O ( 2 N ) O(1)
对于以下所有示例,设输入数据大小为正整数 N N N ,节点类 Node
、函数 test()
如以下代码所示。
// 节点结构体
struct Node {
int val;
struct Node* next;
};
// 创建节点函数
struct Node* createNode(int val) {
struct Node* newNode = (struct Node*)malloc(sizeof(struct Node));
newNode->val = val;
newNode->next = NULL;
return newNode;
}
// 函数 test()
int test() {
return 0;
}
常数级 O ( 1 ) O(1) O(1):
普通常量、变量、对象、元素数量与输入数据大小 N 无关的集合,皆使用常数大小的空间。
int N = 0; // 变量
int num = 0;
int nums[10000] = {0}; // 数组
struct Node* node = createNode(0); // 动态对象
如以下代码所示,虽然函数 test()
调用了 N N N 次,但每轮调用后 test()
已返回,无累计栈帧空间使用,因此空间复杂度仍为 O ( 1 ) O(1) O(1) 。
void algorithm(int N) {
for (int i = 0; i < N; i++) {
test();
}
}
线性级 O ( N ) O(N) O(N):
元素数量与 N N N 呈线性关系的任意类型集合(常见于一维数组、链表、哈希表等),皆使用线性大小的空间。
int* nums_1 = (int*)malloc(N * sizeof(int));
int* nums_2 = (int*)malloc((N / 2) * sizeof(int));
struct Node** nodes = (struct Node**)malloc(N * sizeof(struct Node*));
如下图与代码所示,此递归调用期间,会同时存在 N N N 个未返回的 algorithm()
函数,因此使用 O ( N ) O(N) O(N) 大小的栈帧空间。
int algorithm(int N) {
if (N <= 1) return 1;
return algorithm(N - 1) + 1;
}
平方级 O ( N 2 ) O(N^2) O(N2):
元素数量与 N N N 呈平方关系的任意类型集合(常见于矩阵),皆使用平方大小的空间。
int** num_matrix = (int**)malloc(N * sizeof(int*));
struct Node*** node_matrix = (struct Node***)malloc(N * sizeof(struct Node**));
// 初始化 num_matrix 二维数组
for (int i = 0; i < N; i++) {
num_matrix[i] = (int*)malloc(N * sizeof(int));
for (int j = 0; j < N; j++) {
num_matrix[i][j] = 0;
}
}
// 创建节点对象并初始化
for (int i = 0; i < N; i++) {
node_matrix[i] = (struct Node**)malloc(N * sizeof(struct Node*));
for (int j = 0; j < N; j++) {
node_matrix[i][j] = createNode(j);
}
}
如下图与代码所示,递归调用时同时存在 N N N 个未返回的 algorithm()
函数,使用 O ( N ) O(N) O(N) 栈帧空间;每层递归函数中声明了数组,平均长度为 N 2 \frac{N}{2} 2N ,使用 O ( N ) O(N) O(N) 空间;因此总体空间复杂度为 O ( N 2 ) O(N^2) O(N2)。
int algorithm(int N) {
if (N <= 0) return 0;
int* nums = (int*)malloc(N * sizeof(int));
return algorithm(N - 1);
}
指数级 O ( 2 N ) O(2^N) O(2N):
指数阶常见于二叉树、多叉树。例如,高度为 N N N 的「满二叉树」的节点数量为 2 N 2^N 2N ,占用 O ( 2 N ) O(2^N) O(2N) 大小的空间;同理,高度为 N N N 的「满 m m m 叉树」的节点数量为 m N m^N mN ,占用 O ( m N ) = O ( 2 N ) O(m^N)=O(2^N) O(mN)=O(2N) 大小的空间。
对数级 O ( log N ) O(\log N) O(logN):
对数阶常出现于分治算法的栈帧空间累计、数据类型转换等,例如:
对于算法的性能,需要从时间和空间的使用情况来综合评价。优良的算法应具备两个特性,即时间和空间复杂度皆较低。而实际上,对于某个算法问题,同时优化时间复杂度和空间复杂度是非常困难的。降低时间复杂度,往往是以提升空间复杂度为代价的,反之亦然。
由于当代计算机的内存充足,通常情况下,算法设计中一般会采取「空间换时间」的做法,即牺牲部分计算机存储空间,来提升算法的运行速度。
以 LeetCode 全站第一题 两数之和 为例,「暴力枚举」和「辅助哈希表」分别为「空间最优」和「时间最优」的两种算法。
以上就是算法复杂度的相关内容
下一篇文章将在详细介绍常用的九大数据结构,持续更新中…