不好意思!?我真的只會用 Array.prototype.sort() 寫✍排序!

熊的貓發表於2022-12-14

本文參與了 SegmentFault 思否年度徵文「一名技術人的 2022」,歡迎正在閱讀的你也加入。

前言

什麼是排序?排序在 JavaScript 中對於大部分人來講是這樣的:

arr.sort() // 預設排序,會將元素轉換為字串,然後比較它們的 UTF-16 程式碼單元值實現排序
arr.sort((a, b) => { return a - b }) // 自定義排序,遞增
arr.sort((a, b) => { return b - a }) // 自定義排序,遞減

有毛病嗎?沒毛病!但真的只是這樣嗎?

23BEC8A5.jpg

死亡連問系列:

  • 不用 sort() 怎麼寫排序?
  • 有沒有了解過 sort() 的原理?
  • 你還知道哪些排序演算法?
  • 這些排序演算法都有哪些區別呀?

不好意思!?我真的只會用 Array.prototype.sort() 寫✍排序! 大部分人第一反應絕對是業務中確實不需要自己寫排序演算法呀!問這些問題幹嘛!(你猜猜

就好像買東西,有人覺得能解決當前問題就行,有人覺得既可以解決當前問題又可以解決其他問題才行,為什麼?沒有為什麼,面試官也有不同的需求!23BF0C55.png

下面我們就從以下兩個方面來聊一聊:

  • 常見的排序演算法
  • Array.prototype.sort() 的原理

常見的排序演算法

常見排序演算法主要包含如下 5 種:

  • 氣泡排序
  • 選擇排序
  • 插入排序
  • 歸併排序
  • 快速排序

如果你還在死記所謂的模板,不出意外的話會三番兩次的遺忘,不如給自己一點時間去了解其核心的思想,真正做到讓 核心思想 帶著你把程式碼寫出來!

氣泡排序 — normal 版本

時間複雜度:O(n^2)

核心思想

所謂的 冒泡 其實就是在 每一輪的遍歷 中選出一個 最值(最小/最大) 值移動到陣列的 兩端(最左端/最右端)

基於 最大值冒泡 可以理解為,從第一個元素開始,重複比較相鄰的兩個元素

  • 如果 前一項元素 > 後一項元素,則交換它們的位置
  • 否則 不交換,繼續比對後續的元素

基於 最小值冒泡 可以理解為,從第一個元素開始,重複比較後續的元素

  • 如果 頭部元素 > 後續元素,則交換它們的位置
  • 否則 不交換,繼續比對後續的元素

JavaScript 實現

如下是選擇每次遍歷的 最大值 移動端陣列的 最右端 來實現 冒泡

export function bubbleSort(arr) {
  const len = arr.length;

  // 外層遍歷負責從頭到尾進行比較
  for (let i = 0; i < len; i++) {
    for (let j = 0; j < len; j++) {
      // 若相鄰元素前面的數比後面的大,則交換
      if (arr[j] > arr[j + 1]) {
        [arr[j], arr[j + 1]] = [arr[j + 1], arr[j]];
      }
    }
  }
  
  return arr;
}

如下是選擇每次遍歷的 最小值 移動端陣列的 最左端 來實現 冒泡

export function bubbleSort(arr) {
  const len = arr.length;
  
  // 外層迴圈負責從前往後遍歷陣列元素,每次遍歷時找出比它小的和它替換
  for (let i = 0; i < len; i++) {
      // 當內層迴圈遍歷完一次陣列,就能找出本次遍歷中的最小值,並把最小值移動到陣列頭部
       for (let j = i + 1; j < len; j++) {
           // 只要當前頭部元素大於後續任意元素就直接交換位置
           if (arr[i] > arr[j]) {
               [arr[i], arr[j]] = [arr[j], arr[i]]
           }
       }
   }

  return arr;
}

氣泡排序 — better 版本

時間複雜度:O(n^2)

核心思想

以上 normal 版本 的實現是最基本的實現,只考慮核心思想,沒有考慮重複比較的問題,比如基於 最大值冒泡 的方式中使用到的核心程式碼為:

// 外層遍歷負責從頭到尾進行比較
  for (let i = 0; i < len; i++) {
    for (let j = 0; j < len; j++) {
      // 若相鄰元素前面的數比後面的大,則交換
      if (arr[j] > arr[j + 1]) {
        [arr[j], arr[j + 1]] = [arr[j + 1], arr[j]];
      }
    }
  }

裡面用到的 雙重迴圈 中的內層迴圈每次都只是簡單的從頭遍歷到尾,但是真的有必要嗎?

前面核心思想部分我們是不是講過,內層迴圈每次遍歷結束後,本次遍歷的最大值就會被移動到陣列尾部,即如下:

  • 第 1 次內層迴圈遍歷結束,得到 第 n 大值
  • 第 2 次內層迴圈遍歷結束,得到 第 n-1 大值
  • 第 3 次內層迴圈遍歷結束,得到 第 n-2 大值
  • ......
  • 第 n 次內層迴圈遍歷結束,得到 第 1 大值最小值

這就引出了值得最佳化的點,就是每次內層迴圈遍歷時,就只需要比較 n - i 之前的元素,因為從 n - i 到 n 的元素都已經有序了。

JavaScript 實現

export function betterBubbleSort(arr) {
  const len = arr.length;

  for (let i = 0; i < len; i++) {
    // len - i 避免遍歷到已經有序的部分
    for (let j = 0; j < len - i; j++) {
      if (arr[j] > arr[j + 1]) {
        [arr[j], arr[j + 1]] = [arr[j + 1], arr[j]];
      }
    }
  }

  return arr;
}

氣泡排序 — best 版本

時間複雜度:O(n) —— O(n^2)

核心思想

以上 better 版本 的實現已經是最佳化內層迴圈的遍歷次數,但是還有一種情況不得不考慮,那就是傳入的陣列本身就是 有序陣列 時,按正常邏輯有序陣列就不需要遍歷了,但是 JavaScript 中可沒有提供給你一個啥屬效能夠標識它是否是有序的,因此還是得透過遍歷陣列才能知道它到底有沒有序。

而這樣的方式,基於 better 版本 來講,它的內層迴圈該遍歷多少次,還是會遍歷多少次,即使一次也沒有發生過交換操作。

那怎麼辦?怎麼標識一個陣列是不是有序的呢?

我們知道只要內層迴圈中進入互動操作的條件分支,那麼證明陣列必然是無序的,因此可以定義一個 isOrder 用於標識陣列是否有序,預設陣列是有序的,直接向外進行返回即可;但只要發生交換操作,就將 isOrder 的值改變,證明 當前陣列是無序 的,需要繼續往後進行判斷。

這樣一來,當傳入陣列是有序時,只需要外層迴圈執行 1 次,內層迴圈執行 n 次,就可以判斷出當前陣列是否有序,因此 最好的情況下時間複雜度為 O(n),最壞情況下時間複雜度為 O(n^2)

JavaScript 實現

export function bestBubbleSort(arr) {
  const len = arr.length;
  // 定義 isOrder 用於標識陣列是否有序,預設是有序的
  let isOrder = true;

  for (let i = 0; i < len; i++) {
    for (let j = 0; j < len - i; j++) {
      if (arr[j] > arr[j + 1]) {
        [arr[j], arr[j + 1]] = [arr[j + 1], arr[j]];
        isOrder = false;
      }
    }

    // isOrder 的值沒有發生更改,意味著陣列是有序的,不需要進行額外排序
    if (isOrder) return arr;
  }

  return arr;
}

選擇排序

時間複雜度:O(n^2)

核心思想

所謂 選擇 就是選擇 最值(最大/最小),就是每次遍歷確定 最小值索引,每輪遍歷結束把 最小值放到陣列頭部(由於上面我們已經演示過不同最值的實現方式,考慮到篇幅,這裡我們就以最小值的形式來看看)。

JavaScript 實現

看著下面的實現,不知道你會不會發現,這和我們前面講的 基於 最小值 冒泡 的實現方式很類似,只是如下的方式多了 最小值索引 minIdx,並且交換操作是發生在 內層迴圈結束後,而前者是在 內層迴圈中 進行的交換操作。

export function selectSort(arr) {
  const len = arr.length;

  // 定義最小值索引
  let minIdx;

  for (let i = 0; i < len; i++) {
    // 將每次迴圈索引 i 認為是本次遍歷的 最小值索引 minIdx
    minIdx = i;

    // i、j 定義為本次需要遍歷區間的 邊界,i 為 左邊界,j 為 右邊界
    for (let j = i; j < len; j++) {
      if (arr[j] < arr[minIdx]) {
        minIdx = j;
      }
    }

    // 若當前 minIdx 和 i 不相等,則表明當前已經找到新的最小的元素,則進行交換
    if (minIdx !== i) {
      [arr[i], arr[minIdx]] = [arr[minIdx], arr[i]];
    }
  }

  return arr;
}

插入排序

時間複雜度:O(n^2)

核心思想

所謂 插入 就是指將當前遍歷到的元素往 已有序的部分 中進行插入動作,已有序部分你大可以預設陣列的 第一個元素 就是 已有序部分,後續遍歷的元素只要保證在前面已有序的部分中找合適的位置進行插入即可。

JavaScript 實現

export function insertSort(arr) {
  const len = arr.length;

  // temp 用來儲存當前需要插入的元素
  let temp;

  // i = 1 即預設第一位元素(即 i = 0)是有序的
  for (let i = 1; i < len; i++) {
    // j 用於幫助 temp 尋找自己應該有的定位
    let j = i;
    temp = arr[i];

    // j 此時為有序區域的 右邊界,因此 j - 1 就是有序區域中的內容
    // 判斷 j 前面一個元素是否比 temp 大
    while (arr[j - 1] > temp) {
      // 如果是,則將 j 前面的一個元素後移一位,為 temp 讓出位置
      arr[j] = arr[j - 1];
      j--;
    }

    // 迴圈讓位,最後得到的 j 就是 temp 的正確索引
    arr[j] = temp;
  }

  return arr;
}

歸併排序

時間複雜度:O(nlog(n))

核心思想

所謂 歸併 翻譯過來就是 遞迴 + 合併遞迴 就是用於處理相同且重複的內容,那 合併 是合併什麼!既然 需要合併,那意味著 先得分開,分誰?當然是將陣列劃分成子陣列了(難道是分蛋糕嗎),即只要保證子陣列有序,且保證合併後的陣列也保證是有序的,那麼最後一次合併得到的陣列自然就是有序的。

這其實是 分而治之 的思想,指的是 將一個大問題分解為若干個子問題,針對子問題分別求解後,再將子問題的解整合為大問題的解

JavaScript 實現

時間複雜度的分析:

每一輪遞迴,都需要做 切分合併 操作,其中對於 n 的陣列來說,需要切分 log2(n) 次,而切分的實際動作是固定的如下程式碼所示,因此其時間複雜度為 O(1),而在合併操作中透過 while 來實現兩個陣列的有序合併,因此其時間複雜度為 O(n),所以最終整體的時間複雜度為 O(nlog(n))

// 計算分割點 
const mid = Math.floor(len / 2) 
// 遞迴分割左子陣列,然後合併為有序陣列 
const leftArr = mergeSort(arr.slice(0, mid)) 
// 遞迴分割右子陣列,然後合併為有序陣列 
const rightArr = mergeSort(arr.slice(mid,len))
log2(n) 表示的是 以 2 的多少次方等於 n,數學上叫 以 2 為底 n 的 對數,但在時間複雜度中一般涉及常數部分可以直接忽略不考慮,即 log2(n) 表示為 log(n)
export function mergeSort(arr) {
  const len = arr.length;

  // 定義遞迴邊界
  if (len <= 1) {
    return arr;
  }

  // 獲取中間元素的索引值
  const midIdx = Math.floor(len / 2);

  // 根據中間索引 midIdx 劃分左右兩個子陣列,即進行了 分割
  const left = mergeSort(arr.slice(0, midIdx));
  const right = mergeSort(arr.slice(midIdx, len));

  // 將左右兩個子陣列進行有序的合併
  return mergeArr(left, right);
}

// 透過雙針指標合併兩個有序陣列
function mergeArr(arr1, arr2) {
  const len1 = arr1.length;
  const len2 = arr2.length;

  // 定義 l r 指標,分別指向 arr1 arr2 中的元素
  let i = 0,
    j = 0;

  // 定義結果集
  const res: any[] = [];

  // 迴圈合併陣列,直到至少一個陣列被遍歷完
  while (i < len1 && j < len2) {
    if (arr1[i] > arr2[j]) {
      res.push(arr2[j]);
      j++;
    } else {
      res.push(arr1[i]);
      i++;
    }
  }

  // 判斷具體是哪個陣列被遍歷完,將另一個陣列直接進行合併即可
  if (i < len1) {
    return res.concat(arr1.slice(i));
  } else {
    return res.concat(arr2.slice(j));
  }
}

快速排序

時間複雜度:O(nlog(n)) —— O(n^2)

核心思想

快速排序 實際上和 歸併排序 的思想是高度統一的,都是利用 分治思想 將大問題的解變成小問題的解,但區別在於 歸併 是將陣列真正進行了分割,而 快排 則是直接在原有的陣列內部進行排序,不會真正將陣列進行分割,而是用索引值作為指標來代替。

JavaScript 實現

時間複雜度分析:

  • 最好情況

    • 每次選擇基準值,都剛好是當前子陣列的中間數,即確保每一次分割都能將陣列分為兩半,進而只需要遞迴 log2(n)
    • 也可以認為 快排歸併 核心思路一致,於是時間複雜度也為 O(nlog(n))
  • 最壞情況

    • 每次劃分取到的都是當前陣列中的 最大值/最小值,此時 快排 退化為 氣泡排序,因此時間複雜度是 O(n^2)
export function quickSort(arr, left = 0, right = arr.length - 1) {
  // 遞迴邊界
  if (arr.length > 1) {
    // lineIndex 表示下一次劃分左右子陣列的索引位
    const lineIndex = partition(arr, left, right);

    // 如果左邊子陣列的長度不小於 1,則遞迴快排這個子陣列
    if (left < lineIndex - 1) {
      // 左子陣列以 lineIndex-1 為右邊界
      quickSort(arr, left, lineIndex - 1);
    }

    // 如果右邊子陣列的長度不小於1,則遞迴快排這個子陣列
    if (right > lineIndex) {
      // 右子陣列以 lineIndex 為左邊界
      quickSort(arr, lineIndex, right);
    }
  }

  return arr;
}

// 以基準值為軸心,劃分左右子陣列的過程
function partition(arr, left, right) {
  // 基準值預設取中間位置的元素
  let pivotValue = arr[Math.floor(left + (right - left) / 2)];

  // 初始化左右指標
  let i = left;
  let j = right;

  // 當左右指標不越界時,迴圈執行以下邏輯
  while (i <= j) {
    // 左指標所指元素若小於基準值,則右移左指標
    while (arr[i] < pivotValue) {
      i++;
    }

    // 右指標所指元素大於基準值,則左移右指標
    while (arr[j] > pivotValue) {
      j--;
    }

    // 若 i<=j,則意味著基準值【左邊】存在較大元素 或【右邊】存在較小元素
    // 交換兩個元素確保左右兩側有序
    if (i <= j) {
      [arr[i], arr[j]] = [arr[j], arr[i]];
      i++;
      j--;
    }
  }

  // 返回左指標索引作為下一次劃分左右子陣列的依據
  return i;
}

Array.prototype.sort() 的原理

v8 為了實現 位元組碼 + 即時編譯(JIT) 的最佳化,sort 函式在 7.0 後使用谷歌自研的 Torque 語言(即 .tq)來開發,且排序演算法變成了 TimSort

7.0 版本及之前的實現

原始碼地址

該版本及之前使用 js 來開發,核心內容如下:

  • 陣列長度 <= 10 時,使用 插入排序
  • 陣列長度 > 10 時,使用 快速排序
  // Insertion sort is faster for short arrays.
  if (to - from <= 10) {
    // 插入排序
    InsertionSort(a, from, to);
    return;
  }
  ...
  if (to - high_start < low_end - from) {
    // 快速排序
    QuickSort(a, high_start, to);
    to = low_end;
  } else {
    QuickSort(a, from, low_end);
    from = high_start;
  }

原因在於當資料比較少的時候,插入排序 可能執行時間 更短,比如陣列長度為 10 時:

  • 插排 的時間複雜度為:O(n^2),即此時只需要執行 10*10=100
  • 快排 的時間複雜度雖可能為 O(nlog(n)),但其可能為 f*n*(log n)+c,其中 f 為係數,c 為常數,假設 f=10,c=20,此時 O(10) = 10*10*log10+20,在這種情況下明顯執行次數會大於 100

Torque 中的實現

原始碼地址

該版本及使用 tq 來開發,核心內容如下:

  • 陣列長度較小 時,使用 二分插入排序
  • 陣列長度較大 時,使用 歸併排序

其他具體內容可直接檢視原始碼中與 TimeSort 相關部分。

  while (remaining != 0) {
    let currentRunLength: Smi = CountAndMakeRun(low, low + remaining);

    // If the run is short, extend it to min(minRunLength, remaining).
    if (currentRunLength < minRunLength) {
      const forcedRunLength: Smi = SmiMin(minRunLength, remaining);
      
      // 二分插入排序
      BinaryInsertionSort(low, low + currentRunLength, low + forcedRunLength);
      
    }
    
    ...

    // 歸併
    MergeCollapse(context, sortState);
}

最後

2022 即將過去,收拾好心情,繼續努力!!!未來可期,大家加油!!!

相關文章