目录
一、算法复杂度
算法复杂度旨在计算在输入数据量 𝑁 的情况下,算法的「时间使用」和「空间使用」情况;体现算法运行使用的时间和空间随「数据大小 𝑁 」而增大的速度。
算法复杂度主要可从 时间 、空间 两个角度评价:
- 时间: 假设各操作的运行时间为固定常数,统计算法运行的「计算操作的数量」 ,以代表算法运行所需时间;
- 空间: 统计在最差情况下,算法运行所需使用的「最大空间」;
「输入数据大小 𝑁」指算法处理的输入数据量;根据不同算法,具有不同定义,例如:
- 排序算法: 𝑁 代表需要排序的元素数量;
- 搜索算法: 𝑁 代表搜索范围的元素总数,例如数组大小、矩阵大小、二叉树节点数、图节点和边数等;
接下来,本文将分别从概念定义、符号表示、常见种类、时空权衡、示例解析、示例题目等角度入手,介绍「时间复杂度」和「空间复杂度」。
二、时间复杂度
2.1 定义
定义:时间复杂度(Time Complexity)是计算机科学中用于描述算法运行时间的一个数学函数,它定量地描述了算法的运行时间与输入数据规模之间的关系。时间复杂度用于衡量算法 随着输入规模增长 而 执行时间的增长趋势,通常计算 基本操作的执行次数。
- 统计的是算法的「计算操作数量」,而不是「运行的绝对时间」。计算操作数量和运行绝对时间呈正相关关系,并不相等。算法运行时间受到「编程语言 、计算机处理器速度、运行环境」等多种因素影响。例如,同样的算法使用 Python 或 C++ 实现、使用 CPU 或 GPU 、使用本地 IDE 或力扣平台提交,运行时间都不同。
- 体现的是计算操作随数据大小N变化时的变化情况。假设算法运行总共需要「1次操作」、「 100 次操作」,此两情况的时间复杂度都为常数级 𝑂(1) ;需要「 𝑁 次操作」、「 100𝑁 次操作」的时间复杂度都为 𝑂(𝑁) 。
一个算法在不同的机器上的执行时间是不同的,但语句执行次数是一样的。因此,可以使用语句执行次数来衡量算法的时间复杂度。
2.2 符号表示
根据输入数据的特点,时间复杂度具有「最差」、「平均」、「最佳」三种情况,分别使用
𝑂 , Θ , Ω 三种符号表示。以下借助一个查找算法的示例题目帮助理解。
题目: 输入长度为 𝑁 的整数数组 nums ,判断此数组中是否有数字 7 ,若有则返回 true ,否则返回 false 。
解题算法: 线性查找,即遍历整个数组,遇到 7 则返回 true 。
bool findSeven(vector<int>& nums) { for (int num : nums) { if (num == 7) return true; } return false; }
大 𝑂 是最常使用的时间复杂度评价渐进符号。
2.3 常见种类
根据从小到大排列,常见的算法时间复杂度主要有:
时间复杂度一般是 对最坏情况的估计,它衡量的是 随着输入规模增大,算法执行时间的增长趋势。
2.4 计算操作次数
for 循环的执行流程
for(i = 1; i <= n; i++) {
// 循环体
x++;
}
for
循环由 四部分 组成:
- 初始化:
i = 1
(执行 1 次)。 - 条件检查:
i <= n
(执行 n+1 次,包括最后一次失败)。 - 循环体执行:当
i
从1
变到n
,循环体执行n
次。 - 自增操作:
i++
(执行n
次)。
因此上述的for循环执行的次数是(3n+2)次,则上述for循环的时间复杂度为O(3n+2),又可以表示成O(n)(忽略初始化、终止条件检查和自增部分),这是因为随着数据规模n的不断增加,系数、常量对执行时间的增长趋势影响非常的小。
两层for循环-----在下面的代码中,x++; 语句一共执行了多少次?
for(i=1;i<=n;i++){ for(j=1;j<=n;j++){ x++; } }
我们来逐步分解:
先来看下面第1个代码段,for循环会执行多少次?
在这里我们不考虑循环体的执行次数、不考虑自增操作执行次数,同时也不考虑初始化执行次数。下面的例子同理。这样的话,只剩下条件检查:
i <= n
(执行 n+1 次,包括最后一次失败)。因此这条for语句一共执行n+1次。再来看第2个代码段中的x++语句执行多少次?
x++;会执行n次。(x++属于循环体内)
再来看第三个代码段中的内层for循环会执行多少次?
对于外层for循环可以进入到外层for循环的循环体n次,内层for循环自己执行n+1次(内层for循环执行次数可参考代码段1中for循环执行次数),因此内层for循环共执行n(n+1)次。
接下来我们来看第四段代码中x++共执行多少次?
x++会执行n×n次。
例题:假设n是描述问题规模的非负整数,下面的程序片段的时间复杂度是什么?
//第五段代码: for (i = 1; i <= n; i++) { // (1) 外层循环:i 从 1 到 n for (j = 1; j <= i; j++) { // (2) 中层循环:j 从 1 到 i for (k = 1; k <= j; k++) {// (3) 内层循环:k 从 1 到 j x++; // (4) 计数操作 } } }
我们 从内到外 逐层计算
x++
的执行次数。最内层
for(k=1; k<=j; k++)
k
从1
到j
,因此x++语句执行j
次。中层
for(j=1; j<=i; j++)
j
取值为1, 2, ..., i
,所以x++语句在中层for循环中执行了:次。
最外层
for(i=1; i<=n; i++)
i
取值为1, 2, ..., n
,所以总的x++
执行次数为:次。
综上,x++执行的总次数为:
,接下来我们计算这个式子。
先计算:
接下来计算:
最终得:
这便是x++语句执行的总次数。
当然,根据x++执行的次数,我们也可以知道该代码片段的时间复杂度为O(N^3)。
计算时间复杂度时只需考虑执行次数最多的语句即可。
例题:求下面代码段中x++的执行次数?
推导循环终止条件:
我们的目标是找到i=2^k<=n时的最大整数k值即可。对式2^k<=n同取以2为底的自然对数,则有k<=log2(n)时满足条件,由于k为最大整数,因此k取|_log2(n)_|。举例:当n=8(n的值是2的幂次)时,log2(n)=3,|_log2(n)_|=3,那么k取3满足2=^3<=n;当n=10时(n的值不是2的幂次)时,log2(n)=3.322,|_log2(n)_|=3,那么k取3满足2^3<=n。因此k=|_log2(n)_|时符合条件。|_x_|表示向下取整。
接下来我们计算x++执行了多少次?从i=2^0,2^1,2^2,....,2^k均符合条件,因此x++执行k+1次,即:|_log2(n)_|+1次。
假设n=8,我们来看i的变化:
i=2^0=1,1<=n,进入循环体,x++执行;
i=2^1=2,2<=n,进入循环体,x++执行;
i=2^2=4,4<=n,进入循环体,x++执行;
i=2^3=8,8<=n,进入循环体,x++执行;
i=2^4=16,16>n,退出循环,x++不执行。
也就是说x++执行了四次。
也就是说,当n=2^k,k=log2(n),x++执行的次数应该为:k+1=log2(n)+1次。
假设n=10(n不是2的幂次方时),我们来看i的变化:
i=2^0=1,1<=n,进入循环体,x++执行;
i=2^1=2,2<=n,进入循环体,x++执行;
i=2^2=4,4<=n,进入循环体,x++执行;
i=2^3=8,8<=n,进入循环体,x++执行;
i=2^4=16,16>n,退出循环,x++不执行。
也就是说x++执行了四次。
也就是说,当n=2^k,k=log2(n),x++执行的次数应该为:k+1=|_log2(n)_|+1次。
时间复杂度为O(log2(n))。
参考学习视频:
时间复杂度、空间复杂度求解|数据结构_哔哩哔哩_bilibili
2.5 时间复杂度的示例
对于以下所有示例,设输入数据大小为 𝑁 ,计算操作数量为 𝑐𝑜𝑢𝑛𝑡 。图中每个「蓝色方块」代表一个单元计算操作。
2.5.1 常数O(1)
运行次数与 𝑁 大小呈常数关系,即不随输入数据大小 𝑁 的变化而变化。【表示算法的运行时间与输入规模无关,如访问数组中的某个元素。】
int algorithm(int N) {
int a = 1;
int b = 2;
int x = a * b + N;
return 1;
}
对于以下代码,无论 𝑎 取多大,都与输入数据大小 𝑁 无关,因此时间复杂度仍为 𝑂(1) 。
int algorithm(int N) {
int count = 0;
int a = 10000;
for (int i = 0; i < a; i++) {
count++;
}
return count;
}
2.5.2 线性 𝑂(𝑁)
循环运行次数与 𝑁 大小呈线性关系,时间复杂度为 𝑂(𝑁) 。【表示算法的运行时间与输入规模成正比,如遍历数组。】
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;
}
2.5.3 平方O(N^2)
两层循环相互独立,都与 𝑁 呈线性关系,因此总体与 𝑁 呈平方关系,时间复杂度为O(N^2)。
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;
}
2.5.4 指数O(2^N)
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;
}
2.5.5 阶乘O(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;
}
2.5.6 对数O(logN)
int algorithm(int N) {
int count = 0;
float i = N;
while (i > 1) {
i = i / 2;
count++;
}
return count;
}
int algorithm(int N) {
int count = 0;
float i = N;
int a = 3;
while (i > 1) {
i = i / a;
count++;
}
return count;
}
如下图所示,为二分查找的时间复杂度示意图,每次二分将搜索区间缩小一半。
2.5.7 线性对数O(NlogN)
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;
}
线性对数阶常出现于排序算法,例如「快速排序」、「归并排序」、「堆排序」等,其时间复杂度原理如下图所示。
三、空间复杂度
3.1 定义
定义:空间复杂度(Space Complexity) 是算法分析中的一个重要概念,用于衡量算法在运行过程中所需的额外存储空间(除了输入数据本身占用的空间)随输入规模增长的变化趋势。它通常用大 O 符号(O)表示。空间复杂度描述的是算法在运行过程中 临时占用的存储空间 与输入规模 n 之间的关系。
它包括:
-
算法使用的变量(如循环变量、临时变量等)。
-
数据结构占用的空间(如数组、栈、队列、哈希表等)。
-
递归调用栈的空间(如果算法是递归的)。
注意:空间复杂度 不包括输入数据本身占用的空间,因为输入数据是问题本身固有的,而不是算法额外需要的。
空间复杂度涉及的空间类型有:
输入空间: 存储输入数据所需的空间大小;
暂存空间: 算法运行过程中,存储所有中间变量和对象等数据所需的空间大小;
输出空间: 算法运行返回时,存储输出数据所需的空间大小;
通常情况下,空间复杂度指在输入数据大小为 𝑁 时,算法运行所使用的「暂存空间」+「输出空间」的总体大小。
而根据不同来源,算法使用的内存空间分为三类:
指令空间:编译后,程序指令所使用的内存空间。
数据空间:算法中的各项变量使用的空间,包括:声明的常量、变量、动态数组、动态对象等使用的内存空间。
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() 返回后,栈帧空间已被释放,因此空间复杂度仍为 O(1) 。
int test() {
return 0;
}
void algorithm(int N) {
for (int i = 0; i < N; i++) {
test();
}
}
算法中,栈帧空间的累计常出现于递归调用。如以下代码所示,通过递归调用,会同时存在
N 个未返回的函数 algorithm() ,此时累计使用 O(N) 大小的栈帧空间。
int algorithm(int N) {
if (N <= 1) return 1;
return algorithm(N - 1) + 1;
}
3.2 空间复杂度符号表示
void algorithm(int N) {
int num = 5; // O(1)
vector<int> nums(10); // O(1)
if (N > 10) {
nums.resize(N); // O(N)
}
}
3.3 常见种类
根据从小到大排列,常见的算法空间复杂度有:
3.4 示例解析
对于以下所有示例,设输入数据大小为正整数 𝑁,节点类 Node
、函数 test()
如以下代码所示。
// 节点类 Node
struct Node {
int val;
Node *next;
Node(int x) : val(x), next(NULL) {}
};
// 函数 test()
int test() {
return 0;
}
3.4.1 常数O(1)
普通常量、变量、对象、元素数量与输入数据大小 𝑁 无关的集合,皆使用常数大小的空间。
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() 已返回,无累计栈帧空间使用,因此空间复杂度仍为 O(1) 。
void algorithm(int N) {
for (int i = 0; i < N; i++) {
test();
}
}
3.4.2 线性O(N)
元素数量与 𝑁 呈线性关系的任意类型集合(常见于一维数组、链表、哈希表等),皆使用线性大小的空间。
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;
}
3.4.3 平方O(N^2)
元素数量与 𝑁 呈平方关系的任意类型集合(常见于矩阵),皆使用平方大小的空间。
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() 函数,使用 O(N) 栈帧空间;每层递归函数中声明了数组,平均长度为 𝑁/2 ,使用 O(N) 空间;因此总体空间复杂度为 O(N^2)。
int algorithm(int N) {
if (N <= 0) return 0;
int nums[N];
return algorithm(N - 1);
}
3.4.4 指数0(2^N)
3.4.5 对数O(logN)
-------------------------------------------------------------------------------------------------------------------------------
声明:
本文章大部分参考自leetcode中一篇博客:链接:https://leetcode.cn/circle/discuss/rGdNq1/