齊姐漫畫:排序演算法(一)

小齊本齊發表於2020-05-27

插入排序

借用《演算法導論》裡的例子,就是我們打牌的時候,每新拿一張牌都會把它按順序插入,這,其實就是插入排序。

齊姐宣告:雖然我們用打牌的例子,但是可不能學胡適先生啊。

對於陣列來說怎麼做呢?

有一個重要的思想,叫做擋板法,就是用擋板把陣列分成兩個區間:

  • 擋板左邊:已排序
  • 擋板右邊:未排序

那麼排序分三步走

  1. 最初擋板是在陣列的最左邊,保證已排序區間裡一個數都沒有,或者也可以包含一個數啦;
  2. 核心思想就是:

依次遍歷未排序區間裡的元素,在已排序區間裡找到正確的位置插入;

  1. 重複這個過程,直到未排序區間為空。

舉個例子:{5, 2, 1, 0}

第一步,擋板最初在這裡:

第二步,
把 2 插入已排序區間的正確位置,變成:

重複這個步驟,把 1 排好:

最後把 0 排好:

那程式碼也很簡單:

public void insertionSort(int[] input) {
    if(input == null || input.length <= 1) {
        return;
    }
    for(int i = 1; i < input.length; i++) {
        int tmp = input[i];
        int j = i - 1;
        while(j >= 0 && input[j] > tmp) {
            input[j+1] = input[j];
            j --;
        }
        input[j+1] = tmp;
    }
}

我們來分析一下這個演算法的時空複雜度。

時間複雜度

關於時間複雜度大 O 有兩個要點

  • 是描述隨著自變數的增長,所需時間的增長率;
  • 是漸近線複雜度,就是說

    • 不看係數
    • 只看最高階項

那麼我們關心的 worst case 的情況就是:
如果陣列是近乎倒序的,每次插入都要在陣列的第一個位置插入,那麼已排序區間內的所有的元素都要往後移動一位,這一步平均是 O(n),那麼重複 n 次就是 O(n^2).

空間複雜度

重點是一個峰值的概念,並不是累計使用的空間。

這裡是 O(1) 沒什麼好說的。

引入一個概念:sorted in place,也就是原地排序

原地排序就是指空間複雜度為 O(1) 的演算法,因為沒有佔用額外的空間,就是原地打轉嘛。

其實 in-place 的思想並不是只在排序演算法裡有,只不過排序演算法是一個最廣為人知的例子罷了。本質上就是一個節省使用空間的思想。

但是對於排序演算法,只分析它的時空複雜度是不夠的,還有另外一個重要指標:

穩定性

意思是元素之間的相對順序是否保持了不變。

比如說:{5, 2, 2, 1, 0}

這個陣列排序完成後這裡面的兩個 2 的相對順序沒有變,那麼這個排序就是一個穩定排序。

那有同學可能就想,順序變了又有什麼關係呢?

其實,在實際工作中我們排序的物件不會只是一個數字,而是一個個的物件 (object),那麼先按照物件的一個性質來排序,再按照另一個性質來排序,那就不希望原來的那個順序被改變了。好像有點抽象,我們舉個例子。

比如在股票交易系統裡,有買賣雙方的報價,那是如何匹配的呢?

  • 先按照價格排序;
  • 在相等的價格中,按照出價的時間順序來排序。

那麼一般來說系統會維持一個按時間排序的價格序列,那麼此時只需要用一個具有穩定性的排序演算法,再按照價格大小來排序就好了。因為穩定性的排序演算法可以保持大小相同的兩個物件仍維持著原來的時間順序。

那麼插入排序是否是穩定性的排序呢?

答案是肯定的。因為在我們插入新元素的時候是從後往前檢查,並不是像打牌的時候隨便插一個位置不能保證相對順序。

大家可以看下下面的動畫 就非常清楚了~

優化

插入排序其實是有很大的優化空間的,你可以搜一下“希爾排序”。

在剛開始學習的時候,深度固然重要,但因為廣度不夠,如果學的太深可能會很痛苦,一個知識點就無窮無盡的延展,這並不是一個高效的學習方式。

時間有限時還要做好深度和廣度的平衡:

  • 在常用常考的知識點上多花時間精力,追求深度;
  • 在一些擴充性的知識點上點到為止,先知道有這麼回事就行。

保持 open minded 的心態,後期就會有質的提高。

選擇排序

選擇排序也是利用了“擋板法”這個經典思想。

擋板左邊是已排序區間,右邊是未排序區間,那麼每次的“選擇”是去找右邊未排序區間的最小值,找到之後和擋板後面的第一個值換一下,然後再把擋板往右移動一位,保證排好序的這些元素在擋板的左邊。

比如之前的例子:{5, 2, 0, 1}

我們用一個擋板來分隔陣列是否排好序,
用指標 j 來尋找未排序區間的最小值;

第一輪 j 最初指向 5,然後遍歷整個未排序區間,最終指向 0,那麼 0 就和擋板後的第一個元素換一下,也就是和 5 交換一下位置,擋板向右移動一位,結束第一輪。

第二輪,j 從擋板後的2開始遍歷,最終指向1,然後1和擋板後的第一個元素 2 換一下,擋板向右移動一位,結束第二輪。

第三輪,j 從2開始遍歷,最終指向2,然後和2自己換一下,擋板向右移動一位,結束第三輪。

還剩一個元素,不用遍歷了,就結束了。

選擇排序與之前的插入排序對比來看,要注意兩點:

  1. 擋板必須從 0 開始,而不能從 1 開始。雖然在這兩種演算法中,擋板的物理意義都是分隔已排序和未排序區間,但是它們的已排序區間裡放的元素的意義不同:
  • 選擇排序是隻能把當前的最小值放進來,而不能放其他的;
  • 插入排序的第一個元素可以為任意值。

所以選擇排序的擋板左邊最開始不能有任何元素。

  1. 在外層迴圈時,
  • 選擇排序的最後一輪可以省略,因為只剩下最大的那個元素了;
  • 插入排序的最後一輪不可省略,因為它的位置還沒定呢。

    class Solution {
      public void selectionSort(int[] input) {
        if(input == null || input.length <= 1) {
          return;
        } 
        for(int i = 0; i < input.length - 1; i++) {
          int minValueIndex = i;
          for(int j = i + 1; j < input.length; j++) {
            if(input[j] < input[minValueIndex]) {
              minValueIndex = j;
            }
          }
          swap(input, minValueIndex, i);
        }
      }
      private void swap(int[] input, int x, int y) {
        int tmp = input[x];
        input[x] = input[y];
        input[y] = tmp;
      }
    }

時間複雜度

最內層的 if 語句每執行一次是 O(1) ,那麼要執行多少次呢?

  • 當 i = 0 時,是 n-1 次;
  • 當 i = 1 時,是 n-2 次;
  • ...
  • 最後是 1 次;

所以加起來,總共是:
(n-1) + (n-2) + … + 1 = n*(n-1) / 2 = O(n^2)

是這樣算出來的,而不是一拍腦袋說兩層迴圈就是 O(n^2).

空間複雜度

這個很簡單,最多的情況是 call swap() 的時候,然後 call stack 上每一層就用了幾個有限的變數,所以是 O(1)。

那自然也是原地排序演算法了。

穩定性

這個答案是否定的,選擇排序並沒有穩定性。

因為交換的過程破壞了原有的相對順序,比如: {5, 5, 2, 1, 0} 這個例子,第一次交換是 0 和 第一個 5 交換,於是第一個 5 跑到了陣列的最後一位,且再也無翻身之地,所以第一個 5 第二個 5 的相對順序就已經打亂了。

這個問題在石頭哥的那篇谷歌面經文章裡有被考到哦,如果還沒有看過這篇面經文章的,在「碼農田小齊」公眾號裡回覆「谷歌」二字,就可以看到了。

優化

選擇排序的其中一步是選出每一輪的最小值,那麼這一步如果使用 heapify() 來優化,就可以從 O(n) 優化到 O(logn),這其實就變成了 heapSort.


相關文章