复杂度分析

什么是大O

n表示数据规模,则表示运行算法所需要执行的指令数,和成正比。

一个算法有多个步骤,每个步骤规模相同但时间复杂度不同,最终的时间复杂度以最高的那个为准。

对数据规模的概念

如果想要在1s之内解决问题:

  • 的算法可以处理大约级别的数据;
  • 的算法可以处理大约级别的数据;
  • 的算法可以处理大约级别的数据;

这个结论只是执行简单的数据操作,实际还需要再低估一下。

空间复杂度

  • 多开一个辅助数组:;
  • 多开一个辅助的二维数组:
  • 多开常数空间:;

需要注意:递归调用是有空间代价的,递归的深度就是空间复杂度。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
//计算n以内数据的和

//空间复杂度O(1)
int sum1(int n)
{
assert(n >= 0);
int ret = 0;
for(int i=0;i<=n;i++)
{
ret += i;
}
return ret;
}

//递归调用:空间复杂度O(n)
int sum2(int n)
{
assert(n>=0);
if(n == 0)
return 0;

return n + sum2(n-1);
}

简单的时间复杂度分析

O(1)

常数级的算法很简单,没有数据规模的变化

1
2
3
4
5
6
7
// O(1)
void swapTwoInts(int &a , int &b){
int temp = a;
a = b;
b = temp;
return;
}

O(n)

O(n)的算法典型的特征就是有个循环,并且循环的次数和n相关。

其实正常来说,应该是,其中C是个常数,且不一定大于1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// O(n)
int sum(int n){

assert(n >= 0);

int ret = 0;
for( int i = 0 ; i <= n ; i ++ )
ret += i;
return ret;
}

//字符串反转,只需要进行1/2次的swap操作即可
void reverse(string &s){

int n = s.size();
for(int i = 0 ; i < n/2 ; i ++)
swap( s[i] , s[n-1-i] );
return;
}

O(n^2)

见到算法内部有双重循环,每层循环都是n相关基本就八九不离十了。

1
2
3
4
5
6
7
8
9
10
11
12
//选择排序算法
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] );
}
}

千万要注意两层循环都要和n相关,不要看到双重循环就认为是

1
2
3
4
5
6
7
8
9
10

//第二重循环哪怕循环300w次,这个也是O(n)
void printInformation(int n){

for( int i = 1 ; i <= n ; i ++ )
for( int j = 1 ; j <= 30 ; j ++ )
cout << "Class " << i << " - " << "No. " << j << endl;
return;
}

O(logn)

经典的二分查找法:

1
2
3
4
5
6
7
8
9
10
11
12
//二分查找法
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;
}

将数字整形转化为字符串:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
string intToString(int num){

string s = "";
string sign = "+";
if(num < 0){
num = -num;
sign = "-";
}

while(num){
s += '0' + num % 10;
num /= 10;
}

if(s == "")
s = "0";

reverse(s);
if(sign == "-")
return sign + s;
else
return s;
}

分析算法思想,核心在" num /= 10"步中,就是n经过几次“除以10”操作后等于0,那么就是;

虽然上面两个例子,一个是以2为底,一个是以10为底,但都是,这个可以通过对数函数的换底公式来理解:有换底公式,可见之间只相差一个常数,在时间复杂度下可以直接理解为

还需要注意,双重循环也可能出现logN的复杂度,需要注意量增长的规模:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
//复杂度:O(nlogn)
void hello(int n){

for( int sz = 1 ; sz < n ; sz += sz )
for( int i = 1 ; i < n ; i ++ )
cout << "Hello, Algorithm!" << endl;
}

//外层循环每次增长可以理解为sz*2
//其实就可以理解为n经过几次“除以2”的操作后等于1,外层循环复杂度O(logn)
//内层循环复杂度是O(n)
//合起来是O(nlogn)



//判断是否为素数
// O(sqrt(n))
bool isPrime(int num){

if( num <= 1 ) return false;
if( num == 2 ) return true;
if( num % 2 == 0 ) return false;

for(int x = 3 ; x * x <= num ; x += 2)
if( num%x == 0 )
return false;

return true;
}

//

递归算法的复杂度分析

面对递归算法需要具体问题具体分析。

递归中只会进行一次递归调用

如果递归函数中只进行一次递归调用,递归深度为depth,在每个递归函数中的时间复杂度为T,则总体的时间复杂度为,既只需关注递归的深度

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//递归调用的二分查找法
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);

}

  二分查找法很容易用递归实现,上述代码每次调用时,内部要么直接返回,要么数组左半边调用递归,要么数组右半边调用递归,只会调用一次,所以分析时间复杂度只有看递归的深度即可。

  每次调用数组减少一半,是典型的logn情况,所以复杂度为

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// sum,O(n)
int sum(int n){

assert(n >= 0);

if(n == 0)
return 0;
return n + sum(n - 1);
}

// 求x的n次方:O(logn)
double pow(double x, int n){

assert(n >= 0);

if(n == 0)
return 1.0;

double t = pow(x, n / 2); //每次减半,logn
if(n % 2)
return x * t * t;

return t * t;
}

递归中进行多次递归调用

当递归中有多次递归调用时,就需要关注递归调用的次数了。如果递归中只调用一次,深度就是次数,但如果调用了多次,那么深度和次数就是两个概念了。

1
2
3
4
5
6
7
8
9
10
11
//O(2^n)
//这个是指数级的算法,是个非常慢的算法
int f(int n)
{
assert(n >= 0);

if(n == 0)
return 1;

return f(n-1) + f(n-1);
}

均摊复杂度分析

  有时候会遇到这种情况:解决某个问题时运用了一系列算法,某个算法的复杂度很高,但这个算法可以降低其他算法的复杂度,这时候就需要计算分摊复杂度。

  均摊复杂度的经典问题就是动态数组vector。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
template <typename T>
class MyVector{

private:
T* data;
int size; // 存储数组中的元素个数
int capacity; // 存储数组中可以容纳的最大的元素个数

// 复杂度为 O(n)
void resize(int newCapacity){

assert(newCapacity >= size);
T *newData = new T[newCapacity];
for( int i = 0 ; i < size ; i ++ )
newData[i] = data[i];
delete[] data;

data = newData;
capacity = newCapacity;
}

public:
MyVector(){

data = new T[100];
size = 0;
capacity = 100;
}

~MyVector(){

delete[] data;
}

// 平均复杂度为 O(1)
void push_back(T e){
//当元素到达容量上限时,需要调用resize扩大空间
if(size == capacity)
resize(2 * capacity);

data[size++] = e;
}

// 平均复杂度为 O(1)
T pop_back(){

assert(size > 0);
size --;

return data[size];
}

};

  上面的代码只在push操作时resize了空间,但是没有在pop操作时resize空间。我们当然可以参考push操作,在pop到capacity一半时resize容量,但这里涉及一个问题,就是要防止复杂度震荡

  考虑这个问题:当push到临界点时resize一倍空间,然后立即pop,此时又resize为一半,然后立即push,这种极端场景下时间复杂度无法均摊,会退化为

  如果想避免这种场景,可以不再临界点处resize,当pop操作到达capacity的1/4处时,resize为1/2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 平均复杂度为 O(1)
T pop_back(){

assert(size > 0);
T ret = data[size-1]; //这里一定要提前拿出来,resize操作会修改data
size --;

// 在size达到静态数组最大容量的1/4时才进行resize
// resize的容量是当前最大容量的1/2
// 防止复杂度的震荡
if(size == capacity / 4)
resize(capacity / 2);

return ret;
}