《圖說演算法-使用C#》要點摘錄 – 「排序演算法」

書名:《圖說演算法-使用C#》

作者:吳燦銘、胡昭民

所讀版本:博碩文化

排序方式

  • 直接移動:直接交換儲存數據的位置

  • 邏輯移動:不移動存儲位置,僅改變指向目標數據的指標值

排序術語

  • 穩定:兩個相同鍵值的記錄在排序後仍然保持相同的次序

  • 不同情況下的時間複雜度

    • 最好情況:原始資料已完成排序

    • 最壞情況:資料中的每一個鍵值都要重新排序

    • 平均情況:介乎最好與最壞,平常遇見最多的情況

  • 空間複雜度:執行過程中所需付出的額外記憶體空間

排序算法

冒泡排序
  • 由第一個元素開始,比較相鄰元素大小,若大小順序有誤,則對調後再進行下一個元素的比較

  • 時間複雜度:

    • 最壞/平均:O(n^2)

    • 最佳:O(n)

  • 穩定的排序

  • 空間複雜度:O(1)

  • 適用於數據量小/部分數據已排序的情況

  • 通過記錄該次循環是否有進行過比較,沒有則直接中斷循環來減少多餘的比較。

  • 我的冒泡排序實現

    • public static void Bubble(int[] arr)
      {
         int flag; //Optimize Code
         for (int i = 0; i < arr.Length; i++)
        {
             flag = 0; //Optimize Code
             for (int j = 0; j < arr.Length - 1; j++)
            {
                 if(arr[j + 1] < arr[j])
                {
                     int temp = arr[j];
                     arr[j] = arr[j + 1];
                     arr[j + 1] = temp;
                     flag++;
                }
            }
             if (flag == 0) { break; } //Optimize Code
        }
      }
    選擇排序
  • 反覆從未排序的數組中取出最小/最大的元素,加入到另一個數組中。

  • 時間複雜度:

    • 無論哪種情況都要找到最大/最小值,時間複雜度穩定為O(n^2)

  • 不穩定的排序

  • 空間複雜度:O(1)

  • 適用於數據量小/部分數據已排序的情況

  • 我的選擇排序實現

    • public static void Select(int[] arr)
      {
         for (int i = 0; i < arr.Length - 1; i++)
        {
             for (int j = i + 1; j < arr.Length; j++)
            {
                 if (arr[j] < arr[i])
                {
                     int temp = arr[i];
                     arr[i] = arr[j];
                     arr[j] = temp;
                }
            }
        }
      }
插入排序
  • 將數組中的元素,逐一與已排序好的元素作比較,並插入到適當的位置中。

  • 時間複雜度:

    • 最壞/平均:O(n^2)

    • 最好:O(n)

  • 穩定的排序

  • 空間複雜度:O(1)

  • 適用於大部分數據已排序/在已排序的數據上新增數據進行排序

  • 插入排序會造成數據的大量搬移,所以最好通過單向鏈表實現

  • 我的單向鏈表插入排序實現

    • public class LinkedNode
      {
         public int val;
         public LinkedNode next;
         public LinkedNode(int val = 0, LinkedNode next = null)
        {
             this.val = val;
             this.next = next;
        }
      }

      public static LinkedNode Insert_LinkedList(int[] arr)
      {
         LinkedNode head = new LinkedNode(-99);
         LinkedNode curr = head.next;
         LinkedNode prev = head;


         for (int i = 0; i < arr.Length; i++)
        {
             LinkedNode newNode = new LinkedNode(arr[i]);

             curr = head.next;
             prev = head;

             while (curr != null && newNode.val > curr.val)
            {
                 curr = curr.next;
                 prev = prev.next;
            }
             prev.next = newNode;
             newNode.next = curr;
        }

         return head.next;
      }
  • 我的數組插入排序實現

    • public static void Insert_Loop(int[] arr)
      {
         for (int i = 1; i < arr.Length; i++)
        {
             int compareIdx = i;
             int compareTarget = arr[compareIdx];
             while(compareIdx > 0 && compareTarget < arr[compareIdx - 1])
            {
                 arr[compareIdx] = arr[compareIdx - 1];
                 compareIdx--;
            }
             arr[compareIdx] = compareTarget;
        }
      }
謝耳排序
  • 插入排序的加速版

  • 通過將數據區分成特定間隔的幾個小區塊,再使用插入排序排完區塊內的數據後再慢慢減少間隔的距離,從而減少插入排序法中數據搬移的次數,加速排序的進行。

    • 將數據分成(數據總數 / 質數) = 劃分數份,比如(8/2)就把數據分成4份

    • 每一份的兩個數據之間下標的差 = 劃分數

      • 如數據:63, 92, 27, 36, 45, 71, 58, 7

      • 劃分後分組為(63, 45), (92, 71), (27, 58), (36, 7)

      • 它們的下標為:(0, 4), (1, 5), (2, 6), (3, 7)

      • 對每一組進行插入排序,然後把間隔/2,重複執行直到間距 = 1,完成排序

  • 時間複雜度:所有情況均為O(n^(2/3))

  • 穩定的排序

  • 空間複雜度:O(1)

  • 適用於大部分數據已排序完成的情況

  • 書中的謝耳排序實現

    • public static void Shell(int[] arr)
      {
         int j; //以j來定位比較的元素
         int tmp; //資料暫存
         int jmp; //間距位移量

         jmp = arr.Length / 2;
         while(jmp != 0)
        {
             for (int i = jmp; i < arr.Length; i++)
            {
                 tmp = arr[i];
                 j = i - jmp;
                 while(j >= 0 && tmp < arr[j])
                {
                     arr[j + jmp] = arr[j];
                     j = j - jmp;
                }
                 arr[jmp + j] = tmp;
            }

             jmp = jmp / 2;
        }
      }
快速排序
  • 先在數據中挑選一個「中間值」,並根據這個「中間值」,將所有打算排序的數據分為兩部分:

    • 左邊:小於中間值

    • 右邊:大於中間值

  • 然後再兩邊的數據各自重複進行上述步驟,直到排序完成

  • 時間複雜度:

    • 最快/平均:O(nlog2n)

    • 最壞:O(n^2)

  • 不穩定的排序

  • 空間複雜度:

    • 最壞:O(n)

    • 最好:O(nlog2n)

  • 我的快速排序實現

    • public static void QuickSort(int[] arr, int left, int right)
      {
         if(left < right)
        {
             //初始值
             int standard = arr[left]; //基準數,把小於等於它的數放在它的左邊,把大於它的數放在它的右邊
             //用來做循環的標志位
             int i = left;
             int j = right;
             //一直重複以下兩個循環,直到i == j(找到中間位置)。也就是基準數所在的位置
             while (i < j)
            {
                 //從後往前比較(從右向左比較)找一個比standard小 or 等於standard 的數字
                 //然後放在我們的坑裡面
                 //坑位於 i 的位置
                 while (i < j)
                {
                     if (arr[j] <= standard) //從排序數據的尾部開始,找到了一個小於/等於基準數的數字,應該把他放在standard的左邊
                    {
                         arr[i] = arr[j];
                         break;
                    }
                     else
                    {
                         j--; //向左移動到下一個數字,然後做比較
                    }
                }

                 //從前往後(從左向右)找一個比standard大的數字,放在坑裡面
                 //現在的坑位於j的位置
                 while (i < j)
                {
                     if(arr[i] > standard)
                    {
                         arr[j] = arr[i];
                         break;
                    }
                     else
                    {
                         i++;
                    }
                }
            }
             //現在i == j了,跳出循環,把基準值standard 賦值到中間位置(i)
             arr[i] = standard;//left -- i -- right
             QuickSort(arr, left, i - 1);
             QuickSort(arr, j + 1, right);
        }
      }
歸并排序
  • 針對已排序好的兩個或以上的數組,通過合併將它們經合成一個大的且已排序完成的數組

  • 因此,如果是對一個未排序的數組進行歸并排序,首先要將數組重複對半分割直到數組不能再分割為止(數組長度==1)

    • 然後再對兩個數組之間的元素進行比較,按據從小到大/從大到小的規則輸出到一個新的數組裡,然後再把這個合併後的數組往上提交。

    • 提交上去的數組又會跟另一邊提交上去的數組之間進行比較和合併,最後回到頂端,合併成一個排序完成的數組

  • 時間複雜度:所有情況均為O(nlogn)

  • 空間複雜度為O(n)

  • 穩定的排序

  • 我的歸并排序實現

    • public static int[] MergeSort(int[] arr)
      {
         if (arr.Length <= 1) { return arr; }

         //重複對半分割,直到無法分割
         int mid = arr.Length / 2;
         int[] left = MergeSort(arr.Take(mid).ToArray());
         int[] right = MergeSort(arr.Skip(mid).Take(arr.Length - mid).ToArray());

         //合併左右兩個數組
         return Merger(left, right);
      }
      static int[] Merger(int[] leftPart, int[] rightPart)
      {
         int[] res = new int[leftPart.Length + rightPart.Length];
         int leftIdx = 0;
         int rightIdx = 0;
         int resIdx = 0;

         //左數組和右數組都有沒用完的元素
         while(leftIdx < leftPart.Length && rightIdx < rightPart.Length)
        {
             if(leftPart[leftIdx] < rightPart[rightIdx])
            {
                 res[resIdx] = leftPart[leftIdx];
                 leftIdx++;
            }
             else
            {
                 res[resIdx] = rightPart[rightIdx];
                 rightIdx++;
            }
             resIdx++;
        }

         //其中一個數組遍歷到了末尾,把剩下的另外一個數組元素也加到結果裡
         while(leftIdx < leftPart.Length)
        {
             res[resIdx] = leftPart[leftIdx];
             resIdx++;
             leftIdx++;
        }

         while(rightIdx < rightPart.Length)
        {
             res[resIdx] = rightPart[rightIdx];
             resIdx++;
             rightIdx++;
        }

         return res;
      }
基數排序
  • 基數排序是一種分配模式排序方式,不需要進行元素間的比較動作。

  • 時間複雜度:所有情況均為O(nlogpk),k = 原始數據的最大值

  • 穩定的排序

  • 空間複雜度:O(n * p),p是數據字元數

  • 若n(數據量)很大,p很小,基數排序將會很有效率

  • 書中的基數排序實現

    • public static void Radix(int[] arr)
      {
         //0 -> 10 -> 100
         for (int i = 1; i <= 100; i *= 10)
        {
             int[,] tmp = new int[10, 100]; //[0-9位數, 資料個數]
             for (int j = 0; j < arr.Length; j++)
            {
                 //m: i位數的值(arr[j] = 36; i = 10 -> (36/10) % 10 = 3)
                 int m = (arr[j] / i) % 10;                    
                 tmp[m, j] = arr[j];
            }

             //將tmp內的值放回原數組
             int k = 0;
             for (int j = 0; j < 10; j++)
            {
                 for (int l = 0; l < arr.Length; l++)
                {
                     if (tmp[j, l] != 0)
                    {
                         arr[k] = tmp[j, l];
                         k++;
                    }
                }
            }
        }
      }
堆排序
  • 利用二叉樹的技巧,通過堆積樹完成排序

  • 堆積樹特點

    • 是一個完整二叉樹

    • 所有節點都大於等於(最大堆積樹) or 小於等於(最小堆積樹)它左右子節點的值

    • 樹根(首位下標的元素)是堆積樹中最大/最小的

  • 時間複雜度:均為O(nlogn)

  • 不穩定的排序

  • 空間複雜度:O(1)

  • 我的最小堆排序(輸出順序從大至小)

    • public static void HeapSort(int[] arr)
      {
         //arr.Length / 2 => 最後一個葉節點的父親節點 => 最後一個非葉節點
         //節點編號從1開始
         for (int i = arr.Length / 2; i > 0; i--)
        {
             //將每一個非葉節點構造成最小堆
             HeapAdjust(i, arr, arr.Length);
        }
         //經過上面的循環,成功將完全二叉樹變成了小頂堆

         foreach (var item in arr)
        {
             Console.Write(item + ", ");
        }

         //小頂堆只能保證根節點的元素是最小的,但不能保證後面的元素也是順序的
         //因此還需要持續將根節點放到樹的末尾,然後把除末尾以外的節點進行最小堆的重構
         //從而持續把最小元素推到根節點,再把之放在末尾,重複進行
         //因此,最小堆可以得到一個從大至小排序的數組,而最大堆則可以得到一個從小到大排序的數組
         //從最後一個數開始,與第一個數進行交換
         for (int i = arr.Length; i > 1; i--)
        {
             //1 和 i 位置互換,交換之後,
             //編號為1存儲的是最大值,編號為i存儲的是最小值
             int temp = arr[0];
             arr[0] = arr[i - 1];
             arr[i - 1] = temp;

             //持續把最小節點放到末尾,重構堆時將末尾的元素忽略
             HeapAdjust(1, arr, i - 1);
        }

         Console.WriteLine();

         foreach (var item in arr)
        {
             Console.Write(item + ", ");
        }

      }
      static void HeapAdjust(int targetNumber, int[] arr, int numLimit)
      {
         //把編號1到(i - 1)構造成小頂堆
         //把i節點的子樹變成小頂堆
         int minNodeNumber = targetNumber;
         int tmp = targetNumber;

         while (true)
        {
             int leftSon = tmp * 2; //i的左子樹在array裡的下標
             int rightSon = tmp * 2 + 1; //i的右子樹在array裡的下標

             //左子節點存在(leftSon <= numLimit)
             //且左子節點比當前節點的值小(arr[leftSon - 1] > arr[numLimit - 1])
             //需要-1是因為leftSon和numLimit都是編號(由1開始),而不是索引(由0開始)
             //最後得到當前樹與兩個子樹中最小的節點
             if(leftSon <= numLimit
                && arr[leftSon - 1] < arr[minNodeNumber - 1])
            {                
                 minNodeNumber = leftSon;
            }
             if(rightSon <= numLimit
                && arr[rightSon - 1] < arr[minNodeNumber - 1])
            {                  
                 minNodeNumber = rightSon;
            }
             //經過上面的處理後
             //minNodeNumber = 這個子樹裡最小節點的編號
             //tmp依然等當前父節點(i)的編號

             //發現了一個比i數據更小的子節點,將tmp和minNodeNumber編號裡的數據交換
             if (minNodeNumber != tmp)
            {
                 int cache = arr[tmp - 1];
                 arr[tmp - 1] = arr[minNodeNumber - 1];
                 arr[minNodeNumber - 1] = cache;
                 //交換之後,讓當前父節點編號往下移動,指向較小節點的原來編號
                 //然後再通過這個編號去檢查下面還有沒有子節點
                 tmp = minNodeNumber; //讓交換後的節點再跟他的子節點做比較
            }
             else
            {
                 //當子節點編號 與 父節點編號 相等,那就代表沒有子節點,跳出循環
                 //子節點沒有比i節點更小的,已有小頂堆
                 break;
            }
        }
      }