- 前言
- 上题~
- 3. 无重复字符的最长子串|中等、高频">3. 无重复字符的最长子串|中等、高频
- 20. 有效的括号|简单、高频
- 4. 寻找两个正序数组的中位数|困难、低频
- 1. 两数之和|简单、高频">1. 两数之和|简单、高频
- 88. 合并两个有序数组|简单、高频">88. 合并两个有序数组|简单、高频
- 415. 字符串相加|简单、高频">415. 字符串相加|简单、高频
- 165. 比较版本号|中等、高频">165. 比较版本号|中等、高频
- 70. 爬楼梯|简单、中频
- 46. 全排列|中等、高频">46. 全排列|中等、高频
- 53. 最大子序和|简单、高频">53. 最大子序和|简单、高频
- 112. 路径总和|容易、高频">112. 路径总和|容易、高频
- 在最后
前言
本周刷了11道~
okkjoo-leetcodeHot-byJs
带你用 JS 刷高频面试算法题~ 每周日更新~ 合集仓库:okkjoo-leetcodeHot-byJs
这是第一周的刷题记录与题解分享,如果你已经按题型分类系统地刷了一遍算法面试题的各个题型,想感受一下面试题的”随机性”的话,欢迎一起~
上题~
3. 无重复字符的最长子串|中等、高频
题目描述
给定一个字符串
s
,请你找出其中不含有重复字符的 最长子串 的长度。
解题思路
无重复+最长子串——看到这两个个关键字你就要想到滑动窗口,那么这道题就是窗口大小无限制的滑动窗口~ 滑动窗口具体控制就是双指针啦
然后需要求得的就是 满足条件(不含重复字符)的窗口中,最大的窗口。
- 用一个 set 存储窗口内的元素
- 当窗口内没有重复字符时就不断地向右边扩张
- 新的右边界字符存入 set
- 出现重复后就缩小左边的窗口
- 左边界限向右移动
- 直到最右边界限
代码
/**
* @param {string} s
* @return {number}
*/
var lengthOfLongestSubstring = function(s) {
const set = new Set();//记录窗口内元素是否出现过
let i = 0, j = 0, res = 0;//左右指针、答案
if(s.length == 0)return 0;//特殊情况
while(j < s.length){//右边界小于字符串长度
if(!set.has(s[j])){//set中没有
set.add(s[j]);//放入set中
res = Math.max(res, set.size);//看看答案要不要更新
}else{//set中已经有了
while(set.has(s[j])){//右移左边界直到没有重复字符
set.delete(s[i]);
i++;
}
set.add(s[j]);//将右边界字符加入
}
j++;//无论如何右边界都是一直走的
}
return res
};
20. 有效的括号|简单、高频
题目描述
给定一个只包括 ‘(‘,’)’,’{‘,’}’,’[‘,’]’ 的字符串 s ,判断字符串是否有效。
有效字符串需满足:
左括号必须用相同类型的右括号闭合。
左括号必须以正确的顺序闭合。
解题思路
括号问题,常考的就是栈的运用与操作了~
遍历字符串s,分情况讨论:
- 拿到左括号:
- 压入栈中
- 拿到右括号,分类讨论:
- 栈不为空:
- 栈顶为对应的左括号:取出栈顶,继续遍历
- 栈顶不是对应的左括号,
return false
- 栈为空: 直接
return false
- 栈不为空:
遍历完之后,如果栈中还有括号,就说明有左括号没有右括号来与之配对,return false
扩展:
也可以通过设置计数器变量存储左右括号出现次数来进行判断
pps:只有一种括号类型的时候才可以使用计数器这个方法:(())
,如果不止一种括号,可能就会有些样例过不了,所以这个方法是无效的,例如这种示例4([)]'
,没有以正确的顺序闭合。
⭐:
- 栈的操作
- JS中的数组中的push、popAPI 就可以模拟栈
代码
/**
* @param {string} s
* @return {boolean}
*/
var isValid = function(s) {
const stk = [] //数组模拟栈
const mp = new Map()////对应括号映射
mp['(']=')'
mp['{']='}'
mp['[']=']'
for(let c of s){
//拿到左括号
// if(['(','[','{'].indexOf(c) != -1){
if(c in mp){
stk.push(c)
}else {
const top = stk.pop()
if(c !== mp[top]) return false //栈顶不是对应的左括号或者为undefined
}
}
if(stk.length)return false
return true//一切都恰当
};
4. 寻找两个正序数组的中位数|困难、低频
题目描述
给定两个大小分别为 m 和 n 的正序(从小到大)数组 nums1 和 nums2。请你找出并返回这两个正序数组的 中位数 。
算法的时间复杂度应该为 O(log (m+n)) 。
解题思路
不完全暴力
双指针分别从两个数组A、B起始位开始
- 如果
A[i] <= B[j]
, 则把A[i]
放入新的数组中,i 往后移一位,即 i++ . - 如果
A[i] > B[j]
, 则把B[j]
放入新的数组中,j 往后移一位, 即 j++ . - 计数器cnt,每次任意指针时都
- 重复,直到
cnt == (n+1)/2
,即到达中位数之地
虽然比起完全合并优化了一些,小于O(n+m)
,但没有达到O(log(n+m))的要求
二分查找
⭐:
- 两个有序数组合并后,一个数组中本身的相对位置并不会变
- 有序——二分查找
- 对小的那个数组进行二分,确定一个 i,自然就能得到 j,因为最后的找到中位数的情况就是
(i+1) + (j+1) == (m + n + 1) / 2
其中m、n分别两个数组的大小
具体来说,就是最后的情况满足len(Aleft)+len(Bleft)=(m+n+1)/2
,并且满足(maxLeftA <= minRightB && maxLeftB <=minRightA)
,也就是最终为下图这样的形式:
那么要怎么到达这样的形式呢
对小的那一个数组进行二分查找,不满足就调整,具体怎么调整看代码,直到调整到满足条件
最终复杂度为O(min(m,n))
代码
/**
* @param {number[]} nums1
* @param {number[]} nums2
* @return {number}
*/
var findMedianSortedArrays = function(nums1, nums2) {
//对数组长度较短的那个进行二分查找操作
nums1.length > nums2.length && ([nums1, nums2] = [nums2, nums1])
const m = nums1.length,
n = nums2.length
let low = 0, high = m
//二分
while(low <= high){
const i = low + Math.floor((high - low) / 2), //i:数组A中 minRightA 的下标
j = Math.floor((m + n + 1) / 2) - i //j:数组B中 minRightB 的下标
//这里注意特判,在边界的时候为了满足下面的条件,left的就是负无穷,right那头就是正无穷
const maxLeftA = i === 0 ? -Infinity : nums1[i-1],
minRightA = i === m ? Infinity : nums1[i]
const maxLeftB = j === 0 ? -Infinity : nums2[j - 1],
minRightB = j === n ? Infinity : nums2[j]
//进行判断
if(maxLeftA <= minRightB && maxLeftB <= minRightA){
return (m + n) & 1 //m+n 的奇偶情况分量讨论
? Math.max(maxLeftA, maxLeftB)
: (Math.max(maxLeftA, maxLeftB) + Math.min(minRightA, minRightB))/2
}//不满足的话就要根据情况调整 low / high 指针
else if(maxLeftA > minRightB){
high = i - 1
}else {
low = low + 1
}
}
};
1. 两数之和|简单、高频
题目描述
给定一个整数数组 nums 和一个整数目标值 target,请你在该数组中找出 和为目标值 target 的那 两个 整数,并返回它们的数组下标。
你可以假设每种输入只会对应一个答案。但是,数组中同一个元素在答案里不能重复出现。
你可以按任意顺序返回答案。
解题思路
暴力
最容易想到的就是两层for来遍历,得到满足条件的两个整数
没什么好说的,也当然是必须优化的
时间O(n^2),空间O(1)
Map
用 Map 记录遍历过的值以及对应下标,同时当前遍历到的值为 value
,判断( target - value)
是否记录过在Map 中
⭐:
- 求和 —> 求差
- Map 记录值与下标的映射
- 空间换时间
时间O(1),空间O(n)
代码
/**
* @param {number[]} nums
* @param {number} target
* @return {number[]}
*/
var twoSum = function (nums, target) {
const mp = new Map();
for (let i = 0; i < nums.length; i++) {
const v = nums[i];
const diff = target - v;
if (mp.has(diff)) return [mp.get(diff), i];
mp.set(v, i);
}
};
88. 合并两个有序数组|简单、高频
题目描述
给你两个按 非递减顺序 排列的整数数组 nums1 和 nums2,另有两个整数 m 和 n ,分别表示 nums1 和 nums2 中的元素数目。
请你 合并 nums2 到 nums1 中,使合并后的数组同样按 非递减顺序 排列。
注意:最终,合并后数组不应由函数返回,而是存储在数组 nums1 中。为了应对这种情况,nums1 的初始长度为 m + n,其中前 m 个元素表示应合并的元素,后 n 个元素为 0 ,应忽略。nums2 的长度为 n
解题思路
暴力
没做过的第一个想法肯定是将num2放到num1后面,然后再一起进行排序
但这显然就用不到两个数组一开始就是有序的特征了
归并排序
复习一下归并排序的步骤:
- 申请空间,使其大小为两个已经排序序列之和,该空间用来存放合并后的序列;
- 设定两个指针,最初位置分别为两个已经排序序列的起始位置;
- 比较两个指针所指向的元素,选择相对小的元素放入到合并空间,并移动指针到下一位置;
- 重复步骤 3 直到某一指针达到序列尾;
- 将另一序列剩下的所有元素直接复制到合并序列尾。
关键在于第三步~
比较两个数组头元素然后将较小的推至最终数组,并将其从原数组中吐出去,不断循环,直到两个数组都为空
但值得注意的是 题目要求 不返回一个新数组,而是存储再数组num1中,也就是要求原地修改,不能申请多的空间~ 为此,题目里说了 num1 的初始长度为 m+n,后n个元素为0
那么我们可以从后往前比较,从后面插入即可,用三个指针模拟
- cur:记录当前到那个位置
- m:记录 num1 数组处理到哪里
- n:记录 num2 数组处理到哪里
时间复杂度O(n) 空间复杂度O(1)
代码
/**
* @param {number[]} nums1
* @param {number} m
* @param {number[]} nums2
* @param {number} n
* @return {void} Do not return anything, modify nums1 in-place instead.
*/
var merge = function(nums1, m, nums2, n) {
let cur = m + n - 1 //从nums1尾部开始
while(cur >= 0){
if(n===0)return //num2已经全部放入num1中了
if(m < 1){//num1指针先走完了
nums1[cur--] = nums2[--n]
continue
}
if(n < 1){//num2指针先走完了
nums1[cur--] = nums1[--m]
continue
}
//取较大的插入 nums1 的末尾、更新对应的指针
if(nums1[m - 1] > nums2[n - 1]){
nums1[cur--] = nums1[--m]
}else {
nums1[cur--] = nums2[--n]
}
}
};
415. 字符串相加|简单、高频
题目描述
给定两个字符串形式的非负整数 num1 和num2 ,计算它们的和并同样以字符串形式返回。
你不能使用任何內建的用于处理大整数的库(比如 BigInteger), 也不能直接将输入的字符串转换为整数形式。
解题思路
直接模拟
对两个非负整数进行 竖式计算 的模拟即可
将相同数位对齐,从低到高逐位相加,如果当前位和超过 1010,则向高位进一位
双指针从数的末尾即最低位开始,逐位相加,记录进位到 add 变量中
较少位的那一个数就在前面补零
⭐:
num1.charAt(i) - '0'
将一个数字字符转为数字- 最后要
reverse
才是正确顺序的结果 - 再用
join
将数组转为字符串
时间复杂度O(n),空间复杂度O(1)
代码
/**
* @param {string} num1
* @param {string} num2
* @return {string}
*/
var addStrings = function(num1, num2) {
let i = num1.length - 1,
j = num2.length - 1
add = 0
const ans = []
while(i >= 0 || j >= 0 || add > 0){
const a = i >= 0 ? num1[i].charAt() - '0' : 0,
b = j >= 0 ? num2[j].charAt() - '0' : 0
const res = a + b + add
ans.push(res % 10)
add = Math.floor(res / 10)
i--,j--
}
return ans.reverse().join('')
};
165. 比较版本号|中等、高频
题目描述
给你两个版本号 version1 和 version2 ,请你比较它们。
版本号由一个或多个修订号组成,各修订号由一个 ‘.’ 连接。每个修订号由 多位数字 组成,可能包含 前导零 。每个版本号至少包含一个字符。修订号从左到右编号,下标从 0 开始,最左边的修订号下标为 0 ,下一个修订号下标为 1 ,以此类推。例如,2.5.33 和 0.1 都是有效的版本号。
比较版本号时,请按从左到右的顺序依次比较它们的修订号。比较修订号时,只需比较 忽略任何前导零后的整数值 。也就是说,修订号 1 和修订号 001 相等 。如果版本号没有指定某个下标处的修订号,则该修订号视为 0 。例如,版本 1.0 小于版本 1.1 ,因为它们下标为 0 的修订号相同,而下标为 1 的修订号分别为 0 和 1 ,0 < 1 。
返回规则如下:
如果 version1 > version2 返回 1,
如果 version1 < version2 返回 -1,
除此之外返回 0。
解题思路
最方便的字符串切割
利用 split('.')
将版本号字符串切割为数组,从下标为0开始依次比对:
时间复杂度O(n),空间复杂度O(n)
/**
* @param {string} version1
* @param {string} version2
* @return {number}
*/
var compareVersion = function(version1, version2) {
const v1 = version1.split('.')
v2 = version2.split('.')
for (let i = 0; i < v1.length || i < v2.length; ++i) {
let x = 0, y = 0;
if (i < v1.length) x = parseInt(v1[i]);
if (i < v2.length) y = parseInt(v2[i]);
if (x !== y)return x > y ? 1 : -1;
}
return 0;
};
空间优化
我们可以不降其切割后放入数组再进行逐一比对,而是在切割时就进行逐一比对
代码
/**
* @param {string} version1
* @param {string} version2
* @return {number}
*/
var compareVersion = function (version1, version2) {
const n = version1.length,
m = version2.length;
let i = 0,
j = 0; //双指针
while (i < n || j < m) {
let a = 0, b = 0; //同一下标下的两个修订号
for (; i < n && version1[i] !== "."; ++i) {
a = a * 10 + version1[i] - "0";
}
++i; // 跳过点号
for (; j < m && version2[j] !== "."; ++j) {
b = b * 10 + version2[j] - "0";
}
++j; // 跳过点号
if (a !== b) return a > b ? 1 : -1;
}
return 0;
};
70. 爬楼梯|简单、中频
题目描述
假设你正在爬楼梯。需要
n
阶你才能到达楼顶。每次你可以爬
1
或2
个台阶。你有多少种不同的方法可以爬到楼顶呢?
解题思路
动态规划
f(x) 表示爬到第x级阶梯的方案数,由每次可走一步或两步可知
- 转移条件为:
f(x)=f(x-1)+f(x-2)
- 初始化:
f(0)=1、f(1)=1
,到第0级和第1级的方案数都是1
那么我们对f[]
数组从2开始的遍历即可
for(let i = 2; i <=n; i++){
f[i] = f[i-1] + f[i-2];
}
return f[n]
这样的时间复杂度和空间复杂度都是O(n)
我们发现,f(x)
只与f(x-1)
和f(x-2)
有关,也就是与f(x-2)
之前的东西再无瓜葛,最后要求的结果也只是f(x)
再利用滚动数组思想优化一下:直接用三个变量存储关键的东西就行了
var climbStairs = function(n) {
let p = 0, q = 0, r = 1;
for (let i = 1; i <= n; ++i) {
p = q;
q = r;
r = p + q;
}
return r;
};
现在时间复杂度为O(n),空间复杂度为O(1)
但这种dp的入门题,还用dp就有点low了(卷,就嗯卷)
矩阵快速幂
用这个方法需要对线性代数有了解,但如果只是想看懂我的题解的话,也不需要特别多
- 矩阵乘法:一句话总结,就是矩阵C 内第1行第1列的元素 = 矩阵A第一行元素 与 矩阵 B第一列元素对应各项相乘再累加后得到的
也算有关于数论吧
快速幂
//a^n
while(n>0)
{
if(n&1) res*=a;
n>>=1;
a=a*a;
}
对于n次幂,对n二进制话,二进制位上位1 的话就相乘,然后一直将n右移
什么时候可以用矩阵快速幂
矩阵快速幂的使用相较于dp可能没那么好看出来——也可能只是我相比这个来说dp比较熟悉
- 问题可转换为求解矩阵的n次方
- 结合快速幂优化效率
- 递归式形如齐次线性递推式
- 就可以构造出矩阵n次方再乘一个列向量a得到一个列向量b,且列向量b中包含我们需要的f(x)
- 递归式可以转换为齐次线性递推式
这道题目中的转移条件f(x)=f(x-1)+f(x-2)
就是上面的第二种情况:递归式形如其次线性递归式
这个方法的时间复杂度为O(logn)
如果你觉得难的话,其实只学dp也行,这个方法讲个思路应该也不错了…
代码
最终代码如下:
/**
* @param {number} n
* @return {number}
*/
var climbStairs = function(n) {
const q = [[1, 1], [1, 0]];
const res = pow(q, n);
return res[0][0];
};
const pow = (a, n) => {
let ret = [[1, 0], [0, 1]];
while (n > 0) {
if ((n & 1) === 1) {
ret = multiply(ret, a);
}
n >>= 1;
a = multiply(a, a);
}
return ret;
}
const multiply = (a, b) => {
const c = new Array(2).fill(0).map(() => new Array(2).fill(0));
for (let i = 0; i < 2; i++) {
for (let j = 0; j < 2; j++) {
c[i][j] = a[i][0] * b[0][j] + a[i][1] * b[1][j];
}
}
return c;
}
46. 全排列|中等、高频
题目描述
给定一个不含重复数字的数组
nums
,返回其 所有可能的全排列 。你可以 按任意顺序 返回答案。
解题思路
求全排列问题需要的前置知识:回溯~
以[1,2,3]
为例
- 从中选一个数a
- 再选一个数,且该数不能为选过的数
- 重复,直到选完所有的数
那么关键就在于 怎么判定该数选没选过,如果用多一个 map 专门判断,判断的时间复杂度为O(1),但是要用到额外的空间O(n),但是本来就需要用到 tmpList 存储临时结果,所以直接用自带的 includes 线性查找判断就好了
总体的时间复杂度还是O(n),空间复杂度O(n)
代码
// 回溯函数
function backtrack(lists, tmpList, nums){
if(tmpList.length === nums.length) return lists.push([...tmpList])
for(let i = 0; i < nums.length; i++){
if(tmpList.includes(nums[i])) continue
tmpList.push(nums[i])
backtrack(lists, tmpList, nums)
tmpList.pop() //回溯
}
}
/**
* @param {number[]} nums
* @return {number[][]}
*/
var permute = function(nums) {
const lists = []
backtrack(lists, [], nums)
return lists
};
53. 最大子序和|简单、高频
题目描述
给你一个整数数组
nums
,请你找出一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。子数组 是数组中的一个连续部分。
解题思路
暴力
计算所有[i...j]
和,留最大的max
作为答案。
时间复杂度 O(n^2)
,空间复杂度O(1)
代码就不写了,就是二重循环
动态规划 dp
想办法把他拆解为规模小一点的子问题~
用dp[i]
表示末尾下标为i
子序列中最大之和。
- 最后答案就是
{dp[i]|i∈[0,1,2,n-1]}
中最大值,这个在遍历dp[i]
的时候就可以存下来 - 那么已知
dp[i-1]
要怎么推出dp[i]
呢dp[i] = dp[i-1] + nums[i]
- 但是注意:有可能
dp[i-1]
是一个复数,那还不如不加呢。所以也可能是dp[i] = nums[i]
现在时间复杂度O(n)
,空间复杂度O(n)
。我们还能优化一下,不新建dp[]
数组,直接在传入的nums[]
上操作,空间复杂度为O(1)
动态规划代码
/**
* @param {number[]} nums
* @return {number}
*/
var maxSubArray = function(nums) {
const len = nums.length
let mx = nums[0]
for(let i = 1; i < len; i++){
nums[i] = Math.max(0, nums[i-1]) + nums[i]
if(nums[i] > mx)mx = nums[i]
}
return mx
};
前缀和
前缀和,一种降低查询操作复杂度的预处理手段,一句话概述的话就是这样:让s[i]
记录下标从0到i的和,那么[i, j]
的和就等于s[j] - s[i-1]
那么在这里该怎么结合在一起?
当s[i]
是s[0],s[1],...s[j-1]
最小值的时候,s[j]-s[i]
就是以 j 为下标的子序列之和的最大值了
我们这里只需要临时存储s[i]
就好了,所以直接用sum
变量就行
时间复杂度为O(n)
,空间复杂度为O(1)
前缀和代码
/**前缀和
* @param {number[]} nums
* @return {number}
*/
var maxSubArray = function(nums) {
const len = nums.length
let mx = nums[0], mn = 0, sum = 0
for(let i = 0;i < len; i++){
sum += nums[i]
if(sum - mn > mx) mx = sum - mn
if(sum < mn) mn = sum
}
return mx
};
112. 路径总和|容易、高频
题目描述
给你二叉树的根节点 root 和一个表示目标和的整数 targetSum 。判断该树中是否存在 根节点到叶子节点 的路径,这条路径上所有节点值相加等于目标和 targetSum 。如果存在,返回 true ;否则,返回 false 。
叶子节点 是指没有子节点的节点。
解题思路
这题形式上有点像两数之和有没有
看到这样的一个二叉树,我们可以想到使用深度优先搜索 dfs,来做这道题目。这里前中后序都无所谓
那么 dfs 递归函数的参数就应该是
- 二叉树的节点
- 一个用于判断是否达到目标和的计数器
- 如果用加法就比较麻烦,每一处都需要传入 targetSum 来在函数体内判断,我们可以用减法,第一次调用传入的是 targetSum ,后面判断是否为0就ok了
因为只需要搜索其中一条符合条件的路径,那么递归函数就需要一个返回值,当遇到合适的路径就及时返回,不要再继续搜索了
⭐:
- 节点的值可以为负,所以也不能剪枝,必须遍历完一整条路径
代码
/**
* 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 {TreeNode} root
* @param {number} targetSum
* @return {boolean}
*/
// var hasPathSum = function(root, targetSum) {
// const traversal = (node, cnt) =>{
// if(cnt === 0 && !node.left && !node.right) return true // 找到符合题意的叶子节点
// if(!node.left && !node.right) return false //不合适的叶子节点就直接返回
// // 往左右子节点找(如果子节点不为空的话)
// if(node.left && traversal(node.left, cnt - node.left.val)) return true
// if(node.right && traversal(node.right, cnt - node.right.val)) return true
// return false //都没找到合适的就 false
// }
// if(!root) return false //空树
// return traversal(root, targetSum - root.val)
// };
//将上面的 traversal 融合进来,简化代码
var hasPathSum = function(root, targetSum) {
if(!root) return false
if(!root.left && !root.right && targetSum === root.val) return true
return (
hasPathSum(root.left, targetSum - root.val) ||
hasPathSum(root.right, targetSum - root.val)
);
};
在最后
好了这就是本周的刷题记录与题解分享了,如果你已经按题型分类系统地刷了一遍算法面试题的各个题型,想感受一下面试题的”随机性”的话,欢迎一起~
🌊如果有所帮助,欢迎点赞关注,一起进步⛵