算法爲王。javascript
想學好前端,先練好內功,內功不行,就算招式練的再花哨,終究成不了高手;只有內功深厚者,前端之路纔會走得更遠。html
筆者寫的 JavaScript 數據結構與算法之美 系列用的語言是 JavaScript ,旨在入門數據結構與算法和方便之後複習。前端
文中包含了 十大經典排序算法
的思想、代碼實現、一些例子、複雜度分析、動畫、還有算法可視化工具。java
這應該是目前最全的 JavaScript 十大經典排序算法
的講解了吧。git
複雜度分析是整個算法學習的精髓。github
時間和空間複雜度的詳解,請看 JavaScript 數據結構與算法之美 - 時間和空間複雜度。算法
學習排序算法,咱們除了學習它的算法原理、代碼實現以外,更重要的是要學會如何評價、分析一個排序算法。shell
分析一個排序算法,要從 執行效率
、內存消耗
、穩定性
三方面入手。api
1. 最好狀況、最壞狀況、平均狀況時間複雜度數組
咱們在分析排序算法的時間複雜度時,要分別給出最好狀況、最壞狀況、平均狀況下的時間複雜度。 除此以外,你還要說出最好、最壞時間複雜度對應的要排序的原始數據是什麼樣的。
2. 時間複雜度的係數、常數 、低階
咱們知道,時間複雜度反應的是數據規模 n 很大的時候的一個增加趨勢,因此它表示的時候會忽略係數、常數、低階。
可是實際的軟件開發中,咱們排序的多是 10 個、100 個、1000 個這樣規模很小的數據,因此,在對同一階時間複雜度的排序算法性能對比的時候,咱們就要把係數、常數、低階也考慮進來。
3. 比較次數和交換(或移動)次數
這一節和下一節講的都是基於比較的排序算法。基於比較的排序算法的執行過程,會涉及兩種操做,一種是元素比較大小,另外一種是元素交換或移動。
因此,若是咱們在分析排序算法的執行效率的時候,應該把比較次數和交換(或移動)次數也考慮進去。
也就是看空間複雜度。
還須要知道以下術語:
相等
的元素,通過排序以後,相等元素之間原有的前後順序不變
。 好比: a 本來在 b 前面,而 a = b,排序以後,a 仍然在 b 的前面;相等
的元素,通過排序以後,相等元素之間原有的前後順序改變
。 好比:a 本來在 b 的前面,而 a = b,排序以後, a 在 b 的後面;思想
特色
實現
// 冒泡排序(未優化)
const bubbleSort = arr => {
console.time('改進前冒泡排序耗時');
const length = arr.length;
if (length <= 1) return;
// i < length - 1 是由於外層只須要 length-1 次就排好了,第 length 次比較是多餘的。
for (let i = 0; i < length - 1; i++) {
// j < length - i - 1 是由於內層的 length-i-1 到 length-1 的位置已經排好了,不須要再比較一次。
for (let j = 0; j < length - i - 1; j++) {
if (arr[j] > arr[j + 1]) {
const temp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = temp;
}
}
}
console.log('改進前 arr :', arr);
console.timeEnd('改進前冒泡排序耗時');
};
複製代碼
優化:當某次冒泡操做已經沒有數據交換時,說明已經達到徹底有序,不用再繼續執行後續的冒泡操做。
// 冒泡排序(已優化)
const bubbleSort2 = arr => {
console.time('改進後冒泡排序耗時');
const length = arr.length;
if (length <= 1) return;
// i < length - 1 是由於外層只須要 length-1 次就排好了,第 length 次比較是多餘的。
for (let i = 0; i < length - 1; i++) {
let hasChange = false; // 提早退出冒泡循環的標誌位
// j < length - i - 1 是由於內層的 length-i-1 到 length-1 的位置已經排好了,不須要再比較一次。
for (let j = 0; j < length - i - 1; j++) {
if (arr[j] > arr[j + 1]) {
const temp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = temp;
hasChange = true; // 表示有數據交換
}
}
if (!hasChange) break; // 若是 false 說明全部元素已經到位,沒有數據交換,提早退出
}
console.log('改進後 arr :', arr);
console.timeEnd('改進後冒泡排序耗時');
};
複製代碼
測試
// 測試
const arr = [7, 8, 4, 5, 6, 3, 2, 1];
bubbleSort(arr);
// 改進前 arr : [1, 2, 3, 4, 5, 6, 7, 8]
// 改進前冒泡排序耗時: 0.43798828125ms
const arr2 = [7, 8, 4, 5, 6, 3, 2, 1];
bubbleSort2(arr2);
// 改進後 arr : [1, 2, 3, 4, 5, 6, 7, 8]
// 改進後冒泡排序耗時: 0.318115234375ms
複製代碼
分析
冒泡的過程只涉及相鄰數據的交換操做,只須要常量級的臨時空間,因此它的空間複雜度爲 O(1),是一個原地
排序算法。
在冒泡排序中,只有交換才能夠改變兩個元素的先後順序。 爲了保證冒泡排序算法的穩定性,當有相鄰的兩個元素大小相等的時候,咱們不作交換,相同大小的數據在排序先後不會改變順序。 因此冒泡排序是穩定
的排序算法。
最佳狀況:T(n) = O(n),當數據已是正序時。 最差狀況:T(n) = O(n2),當數據是反序時。 平均狀況:T(n) = O(n2)。
動畫
插入排序又爲分爲 直接插入排序 和優化後的 拆半插入排序 與 希爾排序,咱們一般說的插入排序是指直接插入排序。
1、直接插入
思想
通常人打撲克牌,整理牌的時候,都是按牌的大小(從小到大或者從大到小)整理牌的,那每摸一張新牌,就掃描本身的牌,把新牌插入到相應的位置。
插入排序的工做原理:經過構建有序序列,對於未排序數據,在已排序序列中從後向前掃描,找到相應位置並插入。
步驟
實現
// 插入排序
const insertionSort = array => {
const len = array.length;
if (len <= 1) return
let preIndex, current;
for (let i = 1; i < len; i++) {
preIndex = i - 1; //待比較元素的下標
current = array[i]; //當前元素
while (preIndex >= 0 && array[preIndex] > current) {
//前置條件之一: 待比較元素比當前元素大
array[preIndex + 1] = array[preIndex]; //將待比較元素後移一位
preIndex--; //遊標前移一位
}
if (preIndex + 1 != i) {
//避免同一個元素賦值給自身
array[preIndex + 1] = current; //將當前元素插入預留空位
console.log('array :', array);
}
}
return array;
};
複製代碼
測試
// 測試
const array = [5, 4, 3, 2, 1];
console.log("原始 array :", array);
insertionSort(array);
// 原始 array: [5, 4, 3, 2, 1]
// array: [4, 5, 3, 2, 1]
// array: [3, 4, 5, 2, 1]
// array: [2, 3, 4, 5, 1]
// array: [1, 2, 3, 4, 5]
複製代碼
分析
插入排序算法的運行並不須要額外的存儲空間,因此空間複雜度是 O(1),因此,這是一個原地
排序算法。
在插入排序中,對於值相同的元素,咱們能夠選擇將後面出現的元素,插入到前面出現元素的後面,這樣就能夠保持原有的先後順序不變,因此插入排序是穩定
的排序算法。
最佳狀況:T(n) = O(n),當數據已是正序時。 最差狀況:T(n) = O(n2),當數據是反序時。 平均狀況:T(n) = O(n2)。
動畫
2、拆半插入
插入排序也有一種優化算法,叫作拆半插入
。
思想
折半插入排序是直接插入排序的升級版,鑑於插入排序第一部分爲已排好序的數組,咱們沒必要按順序依次尋找插入點,只需比較它們的中間值與待插入元素的大小便可。
步驟
注:x >> 1 是位運算中的右移運算,表示右移一位,等同於 x 除以 2 再取整,即 x >> 1 == Math.floor(x/2) 。
// 折半插入排序
const binaryInsertionSort = array => {
const len = array.length;
if (len <= 1) return;
let current, i, j, low, high, m;
for (i = 1; i < len; i++) {
low = 0;
high = i - 1;
current = array[i];
while (low <= high) {
//步驟 1 & 2 : 折半查找
m = (low + high) >> 1; // 注: x>>1 是位運算中的右移運算, 表示右移一位, 等同於 x 除以 2 再取整, 即 x>>1 == Math.floor(x/2) .
if (array[i] >= array[m]) {
//值相同時, 切換到高半區,保證穩定性
low = m + 1; //插入點在高半區
} else {
high = m - 1; //插入點在低半區
}
}
for (j = i; j > low; j--) {
//步驟 3: 插入位置以後的元素所有後移一位
array[j] = array[j - 1];
console.log('array2 :', JSON.parse(JSON.stringify(array)));
}
array[low] = current; //步驟 4: 插入該元素
}
console.log('array2 :', JSON.parse(JSON.stringify(array)));
return array;
};
複製代碼
測試
const array2 = [5, 4, 3, 2, 1];
console.log('原始 array2:', array2);
binaryInsertionSort(array2);
// 原始 array2: [5, 4, 3, 2, 1]
// array2 : [5, 5, 3, 2, 1]
// array2 : [4, 5, 5, 2, 1]
// array2 : [4, 4, 5, 2, 1]
// array2 : [3, 4, 5, 5, 1]
// array2 : [3, 4, 4, 5, 1]
// array2 : [3, 3, 4, 5, 1]
// array2 : [2, 3, 4, 5, 5]
// array2 : [2, 3, 4, 4, 5]
// array2 : [2, 3, 3, 4, 5]
// array2 : [2, 2, 3, 4, 5]
// array2 : [1, 2, 3, 4, 5]
複製代碼
注意
:和直接插入排序相似,折半插入排序每次交換的是相鄰的且值爲不一樣的元素,它並不會改變值相同的元素之間的順序,所以它是穩定的。
3、希爾排序
希爾排序是一個平均時間複雜度爲 O(n log n) 的算法,會在下一個章節和 歸併排序、快速排序、堆排序 一塊兒講,本文就不展開了。
思路
選擇排序算法的實現思路有點相似插入排序,也分已排序區間和未排序區間。可是選擇排序每次會從未排序區間中找到最小的元素,將其放到已排序區間的末尾。
步驟
實現
const selectionSort = array => {
const len = array.length;
let minIndex, temp;
for (let i = 0; i < len - 1; i++) {
minIndex = i;
for (let j = i + 1; j < len; j++) {
if (array[j] < array[minIndex]) {
// 尋找最小的數
minIndex = j; // 將最小數的索引保存
}
}
temp = array[i];
array[i] = array[minIndex];
array[minIndex] = temp;
console.log('array: ', array);
}
return array;
};
複製代碼
測試
// 測試
const array = [5, 4, 3, 2, 1];
console.log('原始array:', array);
selectionSort(array);
// 原始 array: [5, 4, 3, 2, 1]
// array: [1, 4, 3, 2, 5]
// array: [1, 2, 3, 4, 5]
// array: [1, 2, 3, 4, 5]
// array: [1, 2, 3, 4, 5]
複製代碼
分析
選擇排序空間複雜度爲 O(1),是一種原地
排序算法。
選擇排序每次都要找剩餘未排序元素中的最小值,並和前面的元素交換位置,這樣破壞了穩定性。因此,選擇排序是一種不穩定
的排序算法。
不管是正序仍是逆序,選擇排序都會遍歷 n2 / 2 次來排序,因此,最佳、最差和平均的複雜度是同樣的。 最佳狀況:T(n) = O(n2)。 最差狀況:T(n) = O(n2)。 平均狀況:T(n) = O(n2)。
動畫
思想
排序一個數組,咱們先把數組從中間分紅先後兩部分,而後對先後兩部分分別排序,再將排好序的兩部分合並在一塊兒,這樣整個數組就都有序了。
歸併排序採用的是分治思想
。
分治,顧名思義,就是分而治之,將一個大問題分解成小的子問題來解決。小的子問題解決了,大問題也就解決了。
注:x >> 1 是位運算中的右移運算,表示右移一位,等同於 x 除以 2 再取整,即 x >> 1 === Math.floor(x / 2) 。
實現
const mergeSort = arr => {
//採用自上而下的遞歸方法
const len = arr.length;
if (len < 2) {
return arr;
}
// length >> 1 和 Math.floor(len / 2) 等價
let middle = Math.floor(len / 2),
left = arr.slice(0, middle),
right = arr.slice(middle); // 拆分爲兩個子數組
return merge(mergeSort(left), mergeSort(right));
};
const merge = (left, right) => {
const result = [];
while (left.length && right.length) {
// 注意: 判斷的條件是小於或等於,若是隻是小於,那麼排序將不穩定.
if (left[0] <= right[0]) {
result.push(left.shift());
} else {
result.push(right.shift());
}
}
while (left.length) result.push(left.shift());
while (right.length) result.push(right.shift());
return result;
};
複製代碼
測試
// 測試
const arr = [3, 44, 38, 5, 47, 15, 36, 26, 27, 2, 46, 4, 19, 50, 48];
console.time('歸併排序耗時');
console.log('arr :', mergeSort(arr));
console.timeEnd('歸併排序耗時');
// arr : [2, 3, 4, 5, 15, 19, 26, 27, 36, 38, 44, 46, 47, 48, 50]
// 歸併排序耗時: 0.739990234375ms
複製代碼
分析
這是由於歸併排序的合併函數,在合併兩個有序數組爲一個有序數組時,須要藉助額外的存儲空間。 實際上,儘管每次合併操做都須要申請額外的內存空間,但在合併完成以後,臨時開闢的內存空間就被釋放掉了。在任意時刻,CPU 只會有一個函數在執行,也就只會有一個臨時的內存空間在使用。臨時內存空間最大也不會超過 n 個數據的大小,因此空間複雜度是 O(n)。 因此,歸併排序不是
原地排序算法。
merge 方法裏面的 left[0] <= right[0] ,保證了值相同的元素,在合併先後的前後順序不變。歸併排序是穩定
的排序方法。
從效率上看,歸併排序可算是排序算法中的佼佼者
。假設數組長度爲 n,那麼拆分數組共需 logn 步,又每步都是一個普通的合併子數組的過程,時間複雜度爲 O(n),故其綜合時間複雜度爲 O(n log n)。
最佳狀況:T(n) = O(n log n)。 最差狀況:T(n) = O(n log n)。 平均狀況:T(n) = O(n log n)。
動畫
快速排序的特色就是快,並且效率高!它是處理大數據最快的排序算法之一。
思想
特色:快速,經常使用。
缺點:須要另外聲明兩個數組,浪費了內存空間資源。
實現
方法一:
const quickSort1 = arr => {
if (arr.length <= 1) {
return arr;
}
//取基準點
const midIndex = Math.floor(arr.length / 2);
//取基準點的值,splice(index,1) 則返回的是含有被刪除的元素的數組。
const valArr = arr.splice(midIndex, 1);
const midIndexVal = valArr[0];
const left = []; //存放比基準點小的數組
const right = []; //存放比基準點大的數組
//遍歷數組,進行判斷分配
for (let i = 0; i < arr.length; i++) {
if (arr[i] < midIndexVal) {
left.push(arr[i]); //比基準點小的放在左邊數組
} else {
right.push(arr[i]); //比基準點大的放在右邊數組
}
}
//遞歸執行以上操做,對左右兩個數組進行操做,直到數組長度爲 <= 1
return quickSort1(left).concat(midIndexVal, quickSort1(right));
};
const array2 = [5, 4, 3, 2, 1];
console.log('quickSort1 ', quickSort1(array2));
// quickSort1: [1, 2, 3, 4, 5]
複製代碼
方法二:
// 快速排序
const quickSort = (arr, left, right) => {
let len = arr.length,
partitionIndex;
left = typeof left != 'number' ? 0 : left;
right = typeof right != 'number' ? len - 1 : right;
if (left < right) {
partitionIndex = partition(arr, left, right);
quickSort(arr, left, partitionIndex - 1);
quickSort(arr, partitionIndex + 1, right);
}
return arr;
};
const partition = (arr, left, right) => {
//分區操做
let pivot = left, //設定基準值(pivot)
index = pivot + 1;
for (let i = index; i <= right; i++) {
if (arr[i] < arr[pivot]) {
swap(arr, i, index);
index++;
}
}
swap(arr, pivot, index - 1);
return index - 1;
};
const swap = (arr, i, j) => {
let temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
};
複製代碼
測試
// 測試
const array = [5, 4, 3, 2, 1];
console.log('原始array:', array);
const newArr = quickSort(array);
console.log('newArr:', newArr);
// 原始 array: [5, 4, 3, 2, 1]
// newArr: [1, 4, 3, 2, 5]
複製代碼
分析
由於 partition() 函數進行分區時,不須要不少額外的內存空間,因此快排是原地排序
算法。
和選擇排序類似,快速排序每次交換的元素都有可能不是相鄰的,所以它有可能打破原來值爲相同的元素之間的順序。所以,快速排序並不穩定
。
極端的例子:若是數組中的數據原來已是有序的了,好比 1,3,5,6,8。若是咱們每次選擇最後一個元素做爲 pivot,那每次分區獲得的兩個區間都是不均等的。咱們須要進行大約 n 次分區操做,才能完成快排的整個過程。每次分區咱們平均要掃描大約 n / 2 個元素,這種狀況下,快排的時間複雜度就從 O(nlogn) 退化成了 O(n2)。
最佳狀況:T(n) = O(n log n)。 最差狀況:T(n) = O(n2)。 平均狀況:T(n) = O(n log n)。
動畫
解答開篇問題
快排和歸併用的都是分治思想,遞推公式和遞歸代碼也很是類似,那它們的區別在哪裏呢 ?
能夠發現:
由下而上
的,先處理子問題,而後再合併。由上而下
的,先分區,而後再處理子問題。思想
過程
實現
const shellSort = arr => {
let len = arr.length,
temp,
gap = 1;
console.time('希爾排序耗時');
while (gap < len / 3) {
//動態定義間隔序列
gap = gap * 3 + 1;
}
for (gap; gap > 0; gap = Math.floor(gap / 3)) {
for (let i = gap; i < len; i++) {
temp = arr[i];
let j = i - gap;
for (; j >= 0 && arr[j] > temp; j -= gap) {
arr[j + gap] = arr[j];
}
arr[j + gap] = temp;
console.log('arr :', arr);
}
}
console.timeEnd('希爾排序耗時');
return arr;
};
複製代碼
測試
// 測試
const array = [35, 33, 42, 10, 14, 19, 27, 44];
console.log('原始array:', array);
const newArr = shellSort(array);
console.log('newArr:', newArr);
// 原始 array: [35, 33, 42, 10, 14, 19, 27, 44]
// arr : [14, 33, 42, 10, 35, 19, 27, 44]
// arr : [14, 19, 42, 10, 35, 33, 27, 44]
// arr : [14, 19, 27, 10, 35, 33, 42, 44]
// arr : [14, 19, 27, 10, 35, 33, 42, 44]
// arr : [14, 19, 27, 10, 35, 33, 42, 44]
// arr : [14, 19, 27, 10, 35, 33, 42, 44]
// arr : [10, 14, 19, 27, 35, 33, 42, 44]
// arr : [10, 14, 19, 27, 35, 33, 42, 44]
// arr : [10, 14, 19, 27, 33, 35, 42, 44]
// arr : [10, 14, 19, 27, 33, 35, 42, 44]
// arr : [10, 14, 19, 27, 33, 35, 42, 44]
// 希爾排序耗時: 3.592041015625ms
// newArr: [10, 14, 19, 27, 33, 35, 42, 44]
複製代碼
分析
希爾排序過程當中,只涉及相鄰數據的交換操做,只須要常量級的臨時空間,空間複雜度爲 O(1) 。因此,希爾排序是原地排序
算法。
咱們知道,單次直接插入排序是穩定的,它不會改變相同元素之間的相對順序,但在屢次不一樣的插入排序過程當中,相同的元素可能在各自的插入排序中移動,可能致使相同元素相對順序發生變化。 所以,希爾排序不穩定
。
最佳狀況:T(n) = O(n log n)。 最差狀況:T(n) = O(n log2 n)。 平均狀況:T(n) = O(n log2 n)。
動畫
堆的定義
堆實際上是一種特殊的樹。只要知足這兩點,它就是一個堆。
對於每一個節點的值都大於等於
子樹中每一個節點值的堆,咱們叫做大頂堆
。 對於每一個節點的值都小於等於
子樹中每一個節點值的堆,咱們叫做小頂堆
。
其中圖 1 和 圖 2 是大頂堆,圖 3 是小頂堆,圖 4 不是堆。除此以外,從圖中還能夠看出來,對於同一組數據,咱們能夠構建多種不一樣形態的堆。
思想
實現
// 堆排序
const heapSort = array => {
console.time('堆排序耗時');
// 初始化大頂堆,從第一個非葉子結點開始
for (let i = Math.floor(array.length / 2 - 1); i >= 0; i--) {
heapify(array, i, array.length);
}
// 排序,每一次 for 循環找出一個當前最大值,數組長度減一
for (let i = Math.floor(array.length - 1); i > 0; i--) {
// 根節點與最後一個節點交換
swap(array, 0, i);
// 從根節點開始調整,而且最後一個結點已經爲當前最大值,不須要再參與比較,因此第三個參數爲 i,即比較到最後一個結點前一個便可
heapify(array, 0, i);
}
console.timeEnd('堆排序耗時');
return array;
};
// 交換兩個節點
const swap = (array, i, j) => {
let temp = array[i];
array[i] = array[j];
array[j] = temp;
};
// 將 i 結點如下的堆整理爲大頂堆,注意這一步實現的基礎其實是:
// 假設結點 i 如下的子堆已是一個大頂堆,heapify 函數實現的
// 功能是其實是:找到 結點 i 在包括結點 i 的堆中的正確位置。
// 後面將寫一個 for 循環,從第一個非葉子結點開始,對每個非葉子結點
// 都執行 heapify 操做,因此就知足告終點 i 如下的子堆已是一大頂堆
const heapify = (array, i, length) => {
let temp = array[i]; // 當前父節點
// j < length 的目的是對結點 i 如下的結點所有作順序調整
for (let j = 2 * i + 1; j < length; j = 2 * j + 1) {
temp = array[i]; // 將 array[i] 取出,整個過程至關於找到 array[i] 應處於的位置
if (j + 1 < length && array[j] < array[j + 1]) {
j++; // 找到兩個孩子中較大的一個,再與父節點比較
}
if (temp < array[j]) {
swap(array, i, j); // 若是父節點小於子節點:交換;不然跳出
i = j; // 交換後,temp 的下標變爲 j
} else {
break;
}
}
};
複製代碼
測試
const array = [4, 6, 8, 5, 9, 1, 2, 5, 3, 2];
console.log('原始array:', array);
const newArr = heapSort(array);
console.log('newArr:', newArr);
// 原始 array: [4, 6, 8, 5, 9, 1, 2, 5, 3, 2]
// 堆排序耗時: 0.15087890625ms
// newArr: [1, 2, 2, 3, 4, 5, 5, 6, 8, 9]
複製代碼
分析
整個堆排序的過程,都只須要極個別臨時存儲空間,因此堆排序是
原地排序算法。
由於在排序的過程,存在將堆的最後一個節點跟堆頂節點互換的操做,因此就有可能改變值相同數據的原始相對順序。 因此,堆排序是不穩定
的排序算法。
堆排序包括建堆和排序兩個操做,建堆過程的時間複雜度是 O(n),排序過程的時間複雜度是 O(nlogn),因此,堆排序總體的時間複雜度是 O(nlogn)。
最佳狀況:T(n) = O(n log n)。 最差狀況:T(n) = O(n log n)。 平均狀況:T(n) = O(n log n)。
動畫
桶排序是計數排序的升級版,也採用了分治思想
。
思想
好比:
桶排序利用了函數的映射關係,高效與否的關鍵就在於這個映射函數的肯定。
爲了使桶排序更加高效,咱們須要作到這兩點:
桶排序的核心:就在於怎麼把元素平均分配到每一個桶裏,合理的分配將大大提升排序的效率。
實現
// 桶排序
const bucketSort = (array, bucketSize) => {
if (array.length === 0) {
return array;
}
console.time('桶排序耗時');
let i = 0;
let minValue = array[0];
let maxValue = array[0];
for (i = 1; i < array.length; i++) {
if (array[i] < minValue) {
minValue = array[i]; //輸入數據的最小值
} else if (array[i] > maxValue) {
maxValue = array[i]; //輸入數據的最大值
}
}
//桶的初始化
const DEFAULT_BUCKET_SIZE = 5; //設置桶的默認數量爲 5
bucketSize = bucketSize || DEFAULT_BUCKET_SIZE;
const bucketCount = Math.floor((maxValue - minValue) / bucketSize) + 1;
const buckets = new Array(bucketCount);
for (i = 0; i < buckets.length; i++) {
buckets[i] = [];
}
//利用映射函數將數據分配到各個桶中
for (i = 0; i < array.length; i++) {
buckets[Math.floor((array[i] - minValue) / bucketSize)].push(array[i]);
}
array.length = 0;
for (i = 0; i < buckets.length; i++) {
quickSort(buckets[i]); //對每一個桶進行排序,這裏使用了快速排序
for (var j = 0; j < buckets[i].length; j++) {
array.push(buckets[i][j]);
}
}
console.timeEnd('桶排序耗時');
return array;
};
// 快速排序
const quickSort = (arr, left, right) => {
let len = arr.length,
partitionIndex;
left = typeof left != 'number' ? 0 : left;
right = typeof right != 'number' ? len - 1 : right;
if (left < right) {
partitionIndex = partition(arr, left, right);
quickSort(arr, left, partitionIndex - 1);
quickSort(arr, partitionIndex + 1, right);
}
return arr;
};
const partition = (arr, left, right) => {
//分區操做
let pivot = left, //設定基準值(pivot)
index = pivot + 1;
for (let i = index; i <= right; i++) {
if (arr[i] < arr[pivot]) {
swap(arr, i, index);
index++;
}
}
swap(arr, pivot, index - 1);
return index - 1;
};
const swap = (arr, i, j) => {
let temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
};
複製代碼
測試
const array = [4, 6, 8, 5, 9, 1, 2, 5, 3, 2];
console.log('原始array:', array);
const newArr = bucketSort(array);
console.log('newArr:', newArr);
// 原始 array: [4, 6, 8, 5, 9, 1, 2, 5, 3, 2]
// 堆排序耗時: 0.133056640625ms
// newArr: [1, 2, 2, 3, 4, 5, 5, 6, 8, 9]
複製代碼
分析
由於桶排序的空間複雜度,也即內存消耗爲 O(n),因此不是
原地排序算法。
取決於每一個桶的排序方式,好比:快排就不穩定,歸併就穩定。
由於桶內部的排序能夠有多種方法,是會對桶排序的時間複雜度產生很重大的影響。因此,桶排序的時間複雜度能夠是多種狀況的。
總的來講
最佳狀況:當輸入的數據能夠均勻的分配到每個桶中。 最差狀況:當輸入的數據被分配到了同一個桶中。
如下是桶的內部排序
爲快速排序
的狀況:
若是要排序的數據有 n 個,咱們把它們均勻地劃分到 m 個桶內,每一個桶裏就有 k =n / m 個元素。每一個桶內部使用快速排序,時間複雜度爲 O(k * logk)。 m 個桶排序的時間複雜度就是 O(m * k * logk),由於 k = n / m,因此整個桶排序的時間複雜度就是 O(n*log(n/m))。 當桶的個數 m 接近數據個數 n 時,log(n/m) 就是一個很是小的常量,這個時候桶排序的時間複雜度接近 O(n)。
最佳狀況:T(n) = O(n)。當輸入的數據能夠均勻的分配到每個桶中。
最差狀況:T(n) = O(nlogn)。當輸入的數據被分配到了同一個桶中。
平均狀況:T(n) = O(n)。
桶排序最好狀況下使用線性時間 O(n),桶排序的時間複雜度,取決與對各個桶之間數據進行排序的時間複雜度,由於其它部分的時間複雜度都爲 O(n)。 很顯然,桶劃分的越小,各個桶之間的數據越少,排序所用的時間也會越少。但相應的空間消耗就會增大。
適用場景
動畫
思想
關鍵在於理解最後反向填充時的操做。
使用條件
實現
方法一:
const countingSort = array => {
let len = array.length,
result = [],
countArr = [],
min = (max = array[0]);
console.time('計數排序耗時');
for (let i = 0; i < len; i++) {
// 獲取最小,最大 值
min = min <= array[i] ? min : array[i];
max = max >= array[i] ? max : array[i];
countArr[array[i]] = countArr[array[i]] ? countArr[array[i]] + 1 : 1;
}
console.log('countArr :', countArr);
// 從最小值 -> 最大值,將計數逐項相加
for (let j = min; j < max; j++) {
countArr[j + 1] = (countArr[j + 1] || 0) + (countArr[j] || 0);
}
console.log('countArr 2:', countArr);
// countArr 中,下標爲 array 數值,數據爲 array 數值出現次數;反向填充數據進入 result 數據
for (let k = len - 1; k >= 0; k--) {
// result[位置] = array 數據
result[countArr[array[k]] - 1] = array[k];
// 減小 countArr 數組中保存的計數
countArr[array[k]]--;
// console.log("array[k]:", array[k], 'countArr[array[k]] :', countArr[array[k]],)
console.log('result:', result);
}
console.timeEnd('計數排序耗時');
return result;
};
複製代碼
測試
const array = [2, 2, 3, 8, 7, 1, 2, 2, 2, 7, 3, 9, 8, 2, 1, 4, 2, 4, 6, 9, 2];
console.log('原始 array: ', array);
const newArr = countingSort(array);
console.log('newArr: ', newArr);
// 原始 array: [2, 2, 3, 8, 7, 1, 2, 2, 2, 7, 3, 9, 8, 2, 1, 4, 2, 4, 6, 9, 2]
// 計數排序耗時: 5.6708984375ms
// newArr: [1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 4, 4, 6, 7, 7, 8, 8, 9, 9]
複製代碼
方法二:
const countingSort2 = (arr, maxValue) => {
console.time('計數排序耗時');
maxValue = maxValue || arr.length;
let bucket = new Array(maxValue + 1),
sortedIndex = 0;
(arrLen = arr.length), (bucketLen = maxValue + 1);
for (let i = 0; i < arrLen; i++) {
if (!bucket[arr[i]]) {
bucket[arr[i]] = 0;
}
bucket[arr[i]]++;
}
for (let j = 0; j < bucketLen; j++) {
while (bucket[j] > 0) {
arr[sortedIndex++] = j;
bucket[j]--;
}
}
console.timeEnd('計數排序耗時');
return arr;
};
複製代碼
測試
const array2 = [2, 2, 3, 8, 7, 1, 2, 2, 2, 7, 3, 9, 8, 2, 1, 4, 2, 4, 6, 9, 2];
console.log('原始 array2: ', array2);
const newArr2 = countingSort2(array2, 21);
console.log('newArr2: ', newArr2);
// 原始 array: [2, 2, 3, 8, 7, 1, 2, 2, 2, 7, 3, 9, 8, 2, 1, 4, 2, 4, 6, 9, 2]
// 計數排序耗時: 0.043212890625ms
// newArr: [1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 4, 4, 6, 7, 7, 8, 8, 9, 9]
複製代碼
例子
能夠認爲,計數排序實際上是桶排序的一種特殊狀況。
當要排序的 n 個數據,所處的範圍並不大的時候,好比最大值是 k,咱們就能夠把數據劃分紅 k 個桶。每一個桶內的數據值都是相同的,省掉了桶內排序的時間。
咱們都經歷太高考,高考查分數系統你還記得嗎?咱們查分數的時候,系統會顯示咱們的成績以及所在省的排名。若是你所在的省有 50 萬考生,如何經過成績快速排序得出名次呢?
分析
由於計數排序的空間複雜度爲 O(k),k 桶的個數,因此不是原地排序算法。
計數排序不改變相同元素之間本來相對的順序,所以它是穩定的排序算法。
最佳狀況:T(n) = O(n + k) 最差狀況:T(n) = O(n + k) 平均狀況:T(n) = O(n + k) k 是待排序列最大值。
動畫
思想
基數排序是一種非比較型整數排序算法,其原理是將整數按位數切割成不一樣的數字,而後按每一個位數分別比較。
例子
假設咱們有 10 萬個手機號碼,但願將這 10 萬個手機號碼從小到大排序,你有什麼比較快速的排序方法呢 ?
這個問題裏有這樣的規律:假設要比較兩個手機號碼 a,b 的大小,若是在前面幾位中,a 手機號碼已經比 b 手機號碼大了,那後面的幾位就不用看了。因此是基於位
來比較的。
桶排序、計數排序能派上用場嗎 ?手機號碼有 11 位,範圍太大,顯然不適合用這兩種排序算法。針對這個排序問題,有沒有時間複雜度是 O(n) 的算法呢 ? 有,就是基數排序。
使用條件
位
來比較;方案
按照優先從高位或低位來排序有兩種實現方案:
實現
/** * name: 基數排序 * @param array 待排序數組 * @param max 最大位數 */
const radixSort = (array, max) => {
console.time('計數排序耗時');
const buckets = [];
let unit = 10,
base = 1;
for (let i = 0; i < max; i++, base *= 10, unit *= 10) {
for (let j = 0; j < array.length; j++) {
let index = ~~((array[j] % unit) / base); //依次過濾出個位,十位等等數字
if (buckets[index] == null) {
buckets[index] = []; //初始化桶
}
buckets[index].push(array[j]); //往不一樣桶裏添加數據
}
let pos = 0,
value;
for (let j = 0, length = buckets.length; j < length; j++) {
if (buckets[j] != null) {
while ((value = buckets[j].shift()) != null) {
array[pos++] = value; //將不一樣桶裏數據挨個撈出來,爲下一輪高位排序作準備,因爲靠近桶底的元素排名靠前,所以從桶底先撈
}
}
}
}
console.timeEnd('計數排序耗時');
return array;
};
複製代碼
測試
const array = [3, 44, 38, 5, 47, 15, 36, 26, 27, 2, 46, 4, 19, 50, 48];
console.log('原始array:', array);
const newArr = radixSort(array, 2);
console.log('newArr:', newArr);
// 原始 array: [3, 44, 38, 5, 47, 15, 36, 26, 27, 2, 46, 4, 19, 50, 48]
// 堆排序耗時: 0.064208984375ms
// newArr: [2, 3, 4, 5, 15, 19, 26, 27, 36, 38, 44, 46, 47, 48, 50]
複製代碼
分析
由於計數排序的空間複雜度爲 O(n + k),因此不是原地排序算法。
基數排序不改變相同元素之間的相對順序,所以它是穩定的排序算法。
最佳狀況:T(n) = O(n * k) 最差狀況:T(n) = O(n * k) 平均狀況:T(n) = O(n * k) 其中,k 是待排序列最大值。
動畫
LSD 基數排序動圖演示:
十大經典排序算法的 時間複雜度與空間複雜度 比較。
名稱 | 平均 | 最好 | 最壞 | 空間 | 穩定性 | 排序方式 |
---|---|---|---|---|---|---|
冒泡排序 | O(n2) | O(n) | O(n2) | O(1) | Yes | In-place |
插入排序 | O(n2) | O(n) | O(n2) | O(1) | Yes | In-place |
選擇排序 | O(n2) | O(n2) | O(n2) | O(1) | No | In-place |
歸併排序 | O(n log n) | O(n log n) | O(n log n) | O(n) | Yes | Out-place |
快速排序 | O(n log n) | O(n log n) | O(n2) | O(logn) | No | In-place |
希爾排序 | O(n log n) | O(n log2 n) | O(n log2 n) | O(1) | No | In-place |
堆排序 | O(n log n) | O(n log n) | O(n log n) | O(1) | No | In-place |
桶排序 | O(n + k) | O(n + k) | O(n2) | O(n + k) | Yes | Out-place |
計數排序 | O(n + k) | O(n + k) | O(n + k) | O(k) | Yes | Out-place |
基數排序 | O(n * k) | O(n * k) | O(n * k) | O(n + k) | Yes | Out-place |
名詞解釋:
算法可視化工具 algorithm-visualizer 算法可視化工具 algorithm-visualizer 是一個交互式的在線平臺,能夠從代碼中可視化算法,還能夠看到代碼執行的過程。旨在經過交互式可視化的執行來揭示算法背後的機制。 效果以下圖:
算法可視化動畫網站 visualgo.net/en 效果以下圖:
算法可視化動畫網站 www.ee.ryerson.ca 效果以下圖:
illustrated-algorithms 變量和操做的可視化表示加強了控制流和實際源代碼。您能夠快速前進和後退執行,以密切觀察算法的工做方式。 效果以下圖:
JavaScript 數據結構與算法之美 系列文章,暫時寫了以下的 11 篇文章,後續還有想寫的內容,再補充。
所寫的內容只是數據結構與算法內容的冰山一角,若是你還想學更多的內容,推薦學習王爭老師的 數據結構與算法之美。
從時間和空間複雜度、基礎數據結構到排序算法,文章的內容有必定的關聯性,因此閱讀時推薦按順序來閱讀,效果更佳。
若是有錯誤或者不嚴謹的地方,請務必給予指正,以避免誤人子弟,十分感謝。
文中全部的代碼及測試事例都已經放到個人 GitHub 上了。
筆者爲了寫好這系列的文章,花費了大量的業餘時間,邊學邊寫,邊寫邊修改,先後歷時差很少 2 個月,入門級的文章總算是寫完了。
若是你以爲有用或者喜歡,就點收藏,順便點個贊吧,你的支持是我最大的鼓勵 !