+

写在前面

FlyPython推出的《Python面试专项课程》原计划在春节假期后开始更新,考虑到后面刷题的需求可能增多,我们对题目进行了调整并于今天开始更新。

+

原先的LeetCode刷题计划中的精选TOP面试题推后,我们先过一遍《程序员面试金典》这本书上的面试题,为基础薄弱的同学夯实基础。

+ +

欢迎关注我们的公众号flypython和网站flypython.com,我们将持续更新这个专项课程。

+

为什么需要复杂度分析

今天是这个专项课程的第0课,我们带来算法学习最重要的一个部分:复杂度分析。

+

为什么说复杂度分析是最重要呢?因为数据结构和算法其实解决的是”快”和”省”的问题。”快”就是如何让代码运行得更快,”省”就是让代码节约存储空间。怎么样来衡量你编写的算法代码执行效率呢?这里引出来今天要讲的内容:时间、空间复杂度分析。

+

你可能会说,不就是执行效率么?我把代码跑一遍,增加cProfile的一些操作,不就得到了执行时间和内存占用么。

+

其实你上面说的也是一种方法,叫事后统计法,在做性能优化时会大量采用。但是事后统计法有它的局限性。第一,它依赖测试环境,测试环境不一样,得到的数据会都不一样;第二,它受数据规模的影响大,比如排序,排10个数和排100万个数肯定不一样。所以我们不能以具体测试数据来表示,需要的是一个估算的执行效率的方法。这个方法就是我们要讲的大O复杂度表示法。

+

大O复杂度表示法

我们从一个例子开始,下面是求1,2,3,…n的累加和的代码

+
1
2
3
4
5
6
7
8
1 int cal(int n) {
2 int sum = 0;
3 int i = 1;
4 for (; i <= n; ++i) {
5 sum = sum + i;
6 }
7 return sum;
8 }
+ +

我们假设每一行代码的执行时间是相同的,为unit_time,那么这一段代码,第2,3行执行了一次,4,5行执行了n遍,第7行执行了一次,那总共执行的次数2n+3,执行时间为(2n+3)* unit_time。

+

可以看出,所有代码的执行时间 T(n) 与每行代码的执行次数成正比。可以用以下公式表示:

+

+

其中T(n)为所有代码的执行时间
其中f(n)表示每行代码执行的次数总和

+

公式中的O,表示代码的执行时间 T(n) 与 f(n) 表达式成正比。我们的例子中,T(n)=O(2n+3),这就是大O时间复杂度表示法。

+

大 O 时间复杂度实际上并不具体表示代码真正的执行时间,而是表示代码执行时间随数据规模增长的变化趋势,所以,也叫作渐进时间复杂度(asymptotic time complexity),简称时间复杂度。

+

时间复杂度分析

    +
  • O(1): 常数
  • +
  • O(logn): 对数
  • +
  • O(n): 线性
  • +
  • O(nlogn):线性对数
  • +
  • O(n^2): 平⽅
  • +
  • O(n^3): 立⽅
  • +
  • O(n^k): K次方
  • +
  • O(2^n): 指数
  • +
  • O(n!): 阶乘
  • +
+
O(1)
1
2
int a=0;
int b=a;
+ +

O(1) 只是常量级时间复杂度的一种表示方法,并不是指只执行了一行代码。只要代码的执行时间不随 n 的增大而增长,这样代码的时间复杂度我们都记作 O(1)。

+
O(log n)
1
2
3
for(int i=1; i<n; i=i*2){
printf(i);
}
+ +

对数阶时间复杂度非常常见,同时也是最难分析的一种时间复杂度。从代码中可以看出,变量 i 的值从 1 开始取,每循环一次就乘以 2。当大于 n 时,循环结束。还记得我们高中学过的等比数列吗?实际上,变量 i 的取值就是一个等比数列

+

+

通过 2^x = n 求解 x, x=log2n,这段代码的时间复杂度就是 O(log2n),忽略系数,表示为O(logn)。

+
O(n)
1
2
3
for(int i=1; i<=n; i++){
printf(i);
}
+ +

for循环代码执行了n遍,因此它消耗的时间是随着n的变化而变化的,所以通过O(n)来表示。如果平行存在一个for循环m遍的,如下:

+
1
2
3
4
5
6
7
for(int i=1; i<=n; i++){
printf(i);
}

for(int i=1; i<=m; i++){
printf(i);
}
+ +

那时间复杂度为O(m+n)。

+
O(nlogn)
1
2
3
4
5
for(int i=1; i<=n; i++){
for(int j=1; j<=n;j=j*2) {
printf(i,j);
}
}
+ +

线性对数O(nlogn)就是将时间复杂度为O(logn)的代码循环N遍的话,那么它的时间复杂度就是n* O(logn),也就是了O(nlogn)

+
O(n^2)
1
2
3
4
5
for(int i=1; i<=n; i++){
for(int j=1; j<=n;j++) {
printf(i,j);
}
}
+ +

平方就是O(n)代码再嵌套循环一遍,2层n循环,O(n * n) 为O(n^2)。如果嵌套3层n循环,则为O(n^3),如果嵌套k层,则为O(n^k)。

+

如果其中一层循环中的n改为m,如下所示:

+
1
2
3
4
5
for(int i=1; i<=m; i++){
for(int j=1; j<=n;j++) {
printf(i,j);
}
}
+ +

那它的时间复杂度为O(m * n)

+
O(2^n)和O(n!)
1
2
3
for(int i=1; i<=math.pow(2,n); i++){
printf(i);
}
+ +
1
2
3
for(int i=1; i<=factorial(n); i++){
printf(i);
}
+ +

以上两个代码例子分别为O(2^n)复杂度和O(n!)复杂度,指数复杂度和阶乘复杂度属于非多项式量级,我们把时间复杂度为非多项式量级的算法问题叫作 NP(Non-Deterministic Polynomial,非确定多项式)问题。

+

当数据规模 n 越来越大时,非多项式量级算法的执行时间会急剧增加,求解问题的执行时间会无限增长。所以,非多项式时间复杂度的算法其实是非常低效的算法。

+

具体增长曲线如下图所示:

+

+

空间复杂度分析

    +
  • O(1)
  • +
  • O(n)
  • +
  • O(n²)
  • +
+
O(1)
1
2
3
int i=0;
int j=1;
int m = i + j;
+ +

代码中的i,j,m不随着处理数据量变化而变化,算法需要的临时空间是固定的,可以表示为空间复杂度O(1)

+
O(n)
1
2
3
4
5
int[] m = new int[n];
for(i=1; i<=n; ++i)
{
printf(i);
}
+ +

这段代码中,第一行new了一个数组出来,这个数据占用的大小为n,这段代码的2-5行,虽然有循环,但没有再分配新的空间,所以这段代码的空间复杂度为O(n)

+
O(n^2)

平方空间复杂度与O(n)类似,new出来的数组如果是n * n的多维数组,那就是O(n^2)复杂度,如果是m * n的多维数组,那就是O(m * n)复杂度。

+
1
2
int[][] a = new int[n][n];
int[][] a = new int[m][n];
+ +

总结

今天我们学习了复杂度分析的相关基础知识,包括了时间复杂度和空间复杂度,主要是用来分析算法执行效率与数据规模之间的增长关系。

+

常见的复杂度,从低阶到高阶有:O(1)、O(logn)、O(n)、O(nlogn)、O(n^2),几乎包括了后面学习的所有数据结构和算法的复杂度,希望大家可以好好的掌握。

+

+

时间复杂度分析还有最好,最坏,平均,均摊时间复杂度等话题需要好好讨论,请关注后续文章,我们会在刷题过程中穿插讲解。

+

参考资料

+ +