本文介紹了常見的 10 種排序算法的原理、基本實現和常見的優化實現,並有(我的認爲)足夠詳細的代碼註釋。
實在是居家工做,面試筆試必備良藥。html
這裏只給出基於其原理的通常實現,不少算法都有邏輯更復雜的或代碼量更少的精簡版,像遍歷的改爲遞歸的,兩個函數實現的改爲一個函數等等,就再也不說起了。前端
夠詳細了!傻子都能看懂!若是不懂,多看幾遍!git
前幾天在微博上看到一個視頻:用音頻演示15種排序算法,能夠看一下面試
全部動圖均來自《十大經典排序算法總結(JavaScript 描述)》算法
另外一種分類方式是根據是否爲「比較排序」。shell
平均時間複雜度 | 最好 | 最壞 | 空間複雜度 | 穩定性 | |
---|---|---|---|---|---|
冒泡排序 | O(n^2) | O(n) | O(n^2) | O(1) | 穩定 |
選擇排序 | O(n^2) | O(n^2) | O(n^2) | O(1) | 不穩定 |
堆排序 | O(n logn) | O(n logn) | O(n logn) | O(1) | 不穩定 |
插入排序 | O(n^2) | O(n) | O(n^2) | O(1) | 穩定 |
希爾排序 | O(n logn) | O(n log^2 n) | O(n log^2 n) | O(1) | 不穩定 |
快速排序 | O(n logn) | O(n logn) | O(n^2) | O(logn) | 不穩定 |
歸併排序 | O(n logn) | O(n logn) | O(n logn) | O(n) | 穩定 |
計數排序 | O(n+k) | O(n+k) | O(n+k) | O(k) | 穩定 |
桶排序 | O(n+k) | O(n+k) | O(n^2) | O(n+k) | 穩定 |
基數排序 | O(n*k) | O(n*k) | O(n*k) | O(n+k) | 穩定 |
已排序元素將放在數組尾部segmentfault
大體流程:api
演示圖:數組
function bubbleSort(arr) {
for (let i = 0; i < arr.length - 1; i++) {
for (let j = 0; j < arr.length -1 - i; j++) {
if (arr[j] > arr[j+1]) swap(arr, j ,j+1)
}
}
return arr
}
// 後面還會屢次用到,就再也不寫出來了
function swap(arr, n, m) {
[arr[n], arr[m]] = [arr[m], arr[n]]
}
複製代碼
有優化空間,主要從兩方面進行優化:app
檢查某次內層遍歷是否發生交換。
若是沒有發生交換,說明已經排序完成,就算外層循環尚未執行完 length-1
次也能夠直接 break
。
function bubbleSort1(arr) {
for (let i = 0; i < arr.length - 1; i++) {
// 外層循環初始值爲 false,沒有發生交換
let has_exchanged = false
for (let j = 0; j < arr.length - i - 1; j++) {
if (arr[j] > arr[j + 1]) {
swap(arr, j ,j+1)
has_exchanged = true
}
}
// 內層循環結束判斷一下是否發生了交換
if (!has_exchanged) break
}
return arr
}
複製代碼
記錄內層遍歷最後一次發生交換的位置,下一次外層遍歷只須要到這個位置就能夠了。
那麼外層遍歷就不能用 for
了,由於每次遍歷的結束位置可能會發生改變。
function bubbleSort2(arr) {
// 遍歷結束位置的初始值爲數組尾,並逐漸向數組頭部逼近
let high = arr.length - 1
while (high > 0) {
// 本次內層遍歷發生交換的位置的初始值
let position = 0
for (let j = 0; j < high; j++) {
if (arr[j] > arr[j + 1]) {
swap(arr, j, j + 1)
// 若是發生了交換,更新 position
position = j
}
}
// 下次遍歷只須要到 position 的位置便可
high = position
}
return arr
}
複製代碼
雙向遍歷,每次循環能找到一個最大值和一個最小值。
先後各設置一個索引,向中間的未排序部分逼近。
function bubbleSort3(arr) {
let low = 0, high = arr.length - 1
while (low < high) {
// 正向遍歷找最大
for (let i = low; i <= high; i++) if (arr[i] > arr[i + 1]) swap(arr, i, i + 1)
high--
// 反向遍歷找最小
for (let j = high; j >= low; j--) if (arr[j] < arr[j - 1]) swap(arr, j, j - 1)
low++
}
return arr
}
複製代碼
每次遍歷選擇最小。
排序後的元素將放在數組前部
大體流程:
並非倒着的冒泡排序。冒泡排序是比較相鄰的兩個元素
演示圖:
function selectionSort(arr) {
for (let i = 0; i < arr.length; i++) {
let min_index = i
// 遍歷後面的部分,尋找更小值
for (let j = i + 1; j < arr.length; j++) {
// 若是有,更新min_index
if (arr[j] < arr[min_index]) min_index = j
}
swap(arr, i, min_index)
}
return arr
}
複製代碼
使用堆的概念實現的選擇排序。
首先,關於堆:
index
個元素爲堆的父節點,其左右子節點分別爲數組的第 2*index+1
和 2*index+2
個元素已排序元素將放在數組尾部
大體流程:
注意:
演示圖:
// 排序
function heapSort(arr) {
var arr_length = arr.length
if (arr_length <= 1) return arr
// 1. 建最大堆
// 遍歷一半元素就夠了
// 必須從中點開始向左遍歷,這樣才能保證把最大的元素移動到根節點
for (var middle = Math.floor(arr_length / 2); middle >= 0; middle--) maxHeapify(arr, middle, arr_length)
// 2. 排序,遍歷全部元素
for (var j = arr_length; j >= 1; j--) {
// 2.1. 把最大的根元素與最後一個元素交換
swap(arr, 0, j - 1)
// 2.2. 剩餘的元素繼續建最大堆
maxHeapify(arr, 0, j - 2)
}
return arr
}
// 建最大堆
function maxHeapify(arr, middle_index, length) {
// 1. 假設父節點位置的值最大
var largest_index = middle_index
// 2. 計算左右節點位置
var left_index = 2 * middle_index + 1,
right_index = 2 * middle_index + 2
// 3. 判斷父節點是否最大
// 若是沒有超出數組長度,而且子節點比父節點大,那麼修改最大節點的索引
// 左邊更大
if (left_index <= length && arr[left_index] > arr[largest_index]) largest_index = left_index
// 右邊更大
if (right_index <= length && arr[right_index] > arr[largest_index]) largest_index = right_index
// 4. 若是 largest_index 發生了更新,那麼交換父子位置,遞歸計算
if (largest_index !== middle_index) {
swap(arr, middle_index, largest_index)
// 由於這時一個較大的元素提到了前面,一個較小的元素移到了後面
// 小元素的新位置以後可能還有比它更大的,須要遞歸
maxHeapify(arr, largest_index, length)
}
}
複製代碼
已排序元素將放在數組前部
大體流程:
第一種理解方式,也就是通常的實現原理:
在上面的第2步中,遍歷已排序元素時,若是該未排序元素仍然小於當前比較的已排序元素,就把前一個已排序元素的值賦給後一個位置上的元素,也就是產生了兩個相鄰的重複元素。
這樣一來,在比較到最後,找到合適的位置時,用該未排序元素給兩個重複元素中合適的那一個賦值,覆蓋掉一個,排序就完成了。
敘述可能不夠清楚,看後面的代碼就是了。
Talk is hard, show you some codes。
和選擇排序好像有一點相似的地方:
第二種理解方式:
在前面的第2步中,至關於把已排序部分末尾添加一個元素,而且執行一次冒泡排序。 由於前面的數組是已排序的,因此冒泡只須要遍歷一次就能夠給新的元素找到正確的位置。
可是以這種方式實現的代碼沒法使用二分法進行優化。
那麼是否是說明,冒泡排序的優化方法能夠用在這裏?
並非。由於冒泡排序主要從兩方面進行優化:
而這裏的冒泡只有一次,而且也不是找極值。
演示圖:
// 按照第一種理解方式的實現,即通常的實現
function insertionSort(arr) {
for (let index = 1; index < arr.length; index++) {
// 取出一個未排序元素
let current_ele = arr[index]
// 已排序元素的最後一個的位置
let ordered_index = index - 1
// 前面的元素更大,而且還沒遍歷完
while (arr[ordered_index] >= current_ele && ordered_index >= 0) {
// 使用前面的值覆蓋當前的值
arr[ordered_index + 1] = arr[ordered_index]
// 向前移動一個位置
ordered_index--
}
// 遍歷完成,前面的元素都比當前元素小,把未排序元素賦值進去
arr[ordered_index + 1] = current_ele
}
return arr
}
// 按照第二種理解方式的實現
function insertionSort(arr) {
for (let i = 0; i < arr.length; i++) {
// 對前面的已排序數組和新選出來的元素執行一趟冒泡排序
for (let j = i + 1; j >= 0; j--) if (arr[j] < arr[j - 1]) swap(arr, j, j - 1)
}
return arr
}
複製代碼
一個意外的弱智發現:while(a&&b){}
和 while(a){ if(b){} }
不等價。。。
使用二分查找。
遍歷已排序部分時,再也不是按順序挨個比較,而是比較中位數。
function binaryInsertionSort(array) {
for (let i = 1; i < array.length; i++) {
// 未排序部分的第1個
let current_ele = array[i]
// 已排序部分的第1個和最後1個
let left = 0, right = i - 1
// 先找位置
while (left <= right) {
// 再也不是從最後一個位置開始向前每一個都比較,而是比較中間的元素
let middle = parseInt((left + right) / 2)
if (current_ele < array[middle]) right = middle - 1
else left = middle + 1
}
// while結束,已經找到了一個大於或等於當前元素的位置 left
// 再修改數組:把 left 到 i 之間的元素向後移動一個位置
for (let j = i - 1; j >= left; j--) array[j + 1] = array[j]
// 插入當前元素
array[left] = current_ele
}
return array
}
複製代碼
插入排序使用的二分查找和二分查找函數顯然不一樣。
由於二者的目的不相同。
二分查找函數須要返回「存在」或「不存在」;而插入排序中的二分查找,關注的不是存在與否,而是「位置應該在哪裏」,無論存在不存在,都要返回一個位置。
也叫縮小增量排序,是插入排序的加強版。
不直接對整個數組執行插入排序,而是先分組,對每一個組的元素執行插入排序,使數組大體有序,逐步提升這個「大體」的精確度,也就是減小分組的數量,直到最後只有一組。
指定一個增量 gap
,對數組分組,使得每相距 gap-1
的元素爲一組,共分紅 gap
組,對每組執行插入排序。逐步縮小 gap
的大小並繼續執行插入排序,直到爲1,也就是整個數組做爲一組,對整個數組執行插入排序。
能夠發現,無論增量 gap
初始值設定爲多少,最後總會對整個數組進行一次插入排序,也就是說 gap
對排序結果是沒有影響的,只是影響了算法效率。
至於 gap
如何取值最好,尚未研究過。期待你們留言交流。(只是隨便一說,我看這個單純就是爲了面試。。)
大體流程:
gap
的值演示圖:
function shellSort(arr) {
// 外層循環逐步縮小增量 gap 的值
for (let gap = 5; gap > 0; gap = Math.floor(gap / 2)) {
// 中層和內層是插入排序
// 普通插入排序從第1個元素開始,這裏分組了,要看每一組的第1個元素
// 共分紅了 gap 組,第一組的第1個元素索引爲 gap
// 第一組元素索引爲 0, 0+gap, 0+2*gap,...,第二組元素索引爲 1, 1+gap, 2+2*gap,...
for (let i = gap; i < arr.length; i++) {
let current_ele = arr[i]
// 普通插入排序時,j 每次減小1,即與前面的每一個元素比較
// 這裏 j 每次減小 gap,只會與當前元素相隔 n*(gap-1) 的元素比較,也就是隻會與同組的元素比較
let ordered_index = i - gap
while (ordered_index >= 0 && arr[ordered_index] > current_ele) {
arr[ordered_index + gap] = arr[ordered_index]
ordered_index -= gap
}
arr[ordered_index + gap] = current_ele
}
}
return arr
}
複製代碼
大體流程:
pivot
,好比第一個元素
固然能夠選其餘元素,可是最後會遞歸至只剩一個元素,因此仍是選第一個元素比較靠譜
pivot
更小的元素建立一個數組,更大的建立一個數組,相等的也建立一個數組普通快速排序沒有考慮與
pivot
相等的狀況,只建了更小和更大的兩個數組。
像上面考慮與pivot
相等的狀況時,又叫作三路快排。
演示圖:
function quickSort(arr) {
// 只剩1個元素,不能再分割了
if (arr.length <= 1) return arr
// 取第1個元素爲基準值
let base = arr[0]
// 分割爲左小右大兩個數組,以及包含元素自己的中間數組
let left = [], middle = [base], right = []
for (let index = 1; index < arr.length; index++) {
// 若是有與自己同樣大的元素,放入 middle 數組,解決重複元素的問題
if (arr[index] === base) middle.push(arr[index])
else if (arr[index] < base) left.push(arr[index])
else right.push(arr[index])
}
// 遞歸併鏈接
return quickSort(left).concat(middle, quickSort(right))
}
複製代碼
是採用分治法(Divide and Conquer)的一個很是典型的應用。
簡單說就是縮小問題規模,快速排序也是分治法
大體流程:
遞歸地把數組分割成先後兩個子數組,直到數組中只有1個元素
直接分兩半,不用排序
同時,遞歸地從兩個數組中挨個取元素,比較大小併合並
演示圖:
// 分割
function mergeSort2(arr) {
// 若是隻剩一個元素,分割結束
if (arr.length < 2) return arr
// 不然繼續分紅兩部分
let middle_index = Math.floor(arr.length / 2),
left = arr.slice(0, middle_index),
right = arr.slice(middle_index)
return merge2(mergeSort2(left), mergeSort2(right))
}
// 合併
function merge2(left, right) {
let result = []
// 當左右兩個數組都尚未取完的時候,比較大小而後合併
while (left.length && right.length) {
if (left[0] < right[0]) result.push(left.shift())
else result.push(right.shift())
}
// 其中一個數組空了,另外一個還剩下一些元素
// 由於是已經排序過的,因此直接concat就行了
// 注意 concat 不改變原數組
if (left.length) result = result.concat(left)
if (right.length) result = result.concat(right)
return result
}
複製代碼
只能用於由肯定範圍的整數所構成的數組。
統計每一個元素出現的次數,新建一個數組 arr
,新數組的索引爲原數組元素的值,每一個位置上的值爲原數組元素出現的次數。
大體流程:
演示圖:
function countingSort(array) {
let count_arr = [], result_arr = []
// 統計出現次數
for (let i = 0; i < array.length; i++) {
count_arr[array[i]] = count_arr[array[i]] ? count_arr[array[i]] + 1 : 1
}
// 遍歷統計數組,放入結果數組
for (let i = 0; i < count_arr.length; i++) {
while (count_arr[i] > 0) {
result_arr.push(i)
count_arr[i]--
}
}
return result_arr
}
複製代碼
根據原數組的最小和最大值的範圍,劃分出幾個區間,每一個區間用數組來表示,也就是這裏所說的桶。
根據元素大小分別放入對應的桶當中,每一個桶中使用任意算法進行排序,最後再把幾個桶合併起來。
區間的數量通常是手動指定的。
基本流程:
range
range
,商的整數部分即對應的桶的索引,放入該桶push()
,好比使用插入排序concat
起來便可其餘排序方法固然也能夠。不過插入排序實現時更接近「給已排序數組新增一個元素並使之有序」這種目的。
演示圖:
function bucketSort(array, num) {
let buckets = [],
min = Math.min(...array),
max = Math.max(...array)
// 初始化 num 個桶
for (let i = 0; i < num; i++) buckets[i] = []
// (最大值-最小值)/桶數,獲得每一個桶最小最大值的差,即區間
// 好比 range 爲10, 0號桶區間爲0-10,1號桶10-20,...
let range = (max - min + 1) / num
for (let i = 0; i < array.length; i++) {
// (元素-最小值)/區間,取整數部分,就是應該放入的桶的索引
let bucket_index = Math.floor((array[i] - min) / range),
bucket = buckets[bucket_index]
// 空桶直接放入
if (bucket.length) {
bucket.push(array[i])
}
// 非空,插入排序
else {
let i = bucket.length - 1
while (i >= 0 && bucket[i] > array[i]) {
bucket[i + 1] = bucket[i]
i--
}
bucket[i + 1] = array[i]
}
}
// 合併全部桶
let result = []
buckets.forEach((bucket) => {
result = result.concat(bucket)
})
return result
}
複製代碼
一個題外話,關於 Array
的 fill()
方法。
在初始化數組的時候,想着是否是能夠用 let arr = new Array(4).fill([])
,一行代碼就能夠給數組添加初始元素,這樣就不用先建立數組,而後再 for
循環添加元素了。
可是問題是,fill()
添加的引用類型元素——這裏就是空數組 []
——它們指向的是同一個引用。若是修改了其中一個數組,其餘的數組也都跟着變了。
仍是老老實實 for
循環吧。
要求元素必須是0或正整數。
經過比較每一個元素對應位置上數字的大小進行排序:個位與個位,十位與十位 ...
根據比較順序不一樣,分爲兩類:
兩種方法的共同點是:
插播一曲 LSD: Lucy in the Sky with Diamonds
基本流程:
先看一下演示圖比較好
max_len
max_len
做爲遍歷次數,從個位開始;內層循環遍歷數組演示圖:
function radixSortLSD(arr) {
// 找出最大元素
let max_num = Math.max(...arr),
// 獲取其位數
max_len = getLengthOfNum(max_num)
console.log(`最大元素是 ${max_num},長度 ${max_len}`)
// 外層遍歷位數,內層遍歷數組
// 外層循環以最大元素的位數做爲遍歷次數
for (let digit = 1; digit <= max_len; digit++) {
// 初始化0-9 10個數組,這裏暫且叫作桶
let buckets = []
for (let i = 0; i < 10; i++) buckets[i] = []
// 遍歷數組
for (let i = 0; i < arr.length; i++) {
// 取出一個元素
let ele = arr[i]
// 獲取當前元素該位上的值
let value_of_this_digit = getSpecifiedValue(ele, digit)
// 根據該值,決定當前元素要放到哪一個桶裏
buckets[value_of_this_digit].push(ele)
console.log(buckets)
}
// 每次內層遍歷結束,把全部桶裏的元素依次取出來,覆蓋原數組
let result = []
buckets.toString().split(',').forEach((val) => {
if (val) result.push(parseInt(val))
})
// 獲得了一個排過序的新數組,繼續下一輪外層循環,比較下一位
arr = result
console.log(arr)
}
}
function getLengthOfNum(num) { return (num += '').length }
// 獲取一個數字指定位數上的值,超長時返回0
// 個位的位數是1,十位的位數是2 ...
function getSpecifiedValue(num, position) { return (num += '').split('').reverse().join('')[position - 1] || 0 }
複製代碼
這個沒圖,不過更簡單,也不須要圖。
現實生活中比較數字大小的時候通常也是這麼作的,先比較最高位,而後再看更小位。
基本流程:
舉兩個栗子。
沒有重複元素的狀況:
// 原始數組
[110, 24, 27, 56, 9]
// 原數組至關於
[110, 024, 027, 056, 009]
// 第一次入桶,比較最高位百位
[[024, 027, 056, 009], [110]]
// 當桶中有多個元素時,遞歸。這裏就是遞歸第一個桶
// 第二次入桶,比較十位
[[[009], [024, 027], [056]], [110]]
// 第二個桶中還有元素,繼續遞歸
// 第三次入桶,比較個位
[[[009], [[024], [027]], [056]], [110]]
// 結果就是
[009, 024, 027, 056, 110]
複製代碼
也就是說,對於沒有重複元素的狀況,遞歸的最終結果是每一個桶中只有一個元素。
有重複元素的狀況:
[110, 024, 024, 056, 009]
// 第一次入桶,比較百位
[[009, 024, 024, 056], [110]]
// 第二次入桶,比較十位
[[[009], [024, 024], [056]], [110]]
// 第三次入桶,比較個位
[[[009], [[024, 024]], [056]], [110]]
複製代碼
能夠發現,對於有重複元素的狀況,最終重複的元素都會在同一個桶中,不會產生每一個桶中只有一個元素的結果。
這時只要判斷是否已經比較完個位了便可。也就是說,無論有沒有重複元素,最大元素有幾位,就最多須要比較多少次。
總之,能夠想象成一個樹結構,從原數組開始一直向下分出子數組,最後子數組中只有一個元素,或只有重複的元素。
function radixSortMSD(arr) {
// 最大元素
let max_num = Math.max(...arr),
// 獲取其位數做爲初始值,最小值爲1,也就是個位
digit = getLengthOfNum(max_num)
return msd(arr, digit)
}
function msd(arr, digit) {
// 建10個桶
let buckets = []
for (let i = 0; i < 10; i++) buckets[i] = []
// 遍歷數組,入桶。這裏跟 LSD 同樣
for (let i = 0; i < arr.length; i++) {
let ele = arr[i]
let value_of_this_digit = getSpecifiedValue(ele, digit)
buckets[value_of_this_digit].push(ele)
}
// 結果數組
let result = []
// 遍歷每一個桶
for (let i = 0; i < buckets.length; i++) {
// 只剩一個元素,直接加入結果數組
if (buckets[i].length === 1) result = result.concat(buckets[i])
// 還有多個元素,可是已經比較到個位了
// 說明是重複元素的狀況,也直接加入結果數組
else if (buckets[i].length && digit === 1) result = result.concat(buckets[i])
// 還有多個元素,而且尚未比較結束,遞歸比較下一位
else if (buckets[i].length && digit !== 1) result = result.concat(msd(buckets[i], digit - 1))
// 空桶就不做處理了
}
return result
}
複製代碼
十大經典排序算法總結(JavaScript描述) - 掘金
前端 排序算法總結 - segmentfault
JS快速排序&三路快排
圖解排序算法(二)之希爾排序
計數排序,桶排序與基數排序 - segmentfault
時間複雜度 - 維基
比較排序 - 維基
個人其餘文章:
《深刻 JavaScript 經常使用的8種繼承方案》
《免費爲網站添加 SSL 證書》
《詳解 new/bind/apply/call 的模擬實現》