(一)算法基礎-算法複雜度計算

目錄:c++

  1. 什麼是算法複雜度?
  2. T(n)表明什麼?如何計算?
  3. O(f(n))表明什麼?
  4. 常見的時間複雜度及推導舉例
  5. 什麼是最好、平均、最壞狀況?
  6. 什麼是空間複雜度?
  7. 常見排序算法的時間複雜度?
  8. 常見覆雜度函數的對比?

1. 什麼是算法複雜度

從概念上講,算法的複雜度是指算法在編寫成可執行程序後,其運行所須要的時間資源以及空間資源的大小。
通俗的講,就是該算法所須要的成本。咱們作一件事情,也會考慮到成本,就好比時間成本和金錢成本,達到相同結果的事情,咱們天然會以爲花的時間更少,花的金錢更少的事情會更加高效。因此一個算法的優劣,即是從其時間複雜度以及空間複雜度來衡量的。算法

2. T(n)表明什麼?如何計算?

那什麼是時間複雜度呢?
在講明白時間複雜度以前,咱們首先須要知道T(n)是什麼。一個算法運行所須要的時間理論上來說是沒有辦法算出來的,由於它受到運行的平臺以及硬件條件的影響。咱們也不須要知道一個算法所須要的具體時間是多少,假設算法內每個語句爲一個時間單位,那麼一個算法所須要的時間就與執行該算法的計算語句次數有關,若是計算語句次數越多,那麼其耗時確定也會越久,即算法消耗的時間與算法所須要的計算語句次數成正比。咱們將這個算法所須要執行的計算語句次數稱之爲「時間頻度」或「語句頻度」,記作T(n).數組

以下代碼,咱們看一下如何計算時間頻度函數

void calculate() {
    int arr[] = {1, 2, 3, 4, 5 ,6, 7, 8, 9};    // 1次
    int length = sizeof(arr) / sizeof(*arr);    // 1次
    int sum = 0;                                // 1次
    for (int i = 0; i < length; i++) {         
        sum += (*arr + i);                      // 1次 * 9
    }
    std::cout << "sum = " << sum << std::endl;  // 1次
}
複製代碼

在上面這個函數中,咱們能夠看到,整個函數執行的語句頻度爲1+1+1+1x9+1 = 13次,計做:T(n) = 13。 當arr這個數組內的元素越多時,那麼整個算法的時間頻度就會變得更高。若是咱們將這個數組的長度用n來表,那麼隨着n的遞增變化,這個函數的時間頻度也就遞增,n在這裏稱爲這個問題的規模。因此上面這個問題就理解爲: 解決一個規模大小爲n的問題,對應n+4次步驟,所須要的時間頻度爲T(n).那麼,calculate函數內的時間頻度便爲:T(n) = n + 4. 而當規模變化時,整個算法的時間頻度T(n)也就跟着變化了,當咱們想要了解這個變化規律時,便引入了時間複雜度的概念.學習

3. O(f(n))表明什麼?

若是存在一個正常數c,以及一個輔助函數f(n),可以使f(n) x c >= T(n),那麼就計做T(n) = O(f(n)),這裏的O(f(n))便稱爲算法的漸變時間複雜度(asymptotic time complexity),簡稱爲時間複雜度。 一般在T(n)函數中若是隨着n的不斷遞增變化,其中對整個問題複雜度的影響最大的一項,咱們將其做爲f(n),而會去掉影響稍若的係數或者常數。ui

4. 常見的時間複雜度及推導舉例

例3.1(O(1))常數階量級:spa

void function() {
   int sum = 0;                  // 1次
   for(int i = 0; i < 10; i++) {
       Sum += i;                 // 10次
   }
}
複製代碼

注意:O(1)表示時間複雜度爲一個常數,並不表明整個算法執行語句只有一句,只要時間複雜度不會隨着n的變化而持續變化的算法咱們便將其算法複雜度計做O(1),如上面的函數,T(n) = 10 + 1;3d

例3.2(O(n))線性階量級:code

void function() {
   int sum = 0;                  // 1次
   for(int i = 0; i < n; i++) {
       Sum += i;                 // n次
   }
}
複製代碼

上面這個函數T(n) = n + 1;當n無限大時,咱們會發現n會無限趨近於T(n),而常數1能夠忽略不計.因此這裏的 f(n) = n,這是一個線性增加的函數,計做O(n).cdn

例3.3(O(n^2))平方階量級:

void function() {
   int sum = 0;                  // 1次
   
   for(int i = 0; i < n; i++) {
       sum += i;                 // n次
       for(int j = 0; j < n; j++) {
          sum += j*I;           // n^2次
       }
   }
}
複製代碼

上面這個函數T(n) = n^2 + n + 1;一樣的道理,當n無限大時,咱們會發現n^2會無限趨近於T(n),而一次項n以及常數1,會隨着n的無限增大,其影響力變得越小,能夠忽略不計.因此這裏的 f(n) = n^2,這是一個二次函數,計做O(n^2).

例3.4(O(n^2))平方階量級:

void function() {
   int sum = 0;                  // 1次
   
   for(int i = 0; i < n; i++) {
       sum += i;                 // n次
       for(int j = i; j < n; j++) {
          sum += j*i;           // n*(n-i)次
       }
   }
}
複製代碼

當i = 0時,會執行1 + n + 1 次
當i = 1時,會執行1 + (n - 1) + 1 次
當i = 2時,會執行1 + (n - 2) + 1 次
……
當i = n時,會執行1 + (n - n) + 1次
因此
T(n) = n + ((n+1)xn)/2 + 1 = 1/2(n^2) + (3/2)xn + 1
上面這個函數T(n) = 1/2(n^2) + (3/2)xn + 1;一樣的道理,當n無限大時,咱們會發現n^2會無限趨近於T(n),而二次項係數1/2與整個一次項(3/2)*n以及常數1,會隨着n的無限增大,其影響力變得越小,能夠忽略不計.因此這裏的 f(n) = n^2,這是一個二次函數,計做O(n^2).

例3.5(O(n^k))k次方階量級:

void function() {
   int sum = 0;                  // 1次
   for(int i = 0; i < n; i++) {
       for (int j = 0; j < n; j++) {
           for (int k = 0; k < n; k++) {
               sum += i * j * k;        // n^3
           }
       }
   }
}
複製代碼

例3.6(O(2^n))指數階量級:

long function() {
   if (n < 2) {
       return 1;
   }
   return function(n-1) + function(n-2)
}
複製代碼

n = 0, T(n) = 1; n = 1, T(n) = 1; n = 2, T(n) = 2; n = 3, T(n) = 3; n = 4, T(n) = 5; n = 5, T(n) = 8; .... n = n, T(n) = T(n-1) + T(n-2); 很明顯這是一個斐波那契數列,其經過概括法能夠證實,n>4時,T(n) >= (3/2)^n,當咱們能夠講其成2^n,即計做T(n) = O(n^2).

例3.7(O(logn))或者(O(nlogn))對數階量級:

void function() {
   int sum = 1;                  
   while (sum < n) {
       sum = sum * 2;
   }
}
複製代碼

n = 1, T(n) = 0; (約等於log2(1)) n = 2, T(n) = 1; (等於log2(2) n = 3, T(n) = 2; (log2(3) 約等於log2(4)) n = 4, T(n) = 2;(等於log2(4)) n = 5, T(n) = 2; n = 6, T(n) = 2; n = 7, T(n) = 2; n = 8, T(n) = 3;(等於log2(8)) ..... n = n, T(n) = O(logn) 若是講循環中的sum = sum * 2還作sum = sum * 3,那麼結果約等於log3(n),可是經過換底公式,再去掉係數後,T(n) = O(logn).
O(nlogn),就是把上面的代碼在循環執行n遍了。歸併排序、快速排序的時間複雜度就是O(nlogn)

5. 什麼是最好、平均、最壞狀況?

一般一個算法的複雜度不只與其規模n的大小有關係,還會與數據源自己的初始狀態有關。例如在下列的冒泡排序(從小到大)算法中:

void bubbleSort(int arr[], int n) {
    if (n < 2) {
        return;
    }
    bool isAllInOrder = false;
    for (int i = 0; i < n-1 && !isAllInOrder; i++) {
        isAllInOrder = true;
        for (int j = 0; j < n - i - 1; j++) {
            if (arr[j] > arr[j + 1]) {
                int temp = arr[j];
                arr[j] = arr[j+1];
                arr[j+1] = temp;
                isAllInOrder = false;
            }
        }
    }
}
複製代碼

核心計算只有在數組中相鄰兩個元素,前者大於後者的狀況下才會去作交換。所以,若是數據源自己就是一個從小到大的有序的數組後,那麼該算法的時間複雜度將會是n,這就是咱們所說的最好狀況。可是,相反,若是數據源初始化時是一個從大到小排列的數組,那麼這個冒泡算法的核心計算將會進行(n-1)x(n-2)/2次,這就是該算法的最壞狀況。咱們在研究算法的時間複雜度時,都是以最壞的狀況來作比較的。至於平均狀況,實踐中能夠採將事前預估與過後統計相結合起來使用。好比若是咱們在計算一個10x10的矩陣相乘,運行時間爲10ms,那麼運行一個31x31的矩陣相乘能夠預估爲(31/10)^2.

冒泡排序的效率其實不是很高,快速排序是對冒泡排序的改進,下面咱們就再以快速排序(QuickSort)來舉例,經過計算來推導快速排序的最壞、最好和平均狀況的時間複雜度。
快速排序算法的核心思想就是選擇數組中任意一元素(一般會選擇第一個元素)做爲樞軸(pivot),將小於樞軸元素的數組元素放到左邊,大於樞軸元素的數組元素放到右邊,這樣就將原數組分紅了以樞軸元素爲中心的兩個數組,而後分別再對這兩個數組進行一樣的分割操做,直到分割到最後只剩兩個元素的數組。
在分割數組中(假定是從小到大排序),咱們使用low與high分別指向將要排序數組的第一個和最後一個元素。從high標記位置向前查詢,直到找到一個比pivot元素小的元素,將它與low位置對應的元素交換。而後再從low位置向後查詢,直到找到一個比pivot元素大的元素,將它與high位置進行交換。繼續這樣兩端交換查詢,對比,直到low==high,即low與high指向同一個元素,最後將pivot元素存放到low(或high)位置上,這樣就完成了一輪數組分割。
下面咱們使用圖來講明一下數組分割的具體操做,如圖:

這個長度爲8的元素通過三次排序後,便稱爲了一個有序序列。下面是具體的程序:

void quickSort(int arr[], int start, int end) {
    if (start >= end) {
        return;
    }
    int pivot = arr[start];
    int low = start;
    int high = end;
    while (low < high) {
        while (high > low && arr[high] >= pivot) {
            high --;
        }
        arr[low] = arr[high];
        
        while (low < high && arr[low] < pivot) {
            low ++;
        }
        arr[high] = arr[low];
    }
    arr[low] = pivot;
    
    
    quickSort(arr, start, low - 1);
    quickSort(arr, low + 1, end);
}
int arr[] = {23, 3, 12, 4, 7 , 8 ,23, 10,1, 11, 12, 2, 3};
quickSort(arr, 0, sizeof(arr) / sizeof(*arr) - 1);
複製代碼

那麼,有了算法,咱們再來推導一下該算法的最壞狀況。很明顯若是每進行一次分割,low或high其中一個位置一直不變的狀況下(即原數組是一個有序序列),那麼就會對比n-i次(i屬於[1,n-1]),直到排序完,整個時間頻度爲
(n-1) + (n-2) + (n - 3) .... + 2 + 1 = (n x (n - 1)) / 2 = (n^2 - n) / 2
當n趨近於無窮大時,n^2將會是主要影響因素,因此在最壞的狀況下,快速排序的時間複雜度是O(n^2).
快速排序的過程能夠看做是一棵二叉樹創建的過程,將原數據進行不停地分裂,每次分裂成兩個節點,直到最後分裂出的葉子節點裏只包含兩個元素爲止。每一次的分裂,須要的時間複雜度爲節點包含的元素個數減1。其實這樣咱們就知道,整個二叉樹的效率與這顆二叉樹的深度有關,深度越深,其效率就越低,相反,若是二叉樹的深度越淺,其效率就會越高。若是要保證其深度最淺,那麼只須要每次分裂時,其直接孩子節點的元素包含個數之差小於或等於1便可,如圖所示,若是咱們要將[1,16]這16個數據進行分裂,那最好的狀況就是每次都是平均分割。

下面,咱們來經過公式來計算最好狀況的時間複雜度。
若是每次都是平均分割,那麼T(n) = (n-1) + T(n/2) + T(n/2)
T(n) <= n + 2T(n/2)
T(n) <= n + 2(n/2 + 2T(n/4)) = 2n + 4T(n/4)
T(n) <= 2n + 4(n/4 + 2T(n/8)) = 3n + 8T(n/8)
T(n) <= 3n + 8(n/8 + 2T(n/16)) = 4n + 16T(n/16)
......
T(n) <= log(2,n)n + 2^log(2,n)T(2) ,其中T(2) = 1

T(n) <= log(2,n)n + 2^log(2,n) = nlgn + n
忽略掉最後的n,即快速排序最好的時間複雜度爲O(nlgn).
固然,這只是最好的狀況,那麼平均狀況該如何呢,又怎麼來計算呢?
第一次分割,記分割的爲止爲k,分割所須要的時間計做cn,c爲常數(最壞的狀況下c = (n-1)/n),那麼
T(n) = c*n + T(k-1) + T(n-k),其中k∊[1,n]
k在[1,n]任意一位置的機率爲1/n,那快速排序的平均時間複雜度可使用數學指望公式:

經過數學概括法能夠進一步證實其平均複雜 <= c*(n+1)ln(n=1),其中n>=2. 因此快速排序的平均時間複雜度也爲nlgn.

6. 什麼是空間複雜度?

與時間複雜度相似,咱們將空間複雜度(Space Complexity)計做:
S(n) = O(f(n))
一個算法除了須要指令(即程序)、輸入數據、常量、變量數據外,還須要對數據進行操做的工做單元以及爲輔助計算所須要的信息的輔助空間。若輸入數據所佔的空間只與問題自己有關係,而和算法無關,則只須要分析除輸入和程序以外的額外空間。若額外空間相對與輸入來講是一個常數,那麼咱們就稱此算法爲
原地工做

現在科技飛速發展,存儲空間已經不是什麼問題,對於通常的算法,如今也不多會對減小一些存儲空間而對算法大動干戈。

7. 常見排序算法的時間複雜度?

下面這張圖是在網上搜索到的一個用用排序時間複雜度的比較結論圖,能夠先大作個瞭解。後面的博客中,我將對每個排序算法進行單獨的分析。

image

8. 常見覆雜度函數的對比?

f(n) O name
c O(1) 常數函數
2n+c O(n) 線性函數
3n^2 + n + c O(n^2) 平方階函數
n^k + n + c O(n^k) 指數階函數
clog2n + c O(logn) 對數函數
nlog2n + c O(nlogn) 對數函數

image

總結

一般爲了去找到解決同一問題的更優算法,咱們採用算法複雜度來做爲衡量其優劣的方法。咱們在寫完一段程序後,用這樣的思惟去多思考一下是否還有更優解,在常年累月不斷鍛鍊本身的思惟的同時,也是對咱們自身能力的極大提高。

本文爲原創內容,供學習參考以及總結概括使用,若文章中有引用到涉及版權相關的圖片,請告知! 勘誤,請留言!

相關文章
相關標籤/搜索