我们在编写一个算法的时候,需要衡量这个算法的好坏。由于程序的运行需要耗费时间和空间,所以我们可以从时间的空间两个维度来衡量。
即时间复杂度和空间复杂度
现在计算机存储容量的发展已经达到了较高的程度,所以我们更注重算法的时间复杂度。
(一个算法好坏的衡量还有其他维度,要根据具体的环境来判断)
什么是时间复杂度
时间复杂度是一个函数,它定性描述该算法的运行时间。
因为算法运行的时间我们没办法算出来,只有上机测试才能知道,不同的机器运行出来的时间也不一样,所以才需要分析时间复杂度。
算法中的基本操作的执行次数,为算法的时间复杂度。
即找到操作次数关于问题规模n的函数f(n),那么时间复杂度记为 O ( f ( n ) ) O(f(n)) O(f(n))
当输入量n逐渐增大时,时间复杂度的极限情形称为算法的“渐近时间复杂度”。
这说明,大O表示的是一种n趋于无穷大的极限情形。
大O表示法
int fun(int n) {
int count = 0;
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
count++;
}
}
for (int i = 0; i < 2 * n; i++) {
count++;
}
int m = 100;
while (m--) {
count++;
}
return count;
}
可以简单算出操作单元数随n的变化函数为
f
(
n
)
=
n
2
+
2
n
+
100
f(n)=n^2+2n+100
f(n)=n2+2n+100
n | count |
---|---|
100 | 10300 |
1000 | 1002100 |
10000 | 100020100 |
可以看到随着n的增大, 2 n + 100 2n+100 2n+100对函数值的影响越来越小, n 2 n^2 n2对数据规模的影响起主导作用。
也就是说,在n趋于无穷大的时候,后两项可以忽略,只要抓大头就可以了。
不同时间复杂度的差异
实际上时间复杂度是比较模糊的表示方法(因为忽略了影响较小的项和系数),选用算法也不一定时间复杂度越低越好
如: O ( 20 n 2 ) O(20n^2) O(20n2)和 O ( n 3 ) O(n^3) O(n3)虽然前者可以表示为 O ( n 2 ) O(n^2) O(n2),次数比后者小。但实际上当 n < 20 n<20 n<20的时候,后者的操作单元数比前者小。
而我们平时说 O ( n 2 ) O(n^2) O(n2)的算法比 O ( n 3 ) O(n^3) O(n3)的好,是在默认了数据量足够大的情况下,系数已经起不到决定性作用的时候才把系数给忽略了。
计算时间复杂度时候只要保留最高项,去掉较低的数量级和常数系数。
时间复杂度排行:
O(1)常数阶 < O ( log n ) O(\log n) O(logn)对数阶 < O ( n ) O(n) O(n)线性阶 < O ( n 2 ) O(n^2) O(n2)平方阶 < O ( n 3 ) O(n^3) O(n3)立方阶 < O ( 2 n ) O(2^n) O(2n)指数阶
另外有些算法的时间复杂度存在最好、平均和最坏情况:
最坏情况:任意输入规模的最大运行次数(上界)
平均情况:任意输入规模的期望运行次数
最好情况:任意输入规模的最小运行次数(下界)
例如:在一个长度为N数组中搜索一个数据x
最好情况:1次找到
最坏情况:N次找到
平均情况:N/2次找到
在实际中一般情况关注的是算法的最坏运行情况,所以数组中搜索数据时间复杂度为 O ( n ) O(n) O(n)
❔ 为什么对数阶的底可以忽略
假设有两个算法,它们的时间复杂度分别为 O ( log 2 n ) O(\log_2n) O(log2n)和 O ( log 10 n ) O(\log_{10}n) O(log10n)
由高中学的换底公式
log
2
n
=
log
10
n
log
10
2
\log_2n=\frac{\log_{10}n}{\log_{10}2}
log2n=log102log10n
其中
log
10
2
\log_{10}2
log102是个常数可以忽略,也就是说
O
(
log
2
n
)
O(\log_2n)
O(log2n)=
O
(
log
10
n
)
O(\log_{10}n)
O(log10n)
所以底数的影响可以忽略,都可以直接记为 O ( log n ) O(\log n) O(logn)
💯练习
求以下时间复杂度:
int fun(int n, int m) {
int count = 0;
for (int i = 0; i < n; i++) {
count++;
}
for (int i = 0; i < m; i++) {
count++;
}
return count;
}
时间复杂度为 O ( m + n ) O(m+n) O(m+n)
const char* strchr(const char* str, int character);
这是一个从字符串中搜索字符的库函数,遍历搜索最好1次,最坏n次,时间复杂度取最坏情况 O ( n ) O(n) O(n)
void bubble(int* arr, int sz) {
int i, j, tmp;
for (i = 0; i < sz - 1; i++) {
for (j = 0; j < sz - 1 - i; j++) {
if (arr[j] > arr[j + 1]) {
tmp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = tmp;
}
}
}
}
一个冒泡排序。数据规模n就表示数组大小。最好情况是数组已经排好序,遍历一遍执行n次。最坏是升序(降序)转降序(升序),每一趟的执行次数是上一趟的减1,等差数列求出执行 n ( n + 1 ) 2 \frac{n(n+1)}2 2n(n+1)次。简化最坏的情况得出时间复杂度 O ( n 2 ) O(n^2) O(n2)
int BinarySearch(int* a, int n, int x) {
int begin = 0;
int end = n;
while (begin < end) {
int mid = begin + ((end - begin) >> 1);
if (a[mid] < x)
begin = mid + 1;
else if (a[mid] > x)
end = mid;
else
return mid;
}
return -1;
}
这是一个二分查找。最好情况执行1次
最坏情况也就是找到最后一个
设执行次数为 x x x,数据规模为 n n n,因为每执行一次,数据量就对半减少,可以得出
n ( 1 2 ) x = 1 n(\frac12)^x=1 n(21)x=1
2 x = n 2^x=n 2x=n
x = log 2 n x=\log_2n x=log2n
取最坏情况,时间复杂度为 O ( log n ) O(\log n) O(logn)
注意二分查找必须保证数组是有序的。
🙉递归的时间复杂度
递归的时间复杂度 = 递归的次数 * 每次递归中的操作次数
int Fac(int N) {
if (0 == N)
return 1;
return Fac(N - 1) * N;
}
递归了n次,每次进行一个乘法操作。时间复杂度为 O ( n ) O(n) O(n)
int Fib(int N) {
if (N < 3)
return 1;
return Fib(N - 1) + Fib(N - 2);
}
斐波那契数列的递归算法。这个代码虽然简短,但是真的好吗?
如要求第五项
递归过程如下:
每一个节点代表递归了一次,n足够大可以看作一棵满二叉树,算出所有节点的数量,取最高项得时间复杂度为 O ( 2 n ) O(2^n) O(2n)
指数阶复杂度非常高,所以不建议这样计算斐波那契数列。
优化:
int Fib(int first, int second, int n) {
if (n < 3) {
return 1;
}
else if (n == 3) {
return first + second;
}
else {
return fib(second, first + second, n - 1);
}
}
其实就是first+second赋值给second,之前的second赋值给first。有递归前进段没有递归返回段,
每递归一次n - 1,所以只递归了n次,时间复杂度 O ( n ) O(n) O(n)
非递归但是同样的原理:
int Fib(int n) {
int n2 = 1, n1 = 1;
int temp;
if (n < 3) {
return 1;
}
n -= 2;
while (n--) {
temp = n1 + n2;
n2 = n1;
n1 = temp;
}
return n1;
}