JavaScript 資料結構與演算法之美 - 十大經典排序演算法

天明夜盡發表於2019-07-30

1. 前言

演算法為王。

想學好前端,先練好內功,內功不行,就算招式練的再花哨,終究成不了高手;只有內功深厚者,前端之路才會走得更遠。

筆者寫的 JavaScript 資料結構與演算法之美 系列用的語言是 JavaScript ,旨在入門資料結構與演算法和方便以後複習。

文中包含了 十大經典排序演算法 的思想、程式碼實現、一些例子、複雜度分析、動畫、還有演算法視覺化工具。

這應該是目前最全的 JavaScript 十大經典排序演算法 的講解了吧。

2. 如何分析一個排序演算法

複雜度分析是整個演算法學習的精髓。

  • 時間複雜度: 一個演算法執行所耗費的時間。
  • 空間複雜度: 執行完一個程式所需記憶體的大小。

時間和空間複雜度的詳解,請看 JavaScript 資料結構與演算法之美 - 時間和空間複雜度

學習排序演算法,我們除了學習它的演算法原理、程式碼實現之外,更重要的是要學會如何評價、分析一個排序演算法。

分析一個排序演算法,要從 執行效率記憶體消耗穩定性 三方面入手。

2.1 執行效率

1. 最好情況、最壞情況、平均情況時間複雜度

我們在分析排序演算法的時間複雜度時,要分別給出最好情況、最壞情況、平均情況下的時間複雜度。 除此之外,你還要說出最好、最壞時間複雜度對應的要排序的原始資料是什麼樣的。

2. 時間複雜度的係數、常數 、低階

我們知道,時間複雜度反應的是資料規模 n 很大的時候的一個增長趨勢,所以它表示的時候會忽略係數、常數、低階。

但是實際的軟體開發中,我們排序的可能是 10 個、100 個、1000 個這樣規模很小的資料,所以,在對同一階時間複雜度的排序演算法效能對比的時候,我們就要把係數、常數、低階也考慮進來。

3. 比較次數和交換(或移動)次數

這一節和下一節講的都是基於比較的排序演算法。基於比較的排序演算法的執行過程,會涉及兩種操作,一種是元素比較大小,另一種是元素交換或移動。

所以,如果我們在分析排序演算法的執行效率的時候,應該把比較次數和交換(或移動)次數也考慮進去。

2.2 記憶體消耗

也就是看空間複雜度。

還需要知道如下術語:

  • 內排序:所有排序操作都在記憶體中完成;
  • 外排序:由於資料太大,因此把資料放在磁碟中,而排序通過磁碟和記憶體的資料傳輸才能進行;
  • 原地排序:原地排序演算法,就是特指空間複雜度是 O(1) 的排序演算法。

2.3 穩定性

  • 穩定:如果待排序的序列中存在值相等的元素,經過排序之後,相等元素之間原有的先後順序不變。 比如: a 原本在 b 前面,而 a = b,排序之後,a 仍然在 b 的前面;
  • 不穩定:如果待排序的序列中存在值相等的元素,經過排序之後,相等元素之間原有的先後順序改變。 比如:a 原本在 b 的前面,而 a = b,排序之後, a 在 b 的後面;

3. 十大經典排序演算法

3.1 氣泡排序(Bubble Sort)

冒泡

思想

  • 氣泡排序只會操作相鄰的兩個資料。
  • 每次冒泡操作都會對相鄰的兩個元素進行比較,看是否滿足大小關係要求。如果不滿足就讓它倆互換。
  • 一次冒泡會讓至少一個元素移動到它應該在的位置,重複 n 次,就完成了 n 個資料的排序工作。

特點

  • 優點:排序演算法的基礎,簡單實用易於理解。
  • 缺點:比較次數多,效率較低。

實現

// 氣泡排序(未優化)
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)。

動畫

氣泡排序動畫

氣泡排序動畫

3.2 插入排序(Insertion Sort)

插入排序又為分為 直接插入排序 和優化後的 拆半插入排序希爾排序,我們通常說的插入排序是指直接插入排序。

一、直接插入

思想

一般人打撲克牌,整理牌的時候,都是按牌的大小(從小到大或者從大到小)整理牌的,那每摸一張新牌,就掃描自己的牌,把新牌插入到相應的位置。

插入排序的工作原理:通過構建有序序列,對於未排序資料,在已排序序列中從後向前掃描,找到相應位置並插入。

步驟

  • 從第一個元素開始,該元素可以認為已經被排序;
  • 取出下一個元素,在已經排序的元素序列中從後向前掃描;
  • 如果該元素(已排序)大於新元素,將該元素移到下一位置;
  • 重複步驟 3,直到找到已排序的元素小於或者等於新元素的位置;
  • 將新元素插入到該位置後;
  • 重複步驟 2 ~ 5。

實現

// 插入排序
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)。

動畫

insertion-sort.gif

二、拆半插入

插入排序也有一種優化演算法,叫做拆半插入

思想

折半插入排序是直接插入排序的升級版,鑑於插入排序第一部分為已排好序的陣列,我們不必按順序依次尋找插入點,只需比較它們的中間值與待插入元素的大小即可。

步驟

  • 取 0 ~ i-1 的中間點 ( m = (i-1) >> 1 ),array[i] 與 array[m] 進行比較,若 array[i] < array[m],則說明待插入的元素 array[i] 應該處於陣列的 0 ~ m 索引之間;反之,則說明它應該處於陣列的 m ~ i-1 索引之間。
  • 重複步驟 1,每次縮小一半的查詢範圍,直至找到插入的位置。
  • 將陣列中插入位置之後的元素全部後移一位。
  • 在指定位置插入第 i 個元素。

注: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]
複製程式碼

注意:和直接插入排序類似,折半插入排序每次交換的是相鄰的且值為不同的元素,它並不會改變值相同的元素之間的順序,因此它是穩定的。

三、希爾排序

希爾排序是一個平均時間複雜度為 O(n log n) 的演算法,會在下一個章節和 歸併排序、快速排序、堆排序 一起講,本文就不展開了。

3.3 選擇排序(Selection Sort)

思路

選擇排序演算法的實現思路有點類似插入排序,也分已排序區間和未排序區間。但是選擇排序每次會從未排序區間中找到最小的元素,將其放到已排序區間的末尾。

步驟

  1. 首先在未排序序列中找到最小(大)元素,存放到排序序列的起始位置。
  2. 再從剩餘未排序元素中繼續尋找最小(大)元素,然後放到已排序序列的末尾。
  3. 重複第二步,直到所有元素均排序完畢。

實現

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)。

動畫

selection-sort.gif

3.4 歸併排序(Merge Sort)

思想

排序一個陣列,我們先把陣列從中間分成前後兩部分,然後對前後兩部分分別排序,再將排好序的兩部分合並在一起,這樣整個陣列就都有序了。

歸併排序採用的是分治思想

分治,顧名思義,就是分而治之,將一個大問題分解成小的子問題來解決。小的子問題解決了,大問題也就解決了。

merge-sort-example.png

注: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)。

動畫

merge-sort.gif

3.5 快速排序 (Quick Sort)

快速排序的特點就是快,而且效率高!它是處理大資料最快的排序演算法之一。

思想

  • 先找到一個基準點(一般指陣列的中部),然後陣列被該基準點分為兩部分,依次與該基準點資料比較,如果比它小,放左邊;反之,放右邊。
  • 左右分別用一個空陣列去儲存比較後的資料。
  • 最後遞迴執行上述操作,直到陣列長度 <= 1;

特點:快速,常用。

缺點:需要另外宣告兩個陣列,浪費了記憶體空間資源。

實現

方法一:

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)。

動畫

quick-sort.gif

解答開篇問題

快排和歸併用的都是分治思想,遞推公式和遞迴程式碼也非常相似,那它們的區別在哪裡呢 ?

快速排序與歸併排序

可以發現:

  • 歸併排序的處理過程是由下而上的,先處理子問題,然後再合併。
  • 而快排正好相反,它的處理過程是由上而下的,先分割槽,然後再處理子問題。
  • 歸併排序雖然是穩定的、時間複雜度為 O(nlogn) 的排序演算法,但是它是非原地排序演算法。
  • 歸併之所以是非原地排序演算法,主要原因是合併函式無法在原地執行。
  • 快速排序通過設計巧妙的原地分割槽函式,可以實現原地排序,解決了歸併排序佔用太多記憶體的問題。

3.6 希爾排序(Shell Sort)

思想

  • 先將整個待排序的記錄序列分割成為若干子序列。
  • 分別進行直接插入排序。
  • 待整個序列中的記錄基本有序時,再對全體記錄進行依次直接插入排序。

過程

  1. 舉個易於理解的例子:[35, 33, 42, 10, 14, 19, 27, 44],我們採取間隔 4。建立一個位於 4 個位置間隔的所有值的虛擬子列表。下面這些值是 { 35, 14 },{ 33, 19 },{ 42, 27 } 和 { 10, 44 }。

栗子

  1. 我們比較每個子列表中的值,並在原始陣列中交換它們(如果需要)。完成此步驟後,新陣列應如下所示。

栗子

  1. 然後,我們採用 2 的間隔,這個間隙產生兩個子列表:{ 14, 27, 35, 42 }, { 19, 10, 33, 44 }。

栗子

  1. 我們比較並交換原始陣列中的值(如果需要)。完成此步驟後,陣列變成:[14, 10, 27, 19, 35, 33, 42, 44],圖如下所示,10 與 19 的位置互換一下。

image.png

  1. 最後,我們使用值間隔 1 對陣列的其餘部分進行排序,Shell sort 使用插入排序對陣列進行排序。

栗子

實現

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)。

動畫

shell-sort.gif

3.7 堆排序(Heap Sort)

堆的定義

堆其實是一種特殊的樹。只要滿足這兩點,它就是一個堆。

  • 堆是一個完全二叉樹。 完全二叉樹:除了最後一層,其他層的節點個數都是滿的,最後一層的節點都靠左排列。
  • 堆中每一個節點的值都必須大於等於(或小於等於)其子樹中每個節點的值。 也可以說:堆中每個節點的值都大於等於(或者小於等於)其左右子節點的值。這兩種表述是等價的。

對於每個節點的值都大於等於子樹中每個節點值的堆,我們叫作大頂堆。 對於每個節點的值都小於等於子樹中每個節點值的堆,我們叫作小頂堆

區分堆、大頂堆、小頂堆

其中圖 1 和 圖 2 是大頂堆,圖 3 是小頂堆,圖 4 不是堆。除此之外,從圖中還可以看出來,對於同一組資料,我們可以構建多種不同形態的堆。

思想

  1. 將初始待排序關鍵字序列 (R1, R2 .... Rn) 構建成大頂堆,此堆為初始的無序區;
  2. 將堆頂元素 R[1] 與最後一個元素 R[n] 交換,此時得到新的無序區 (R1, R2, ..... Rn-1) 和新的有序區 (Rn) ,且滿足 R[1, 2 ... n-1] <= R[n]。
  3. 由於交換後新的堆頂 R[1] 可能違反堆的性質,因此需要對當前無序區 (R1, R2 ...... Rn-1) 調整為新堆,然後再次將 R[1] 與無序區最後一個元素交換,得到新的無序區 (R1, R2 .... Rn-2) 和新的有序區 (Rn-1, Rn)。不斷重複此過程,直到有序區的元素個數為 n - 1,則整個排序過程完成。

實現

// 堆排序
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)。

動畫

heap-sort.gif

heap-sort2.gif

3.8 桶排序(Bucket Sort)

桶排序是計數排序的升級版,也採用了分治思想

思想

  • 將要排序的資料分到有限數量的幾個有序的桶裡。
  • 每個桶裡的資料再單獨進行排序(一般用插入排序或者快速排序)。
  • 桶內排完序之後,再把每個桶裡的資料按照順序依次取出,組成的序列就是有序的了。

比如:

JavaScript 資料結構與演算法之美 - 十大經典排序演算法

桶排序利用了函式的對映關係,高效與否的關鍵就在於這個對映函式的確定。

為了使桶排序更加高效,我們需要做到這兩點:

  • 在額外空間充足的情況下,儘量增大桶的數量。
  • 使用的對映函式能夠將輸入的 N 個資料均勻的分配到 K 個桶中。

桶排序的核心:就在於怎麼把元素平均分配到每個桶裡,合理的分配將大大提高排序的效率。

實現

// 桶排序
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)。 很顯然,桶劃分的越小,各個桶之間的資料越少,排序所用的時間也會越少。但相應的空間消耗就會增大。

適用場景

  • 桶排序比較適合用在外部排序中。
  • 外部排序就是資料儲存在外部磁碟且資料量大,但記憶體有限,無法將整個資料全部載入到記憶體中。

動畫

bocket-sort.gif

3.9 計數排序(Counting Sort)

思想

  • 找出待排序的陣列中最大和最小的元素。
  • 統計陣列中每個值為 i 的元素出現的次數,存入新陣列 countArr 的第 i 項。
  • 對所有的計數累加(從 countArr 中的第一個元素開始,每一項和前一項相加)。
  • 反向填充目標陣列:將每個元素 i 放在新陣列的第 countArr[i] 項,每放一個元素就將 countArr[i] 減去 1 。

關鍵在於理解最後反向填充時的操作。

使用條件

  • 只能用在資料範圍不大的場景中,若資料範圍 k 比要排序的資料 n 大很多,就不適合用計數排序。
  • 計數排序只能給非負整數排序,其他型別需要在不改變相對大小情況下,轉換為非負整數。
  • 比如如果考試成績精確到小數後一位,就需要將所有分數乘以 10,轉換為整數。

實現

方法一:

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 萬考生,如何通過成績快速排序得出名次呢?

  • 考生的滿分是 900 分,最小是 0 分,這個資料的範圍很小,所以我們可以分成 901 個桶,對應分數從 0 分到 900 分。
  • 根據考生的成績,我們將這 50 萬考生劃分到這 901 個桶裡。桶內的資料都是分數相同的考生,所以並不需要再進行排序。
  • 我們只需要依次掃描每個桶,將桶內的考生依次輸出到一個陣列中,就實現了 50 萬考生的排序。
  • 因為只涉及掃描遍歷操作,所以時間複雜度是 O(n)。

分析

  • 第一,計數排序是原地排序演算法嗎 ?

因為計數排序的空間複雜度為 O(k),k 桶的個數,所以不是原地排序演算法。

  • 第二,計數排序是穩定的排序演算法嗎 ?

計數排序不改變相同元素之間原本相對的順序,因此它是穩定的排序演算法。

  • 第三,計數排序的時間複雜度是多少 ?

最佳情況:T(n) = O(n + k) 最差情況:T(n) = O(n + k) 平均情況:T(n) = O(n + k) k 是待排序列最大值。

動畫

counting-sort.gif

3.10 基數排序(Radix Sort)

思想

基數排序是一種非比較型整數排序演算法,其原理是將整數按位數切割成不同的數字,然後按每個位數分別比較。

例子

假設我們有 10 萬個手機號碼,希望將這 10 萬個手機號碼從小到大排序,你有什麼比較快速的排序方法呢 ?

這個問題裡有這樣的規律:假設要比較兩個手機號碼 a,b 的大小,如果在前面幾位中,a 手機號碼已經比 b 手機號碼大了,那後面的幾位就不用看了。所以是基於來比較的。

桶排序、計數排序能派上用場嗎 ?手機號碼有 11 位,範圍太大,顯然不適合用這兩種排序演算法。針對這個排序問題,有沒有時間複雜度是 O(n) 的演算法呢 ? 有,就是基數排序。

使用條件

  • 要求資料可以分割獨立的來比較;
  • 位之間由遞進關係,如果 a 資料的高位比 b 資料大,那麼剩下的地位就不用比較了;
  • 每一位的資料範圍不能太大,要可以用線性排序,否則基數排序的時間複雜度無法做到 O(n)。

方案

按照優先從高位或低位來排序有兩種實現方案:

  • MSD:由高位為基底,先按 k1 排序分組,同一組中記錄, 關鍵碼 k1 相等,再對各組按 k2 排序分成子組, 之後,對後面的關鍵碼繼續這樣的排序分組,直到按最次位關鍵碼 kd 對各子組排序後,再將各組連線起來,便得到一個有序序列。MSD 方式適用於位數多的序列。
  • LSD:由低位為基底,先從 kd 開始排序,再對 kd - 1 進行排序,依次重複,直到對 k1 排序後便得到一個有序序列。LSD 方式適用於位數少的序列。

實現

/**
	* 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 基數排序動圖演示:

radixSort.gif

4. 複雜度對比

十大經典排序演算法的 時間複雜度與空間複雜度 比較。

名稱 平均 最好 最壞 空間 穩定性 排序方式
氣泡排序 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

名詞解釋:

  • n:資料規模;
  • k:桶的個數;
  • In-place: 佔用常數記憶體,不佔用額外記憶體;
  • Out-place: 佔用額外記憶體。

5. 演算法視覺化工具

  • 演算法視覺化工具 algorithm-visualizer 演算法視覺化工具 algorithm-visualizer 是一個互動式的線上平臺,可以從程式碼中視覺化演算法,還可以看到程式碼執行的過程。旨在通過互動式視覺化的執行來揭示演算法背後的機制。 效果如下圖:

    演算法視覺化工具

  • 演算法視覺化動畫網站 visualgo.net/en 效果如下圖:

    quick-sort.gif

  • 演算法視覺化動畫網站 www.ee.ryerson.ca 效果如下圖:

    insert-sort.gif

  • illustrated-algorithms 變數和操作的視覺化表示增強了控制流和實際原始碼。您可以快速前進和後退執行,以密切觀察演算法的工作方式。 效果如下圖:

    binary-search.gif

6. 系列文章

JavaScript 資料結構與演算法之美 系列文章,暫時寫了如下的 11 篇文章,後續還有想寫的內容,再補充。

所寫的內容只是資料結構與演算法內容的冰山一角,如果你還想學更多的內容,推薦學習王爭老師的 資料結構與演算法之美

從時間和空間複雜度、基礎資料結構到排序演算法,文章的內容有一定的關聯性,所以閱讀時推薦按順序來閱讀,效果更佳。

如果有錯誤或者不嚴謹的地方,請務必給予指正,以免誤人子弟,十分感謝。

7. 最後

文中所有的程式碼及測試事例都已經放到我的 GitHub 上了。

筆者為了寫好這系列的文章,花費了大量的業餘時間,邊學邊寫,邊寫邊修改,前後歷時差不多 2 個月,入門級的文章總算是寫完了。

如果你覺得有用或者喜歡,就點收藏,順便點個贊吧,你的支援是我最大的鼓勵 !

相關文章