大 O 复杂度表示法
算法的执行效率,粗略地讲,就是算法代码执行的时间。
下方这段代码很简单,意为计算1 + 2 + 3 +…+ n的和
public void cal(int n) {
int sum = 0;
for (int i = 1; i <= n; i++) {
sum = sum + i;
}
}
假设每行代码执行的时间都一样,为 x
第二行定义变量,执行一次,定义for循环和循环内部各执行n次,就是2n,那么这段代码的总执行时间就是:(2n + 1) * x
尽管我们不知道x的具体时间,但是通过代码执行时间的推导过程,可以得到一个非常重要的规律,那就是,所有代码的执行时间 T(n) 与每行代码的执行次数 f(n) 成正比。
总结为公式就是:
T(n) = O(f(n))
- T(n) 表示代码执行的时间;
- n 表示数据规模的大小;
- f(n) 表示每行代码执行的次数总和。
大 O,表示代码的执行时间 T(n) 与 f(n) 表达式成正比。也叫做大 O 时间复杂度表示法。大 O 时间复杂度实际上并不具体表示代码真正的执行时间,而是表示代码执行时间随数据规模增长的变化趋势,所以,也叫作渐进时间复杂度,简称时间复杂度。
当 n 很大时,你可以把它想象成 10000、100000。而公式中的低阶、常量、系数三部分并不左右增长趋势,所以都可以忽略。只需要记录一个最大量级就可以了。
时间复杂度分析
1. 只关注循环执行次数最多的一段代码
因为在大O时间复杂度表示法中,低阶、常量、系数都可以忽略不计,所以在分析一个算法、一段代码的时间复杂度的时候,也只关注循环执行次数最多的那一段代码就可以了。这段核心代码执行次数的 n 的量级,就是整段要分析代码的时间复杂度。
public void cal(int n) {
int sum = 0;
for (int i = 1; i <= n; i++) {
sum = sum + i;
}
}
还是最开始的一段代码,其中第二行定义变量,与n的大小无关,所以对于复杂度并没有影响。循环执行次数最多的是第 3、4 行代码,所以这块代码要重点分析。这两行代码被执行了 n 次,所以总的时间复杂度就是 O(n)。
2. 加法法则
总复杂度等于量级最大的那段代码的复杂度
public int cal(int n) {
int num = 0;
for (int i = 1;i <= 100;i++) {
num = num + i;
}
int sum = 0;
for (int i = 1; i <= n; i++) {
sum = sum + i;
}
return num + sum;
}
这段代码分为两个部分,分别是求num和sum,返回最终相加的结果,分开剖析两段代码的时间复杂度,然后把它们放到一块儿,再取一个量级最大的作为整段代码的复杂度。
第一段代码共执行100次,所以是一个常量的执行时间,跟 n 的规模无关。即便这段代码循环 10000 次、100000 次,只要是一个已知的数,跟 n 无关,照样也是常量级的执行时间。当 n 无限大的时候,就可以忽略。尽管对代码的执行时间会有很大影响,但是回到时间复杂度的概念来说,它表示的是一个算法执行效率与数据规模增长的变化趋势,所以不管常量的执行时间多大,我们都可以忽略掉。因为它本身对增长趋势并没有影响。
第二段代码共执行n次,时间复杂度为O(n)。
我们取其中最大的量级。所以,整段代码的时间复杂度就为O(n)。也就是说:总的时间复杂度就等于量级最大的那段代码的时间复杂度。
3. 乘法法则
嵌套代码的复杂度等于嵌套内外代码复杂度的乘积
public void cal(int n) {
int num = 0;
for (int i = 0;i < n;i++) {
num = num + calTest(i);
}
}
private int calTest(int n) {
int sum = 0;
for (int i = 0;i < n;i++) {
sum = sum + i;
}
return sum;
}
单独看 cal() 函数。假设 calTest() 只是一个普通的操作,那这段代码的时间复杂度就是O(n),但 calTest() 函数本身不是一个简单的操作,它的时间复杂度是O(n),所以,整个 cal() 函数的时间复杂度就是,O(n*n) = O(n²)。
常见的时间复杂度
- 常量阶O(1)
- 对数阶O(logn)
- 线性阶O(n)
- 线性对折阶O(nlogn)
- 平方阶O(n²)
- 立方阶O(n³)
- k次方阶O(n的k次方)
- 指数阶O(2的n次方)
- 阶乘阶O(n!)
1. O(1)
O(1) 只是常量级时间复杂度的一种表示方法,并不是指只执行了一行代码。比如这段代码,即便有 3 行,它的时间复杂度也是 O(1),而不是 O(3)。
int i = 0;
int j = 0;
int k = 0;
总结:只要代码的执行时间不随 n 的增大而增长,这样代码的时间复杂度我们都记作 O(1)。或者说,一般情况下,只要算法中不存在循环语句、递归语句,即使有成千上万行的代码,其时间复杂度也是Ο(1)。
2.O(logn)、O(nlogn)
public void cal() {
i=1;
while (i <= n) {
i = i * 2;
}
}
根据前面的复杂度分析方法,第三行代码是循环执行次数最多的。所以,只要能计算出这行代码被执行了多少次,就能知道整段代码的时间复杂度。
从代码中可以看出,变量 i 的值从 1 开始取,每循环一次就乘以 2。当大于 n 时,循环结束。实际上,变量 i 的取值就是一个等比数列。一个一个列出来,就应该是这个样子的:
2的零次方,2的一次方,2的2次方,2的三次方…2的x次方 = n
通过 2的x次方 = n 求解。x=log2n,所以,这段代码的时间复杂度就是 O(log2n)。
同理将代码中的 i * 2 改为-> i * 3,时间复杂度为 O(log3n)。
基于前面的一个理论:在采用大 O 标记复杂度的时候,可以忽略系数,因此,在对数阶时间复杂度的表示方法里,忽略对数的“底”,统一表示为 O(logn)。
O(nlogn) 就很容易理解了。根据上述的乘法法则,如果一段代码的时间复杂度是 O(logn),循环执行 n 遍,时间复杂度就是 O(nlogn) 了。而且,O(nlogn) 也是一种非常常见的算法时间复杂度。比如,归并排序、快速排序的时间复杂度都是 O(nlogn)。
3.O(m+n)、O(m*n)
public int cal(int m, int n) {
int sum_1 = 0;
for (int i = 1;i < m;i++) {
sum_1 = sum_1 + i;
}
int sum_2 = 0;
for (int j = 1;j < n;j++) {
sum_2 = sum_2 + j;
}
return sum_1 + sum_2;
}
从代码中可以看出,m 和 n 是表示两个数据规模。无法事先评估 m 和 n 谁的量级大,所以在表示复杂度的时候,就不能简单地利用加法法则,省略掉其中一个。所以,上面代码的时间复杂度就是 O(m+n)。
乘法法则依然有效:T1(m) * T2(n) = O(f(m) * f(n))。
空间复杂度分析
时间复杂度的全称是渐进时间复杂度,表示算法的执行时间与数据规模之间的增长关系。类比一下,空间复杂度全称就是渐进空间复杂度,表示算法的存储空间与数据规模之间的增长关系。
public void print(int n) {
int i = 0;
int[] a = new int[n];
for (i;i < n;i++) {
a[i] = i * i;
}
for (i = n-1; i >= 0; i--) {
System.out.println(a[i]);
}
}
跟时间复杂度分析一样,可以看到,第 2 行代码中,申请了一个空间存储变量 i,但是它是常量阶的,跟数据规模 n 没有关系,所以可以忽略。第 3 行申请了一个大小为 n 的 int 类型数组,除此之外,剩下的代码都没有占用更多的空间,所以整段代码的空间复杂度就是 O(n)。
小节
复杂度也叫渐进复杂度,包括时间复杂度和空间复杂度,用来分析算法执行效率与数据规模之间的增长关系,可以粗略地表示,越高阶复杂度的算法,执行效率越低。常见的复杂度并不多,从低阶到高阶有:O(1)、O(logn)、O(n)、O(nlogn)、O(n²)。