算法复杂度旨在描述 输入数据量 时,算法的 时间使用 和 空间使用 情况
-
时间:假设各操作的运行时间为固定常数,统计算法运行的 计算操作的数量 ,以代表算法运行所需时间
-
空间:统计在最差情况下 ,算法运行所需使用的 最大空间
输入数据大小 指算法处理的输入数据量,根据不同算法,具有不同的定义,例如:
- 排序算法: 代表需要排序的元素数量
- 搜索算法: 代表搜索范围的元素总数,例如数组大小、矩阵大小、二叉树节点数、图节点和边数等
# 时间复杂度
统计的是算法的 计算操作数量 ,而不是 运行的绝对时间
计算操作数量 和 运行绝对时间 呈正相关关系,并不相等。算法运行时间受到「编程语言 、计算机处理器速度、运行环境」等多种因素影响。
体现的是计算操作随数据大小 变化时的变化情况。假设算法运行总共需要 次操作、 次操作,此两情况的时间复杂度都为常数级 ;需要 次操作、 次操作 的时间复杂度都为
# 符号表示
时间复杂度具有 最差、平均、最佳 三种情况,分别使用 , , 三种符号表示
题目:输入长度为 N
的整数数组 nums
,判断此数组中是否有数字 7
,若有则返回 true
,否则返回 false
解题算法:线性查找,即遍历整个数组,遇到 7
则返回 true
代码:
bool findSeven(vector<int>& nums) { | |
for (int num : nums) { | |
if (num == 7) | |
return true; | |
} | |
return false; | |
} |
最佳情况 :当数组首个数字为 7
时,无论 nums
有多少元素,线性查找的循环次数都为 次
最差情况 : nums
中所有数字都不为 7
,此时线性查找会遍历整个数组,循环 N
次
平均情况 :需要考虑输入数据的分布情况,计算所有数据情况下的平均时间复杂度。例如本题,需要考虑数组长度、数组元素的取值范围等
# 常见种类
根据从小到大排列,常见的算法时间复杂度主要有:
# 示例解析
对于以下所有示例,设输入数据大小为 ,计算操作数量为 。图中每个「蓝色方块」代表一个单元计算操作。
# 常数
运行次数与 大小呈常数关系,即不随输入数据大小 的变化而变化
int algorithm(int N) { | |
int a = 1; | |
int b = 2; | |
int x = a * b + N; | |
return 1; | |
} |
对于以下代码,无论 取多大,都与输入数据大小 无关,因此时间复杂度仍为
int algorithm(int N) { | |
int count = 0; | |
int a = 10000; | |
for (int i = 0; i < a; i++) { | |
count++; | |
} | |
return count; | |
} |
# 线性
循环运行次数与 大小呈线性关系,时间复杂度为
int algorithm(int N) { | |
int count = 0; | |
for (int i = 0; i < N; i++) | |
count++; | |
return count; | |
} |
之前一个例子的循环次数并不随 改变,而这里的循环次数会随 变化
对于以下代码,虽然是两层循环,但第二层与 大小无关,因此整体仍与 呈线性关系
int algorithm(int N) { | |
int count = 0; | |
int a = 10000; | |
for (int i = 0; i < N; i++) { | |
for (int j = 0; j < a; j++) { | |
count++; | |
} | |
} | |
return count; | |
} |
# 平方
两层循环相互独立,都与 呈线性关系,因此总体与 呈平方关系
int algorithm(int N) { | |
int count = 0; | |
for (int i = 0; i < N; i++) { | |
for (int j = 0; j < N; j++) { | |
count++; | |
} | |
} | |
return count; | |
} |
以 冒泡排序 为例,其包含两层独立循环:
- 第一层复杂度为 ;
- 第二层平均循环次数为 ,复杂度为 ,推导过程如下:
因此,冒泡排序的总体时间复杂度为 ,代码如下所示
vector<int> bubbleSort(vector<int>& nums) { | |
int N = nums.size(); | |
for (int i = 0; i < N - 1; i++) { | |
for (int j = 0; j < N - 1 - i; j++) { | |
if (nums[j] > nums[j + 1]) { | |
swap(nums[j], nums[j + 1]); | |
} | |
} | |
} | |
return nums; | |
} |
# 指数
生物学科中的 “细胞分裂” 即是指数级增长
算法中,指数阶常出现于 递归 ,算法原理图与代码如下所示
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; | |
} |
# 阶乘
阶乘阶对应数学上常见的 “全排列”
如下图与代码所示,阶乘常使用 递归 实现,算法原理:第一层分裂出 个,第二层分裂出 个,…… ,直至到第 层时终止并回溯
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; | |
} |
# 对数
对数阶与指数阶相反,指数阶为 “每轮分裂出两倍的情况” ,而对数阶是 “每轮排除一半的情况” 。对数阶常出现于 二分法 、分治 等算法中,体现着 “一分为二” 或 “一分为多” 的算法思想
设循环次数为 ,则输入数据大小 与 呈线性关系,两边同时取 对数,则得到循环次数 与 呈线性关系,即时间复杂度为
int algorithm(int N) { | |
int count = 0; | |
float i = N; | |
while (i > 1) { | |
i = i / 2; | |
count++; | |
} | |
return count; | |
} |
如以下代码所示,对于不同 的取值,循环次数 与 呈线性关系,时间复杂度为 。无论底数 a 取值,时间复杂度都可以记作 :
int algorithm(int N) { | |
int count = 0; | |
float i = N; | |
int a = 3; | |
while (i > 1) { | |
i = i / a; | |
count++; | |
} | |
return count; | |
} |
如下图所示,为二分查找的时间复杂度示意图,每次二分将搜索区间缩小一半,二分的次数为
# 线性对数
两层循环相互独立,第一层和第二层时间复杂度分别为 和 ,则总体时间复杂度为
int algorithm(int N) { | |
int count = 0; | |
float i = N; | |
while (i > 1) { | |
i = i / 2; | |
for (int j = 0; j < N; j++) | |
count++; | |
} | |
return count; | |
} |
线性对数阶常出现于排序算法,例如 快速排序 、归并排序 、堆排序 等,其时间复杂度原理如下图所示
# 空间复杂度
空间复杂度涉及的空间类型有:
-
输入空间 :存储输入数据所需的空间大小
-
暂存空间 :算法运行过程中,存储所有中间变量和对象等数据所需的空间大小
-
输出空间 :算法运行返回时,存储输出数据所需的空间大小
通常情况下,空间复杂度指在输入数据大小为 时,算法运行所使用的 暂存空间 + 输出空间 的总体大小
根据不同来源,算法使用的内存空间分为三类:
-
指令空间 :编译后,程序指令所使用的内存空间
-
数据空间 :算法中的各项变量使用的空间,包括:声明的常量、变量、动态数组、动态对象等使用的内存空间
struct Node {
int val;
Node *next;
Node(int x) : val(x), next(NULL) {}
};
void algorithm(int N) {
int num = N; // 变量
int nums[N]; // 动态数组
Node* node = new Node(N); // 动态对象
}```
-
栈帧空间 :程序 调用函数 是基于 栈 实现的,函数在调用期间,占用常量大小的栈帧空间,直至返回后释放。如以下代码所示,在循环中调用函数,每轮调用
test()
返回后,栈帧空间已被释放,因此空间复杂度仍为int test() {
return 0;
}
void algorithm(int N) {
for (int i = 0; i < N; i++) {
test();
}
}
算法中,栈帧空间的累计常出现于递归调用 。如以下代码所示,通过递归调用,会同时存在 个未返回的函数
algorithm()
,此时累计使用 大小的栈帧空间int algorithm(int N) {
if (N <= 1) return 1;
return algorithm(N - 1) + 1;
}
# 符号表示
通常情况下,空间复杂度统计 算法在 “最差情况” 下使用的空间大小 ,以体现算法运行所需预留的空间量,使用符号 表示
最差情况有两层含义,分别为 最差输入数据 、算法运行中的 最差运行点
例如以下代码:输入整数 ,取值范围
void algorithm(int N) { | |
int num = 5; // O(1) | |
vector<int> nums(10); // O(1) | |
if (N > 10) { | |
nums.resize(N); // O(N) | |
} | |
} |
- 最差输入数据 :当 时,数组
nums
的长度恒定为 ,空间复杂度为 。当 时,数组nums
长度为 ,空间复杂度为 。因此,空间复杂度应为最差输入数据情况下的 - 最差运行点 :在执行
nums(10)
时,算法仅使用 大小的空间;而当执行nums.resize(N)
时,算法使用 的空间;因此,空间复杂度应为最差运行点的
# 常见种类
根据从小到大排列,常见的算法空间复杂度有:
# 示例解析
对于以下所有示例,设输入数据大小为正整数 ,节点类 Node
、函数 test()
如以下代码所示
// 节点类 Node | |
struct Node { | |
int val; | |
Node *next; | |
Node(int x) : val(x), next(NULL) {} | |
}; | |
// 函数 test () | |
int test() { | |
return 0; | |
} |
# 常数
普通常量、变量、对象、元素数量与输入数据大小 无关的集合,皆使用常数大小的空间
void algorithm(int N) { | |
int num = 0; | |
int nums[10000]; | |
Node* node = new Node(0); | |
unordered_map<int, string> dic; | |
dic.emplace(0, "0"); | |
} |
如以下代码所示,虽然函数 test()
调用了 次,但每轮调用后 test()
已返回,无累计栈帧空间使用,因此空间复杂度仍为
void algorithm(int N) { | |
for (int i = 0; i < N; i++) { | |
test(); | |
} | |
} |
# 线性
元素数量与 呈线性关系的任意类型集合(常见于 一维数组 、链表 、哈希表 等),皆使用线性大小的空间
void algorithm(int N) { | |
int nums_1[N]; | |
int nums_2[N / 2 + 1]; | |
vector<Node*> nodes; | |
for (int i = 0; i < N; i++) { | |
nodes.push_back(new Node(i)); | |
} | |
unordered_map<int, string> dic; | |
for (int i = 0; i < N; i++) { | |
dic.emplace(i, to_string(i)); | |
} | |
} |
如下图与代码所示,此递归调用期间,会同时存在 个未返回的 algorithm()
函数,因此使用 大小的栈帧空间
int algorithm(int N) { | |
if (N <= 1) return 1; | |
return algorithm(N - 1) + 1; | |
} |
# 平方
元素数量与 呈平方关系的任意类型集合(常见于矩阵),皆使用平方大小的空间
void algorithm(int N) { | |
vector<vector<int>> num_matrix; | |
for (int i = 0; i < N; i++) { | |
vector<int> nums; | |
for (int j = 0; j < N; j++) { | |
nums.push_back(0); | |
} | |
num_matrix.push_back(nums); | |
} | |
vector<vector<Node*>> node_matrix; | |
for (int i = 0; i < N; i++) { | |
vector<Node*> nodes; | |
for (int j = 0; j < N; j++) { | |
nodes.push_back(new Node(j)); | |
} | |
node_matrix.push_back(nodes); | |
} | |
} |
如下图与代码所示,递归调用时同时存在 个未返回的 algorithm()
函数,使用 栈帧空间;每层递归函数中声明了数组,平均长度为 ,使用 空间;因此总体空间复杂度为
int algorithm(int N) { | |
if (N <= 0) return 0; | |
int nums[N]; | |
return algorithm(N - 1); | |
} |
# 指数
指数阶常见于 二叉树 、多叉树
例如,高度为 的 满二叉树(perfect binary tree) 的节点数量为 ,占用 大小的空间;同理,高度为 的 满 叉树 的节点数量为 ,占用 大小的空间
# 对数
对数阶常出现于分治算法的栈帧空间累计、数据类型转换等,例如:
- 快速排序 ,平均空间复杂度为 ,最差空间复杂度为 。拓展知识:通过应用 Tail Call Optimization ,可以将快速排序的最差空间复杂度限定至
- 数字转化为字符串 ,设某正整数为 ,则字符串的空间复杂度为 。推导如下:正整数 的位数为 ,即转化的字符串长度为 ,因此空间复杂度为
# 时空权衡
优良的算法应具备两个特性,即时间和空间复杂度皆较低
实际上,对于某个算法问题,同时优化时间复杂度和空间复杂度是非常困难的。降低时间复杂度,往往是以提升空间复杂度为代价的,反之亦然
由于当代计算机的内存充足,通常情况下,算法设计中一般会采取「空间换时间」的做法,即 牺牲部分计算机存储空间,来提升算法的运行速度
以 LeetCode 1. 两数之和 为例,暴力枚举 和 辅助哈希表 分别为 空间最优 和 时间最优 的两种算法
注:以上内容来自于 https://leetcode-cn.com/leetbook/read/illustration-of-algorithm/r8ytog/