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

问题都很长,所以只记录题目和解题思路

准备

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

你是天才,所以不用学数据结构 | 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. 除了自身以外数组的乘积

明天继续 = =

41. 缺失的第一个正数

矩阵

73. 矩阵置零

54. 螺旋矩阵

48. 旋转图像

240. 搜索二维矩阵 II

链表

160. 相交链表

206. 反转链表

234. 回文链表

141. 环形链表

142. 环形链表 II

21. 合并两个有序链表

2. 两数相加

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

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

25. K 个一组翻转链表

138. 随机链表的复制

148. 排序链表

23. 合并 K 个升序链表

146. LRU 缓存

二叉树

94. 二叉树的中序遍历

104. 二叉树的最大深度

226. 翻转二叉树

101. 对称二叉树

543. 二叉树的直径

102. 二叉树的层序遍历

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

98. 验证二叉搜索树

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

199. 二叉树的右视图

114. 二叉树展开为链表

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

437. 路径总和 III

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

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

图论

200. 岛屿数量

994. 腐烂的橘子

207. 课程表

208. 实现 Trie (前缀树)

回溯

46. 全排列

78. 子集

17. 电话号码的字母组合

39. 组合总和

22. 括号生成

79. 单词搜索

131. 分割回文串

51. N 皇后

二分查找

35. 搜索插入位置

74. 搜索二维矩阵

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

33. 搜索旋转排序数组

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

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

20. 有效的括号

155. 最小栈

394. 字符串解码

739. 每日温度

84. 柱状图中最大的矩形

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

347. 前 K 个高频元素

295. 数据流的中位数

贪心算法

121. 买卖股票的最佳时机

55. 跳跃游戏

45. 跳跃游戏 II

763. 划分字母区间

动态规划

70. 爬楼梯

118. 杨辉三角

198. 打家劫舍

279. 完全平方数

322. 零钱兑换

139. 单词拆分

300. 最长递增子序列

152. 乘积最大子数组

416. 分割等和子集

32. 最长有效括号

多维动态规划

62. 不同路径

64. 最小路径和

5. 最长回文子串

1143. 最长公共子序列

72. 编辑距离

技巧

136. 只出现一次的数字

169. 多数元素

75. 颜色分类

31. 下一个排列

287. 寻找重复数