由于面试需要,决定从零开始刷 LeetCode Hot 100,记录一下刷题过程。

问题都很长,所以只记录题目和解题思路,当前进度(32/100 + 6)上次更新 7.28

准备

先简单了解下数据结构,很不错的视频

你是天才,所以不用学数据结构 | bilibili

然后上 LeetCode 注册账号,开始刷题。

哈希

1. 两数之和

最直接的是使用两层 for 循环,遍历判断两数之和是否等于 target,时间复杂度 O(n^2)

更好的思路是使用哈希表(Map),A + B = C,这里是去找 C - A 的值,如果这个值在哈希表中存在,就说明找到了 A 和 B,时间复杂度为 O(n)

/**
* @param {number[]} nums
* @param {number} target
* @return {number[]}
*/
var twoSum = function (nums, target) {
const map = new Map()
for (let i = 0; i < nums.length; i++) {
const need = target - nums[i]
if (map.has(need)) return [map.get(need), i]
map.set(nums[i], i)
}
return []
}

49. 字母异位词分组

字母异位词就是两个单词的组成字母完全相同,如 abc 和 cba 是异位词

思路:对每个字符串字母排序,判断排序后字符串是否相等,然后对原字符进行分组

思路有了就找结构,要存原字符串,然后原字符串要对应排序后的字符串,所以哈希表(Map)最好不过了

/**
* @param {string[]} strs
* @return {string[][]}
*/
var groupAnagrams = function (strs) {
const map = new Map()
for (const s of strs) {
const key = s.split('').sort().join('')
if (!map.has(key)) {
map.set(key, [])
}
map.get(key).push(s)
}
return [...map.values()]
}

128. 最长连续序列

思路:先对数组去重排序,存两个值:一个当前连续长度,一个最长连续长度,然后遍历一遍新数组,连续的就加当前长度,不连续就重置当前长度,最后返回最长长度

这里去重用的是 Set 结构,去除重复元素,排序用数组的 sort 方法。

/**
* @param {number[]} nums
* @return {number}
*/
var longestConsecutive = function (nums) {
if (nums.length === 0) return 0
const snums = [...new Set(nums)].sort((a, b) => a - b)
let current = 1
let result = 1
for (let i = 0; i < snums.length - 1; i++) {
const a = snums[i]
const b = snums[i + 1]
if (b - a === 1) {
current++
result = Math.max(current, result)
} else {
current = 1
}
}
return result
}

最开始漏掉了判断数组长度为 0 的情况,每道题都应该去考虑边界情况。

这个能够 AC(Accepted),但是时间复杂度是 O(n logn),题目要求 O(n) 的时间复杂度,所以这个解法不符合要求。

看了题解,是在判断是否连续时,判断当前元素的上一个元素是否存在,如果不存在则说明当前元素是连续序列的起点,然后从当前元素去向后找连续的元素。

更改后:

/**
* @param {number[]} nums
* @return {number}
*/
var longestConsecutive = function (nums) {
if (nums.length === 0) return 0
const set = new Set(nums)
let longest = 0
for (const num of set) {
// 连续起始
if (!set.has(num - 1)) {
let current = num
let length = 1
while (set.has(current + 1)) {
current++
length++
}
longest = Math.max(length, longest)
}
}
return longest
}

双指针

283. 移动零

思路:将所有零后移,可以使用双指针,一个指针遍历数组,另一个指针记录非零元素位置,当遍历到非零元素时,将其放到非零元素位置指针上,然后指针后移,直到遍历完数组,最后将非零元素位置指针之后的元素全部设为 0。

/**
* @param {number[]} nums
* @return {void} Do not return anything, modify nums in-place instead.
*/
var moveZeroes = function (nums) {
let slow = 0
for (let fast = 0; fast < nums.length; fast++) {
if (nums[fast] !== 0) {
nums[slow] = nums[fast]
slow++
}
}
while (slow < nums.length) {
nums[slow] = 0
slow++
}
}

11. 盛最多水的容器

暴力思路:容积最大,就是 x * y 最大,那就挨个遍历所有可能的组合,取最大值,时间复杂度 O(n^2)

/**
* @param {number[]} height
* @return {number}
*/
var maxArea = function (height) {
let max = 0
for (let i = 0; i < height.length; i++) {
for (let j = i + 1; j < height.length; j++) {
const x = j - i
const y = Math.min(height[i], height[j])
max = Math.max(max, x * y)
}
}
return max
}

这个解法最无脑,但是超时了

思路:原理依旧是 x * y 最大,使用双指针分别指向最左侧和最右侧,这样的话就是 x 最大,接下来指针向内移动,每次移动 x 都会减小,所以要让 y 尽可能大,将两个指针所在的高度比较,将较小的指针向内移动,直到两个指针相遇,时间复杂度 O(n)

/**
* @param {number[]} height
* @return {number}
*/
var maxArea = function (height) {
let left = 0
let right = height.length - 1
let ans = 0
while (left < right) {
const h = Math.min(height[left], height[right])
const w = right - left
ans = Math.max(ans, h * w)
if (height[left] < height[right]) {
left++
} else {
right--
}
}
return ans
}

这里用到了贪心算法,贪心算法就是局部最优 -> 全局最优。

15. 三数之和

虽然这个依然能暴力,但是 3 个 for 循环,时间复杂度飙到 O(n^3)了,所以还是要用双指针。

思路:固定一个数,然后使用双指针找另外两个数,跟 11. 盛最多水的容器 类似,两个指针向内移动。

容易漏的点是去重,外层 for 索引和两个指针都要去重。

/**
* @param {number[]} nums
* @return {number[][]}
*/
var threeSum = function (nums) {
const ans = []
let snums = nums.sort((a, b) => a - b)
for (let i = 0; i < snums.length; i++) {
if (i > 0 && snums[i] === snums[i - 1]) continue
const a = snums[i]
let left = i + 1
let right = snums.length - 1
while (left < right) {
const b = snums[left]
const c = snums[right]
const sum = a + b + c
if (sum === 0) {
ans.push([a, b, c])
while (left < right && snums[left] === snums[left + 1]) left++
while (left < right && snums[right] === snums[right - 1]) right--
left++
right--
} else if (sum < 0) {
left++
} else {
right--
}
}
}
return ans
}

42. 接雨水

思路:每个位置的雨水是被两侧围住的,能分析出来每个位置的雨水量 = min(左边最高高度, 右边最高高度) - 当前高度。

暴力解法是每个位置向两侧遍历找最高高度,时间复杂度 O(n^2);稍微好一点的话,遍历一遍数组,用两个数组分别存储每个位置的左边最高高度和右边最高高度,然后再遍历一遍数组计算雨水量,时间复杂度 O(n)。

更好的办法是使用双指针,两个指针分别指向数组的两端,在线更新左右两侧的最高高度,移动较低的指针,直到两个指针相遇。因为从最左侧和从最右侧出发,对于一侧经过的位置是逐渐增加的,所以可以在线更新最高高度。

/**
* @param {number[]} height
* @return {number}
*/
var trap = function (height) {
let l = 0
let r = height.length - 1
let leftMax = 0
let rightMax = 0
let ans = 0
while (l < r) {
if (height[l] <= height[r]) {
if (height[l] >= leftMax) {
leftMax = height[l]
} else {
ans += leftMax - height[l]
}
l++
} else {
if (height[r] >= rightMax) {
rightMax = height[r]
} else {
ans += rightMax - height[r]
}
r--
}
}
return ans
}

滑动窗口

3. 无重复字符的最长子串

思路:滑动窗口,[left, right],找连续无重复最长子串,让 left 先固定,right 向右移动,right 移动过程用 Map 记录字符索引,这样当 right 移动到重复字符时,将 left 移动到重复字符的下一个位置,名副其实的 “滑动窗口”。

有个容易漏的点,移动 left 应该比较重复字符的索引和 left 当前大小,取最大值,这样才能保证 left 不会回退过去。

/**
* @param {string} s
* @return {number}
*/
var lengthOfLongestSubstring = function (s) {
if (s.length <= 1) return s.length
let ans = 0
const map = new Map()
let left = 0
for (let right = 0; right < s.length; right++) {
if (map.has(s[right])) {
left = Math.max(left, map.get(s[right]) + 1)
}
map.set(s[right], right)
ans = Math.max(ans, right - left + 1)
}
return ans
}

438. 找到字符串中所有字母异位词

首先是最无脑打法:

/**
* @param {string} s
* @param {string} p
* @return {number[]}
*/
var findAnagrams = function (s, p) {
const n = p.length
const target = p.split('').sort().join()
let left = 0
const ans = []
for (let right = n - 1; right < s.length; right++) {
if (
s
.slice(left, right + 1)
.split('')
.sort()
.join() == target
) {
ans.push(left)
}
left++
}
return ans
}

提交运行,超出时间限制,只好另寻方法

看了题解,使用滑动窗口的同时统计字符频次,一个 need Map + 一个 window Map + 一个 valid 计数器,滑动窗口的同时更新 window Map,然后对 valid++ 或 valid—,当 valid === need Map 大小 (need.size) 时,说明找到了一个异位词。

/**
* @param {string} s
* @param {string} p
* @return {number[]}
*/
var findAnagrams = function (s, p) {
const ans = []
const need = new Map()
const window = new Map()
for (const ch of p) {
need.set(ch, (need.get(ch) || 0) + 1)
}
let left = 0
let right = 0
let valid = 0
while (right < s.length) {
const ch = s[right]
if (need.has(ch)) {
window.set(ch, (window.get(ch) || 0) + 1)
if (need.get(ch) === window.get(ch)) {
valid++
}
}
right++
if (right - left === p.length) {
if (valid === need.size && right - left === p.length) {
ans.push(left)
}
const l = s[left]
if (need.has(l)) {
if (need.get(l) === window.get(l)) {
valid--
}
window.set(l, window.get(l) - 1)
}
left++
}
}
return ans
}

子串

560. 和为 K 的子数组

思路:前缀和 + 哈希表,遍历数组,计算前缀和 sum,同时把前缀和记录到哈希表,值是出现的次数,然后判断 sum - k 是否在哈希表中(看到了两数之和的影子),如果存在就 ans++。

/**
* @param {number[]} nums
* @param {number} k
* @return {number}
*/
var subarraySum = function (nums, k) {
let ans = 0
let sum = 0
const map = new Map()
map.set(0, 1)
for (const num of nums) {
sum += num
const target = sum - k
if (map.has(target)) {
ans += map.get(target)
}
map.set(sum, (map.get(sum) || 0) + 1)
}
return ans
}

239. 滑动窗口最大值

~~休息一会~~ 休息完了,先来个暴力解法(bushi)

思路:又是新东西,这次用单调递减队列 q(ueue) 去获取最大值,最开始是用队列存的值,结果发现如果有重复值,删除的时候就会出问题,改成存索引简直完美。新增元素时,从队尾不断向前比较,然后填入元素,同时根据当前最大值的索引判断是否过期,在窗口成型后,每次推送 q[0] 的值到结果数组中。

/**
* @param {number[]} nums
* @param {number} k
* @return {number[]}
*/
var maxSlidingWindow = function (nums, k) {
const n = nums.length
if (n === 0 || k === 0) return []
const res = []
const q = []
for (let i = 0; i < n; i++) {
while (q.length && nums[i] >= nums[q[q.length - 1]]) {
q.pop()
}
q.push(i)
if (q[0] <= i - k) {
q.shift()
}
if (i >= k - 1) {
res.push(nums[q[0]])
}
}
return res
}

76. 最小覆盖子串

思路:这一个跟找字母异位词类似…依旧使用滑动窗口 + 哈希表,统计字符频次,一个 need Map + 一个 window Map + 一个 valid 计数器,滑动窗口的同时更新 window Map,然后对 valid++ 或 valid—,当 valid === need Map 大小 (need.size) 时,说明找到了一个覆盖子串,然后左侧不断收缩,记录最小长度,直到 valid !== need.size 跳出循环,继续右侧扩张。

/**
* @param {string} s
* @param {string} t
* @return {string}
*/
var minWindow = function (s, t) {
if (s.length === 0 || t.length > s.length) return ''
const need = new Map()
const window = new Map()
let valid = 0
let start = 0
let minLen = Infinity
for (const s of t) {
need.set(s, (need.get(s) || 0) + 1)
}
let left = 0
let right = 0
while (right < s.length) {
const b = s[right]
if (need.has(b)) {
window.set(b, (window.get(b) || 0) + 1)
if (need.get(b) === window.get(b)) {
valid++
}
}
right++
while (valid === need.size) {
if (right - left < minLen) {
minLen = right - left
start = left
}
const a = s[left]
if (need.has(a)) {
if (window.get(a) === need.get(a)) {
valid--
}
window.set(a, window.get(a) - 1)
}
left++
}
}
if (minLen === Infinity) return ''
return s.substring(start, start + minLen)
}

普通数组

53. 最大子数组和

思路:找到连续子数组最大的和,想到前缀和,这个和一定是一个前缀和减去前面最小的前缀和。那就很简单了,for 遍历一遍数组,计算前缀和 pre,同时记录最小前缀和 min,取 pre - min 的最大值。

/**
* @param {number[]} nums
* @return {number}
*/
var maxSubArray = function (nums) {
let max = -Infinity
let min = 0
let pre = 0
for (let i = 0; i < nums.length; i++) {
pre += nums[i]
max = Math.max(max, pre - min)
min = Math.min(min, pre)
}
return max
}

还有一种思路是动态规划,dp[i] 表示以 nums[i] 结尾的最大子数组和,那么 dp[i] = max(dp[i - 1] + nums[i], nums[i]),也就是让当前元素加上前一个最大子数组和,与当前元素本身比较,取最大值。有点像走楼梯问题,走楼梯只能走一阶或者两阶,那么走最后一阶的时候一定是从倒数第二阶或倒数第一阶走上来的。

/**
* @param {number[]} nums
* @return {number}
*/
var maxSubArray = function (nums) {
let cur = nums[0]
let res = nums[0]
for (let i = 1; i < nums.length; i++) {
cur = Math.max(cur + nums[i], nums[i])
res = Math.max(res, cur)
}
return res
}

56. 合并区间

思路:先对区间左端点排序,然后遍历区间数组,这里用 ans 存储合并后的区间。

这里有个点是 last 是对 ans 的最后一个数组元素的引用,直接修改 last[1] 就是修改了 ans 中的最后一个区间。原本一股脑写了 ans.pop(),然后再 push()

/**
* @param {number[][]} intervals
* @return {number[][]}
*/
var merge = function (intervals) {
if (intervals.length === 0) return []
const ans = []
intervals.sort((a, b) => a[0] - b[0])
ans.push(intervals[0])
for (let i = 1; i < intervals.length; i++) {
const cur = intervals[i]
const last = ans[ans.length - 1]
if (cur[0] <= last[1]) {
last[1] = Math.max(last[1], cur[1])
} else {
ans.push(cur)
}
}
return ans
}

189. 轮转数组

思路:用一个额外数组存储轮转后的数组,很简单直接

/**
* @param {number[]} nums
* @param {number} k
* @return {void} Do not return anything, modify nums in-place instead.
*/
var rotate = function (nums, k) {
const n = nums.length
k = k % n
const res = new Array(n)
for (let i = 0; i < n; i++) {
res[(i + k) % n] = nums[i]
}
for (let i = 0; i < n; i++) {
nums[i] = res[i]
}
}

看题解还有原地反转算法,空间复杂度为 O(1),先将整个数组反转,然后将前 k 个元素反转,再将后 n - k 个元素反转。反转函数用的是双指针。

/**
* @param {number[]} nums
* @param {number} k
* @return {void} Do not return anything, modify nums in-place instead.
*/
var rotate = function (nums, k) {
const n = nums.length
k = k % n
if (k === 0) return
const reverse = (arr, l, r) => {
while (l < r) {
const tmp = arr[l]
arr[l] = arr[r]
arr[r] = tmp
l++
r--
}
}
reverse(nums, 0, n - 1)
reverse(nums, 0, k - 1)
reverse(nums, k, n - 1)
}

238. 除了自身以外数组的乘积

思路:不让用除法,拆解问题,除了自身以外数组的乘积,也就是左侧数乘积 * 右侧数乘积,先遍历一遍数组,声明 pre 前缀乘积变量,把左侧数乘积放到 ans 结果数组中,ans[0] 就是 nums[0] 左侧数的乘积,没有问题。然后是 suf 后缀乘积,将 suf 乘进 ans 数组里。

/**
* @param {number[]} nums
* @return {number[]}
*/
var productExceptSelf = function (nums) {
const ans = new Array(nums.length)
let pre = 1
for (let i = 0; i < nums.length; i++) {
ans[i] = pre
pre *= nums[i]
}
let suf = 1
for (let i = nums.length - 1; i >= 0; i--) {
ans[i] *= suf
suf *= nums[i]
}
return ans
}

41. 缺失的第一个正数

接下来转变下策略,先刷高频题,不按顺序刷了 = =

矩阵

73. 矩阵置零

54. 螺旋矩阵

48. 旋转图像

240. 搜索二维矩阵 II

链表

160. 相交链表

206. 反转链表

思路:一个 prev 一个 curr,用 temp 存中间值

/**
* Definition for singly-linked list.
* function ListNode(val, next) {
* this.val = (val===undefined ? 0 : val)
* this.next = (next===undefined ? null : next)
* }
*/
/**
* @param {ListNode} head
* @return {ListNode}
*/
var reverseList = function (head) {
let prev = null
let curr = head
while (curr) {
const temp = curr.next
curr.next = prev
prev = curr
curr = temp
}
return prev
}

234. 回文链表

141. 环形链表

哈希表思路:用 Set 存储访问过的节点,如果访问过就说明有环,直到访问到 null 说明没有环。

/**
* @param {ListNode} head
* @return {boolean}
*/
var hasCycle = function (head) {
const visited = new Set()
let cur = head
while (cur !== null) {
if (visited.has(cur)) {
return true
}
visited.add(cur)
cur = cur.next
}
return false
}

快慢指针思路:快指针每次走两步,慢指针每次走一步,如果有环,快指针一定会追上慢指针,如果没有环,快指针会先到 null。

/**
* @param {ListNode} head
* @return {boolean}
*/
var hasCycle = function (head) {
if (head === null || head.next === null) {
return false
}
let slow = head
let fast = head
while (fast !== null && fast.next !== null) {
slow = slow.next
fast = fast.next.next
if (slow === fast) {
return true
}
}
return false
}

142. 环形链表 II

21. 合并两个有序链表

思路:新建一个链表 + 两个指针,两个指针分别指向两个列表的头部,比较值然后在新链表中插入较小的值,指针后移,直到一个链表遍历完,然后将另一个链表剩余的部分接到新链表后面。

/**
* Definition for singly-linked list.
* function ListNode(val, next) {
* this.val = (val===undefined ? 0 : val)
* this.next = (next===undefined ? null : next)
* }
*/
/**
* @param {ListNode} list1
* @param {ListNode} list2
* @return {ListNode}
*/
var mergeTwoLists = function (list1, list2) {
const dummy = new ListNode(-1)
let curr = dummy
let p1 = list1
let p2 = list2
while (p1 && p2) {
if (p1.val < p2.val) {
curr.next = p1
p1 = p1.next
} else {
curr.next = p2
p2 = p2.next
}
curr = curr.next
}
curr.next = p1 ? p1 : p2
return dummy.next
}

2. 两数相加

19. 删除链表的倒数第 N 个结点

24. 两两交换链表中的节点

25. K 个一组翻转链表

思路:分段 + 反转链表 + 拼接

/**
* Definition for singly-linked list.
* function ListNode(val, next) {
* this.val = (val===undefined ? 0 : val)
* this.next = (next===undefined ? null : next)
* }
*/
/**
* @param {ListNode} head
* @param {number} k
* @return {ListNode}
*/
var reverseKGroup = function (head, k) {
if (!head || k === 1) return head
const dummy = new ListNode(0, head)
let pre = dummy
while (true) {
// 1. 从 pre 开始向后走 k 步,看看够不够一组
let end = pre
for (let i = 0; i < k && end !== null; i++) {
end = end.next
}
if (end === null) break
// 2. 记录这一组的开始和下一组的开始
let start = pre.next
let nextGroupStart = end.next
// 3. 断开这一段,单独反转 [start, end]
end.next = null
// 反转整段,返回新头
let newHead = reverseList(start)
// 4. 接回到原链表
pre.next = newHead
start.next = nextGroupStart
// 5. pre 移动到当前组的尾部
pre = start
}
return dummy.next
}
// 反转整条链表:返回新头
function reverseList(head) {
let prev = null
let curr = head
while (curr !== null) {
const temp = curr.next
curr.next = prev
prev = curr
curr = temp
}
return prev
}

138. 随机链表的复制

148. 排序链表

23. 合并 K 个升序链表

146. LRU 缓存

思路:LRU 要存的值是 key-value,然后要求删除最早未使用的元素,所以这道题用 Map 再合适不过。这里获取最早使用元素用的是 map.keys() 方法,返回一个迭代器对象,用 next() 方法获取第一个元素。

/**
* @param {number} capacity
*/
var LRUCache = function (capacity) {
this.map = new Map()
this.cap = capacity
}
/**
* @param {number} key
* @return {number}
*/
LRUCache.prototype.get = function (key) {
const map = this.map
if (!map.has(key)) return -1
const value = map.get(key)
map.delete(key)
map.set(key, value)
return value
}
/**
* @param {number} key
* @param {number} value
* @return {void}
*/
LRUCache.prototype.put = function (key, value) {
const map = this.map
if (map.has(key)) {
map.delete(key)
}
map.set(key, value)
if (map.size > this.cap) {
const first = map.keys().next().value
map.delete(first)
}
}

还有经典解法,哈希表 + 双向链表,两个头尾哨兵节点,但是没有 Map 简单好用,这里不写了。

二叉树

94. 二叉树的中序遍历

递归思路:中序遍历的顺序是左子树 -> 根节点 -> 右子树,所以先递归访问左子树,然后访问根节点,最后递归访问右子树。

/**
* @param {TreeNode} root
* @return {number[]}
*/
var inorderTraversal = function (root) {
const res = []
function dfs(node) {
if (!node) return
dfs(node.left)
res.push(node.val)
dfs(node.right)
}
dfs(root)
return res
}

迭代思路:使用栈,先将左子树入栈,然后访问根节点,最后访问右子树。

/**
* @param {TreeNode} root
* @return {number[]}
*/
var inorderTraversal = function (root) {
const res = []
const stack = []
let cur = root
while (cur || stack.length) {
if (cur) {
stack.push(cur)
cur = cur.left
} else {
cur = stack.pop()
res.push(cur.val)
cur = cur.right
}
}
return res
}

104. 二叉树的最大深度

226. 翻转二叉树

101. 对称二叉树

543. 二叉树的直径

102. 二叉树的层序遍历

108. 将有序数组转换为二叉搜索树

98. 验证二叉搜索树

230. 二叉搜索树中第 K 小的元素

思路:二叉搜索树中左子树 < 根节点 < 右子树,这里递归访问左子树 -> 根节点 -> 右子树,访问的顺序就是从小到大,使用 visited 计数器记录访问了多少个节点,当 visited === k 时,说明找到了第 k 小的元素。

/**
* @param {TreeNode} root
* @param {number} k
* @return {number}
*/
var kthSmallest = function (root, k) {
let ans = null
let visited = 0
function dfs(node) {
if (!node || visited >= k) return
dfs(node.left)
visited++
if (visited === k) {
ans = node.val
return
}
dfs(node.right)
}
dfs(root)
return ans
}

199. 二叉树的右视图

114. 二叉树展开为链表

105. 从前序与中序遍历序列构造二叉树

思路:理解前序和中序遍历的特点,前序遍历的第一个元素是根节点,然后在中序遍历中找到根节点的位置,左边的就是左子树,右边的就是右子树,然后递归构建左右子树。利用哈希表存储中序遍历的值和索引,方便快速查找根节点在中序遍历中的位置。

/**
* Definition for a binary tree node.
* function TreeNode(val, left, right) {
* this.val = (val===undefined ? 0 : val)
* this.left = (left===undefined ? null : left)
* this.right = (right===undefined ? null : right)
* }
*/
/**
* @param {number[]} preorder
* @param {number[]} inorder
* @return {TreeNode}
*/
var buildTree = function (preorder, inorder) {
if (preorder.length === 0) return null
const indexMap = new Map()
for (let i = 0; i < inorder.length; i++) {
indexMap.set(inorder[i], i)
}
function build(preL, preR, inL, inR) {
if (preL > preR) return null
const rootVal = preorder[preL]
const root = new TreeNode(rootVal)
const idx = indexMap.get(rootVal)
const leftSize = idx - inL
root.left = build(preL + 1, preL + leftSize, inL, idx - 1)
root.right = build(preL + leftSize + 1, preR, idx + 1, inR)
return root
}
return build(0, preorder.length - 1, 0, inorder.length - 1)
}

437. 路径总和 III

236. 二叉树的最近公共祖先

124. 二叉树中的最大路径和

图论

200. 岛屿数量

思路:深度优先搜索 DFS + “淹没”岛屿,遍历访问,遇到 1 则将周围的 1 都淹没成 0,岛屿数量 +1。

/**
* @param {character[][]} grid
* @return {number}
*/
var numIslands = function (grid) {
if (!grid || grid.length === 0) return 0
const m = grid.length
const n = grid[0].length
let count = 0
const dfs = (i, j) => {
if (i < 0 || i >= m || j < 0 || j >= n) return
if (grid[i][j] === '0') return
grid[i][j] = '0'
dfs(i - 1, j)
dfs(i + 1, j)
dfs(i, j - 1)
dfs(i, j + 1)
}
for (let i = 0; i < m; i++) {
for (let j = 0; j < n; j++) {
if (grid[i][j] === '1') {
count++
dfs(i, j)
}
}
}
return count
}

994. 腐烂的橘子

207. 课程表

208. 实现 Trie (前缀树)

回溯

回溯算法是一种通过尝试所有可能的解决方案来解决问题的算法。它通常用于组合、排列、子集等问题。回溯算法的核心思想是通过递归来构建解空间树,并在每个节点上做出选择,然后继续递归探索,直到达到终止条件。如果当前路径不满足条件,就回溯到上一个节点,尝试其他选择。

46. 全排列

思路:用回溯生成全排列。用 path 记录当前排列,用 used 标记每个元素是否已在本轮使用。每次递归,当 path 长度等于 nums 长度时,将 path 的拷贝加入结果集;否则遍历 nums,对于未使用的元素,将其加入 path 并标记 used 后继续递归,回溯时再撤销选择(从 path 移除并重置 used)。

/**
* @param {number[]} nums
* @return {number[][]}
*/
var permute = function (nums) {
const res = []
const path = []
const used = new Array(nums.length).fill(false)
function backtrack() {
if (path.length === nums.length) {
// 推送拷贝
res.push(path.slice())
return
}
for (let i = 0; i < nums.length; i++) {
if (used[i]) {
continue
}
used[i] = true
path.push(nums[i])
backtrack()
// 撤销选择:恢复状态,回到上一层
path.pop()
used[i] = false
}
}
backtrack()
return res
}

78. 子集

17. 电话号码的字母组合

39. 组合总和

22. 括号生成

79. 单词搜索

131. 分割回文串

51. N 皇后

二分查找

35. 搜索插入位置

74. 搜索二维矩阵

34. 在排序数组中查找元素的第一个和最后一个位置

33. 搜索旋转排序数组

153. 寻找旋转排序数组中的最小值

4. 寻找两个正序数组的中位数

20. 有效的括号

思路:栈的基本应用,先进后出,比较括号是否匹配就是与栈顶比较

/**
* @param {string} s
* @return {boolean}
*/
var isValid = function (s) {
if (s.length % 2 === 1) return false
const stack = []
const map = {
')': '(',
']': '[',
'}': '{',
}
for (const ch of s) {
if (ch in map) {
const top = stack.length ? stack.pop() : '#'
if (top !== map[ch]) {
return false
}
} else {
stack.push(ch)
}
}
return stack.length === 0
}

155. 最小栈

394. 字符串解码

思路:栈的应用,遇到数字和字符就存储下来,遇到 [ 就把当前数字和字符串入栈,遇到 ] 就出栈,拼接字符串。

/**
* @param {string} s
* @return {string}
*/
var decodeString = function (s) {
const nums = []
const strs = []
let num = ''
let str = ''
for (const ch of s) {
if (ch >= '0' && ch <= '9') {
num += ch
} else if ((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z')) {
str += ch
} else if (ch === '[') {
nums.push(Number(num))
strs.push(str)
num = ''
str = ''
} else if (ch === ']') {
str = strs.pop() + str.repeat(nums.pop())
}
}
return str
}

739. 每日温度

84. 柱状图中最大的矩形

215. 数组中的第 K 个最大元素

思路:nums.sort,然后返回 nums[k - 1] O(n) 复杂度要用到堆排序,建立大根堆,堆顶就是最大值,第 K 个最大值就弹出堆顶 K - 1 次,之后堆顶就是第 K 个最大值。重点在于 heapify() 函数,倒着排序建堆,理解原理就能看明白函数了。

堆排序,看的这个视频:堆与堆排序 | bilibili

/**
* @param {number[]} nums
* @param {number} k
* @return {number}
*/
var findKthLargest = function (nums, k) {
const n = nums.length
function heapify(i, heapSize) {
while (true) {
let largest = i
let left = 2 * i + 1
let right = 2 * i + 2
if (left < heapSize && nums[left] > nums[largest]) {
largest = left
}
if (right < heapSize && nums[right] > nums[largest]) {
largest = right
}
if (i === largest) break
;[nums[i], nums[largest]] = [nums[largest], nums[i]]
i = largest
}
}
for (let i = Math.floor(n / 2) - 1; i >= 0; i--) {
heapify(i, n)
}
let heapSize = n
for (let i = 0; i < k - 1; i++) {
;[nums[0], nums[heapSize - 1]] = [nums[heapSize - 1], nums[0]]
heapSize--
heapify(0, heapSize)
}
return nums[0]
}

347. 前 K 个高频元素

295. 数据流的中位数

贪心算法

121. 买卖股票的最佳时机

思路:记录购入最小价格,遍历更新最小价格以及计算利润。

/**
* @param {number[]} prices
* @return {number}
*/
var maxProfit = function (prices) {
let minPrice = prices[0]
let maxProfit = 0
for (let i = 1; i < prices.length; i++) {
const profit = prices[i] - minPrice
maxProfit = Math.max(profit, maxProfit)
minPrice = Math.min(prices[i], minPrice)
}
return maxProfit
}

55. 跳跃游戏

45. 跳跃游戏 II

763. 划分字母区间

动态规划

70. 爬楼梯

118. 杨辉三角

198. 打家劫舍

279. 完全平方数

322. 零钱兑换

139. 单词拆分

300. 最长递增子序列

思路:二分查找 + 贪心算法。lowerBound() 函数是二分查找,返回第一个大于等于 target 的索引,tail 数组存储当前最长递增子序列的末尾元素,遍历 nums 数组,使用 lowerBound() 找到 x 在 tail 中的位置 i,如果 i === tail.length,说明 x 比 tail 中所有元素都大,将 x 添加到 tail 末尾,否则将 tail[i] 替换为 x,这样可以保证 tail 中的元素尽可能小,从而为后续的元素提供更多的选择。最后返回 tail.length 即为最长递增子序列的长度。

/**
* @param {number[]} nums
* @return {number}
*/
var lengthOfLIS = function (nums) {
const lowerBound = (nums, target) => {
let left = 0
let right = nums.length
while (left < right) {
const mid = left + Math.floor((right - left) / 2)
if (nums[mid] < target) {
left = mid + 1
} else {
right = mid
}
}
return left
}
const tail = []
for (const x of nums) {
const i = lowerBound(tail, x)
if (i === tail.length) {
tail.push(x)
} else {
tail[i] = x
}
}
return tail.length
}

152. 乘积最大子数组

416. 分割等和子集

32. 最长有效括号

思路:栈存储左括号的索引,遇到右括号就弹出栈顶索引,如果栈为空,说明当前右括号没有匹配的左括号,将当前索引入栈作为新的起点(分界线),否则计算当前有效括号长度为 i - stack[stack.length - 1],更新最大长度 maxLen。

/**
* @param {string} s
* @return {number}
*/
var longestValidParentheses = function (s) {
const stack = [-1]
let maxLen = 0
for (let i = 0; i < s.length; i++) {
const ch = s[i]
if (ch === '(') {
stack.push(i)
} else {
stack.pop()
if (stack.length === 0) {
stack.push(i)
} else {
const currLen = i - stack[stack.length - 1]
maxLen = Math.max(currLen, maxLen)
}
}
}
return maxLen
}

多维动态规划

62. 不同路径

64. 最小路径和

思路:动态规划,dp[i][j] = Math.min(dp[i - 1][j], dp[i][j - 1]) + grid[i][j],先计算第一行和第一列的最小路径和,然后从 (1, 1) 开始计算每个位置的最小路径和,最后返回右下角的值。

/**
* @param {number[][]} grid
* @return {number}
*/
var minPathSum = function (grid) {
const m = grid.length
const n = grid[0].length
for (let i = 1; i < m; i++) {
grid[i][0] += grid[i - 1][0]
}
for (let j = 1; j < n; j++) {
grid[0][j] += grid[0][j - 1]
}
for (let i = 1; i < m; i++) {
for (let j = 1; j < n; j++) {
grid[i][j] += Math.min(grid[i - 1][j], grid[i][j - 1])
}
}
return grid[m - 1][n - 1]
}

5. 最长回文子串

1143. 最长公共子序列

72. 编辑距离

技巧

136. 只出现一次的数字

169. 多数元素

75. 颜色分类

31. 下一个排列

287. 寻找重复数

其他(非 Hot 100)

8. 字符串转换整数 (atoi)

思路:题目说的很明白了,trim() 去零,用 sign 记录正负号,res 存储结果,遍历字符串,遇到非数字就 break,遇到数字就计算 res = res * 10 + digit,同时判断是否溢出。这里判断溢出原理是每次 res = res * 10 + digit,就是 res * 10 + digit > INT_MAX,变形一下就是 res > (INT_MAX - digit) / 10,floor() 是因为 res 是整数,INT_MAX - digit 可能不是 10 的倍数。

/**
* @param {string} s
* @return {number}
*/
var myAtoi = function (s) {
s = s.trim()
if (s === '') return 0
const INT_MAX = 2 ** 31 - 1
const INT_MIN = -(2 ** 31)
const n = s.length
let i = 0
let sign = 1
if (s[i] === '-') {
sign = -1
i++
} else if (s[i] === '+') {
i++
}
let res = 0
while (i < n) {
const ch = s[i]
if (ch < '0' || ch > '9') break
const digit = ch.charCodeAt(0) - '0'.charCodeAt(0)
if (res > Math.floor((INT_MAX - digit) / 10)) {
return sign === 1 ? INT_MAX : INT_MIN
}
res = res * 10 + digit
i++
}
return sign * res
}

43. 字符串相乘

思路:模拟竖式乘法,创建 n1 + n2 长度(最大位)的数组 res,其中 res[i + j + 1] 为计算中当前位,res[i + j] 为进位,最后将 res 前置 0 去除。

/**
* @param {string} num1
* @param {string} num2
* @return {string}
*/
var multiply = function (num1, num2) {
if (num1 === '0' || num2 === '0') return '0'
const n1 = num1.length
const n2 = num2.length
const res = new Array(n1 + n2).fill(0)
for (let i = n1 - 1; i >= 0; i--) {
const x = num1.charCodeAt(i) - '0'.charCodeAt(0)
for (let j = n2 - 1; j >= 0; j--) {
const y = num2.charCodeAt(j) - '0'.charCodeAt(0)
const sum = res[i + j + 1] + x * y
res[i + j + 1] = sum % 10
res[i + j] += Math.floor(sum / 10)
}
}
let k = 0
while (k < res.length - 1 && res[k] === 0) k++
return res.slice(k).join('')
}

93. 复原 IP 地址

思路:回溯递归,利用 path.pop() 进行回溯,同时递归终止条件是 path 长度为 4 且 start === n,说明已经找到了一个合法的 IP 地址。同时判断剩余字符数是否满足剩余段数的要求,如果不满足就直接返回,剪枝优化。

/**
* @param {string} s
* @return {string[]}
*/
var restoreIpAddresses = function (s) {
const res = []
const n = s.length
if (n < 4 || n > 12) return res
function backtrack(start, path) {
if (path.length === 4) {
if (start === n) {
res.push(path.join('.'))
}
return
}
const remainChars = n - start
const remainSegs = 4 - path.length
if (remainChars < remainSegs || remainChars > 3 * remainSegs) {
return
}
for (let len = 1; len <= 3; len++) {
if (start + len > n) break
const part = s.substring(start, start + len)
if (part[0] === '0' && part.length > 1) break
const num = Number(part)
if (num < 0 || num > 255) break
path.push(part)
backtrack(start + len, path)
path.pop()
}
}
backtrack(0, [])
return res
}

165. 比较版本号

思路:先将版本号按 ’.’ 分割成数组,然后同时遍历两个数组,获取当前版本号的整数值,如果遍历到长度外就用 0 补,根据大小返回结果。

/**
* @param {string} version1
* @param {string} version2
* @return {number}
*/
var compareVersion = function (version1, version2) {
const a = version1.split('.')
const b = version2.split('.')
const n = Math.max(a.length, b.length)
for (let i = 0; i < n; i++) {
const v1 = i < a.length ? parseInt(a[i], 10) : 0
const v2 = i < b.length ? parseInt(b[i], 10) : 0
if (v1 > v2) return 1
if (v1 < v2) return -1
}
return 0
}

442. 数组中重复的数据

思路:根据题目限制,数组中的整数都在 1 到 n 之间,这里用原数组标记是否访问,很巧妙地不改变数组的(绝对)值,同时能够标记访问过的元素,遍历数组,根据值获取索引 x - 1,如果 nums[x - 1] < 0,说明 x 已经访问过了,将 x 添加到结果数组中,否则将 nums[x - 1] 取负数标记访问过。

/**
* @param {number[]} nums
* @return {number[]}
*/
var findDuplicates = function (nums) {
const res = []
for (let i = 0; i < nums.length; i++) {
const x = Math.abs(nums[i])
if (nums[x - 1] < 0) {
res.push(x)
} else {
nums[x - 1] = -nums[x - 1]
}
}
return res
}

LCR 180.文件组合

思路:滑动窗口

/**
* @param {number} target
* @return {number[][]}
*/
var fileCombination = function (target) {
const res = []
let l = 1,
r = 1,
sum = 0
while (r < target) {
sum += r
while (sum > target) {
sum -= l
l++
}
if (sum === target && r > l) {
const temp = []
for (let x = l; x <= r; x++) {
temp.push(x)
}
res.push(temp)
}
r++
}
return res
}

122. 买卖股票的最佳时机 II

思路:利润累计,遍历价格数组,如果当前价格大于前一天的价格,就卖出,累加利润。

/**
* @param {number[]} prices
* @return {number}
*/
var maxProfit = function (prices) {
let profit = 0
for (let i = 1; i < prices.length; i++) {
if (prices[i] > prices[i - 1]) {
profit += prices[i] - prices[i - 1]
}
}
return profit
}