快排其實很簡單

weixin_33751566發表於2018-09-09

大學時期,老師只講了課本上快排的一種實現方式,當時學的雲裡霧裡,最近調研一下知乎上的一些實現方式,總結歸納了一下,發現還是非常簡單的,只要清晰了具體概念,實現方式就可以隨意發揮了(至於可讀性,簡易性當然各有千秋)。

什麼是快排

  1. 從數列中挑出一個元素,稱為“基準”(pivot),
  2. 重新排序數列,所有比基準值小的元素擺放在基準前面,所有比基準值大的元素擺在基準後面(相同的數可以到任何一邊)。在這個分割槽結束之後,該基準就處於數列的中間位置。這個稱為分割槽(partition)操作。
  3. 遞迴地(recursively)把小於基準值元素的子數列和大於基準值元素的子數列排序。

換種說法

  1. 隨意找個對比基準
  2. 將所有小於 基準 的元素放到它前面,大於它的放到後面,這樣該基準的位置即為它的最終位置(不然呢?)
  3. 遞迴地切分基準左右兩側的子列表,重複 1

有木有感覺清晰了很多~
再看一下執行中遞迴的結構樹:

442688-0fea49275a80346b.png
quickSort.png

從圖中可以看出,排序是自上而下的,快排的排序操作應當是在遞進的過程中, 整個過程中,除了葉子每一個元素都是基準元素。

Code Time

錯誤的示例

這裡只要寫好最核心的一步(對樹單層中基準位置的調整)函式,再配合基本的遞迴結構,就 OK 了。在知乎上找了一個比較清晰的一種實現方式:

/**
 * 作為遞迴的入口
 * @left 當前函式幀中輸入陣列的起始點
 * @right 當前函式幀中輸入陣列的終點
 */
function quickSort(arr, left, right) {
  const i = correctPivot(arr, left, right)
  if(curPivot > i) quickSort(arr, left, i)
  if(curPivot < i) quickSort(arr, i + 1, right)
}

function correctPivot(arr, i, j) {
  let mid = arr[Math.floor((i + j)/2)]
  while(i < j) {
    while(arr[i] < mid) i++
    while(arr[j] > mid) j--
    if(i<=j) {
      swap(arr, i, j) // 交換 i 和 j 位置上的元素,很簡單,用輔助變數 tmp
      i++, j--
    }
  }
  return i
}

將原始碼拆分為了如上函式,經過模擬分析,發現這個核心函式就是錯的,它以 i 和 j 最終匯合的地點為新的切分點,而不是當前基準點的位置,因此基準很可能依舊在待排序的子陣列中,這樣在接下來的層級中,依舊還需要對老的基準進行對比(移動不會,因為已經是到最終的位置了),這樣無形間增加了時間複雜度,完全違背了利用分治來分解問題的初衷,而且還可能因為條件判斷異常導致棧溢位。

《資料結構》課本上的解法

因為我是計科科班出身,因此有必要曬一下被教育部認可的排序演算法:

function quickSort(arr, first, end) {
  if(first >= end) return // 遞迴到葉子節點
   
  const pivot = correctPivot(arr, first, end)
  quickSort(arr, first, pivot - 1)
  quickSort(arr, pivot + 1, end)  
}

function correctPivot(arr, first, end) {
  while(first < end) {
    while(first < end && arr[first] <= arr[end]) end-- // 右側掃描
    if(first < end) {
      swap(arr, fist, end)
      first++
    }

    while(first < end && arr[first] <= arr[end]) first++ // 左側掃描
    if(first < end) {
      swap(arr, fist, end)
      end--
    }
  }
  return first
}

在已知 corretPivot 函式返回的是基準最終的位置,因此我們只需要不斷操作最終得到它的 index 即可。

解析 correctPivot

  1. 由於在移動 first 和 end 的時候,二者重合之際即是基準的最終位置
  2. 這裡我們是預設以第一個元素為基準,掃描基準右側,讓 end 向左一直移動到比基準小的元素的位置,然後交換基準與該元素的位置,此時該元素已經確定在基準左側,所以不再處於對比範圍(因為我們只是在找基準的位置,無需關心基準以外元素的狀態,只需記住小於基準的一定都要在左側,大於之的在另一側),故 first 後移
  3. 開始掃描(基準的)左側,一直移動到比基準大的位置(表示該數應處於基準的右側),交換之,end 前移。
  4. 重複 2、3 步驟

其實在每次掃描時,都能夠讓基準處於 first 或者 end 的位置上,比如掃描完右側,那麼基準必然是會在 end 的位置,反之依然。這裡對一側可以簡單的理解為基準的另一側。在交換之前,每次在移動遊標(first 或者 end) 的時候,都是在順序地排除比基準都大(小)的元素,縮小對比範圍,每次的比較範圍都是 first~end,直到 first === end 時,表示它兩側都是比它小或大的元素。

《演算法導論》

function correctPivot(arr, l, r) {
  let m = l - 1
  for (let i = l; i <= r; i++) {
    if (arr[i] < arr[r]) {
      swap(++m, i)
    }
  }
  swap(m+1, r)
  return m + 1
}

有木有太簡單了,只用了一次迴圈就搞定了基準的位置。

解析
先確定一件事,就是 m + 1 才是基準的最終位置,而我們每一次執行函式都是以最後一個元素為基準的。

  1. 前移 m
  2. 從前向後遍歷整個陣列(這裡指的是 l ~ r 範圍內的部分), 如果發現該元素小於基準,則後移 m,並將當前元素和 m 位置上的元素交換。(這裡相當於是把小於基準的所有元素都插入到 m 的位置)
  3. 迴圈結束,m 的位置為最後一個比基準小的元素。
  4. 將基準移動到 m + 1 的位置,結束。

相關文章