傳統的 面試過程 一般以最基本的如何編寫 手機屏幕頁面 問題爲開始,而後經過全天的 現場工做 來檢驗 編碼能力 和 文化契合 度。 幾乎無一例外,決定性的因素仍是 編碼能力。 畢竟,工程師是靠一天結束之時產出可以使用的軟件來得到報酬的。通常來講,咱們會使用 白板 來測試這種編碼能力。比得到正確答案更重要的是清晰明瞭的思考過程。編碼和生活同樣,正確的答案不老是顯而易見的,可是好的論據一般是足夠好的。 有效 的 推理 能力標誌着學習,適應和發展的潛力。最好的工程師老是在成長,最好的公司老是在不斷創新。javascript
算法挑戰 是有效的鍛鍊能力的方法,由於總有不止一種的方法來解決它們。這爲決策和演算決策提供了可能性。當解決算法問題的時候,咱們應該挑戰自我,從多個角度來看 問題的定義 ,而後權衡各類方式的 益處 和 缺陷 。經過足夠的聯繫,咱們甚至能夠一瞥宇宙的真理; 沒有「完美」的解決方案 。java
真正掌握 算法 就是去理解 數據 和 結構 之間的關係。數據結構和算法之間的關係,就如同「陰」之於「陽」, 玻璃杯 之於 水 。沒有玻璃杯,水就沒法被承載。沒有數據結構,咱們就沒有能夠用於邏輯的對象。沒有水,玻璃杯會由於缺少物質而變空。沒有算法,對象就沒法被轉化或者「消費」。node
關於數據結構深刻分析,能夠參考: Data Structures in JavaScript:面試
應用於代碼中,一個算法只是一個把肯定的 數據結構 的 輸入 轉化爲一個肯定的 數據結構 的 輸出 的 function
。算法 內在 的 邏輯 決定了如何轉換。首先,輸入和輸出應該被 明確 定義爲 單元測試。這須要徹底的理解手頭的問題,這是不容小覷的,由於完全分析問題能夠無需編寫任何代碼,就天然地解決問題。正則表達式
一旦完全掌握問題的領域,就能夠開始對解決方案進行 頭腦風暴 。 須要哪些變量?須要多少循環以及哪些類型的循環?有沒有巧妙的內置的方法能夠提供幫助?須要考慮哪些邊緣狀況? 複雜和重複的邏輯只會徒增閱讀和理解的難度。 幫助函數能夠被抽象或者抽離嗎? 算法一般須要是可擴展的。 隨着輸入規模的增長,函數將如何執行? 是否應該有某種緩存機制? 而性能優化(時間)一般須要犧牲內存空間(增長內存消耗)。算法
爲了使問題更具體,讓咱們來繪製一個 圖表 !編程
當解決方案中的高級結構開始出現時,咱們就能夠開始寫 僞代碼 了。爲了給面試官留下真正的印象, 請 優先 考慮代碼的重構和 複用 。有時,行爲相似的函數能夠合併成一個能夠接受額外參數的更通用的函數。其餘時候,去參數化會更好。保持函數的 純淨 以便於測試和維護也是頗有先見之明的。換言之,設計算法時,將 架構 和 設計模式 歸入到你的考慮範圍內。設計模式
若是有任何不清楚的地方,請 提問 以便說明!api
爲了估算算法運行時的複雜度,在計算算法所需的 操做次數 以前,咱們一般把 輸入大小 外推至無窮來估算算法的可擴展性。在這種最壞狀況的運行時上限狀況下,咱們能夠忽略係數以及附加項,只保留主導函數的因子。所以,只須要幾種類型就能夠描述幾乎全部的可擴展算法。數組
最優最理想的算法,是在時間和空間維度以 常數 速率變化。這就是說它徹底不關心輸入大小的變化。次優的算法是對時間或空間以 對數 速率變化,再次分別是 線性 , 線性對數 , 二次 和 指數 型。最糟糕的是對時間或空間以 階乘 速率變化。在 Big-O 表示法中:
當咱們考慮算法的時間和空間複雜性之間的權衡時,Big-O 漸近分析 是不可或缺的工具。然而,Big O 忽略了在實際實踐中可能有影響的常量因素。此外,優化算法的時間和空間複雜性可能會增長現實的開發時間或對代碼可讀性產生負面影響。在設計算法的結構和邏輯時,對真正可忽略不計的東西的直覺一樣重要。
最乾淨的算法一般會利用語言中固有的 標準 對象。能夠說計算機科學中最重要的是Arrays
。在JavaScript中,沒有其餘對象比數組擁有更多的實用工具方法。值得記住的數組方法是: sort
, reverse
, slice
, 以及 splice
。數組從 第0個索引 開始插入數組元素。這意味着最後一個數組元素的位置是 array.length — 1
。數組是 索引 (推入) 的最佳選擇,但對於 插入, 刪除 (不彈出), 和 搜索 等動做很是糟糕。在 JavaScript 中, 數組能夠 動態 增加。
對應的 Big O :
完整的閱讀 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個 常常被問到的 算法問題。咱們將討論不一樣的方法和它們的利弊以及運行中的時間複雜性。最優雅的解決方案一般會利用特殊的 「技巧」 或者敏銳的洞察力。記住這一點,讓咱們開始吧!
把一個給定的 一串字符
看成 輸入 ,編寫一個函數,將傳入字符串 反轉 字符順序後返回。
describe("String Reversal", () => { it("**Should reverse string**", () =\> { assert.equal(reverse("Hello World!"), "!dlroW olleH"); }); }); 複製代碼
分析:
若是咱們知道「技巧」,那麼解決方案就不重要了。技巧就是意識到咱們可使用 數組 的內置方法 reverse
。首先,咱們對 字符串 使用 split
方法生成一個 字符數組 ,而後咱們能夠用 reverse
方法,最後用 join
方法將字符數組從新組合回一個 字符串。這個解決方案能夠用一行代碼來完成!雖然不那麼優雅,但也能夠藉助最新的語法和幫助函數來解決問題。使用新的 for of
循環迭代字符串中的每個字符,能夠展現出咱們對最新語法的熟悉狀況。或者,咱們能夠用數組的 reduce
方法,它使咱們再也不須要保留臨時基元。
對於給定的字符串的每一個字符都要被「訪問」一次。雖然這中訪問會屢次發生,可是 時間 能夠被歸一化爲 線性 時間。而且由於沒有單獨的內部狀態須要被保存,所以 空間 是 恆定 的。
迴文 是指一個 單詞
或 短語
正向和反向 閱讀都是同樣的。寫一個函數來驗證給定輸入值是不是迴文。
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
方法來剔除非字母字符。若是不容許使用正則表達式,咱們就創造一個由 可接受字符 組成的 數組
用做過濾器。
給定一個 整數
, 反轉 數字的順序。
describe("Integer Reversal", () => { it("**Should reverse integer**", () =\> { assert.equal(reverse(1234), 4321); assert.equal(reverse(-1200), -21); }); }); 複製代碼
分析:
這裏的技巧是先把數字經過內置的 toString
方法轉化爲一個 字符串
。而後,咱們能夠簡單的複用 反轉字符串 的算法邏輯。在數字反轉以後,咱們可使用全局的 parseInt
函數將字符串轉換回整數,並使用 Math.sign
來處理數字的符號。這種方法能夠簡化爲一行代碼!
因爲咱們複用了 反轉字符串 的算法邏輯,這個算法的時間和空間的運行時複雜度也與以前相同。
給定一個 數字
做爲輸入值, 打印出從 1 到給定數字的全部整數。 可是,當整數能夠被 2 整除時,打印出「Fizz」; 當它能夠被3整除時,打印出「Buzz」; 當它能夠同時被2和3整除時,打印出「Fizz Buzz」。
分析:
當咱們意識到 模運算符 可用於檢查可分性(是否能被整除)時,這個經典算法的挑戰就變得很是簡單了。模運算符對兩個數字求餘,返回兩數相除的餘數。所以咱們能夠簡單的遍歷每一個整數,檢查它們對二、3整除的餘數是否等於 0
。這展示了咱們的數學功底,由於咱們知道當一個數能夠同時被 a
和 b
整除時,它也能夠被它們的 最小公倍數 整除。
一樣,這個算法的時間和空間的運行時複雜度也與以前相同,由於每個整數都被訪問和檢查過一次但不須要保存內部狀態。
給定一個由字符組成的 字符串
,返回字符串中 出現頻次最高 的 字符
。
describe("Max Character", () => { it("**Should return max character**", () =\> { assert.equal(max("Hello World!"), "l"); }); }); 複製代碼
分析:
這裏的技巧是建立一個表格,用來記錄遍歷字符串時每一個字符出現的次數。這個表格能夠用 對象字面量
來建立,用 字符
做爲對象字面量的 鍵 ,用字符出現的 次數
做爲 值 。而後,咱們遍歷表格,經過一個保存每一個鍵值對的 臨時 變量
來找到出現頻次最大的字符。
雖然咱們使用了兩個獨立的循環來遍歷兩個不一樣的輸入值( 字符串 和 字符映射 ),但時間複雜度仍然是 線性 的。雖然循環是對於字符串,但最終,字符映射的大小會有一個極限,由於任何一種語言的字符都是 有限 個的。出於一樣的緣由,雖然要保存內部狀態,但無論輸入字符串如何增加,空間複雜度也是 恆定 的。臨時基元在大尺度上看也是能夠忽略不計的。
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)
複雜度,由於檢查了兩個不一樣的字符串。
另外一種更優雅的方法是咱們能夠簡單的對輸入值 排序
,而後檢查它們是否相等!然而,它的缺點是排序一般須要 線性 時間。
給定一個 字符串
類型的單詞或短語, 計算 元音
的個數.
describe("Vowels", () => { it("**Should count vowels**", () =\> { assert.equal(vowels("hello world"), 3); }); }); 複製代碼
分析:
最簡單的辦法是使用 正則表達式 取出全部的元音字母,而後計算它們的數量。若是不容許使用正則表達式,咱們能夠簡單的遍歷每個字符,檢查它是不是緣由字母。不過首先要把字符串轉化爲 小寫 。
兩種方法都是 線性 時間複雜度和 恆定 空間複雜度,由於每個字符都須要被檢查一次,而臨時基元能夠忽略不計。
對於一個給定 大小
的 數組
,將數組 元素 分割成一個給定大小的 數組 類型的 列表
。
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遞增。
這些算法都具備 線性 時間複雜度,由於每一個數組項都須要被訪問一次。它們也都有 線性 的空間複雜度,由於須要保存一個內在的 「塊」 類型數組,該數組大小會隨着輸入值變化而變化。
給定一個任意類型的 數組
, 反轉 數組的順序。
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 近似地忽略了係數。
給定一個 詞組
, 反轉 詞組中每一個單詞的字符順序。
describe("Reverse Words", () => { it("**Should reverse words**", () =\> { assert.equal(reverseWords("I love JavaScript!"), "I evol !tpircSavaJ"); }); }); 複製代碼
分析:
咱們可使用split方法建立單個單詞的數組。而後對每個單詞,咱們使用 反轉字符串 的邏輯來反轉它的字符。另外一種方法是 反向 遍歷每一個單詞,並將結果存儲在臨時變量中。不管哪一種方式,咱們都須要暫存全部反轉的單詞,最後再把它們拼接起來。
因爲每個字符都被遍歷了一遍,而且所需的臨時變量大小與輸入字符串成比例,因此時間和空間複雜度都是 線性 的。
給定一個 詞組
,對每個單詞進行 首字母大寫 。
describe("Capitalization", () => { it("**Should capitalize phrase**", () =\> { assert.equal(capitalize("hello world"), "Hello World"); }); }); 複製代碼
分析:
一種解決方法是遍歷每一個字符,當遍歷字符的前一個字符是 空格 時,就對當前字符使用 toUpperCase
方法使其變成大寫。因爲 字符串文字 在 JavaScript 中是 不可變 的,因此咱們須要使用適當的大寫轉化方法重建輸入字符串。這種方法要求咱們始終將第一個字符大寫。另外一種更簡潔的方法是將輸入字符串 split
成一個 由單詞組成的數組 。而後,遍歷這個數組,將每一個元素第一個字符大寫,最後將單詞從新鏈接在一塊兒。出於相同的不可變緣由,咱們須要在內存裏保存一個 臨時數組 來保存被正確大寫的單詞。
兩種方式都是 線性 的時間複雜度,由於每一個字符串都被遍歷了一次。它們也都是 線性 的空間複雜度,由於保存了一個臨時變量,該變量與輸入字符串成比例增加。
給定一個 短語
, 經過將每一個字符 替換 成字母表向前或向後移動一個給定的 整數
的新字符。若有必要,移位應繞回字母表的開頭或結尾。
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時,循環計數到字母表數組的開頭或結尾的行爲。最後,咱們須要在將結果追加到結果字符串以前檢查原始字符串中的大小寫。
因爲須要訪問每個輸入字符串的字符,而且須要根據輸入字符串新建一個結果字符串,所以這個算法的時間和空間複雜度都是 線性 的。
給定一個 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單詞。
給定一個數字組成的 數組
,計算這些數的 平均值 , 中位數 和 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。由於每一個數字都須要在內存中保留和計數,因此這個算法具備 線性 的時間和空間複雜度。
給定一組數字,返回知足「兩數字之和等於給定 和
」的 全部組合 。每一個數字能夠被使用不止一次。
describe("Two Sum", () => { it("**Should implement two sum**", () =\> { assert.deepEqual(twoSum(\[1, 2, 2, 3, 4\], 4), \[\[2, 2\], \[3, 1\]\]); }); }); 複製代碼
分析:
顯而易見的解決方案是建立 嵌套循環 ,該循環檢查每個數字與同組中其餘數字。那些知足求和以後知足給定和的組合能夠被推入到一個 結果數組 中。然而,這種嵌套會引發 指數 型的時間複雜度,這對於大輸入值而言很是不適用。
一個討巧的辦法是在咱們遍歷輸入數組時維護一個包含每一個數字的 「對應物」 的數組,同時檢查每一個數字的對應物是否已經存在。經過維護這樣的數組,咱們犧牲了空間效率來得到 線性 的時間複雜度。
給定一組按照時間順序給出的股票價格,找到 最低 買入價
和 最高 賣出價
使得 利潤最大化 。
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]); }); 複製代碼
分析:
一樣,咱們能夠構建 嵌套循環 ,該循環檢查買入價和賣出價的每種可能組合,看看哪一對產生最大的利潤。實際操做中咱們不能在購買以前出售,因此不是每一個組合都須要被檢查。具體而言,對於給定的買入價格,咱們能夠忽略賣出價格以前的全部價格。所以,該算法的時間複雜度優於 二次 型。
不過稍微考慮一下,咱們能夠對價格數組只使用一次循環來解決問題。關鍵點是要意識到賣價毫不應低於買入價; 若是是這樣,咱們應該以較低的價格購買股票。就是說在代碼中,咱們能夠簡單的維護一個 臨時布爾值 來表示咱們應該在下一次迭代時更改買入價格。這種優雅的方法只須要一個循環,所以具備 線性 的時間複雜度和 恆定 的空間複雜度。
對於給定的 數字
,找到從零到該數字之間的全部 素數 。
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))
。
實現一個返回給定 索引
處的 斐波納契數 的 函數
。
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爲給定索引值),這能夠達到 線性 的時間和空間複雜度。
給斐波納契數列實現一個 高效 的遞歸函數。
describe("Memoized Fibonacci", () => { it("**Should implement memoized fibonacci**", () =\> { assert.equal(fibonacci(6), 8); assert.equal(fibonacci(10), 55); }); }); 複製代碼
分析:
因爲斐波納契數列對本身進行了冗餘的調用,所以它能夠戲劇性的從被稱爲 記憶化 的策略中獲益匪淺。換句話說,若是咱們在調用函數時 緩存 全部的輸入和輸出值,則調用次數將減小到 線性 時間。固然,這意味着咱們犧牲了額外的內存。
在代碼中,咱們能夠在函數自己內部實現 記憶化 技術,或者咱們能夠將它抽象爲高階效用函數,該函數能夠裝飾任何 記憶化 函數。
對於給定長度的 步幅
,使用 # and ‘ ’ 打印出一個 「樓梯」 。
describe("Steps", () => { it("**Should print steps**", () =\> { assert.equal(steps(3), "# \\n## \\n###\\n"); assert.equal(_steps(3), "# \\n## \\n###\\n"); }); }); 複製代碼
分析:
關鍵的看法是要意識到,當咱們向下移動步幅時,#
的數量會不斷 增長 ,而 ' ' 的數量會相應 減小 。若是咱們有 n
步要移動,全局的範圍就是 n
行 n
列。這意味着運行時複雜度對於時間和空間都是 二次 型的。
一樣,咱們發現這也可使用遞歸的方式來解決。除此以外,咱們須要傳遞 額外的參數 來代替必要的臨時變量。
對於給定數量的 階層
,使用 # 和 ' ' 打印出 "金字塔"。
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
遍歷構建出一個金字塔,所以它的運行時時間複雜度和空間複雜度都是 二次
型的。
一樣,咱們能夠發現這裏的遞歸調用可使用以前的方法:須要傳遞一個 附加變量 來代替必要的臨時變量。
建立一個給定 大小
的 方陣 ,使方陣中的元素按照 螺旋順序 排列。
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 自然支持這兩種方案。一般來講,爲了達到更好的性能會犧牲一些空間效率,或者須要犧牲性能來減小內存消耗。正確的平衡二者,須要根據實際上下爲和環境來決定。值得慶幸的是,大多數面書館都更關注 計算推理過程 而不是結果。
爲了給你的面試官留下深入的印象,要儘可能尋找機會利用 架構設計 和 設計模式 來提高 可複用性 和 可維護性 。若是你正在尋找一個資深職位,對基礎知識和第一原則的掌握與系統級設計的經驗一樣重要。不過,最好的公司也會評估 文化契合 度。由於沒有人是完美的,因此合適的團隊是必不可少的。更重要的是,這世上的一些事是不可能憑一己之力達到的。你們共同創造的東西每每是最使人滿意和最有意義的。