最近國內大廠面試中,出現 LeetCode
真題考察的頻率愈來愈高了。我也觀察到有愈來愈多的前端同窗開始關注算法這個話題。前端
可是算法是一個門檻很高的東西,在一個算法新手的眼裏,它的智商門檻要求很高。事實上是這個樣子的嗎?若是你懷疑本身的智商不夠去學習算法,那麼你必定要先看完這篇文章:《天生不聰明》,也正是這篇文章激勵了我開始了算法之路。node
這篇文章,我會先總結幾個必學的題目分類,給出這個分類下必作例題的詳細題解,而且在文章的末尾給出每一個分類下必刷的題目的獲取方式。面試
必定要耐心看到底,會有重磅乾貨。算法
我從 5 月份準備離職的時候開始學習算法,在此以前對於算法我是零基礎,在最開始我對於算法的感覺也和你們同樣,以爲本身智商可能不夠,望而卻步。可是看了一些大佬對於算法和智商之間的關係,我發現算法好像也是一個經過練習能夠慢慢成長的學科,而不是隻有智商達到了某個點纔能有入場券,因此我開始了個人算法之路。經過視頻課程 + 分類刷題 + 總結題解 + 回頭複習的方式,我在兩個月的時間裏把力扣的解題數量刷到了200題。對於一個算法新人來講,這應該算是一個還能夠的成績,這篇文章,我把我總結的一些學習心得,和一些經典例題分享給你們。數組
分類刷題:不少第一次接觸力扣的同窗對於刷題的方法不太瞭解,有的人跟着題號刷,有的人跟着每日一題刷,可是這種漫無目的的刷題方式通常都會在中途某一天放棄,或者刷了好久可是卻發現沒什麼沉澱。這裏不囉嗦,直接點明一個全部大佬都推薦的刷題方法:把本身的學習階段分散成幾個時間段去刷不一樣分類的題型,好比第一週專門解鏈表相關題型,第二週專門解二叉樹相關題型。這樣你的知識會造成一個體系,經過一段時間的刻意練習把這個題型相關的知識點強化到你的腦海中,不容易遺忘。緩存
適當放棄:不少同窗遇到一個難題,非得埋頭鑽研,幹他 2 個小時。最後挫敗感十足,長此以往可能就放棄了算法之路。要知道算法是個沉澱了幾十年的領域,題解裏的某個算法多是某些教授研究不少年的心血,你想靠本身一個新手去想出來同等優秀的解法,豈不是想太多了。因此要學會適當放棄,通常來講,比較有目的性(面試)刷題的同窗,他面對一道新的題目毫無頭緒的話,會在 10 分鐘以內直接放棄去看題解,而後記錄下來,反覆複習,直到這個解法成爲本身的知識爲止。這是效率最高的學習辦法。安全
接受本身是新手:沒錯,說的難聽一點,接受本身不是天才這個現實。你在刷題的過程當中會遇到不少困擾你的時候,好比相同的題型已經看過例題,稍微變了條件就解不出來。或者對於一個 easy
難度的題毫無頭緒。或者甚至看不懂別人的題解(沒錯我常常)相信我,這很正常,不能說明你不適合學習算法,只能說明算法確實是一個博大精深的領域,把本身在其餘領域的沉澱拋開來,接受本身是新手這個事實,多看題解,多請教別人。bash
接下來我會放出幾個分類的經典題型,以及我對應的講解,當作開胃菜,而且在文章的末尾我會給出獲取每一個分類推薦你去刷的題目的合集,記得看到底哦。cookie
兩個數組的交集 II-350網絡
給定兩個數組,編寫一個函數來計算它們的交集。
示例 1:
輸入: nums1 = [1,2,2,1], nums2 = [2,2]
輸出: [2,2]
示例 2:
輸入: nums1 = [4,9,5], nums2 = [9,4,9,8,4]
輸出: [4,9]
複製代碼
來源:力扣(LeetCode)
連接:leetcode-cn.com/problems/in…
著做權歸領釦網絡全部。商業轉載請聯繫官方受權,非商業轉載請註明出處。
爲兩個數組分別創建 map,用來存儲 num -> count 的鍵值對,統計每一個數字出現的數量。
而後對其中一個 map 進行遍歷,查看這個數字在兩個數組中分別出現的數量,取出現的最小的那個數量(好比數組 1 中出現了 1 次,數組 2 中出現了 2 次,那麼交集應該取 1 次),push 到結果數組中便可。
/** * @param {number[]} nums1 * @param {number[]} nums2 * @return {number[]} */
let intersect = function (nums1, nums2) {
let map1 = makeCountMap(nums1)
let map2 = makeCountMap(nums2)
let res = []
for (let num of map1.keys()) {
const count1 = map1.get(num)
const count2 = map2.get(num)
if (count2) {
const pushCount = Math.min(count1, count2)
for (let i = 0; i < pushCount; i++) {
res.push(num)
}
}
}
return res
}
function makeCountMap(nums) {
let map = new Map()
for (let i = 0; i < nums.length; i++) {
let num = nums[i]
let count = map.get(num)
if (count) {
map.set(num, count + 1)
} else {
map.set(num, 1)
}
}
return map
}
複製代碼
最接近的三數之和-16
給定一個包括 n 個整數的數組 nums 和 一個目標值 target。找出 nums 中的三個整數,使得它們的和與 target 最接近。返回這三個數的和。假定每組輸入只存在惟一答案。
示例:
輸入:nums = [-1,2,1,-4], target = 1
輸出:2
解釋:與 target 最接近的和是 2 (-1 + 2 + 1 = 2) 。
複製代碼
提示:
3 <= nums.length <= 10^3
-10^3 <= nums[i] <= 10^3
-10^4 <= target <= 10^4
來源:力扣(LeetCode)
連接:leetcode-cn.com/problems/3s…
著做權歸領釦網絡全部。商業轉載請聯繫官方受權,非商業轉載請註明出處。
先按照升序排序,而後分別從左往右依次選擇一個基礎點 i
(0 <= i <= nums.length - 3
),在基礎點的右側用雙指針去不斷的找最小的差值。
假設基礎點是 i
,初始化的時候,雙指針分別是:
left
:i + 1
,基礎點右邊一位。right
: nums.length - 1
數組最後一位。而後求此時的和,若是和大於 target
,那麼能夠把右指針左移一位,去試試更小一點的值,反之則把左指針右移。
在這個過程當中,不斷更新全局的最小差值 min
,和此時記錄下來的和 res
。
最後返回 res
便可。
/** * @param {number[]} nums * @param {number} target * @return {number} */
let threeSumClosest = function (nums, target) {
let n = nums.length
if (n === 3) {
return getSum(nums)
}
// 先升序排序 此爲解題的前置條件
nums.sort((a, b) => a - b)
let min = Infinity // 和 target 的最小差
let res
// 從左往右依次嘗試定一個基礎指針 右邊至少再保留兩位 不然沒法湊成3個
for (let i = 0; i <= nums.length - 3; i++) {
let basic = nums[i]
let left = i + 1 // 左指針先從 i 右側的第一位開始嘗試
let right = n - 1 // 右指針先從數組最後一項開始嘗試
while (left < right) {
let sum = basic + nums[left] + nums[right] // 三數求和
// 更新最小差
let diff = Math.abs(sum - target)
if (diff < min) {
min = diff
res = sum
}
if (sum < target) {
// 求出的和若是小於目標值的話 能夠嘗試把左指針右移 擴大值
left++
} else if (sum > target) {
// 反之則右指針左移
right--
} else {
// 相等的話 差就爲0 必定是答案
return sum
}
}
}
return res
}
function getSum(nums) {
return nums.reduce((total, cur) => total + cur, 0)
}
複製代碼
無重複字符的最長子串-3
給定一個字符串,請你找出其中不含有重複字符的 最長子串 的長度。
示例 1:
輸入: "abcabcbb"
輸出: 3
解釋: 由於無重複字符的最長子串是 "abc",因此其長度爲 3。
複製代碼
示例 2:
輸入: "bbbbb"
輸出: 1
解釋: 由於無重複字符的最長子串是 "b",因此其長度爲 1。
複製代碼
示例 3:
輸入: "pwwkew"
輸出: 3
解釋: 由於無重複字符的最長子串是 "wke",因此其長度爲 3。
請注意,你的答案必須是 子串 的長度,"pwke" 是一個子序列,不是子串。
複製代碼
來源:力扣(LeetCode) 連接:leetcode-cn.com/problems/lo… 著做權歸領釦網絡全部。商業轉載請聯繫官方受權,非商業轉載請註明出處。
這題是比較典型的滑動窗口問題,定義一個左邊界 left
和一個右邊界 right
,造成一個窗口,而且在這個窗口中保證不出現重複的字符串。
這須要用到一個新的變量 freqMap
,用來記錄窗口中的字母出現的頻率數。在此基礎上,先嚐試取窗口的右邊界再右邊一個位置的值,也就是 str[right + 1]
,而後拿這個值去 freqMap
中查找:
right ++
,擴大窗口右邊界。left ++
,縮進左邊界,而且記得把 str[left]
位置的值在 freqMap
中減掉。循環條件是 left < str.length
,容許左邊界一直滑動到字符串的右界。
/** * @param {string} s * @return {number} */
let lengthOfLongestSubstring = function (str) {
let n = str.length
// 滑動窗口爲s[left...right]
let left = 0
let right = -1
let freqMap = {} // 記錄當前子串中下標對應的出現頻率
let max = 0 // 找到的知足條件子串的最長長度
while (left < n) {
let nextLetter = str[right + 1]
if (!freqMap[nextLetter] && nextLetter !== undefined) {
freqMap[nextLetter] = 1
right++
} else {
freqMap[str[left]] = 0
left++
}
max = Math.max(max, right - left + 1)
}
return max
}
複製代碼
兩兩交換鏈表中的節點-24
給定一個鏈表,兩兩交換其中相鄰的節點,並返回交換後的鏈表。
你不能只是單純的改變節點內部的值,而是須要實際的進行節點交換。
示例:
給定 1->2->3->4, 你應該返回 2->1->4->3.
複製代碼
來源:力扣(LeetCode)
連接:leetcode-cn.com/problems/sw…
著做權歸領釦網絡全部。商業轉載請聯繫官方受權,非商業轉載請註明出處。
這題本意比較簡單,1 -> 2 -> 3 -> 4
的狀況下能夠定義一個遞歸的輔助函數 helper
,這個輔助函數對於節點和它的下一個節點進行交換,好比 helper(1)
處理 1 -> 2
,而且把交換變成 2 -> 1
的尾節點 1
的next
繼續指向 helper(3)
也就是交換後的 4 -> 3
。
邊界狀況在於,若是順利的做了兩兩交換,那麼交換後咱們的函數返回出去的是 交換後的頭部節點,可是若是是奇數剩餘項的狀況下,沒辦法作交換,那就須要直接返回 本來的頭部節點。這個在 helper
函數和主函數中都有體現。
let swapPairs = function (head) {
if (!head) return null
let helper = function (node) {
let tempNext = node.next
if (tempNext) {
let tempNextNext = node.next.next
node.next.next = node
if (tempNextNext) {
node.next = helper(tempNextNext)
} else {
node.next = null
}
}
return tempNext || node
}
let res = helper(head)
return res || head
}
複製代碼
二叉樹的全部路徑-257
給定一個二叉樹,返回全部從根節點到葉子節點的路徑。
說明: 葉子節點是指沒有子節點的節點。
示例:
輸入:
1
/ \
2 3
\
5
輸出: ["1->2->5", "1->3"]
解釋: 全部根節點到葉子節點的路徑爲: 1->2->5, 1->3
複製代碼
來源:力扣(LeetCode)
連接:leetcode-cn.com/problems/bi…
著做權歸領釦網絡全部。商業轉載請聯繫官方受權,非商業轉載請註明出處。
用當前節點的值去拼接左右子樹遞歸調用當前函數得到的全部路徑。
也就是根節點拼上以左子樹爲根節點獲得的路徑,加上根節點拼上以右子樹爲根節點獲得的全部路徑。
直到葉子節點,僅僅返回包含當前節點的值的數組。
let binaryTreePaths = function (root) {
let res = []
if (!root) {
return res
}
if (!root.left && !root.right) {
return [`${root.val}`]
}
let leftPaths = binaryTreePaths(root.left)
let rightPaths = binaryTreePaths(root.right)
leftPaths.forEach((leftPath) => {
res.push(`${root.val}->${leftPath}`)
})
rightPaths.forEach((rightPath) => {
res.push(`${root.val}->${rightPath}`)
})
return res
}
複製代碼
在每一個樹行中找最大值-515
您須要在二叉樹的每一行中找到最大的值。
輸入:
1
/ \
3 2
/ \ \
5 3 9
輸出: [1, 3, 9]
複製代碼
這是一道典型的 BFS 題目,BFS 的套路其實就是維護一個 queue 隊列,在讀取子節點的時候同時把發現的孫子節點 push 到隊列中,可是先不處理,等到這一輪隊列中的子節點處理完成之後,下一輪再繼續處理的就是孫子節點了,這就實現了層序遍歷,也就是一層層的去處理。
可是這裏有一個問題卡住我了一會,就是如何知道當前處理的節點是哪一個層級的,在最開始的時候我嘗試寫了一下二叉樹求某個 index 所在層級的公式,可是發現這種公式只能處理「平衡二叉樹」。
後面看題解發現他們都沒有專門維護層級,再仔細一看才明白層級的思路:
其實就是在每一輪 while 循環裏,再開一個 for 循環,這個 for 循環的終點是「提早緩存好的 length 快照」,也就是進入這輪 while 循環時,queue 的長度。其實這個長度就剛好表明了「一個層級的長度」。
緩存後,for 循環裏能夠安全的把子節點 push 到數組裏而不影響緩存的當前層級長度。
另外有一個小 tips,在 for 循環處理完成後,應該要把 queue 的長度截取掉上述的緩存長度。一開始我使用的是 queue.splice(0, len)
,結果速度只擊敗了 33%的人。後面換成 for 循環中去一個一個shift
來截取,速度就擊敗了 77%的人。
/** * @param {TreeNode} root * @return {number[]} */
let largestValues = function (root) {
if (!root) return []
let queue = [root]
let maximums = []
while (queue.length) {
let max = Number.MIN_SAFE_INTEGER
// 這裏須要先緩存length 這個length表明當前層級的全部節點
// 在循環開始後 會push新的節點 length就不穩定了
let len = queue.length
for (let i = 0; i < len; i++) {
let node = queue[i]
max = Math.max(node.val, max)
if (node.left) {
queue.push(node.left)
}
if (node.right) {
queue.push(node.right)
}
}
// 本「層級」處理完畢,截取掉。
for (let i = 0; i < len; i++) {
queue.shift()
}
// 這個for循環結束後 表明當前層級的節點所有處理完畢
// 直接把計算出來的最大值push到數組裏便可。
maximums.push(max)
}
return maximums
}
複製代碼
有效的括號-20
給定一個只包括 '(',')','{','}','[',']'
的字符串,判斷字符串是否有效。
有效字符串需知足:
示例 1:
輸入: "()"
輸出: true
複製代碼
示例 2:
輸入: "()[]{}"
輸出: true
複製代碼
示例 3:
輸入: "(]"
輸出: false
複製代碼
示例 4:
輸入: "([)]"
輸出: false
複製代碼
示例 5:
輸入: "{[]}"
輸出: true
複製代碼
提早記錄好左括號類型 (, {, [
和右括號類型), }, ]
的映射表,當遍歷中遇到左括號的時候,就放入棧 stack
中(其實就是數組),當遇到右括號時,就把 stack
頂的元素 pop
出來,看一下是不是這個右括號所匹配的左括號(好比 (
和 )
是一對匹配的括號)。
當遍歷結束後,棧中不該該剩下任何元素,返回成功,不然就是失敗。
/** * @param {string} s * @return {boolean} */
let isValid = function (s) {
let sl = s.length
if (sl % 2 !== 0) return false
let leftToRight = {
"{": "}",
"[": "]",
"(": ")",
}
// 創建一個反向的 value -> key 映射表
let rightToLeft = createReversedMap(leftToRight)
// 用來匹配左右括號的棧
let stack = []
for (let i = 0; i < s.length; i++) {
let bracket = s[i]
// 左括號 放進棧中
if (leftToRight[bracket]) {
stack.push(bracket)
} else {
let needLeftBracket = rightToLeft[bracket]
// 左右括號都不是 直接失敗
if (!needLeftBracket) {
return false
}
// 棧中取出最後一個括號 若是不是須要的那個左括號 就失敗
let lastBracket = stack.pop()
if (needLeftBracket !== lastBracket) {
return false
}
}
}
if (stack.length) {
return false
}
return true
}
function createReversedMap(map) {
return Object.keys(map).reduce((prev, key) => {
const value = map[key]
prev[value] = key
return prev
}, {})
}
複製代碼
直接看我寫的這兩篇文章便可,遞歸與回溯甚至是日常業務開發中最多見的算法場景之一了,因此我重點總結了兩篇文章。
《前端電商 sku 的全排列算法很難嗎?學會這個套路,完全掌握排列組合。》
打家劫舍 - 198
你是一個專業的小偷,計劃偷竊沿街的房屋。每間房內都藏有必定的現金,影響你偷竊的惟一制約因素就是相鄰的房屋裝有相互連通的防盜系統,若是兩間相鄰的房屋在同一夜被小偷闖入,系統會自動報警。
給定一個表明每一個房屋存放金額的非負整數數組,計算你在不觸動警報裝置的狀況下,可以偷竊到的最高金額。
示例 1:
輸入: [1,2,3,1]
輸出: 4
解釋: 偷竊 1 號房屋 (金額 = 1) ,而後偷竊 3 號房屋 (金額 = 3)。
偷竊到的最高金額 = 1 + 3 = 4 。
示例 2:
輸入: [2,7,9,3,1]
輸出: 12
解釋: 偷竊 1 號房屋 (金額 = 2), 偷竊 3 號房屋 (金額 = 9),接着偷竊 5 號房屋 (金額 = 1)。
偷竊到的最高金額 = 2 + 9 + 1 = 12 。
複製代碼
來源:力扣(LeetCode) 連接:leetcode-cn.com/problems/ho… 著做權歸領釦網絡全部。商業轉載請聯繫官方受權,非商業轉載請註明出處。
動態規劃的一個很重要的過程就是找到「狀態」和「狀態轉移方程」,在這個問題裏,設 i
是當前屋子的下標,狀態就是 以 i 爲起點偷竊的最大價值
在某一個房子面前,盜賊只有兩種選擇:偷或者不偷。
在這兩個值中,選擇最大值記錄在 dp[i]
中,就獲得了以 i
爲起點所能偷竊的最大價值。。
動態規劃的起手式,找基礎狀態,在這題中,以終點爲起點的最大價值必定是最好找的,由於終點不可能再繼續日後偷竊了,因此設 n
爲房子的總數量, dp[n - 1]
就是 nums[n - 1]
,小偷只能選擇偷竊這個房子,而不能跳過去選擇下一個不存在的房子。
那麼就找到了動態規劃的狀態轉移方程:
// 搶劫當前房子
robNow = nums[i] + dp[i + 2] // 「當前房子的價值」 + 「i + 2 下標房子爲起點的最大價值」
// 不搶當前房子,搶下一個房子
robNext = dp[i + 1] //「i + 1 下標房子爲起點的最大價值」
// 二者選擇最大值
dp[i] = Math.max(robNow, robNext)
複製代碼
,而且從後往前求解。
function (nums) {
if (!nums.length) {
return 0;
}
let dp = [];
for (let i = nums.length - 1; i >= 0; i--) {
let robNow = nums[i] + (dp[i + 2] || 0)
let robNext = dp[i + 1] || 0
dp[i] = Math.max(robNow, robNext)
}
return dp[0];
};
複製代碼
最後返回 以 0 爲起點開始打劫的最大價值 便可。
分發餅乾-455
假設你是一位很棒的家長,想要給你的孩子們一些小餅乾。可是,每一個孩子最多隻能給一塊餅乾。對每一個孩子 i ,都有一個胃口值 gi ,這是能讓孩子們知足胃口的餅乾的最小尺寸;而且每塊餅乾 j ,都有一個尺寸 sj 。若是 sj >= gi ,咱們能夠將這個餅乾 j 分配給孩子 i ,這個孩子會獲得知足。你的目標是儘量知足越多數量的孩子,並輸出這個最大數值。
注意:
你能夠假設胃口值爲正。 一個小朋友最多隻能擁有一塊餅乾。
示例 1:
輸入: [1,2,3], [1,1]
輸出: 1
解釋:
你有三個孩子和兩塊小餅乾,3個孩子的胃口值分別是:1,2,3。
雖然你有兩塊小餅乾,因爲他們的尺寸都是1,你只能讓胃口值是1的孩子知足。
因此你應該輸出1。
示例 2:
輸入: [1,2], [1,2,3]
輸出: 2
解釋:
你有兩個孩子和三塊小餅乾,2個孩子的胃口值分別是1,2。
你擁有的餅乾數量和尺寸都足以讓全部孩子知足。
因此你應該輸出2.
複製代碼
來源:力扣(LeetCode) 連接:leetcode-cn.com/problems/as… 著做權歸領釦網絡全部。商業轉載請聯繫官方受權,非商業轉載請註明出處。
把餅乾和孩子的需求都排序好,而後從最小的餅乾分配給需求最小的孩子開始,不斷的嘗試新的餅乾和新的孩子,這樣能保證每一個分給孩子的餅乾都恰到好處的不浪費,又知足需求。
利用雙指針不斷的更新 i
孩子的需求下標和 j
餅乾的值,直到二者有其一達到了終點位置:
j++
尋找下一個餅乾,不用擔憂這個餅乾被浪費,由於這個餅乾更不可能知足下一個孩子(胃口更大)。i++; j++; count++
記錄當前的成功數量,繼續尋找下一個孩子和下一個餅乾。/** * @param {number[]} g * @param {number[]} s * @return {number} */
let findContentChildren = function (g, s) {
g.sort((a, b) => a - b)
s.sort((a, b) => a - b)
let i = 0
let j = 0
let count = 0
while (j < s.length && i < g.length) {
let need = g[i]
let cookie = s[j]
if (cookie >= need) {
count++
i++
j++
} else {
j++
}
}
return count
}
複製代碼
其實寫了這麼多,以上分類所提到的題目,只是當前分類下比較適合做爲例題來說解的題目而已,在整個 LeetCode
學習過程當中只是冰山一角。這些題能夠做爲你深刻這個分類的一個入門例題,可是不可避免的是,你必須去下苦功夫刷每一個分類下的其餘經典題目。
若是你信任我,你也能夠點擊這裏 獲取各個分類下必作題目的詳細題解,我跟着一個ACM 亞洲區獎牌得到者給出的提綱,整理了100+道必作題目的詳細題解。
那麼什麼叫必作題目呢?
固然你也能夠去知乎等平臺搜索相關的問題,也會有不少人總結,可是比我總結的全的很少見。100 多題說多也很少,說少也很多。認真學習、解答、吸取這些題目大概要花費1 個月左右的時間。可是相信我,1 個月之後你在算法方面會脫胎換骨,應對國內大廠的算法面試也會變得遊刃有餘。
關於算法在工程方面有用與否的爭論,已是一個經久不衰的話題了。這裏不討論這個,我我的的觀念是絕對有用的,只要你不是一個甘於只作簡單需求的人,你必定會在後續開發架構、遇到難題的過程當中或多或少的從你的算法學習中受益。
再說的功利點,就算是爲了面試,刷算法可以進入大廠也是你職業生涯的一個起飛點,大廠給你帶來的的環境、嚴格的 Code Review
、完善的導師機制和協做流程也是你做爲工程師所求之不得的。
但願這篇文章能讓你再也不繼續懼怕算法面試,跟着我一塊兒攻下這座城堡吧,你們加油!
1.若是本文對你有幫助,就點個贊支持下吧,你的「贊」是我創做的動力。
2.關注公衆號「前端從進階到入院」便可加我好友,我拉你進「前端進階交流羣」,你們一塊兒共同交流和進步。