「筆記」數據結構與算法之美 - 基礎篇(二)

排序(上)

img

  • 如何分析一個排序算法

    • 排序算法的執行效率
      • 最好情況、最壞情況、平均情況時間複雜度
        • 有序度不同的數據,對於排序的執行時間肯定是有影響的
      • 時間複雜度的係數、常數 、低階
        • 在對同一階時間複雜度的排序算法性能對比的時候,我們就要把係數、常數、低階也考慮進來
      • 比較次數和交換(或移動)次數
    • 排序算法的內存消耗
      • 原地排序算法,就是特指空間複雜度是 O(1) 的排序算法
    • 排序算法的穩定性
      • 穩定性:如果待排序的序列中存在值相等的元素,經過排序之後,相等元素之間原有的先後順序不變
  • 有序度 與 逆序度

    • 有序度是數組中具有有序關係的元素對的個數

      img

      • 對於一個完全有序的數組,比如 1,2,3,4,5,6,有序度就是 n*(n-1)/2;也被稱爲滿有序度
    • 逆序度的定義正好跟有序度相反(默認從小到大爲有序)

    • 公式:逆序度 = 滿有序度 - 有序度

    • 排序的過程就是一種增加有序度,減少逆序度的過程,最後達到滿有序度,就說明排序完成

  • 冒泡排序(Bubble Sort)

    • 每次冒泡操作都會對相鄰的兩個元素進行比較,看是否滿足大小關係要求(如果不滿足就讓它倆互換)

      img

    • 性能分析

      • 是原地排序算法
      • 是穩定排序算法
      • 時間複雜度
        • 最好時間複雜度是 O(n)
        • 最壞時間複雜度是 O(n^2)
        • 平均時間複雜度是 O(n^2)
  • 插入排序(Insertion Sort)

    • 將數組中的數據分爲兩個區間,已排序區間和未排序區間

      • 插入算法的核心思想是取未排序區間中的元素,在已排序區間中找到合適的插入位置將其插入

      • 並保證已排序區間數據一直有序,重複這個過程,直到未排序區間中元素爲空,算法結束

        img

        • 插入排序也包含兩種操作,一種是元素的比較,一種是元素的移動
    • 性能分析

      • 是原地排序算法
      • 是穩定排序算法
      • 時間複雜度
        • 最好時間複雜度是 O(n)
        • 最壞時間複雜度是 O(n^2)
        • 平均時間複雜度是 O(n^2)
  • 選擇排序(Selection Sort)

    • 分已排序區間和未排序區間,每次會從未排序區間中找到最小的元素,將其放到已排序區間的末尾

      img

    • 性能分析

      • 是原地排序算法
      • 不是穩定排序算法
      • 時間複雜度
        • 最好時間複雜度是 O(n^2)
        • 最壞時間複雜度是 O(n^2)
        • 平均時間複雜度是 O(n^2)

    排序(下)

  • 歸併排序的原理

    img

    • 歸併排序使用的就是分治思想。分治,顧名思義,就是分而治之,將一個大問題分解成小的子問題來解決

      • 分治是一種解決問題的處理思想,遞歸是一種編程技巧
    • 依次合併子數組

      img

  • 歸併排序的性能分析

    • 不是原地排序算法(空間複雜度是 O(n))
    • 是穩定排序算法
    • 時間複雜度
      • 不管是最好情況、最壞情況,還是平均情況,時間複雜度都是 O(nlogn)
  • 快速排序的原理

    • 快排的思想是這樣的:如果要排序數組中下標從 p 到 r 之間的一組數據,我們選擇 p 到 r 之間的任意一個數據作爲 pivot(分區點)

      • 遍歷 p 到 r 之間的數據,將小於 pivot 的放到左邊,將大於 pivot 的放到右邊,將 pivot 放到中間

      • 數組 p 到 r 之間的數據就被分成了三個部分,根據分治、遞歸的處理思想繼續遞歸左右部分

        img

    • 分區的整個過程(原地排序的方式實現)

      img

      • 這裏我們只需要將 A[i]與 A[j]交換,就可以在 O(1) 時間複雜度內將 A[j]放到下標爲 i 的位置
  • 快速排序的性能分析

    • 是原地排序算法
    • 不是穩定排序算法
    • 時間複雜度
      • 最好時間複雜度是 O(nlogn)
      • 最壞時間複雜度是 O(n^2)
      • 平均時間複雜度是 O(nlogn)
  • 快排和歸併用的都是分治思想,那它們的區別在哪裏呢?

    img

    • 方向
      • 歸併排序的處理過程是由下到上的,先處理子問題,然後再合併
      • 快排正好相反,它的處理過程是由上到下的,先分區,然後再處理子問題
    • 空間複雜度及穩定性
      • 歸併排序雖然是穩定的,但不是原地排序(原因是合併函數無法在原地執行)
      • 快速排序是不穩定排序,但是是原地排序(通過設計巧妙的原地分區函數來實現的)

線性排序

  • 線性排序:排序算法的時間複雜度是線性的(例子:桶排序、計數排序、基數排序

  • 桶排序(Bucket sort)

    • 核心思想是將要排序的數據分到幾個有序的桶裏,每個桶裏的數據再單獨進行排序

      • 桶內排完序之後,再把每個桶裏的數據按照順序依次取出,組成的序列就是有序的了

        img

    • 桶排序對要排序數據的要求是非常苛刻的

      • 要排序的數據需要很容易就能劃分成 m 個桶,並且,桶與桶之間有着天然的大小順序
      • 數據在各個桶之間的分佈是比較均勻的(極端情況下,數據都被劃分到一個桶裏,那就退化爲 O(nlogn) 了
        • 可以對熱點桶數據再一步劃分,劃分成多個子桶
    • 桶排序比較適合用在外部排序中

      • 所謂的外部排序就是數據存儲在外部磁盤中,數據量比較大,內存有限,無法將數據全部加載到內存中
  • 計數排序(Counting sort)

    • 當要排序的 n 個數據,所處的範圍並不大的時候,比如最大值是 k,我們就可以把數據劃分成 k 個桶

      • 每個桶內的數據值都是相同的,省掉了桶內排序的時間
      • 個人覺得,計數排序其實是桶排序的一種特殊情況,應用場景:高考成績快速排序得出名次
    • 計數排序全過程

      img

      • C[6]內存儲的並不是考生,而是對應的考生個數
    • 計數排序只能用在數據範圍不大的場景中,如果數據範圍 k 比要排序的數據 n 大很多,就不適合用計數排序

      • 計數排序只能給非負整數排序,如果要排序的數據是其他類型的,要將其在不改變相對大小的情況下,轉化爲非負整數
  • 基數排序(Radix sort)

    • 基數排序的過程分解圖

      img

      • 這裏按照每位來排序的排序算法要是穩定的,否則這個實現思路就是不正確的
    • 時間複雜度分析

      • 根據每一位來排序,我們可以用剛講過的桶排序或者計數排序,它們的時間複雜度可以做到 O(n)
      • 如果要排序的數據有 k 位,那我們就需要 k 次桶排序或者計數排序,總的時間複雜度是 O(k*n)
      • 當 k 不大的時候,比如手機號碼排序的例子,k 最大就是 11,所以基數排序的時間複雜度就近似於 O(n)
    • 對於待排序字段長度位數不一致的場景,可以通過低位補 0 的方式補齊(因爲根據ASCII 值,所有字母都大於 0

    • 基數排序對要排序的數據是有要求的

      • 要可以分割出獨立的“位”來比較,而且位之間有遞進的關係,若 a 數據的高位比 b 數據大,則 a 直接大於 b
      • 每一位的數據範圍不能太大,要可以用線性排序算法來排序,否則,基數排序的時間複雜度就無法做到 O(n)

排序優化

  • 如何選擇合適的排序算法

    img

    • 如果對小規模數據進行排序,可以選擇時間複雜度是 O(n2) 的算法
    • 如果對大規模數據進行排序,時間複雜度是 O(nlogn) 的算法更加高效
      • 爲了兼顧任意規模數據的排序,首選時間複雜度是 O(nlogn) 的排序算法
      • 歸併排序和快排平均時間複雜度都是O(nlogn) ,但前者不是原地排序(空間複雜度爲O(n)
  • 如何優化快速排序

    • 最壞時間複雜度出現的主要原因還是因爲我們分區點選的不夠合理
      • 最理想的分區點是:被分區點分開的兩個分區中,數據的數量差不多
    • 三數取中法
    • 從區間的首、尾、中間,分別取出一個數,然後對比大小,取這 3 個數的中間值作爲分區點
    • 如果要排序的數組比較大,可以擴大參考數量,比如要“五數取中”或者“十數取中”等
    • 隨機法
      • 隨機法就是每次從要排序的區間中,隨機選擇一個元素作爲分區點
      • 時間複雜度退化爲最糟糕的 O(n2) 的情況,出現的可能性不大
  • 舉例分析排序函數

    • 拿 Glibc 中的 qsort() 函數舉例說明一下
      • qsort() 會優先使用歸併排序來排序輸入數據,因爲歸併排序的空間複雜度是 O(n)
        • 對於小數據量的排序,比如 1KB、2KB 等,歸併排序額外需要 1KB、2KB 的內存空間,這個問題不大
      • 要排序的數據量比較大的時候,qsort() 會改爲用快速排序算法來排序
        • qsort() 選擇分區點的方法就是“三數取中法”
      • 如何避免遞歸太深導致堆棧溢出的問題
        • sort() 是通過自己實現一個堆上的棧,手動模擬遞歸來解決的
      • qsort() 並不僅僅用到了歸併排序和快速排序,它還用到了插入排序
        • 在快速排序的過程中,當要排序的區間中,元素的個數小於等於 4 時,qsort() 就退化爲插入排序
        • 在小規模數據面前,O(n2) 時間複雜度的算法並不一定比 O(nlogn) 的算法執行時間長
          • O(nlogn) 在沒有省略低階、係數、常數之前可能是 O(knlogn + c),k 和 c 可能是一個比較大的數
          • 當我們對小規模數據(比如 n=100)排序時,n2的值實際上比 knlogn+c 還要小
      • 在 qsort() 插入排序的算法實現中也利用了哨兵來簡化代碼,提高執行效率
        • 雖然哨兵可能只是少做一次判斷,但是排序函數是非常常用、非常基礎的函數,性能的優化要做到極致

二分查找(上)

  • 一種針對有序數據集合的查找算法:二分查找(Binary Search)算法,也叫折半查找算法

  • 無處不在的二分思想

    • 二分查找針對的是一個有序的數據集合,查找思想有點類似分治思想
    • 每次都通過跟區間的中間元素對比,將待查找的區間縮小爲之前的一半,直到找到要查找的元素(區間爲 0 時
  • O(logn) 驚人的查找速度

    • 這是一種極其高效的時間複雜度,有的時候甚至比時間複雜度是常量級 O(1) 的算法還要高效
    • 即便 n 非常非常大,對應的 logn 也很小(例如在 42 億個有序數據中用二分查找一個數據,最多需要比較 32 次
  • 二分查找的實現

    • 最簡單的情況就是有序數組中不存在重複元素

      • 
        public int bsearch(int[] a, int n, int value) {
          int low = 0;
          int high = n - 1;
        
          while (low <= high) {
            int mid = (low + high) / 2;
            if (a[mid] == value) {
              return mid;
            } else if (a[mid] < value) {
              low = mid + 1;
            } else {
              high = mid - 1;
            }
          }
        
          return -1;
        }
        
    • 容易出錯的 3 個地方

      • 循環退出條件(注意是 low<=high,而不是 low
      • mid 的取值
        • mid=(low+high)/2 這種寫法是有問題的,可能會有溢出風險,可以改成 low+(high-low)/2
        • 可以將這裏的除以 2 操作轉化成位運算 low+((high-low)>>1) ,計算機處理位運算要快得多
      • low 和 high 的更新(注意這裏的 +1 和 -1,如果直接寫成 low=mid 或者 high=mid,就可能會發生死循環
  • 二分查找應用場景的侷限性

    • 首先,二分查找依賴的是順序表結構,簡單點說就是數組
      • 要原因是二分查找算法需要按照下標隨機訪問元素(鏈表無法有效支持,時間複雜度會變得特別高
    • 其次,二分查找針對的是有序數據
      • 二分查找只能用在插入、刪除操作不頻繁,一次排序多次查找的場景中
    • 再次,數據量太小不適合二分查找
      • 數量小時,直接順序遍歷即可,查找速度都差不多
      • 如果數據之間的比較操作非常耗時,不管數據量大小,我都推薦使用二分查找
    • 最後,數據量太大也不適合二分查找
      • 二分查找的底層需要依賴數組這種數據結構,要求內存空間連續,對內存的要求比較苛刻
      • 比如,我們有 1GB 大小的數據,如果希望用數組來存儲,那就需要 1GB 的連續內存空間

二分查找(下)

  • 4種場景的二分查找變形問題

    • 變體一:查找第一個值等於給定值的元素

      • 比如這樣一個有序數組,其中,a[5],a[6],a[7]的值都等於 8,是重複的數據,找出第一個(下標爲5 那個

        img

      • 代碼實現

        • 
          public int bsearch(int[] a, int n, int value) {
            int low = 0;
            int high = n - 1;
            while (low <= high) {
              int mid =  low + ((high - low) >> 1);
              if (a[mid] > value) {
                high = mid - 1;
              } else if (a[mid] < value) {
                low = mid + 1;
              } else {
                // 如果 mid 等於 0,那這個元素已經是數組的第一個元素,那它肯定是我們要找的
                // 如果 mid 不等於 0,但 a[mid]的前一個元素 a[mid-1]不等於 value,那也說明 a[mid]就是我們要找的第一個值等於給定值的元素
                if ((mid == 0) || (a[mid - 1] != value)) return mid;
                // 如果經過檢查之後發現 a[mid]前面的一個元素 a[mid-1]也等於 value
                // 要找的元素肯定出現在[low, mid-1]之間
                else high = mid - 1;
              }
            }
            return -1;
          }
          
      • 對於我們做工程開發的人來說,代碼易讀懂、沒 Bug,其實更重要,所以我覺得第二種寫法更好

    • 變體二:查找最後一個值等於給定值的元素

      • 同理,改動點在判斷那塊 if ((mid == n - 1) || (a[mid + 1] != value)) 與 else low = mid + 1;
    • 變體三:查找第一個大於等於給定值的元素

      • 比如,數組中存儲的這樣一個序列:3,4,6,7,10。如果查找第一個大於等於 5 的元素,那就是 6

      • 代碼實現

        • 
          public int bsearch(int[] a, int n, int value) {
            int low = 0;
            int high = n - 1;
            while (low <= high) {
              int mid =  low + ((high - low) >> 1);
              if (a[mid] >= value) {
                // 如果 a[mid]前面已經沒有元素,或者前面一個元素小於要查找的值 value
                // 那 a[mid] 就是我們要找的元素
                if ((mid == 0) || (a[mid - 1] < value)) return mid;
                // 如果 a[mid-1]也大於等於要查找的值 value,那說明要查找的元素在[low, mid-1]之間
                else high = mid - 1;
              } else {
              	// 如果 a[mid]小於要查找的值 value,那要查找的值肯定在[mid+1, high]之間
                low = mid + 1;
              }
            }
            return -1;
          }
          
    • 變體四:查找最後一個小於等於給定值的元素(實現原理同上)

  • 二分查找更適合用在“近似”查找問題(等值查詢場景確實不怎麼會被用到

  • 代碼實現時容易出錯的細節有:終止條件、區間上下界更新方法、返回值選擇

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章