介紹
傳統的 面試過程 通常以最基本的如何編寫 手機螢幕頁面 問題為開始,然後通過全天的 現場工作 來檢驗 編碼能力 和 文化契合 度。 幾乎無一例外,決定性的因素還是 編碼能力。 畢竟,工程師是靠一天結束之時產出可使用的軟體來獲得報酬的。一般來說,我們會使用 白板 來測試這種編碼能力。比獲得正確答案更重要的是清晰明瞭的思考過程。編碼和生活一樣,正確的答案不總是顯而易見的,但是好的論據通常是足夠好的。 有效 的 推理 能力標誌著學習,適應和發展的潛力。最好的工程師總是在成長,最好的公司總是在不斷創新。
演算法挑戰 是有效的鍛鍊能力的方法,因為總有不止一種的方法來解決它們。這為決策和演算決策提供了可能性。當解決演算法問題的時候,我們應該挑戰自我,從多個角度來看 問題的定義 ,然後權衡各種方式的 益處 和 缺陷 。通過足夠的聯絡,我們甚至可以一瞥宇宙的真理; 沒有“完美”的解決方案 。
真正掌握 演算法 就是去理解 資料 和 結構 之間的關係。資料結構和演算法之間的關係,就如同“陰”之於“陽”, 玻璃杯 之於 水 。沒有玻璃杯,水就無法被承載。沒有資料結構,我們就沒有可以用於邏輯的物件。沒有水,玻璃杯會因為缺乏物質而變空。沒有演算法,物件就無法被轉化或者“消費”。
關於資料結構深入分析,可以參考: Data Structures in JavaScript:
引言
應用於程式碼中,一個演算法只是一個把確定的 資料結構 的 輸入 轉化為一個確定的 資料結構 的 輸出 的 function
。演算法 內在 的 邏輯 決定了如何轉換。首先,輸入和輸出應該被 明確 定義為 單元測試。這需要完全的理解手頭的問題,這是不容小覷的,因為徹底分析問題可以無需編寫任何程式碼,就自然地解決問題。
一旦徹底掌握問題的領域,就可以開始對解決方案進行 頭腦風暴 。 需要哪些變數?需要多少迴圈以及哪些型別的迴圈?有沒有巧妙的內建的方法可以提供幫助?需要考慮哪些邊緣情況? 複雜和重複的邏輯只會徒增閱讀和理解的難度。 幫助函式可以被抽象或者抽離嗎? 演算法通常需要是可擴充套件的。 隨著輸入規模的增加,函式將如何執行? 是否應該有某種快取機制? 而效能優化(時間)通常需要犧牲記憶體空間(增加記憶體消耗)。
為了使問題更具體,讓我們來繪製一個 圖表 !
當解決方案中的高階結構開始出現時,我們就可以開始寫 虛擬碼 了。為了給面試官留下真正的印象, 請 優先 考慮程式碼的重構和 複用 。有時,行為類似的函式可以合併成一個可以接受額外引數的更通用的函式。其他時候,去引數化會更好。保持函式的 純淨 以便於測試和維護也是很有先見之明的。換言之,設計演算法時,將 架構 和 設計模式 納入到你的考慮範圍內。
如果有任何不清楚的地方,請 提問 以便說明!
Big O(演算法的複雜度)
為了估算演算法執行時的複雜度,在計算演算法所需的 操作次數 之前,我們通常把 輸入大小 外推至無窮來估算演算法的可擴充套件性。在這種最壞情況的執行時上限情況下,我們可以忽略係數以及附加項,只保留主導函式的因子。因此,只需要幾種型別就可以描述幾乎所有的可擴充套件演算法。
最優最理想的演算法,是在時間和空間維度以 常數 速率變化。這就是說它完全不關心輸入大小的變化。次優的演算法是對時間或空間以 對數 速率變化,再次分別是 線性 , 線性對數 , 二次 和 指數 型。最糟糕的是對時間或空間以 階乘 速率變化。在 Big-O 表示法中:
- 常數: O(1)
- 對數: O(log n)
- 線性: O(n)
- 線性對數: O(n log n)
- 二次: O(n²)
- 指數: O(2^n)
- 階乘: O(n!)
當我們考慮演算法的時間和空間複雜性之間的權衡時,Big-O 漸近分析 是不可或缺的工具。然而,Big O 忽略了在實際實踐中可能有影響的常量因素。此外,優化演算法的時間和空間複雜性可能會增加現實的開發時間或對程式碼可讀性產生負面影響。在設計演算法的結構和邏輯時,對真正可忽略不計的東西的直覺同樣重要。
Arrays(陣列)
最乾淨的演算法通常會利用語言中固有的 標準 物件。可以說電腦科學中最重要的是Arrays
。在JavaScript中,沒有其他物件比陣列擁有更多的實用工具方法。值得記住的陣列方法是: sort
, reverse
, slice
, 以及 splice
。陣列從 第0個索引 開始插入陣列元素。這意味著最後一個陣列元素的位置是 array.length — 1
。陣列是 索引 (推入) 的最佳選擇,但對於 插入, 刪除 (不彈出), 和 搜尋 等動作非常糟糕。在 JavaScript 中, 陣列可以 動態 增長。
對應的 Big O :
- 索引: O(1)
- 插入: O(n)
- 刪除: O(n)
- 暴力搜尋: O(n)
- 優化搜尋: O(log n)
完整的閱讀 MDN 有關 Arrays 的文件也是值得的。
類似陣列的還有 Sets
和 Maps
. 在 set 中,元素一定是 唯一 的。在 map 中,元素由字典式關係的 鍵 和 值 組成。當然,Objects
(and their literals) 也可以儲存鍵值對,但鍵必須是 strings
型別。
Object Object建構函式建立一個物件包裝器 developer.mozilla.org
迭代
與 Arrays
密切相關的是使用迴圈 遍歷 它們。在 JavaScript 中,我們可以用 五種 不同的 控制結構 來迭代。可定製化程度最高的是 for
迴圈,我們幾乎可以用它以任何順序來遍歷陣列 索引 。如果無法確定 迭代次數 ,我們可以使用 while
和 do while
迴圈,直到遇到一個滿足確定條件的情況。對於任何物件,我們可以使用 for in
和 for of
迴圈來分別迭代它的“鍵”和“值”。要同時獲取“鍵”和“值”,我們可以使用它的 entries()
方法。我們可以通過 break
語句隨時 中斷迴圈 break
, 或者使用 continue
語句 跳到 。在大多數情況下,通過 generator
函式來控制迭代是最好的選擇。
原生的遍歷所有陣列項的方法是: indexOf
, lastIndexOf
, includes
, fill
和 join
。 另外,我們可以為以下方法提供 回撥函式
: findIndex
, find
, filter
, forEach
, map
, some
, every
和 reduce
。
遞迴
在一篇開創性的論文 Church-Turing Thesis 中,證明了任何迭代函式都可以用遞迴函式重寫,反之亦然。有時,遞迴方法更簡潔,更清晰,更優雅。就用這個 factorial
階乘迭代函式來舉例:
const **factorial** = number => {
let product = 1;
for (let i = 2; i <= number; i++) {
product *= i;
}
return product;
};
複製程式碼
用 recursive
遞迴函式來寫,只需要 一行 程式碼!
const **factorial** = number => {
return number < 2 ? 1 : number * factorial(number - 1);
};
複製程式碼
所有遞迴函式都有一個 通用模式 。它們總是由一個呼叫自身的 遞迴部分 和一個不呼叫自身的 基本情形 組成。當一個函式呼叫自己的時候,它就會將一個新的 執行上下文
推送到 執行堆疊
裡。這種情況會一直持續進行下去,直到遇到 基本情形 ,然後 堆疊 逐個彈出展開成 各個上下文。因此,草率的依賴遞迴會導致可怕的執行時 堆疊溢位
錯誤。
factorial
階乘函式的程式碼示例:
終於,我們準備好接受任何演算法挑戰了!?
熱門的演算法問題
在本節中,我們將按照難度順序瀏覽22個 經常被問到的 演算法問題。我們將討論不同的方法和它們的利弊以及執行中的時間複雜性。最優雅的解決方案通常會利用特殊的 “技巧” 或者敏銳的洞察力。記住這一點,讓我們開始吧!
1. 反轉字串
把一個給定的 一串字元
當作 輸入 ,編寫一個函式,將傳入字串 反轉 字元順序後返回。
describe("String Reversal", () => {
it("**Should reverse string**", () =\> {
assert.equal(reverse("Hello World!"), "!dlroW olleH");
});
});
複製程式碼
分析:
如果我們知道“技巧”,那麼解決方案就不重要了。技巧就是意識到我們可以使用 陣列 的內建方法 reverse
。首先,我們對 字串 使用 split
方法生成一個 字元陣列 ,然後我們可以用 reverse
方法,最後用 join
方法將字元陣列重新組合回一個 字串。這個解決方案可以用一行程式碼來完成!雖然不那麼優雅,但也可以藉助最新的語法和幫助函式來解決問題。使用新的 for of
迴圈迭代字串中的每一個字元,可以展示出我們對最新語法的熟悉情況。或者,我們可以用陣列的 reduce
方法,它使我們不再需要保留臨時基元。
對於給定的字串的每個字元都要被“訪問”一次。雖然這中訪問會多次發生,但是 時間 可以被歸一化為 線性 時間。並且因為沒有單獨的內部狀態需要被儲存,因此 空間 是 恆定 的。
2. 迴文
迴文 是指一個 單詞
或 短語
正向和反向 閱讀都是一樣的。寫一個函式來驗證給定輸入值是否是迴文。
describe("Palindrome", () => {
it("**Should return true**", () =\> {
assert.equal(isPalindrome("Cigar? Toss it in a can. It is so tragic"), true);
});
it("**Should return false**", () =\> {
assert.equal(isPalindrome("sit ad est love"), false);
});
});
複製程式碼
分析:
這裡的關鍵點是意識到:我們基於在前一個問題中學到的東西來解決。除此之外,我們需要返回一個 布林
值。這就像對 原始字串 返回 三重等式 檢查一樣簡單。我們還可以在 陣列 上使用新的 every
方法來檢查 第一個 和 最後一個 字元是否按順序 以中心為對稱點 匹配。然而,這會使檢查次數超過必要次數的兩倍。與前一個問題類似,這個問題的時間和空間的執行時複雜性都 是相同的。
如果我們想擴充套件我們的功能以測試整個 短語 怎麼辦?我們可以創造一個 幫助函式 ,它對 字串
使用 正規表示式 和 replace
方法來剔除非字母字元。如果不允許使用正規表示式,我們就創造一個由 可接受字元 組成的 陣列
用作過濾器。
3. 整數反轉
給定一個 整數
, 反轉 數字的順序。
describe("Integer Reversal", () => {
it("**Should reverse integer**", () =\> {
assert.equal(reverse(1234), 4321);
assert.equal(reverse(-1200), -21);
});
});
複製程式碼
分析:
這裡的技巧是先把數字通過內建的 toString
方法轉化為一個 字串
。然後,我們可以簡單的複用 反轉字串 的演算法邏輯。在數字反轉之後,我們可以使用全域性的 parseInt
函式將字串轉換回整數,並使用 Math.sign
來處理數字的符號。這種方法可以簡化為一行程式碼!
由於我們複用了 反轉字串 的演算法邏輯,這個演算法的時間和空間的執行時複雜度也與之前相同。
4. Fizz Buzz
給定一個 數字
作為輸入值, 列印出從 1 到給定數字的所有整數。 但是,當整數可以被 2 整除時,列印出“Fizz”; 當它可以被3整除時,列印出“Buzz”; 當它可以同時被2和3整除時,列印出“Fizz Buzz”。
分析:
當我們意識到 模運算子 可用於檢查可分性(是否能被整除)時,這個經典演算法的挑戰就變得非常簡單了。模運算子對兩個數字求餘,返回兩數相除的餘數。因此我們可以簡單的遍歷每個整數,檢查它們對2、3整除的餘數是否等於 0
。這展現了我們的數學功底,因為我們知道當一個數可以同時被 a
和 b
整除時,它也可以被它們的 最小公倍數 整除。
同樣,這個演算法的時間和空間的執行時複雜度也與之前相同,因為每一個整數都被訪問和檢查過一次但不需要儲存內部狀態。
5. 最常見字元
給定一個由字元組成的 字串
,返回字串中 出現頻次最高 的 字元
。
describe("Max Character", () => {
it("**Should return max character**", () =\> {
assert.equal(max("Hello World!"), "l");
});
});
複製程式碼
分析:
這裡的技巧是建立一個表格,用來記錄遍歷字串時每個字元出現的次數。這個表格可以用 物件字面量
來建立,用 字元
作為物件字面量的 鍵 ,用字元出現的 次數
作為 值 。然後,我們遍歷表格,通過一個儲存每個鍵值對的 臨時 變數
來找到出現頻次最大的字元。
雖然我們使用了兩個獨立的迴圈來遍歷兩個不同的輸入值( 字串 和 字元對映 ),但時間複雜度仍然是 線性 的。雖然迴圈是對於字串,但最終,字元對映的大小會有一個極限,因為任何一種語言的字元都是 有限 個的。出於同樣的原因,雖然要儲存內部狀態,但不管輸入字串如何增長,空間複雜度也是 恆定 的。臨時基元在大尺度上看也是可以忽略不計的。
6. Anagrams
Anagrams是包含 相同字元 的 單詞
或 短語
。寫一個檢查此功能的 函式
。
describe("Anagrams", () => {
it("**Should implement anagrams**", () =\> {
assert.equal(anagrams("hello world", "world hello"), true);
assert.equal(anagrams("hellow world", "hello there"), false);
assert.equal(anagrams("hellow world", "hello there!"), false);
});
});
複製程式碼
分析:
一種顯而易見的方法是建立一個 字元對映 ,該對映計算每個輸入字串的字元數。之後,我們可以比較對映來看他們是否相同。建立字元對映的邏輯可以抽離成一個 幫助函式 從而更方便的複用。為了更縝密,我們應該首先把字串中所有非字元刪掉,然後把剩下的字元變成小寫。
正如我們所見,字元對映具有 線性 時間複雜度和 恆定 的空間複雜度。更確切地說,這種方法對於時間具有 O(n + m)
複雜度,因為檢查了兩個不同的字串。
另一種更優雅的方法是我們可以簡單的對輸入值 排序
,然後檢查它們是否相等!然而,它的缺點是排序通常需要 線性 時間。
7. 母音
給定一個 字串
型別的單詞或短語, 計算 母音
的個數.
describe("Vowels", () => {
it("**Should count vowels**", () =\> {
assert.equal(vowels("hello world"), 3);
});
});
複製程式碼
分析:
最簡單的辦法是使用 正規表示式 取出所有的母音字母,然後計算它們的數量。如果不允許使用正規表示式,我們可以簡單的遍歷每一個字元,檢查它是否是原因字母。不過首先要把字串轉化為 小寫 。
兩種方法都是 線性 時間複雜度和 恆定 空間複雜度,因為每一個字元都需要被檢查一次,而臨時基元可以忽略不計。
8. 陣列塊
對於一個給定 大小
的 陣列
,將陣列 元素 分割成一個給定大小的 陣列 型別的 列表
。
describe("Array Chunking", () => {
it("**Should implement array chunking**", () =\> {
assert.deepEqual(chunk(\[1, 2, 3, 4\], 2), \[\[1, 2\], \[3, 4\]\]);
assert.deepEqual(chunk(\[1, 2, 3, 4\], 3), \[\[1, 2, 3\], \[4\]\]);
assert.deepEqual(chunk(\[1, 2, 3, 4\], 5), \[\[1, 2, 3, 4\]\]);
});
});
複製程式碼
分析:
一個顯而易見的方法是保持一個對最後一個“塊”的引用,並在遍歷陣列元素時檢查其大小來判斷是否應該向最後一個塊中放元素。更優雅的解決方案是使用內建的 slice
方法。這樣就不需要“引用”,從而使程式碼更清晰。這可以通過 while
迴圈或 for
迴圈來實現,該迴圈以給定大小的step遞增。
這些演算法都具有 線性 時間複雜度,因為每個陣列項都需要被訪問一次。它們也都有 線性 的空間複雜度,因為需要儲存一個內在的 “塊” 型別陣列,該陣列大小會隨著輸入值變化而變化。
9. 反轉陣列
給定一個任意型別的 陣列
, 反轉 陣列的順序。
describe("Reverse Arrays", () => {
it("**Should reverse arrays**", () =\> {
assert.deepEqual(reverseArray(\[1, 2, 3, 4\]), \[4, 3, 2, 1\]);
assert.deepEqual(reverseArray(\[1, 2, 3, 4, 5\]), \[5, 4, 3, 2, 1\]);
});
});
複製程式碼
分析:
當然最簡單的解決辦法是使用內建的 reverse
方法。但這也太賴皮了!如果不允許使用這種方法,我們可以簡單迴圈陣列的一般,並 交換 陣列的開頭和結尾的元素。這意味著我們要在記憶體裡暫存 一個 陣列元素。為了避免這種對暫存的需要,我們可以對陣列對稱位置的元素使用 結構賦值 。
雖然只遍歷了輸入陣列的一半,但時間複雜度仍然是 線性 的,因為 Big O 近似地忽略了係數。
10. 反轉單詞
給定一個 片語
, 反轉 片語中每個單詞的字元順序。
describe("Reverse Words", () => {
it("**Should reverse words**", () =\> {
assert.equal(reverseWords("I love JavaScript!"), "I evol !tpircSavaJ");
});
});
複製程式碼
分析:
我們可以使用split方法建立單個單詞的陣列。然後對每一個單詞,我們使用 反轉字串 的邏輯來反轉它的字元。另一種方法是 反向 遍歷每個單詞,並將結果儲存在臨時變數中。無論哪種方式,我們都需要暫存所有反轉的單詞,最後再把它們拼接起來。
由於每一個字元都被遍歷了一遍,並且所需的臨時變數大小與輸入字串成比例,所以時間和空間複雜度都是 線性 的。
11. 首字母大寫轉換
給定一個 片語
,對每一個單詞進行 首字母大寫 。
describe("Capitalization", () => {
it("**Should capitalize phrase**", () =\> {
assert.equal(capitalize("hello world"), "Hello World");
});
});
複製程式碼
分析:
一種解決方法是遍歷每個字元,當遍歷字元的前一個字元是 空格 時,就對當前字元使用 toUpperCase
方法使其變成大寫。由於 字串文字 在 JavaScript 中是 不可變 的,所以我們需要使用適當的大寫轉化方法重建輸入字串。這種方法要求我們始終將第一個字元大寫。另一種更簡潔的方法是將輸入字串 split
成一個 由單片語成的陣列 。然後,遍歷這個陣列,將每個元素第一個字元大寫,最後將單詞重新連線在一起。出於相同的不可變原因,我們需要在記憶體裡儲存一個 臨時陣列 來儲存被正確大寫的單詞。
兩種方式都是 線性 的時間複雜度,因為每個字串都被遍歷了一次。它們也都是 線性 的空間複雜度,因為儲存了一個臨時變數,該變數與輸入字串成比例增長。
12. 愷撒密碼
給定一個 短語
, 通過將每個字元 替換 成字母表向前或向後移動一個給定的 整數
的新字元。如有必要,移位應繞回字母表的開頭或結尾。
describe("Caesar Cipher", () => {
it("**Should shift to the right**", () =\> {
assert.equal(caesarCipher("I love JavaScript!", 100), "E hkra FwrwOynelp!");
});
it("**Should shift to the left**", () =\> {
assert.equal(caesarCipher("I love JavaScript!", -100), "M pszi NezeWgvmtx!");
});
});
複製程式碼
分析:
首先,我們需要建立一個 字母表字元 組成的 陣列
,以便計算移動字元的結果。這意味著我們要在遍歷字元之前先把 輸入字串
轉化為小寫。我們很容易用常規的 for
迴圈來跟蹤當前索引。我們需要構建一個包含每次迭代移位後的字元的 新字串
。注意,當我們遇到非字母字元時,應該立即將它追加到我們的結果字串的末尾,並使用 continue
語句跳到下一次迭代。有一個關鍵點事要意識到我們可以使用 模運算子
模擬當移位超過26時,迴圈計數到字母表陣列的開頭或結尾的行為。最後,我們需要在將結果追加到結果字串之前檢查原始字串中的大小寫。
由於需要訪問每一個輸入字串的字元,並且需要根據輸入字串新建一個結果字串,因此這個演算法的時間和空間複雜度都是 線性 的。
13. Ransom Note
給定一個 magazine段落
和一個 ransom段落
,判斷 magazine段落 中是否包含每一個 ransom段落 中的單詞 。
const magazine =
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum";
describe("Ransom Note", () => {
it("**Should return true**", () =\> {
assert.equal(ransomNote("sit ad est sint", magazine), true);
});
it("**Should return false**", () =\> {
assert.equal(ransomNote("sit ad est love", magazine), false);
});
it("**Should return true**", () =\> {
assert.equal(ransomNote("sit ad est sint in in", magazine), true);
});
it("**Should return false**", () =\> {
assert.equal(ransomNote("sit ad est sint in in in in", magazine), false);
});
});
複製程式碼
分析:
顯而易見的做法是把magazine段落和ransom段落分拆成由單個單片語成的 陣列 ,然後檢查每個ransom單詞是否存在於magazine段落中。然而,這種方法的時間複雜度是 二次 的,或者說是 O(n * m)
的,這說明這種方法效能不好。如果我們首先建立一個magazine段落的單詞表格,然後檢查ansom段落中的每個詞是否存在於這張表格中,我們就可以實現 線性 時間複雜度。這是因為在 對映物件 中的查詢總是可以在 恆定 時間內完成。但是我們將會犧牲空間複雜度,因為需要把對映物件儲存在記憶體裡。
在程式碼中,這意味著我們需要建立每個magazine段落中單詞的計數,然後檢查 “hash 表格” 是否包含正確數量的ransom單詞。
14. 平均值,中位數和Mode(出現次數最多的數字)
給定一個數字組成的 陣列
,計算這些數的 平均值 , 中位數 和 Mode 。
const **stat1** = new Stats(\[1, 2, 3, 4, 4, 5, 5\]);
const **stat2** = new Stats(\[1, 1, 2, 2, 3, 3, 4, 4\]);
describe("Mean", () => {
it("**Should implement mean**", () =\> {
assert.equal(Stats.round(stat1.mean()), 3.43);
assert.equal(Stats.round(stat2.mean()), 2.5);
});
});
describe("Median", () => {
it("**Should implement median**", () =\> {
assert.equal(stat1.median(), 4);
assert.equal(stat2.median(), 2.5);
});
});
describe("Mode", () => {
it("**Should implement mode**", () =\> {
assert.deepEqual(stat1.mode(), \[4, 5\]);
assert.deepEqual(stat2.mode(), \[\]);
});
});
複製程式碼
分析:
從難度方面講,找到數字集合 平均值 的演算法是最簡單的。統計學上, 平均值
的定義是數字集合的 和 除以數字集合的 數量 。因此,我們可以簡單的使用陣列的 reduce
方法來對它求和,然後除以它的 長度
。這個演算法的執行時複雜度對時間是 線性 的,對空間是 恆定 的。因為每一個數字在遍歷的過程中都需要被求和但不需要在記憶體裡儲存變數。
找到集合 中位數 的演算法困難度是中等的。首先,我們需要給陣列排序,但如果集合的長度是基數,我們就需要額外的邏輯來處理中間的兩個數字。這種情況下,我們需要返回這兩個數字的 平均值 。這個演算法因為需要排序,所以具有 線性對數 時間複雜度,同時因為需要記憶體來保持排序的陣列,所以具有 線性 的空間複雜度。
找到 mode 的演算法是最為複雜的。由於 mode
被定義為最常出現的一個或多個數字,我們需要在記憶體中維護一個 頻率表 。更復雜的是,如果每個值出現的次數都相同,則沒有mode。這意味著在程式碼中,我們需要建立一個 雜湊對映 來計算每一個“數”出現的頻率;然後遍歷這個對映來找到最高頻的一個或多個數字,當然也可能沒有mode。因為每個數字都需要在記憶體中保留和計數,所以這個演算法具有 線性 的時間和空間複雜度。
15. 多重求和
給定一組數字,返回滿足“兩數字之和等於給定 和
”的 所有組合 。每個數字可以被使用不止一次。
describe("Two Sum", () => {
it("**Should implement two sum**", () =\> {
assert.deepEqual(twoSum(\[1, 2, 2, 3, 4\], 4), \[\[2, 2\], \[3, 1\]\]);
});
});
複製程式碼
分析:
顯而易見的解決方案是建立 巢狀迴圈 ,該迴圈檢查每一個數字與同組中其他數字。那些滿足求和之後滿足給定和的組合可以被推入到一個 結果陣列 中。然而,這種巢狀會引起 指數 型的時間複雜度,這對於大輸入值而言非常不適用。
一個討巧的辦法是在我們遍歷輸入陣列時維護一個包含每個數字的 “對應物” 的陣列,同時檢查每個數字的對應物是否已經存在。通過維護這樣的陣列,我們犧牲了空間效率來獲得 線性 的時間複雜度。
16. 利潤最大化
給定一組按照時間順序給出的股票價格,找到 最低 買入價
和 最高 賣出價
使得 利潤最大化 。
describe("Max Profit", () => {
it("**Should return minimum buy price and maximum sell price**", () =\> {
assert.deepEqual(maxProfit([1, 2, 3, 4, 5]), [1, 5]);
assert.deepEqual(maxProfit([2, 1, 5, 3, 4]), [1, 5]);
assert.deepEqual(maxProfit([2, 10, 1, 3]), [2, 10]);
assert.deepEqual(maxProfit([2, 1, 2, 11]), [1, 11]);
});
複製程式碼
分析:
同樣,我們可以構建 巢狀迴圈 ,該迴圈檢查買入價和賣出價的每種可能組合,看看哪一對產生最大的利潤。實際操作中我們不能在購買之前出售,所以不是每個組合都需要被檢查。具體而言,對於給定的買入價格,我們可以忽略賣出價格之前的所有價格。因此,該演算法的時間複雜度優於 二次 型。
不過稍微考慮一下,我們可以對價格陣列只使用一次迴圈來解決問題。關鍵點是要意識到賣價絕不應低於買入價; 如果是這樣,我們應該以較低的價格購買股票。就是說在程式碼中,我們可以簡單的維護一個 臨時布林值 來表示我們應該在下一次迭代時更改買入價格。這種優雅的方法只需要一個迴圈,因此具有 線性 的時間複雜度和 恆定 的空間複雜度。
17. Sieve of Eratosthenes
對於給定的 數字
,找到從零到該數字之間的所有 素數 。
describe("Sieve of Eratosthenes", () => {
it("**Should return all prime numbers**", () =\> {
assert.deepEqual(primes(10), \[2, 3, 5, 7\]);
});
});
複製程式碼
分析:
乍一看,我們可能想要遍歷每個數字,只需使用模數運算子來檢查所有可能的可分性。然而,很容易想到這種方法非常低效,時間複雜度比二次型還差。值得慶幸的是,地理學的發明者 Eratosthenes of Cyrene 還發現了一種有效的識別素數的方法。
在程式碼中,第一步是建立一個與給定數字一樣大的陣列,並將其每個元素初始化為 true
。換句話說,陣列的 索引 代表了所有可能的素數,並且每個數都被假定為 true 。然後我們建立一個 for
迴圈來遍歷從 2 到給定數字的 平方根 之間的數,使用陣列的 鍵插值 來把每個被遍歷數的小於給定數的倍數對應的元素值設為 false 。根據定義,任何整數的乘積都不能是素數,這裡忽略0和1,因為它們不會影響可分性。最後我們可以簡單的篩掉所有 假值 ,以得出所有素數。
通過犧牲空間效率來維護一個內部的 “hash表”,這個Eratosthenes的 篩子 在時間複雜度上會優於 二次 型,或者說是 O(n * log (log n))
。
18. 斐波那契通項公式
實現一個返回給定 索引
處的 斐波納契數 的 函式
。
describe("Fibonacci", () => {
it("**Should implement fibonacci**", () =\> {
assert.equal(fibonacci(1), 1);
assert.equal(fibonacci(2), 1);
assert.equal(fibonacci(3), 2);
assert.equal(fibonacci(6), 8);
assert.equal(fibonacci(10), 55);
});
});
複製程式碼
分析:
由於斐波納契數是前兩者的總和,最簡單的方法就是使用 遞迴 。斐波納契數列假定前兩項分別是1和1; 因此我們可以基於這個事實來建立我們的 基本情形 。對於索引大於2的情況,我們可以呼叫自身函式的前兩項。雖然看著很優雅,這個遞迴方法的效率卻非常糟糕,它具有 指數 型的時間複雜度和 線性 的空間複雜度。因為每個函式呼叫都需要呼叫堆疊,所以記憶體使用以指數級增長,如此一來它會很快就會崩潰。
迭代的方法雖然不那麼優雅,但是時間複雜度卻更優。通過迴圈,建立一個完整的斐波納契數列前N專案(N為給定索引值),這可以達到 線性 的時間和空間複雜度。
19. Memoized Fibonacci
給斐波納契數列實現一個 高效 的遞迴函式。
describe("Memoized Fibonacci", () => {
it("**Should implement memoized fibonacci**", () =\> {
assert.equal(fibonacci(6), 8);
assert.equal(fibonacci(10), 55);
});
});
複製程式碼
分析:
由於斐波納契數列對自己進行了冗餘的呼叫,因此它可以戲劇性的從被稱為 記憶化 的策略中獲益匪淺。換句話說,如果我們在呼叫函式時 快取 所有的輸入和輸出值,則呼叫次數將減少到 線性 時間。當然,這意味著我們犧牲了額外的記憶體。
在程式碼中,我們可以在函式本身內部實現 記憶化 技術,或者我們可以將它抽象為高階效用函式,該函式可以裝飾任何 記憶化 函式。
20. 畫樓梯
對於給定長度的 步幅
,使用 # and ‘ ’ 列印出一個 “樓梯” 。
describe("Steps", () => {
it("**Should print steps**", () =\> {
assert.equal(steps(3), "# \\n## \\n###\\n");
assert.equal(_steps(3), "# \\n## \\n###\\n");
});
});
複製程式碼
分析:
關鍵的見解是要意識到,當我們向下移動步幅時,#
的數量會不斷 增加 ,而 ' ' 的數量會相應 減少 。如果我們有 n
步要移動,全域性的範圍就是 n
行 n
列。這意味著執行時複雜度對於時間和空間都是 二次 型的。
同樣,我們發現這也可以使用遞迴的方式來解決。除此之外,我們需要傳遞 額外的引數 來代替必要的臨時變數。
21. 畫金字塔
對於給定數量的 階層
,使用 # 和 ' ' 列印出 "金字塔"。
describe("Pyramid", () => {
it("**Should print pyramid**", () =\> {
assert.equal(pyramid(3), " # \\n ### \\n#####\\n");
assert.equal(_pyramid(3), " # \\n ### \\n#####\\n");
});
});
複製程式碼
分析:
這裡的關鍵時要意識到當金字塔的高度是 n
時,寬是 2 * n — 1
。然後隨著我們往底部畫時,只需要以中心對稱不斷 增加 # 的數量,同時相應 減少 ' ' 的數量。由於該演算法以 2 * n - 1
* n
遍歷構建出一個金字塔,因此它的執行時時間複雜度和空間複雜度都是 二次
型的。
同樣,我們可以發現這裡的遞迴呼叫可以使用之前的方法:需要傳遞一個 附加變數 來代替必要的臨時變數。
22. 螺旋方陣
建立一個給定 大小
的 方陣 ,使方陣中的元素按照 螺旋順序 排列。
describe("Matrix Spiral", () => {
it("**Should implement matrix spiral**", () =\> {
assert.deepEqual(spiral(3), \[\[1, 2, 3\], \[8, 9, 4\], \[7, 6, 5\]\]);
});
});
複製程式碼
分析:
雖然這是一個很複雜的問題,但技巧其實只是對 當前行 和 當前列 的 開頭 以及 結尾 的位置分別建立一個 臨時變數
。這樣,我們就可以按螺旋方向 遞增 遍歷 起始行
和 起始列
並 遞減 遍歷 結束行
和 結束列
直至方陣的中心。
因為該演算法迭代地構建給定大小的 正方形 矩陣,它的執行時複雜度對時間和空間都是 二次 型的。
資料結構演算法
既然資料結構式構建演算法的 “磚瓦” ,那麼非常值得深入探索常見的資料結構。
再一次,想要快速的高層次的分析,請檢視:
Data Structures in JavaScript
For Frontend Software Engineers medium.com
佇列
給定兩個 佇列
作為輸入,通過將它們“編織”在一起來建立一個 新 佇列。
describe("Weaving with Queues", () => {
it("**Should weave two queues together**", () =\> {
const one = new Queue();
one.enqueue(1);
one.enqueue(2);
one.enqueue(3);
const two = new Queue();
two.enqueue("one");
two.enqueue("two");
two.enqueue("three");
const result = weave(one, two);
assert.equal(result.dequeue(), 1);
assert.equal(result.dequeue(), "one");
assert.equal(result.dequeue(), 2);
assert.equal(result.dequeue(), "two");
assert.equal(result.dequeue(), 3);
assert.equal(result.dequeue(), "three");
assert.equal(result.dequeue(), undefined);
});
複製程式碼
分析:
佇列
類至少需要有一個 入列(enqueue)
方法,一個 出列(dequeue)
方法,和一個 peek
方法。然後,我們使用 while
迴圈,該迴圈判斷 peek 是否存在,如果存在,我們就讓它執行 出列 ,然後 入列 到我們的新 佇列
中。
這個演算法的時間和空間複雜度都是 O(n + m)
沒因為我們需要迭代兩個不同的集合,並且要儲存它們。
堆疊
使用兩個 堆疊 實現 Queue
類。
describe("Queue from Stacks", () => {
it("**Should implement queue using two stacks**", () =\> {
const queue = new Queue();
queue.enqueue(1);
queue.enqueue(2);
queue.enqueue(3);
assert.equal(queue.peek(), 1);
assert.equal(queue.dequeue(), 1);
assert.equal(queue.dequeue(), 2);
assert.equal(queue.dequeue(), 3);
assert.equal(queue.dequeue(), undefined);
});
});
複製程式碼
分析:
我們可以從一個初始化兩個堆疊的 類建構函式 開始。因為在 堆疊 中,最 後 插入的記錄會最 先 被取出,我們需要迴圈到最後一條記錄執行 “出列” 或者 “peek” 來模仿 佇列 的行為:最 先 被插入的記錄會最 先 被取出。我們可以通過使用第二個堆疊來 臨時 儲存第一個堆疊中所有的元素直到結束。在 “peek” 或者 “出列” 之後,我們只要把所有內容移回第一個堆疊即可。對於 “入列” 一個記錄,我們可以簡單的把它push到第一個堆疊即可。
雖然我們使用兩個堆疊並且需要迴圈兩次,但是該演算法在時間和空間複雜度上仍然是漸近 線性 的。 Though we use two stacks and need to loop twice, this algorithm is still asymptotically linear in time and space.
連結串列
單向連結串列通常具有以下功能:
describe("Linked List", () => {
it("**Should implement insertHead**", () =\> {
const chain = new LinkedList();
chain.insertHead(1);
assert.equal(chain.head.data, 1);
});
it("**Should implement size**", () =\> {
const chain = new LinkedList();
chain.insertHead(1);
assert.equal(chain.size(), 1);
});
it("**Should implement getHead**", () =\> {
const chain = new LinkedList();
chain.insertHead(1);
assert.equal(chain.getHead().data, 1);
});
it("**Should implement getTail**", () =\> {
const chain = new LinkedList();
chain.insertHead(1);
assert.equal(chain.getTail().data, 1);
});
it("**Should implement clear**", () =\> {
const chain = new LinkedList();
chain.insertHead(1);
chain.clear();
assert.equal(chain.size(), 0);
});
it("**Should implement removeHead**", () =\> {
const chain = new LinkedList();
chain.insertHead(1);
chain.removeHead();
assert.equal(chain.size(), 0);
});
it("**Should implement removeTail**", () =\> {
const chain = new LinkedList();
chain.insertHead(1);
chain.removeTail();
assert.equal(chain.size(), 0);
});
it("**Should implement insertTail**", () =\> {
const chain = new LinkedList();
chain.insertTail(1);
assert.equal(chain.getTail().data, 1);
});
it("**Should implement getAt**", () =\> {
const chain = new LinkedList();
chain.insertHead(1);
assert.equal(chain.getAt(0).data, 1);
});
it("**Should implement removeAt**", () =\> {
const chain = new LinkedList();
chain.insertHead(1);
chain.removeAt(0);
assert.equal(chain.size(), 0);
});
it("**Should implement insertAt**", () =\> {
const chain = new LinkedList();
chain.insertAt(0, 1);
assert.equal(chain.getAt(0).data, 1);
});
it("**Should implement forEach**", () =\> {
const chain = new LinkedList();
chain.insertHead(1);
chain.insertHead(2);
chain.forEach((node, index) => (node.data = node.data + index));
assert.equal(chain.getTail().data, 2);
});
it("**Should implement iterator**", () =\> {
const chain = new LinkedList();
chain.insertHead(1);
chain.insertHead(2);
for (let node of chain) node.data = node.data + 1;
assert.equal(chain.getTail().data, 2);
});
});
複製程式碼
挑戰 #1: 中點
在不使用計數器的情況下,返回連結串列的 中間值
describe("Midpoint of Linked List", () => {
it("**Should return midpoint of linked list**", () =\> {
const chain = new LinkedList();
chain.insertHead(1);
chain.insertHead(2);
chain.insertHead(3);
chain.insertHead(4);
chain.insertHead(5);
assert.equal(midpoint(chain).data, 3);
});
});
複製程式碼
分析:
這裡的技巧是同時進行 兩次 連結串列遍歷,其中一次遍歷的速度是另一次的 兩倍。當快速的遍歷到達結尾的時候,慢速的就到達了中點!
這個演算法的時間複雜度是 線性 的,空間複雜度是 恆定 的。
Challenge #2: 迴圈
在不保留節點引用的情況下,檢查連結串列是否為 迴圈 。
describe("Circular Linked List", () => {
it("**Should check for circular linked list**", () =\> {
const chain = new LinkedList();
chain.insertHead(1);
chain.insertHead(2);
chain.insertHead(3);
chain.head.next.next.next = chain.head;
assert.equal(circular(chain), true);
});
});
複製程式碼
分析:
很多的連結串列功能都基於連結串列有 明確 的結束節點這個斷言。因此,確保連結串列不是迴圈的這一點很重要。這裡的技巧也是同時進行兩次遍歷,其中一次遍歷的速度是另一次的兩倍。如果連結串列是迴圈的,那麼最終,較快的迴圈將與較慢的迴圈重合。這樣我們就可以返回 true
。否則,遍歷會遇到結束點,我們就可以返回 false
。
這個演算法同樣具有 線性 時間複雜度和 恆定 空間複雜度。
Challenge #3: From Tail
在不使用計數器的情況下,返回連結串列距離連結串列末端給定 步數
的節點的 值 。
describe("From Tail of Linked List", () => {
it("**Should step from tail of linked list**", () =\> {
const chain = new LinkedList();
chain.insertHead(1);
chain.insertHead(2);
chain.insertHead(3);
chain.insertHead(4);
chain.insertHead(5);
assert.equal(fromTail(chain, 2).data, 3);
});
});
複製程式碼
分析:
這裡的技巧和之前類似,我們同時遍歷連結串列兩次。不過,在這個問題中,速度“快”的遍歷比速度“慢”的遍歷 早 給定步數
開始。然後,我們以相同的速度沿著連結串列向下走,直到更快的一個到達終點。這時,慢的遍歷剛好到達距離結尾正確距離的位置。
這個演算法同樣具有 線性 時間複雜度和 恆定 空間複雜度。
樹
樹型結構通常具有以下功能:
describe("Trees", () => {
it("**Should add and remove nodes**", () =\> {
const root = new Node(1);
root.add(2);
assert.equal(root.data, 1);
assert.equal(root.children\[0\].data, 2);
root.remove(2);
assert.equal(root.children.length, 0);
});
it("**Should traverse by breadth**", () =\> {
const tree = new Tree();
tree.root = new Node(1);
tree.root.add(2);
tree.root.add(3);
tree.root.children\[0\].add(4);
const numbers = \[\];
tree.traverseBF(node => numbers.push(node.data));
assert.deepEqual(numbers, \[1, 2, 3, 4\]);
});
it("**Should traverse by depth**", () =\> {
const tree = new Tree();
tree.root = new Node(1);
tree.root.add(2);
tree.root.add(3);
tree.root.children\[0\].add(4);
const numbers = \[\];
tree.traverseDF(node => numbers.push(node.data));
assert.deepEqual(numbers, \[1, 2, 4, 3\]);
});
});
複製程式碼
Challenge #1: 樹的廣度
對於給定的 樹
,返回每個級別的 廣度 。
describe("Width of Tree Levels", () => {
it("**Should return width of each tree level**", () =\> {
const root = new Node(1);
root.add(2);
root.add(3);
root.children\[1\].add(4);
assert.deepEqual(treeWidths(root), \[1, 2, 1\]);
});
});
複製程式碼
分析:
一個樹可以通過 堆疊
對其所有的 切片 進行 深度優先 的遍歷,也可以通過 佇列
的幫助對其所有的 層級 進行 廣度優先 的遍歷。由於我們是想要計算每個級別的多有節點的個數,我們需要以 深度優先 的方式,藉助 佇列
對其進行 廣度優先 的遍歷。這裡的技巧是往佇列中插入一個特殊的 標記
來使我們知道當前的級別被遍歷完成,所以我們就可以 重置 計數器
給下一個級別使用。
這種方法具有 線性 的時間和空間複雜度。儘管我們的 計數器
是一個陣列,但是它的大小永遠不會比線性更大。
Challenge #2: 樹的高度
對於給定的 樹
,返回它的 高度 (樹的最大層級)。
describe("Height of Tree", () => {
it("**Should return max number of levels**", () =\> {
const root = new Node(1);
root.add(2);
root.add(3);
root.children\[1\].add(4);
assert.deepEqual(treeHeight(root), 2);
});
});
複製程式碼
分析:
我們可以直接複用第一個挑戰問題的邏輯。但是,在這個問題中,我們要在遇到 “reset”
的時候增加我們的 計數器
。這兩個邏輯幾乎是相同的,所以這個演算法也具有 線性 的時間和空間複雜度。這裡,我們的 計數器
只是一個整數,因此它的大小更可以忽略不計。
圖表
請等待後續補充! (謝謝)
排序演算法
我們可以使用許多種演算法對資料集合進行排序。幸運的是,面試官只要求我們瞭解基礎知識和第一原則。例如,最佳演算法可以達到 恆定 空間複雜度和 線性 時間複雜度。本著這種精神,我們將按照困難度由簡到難效率由低到高的順序分析最受歡迎的幾個演算法。
氣泡排序
這個演算法是最容易理解的,但效率是最差的。它將每一個元素和其他所有元素做 比較 , 交換 順序,直到較大的元素 “冒泡” 到頂部。這種演算法需要 二次 型的時間和 恆定 的空間。
插入排序
和氣泡排序一樣,每一個元素都要與其他所有元素做比較。不同的是,這裡的操作不是交換,而是 “拼接” 到正確的順序中。事實上,它將保持重複專案的原始順序。這種“貪婪”演算法依然需要 二次 型的時間和 恆定 的空間。
選擇排序
當迴圈遍歷集合時,該演算法查詢並“選擇”具有 最小值 的索引,並將起始元素與索引位置的元素交換。演算法也是需要 二次 型的時間和 恆定 的空間。
快速排序
該演算法遞迴的選擇一個元素作為 軸 ,迭代集合中其他元素,將所有更小的元素向左邊推,將所有更大的元素向右邊推,直到所有元素都被正確排序。該演算法具有 二次 時間複雜度和 對數 空間複雜度,因此在實踐中它通常是 最快度 的。因此,大多數程式語言內建都用該演算法進行排序。
歸併排序
雖然這是效率最高的演算法之一,但這種演算法卻難以理解。它需要一個 遞迴 部分,將一個集合分成單個單元;並且需要一個 迭代 部分,它將單個單元按正確的順序重新組合在一起。這個演算法需要 線性對數 時間和 線性 空間。
計數排序
如果我們用某種方式知道了 最大值 ,我們就可以用這個演算法在 線性 時間和空間裡對集合排序!最大值讓我們建立該大小的陣列來 計算 每個 索引值 出現的次數。然後,只需將所有具有 非零 計數的索引位置的元素提取到結果陣列中。通過對陣列進行 恆定時間 查詢,這個類似雜湊的演算法是最有效的演算法。
其他的排序演算法
搜尋演算法
最糟糕的演算法需要搜尋集合中的每個專案,需要花費 O(n)
時間。如果某個集合已經被排序,那麼每次迭代只需要一半的檢查次數,花費 O(log n)
時間,這對於非常大的資料集來說是一個巨大的效能提升。
二分搜尋
當一個集合被排序時,我們可以 遍歷 或 遞迴 地檢查我們的被檢索值和中間項,丟棄一半我們想要的值不在的部分。事實上,我們的目標可以在 對數 時間和 恆定 空間情況中被找到。
二叉搜尋樹
另一種排序集合的方法是從中生成一個 二叉搜尋樹 (BST) 。對一個 BST 的搜尋和二分搜尋一樣高效。以類似的方式,我們可以在每次迭代中丟棄一半我們知道不包含期望值的部分。事實上,另一種對集合進行排序的方法是按 順序 對這棵樹進行 深度優先 遍歷!
BST 的建立發生在 線性 時間和空間中,但是搜尋它需要 對數 時間和 恆定 空間。
要驗證二叉樹是否為BST,我們可以遞迴檢查每個左子項是否總小於根(最大可能),並且每個右子項總大於 每個根 上的根(最小可能)。這種歌方法需要 線性 時間和 恆定 空間。
總結
在現代Web開發中,函式 是Web體驗的核心。資料結構 被函式接受和返回,而 演算法 則決定了內部的機制。演算法的資料結構的數量級由 空間複雜度 描述,計算次數的數量級由 時間複雜度 描述。在實踐中,執行時複雜性表示為 Big-O 符號,可幫助工程師比較所有可能的解決方案。最有效的執行時是 恆定 時間,不依賴於出入值的大小;最低效的方法需要運算 指數 時間和空間。真正掌握演算法和資料結構是指可以同時 線性 和 系統 的推理。
理論上說,每一個問題都具有 迭代 和 遞迴 的解決方案。迭代演算法是從底部開始 動態 的接近解決方案。遞迴演算法是從頂部開始分解 重複的子問題 。通常,遞迴方案更直觀且更易於實現,但是迭代方案更容易理解,且對記憶體需求更小。通過 一流的函式 和 控制流 結構,JavaScript 天然支援這兩種方案。通常來說,為了達到更好的效能會犧牲一些空間效率,或者需要犧牲效能來減少記憶體消耗。正確的平衡兩者,需要根據實際上下為和環境來決定。值得慶幸的是,大多數面書館都更關注 計算推理過程 而不是結果。
為了給你的面試官留下深刻的印象,要儘量尋找機會利用 架構設計 和 設計模式 來提升 可複用性 和 可維護性 。如果你正在尋找一個資深職位,對基礎知識和第一原則的掌握與系統級設計的經驗同樣重要。不過,最好的公司也會評估 文化契合 度。因為沒有人是完美的,所以合適的團隊是必不可少的。更重要的是,這世上的一些事是不可能憑一己之力達到的。大家共同創造的東西往往是最令人滿意和最有意義的。