算法(Algorithm)是指用来操作数据、解决程序问题的一组方法。对于同一个问题,使用不同的算法,也许最终得到的结果是一样的,比如排序就有前面的十大经典排序和几种奇葩排序,虽然结果相同,但在过程中消耗的资源和时间却会有很大的区别,比如快速排序与猴子排序:)。
那么我们应该如何去衡量不同算法之间的优劣呢?
主要还是从算法所占用的「时间」和「空间」两个维度去考量。
- 时间维度:是指执行当前算法所消耗的时间,我们通常用「时间复杂度」来描述。
- 空间维度:是指执行当前算法需要占用多少内存空间,我们通常用「空间复杂度」来描述。
从时间
的维度进行分析。
什么是大O
当看「时间」二字,我们肯定可以想到将该算法程序运行一篇,通过运行的时间很容易就知道复杂度了。
比如程序员小吴的老式电脑处理10w数据使用冒泡排序要几秒,但读者的iMac Pro 可能只需要0.1s,这样的结果误差就很大了。更何况,有的算法运行时间要很久,根本没办法没时间去完整的运行,还是比如猴子排序:)。
「 远古 」的程序员大佬们提出了通用的方法:「 大O符号表示法
」,即 T(n) = O(f(n))
。
其中 n 表示数据规模 ,O(f(n))表示运行算法所需要执行的指令数,和f(n)成正比。
常见的时间复杂度量级
我们先从常见的时间复杂度量级进行大O的理解:
- 常数阶O(1)
- 线性阶O(n)
- 平方阶O(n²)
- 对数阶O(logn)
- 线性对数阶O(nlogn)
O(1)
无论代码执行了多少行,其他区域不会影响到操作,这个代码的时间复杂度都是O(1)
void swapTwoInts(int &a, int &b){
int temp = a;
a = b;
b = temp;
}
---
const ab = (a,b) => {
a ^= b;
b ^= a;
a ^= b;
return {a,b}
}
O(n)
在下面这段代码,for循环里面的代码会执行 n 遍,因此它消耗的时间是随着 n 的变化而变化的,因此可以用 O(n)
来表示它的时间复杂度。
```
int sum ( int n ){
int ret = 0;
for ( int i = 0 ; i <= n ; i ++){
ret += i;
}
return ret;
}
const f = (n) => {
let num = 0;
for( let i = 0 ; i <= n ; i++) {
num += 1;
}
return num;
}
特别一提的是 c * O(n) 中的 c 可能小于 1 ,比如下面这段代码:
void reverse ( string &s ) {
int n = s.size();
for (int i = 0 ; i < n/2 ; i++) {
swap ( s[i] , s[n-1-i]);
}
}
const r = (arr) => {
const len = arr.length;
for( let i = 0 ; i <= len/2 ; i++) {
console.log(‘run this’);
arr[len - 1 - i] = ({x:arr[i],y:(arr[i] = arr[len - 1 - i])}).x;
}
console.log(arr)
return arr;
}
### O(n²)
当存在双重循环的时候,即把 O(n) 的代码再嵌套循环一遍,它的时间复杂度就是 O(n²) 了。
void selectionSort(int arr[],int n){
for(int i = 0; i < n ; i++){
int minIndex = i;
for (int j = i + 1; j < n ; j++ )
if (arr[j] < arr[minIndex])
minIndex = j;
swap ( arr[i], arr[minIndex]);
}
}
这里简单的推导一下:
- 当 i = 0 时,第二重循环需要运行 (n - 1) 次
- 当 i = 1 时,第二重循环需要运行 (n - 2) 次
- . . . . .
不难得到公式:
(n - 1) + (n - 2) + (n - 3) + … + 0
= (0 + n - 1) * n / 2
= O (n ^2)
当然并不是所有的双重循环都是 O(n²),比如下面这段输出 30n 次 Hello,五分钟学算法:)的代码。
void printInformation (int n ){
for (int i = 1 ; i <= n ; i++)
for (int j = 1 ; j <= 30 ; j ++)
cout<< “Hello,五分钟学算法:)”<< endl;
}
### O(logn)
int binarySearch( int arr[], int n , int target){
int l = 0, r = n - 1;
while ( l <= r) {
int mid = l + (r - l) / 2;
if (arr[mid] == target) return mid;
if (arr[mid] > target ) r = mid - 1;
else l = mid + 1;
}
return -1;
}
// todo
在二分查找法的代码中,通过while循环,成 2 倍数的缩减搜索范围,也就是说需要经过 log2^n 次即可跳出循环。
同样的还有下面两段代码也是 O(logn) 级别的时间复杂度。
// 整形转成字符串
string intToString ( int num ){
string s = “”;
// n 经过几次“除以10”的操作后,等于0
while (num ){
s += ‘0’ + num%10;
num /= 10;
}
reverse(s)
return s;
}
void hello (int n ) {
// n 除以几次 2 到 1
for ( int sz = 1; sz < n ; sz += sz)
for (int i = 1; i < n; i++)
cout<< “Hello,五分钟学算法:)”<< endl;
}
### O(nlogn)
将时间复杂度为O(logn)的代码循环N遍的话,那么它的时间复杂度就是 n * O(logn),也就是了O(nlogn)。
void hello (){
for( m = 1 ; m < n ; m++){
i = 1;
while( i < n ){
i = i * 2;
}
}
}
## 递归算法的时间复杂度
如果递归函数中,只进行一次递归调用,递归深度为depth;
在每个递归的函数中,时间复杂度为T;
**则总体的时间复杂度为O(T * depth)。**
在前面的学习中,归并排序 与 快速排序 都带有递归的思想,并且时间复杂度都是 `O(nlogn)` ,但并不是有递归的函数就一定是 `O(nlogn)` 级别的。从以下两种情况进行分析。
### 1) 递归中进行一次递归调用的复杂度分析
**二分查找法**
int binarySearch(int arr[], int l, int r, int target){
if( l > r ) return -1;
int mid = l + (r-l)/2;
if( arr[mid] == target ) return mid;
else if( arr[mid] > target )
return binarySearch(arr, l, mid-1, target); // 左边
else
return binarySearch(arr, mid+1, r, target); // 右边
}
// todo
比如在这段二分查找法的代码中,每次在 `[ l , r ]` 范围中去查找目标的位置,如果中间的元素 `arr[mid]` 不是 `target` ,那么判断 `arr[mid]` 是比 `target` 大 还是 小 ,进而再次调用 `binarySearch` 这个函数
在这个递归函数中,每一次没有找到 `target` 时,要么调用 左边 的 `binarySearch` 函数,要么调用 右边 的 `binarySearch` 函数。也就是说在此次递归中,最多调用了一次递归调用而已。根据数学知识,需要`log2n`次才能递归到底。因此,二分查找法的时间复杂度为 `O(logn)`。
### 求和
int sum (int n) {
if (n == 0) return 0;
return n + sum( n - 1 )
}
在这段代码中比较容易理解递归深度随输入 n 的增加而线性递增,因此时间复杂度为 `O (n)`。
![求幂](https://image.summer889.com/%E8%AE%A1%E7%AE%97%E6%9C%BA%E5%9F%BA%E7%A1%80/%E6%97%B6%E9%97%B4%E5%A4%8D%E6%9D%82%E5%BA%A6/shijianfuzadu2.gif)
//递归深度:logn
//时间复杂度:O(logn)
double pow( double x, int n){
if (n == 0) return 1.0;
double t = pow(x , n/2);
if (n % 2) return x * t * t;
return t * t;
}
递归深度为 ·,因为是求需要除以 2 多少次才能到底。
### 2) 递归中进行多次递归调用的复杂度分析
递归算法中比较难计算的是多次递归调用。
先看下面这段代码,有两次递归调用。
// O(2^n) 指数级别的数量级,后续动态规划的优化点
int f(int n) {
if (n == 0) return 1;
return f(n-1) + f(n - 1);
}
![二叉树](https://image.summer889.com/%E8%AE%A1%E7%AE%97%E6%9C%BA%E5%9F%BA%E7%A1%80/%E6%97%B6%E9%97%B4%E5%A4%8D%E6%9D%82%E5%BA%A6/shijianfuzadu3.gif)
递归树中节点数就是代码计算的调用次数。
比如 当 `n = 3` 时,调用次数计算公式为
1 + 2 + 4 + 8 = 15
一般的,调用次数计算公式为
2^0 + 2^1 + 2^2 + …… + 2^n
= 2^(n+1) – 1
= O(2^n)
![二叉树2](https://image.summer889.com/%E8%AE%A1%E7%AE%97%E6%9C%BA%E5%9F%BA%E7%A1%80/%E6%97%B6%E9%97%B4%E5%A4%8D%E6%9D%82%E5%BA%A6/shijianfuzadu4.gif)
与之有所类似的是 归并排序 的递归树,区别点在于
1. 上述例子中树的深度为 n,而 `归并排序` 的递归树深度为logn。
2. 上述例子中每次处理的数据规模是一样的,而在 归并排序 中每个节点处理的数据规模是逐渐缩小的
因此,在如 归并排序 等排序算法中,每一层处理的数据量为 `O(n)` 级别,同时有 `logn` 层,时间复杂度便是 `O(nlogn)`。
## 最好、最坏情况时间复杂度
![最好、最坏情况时间复杂度](https://image.summer889.com/%E8%AE%A1%E7%AE%97%E6%9C%BA%E5%9F%BA%E7%A1%80/%E6%97%B6%E9%97%B4%E5%A4%8D%E6%9D%82%E5%BA%A6/shijianfuzadu5.gif)
最好、最坏情况时间复杂度指的是特殊情况下的时间复杂度。
动图表明的是在数组 array 中寻找变量 x 第一次出现的位置,若没有找到,则返回 -1;否则返回位置下标。
int find(int[] array, int n, int x) {
for ( int i = 0 ; i < n; i++) {
if (array[i] == x) {
return i;
break;
}
}
return -1;
}
在这里当数组中第一个元素就是要找的 x 时,时间复杂度是 O(1);而当最后一个元素才是 x 时,时间复杂度则是 `O(n)`。
最好情况时间复杂度就是在最理想情况下执行代码的时间复杂度,它的时间是最短的;最坏情况时间复杂度就是在最糟糕情况下执行代码的时间复杂度,它的时间是最长的。
## 平均情况时间复杂度
最好、最坏时间复杂度反应的是极端条件下的复杂度,发生的概率不大,不能代表平均水平。那么为了更好的表示平均情况下的算法复杂度,就需要引入平均时间复杂度。
平均情况时间复杂度可用代码在所有可能情况下执行次数的加权平均值表示。
还是以 `find` 函数为例,从概率的角度看, x 在数组中每一个位置的可能性是相同的,为 1 / n。那么,那么平均情况时间复杂度就可以用下面的方式计算:
((1 + 2 + … + n) / n + n) / 2 = (3n + 1) / 4
`find` 函数的平均时间复杂度为 `O(n)`。
### 均摊复杂度分析
我们通过一个动态数组的 `push_back` 操作来理解 `均摊复杂度`。
![均摊复杂度](https://image.summer889.com/%E8%AE%A1%E7%AE%97%E6%9C%BA%E5%9F%BA%E7%A1%80/%E6%97%B6%E9%97%B4%E5%A4%8D%E6%9D%82%E5%BA%A6/shijianfuzadu6.gif)
template
class MyVector{
private:
T* data;
int size; // 存储数组中的元素个数
int capacity; // 存储数组中可以容纳的最大的元素个数
// 复杂度为 O(n)
void resize(int newCapacity){
T *newData = new T[newCapacity];
for( int i = 0 ; i < size ; i ++ ){
newData[i] = data[i];
}
data = newData;
capacity = newCapacity;
}
public:
MyVector(){
data = new T[100];
size = 0;
capacity = 100;
}
// 平均复杂度为 O(1)
void push_back(T e){
if(size == capacity)
resize(2 * capacity);
data[size++] = e;
}
// 平均复杂度为 O(1)
T pop_back(){
size –;
return data[size];
}
};
`push_back` 实现的功能是往数组的末尾增加一个元素,如果数组没有满,直接往后面插入元素;如果数组满了,即 `size == capacity` ,则将数组扩容一倍,然后再插入元素。
例如,数组长度为 n,则前 n 次调用 `push_back` 复杂度都为 O(1) 级别;在第 n + 1 次则需要先进行 n 次元素转移操作,然后再进行 1 次插入操作,复杂度为 O(n)。
因此,平均来看:对于容量为 n 的动态数组,前面添加元素需要消耗了 1 * n 的时间,扩容操作消耗 n 时间 ,总共就是 2 * n 的时间,因此均摊时间复杂度为 O(2n / n) = O(2),也就是 O(1) 级别了。
可以得出一个比较有意思的结论:一个相对比较耗时的操作,如果能保证它不会每次都被触发,那么这个相对比较耗时的操作,它所相应的时间是可以分摊到其它的操作中来的。
#