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 循环相比显然是不重要的,所以在这里花费时间也是不明智的。这使得我们得到若干一般法则。
- 法则1一for 循环:
一次 for 循环的运行时间至多是该 for 循环内语句(包括测试)的运行时间乘以迭代的次数。 - 法则2一嵌套的 for 循环:
从里向外分析这些循环。在一组嵌套循环内部的一条语句总的运行时间为该语句的运行时间乘以该组所有的 for 循环的大小的乘积。
作为一个例子,下列程序片段为
O
(
N
2
)
O(N^2)
O(N2):
for( i=0; i<N; i++){
for( j=0; j<N; j++){
k++;
}
}
- 法则3——顺序语句:
多段语句取最大:总复杂度等于量级最大的那段代码的复杂度。
作为一个例子,下面的程序片段先用去
O
(
N
)
O(N)
O(N),再花费
O
(
N
2
)
O(N^2)
O(N2),总的开销也是
O
(
N
2
)
O(N^2)
O(N2):
for( i=0; i<N; i++){
A[i] = 0:
}
for( i =0; i<N; i++){
for( j=0; j<N; j++){
A[i] += A[j] + i + j;
}
}
- 法则4——IF/ELSE语句:
对于程序片段
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)<O(\log N)<O(N)<O(N\log N)<O(N2)<O(Nc)<O(2^N)<O(N!)
O(1)<O(logN)<O(N)<O(NlogN)<O(N2)<O(Nc)<O(2N)<O(N!)指数级、阶乘级是灾难性的,其他级是能接受的范围。
示例解析
下面是几个不同复杂度的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<N){
count *= 2;
}
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 ;
- 最差输入数据: 当
N
≤
10
N\le10
N≤10 时,数组 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)<O(logN)<O(N)<O(N2)<O(2N)
O(1)<O(logN)<O(N)<O(N2)<O(2N)
示例解析
对于以下所有示例,设输入数据大小为正整数
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
(1)Python所有方向的学习路线(新版)
这是我花了几天的时间去把Python所有方向的技术点做的整理,形成各个领域的知识点汇总,它的用处就在于,你可以按照上面的知识点去找对应的学习资源,保证自己学得较为全面。
最近我才对这些路线做了一下新的更新,知识体系更全面了。
(2)Python学习视频
包含了Python入门、爬虫、数据分析和web开发的学习视频,总共100多个,虽然没有那么全面,但是对于入门来说是没问题的,学完这些之后,你可以按照我上面的学习路线去网上找其他的知识资源进行进阶。
(3)100多个练手项目
我们在看视频学习的时候,不能光动眼动脑不动手,比较科学的学习方法是在理解之后运用它们,这时候练手项目就很适合了,只是里面的项目比较多,水平也是参差不齐,大家可以挑自己能做的项目去练练。
网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。
一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!