做爲一個非計算機專業出身的渣渣小前端,算法是前端技能進階路上一座繞不開的大山。曾經嘗試到 leetcode 上開坑,然而發現作出一道題,要花上好幾個小時的時間。後來搜了一些算法學習方法相關的文章,許多人都提到:算法須要先系統性的學習,再去作題。而我平時開發最常使用 JavaScript,所以選用《數據結構與算法 JavaScript 描述》一書做爲入門書籍。前端
閱讀這本書以後,我與 《JS家的排序算法》 做者有一個相同的感覺:書裏有不少小錯誤,不只僅是在文字描述上,在代碼中也有出現。可是不得不認可,這本書很是適合前端開發者的算法入門學習,緣由是其內容足夠簡潔、基礎,它簡潔明瞭地解釋了每種算法的原理,沒有涉及優化,以及算法考察中的一些難點。因此,若是須要進階學習,仍是須要配合其餘書籍食用。node
這篇文章權當我學習算法過程當中的筆記,就從算法中基礎的類別:排序算法開始,有問題請你們指出,我會盡快修正,避免誤導他人。git
首先搭建一個簡單的性能測試平臺:github
// 使用一個函數集合保存全部用於測試的排序算法
let funcs = {
// 工具:交換數組元素
toolExch (a, lo, hi) { let temp = a[lo]; a[lo] = a[hi]; a[hi] = temp }
}
// 生成一個長度爲 10000,數值爲 0-99 之間的數組用於測試
// 注意:排序算法的性能每每與被排序數組的特性有關係
// 如重複數據的數量、數據大小的分佈、數據總體的方差等
// 本文主要的方向仍是說明各類排序算法的原理
// 所以直接生成一組隨機數做爲測試數據
let arr = Array.from({ length: 10000 }, v => Math.floor(Math.random() * 100))
// 執行集合中全部函數
for (let key in funcs) {
// 遇到有 tool 標記的函數判斷爲工具函數,跳過
if (!key.indexOf('tool')) { continue }
let temp = Array.from(arr)
// 使用 console 中的 time 和 timeEnd 函數輸出代碼執行時間
console.time(key)
funcs[key](temp)
console.timeEnd(key)
}
複製代碼
冒泡排序是最慢的排序算法之一,由於它交換元素的次數實在是太多了,但它也是最容易實現的排序算法。在運行過程當中,數據值會像氣泡同樣從一端漂浮到另外一端,好比升序排序,數據會與其右側相鄰的數據進行比較,若它比右側數據大,則會向右邊「冒泡」,直到遇到比它大的數據爲止。web
Bubble Sort 動圖演示 算法可視化來源:visualgo.net/算法
let funcs = {
// 冒泡排序
bubbleSort (arr) {
// 使用一個兩層的循環執行排序
// 內層循環每執行一次,外層循環的指針 i 就向前進一步,表示前面的數據確認已經完成排序
for (let i = 0; i < arr.length - 1; i++) {
// 內層循環保證每次都能將最小的數據移到數組最左邊
for (let j = arr.length - 1; j > i; j--) {
// 當前數據值比前一位的小,則將兩個數據交換位置
// 不然不進行操做,繼續處理下一位的數據
if (arr[j] < arr[j - 1]) {
this.toolExch(arr, j - 1, j)
}
}
}
return arr
}
}
複製代碼
選擇排序的原理,以升序排序爲例,就是從數組的開頭開始,用第一條數據和其餘數據進行比較,取其中最小的數據,與第一個位置的數據交換,再用第二條數據對後面的數據進行比較......如此反覆,當在數組的倒數第二位上執行完這個比較,整個排序就完成了。shell
與冒泡排序同樣,選擇排序也採用了兩層循環,但選擇排序在每次遍歷中只進行了一次數據位置的交換,所以它的速度比冒泡排序要快的多。數組
Selection Sort 動圖演示 算法可視化來源:visualgo.net/瀏覽器
let funcs = {
// 選擇排序
selectionSort (arr) {
// 外層循環維護一個指針 i,每當內層循環完成一次交換,外層循環的指針就往前移一步
// 指針移動到倒數第二個位置 arr.length - 2 時,結束循環
for (let i = 0; i <= arr.length - 2; i++) {
// index 維護了當前內循環中最小值的位置
let index = i
// 內層循環從指針 i 的位置日後查找最小的數據
for (let j = i; j < arr.length; j++) {
// 每當找到更小的數據,就更新 index
if (arr[j] < arr[index]) index = j
}
// 將位置在 index 的最小數據與位置在 i 的當前指針互換位置
this.toolExch(arr, index, i)
}
return arr
}
}
複製代碼
用上面的代碼進行一次粗略的運行(10000 條數據),得出選擇排序的性能要遠遠超過冒泡排序。性能優化
插入排序一樣使用兩層循環,以升序排序爲例:外層循環維護了一個指針 i,它從第二條數據開始向右移動。內層循環則維護一個指針 j 從 i 的位置開始向左移動,若 j 左邊的數據比 j 大,則將左邊的數據右移一格,直到遇到 j 左邊的數據比 j 小,就中止移動,並把最開始用於比較的 i 上的數據插入到這一位置。如此反覆,能夠保證每次內循環結束,i 左邊的數據都是有序的
,則執行完外循環便可完成排序。
Insertion Sort 動圖演示 算法可視化來源:visualgo.net/
let funcs = {
// 插入排序
insertionSort (arr) {
// 向右移動的外循環
for (let i = 1; i < arr.length; i++) {
// 聲明內循環指針
let j = i
// 記錄用於比較的當前數據
let curr = arr[i]
// 內循環,讓當前數據一直向左移動
// 直到遇到比當前數據小的值,或移動到數組左端爲止
while (j > 0 && arr[j - 1] > curr) {
// 將更大的數據往右推
arr[j] = arr[j - 1]
// 指針左移
j--
}
// 將當前數據插入到正確位置,使得 0~i 之間的數據有序
arr[j] = curr
}
return arr
}
}
複製代碼
圖片來自 algs4.cs.princeton.edu
根據《算法(第4版)》中比較插入排序與選擇排序的可視軌跡圖,發現插入排序加入比較的數據比選擇排序要少量多。所以,插入排序的性能是要強於選擇排序的。
用上面的代碼進行一次粗略的運行(10000 條數據),發現插入排序比選擇排序快許多。
《數據結構與算法 JavaScript 描述》一書中將希爾排序放在了高級算法的開篇位置,其實,希爾排序是在插入排序的基礎上進行了改善,它定義了一個間隔序列,讓算法先比較大間隔的數據
,使離正確位置遠的元素能夠更快的歸位,從而減小比較的次數,而後縮小間隔序列進行比較,直到間隔序列爲 1 時,數組有序。
《算法(第4版)》的合著者 Robert Sedgewick 經過一個公式動態定義了希爾排序中的間隔序列,在咱們的代碼實現中,就採用這種方法定義間隔序列。原書中將這種方式稱爲「簡潔的希爾排序」,事實上,希爾排序的性能與間隔序列的定義有着密切的聯繫。
對間隔爲 4 的數據進行比較示意圖
希爾排序動圖演示
let funcs = {
// 希爾排序
shellSort (arr) {
// 定義間隔序列 gap
let len = arr.length
let gap = 1
while (gap < len / 3) {
gap = gap * 3 + 1
}
// 按照間隔序列中的間隔逐次進行插入排序
while (gap >= 1) {
// 執行插入排序
for (let i = gap; i < len; i++) {
let j = i
let curr = arr[i]
while (j >= gap && arr[j - gap] > curr) {
arr[j] = arr[j - gap]
// 每次前進的步數爲 gap,造成對間隔的使用
j -= gap
}
arr[j] = curr
}
// 生成下一個間隔
gap = (gap - 1) / 3
}
return arr
}
}
複製代碼
希爾排序的效率與間隔序列的選擇
有很大的關係,《算法(第4版)》中描述道:「算法的性能不只取決於 h(即間隔),還取決於 h 之間的數學性質,好比他們的公因子等。有不少論文研究了各類不一樣的遞增序列,但都沒法證實某個序列是‘最好的’」。
用上面的代碼對 10000 條數據運行,發如今這個體量下希爾排序比插入排序快很是多。
歸併排序是應用高效算法設計中分治思想
的典型栗子,它的基本原理就是將數組不斷的對半拆分,直到拆分爲一對單個元素,而後將一對單個元素排列至有序,再與相鄰的一對有序元素合併爲一個大的有序數組,直到整個數組有序。
在代碼上,它有兩種實現方式,分別是使用遞歸的,自頂向下的歸併排序
(請見動圖演示:自頂向下的歸併排序),以及使用循環的,自底向上的歸併排序
(請見圖片演示:自底向上的歸併排序)。它們各有各的優勢,遞歸方式比較容易實現,可是會佔用額外的內存空間;循環方式邏輯比較複雜,可是佔用內存較少,性能較好。
《JS家的排序算法》 一文中指出:
好消息!好消息!ES6已經添加了對尾遞歸優化的支持,媽媽不再用擔憂我用JavaScript寫遞歸了。不過,須要注意的是,ES6的尾遞歸優化只在嚴格模式下才會開啓。
事實上,在瀏覽器端,除了 Safari,各大瀏覽器都並無實現尾遞歸優化的特性。在 node 中,尾遞歸優化也並非默認開啓的,須要在調用時使用--harmony_tailcalls
參數,才能手動開啓。並且 JS 的尾遞歸優化仍存在隱式優化和調用棧丟失的問題。所以,在 JS 引擎下使用遞歸方式的歸併排序,仍然有性能和穩定性方面的擔心。詳情參考 《尾遞歸的後續探究》。
動圖演示:自頂向下的歸併排序 算法可視化來源:visualgo.net/
圖片演示:自底向上的歸併排序
let funcs = {
// 自頂向下的歸併排序
merge (arr) {
// 遞歸的排序方法,接收數組、要排序的起始位置與結束位置
let sort = (a, lo, hi) => {
// 若 hi <= lo,則數組已經沒法再分半,即爲遞歸終點,則開始進行排序
if (hi <= lo) return
// 計算要排序數組的中間位置
// mid 即爲前半部分排序的終點
// mid + 1 爲後半部分排序的起點
let mid = lo + Math.floor((hi - lo) / 2)
// 分別對先後兩半進行遞歸調用,直到沒法再分半爲止
sort(a, lo, mid)
sort(a, mid + 1, hi)
// 對數組先後兩半執行歸併
this.toolMerge(a, lo, mid, hi)
}
sort(arr, 0, arr.length - 1)
return arr
},
// 自底向上的歸併排序
mergeBU (arr) {
// 獲取數組長度
let len = arr.length
// 外層循環維護一個歸併的單位大小 sz
// 由於老是進行對半拆分,因此它每次進行歸併的數組應該擴大爲 2 倍,即每次遞增操做爲 sz *= 2
for (let sz = 1; sz < len; sz *= 2) {
// 內循環維護了每次歸併的數組的起始位置 lo
// 結束條件的解釋是:lo + sz 指進行歸併數組的前一半長度,若 lo + sz 的右邊已經沒有數據可供歸併,則循環能夠結束
// 內循環每次執行歸併的數組大小爲 sz * 2,所以每次遞增增長 sz * 2
for (let lo = 0; lo < len - sz; lo += sz * 2) {
// 對當前操做的數組執行歸併
// 起始點爲 lo,中間位置爲 lo + sz - 1
// 結束位置若數組末端的下標更小,則需取數組末端的位置,以結束整個數組的歸併
this.toolMerge(arr, lo, lo + sz - 1, Math.min(lo + sz * 2 - 1, len - 1))
}
}
return arr
},
// 工具函數:原地歸併
// 它接收一個兩半各自有序的數組、起始位置、中間位置、結束位置四個參數
// 輸出將數組左右兩半歸併(邊合併邊排序),得出的大的有序數組
toolMerge (a, lo, mid, hi) {
// 聲明指針 i、j,用於表示分別遍歷左右兩半數組的下標
let i = lo // 左數組的開頭
let j = mid + 1 // 右數組的開頭
// 聲明一個臨時數組,並將傳入數組的全部元素複製過去
// 再從臨時數組中取出元素迴歸到原數組中,最終輸出原數組
let temp = []
for (let k = lo; k <= hi; k++) temp[k] = a[k]
// 遍歷臨時數組
for (let k = lo; k <= hi; k++) {
// 若左數組已經取完,則必從右數組取值,並將右數組指針右移一步
if (i > mid) { a[k] = temp[j++] }
// 若右數組已經取完,則必從左數組取值,並將左數組指針右移一步
else if (j > hi) { a[k] = temp[i++] }
// 若此時右數組的值更小,則取右數組的值迴歸原數組,並將右數組指針右移一步
else if (temp[i] >= temp[j]) { a[k] = temp[j++] }
// 若此時左數組的值更小,則取左數組的值迴歸原數組,並將左數組指針右移一步
else if (temp[i] < temp[j]) { a[k] = temp[i++] }
}
}
}
複製代碼
用上面的代碼對 10000 條數據運行,發現這裏的歸併排序比希爾排序還要慢一些,但仍是比選擇排序和插入排序等基礎排序快。
《算法(第4版)》中描述道:「在實際應用中,歸併排序與希爾排序的運行時間差距在常數級別以內,所以相對性能取決於具體的實現
。理論上來講,尚未人能證實希爾排序對於隨機數據的運行時間是線性對數級別的,所以存在平均狀況下希爾排序的運行時間更高的可能性。在最壞狀況下,這種差距的存在已經被證明,但這對實際應用沒有影響。」
事實上,歸併排序還能經過對小規模數組執行插入排序、當左數組的最右小於右數組的最左時直接認定整個數組有序等方式優化其性能。因此,上面的代碼是有很大的優化空間的,也並不能說明歸併排序比希爾排序慢。
許多書籍都給予了快速排序很高的評價。快速排序是一種平均性能十分優秀的算法,且只須要一個很小的輔助棧(佔用內存小),原理也十分簡單。
快速排序一樣是基於分治的設計思想,它須要一個切分點 pivot
,以升序排序爲例,將數組剩餘的元素中大於 pivot 的放到它的右邊,小於 pivot 的放到它的左邊,而後對根據 pivot 切分的左數組和右數組再分別進行一樣的排序,而後遞歸進行切分操做,直到整個數組有序。
Quick Sort 動圖演示 算法可視化來源:visualgo.net/
let funcs = {
// 使用輔助數組進行拆分,實現很是簡單
qSort (arr) {
if (arr.length === 0) { return [] }
// 聲明輔助數組,保存比 pivot 小及比 pivot 大的數據
// 聲明 pivot,這裏爲了方便直接取數組的第一個值
// 事實上,pivot 能夠爲被排序數組中任意的值,且如何對它進行取值會影響算法最終的性能
let lesser = [], greater = [], pivot = arr[0]
// 遍歷數組,將小於 pivot 的數據放入 lesser中
// 大於 pivot 的數據放入 greater 中
for (let i = 1; i < arr.length; i++) {
if (arr[i] < pivot) {
lesser.push(arr[i])
} else {
greater.push(arr[i])
}
}
// 最終輸出 [...小於 pivot 的數據集合, pivot, ...大於 pivot 的數據集合]
// 並對被切分後的左右數組分別進行遞歸調用,以輸出有序的左右數組
// 所以在全部遞歸完成後,整個數組就會有序
return this.qSort(lesser).concat(pivot, this.qSort(greater))
}
}
複製代碼
上面的代碼大量運用了 JS 的原生 API,性能必然不會出彩,下面咱們來看看它的性能表現。
用上面的代碼對 10000 條數據運行,結果快速排序的性能只比上述未經性能優化的歸併排序好一些,與身爲基礎排序的插入排序性能幾乎持平,遠遠遜色於以前實現的希爾排序。
將數據量擴大到 100000 條,發現這裏實現的快速排序的性能更差,運行時間是上面歸併排序的兩倍。可是頗有意思的是,當數據量增大,基礎排序算法的性能出現急劇降低,基於分治思想的歸併排序與快速排序則展示出了優點。
《算法(第4版)》中對於快速排序的優缺點進行了明確的解釋:「快速排序的內循環比大多數排序算法都要短小,這意味着它不管是在理論上仍是在實際中都要更快。它的主要缺點是很是脆弱,在實現時要很是當心才能避免低劣的性能。」所以快速排序須要各類算法優化的手段,避免這些狀況的發生。
根據上一節實現的經驗,咱們應該避免在切分的過程當中使用 JS 的原生 API,所以咱們須要優化切分
的過程,這裏採用交換先後數組的方式進行切分。
let funcs = {
// 使用交換先後數組元素的方式切分
qSortOptimizeSegmentation (arr) {
// 接收數組 a、數組起始位置 lo、結束位置 hi
// 將數組切分爲左邊小於切分點,右邊大於切分點的兩部分
// 最終輸出切分點的位置
let partition = (a, lo, hi) => {
// 聲明指針 i、j,分別從前向後以及從後向前遍歷數組;聲明切分點 v
let i = lo, j = hi + 1, v = a[lo]
// 外循環控制 i、j 兩枚指針的運動狀況,當他們相遇則結束循環
while (true) {
// 內循環遍歷輸入的數組,i 從前日後移動,j 從後往前移動
// 只要獲取到大於等於 v 的值,i 循環結束,獲取須要移動到切分點右側的數據位置 i
// j 循環同理
while (a[++i] < v) { if (i === hi) { break } }
while (a[--j] > v) { if (j === lo) { break } }
// 外循環終止條件,兩枚指針相遇,整個數組遍歷完成
if (i >= j) { break }
// 將須要移動到另外一側的兩個數據交換位置
this.toolExch(a, i, j)
}
// 此時數據已經以 j 與 j + 1 之間爲分界,切分爲了比 v 小的左數組與比 a 大的右數組
// 將切分點與 j 數據交換位置,得出切分後的數據
this.toolExch(a, lo, j)
// 輸出切分點位置 j
return j
}
let qs = (a, lo, hi) => {
// 處理到數組末尾,結束遞歸
if (lo >= hi) { return }
// 將數組切分爲左邊小於切分點,右邊大於切分點的兩部分,並輸出切分點位置 j
let j = partition(a, lo, hi)
// 對左數組與右數組遞歸執行排序
qs(a, lo, j - 1)
qs(a, j + 1, hi)
}
qs(arr, 0, arr.length - 1)
return arr
}
}
複製代碼
10000 條數據的測試,能夠發現將切分方式優化以後,速度明顯加快。
將測試數據增長到 100000 條,發現優化切分方式後的快速排序性能已經超越了以前的全部實現。也能夠發現,對於越大的數據集,越能發揮快速排序的性能優點。
《算法(第4版)》中說明了在小數據集中使用插入排序的緣由:
和大多數遞歸排序算法同樣,改進快速排序性能的一個簡單辦法基於如下兩點:
所以,在排序小數組時應該切換到插入排序
。然而,多小的數組才須要切換到插入排序呢?書中解釋道:轉換參數的最佳值是和系統相關的,可是 5~15 之間的任意值在大多數狀況下都能使人滿意。
let funcs = {
// 對大小小於 10 (5-15都可) 的數據集進行插入排序,優化小數據集的排序速度
qSortOptimizeSmallDataSet (arr) {
let partition = (a, lo, hi) => {
let i = lo, j = hi + 1, v = a[lo]
while (true) {
while (a[++i] < v) { if (i === hi) { break } }
while (a[--j] > v) { if (j === lo) { break } }
if (i >= j) { break }
this.toolExch(a, i, j)
}
this.toolExch(a, lo, j)
return j
}
let qs = (a, lo, hi) => {
// 起止位置的距離小於等於 10 時採用插入排序並結束遞歸
if (hi <= lo + 10) { a = this.toolInsertionSort(a, lo, hi); return }
let j = partition(a, lo, hi)
qs(a, lo, j - 1)
qs(a, j + 1, hi)
}
qs(arr, 0, arr.length - 1)
return arr
},
// 在指定範圍內執行插入排序
// 在插入排序章節中的實現基礎上增長起止位置的參數
toolInsertionSort (arr, lo, hi) {
for (let i = lo; i < hi + 1; i++) {
let j = i
let curr = arr[i]
while (j > lo && arr[j - 1] > curr) {
arr[j] = arr[j - 1]
j--
}
arr[j] = curr
}
return arr
}
}
複製代碼
qSortOptimizeSmallDataSet 處理 10000 條數據
qSortOptimizeSmallDataSet 處理 100000 條數據
qSortOptimizeSmallDataSet 處理 1000000 條數據
qSortOptimizeSmallDataSet 處理 10000000 條數據
超過一百萬條數據以後,優化了小數據集處理的 qSortOptimizeSmallDataSet 函數取得了更好的成績。
三向切分的關注點在於應對大量的重複數據
。標準的快速排序仍然是基於比較的,這意味着不管重複元素有多少,它都會對全部元素進行比較來輸出結果。而三向切分的原理則是將重複的元素聚合到數組中間,小元素分佈到重複元素序列的左邊,而大元素則分佈在右邊。
三向切分的軌跡(每次迭代循環以後的數組內容)
let funcs = {
// 三向切分把等於切分點的數據都移到中間,避免了全部等於切分點的數據重複排序
qSortThreeWayPartition (arr) {
// 接收一個數組、起始位置與結束位置
let qs = (a, lo, hi) => {
// 一樣對小於等於 10 的數據集進行插入排序
if (hi <= lo + 10) { a = this.toolInsertionSort(a, lo, hi); return }
// 維護兩個指針 lt、gt
// [0, lt] 範圍保存比切分值小的數據
// [gt, arr.length - 1] 範圍保存比切分數據大的數據
// [lt, gt] 範圍則是當次迭代中重複的切分值
let lt = lo, gt = hi
// 維護一個從左到右移動的指針 i,用於遍歷數組
let i = lo + 1
// 標記當前的切分值 v
let v = a[lo]
// 從左向右遍歷數組,直到移動到 gt 位置結束
// 緣由是
while (i <= gt) {
// 若當前值小於切分值,則將當前值與 lt 位置的切分值換位
// lt++,即 lt 右移,爲左側空間的「新人」讓一個位置
// i++,遍歷指針右移
if (a[i] < v) { this.toolExch(a, lt++, i++) }
// 若當前值大於切分值,則將當前值與 gt 位置的未知值換位
// gt--,即 gt 左移,爲右側空間的「新人」讓一個位置
// i 指針此時不須要前進,由於從 gt 換過來的值未知,須要對這個位置從新進行判斷
else if (a[i] > v) { this.toolExch(a, i, gt--) }
// 若當前值等於切分值,則不作處理,讓它呆在 [lt, gt] 範圍
// 這個位置處理完畢,i 指針前進
else { i++ }
}
// 對不等於切分值的數據遞歸執行排序
qs(a, lo, lt - 1)
qs(a, gt + 1, hi)
}
qs(arr, 0, arr.length - 1)
return arr
}
}
複製代碼
三向切分快速排序性能比較(十萬條數據)
三向切分快速排序性能比較(一百萬條數據)
三向切分快速排序性能比較(一千萬條數據)
能夠看出,數據量越大,三向切分的優點就越明顯,這是由於重複的數據變多了。因爲數據是在 0~100 範圍內隨機生成的,那麼若是擴大或縮小數據生成的範圍從而減小或增長重複值的數量
,基於歸類重複值的三向切分性能是否會受到影響呢?
三向切分快速排序性能比較(一千萬條數據、在 0~10 範圍內生成數據)
三向切分快速排序性能比較(一千萬條數據、在 0~100 範圍內生成數據)
三向切分快速排序性能比較(一千萬條數據、在 0~1000 範圍內生成數據)
三向切分快速排序性能比較(一千萬條數據、在 0~10000 範圍內生成數據)
咱們發現,當生成範圍縮小到 0~10 時,三向切分的優點被進一步擴大,但當生成範圍擴大時,三向切分的優點迅速縮小,當生成數據在 0~10000 範圍內時,三向切分的性能就已經低於二切分的實現了。所以三向切分的方案仍是適用於重複數據比較多的時候,如對性別進行排序等等。
事實上,僅在《算法(第4版)》一書的內容中,快速排序的優化思路就還有:切換到插入排序的時機選擇、切分點的取樣方式選擇(如使用數組中位數)、二切分中使用哨兵代替邊界檢查、取樣切分等等。時間所限,如此多的優化方案實在沒法一一研究,更況且還有堆排序、計數排序、桶排序與基數排序這一系列排序算法,若是有機會我再一一補充上來。
不得不說,學習算法的過程總能讓人體會到計算機程序的神奇(旁白:竟然還能這樣運行!),這大概就是「算法之美」吧~
最後附上文中測試平臺的完整版地址:github.com/yyj08070631…