一. 对数据规模又一个概念
想要在1s内解决问题:
- O(n2)的算法可以处理大约104级别的数据
- O(n)的算法可以处理大约10^8级别的数据
- O(nlogn)的算法可以处理大约10^7级别的数据
- 保险起见,在实际中最好降一个级
空间复杂度
- 递归调用是有空间代价的
空间复杂度O(1):
int sum1(int n){
assert(m >= 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(n2)O(n2)
选择排序
void selectionSort(int arr[], int n){
for(int i=0; i < n; i++){
int minIndex = i;
for(int j=i+1; j < n; j++){
minIndex = j;
}
swap( arr[i], arr[minIndex]);
}
}
-
O(n)O(n)
下面的代码是 30n次操作, 是O(n)级别的, 容易被当成O(n^2)
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)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;
}
-
O(logn)O(logn)
正整数转化为字符
string intToString( int num ){
string s = "";
while(num) {
s += '0' + num%10;
num /= 10;
}
reverse(s);
return s;
}
分析: n经过几次"除以10"操作后, 等于0?
log10n=O(logn)log10n=O(logn)
为什么 log10nlog10n
和 log2nlog2n
都是O(logn)O(logn)
级别的?
答:
logaNlogaN
和 logbNlogbN
可以相互转换:
logaN=logab∗logbNlogaN=logab∗logbN
logablogab
是一个常数
-
O(nlogn)O(nlogn)
void hello(int n){
for (int sz = 1; sz < n; sz += sz) // logn
for( int i = 1; i < n; i++){ //n
cout<<"hello , Algorithm!"<<endl;
}
}
第一个forlog2nlog2n
第二个for是nn
, 结合起来就是O(nlogn)O(nlogn)
-
O(n)O(n)
判断n是否为素数
bool isPrime( int n){
for(int x=2; x*x <= n; x++)
if( n%x == 0)
return false;
return true;
}
假如n不是素数, 必然有q<sqrt(n)和p>sqrt(n), p*q=n。
所以不难理解为什么x*x<=n了。
该程序还可以优化, 利用素数都是满足6x-1或6x+5的特性。 具体实现google一下即可
三. 递归算法的复杂度分析
递归中进行一次递归调用的复杂度分析
- 如果递归函数中, 只进行一次递归调用
- 递归深度为depth
- 在每个递归函数中, 时间复杂度为T
- 则总体的时间复杂度为O(T*depth)
- 实际案列
下面代码每次递归都少一半,所以depth是O(logn)
T显然是1
总体就是O(logn)
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)
}
下面代码每次递归都减少1 ,所以depth为n
T显然是1
总体就是 O(n)
int sum(int n){
assert( n >= 0);
if( n== 0)
return 0;
return n + sum(n-1);
}
下面求pow的代码: depth=logn T=1 总体O(logn)
用递归的方法比用n个x相乘,for循环实现的算法O(logn), 要快得多
double pow(double x, int n){
assert(n >= 0)
if(n == 0)
return 1.0;
double t = pow(x, n/2);
if( n%2 )
return x*t*t ; // n如果是奇数,n/2会舍掉一个1, 要补上
return t*t;
}
递归中进行多次递归调用
- 实例一
O(2n)O(2n)
int f(int n){
assert( n >= 0);
if( n == 0)
return 1;
return f(n-1) + f(n-1)
}
该程序的递归调用过程如下
3
/ \
2 2
/ \ / \
1 1 1 1
/ \ / \ / \ / \
0 0 0 0 0 0 0 0
两次的递归调用, 过程看作是二叉树
每一层处理的数字量是-1的, 一共n+1层
一共进行
2^0 + 2^1 + 2^2 + ... + 2^n
=2^(n+1) - 1 次运算,
每层运算复杂度是常数, 所以
O(2^n)
- 实例二
O(nlogn)O(nlogn)
归并排序:
void mergeSort(int arr[], int l, int r){
if(l >= r)
return;
int mid = (l+r)/2;
mergeSort(arr, l, mid);
mergeSort(arr, mid+1, r);
merge(arr, l, mid, r);
}
该程序的递归调用过程如下
8
/ \
4 4
/ \ / \
2 2 2 2
/ \ / \ / \ / \
1 1 1 1 1 1 1 1
两次的递归调用, 过程看作是二叉树
每层处理的数字两是减半的, 一共4层
进行了2^0 + 2^1 + .... + 2^log2(n) 次运算
每次运算处理的量为n,
所以O(nlogn)
四. 均摊复杂度分析
- 下面是一个动态数组的代码
#ifndef INC_06_AMORTIZED_TIME_MYVECTOR_H
#define INC_06_AMORTIZED_TIME_MYVECTOR_H
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){
if(size == capacity)
resize(2 * capacity);
data[size++] = e;
}
// 平均复杂度为 O(1)
T pop_back(){
assert(size > 0);
size --;
return data[size];
}
};
#endif //INC_06_AMORTIZED_TIME_MYVECTOR_H
- 分析
一开始容量capacity=100, 这时候往MyVector加元素时, 时间复杂度是O(1)
当size=capacity时, 我们需要resize,在for中进行了capacity次操作,复杂度为o(n)。
但这中操作只有当size=capacity时,才会做。在这之前进行了capacity次添加元素push_back, 每次复杂度为1。
将resize的操作均摊到之前的push_back上, 每次push_back由1变为了2, 算法依然是O(1)
五. 避免复杂度的震荡
- 假如MyVector中存储了大量元素,突然要删除部分元素, 我们在pop_back的时候并没有将多余的空间缩小。
- 完善pop_back
// 平均复杂度为 O(1)
T pop_back(){
assert(size > 0);
T ret = data[size-1];
size --;
// resize的容量是当前最大容量的1/2
if(size == capacity / 2)
resize(capacity / 2);
return ret;
- 对现在的pop_back进行均摊分析
假设当前容量capacity为2n, 当pop_back到size=n时, 会resize,复杂度为O(n)
与之前的pop_back均摊以后, pop_back由1变为2, 复杂度依然为O(1)
但是将push_back和pop_back一起看时, 会发现问题
- 当非常不巧的, 我们在size=capacity的地方,先push_back再pop_back,push_back和pop_back交替进行
- 这样每次push_back或pop_back都要resize,复杂度变为了O(n)
- 这样就与均摊的复杂度产生矛盾了。形成了复杂度震荡
解决办法
- 让push_back和pop_back错开
- push_back的resize判断条件依然不变
- pop_back的resize判断条件变为 size=capacity/4
// 平均复杂度为 O(1)
T pop_back(){
assert(size > 0);
T ret = data[size-1];
size --;
// 在size达到静态数组最大容量的1/4时才进行resize
// resize的容量是当前最大容量的1/2
// 防止复杂度的震荡
if(size == capacity / 4)
resize(capacity / 2);
return ret;