一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第 1 天,点击查看活动详情。
本篇文章关联 前端面试准备的50道算法题上
2.26 有效的括号
本题利用了栈做数据储存。首先遍历字符串中的括号,如果是左括号就放到一个数组里面,如果不是就从数组中弹出最近插入括号的右括号进行比较,不相等直接返回 false。最后需要判断数组中的括号是否全部匹配完,防止出现多余括号的情况。
var isValid = function (s) {
let arr = []
let obj = {
'(': ')',
'{': '}',
'[': ']'
}
for (const char of s) {
if (obj[char]) {
arr.push(char)
} else {
if (char !== obj[arr.pop()]) {
return false
}
}
}
return !arr.length
}
2.27 移动零
本题利用双指针的思想。定义双指针 i 和 j,其中 i 做为循环遍历的序号,j 指向第一个零的序号。当遍历到非零的时候,该数需要和零进行交换,j 接着指向下一个零;当遍历到零的时候,不需要做交换。
var moveZeroes = function (nums) {
let i = 0, j = 0
while (i < nums.length) {
if (nums[i] !== 0) {
[nums[i], nums[j]] = [nums[j], nums[i]]
i++
j++
} else {
i++
}
}
return nums
}
2.28 二维数组中的查找
以二维数组的左下角为原点建立坐标轴,如果找到目标数则返回 true,如果小于目标数则让 y 递增,如果大于目标数则让 x 递减。
var findNumberIn2DArray = function (matrix, target) {
if (!matrix.length) return false
let x = matrix.length - 1, y = 0
while (x >= 0 && y < matrix[0].length) {
if (matrix[x][y] === target) {
return true
} else if (matrix[x][y] < target) {
y++
} else {
x--
}
}
return false
}
2.29 调整数组顺序使奇数位于偶数前面
类似于题目 2.27移动零,也是利用双指针法,如果遇到奇数就发生交换,否则继续遍历下一个。
var exchange = function (nums) {
let i = 0, j = 0
while (i < nums.length) {
if (nums[i] % 2 === 1) {
[nums[i], nums[j]] = [nums[j], nums[i]]
i++
j++
} else {
i++
}
}
return nums
}
2.30 顺时针打印矩阵
可以把二维数组抽象成一个矩阵,然后遍历的顺序从上,到右,到下,最后是左。注意解答的时候有多次进行了判空处理,其中matrix[i].length 用于判断对应的数组是否为空,flag 用于防止数组为空还继续遍历的情况。
var spiralOrder = function (matrix) {
let res = [], flag = true // 防止出现为空还进行遍历的情况
while (matrix.length) {
if (flag) {
res = res.concat(matrix.shift())
for (let i = 0; i < matrix.length; i++) {
matrix[i].length && res.push(matrix[i].pop())
}
} else {
res = res.concat(matrix.pop().reverse())
for (let i = matrix.length - 1; i >= 0; i--) {
matrix[i].length && res.push(matrix[i].shift())
}
}
flag = !flag
}
return res
}
2.31 数组中出现次数超过一半的数字
先排序然后找到中位数即可。
var majorityElement = function (nums) {
nums.sort((a, b) => a - b)
return nums[Math.floor(nums.length / 2)]
}
2.32 第一个只出现一次的字符
遍历字符串的每一个字符,如果出现该字符通过 indexOf 和 lastIndexOf 查找到的索引一致的话,说明这是一个只出现一次的字符。
var firstUniqChar = function (s) {
for (const char of s) {
if (s.indexOf(char) === s.lastIndexOf(char)) {
return char
}
}
return ' '
}
2.33 扑克牌中的顺子
对数组进行遍历,利用 set 进行判重,大小王之间跳过,找到数组的最大值和最小值,判断最大值和最小值的差是否小于5即可。
var isStraight = function (nums) {
let max = 0, min = 14, set = new Set()
for (const num of nums) {
if (num === 0) continue
if (set.has(num)) return false
set.add(num)
max = Math.max(num, max)
min = Math.min(num, min)
}
return max - min < 5
}
2.34 和为s的两个数字
本题和 2.1 两数之和 不同,题意上说这是一个递增的数组,所以可以使用双指针的思想。定义指针 l 和 r,一个指向第一个数,另一个指向最后一个数。当两数之和等于目标数的时候返回这两个数,小于目标数则 l 递增,大于目标数则 r 递减。
var twoSum = function (nums, target) {
if (nums.length < 2) return []
let l = 0, r = nums.length - 1
while (l < r) {
if (nums[l] + nums[r] === target) return [nums[l], nums[r]]
else if (nums[l] + nums[r] < target) l++
else r--
}
return []
}
2.35 矩形重叠
本题采用逆向思维。找到矩阵不重叠的条件,然后结果取反即可
var isRectangleOverlap = function (rec1, rec2) {
let [x1, y1, x2, y2] = rec1
let [x3, y3, x4, y4] = rec2
return !(x1 >= x4 || x2 <= x3 || y1 >= y4 || y2 <= y3)
}
2.36 从上到下打印二叉树
二叉树的遍历包含深度优先遍历和广度优先遍历。在前端面试准备的50道算法题上中,前序遍历就是典型的深度优先遍历,只需使用递归就可以很快的完成遍历,而广度优先遍历需要使用队列。
先创建一个队列,用于存放节点。通过循环进行出队操作,保存获取到的节点值。如果节点有左右节点,就需要分别对它们进行入队操作。这样不断迭代就可以得到层序遍历的结果。
var levelOrder = function (root) {
if (root === null) return []
let queue = [root], res = []
while (queue.length) {
const node = queue.shift()
res.push(node.val)
node.left && queue.push(node.left)
node.right && queue.push(node.right)
}
return res
};
2.37 从上到下打印二叉树II
类似于上题 从上到下打印二叉树,不同的是,这里使用的不是一维数组,而是使用了二维数组。在创建队列的时候,把根节点和0都当作数组元素保存到数组中,再把这个数组进行入队。当队列非空的时候进行出队操作,获取的值是一个数组,包括节点和层级,判断保存结果的数组是否有该层级,没有就创建该层级,有的话就保存到该层级中。然后判断是否有左右子树,有的话就把左右子树和累加后的层级保存到数组中并进行入队操作。最后返回的就是一个按层次遍历的结果。
var levelOrder = function (root) {
if (root === null) return []
let queue = [[root, 0]], res = [] // 创建一个二维数组做为保存节点的队列
while (queue.length) {
const [node, level] = queue.shift() // 出队
if (!res[level]) res[level] = []
res[level].push(node.val)
node.left && queue.push([node.left, level + 1]) // 将子节点进行入队操作
node.right && queue.push([node.right, level + 1])
}
return res
}
2.38 从上到下打印二叉树 III
类似于上题 从上到下打印二叉树II,不同的是,本题要求进行之字形打印,这就需要在保存结果的时候进行判断。这里使用了位操作,level&1 的结果如果是1说明是奇数层,使用 unshift 方法添加到数组头部,如果是0说明是偶数层,使用 push 方法添加到数组尾部。
var levelOrder = function (root) {
if (!root) return []
let queue = [[root, 0]], res = []
while (queue.length) {
const [node, level] = queue.shift()
if (!res[level]) res[level] = []
level & 1 ? res[level].unshift(node.val) : res[level].push(node.val) // 判断奇偶,如果是奇数添加到数组头部,如果是偶数添加到数组尾部
node.left && queue.push([node.left, level + 1])
node.right && queue.push([node.right, level + 1])
}
return res
}
2.39 旋转数组的最小数字
本题使用二分查找的方法找极值。为了说明清楚,这里直接使用了 leetcode 官方的图片,方便分析。
由于旋转前的数组是一个有序的数组,所以旋转后的结果可以使用下图表示。先确定左右边界 left 和 right,然后找到中位数,通过中位数和右边界进行比较。如果中位数比右边界大,则可以判断最小值在中位数和右边界之间,可以把左边界移到 mid+1;如果中位数比右边界小,则可以判断最小值在左边界和中位数之间,可以把右边界移到 mid;如果中位数和右边界相等,那么最小值的位置还是不确定,但是可以通过右边界左移一位来缩小范围同时也不影响找到最小值。
var minArray = function (numbers) {
let left = 0, right = numbers.length - 1
while (left < right) {
let mid = Math.floor((left + right) / 2)
if (numbers[mid] > numbers[right]) {
left = mid + 1
} else if (numbers[mid] < numbers[right]) {
right = mid
} else {
right--
}
}
return numbers[left]
}
2.40 猜数字大小
本题使用二分查找的思想。找到中位数判断是不是对应的数字,如果是就返回,如果数字偏大则把 right 指向 mid-,如果数字偏小则把 left 指向 mid+1。
var guessNumber = function (n) {
let left = 1, right = n
while (left <= right) {
let mid = Math.floor((left + right) / 2)
let result = guess(mid) // guess() 是系统提供的接口
if (result === 0) {
return mid
} else if (result === -1) {
right = mid - 1
} else {
left = mid + 1
}
}
}
2.41 x 的平方根
类似于上题,本题也使用二分查找的思想。比较特别的是,由于题意要求返回一个整数,所以需要同时满足 mid <= x / mid 并且 mid + 1 > x / (mid + 1) 就可以说明 mid 是 x 的平方根。
var mySqrt = function (x) {
let left = 1,
right = x;
while (left <= right) {
let mid = Math.floor((right + left) / 2);
if (mid <= x / mid) {
if (mid + 1 > x / (mid + 1)) {
return mid;
}
left = mid + 1;
} else {
right = mid - 1;
}
}
return 0;
}
2.42 最小的k个数
最简单的方法就是先遍历再切片找到对应最小的k个数。不过,看题解,更优的方法是使用快排的方式,可是自己没有看懂,就留到以后再学习。
- 使用快排思维来解题
var getLeastNumbers = function (arr, k) {
arr.sort((a, b) => a - b)
return arr.slice(0, k)
}
2.43 买卖股票的最佳时机
本题使用动态规划方法。动态规划就是把复杂问题划分成多个子问题来逐个解决,类似于分治法,不同的是分治中各个子问题是独立的,而动态规划的子问题是相互关联的。
先遍历每个价格,如果这个价格比最低价少,则把该价格做为最低价,如果这个价格不少于最低价,则把这个价格和最低价的差同之前的价格差做比较,找到最大值。
var maxProfit = function (prices) {
let minPrices = prices[0]
let res = 0
for (const v of prices) {
if (minPrices > v) {
minPrices = v
} else {
res = Math.max(res, v - minPrices)
}
}
return res
}
2.44 买卖股票的最佳时机 II
本题使用贪心法。题意说每天都可以买卖股票,也就是说只要求出买卖股票的最大利润即可。可以遍历每天的股票价格,当天价格比上一天高就可以累加到最终利润上。
var maxProfit = function (prices) {
let res = 0
for (let i = 1; i < prices.length; i++) {
if (prices[i] > prices[i - 1]) {
res += prices[i] - prices[i - 1] // 可以简化为 res += Math.max(prices[i] - prices[i - 1],0)
}
}
return res
}
2.45 打家劫舍
本题使用动态规划思想。由于相邻的房屋不能偷取,所以可以得到动态规划方程 Math.max(cur, prev + nums[i]),也就是把上一个房屋累积偷取的金额和上上一个房屋累积偷到的金额加上当前房屋偷取的金额进行比较,找到最大值。
var rob = function (nums) {
if (!nums.length) return 0
let prev = 0, cur = nums[0]
for (let i = 1; i < nums.length; i++) {
const temp = cur
cur = Math.max(cur, prev + nums[i])
prev = temp
}
return cur
}
2.46 二叉搜索树的最近公共祖先
利用二叉搜索树的特性,根节点的值比左节点的值要大,比右节点的值要小。如果 p 和 q 在 root 节点的两侧,则可以得到 (root.val - p.val) * (root.val - q.val) <= 0;如果 p 和 q 在 root 节点的左侧,则返回递归 root 节点的左子树;如果 p 和 q 在 root 节点的右侧,则返回递归 root 节点的右子树。
var lowestCommonAncestor = function (root, p, q) {
if ((root.val - p.val) * (root.val - q.val) <= 0) return root
else if (root.val > p.val) return lowestCommonAncestor(root.left, p, q)
else return lowestCommonAncestor(root.right, p, q)
}
2.47 二叉树的最近公共祖先
如果 root 对应 p 或者 q 节点,那么 root 就是最近公共祖先。如果返回空则继续找左子树和右子树,当左子树为空,则返回 lowestCommonAncestor(root.right, p, q);当右子树为空,则返回 lowestCommonAncestor(root.left, p, q);当左右子树都存在时,返回 root。
var lowestCommonAncestor = function (root, p, q) {
if (!root || root.val === p.val || root.val === q.val) return root
let left = lowestCommonAncestor(root.left, p, q)
let right = lowestCommonAncestor(root.right, p, q)
if (!left) return right
if (!right) return left
return root
}
2.48 岛屿的数量
双层循环遍历每一个值,如果是'1'则岛屿数量增加1,并且把岛屿周围的数值置为‘0‘。在进行置‘0’操作的时候,超出边界或者值已经为’0‘就停止,否则置’0‘,并且递归周围的数进行置’0‘操作。
var numIslands = function (grid) {
let count = 0
for (let i = 0; i < grid.length; i++) {
for (let j = 0; j < grid[0].length; j++) {
if (grid[i][j] === '1') {
count++
turnZero(i, j, grid) // 已经统计了一个岛屿,则把周围的'1'全部变成'0'
}
}
}
return count
};
function turnZero(i, j, grid) {
if (i < 0 || i >= grid.length || j < 0 || j >= grid[0].length || grid[i][j] === '0') return
grid[i][j] = '0'
turnZero(i + 1, j, grid)
turnZero(i - 1, j, grid)
turnZero(i, j + 1, grid)
turnZero(i, j - 1, grid)
}
2.49 最长递增子序列
本题使用了动态规划思想。使用对应数组 nums 序号的 dp 数组来表示每个序号得到的最长递增子序列的值。把当前值和前一个值进行比较,如果当前值比前一个值要大,则取当前序号的 dp 值和前一个序号的 dp 值并加上1进行比较,把较大值做为当前序号的 dp 值。最后返回 dp 数组的最大值即可。
var lengthOfLIS = function (nums) {
let dp = new Array(nums.length).fill(1)
for (let i = 0; i < nums.length; i++) {
for (let j = i - 1; j >= 0; j--) {
if (nums[i] > nums[j]) {
dp[i] = Math.max(dp[i], dp[j] + 1)
}
}
}
return Math.max(...dp)
}
2.50 最长回文子串
本题采用双指针法。遍历字符串,分别寻找长度为偶数的回文子串和长度为奇数的回文子串,而查找回文子串使用双指针法,两个指针没有超过边界且相等就分别向左右两边走,最后返回对应的回文子串。每次遍历的时候都保留当前获取的最长回文子串,遍历完后返回结果即可。
var longestPalindrome = function (s) {
let res = ''
for (let i = 0; i < s.length; i++) {
let s1 = palindrome(s, i, i) // 寻找长度为奇数的回文子串
let s2 = palindrome(s, i, i + 1) // 寻找长度为偶数的回文子串
res = res.length < s1.length ? s1 : res
res = res.length < s2.length ? s2 : res
}
return res
};
function palindrome(s, l, r) {
while (l >= 0 && r < s.length && s[l] === s[r]) {
l--
r++
}
return s.substring(l + 1, r)
}
3. 二叉搜索树的调试方式
leetcode 上的说明一般都没有给出创建二叉搜索树的方式,这样在本地进行代码调试的时候非常不方便。所以掌握二叉搜索树的创建方式很重要。下面给出了二叉搜索树的创建代码,可以看出,得到的 root 节点就是一个 Node 类生成的实例对象,它包含三个属性,其中 val 表示值,left 和 right 分别表示左右节点且都是一个 Node 类生成的实例对象。
class Node {
constructor(val) {
this.val = val;
this.left = null;
this.right = null;
}
}
class BinarySearchTree {
constructor() {
this.root = null;
}
// 插入数据
insert(val) {
//根据传进来的 val 创建节点
let newNode = new Node(val);
//判断根节点是否为空
if (this.root == null) {
this.root = newNode;
} else {
this.insertNode(this.root, newNode);
}
}
insertNode(node, newNode) {
// 判断被比较的节点和新节点的 val 大小
if (newNode.val > node.val) {
if (node.right == null) {
node.right = newNode;
} else {
this.insertNode(node.right, newNode);
}
} else {
if (node.left == null) {
node.left = newNode;
} else {
this.insertNode(node.left, newNode);
}
}
}
}
4. 小结
| 序号 | 名称 | 数据结构 | 解题策略 |
|---|---|---|---|
| 1 | 两数之和 | 数组,map | - |
| 2 | 三数之和 | 数组 | 双指针 |
| 3 | 四数之和 | 数组 | 双指针 |
| 4 | 最接近的三数之和 | 数组 | 双指针 |
| 5 | 二叉树的前序遍历 | 二叉树 | 递归 |
| 6 | 二叉树的中序遍历 | 二叉树 | 递归 |
| 7 | 二叉树的后序遍历 | 二叉树 | 递归 |
| 8 | 二叉树的最大深度 | 二叉树 | 递归 |
| 9 | 平衡二叉树 | 二叉树 | 递归 |
| 10 | 二叉树的镜像 | 二叉树 | 递归 |
| 11 | 对称的二叉树 | 二叉树 | - |
| 12 | 合并的两个有序链表 | 单链表 | 递归 |
| 13 | 相交链表 | 单链表 | - |
| 14 | 删除链表中的节点 | 单链表 | - |
| 15 | 环形链表 | 单链表 | 双指针 |
| 16 | 从尾到头打印链表 | 单链表 | - |
| 17 | 链表中倒数第K个节点 | 单链表 | 双指针 |
| 18 | 翻转链表 | 单链表 | - |
| 19 | 爬楼梯 | 数字 | 动态规划 |
| 20 | 斐波那契数列 | 数字 | 动态规划 |
| 21 | 连续子数组的最大和 | 数组 | 动态规划 |
| 22 | 和为s的连续正数序列 | 数组 | 滑动窗口 |
| 23 | 无重复字符的最长子串 | 数组 | 滑动窗口 |
| 24 | 排序数组中只出现一次的数字 | 数组 | 位运算 |
| 25 | 二叉搜索树的第K大节点 | 二叉树 | 逆中序遍历 |
| 26 | 有效的括号 | 栈 | - |
| 27 | 移动零 | 数组 | 双指针 |
| 28 | 二维数组中的查找 | 二维数组 | - |
| 29 | 调整数组顺序使奇数位于偶数前面 | 数组 | 双指针 |
| 30 | 顺时针打印矩阵 | 二维数组 | - |
| 31 | 数组中出现次数超过一半的数组 | 数组 | - |
| 32 | 第一个只出现一次的字符 | 字符串 | - |
| 33 | 扑克牌中的顺子 | 数组,set | - |
| 34 | 和为s的两个数字 | 数组 | 双指针 |
| 35 | 矩形重叠 | 数组 | 逆向思维 |
| 36 | 从上到下打印二叉树 | 二叉树,队列 | - |
| 37 | 从上到下打印二叉树II | 二叉树,二维数组 | - |
| 38 | 从上到下打印二叉树III | 二叉树,二维数组 | 位运算 |
| 39 | 循转数组的最小数字 | 数组 | 二分查找 |
| 40 | 猜数字大小 | 数字 | 二分查找 |
| 41 | x的平方根 | 数字 | 二分查找 |
| 42 | 最小的K个数 | 数组 | - |
| 43 | 买卖股票的最佳时机 | 数组 | 动态规划 |
| 44 | 买卖股票的最佳时机II | 数组 | 贪心法 |
| 45 | 打家劫舍 | 数字 | 动态规划 |
| 46 | 二叉搜索树的最近公共祖先 | 二叉树 | - |
| 47 | 二叉树的最近公共祖先 | 二叉树 | - |
| 48 | 岛屿的数量 | 二维数组 | - |
| 49 | 最长递增子序列 | 数组 | 动态规划 |
| 50 | 最长回文子串 | 字符串 | 双指针 |
由上表可得出,记录的50道算法题中,涉及的常用数据结构有数组(二维数组),栈,队列,单链表,二叉树,常用的解题策略有动态规划,二分查找,递归,双指针。
参考文章:前端该如何准备数据结构和算法?