圖解例項講解JavaScript演算法,讓你徹底搞懂
你好程式設計師,我們大多數人都害怕演算法,並且從未開始學習它。但我們不應該害怕它。演算法只是解決問題的步驟。
今天讓我們以簡單和說明性的方式介紹主要演算法。
不要試圖記住它們,演算法更多的是解決問題。所以,坐下來用紙和筆。目錄中的術語可能看起來很嚇人,但只要和我在一起,我保證會以儘可能簡單的方式解釋所有內容。
目 錄
-
大 O 表示法
-
理解大 O 符號
-
演算法
-
什麼是演算法,為什麼要關心?
-
遞迴
-
線性搜尋演算法
-
二進位制搜尋演算法
-
樸素搜尋演算法
-
KMP演算法
-
氣泡排序
-
合併排序
-
快速排序
-
基數排序
理解大 O 符號
Big O Notation 是一種表示演算法時間和空間複雜度的方法。
-
時間複雜度:演算法完成執行所花費的時間。
-
空間複雜度:算法佔用的記憶體。
表示演算法時間複雜度的表示式(符號)很少。
-
O(1):常數時間複雜度。這是理想情況。
-
O(log n):對數時間複雜度。如果`log(n) = x`那麼它與`10^x`
-
O(n):線性時間複雜度。時間隨著輸入的數量呈線性增加。例如,如果一個輸入需要 1 毫秒,則 4 個輸入將花費 4 毫秒來執行演算法。
-
O(n^2):二次時間複雜度。這主要發生在巢狀迴圈的情況下。
-
O(n!):階乘時間複雜度。這是最壞的情況,應該避免。
您應該嘗試編寫您的演算法,使其可以用前 3 個符號表示。最後兩個應儘可能避免。
您希望儘可能地降低複雜性,最好避免超過 O(n) 的複雜性。
在本文的後續部分中,您將看到每種表示法的示例。現在,這就是您需要知道的全部內容。
演算法
什麼是演算法,為什麼要關心?
解決問題的方法,或者我們可以說解決問題的步驟、過程或規則集被稱為演算法。
例如:用於查詢與搜尋字串相關的資料的搜尋引擎演算法。
作為一名程式設計師,您會遇到許多需要使用這些演算法解決的問題。因此,如果您已經瞭解它們會更好。
遞迴
呼叫自身的函式是遞迴的。將其視為迴圈的替代方案。
function recursiveFn() {
console.log("This is a recursive function");
recursiveFn();
}
recursiveFn();
在上面的程式碼片段中,請看第 3 行recursiveFn
在 recursiveFn
本身中被呼叫。正如我之前提到的,遞迴是迴圈的替代方法。
那麼,這個函式到底要執行多少次呢?
好吧,這將建立一個無限迴圈,因為在任何時候都無法阻止它。
假設我們只需要執行迴圈 10 次。在第 11 次迭代函式應該返回。這將停止迴圈。
let count = 1;
function recursiveFn() {
console.log(`Recursive ${count}`);
if (count === 10) return;
count++;
recursiveFn();
}
recursiveFn();
在上面的程式碼片段中,第 4 行返回並在計數為 10 時停止迴圈。
現在讓我們看一個更現實的例子。我們的任務是從給定的陣列中返回奇數陣列。這可以通過多種方式實現,包括 for-loop
、Array.filter
方法等
但是為了展示遞迴的使用,我將使用 helperRecursive
函式。
function oddArray(arr) {
let result = [];
function helperRecursiveFn(arr) {
if(arr.length === 0) {
return; // 1
} else if(arr[0] % 2 !== 0) {
result.push(arr[0]); // 2
}
helperRecursiveFn(arr.slice(1)); // 3
}
helperRecursiveFn(arr);
return result;
}
oddArray([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
// OutPut -> [1, 3, 5, 7, 9]
這裡的遞迴函式是helperRecursiveFn
。
例如:第一次 helperRecursiveFn 將被呼叫
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
。下次它將被呼叫,[2, 3, 4, 5, 6, 7, 8, 9, 10]
依此類推,直到陣列長度為 0。
線性搜尋演算法
線性搜尋演算法非常簡單。假設您需要查詢給定陣列中是否存在某個數字。
您將執行一個簡單的 for 迴圈並檢查每個元素,直到找到您要查詢的元素。
const array = [3, 8, 12, 6, 10, 2];
// Find 10 in the given array.
function checkForN(arr, n) {
for(let i = 0; i < array.length; i++) {
if (n === array[i]) {
return `${true} ${n} exists at index ${i}`;
}
}
return `${false} ${n} does not exist in the given array.`;
}
checkForN(array, 10);
這就是線性搜尋演算法。您以線性方式逐一搜索陣列中的每個元素。
線性搜尋演算法的時間複雜度
只有一個 for 迴圈會執行 n 次。其中 n(在最壞的情況下)是給定陣列的長度。這裡的迭代次數(在最壞的情況下)與輸入(長度陣列)成正比。
因此,線性搜尋演算法的時間複雜度是線性時間複雜度:O(n)。
二進位制搜尋演算法
線上性搜尋中,您一次可以消除一個元素。但是使用二進位制搜尋演算法,您可以一次消除多個元素。這就是二分查詢比線性查詢快的原因。
這裡要注意的一點是,二分查詢只對排序好的陣列有效。
該演算法遵循分而治之的方法。讓我們在 [2, 3, 6, 8, 10, 12
] 中找到 8 的索引。
第 1 步:找到陣列的中間索引。
const array = [2, 3, 6, 8, 10, 12];
let firstIndex = 0;
let lastIndex = array.length - 1;
let middleIndex = Math.floor((firstIndex + lastIndex) / 2); // middleIndex -> 2
第 2 步:檢查middleIndex
元素是否 > 8
。如果是,則說明 8 在middleIndex
的左側。因此,將lastIndex
更改為 (middleIndex - 1
)。
第 3 步:否則如果 middleIndex
元素 < 8
。這意味著 8 在middleIndex
的右邊。因此,將firstIndex
更改為 (middleIndex
+ 1);
if (array[middleIndex] > 8) {
lastIndex = middleIndex - 1;
} else {
firstIndex = middleIndex + 1;
}
第 4 步:每次迭代都會根據新的firstIndex
或lastIndex
再次設定middleIndex
。
讓我們以程式碼格式一起檢視所有這些步驟。
function binarySearch(array, element) {
let firstIndex = 0;
let lastIndex = array.length - 1;
let middleIndex = Math.floor((firstIndex + lastIndex) / 2);
while (array[middleIndex] !== element && firstIndex <= lastIndex) {
if(array[middleIndex] > element) {
lastIndex = middleIndex - 1;
}else {
firstIndex = middleIndex + 1;
}
middleIndex = Math.floor((firstIndex + lastIndex) / 2);
}
return array[middleIndex] === element ? middleIndex : -1;
}
const array = [2, 3, 6, 8, 10, 12];
binarySearch(array, 8); // OutPut -> 3
這是上述程式碼的視覺化表示。
步驟1
firstIndex = middleIndex + 1;
第2步
lastIndex = middleIndex - 1;
步驟:3
array[middleIndex] === 8 // Found It
二分查詢的時間複雜度
只有一個 while 迴圈會執行 n 次。但是這裡的迭代次數不依賴於輸入(陣列長度)。
因此,二進位制搜尋演算法的時間複雜度是對數時間複雜度:O(log n)
。你可以檢查 O 符號圖。O(log n) 比 O(n) 快。
樸素搜尋演算法
樸素搜尋演算法用於查詢字串是否包含給定的子字串。例如,檢查“helloworld”是否包含子字串“owo”。
-
首先迴圈主字串(“
helloworld
”)。 -
在子字串 ("
owo
") 上執行巢狀迴圈。 -
如果字元不匹配,則中斷內部迴圈,否則繼續迴圈。
-
如果內迴圈完成並匹配,則返回
true
否則繼續外迴圈。
這是一個視覺表示。
這是程式碼中的實現。
function naiveSearch(mainStr, subStr) {
if (subStr.length > mainStr.length) return false;
for(let i = 0; i < mainStr.length; i++) {
for(let j = 0; j < subStr.length; j++) {
if(mainStr[i + j] !== subStr[j]) break;
if(j === subStr.length - 1) return true;
}
}
return false;
}
現在,讓我們試著理解上面的程式碼。
-
在第 2 行,如果
subString
長度大於mainString
長度,則返回false
。 -
在第 4 行,開始在
mainString
上迴圈。 -
在第 5 行,在
subString
上開始巢狀迴圈。 -
在第 6 行,如果沒有找到匹配項,則中斷內迴圈,並繼續進行外迴圈的下一次迭代。
-
在第 7 行,在內迴圈的最後一次迭代中返回
true
。
樸素搜尋的時間複雜度
迴圈中有迴圈(巢狀迴圈)。兩個迴圈都執行 n 次。因此,樸素搜尋演算法的時間複雜度是 (n * n) Quadratic Time Complexity: O(n^2)。
如上文所述,如果可能,應避免超過 O(n) 的任何時間複雜度。在下一個演算法中,我們將看到一種時間複雜度更低的更好方法。
KMP演算法
KMP演算法是一種模式識別演算法,理解起來有點費勁。好的,讓我們嘗試查詢字串“abcabcabspl”是否包含子字串“abcabs”。
如果我們嘗試使用Naive Search Algo來解決這個問題,它將匹配前 5 個字元但不匹配第 6 個字元。我們將不得不從下一次迭代重新開始,我們將失去上一次迭代的所有進展。
所以,為了儲存我們的進度並使用它,我們必須使用一個叫做 LPS 表的東西。現在在我們匹配的字串“abcab”中,我們將找到最長的相同字首和字尾。
在這裡,在我們的字串“abcab”中,“ab”是最長的相同字首和字尾。
現在,我們將從索引 5(對於主字串)開始下一次搜尋迭代。我們從之前的迭代中儲存了兩個字元。
為了找出字首、字尾以及從哪裡開始下一次迭代,我們使用 LPS 表。
我們的子串(“abcabs”)的 LPS 是“0 0 0 1 2 0”。
下面是如何計算 LPS 表。
function calculateLpsTable(subStr) {
let i = 1;
let j = 0;
let lps = new Array(subStr.length).fill(0);
while(i < subStr.length) {
if(subStr[i] === subStr[j]) {
lps[i] = j + 1;
i += 1;
j += 1;
} else {
if(j !== 0) {
j = lps[j - 1];
} else {
i += 1;
}
}
}
return lps;
}
下面是使用 LPS 表的程式碼實現。
function searchSubString(string, subString) {
let strLength = string.length;
let subStrLength = subString.length;
const lps = calculateLpsTable(subString);
let i = 0;
let j = 0;
while(i < strLength) {
if (string[i] === subString[j]) {
i += 1;
j += 1;
} else {
if (j !== 0) {
j = lps[j - 1];
} else {
i += 1;
}
}
if (j === subStrLength) return true;
}
return false;
}
KMP演算法的時間複雜度
只有一個迴圈執行 n 次。因此,KMP
演算法的時間複雜度是線性時間複雜度:O(n)。
請注意,與 Naive 搜尋演算法相比,時間複雜度是如何提高的。
氣泡排序演算法
排序意味著按升序或降序重新排列資料。氣泡排序是眾多排序演算法中的一種。
在氣泡排序演算法中,我們通過將每個數字與前一個數字進行比較,將較大的數字交換到末尾。這是一個視覺表示。
氣泡排序程式碼實現。
function bubbleSort(array) {
let isSwapped;
for(let i = array.length; i > 0; i--) {
isSwapped = false;
for(let j = 0; j < i - 1; j++) {
if(array[j] > array[j + 1]) {
[array[j], array[j+1]] = [array[j+1], array[j]];
isSwapped = true;
}
}
if(!isSwapped) {
break;
}
}
return array;
}
讓我們試著理解上面的程式碼。
-
從帶有變數 i 的陣列末尾開始迴圈。
-
以變數 j 開始內迴圈,直到 (i - 1)。
-
如果 array[j] > array[j + 1] 交換它們。
-
返回排序陣列。
氣泡排序演算法的時間複雜度
有一個巢狀迴圈,兩個迴圈都執行 n 次,因此該演算法的時間複雜度為 (n * n) 即二次時間複雜度 O(n^2)。
合併排序演算法
合併排序演算法遵循分而治之的方法。它是兩件事的結合——合併和排序。
在這個演算法中,我們首先將主陣列分成多個單獨的排序陣列。
然後我們將單獨排序的元素合併到最終陣列中。
讓我們看看程式碼中的實現。
合併排序陣列
function mergeSortedArray(array1, array2) {
let result = [];
let i = 0;
let j = 0;
while(i < array1.length && j < array2.length) {
if(array1[i] < array2[j]) {
result.push(array1[i]);
i++;
} else {
result.push(array2[j]);
j++;
}
}
while (i < array1.length) {
result.push(array1[i]);
i++;
}
while (j < array2.length) {
result.push(array2[j]);
j++;
}
return result;
}
上面的程式碼將兩個排序數組合併為一個新的排序陣列。
合併排序演算法
function mergeSortedAlgo(array) {
if(array.length <= 1) return array;
let midPoint = Math.floor(array.length / 2);
let leftArray = mergeSortedAlgo(array.slice(0, midPoint));
let rightArray = mergeSortedAlgo(array.slice(midPoint));
return mergeSortedArray(leftArray, rightArray);
}
上述演算法使用遞迴將陣列劃分為多個單元素陣列。
歸併排序演算法的時間複雜度
讓我們嘗試計算歸併排序演算法的時間複雜度。因此,以我們之前的示例([6, 3, 5, 2])為例,將其劃分為多個單元素陣列需要 2 個步驟。
It took 2 steps to divide an array of length 4 - (2^2)
現在,如果我們將陣列 (8) 的長度加倍,則需要 3 個步驟來劃分 - (2^3)。意味著將陣列長度加倍並沒有使步驟加倍。
因此合併排序演算法的時間複雜度是對數時間複雜度 O(log n)。
快速排序演算法
快速排序是最快的排序演算法之一。在快速排序中,我們選擇一個稱為 pivot 的元素,我們會將所有元素(小於 pivot)移動到 pivot 的左側。
視覺表示。
我們將對樞軸左側和右側的陣列重複此過程,直到對陣列進行排序。
程式碼實現:樞軸效用
function pivotUtility(array, start=0, end=array.length - 1) {
let pivotIndex = start;
let pivot = array[start];
for(let i = start + 1; i < array.length; i++) {
if(pivot > array[i]) {
pivotIndex++;
[array[pivotIndex], array[i]] = [array[i], array[pivotIndex]];
}
}
[array[pivotIndex], array[start]] = [array[start], array[pivotIndex]];
return pivotIndex;
}
上面的程式碼標識了 pivot 的正確位置並返回該位置索引。
function quickSort(array, left=0, right=array.length-1) {
if (left < right) {
let pivotIndex = pivotUtility(array, left, right);
quickSort(array, left, pivotIndex - 1);
quickSort(array, pivotIndex + 1, right);
}
return array;
}
上面的程式碼使用遞迴將樞軸移動到左右樞軸陣列的正確位置。
快速排序演算法的時間複雜度
最佳情況:對數時間複雜度 - O(n log n)
平均情況:對數時間複雜度 - O(n log n)
最壞情況:O(n^2)
基數排序演算法
基數排序也稱為桶排序演算法。
這裡首先我們構建 10 個索引桶,從 0 到 9。然後我們取每個數字中的最後一個字元,並將該數字推送到相應的桶中。檢索新順序並重復每個數字的倒數第二個字元。
不斷重複上述過程,直到陣列排序完畢。
在程式碼中實現。
// Count Digits: 下面的程式碼計算給定元素的位數。
function countDigits(number) {
if(number === 0) return 1;
return Math.floor(Math.log10(Math.abs(number))) + 1;
}
// 獲取數字:下面的程式碼從右邊給出索引 i 處的數字。
function getDigit(number, index) {
const stringNumber = Math.abs(number).toString();
const currentIndex = stringNumber.length - 1 - index;
return stringNumber[currentIndex] ? parseInt(stringNumber[currentIndex]) : 0;
}
// MaxDigit:下面的程式碼片段找到了最大位數的數字。
function maxDigit(array) {
let maxNumber = 0;
for(let i = 0; i < array.length; i++) {
maxNumber = Math.max(maxNumber, countDigits(array[i]));
}
return maxNumber;
}
// Radix 演算法:利用上述所有程式碼段對陣列進行排序。
function radixSort(array) {
let maxDigitCount = maxDigits(array);
for(let i = 0; i < maxDigitCount; i++) {
let digitBucket = Array.from({length: 10}, () => []);
for(let j = 0; j < array.length; j++) {
let lastDigit = getDigit(array[j], i);
digitBucket[lastDigit].push(array[j]);
}
array = [].concat(...digitBucket);
}
return array;
}
基數排序演算法的時間複雜度
有一個巢狀的for迴圈,我們知道巢狀的for迴圈的時間複雜度是O(n^2)。但是在這種情況下,for 迴圈都不會執行 n 次。
外迴圈執行 k (maxDigitCount) 次,內迴圈執行 m (陣列長度) 次。因此,基數排序的時間複雜度為 O(kxm) - (其中 kxm = n)線性時間複雜度 O(n)
演算法和計算機原理是如今在企業面試和進入網際網路大廠必要的技能,如果你正在學前端,你也可以來諮詢我們,我們的JavaScript系統課程中針對演算法和基礎原理也有詳細的視訊簡介!!