1. 時間複雜度分析
阿新 • • 發佈:2018-12-01
一. 對資料規模又一個概念
想要在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;