数组
11. 盛最多水的容器
分类:贪心 | 数组 | 双指针
给定一个长度为 n 的整数数组 height 。有 n 条垂线,第 i 条线的两个端点是 (i, 0) 和 (i, height[i]) 。 找出其中的两条线,使得它们与 x 轴共同构成的容器可以容纳最多的水。 返回容器可以储存的最大水量。
var maxArea = function(height) {
let maxArea = 0;
let leftP = 0;
let rightP = height.length - 1;
while (leftP < rightP) {
// 短板效应,计算当前的面积
const currentArea = (rightP - leftP) * Math.min(height[startIndex], height[endIndex])
// 每次计算完面积之后,和maxArea比较,更新maxArea
maxArea = Math.max(currentArea, maxArea);
// 当移动较大一边的时候,面积一定会减小。移动较小的那一端,面积可能增加
// (因为面积 = 宽度 * 高度, 而移动中宽度在减小,高度取决于矮的一端)
height[leftP] > height[rightP] ? rightP-- : leftP++;
}
return maxArea;
}
解题思路: 双指针。
- 容量取决于左右边的短板和相隔的距离。用
leftP和rightP从两端向中心移动:currentArea = (rightP - leftP) * Math.min(height[leftP], height[rightP])- 如果移动较大的那一边,面积一定变小。如果移动较小的那一边,面积
可能变大(因为面积 = 宽度 * 高度, 而移动中宽度在减小,高度又取决于矮的一端,所以你高的一端再怎么长高也无济于事)- 每次移动完之后算一下
currentArea,并且更新maxArea。直到leftP和rightP重合为止。
15. 三数之和
给你一个包含 n 个整数的数组 nums,判断 nums 中是否存在三个元素 a,b,c ,使得 a + b + c = 0 ?请你找出所有和为 0 且不重复的三元组。
注意:答案中不可以包含重复的三元组。
分类:数组 | 双指针
/**
* @param {number[]} nums
* @return {number[][]}
*/
var threeSum = function(nums) {
let res = [];
if (nums?.length < 3) {
return res;
}
// 先排序
nums.sort((a, b) => a - b);
debugger
for (let i = 0; i < nums.length; i++) {
if (nums[i] > 0){
break;
}
// i 重复的情况,则省去遍历
if (i - 1 >= 0 && nums[i] === nums[i - 1]) {
continue;
}
let left = i + 1;
let right = nums.length - 1;
while(left < right) {
const count = nums[i] + nums[left] + nums[right];
if (count === 0) {
res.push([nums[i], nums[left], nums[right]]);
// 重点: push之后,left++, right--,但是这个时候left++ || right-- 不能和这一次的值相同
while(left < right && nums[left] === nums[left + 1]) {
left += 1;
}
while(left < right && nums[right] === nums[right - 1]) {
right -= 1;
}
left++;
right--;
} else if (count < 0) {
left += 1;
} else if (count > 0) {
right -= 1;
}
}
}
return res;
}
解题思路:排序 + 双指针。
先排序,排序后固定一个数 nums[i],再使用左右指针指向nums[i]后面的两端,数字分别为 nums[L]和 nums[R],计算三个数的和 sum 判断是否满足为 0,满足则添加进结果集
如果nums[i] > 0. 那没什么好说的了,直接退出循环,return res;
如果nums[i] === nums[i - 1],则表示重复了,continue,进入下一轮循环
如果count === 0,则res.push([nums[i], nums[left], nums[right]]);
如果count < 0,则 left++;
如果count > 0,则 right--;
26. 删除有序数组中的重复项
分类:数组 | 双指针
给你一个 升序排列 的数组 nums ,请你 原地 删除重复出现的元素,使每个元素 只出现一次 ,返回删除后数组的新长度。元素的 相对顺序 应该保持 一致 。 由于在某些语言中不能改变数组的长度,所以必须将结果放在数组nums的第一部分。更规范地说,如果在删除重复项之后有 k 个元素,那么 nums 的前 k 个元素应该保存最终结果。 将最终结果插入 nums 的前 k 个位置后返回 k 。 不要使用额外的空间,你必须在 原地 修改输入数组 并在使用 O(1) 额外空间的条件下完成。
/**
* @param {number[]} nums
* @return {number}
*/
var removeDuplicates = function (nums) {
let slow = 0;
let fast = 0;
while(fast < nums.length) {
if (nums[slow] !== nums[fast]) {
slow++;
nums[slow] = nums[fast];
}
fast += 1;
}
return slow + 1;
};
解题思路:双指针(快慢指针) + 分区
- 定义两枚指针:
current指针来遍历数组、partition指针用来分区,保证[0, partition]的所有数都为是唯一的。- 当
current遍历到下一个和nums[partition]不相同的数时,交换current和partition的数,并且partition++、同时current++。(为什么只和partition比较就可以?因为数组是升序的)- 遇到和
partition相同的数时,current++达到删除重复数的效果**Tips: **模式识别:
原地基本上意味着需要用交换来实现。如果是操作数组,为了不频繁移动数组,大概率使用双指针。(但是在实际操作中,遇到这样的问题我觉得更好的做法应该是用Hash Map)
33. 搜索旋转排序数组
分类:数组 | 二分查找
整数数组 nums 按升序排列,数组中的值 互不相同 。 在传递给函数之前,nums 在预先未知的某个下标 k(0 <= k < nums.length)上进行了 旋转,使数组变为 [nums[k], nums[k+1], ..., nums[n-1], nums[0], nums[1], ..., nums[k-1]](下标 从 0 开始 计数)。例如, [0,1,2,4,5,6,7] 在下标 3 处经旋转后可能变为 [4,5,6,7,0,1,2] 。 给你 旋转后 的数组 nums 和一个整数 target ,如果 nums 中存在这个目标值 target ,则返回它的下标,否则返回 -1 。 你必须设计一个时间复杂度为 O(log n) 的算法解决此问题。
var search = function (nums, target) {
// 时间复杂度:O(logn)
// 空间复杂度:O(1)
// [6,7,8,1,2,3,4,5]
if (nums.length === 1) {
return nums[0] === target ? 0 : -1;
}
let start = 0;
let end = nums.length - 1;
while (start < end) {
// 取 mid
const mid = parseInt((left + right) / 2);
// 返回结果
if (nums[mid] === target) {
return mid;
}
// 边界场景特殊处理 直接返回
if (nums[start] === target) {
return start;
}
// 边界场景特殊处理 直接返回
if (nums[end] == target) {
return end;
}
// 假设比左侧第一个还要小,那么说明mid的右侧是 有序递增的
if (nums[mid] < nums[start]) {
// 如果 target 就在右边区间内,那么下一个二分的区间就为[mid + 1, end]
if (nums[mid] < target && nums[end] > target) {
start = mid + 1;
} else {
// 假设右边最大的都比target小,那么就在左边找
end = mid - 1;
}
// 说明 mid 的左侧是有序递增的
} else {
// 如果 target 就在左边区间内,那么下一个二分的区间就为[start, mid - 1]
if (nums[start] < target && nums[mid] > target) {
end = mid - 1;
} else {
// 假设不在左边区间内,那么就找右边区间
start = mid + 1;
}
}
}
return -1;
};
解题思路:二分查找法。这题首先,O(logn),我们首先想到二分查找法。
- 二分查找法的前提是,必须保证有序,我们通过观察,可以发现,翻转后的数组,可以看成是两个有序数组。
- 假设采用二分法,用mid将数组拆成两半的时候,肯定有一半是有序的,有一半是无序的(可以想象一下)
- 判断target的左侧和右侧哪边是递增的,来进一步判断target是落在左右两边哪个数组内
- 最后可以想象一下,当缩减的数组长度为1的时候还没有找到target, 那么说明target不存在,直接返回-1
Tips:这题本质上还是二分查找法,本质上是在不断缩小搜索的范围,直到缩小到可以直接在
left、right、mid中找到target为止。
34. 在排序数组中查找元素的第一个和最后一个位置
分类:数组 | 二分查找
给你一个按照非递减顺序排列的整数数组 nums,和一个目标值 target。请你找出给定目标值在数组中的开始位置和结束位置。 如果数组中不存在目标值 target,返回 [-1, -1]。 你必须设计并实现时间复杂度为 O(log n) 的算法解决此问题。
var searchRange = function(nums, target) {
// 先写个二分法
var left = 0;
var right = nums.length - 1;
while(left <= right) {
var mid = Math.floor((left + right) / 2);
if (nums[mid] === target) {
// 先找到一个target,从target的左右两边开始查找
left = mid - 1;
right = mid + 1;
while(left >= 0 && nums[left] === target) {
left--;
}
while(right <= nums.length - 1 && nums[right] === target) {
right++;
}
left++;
right--;
return [left, right];
}
if (nums[mid] > target) {
right = mid - 1;
} else if (nums[mid] < target) {
left = mid + 1;
}
}
return [-1, -1]
};
解题思路: 二分查找 + 中心扩散法。由于题目中提到nums是非递减的、且需要采用 O(log n) 的算法,我们首先联想到二分查找(快速排序也是基于二分法)。
具体做法:
- 用left,right分别指向数组的两端。并找到数组的中点,判断和target的关系:
- 如果nums[mid] === target,则以此为中心,向左右两边进行扩散,直到 !== target 为止,返回左右边界。
- 如果nums[mid] > target,则target一定在num[left] ~ nums[mid]中间,right - 1。重复
1的过程。- 如果nums[mid] < target,则target一定在num[mid] ~ nums[right]中间,left - 1。重复
1的过程。- 直到
right <= left时退出循环,返回[-1, -1]
48. 旋转图像
分类:数组 | 数学 | 矩阵
给定一个 n × n 的二维矩阵 matrix 表示一个图像。请你将图像顺时针旋转 90 度。 你必须在 原地 旋转图像,这意味着你需要直接修改输入的二维矩阵。请不要 使用另一个矩阵来旋转图像。
/**
* @param {number[][]} matrix
* @return {void} Do not return anything, modify matrix in-place instead.
*/
var rotate = function(matrix) {
const n = matrix.length;
for (let row = 0; row < Math.floor(n / 2); row++) {
for (let col = 0; col < Math.floor((n + 1) / 2); col++) {
const temp = matrix[row][col] // 需要引入一个临时变量来中转一下
matrix[row][col] = matrix[n - 1 - col][row];
matrix[n - 1 - col][row] = matrix[n - 1 - row][n - 1 - col];
matrix[n - 1 - row][n - 1 - col] = matrix[col][n - 1 - row];
matrix[col][n - 1 - row] = temp;
}
}
};
解题思路:找规律:
- 首先,对于矩阵中第
i行的第j个元素,在旋转后,它出现在倒数第i列的第j行个位置。推导出:
Matrix[j][n - 1 - i] = Matrix[i][j]
- 由于题目要求原地旋转,所以需要一次性同时交换四个位置:按照
1中的公式,找出这四个位置的旋转关系:
Matrix[j][n - 1 - i] = Matrix[i][j]
Matrix[n - 1 - i][n - 1 - j] = Matrix[j][n - 1 - i]
Matrix[n - 1 - j][i] = Matrix[n - 1 - i][n - 1 - j]
Matrix[i][j] = Matrix[n - 1 - j][i]
- 确定旋转的次数:
- 当n为偶数时:需要旋转 n^2 / 4 = n / 2 * n / 2次。即:双层循环,每层次数为 n / 2 次
- 当n为奇数时:需要旋转 (n^2 − 1) / 4 = ((n−1)/2) * ((n+1)/2)次。即:双层循环,第一层次数为n - 1 / 2次,第二层次数为 n + 1 / 2次(其实也可以反过来)。
- 综合以上两种情况:第一层循环的次数为
Math.floor(n / 2)次,第二层循环的次数为Math.floor((n + 1) / 2)次(其实也可以反过来)
56. 合并区间
分类:数组 | 排序
以数组 intervals 表示若干个区间的集合,其中单个区间为 intervals[i] = [starti, endi] 。请你合并所有重叠的区间,并返回 一个不重叠的区间数组,该数组需恰好覆盖输入中的所有区间 。
/**
* @param {number[][]} intervals
* @return {number[][]}
*/
var merge = function (intervals) {
const res = [];
// 先根据左区间进行排序,好处: 1.判断一端就能保证区间有没有重叠 2.只需要确定合并后的右区间
intervals = intervals.sort((a, b) => a[0] - b[0]);
// overLappingIntervals 为最新的重叠区间,默认的第一个重叠区间为 intervals[0]
let overLappingIntervals = intervals[0];
for (let i = 1; i < intervals.length; i++) {
let cur = intervals[i];
// 有重合: |------|
// |------|
if (overLappingIntervals[1] >= cur[0]) {
overLappingIntervals[1] = Math.max(cur[1], overLappingIntervals[1]);
} else {
// 不重合,overLappingIntervals推入res数组
res.push(overLappingIntervals);
overLappingIntervals = cur; // 更新 overLappingIntervals
}
}
// 重点!! 记得将最后一个重叠区间push
res.push(overLappingIntervals);
return res;
};
解题思路: 先排序,再合并。
- 先将区间集合根据左区间的进行升序排列。原因:
- 其实用一端就能判断出两个区间有没有重叠
- 只需要关心合并后的右区间
- 将默认的重叠区间设为
intervals[0]。然后从intervals[1]开始遍历,如果当前区间的左区间 <= 重叠区间的右区间。说明有重叠。将两个区间进行合并,并且更新重叠区间:overLappingIntervals[1] = max(overLappingIntervals[1], cur[1])。- 如果当前区间没和当前的重叠区间发生重叠,那么将重叠区间加入
res。并且更新最新的重叠区间为cur。- 记得遍历完区间后,需要将最后一个重叠区间加入到
res中。
75. 颜色分类
分类:数组 | 双指针 | 排序
给定一个包含红色、白色和蓝色、共 n 个元素的数组 nums ,原地对它们进行排序,使得相同颜色的元素相邻,并按照红色、白色、蓝色顺序排列。
我们使用整数 0、 1 和 2 分别表示红色、白色和蓝色。
必须在不使用库的sort函数的情况下解决这个问题。
/**
* @desc 红 白 蓝 荷兰国旗问题
* 用三指针做遍历交换
*/
var sortColors = function (nums) {
// 我们用 leftP、rightP两枚指针来做分区,< leftP的都为0、leftP <= number < =rightP的都为1、 > rightP的都为2
// 再定义一枚用于遍历的指针,在遍历的过程中,更新 leftP 和 rightP 的边界
let leftP = 0; // 左指针
let rightP = nums.length - 1; // 右指针
let currentP = 0; // 用于遍历的指针
// 结束条件: 遍历的指针在右指针的右边时候,已经完成分区
while (currentP <= rightP) {
console.log('nums', nums);
// 当前值为0:将0换到左边界的位置,并且将左边界的位置往前移动一位(左边界的值只可能为1, 初始化的时候比较特殊为0,所以换过来的时候不用看,位置一定是对的)
if (nums[currentP] === 0) {
console.log('nums[leftP]', nums[leftP]);
[nums[currentP], nums[leftP]] = [nums[leftP], nums[currentP]]
// 此时 leftP, currentP 向右移动一位
leftP++;
currentP++;
} else if (nums[currentP] === 1) {
// 不做交换,currentP向右移动一位
currentP++;
} else if (nums[currentP] === 2) {
// 为 2 的情况,将 currentP 和 rightP 换一下
// 注意,这个时候换过来的p2是还没有遍历过的,此时不需要移动,还得再下一次循环中判断一次
[nums[currentP], nums[rightP]] = [nums[rightP], nums[currentP]]
rightP--;
}
}
return nums;
};
解题思路: 双指针 + 分区:
- 我们用 leftP、rightP两枚指针来做分区,其中:[0, leftP)的都为0、[leftP, rightP]的都为1、(rightP, end]的都为2。
- 再定义一枚用于遍历的
current指针,在遍历的过程中会出现一下三种情况:
nums[current] === 0:将nums[current]和nums[left]交换,left指针和current指针都向前移动一位nums[current] === 1:不做任何处理,仅移动current指针nums[current] === 2:将nums[current]和nums[right]交换,仅把current指针向前移动一位。因为从右边界换过来的数是没有判断过的,需要在下一次循环中再判断一次。
88. 合并两个有序数组
分类:数组 | 双指针 | 排序
给你两个按 非递减顺序 排列的整数数组 nums1 和 nums2,另有两个整数 m 和 n ,分别表示 nums1 和 nums2 中的元素数目。 请你 合并 nums2 到 nums1 中,使合并后的数组同样按 非递减顺序 排列。 注意:最终,合并后数组不应由函数返回,而是存储在数组 nums1 中。为了应对这种情况,nums1 的初始长度为 m + n,其中前 m 个元素表示应合并的元素,后 n 个元素为 0 ,应忽略。nums2 的长度为 n 。
/**
* @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 point1 = m - 1; // 从数组的末尾开始遍历
let point2 = n - 1; // 从数组的末尾开始遍历
let point3 = m + n - 1;
while(point1 >= 0 && point2 >= 0) {
if (nums1[point1] > nums2[point2]) {
nums1[point3] = nums1[point1];
point1--;
point3--;
} else {
nums1[point3] = nums2[point2];
point2--;
point3--;
}
}
while(point2 >= 0) {
nums1[point3] = nums2[point2];
point3--;
point2--;
}
while(point1 >= 0) {
nums1[point3] = nums1[point1];
point3--;
point1--;
}
return nums1;
};
解题思路:逆向双指针:
- 因为数组是升序排列的,所以我们逆向遍历数组,将较大的数逆向插入的nums1的尾部。
- 当其中一个数组遍历完之后,循环遍历另外一个数组即可。
189. 轮转数组
分类:数组 | 数学 | 双指针
给你一个数组,将数组中的元素向右轮转 k 个位置,其中 k 是非负数。
/**
* @param {number[]} nums
* @param {number} k
* @return {void} Do not return anything, modify nums in-place instead.
*/
var rotate = function(nums, k) {
while (k > 0) {
const lastVal = nums.pop();
nums.unshift(lastVal);
k--;
}
};
解题思路:弹出数组的最后一个数,往数组的头插入。循环执行k遍。
215. 数组中的第K个最大元素
分类:数组 | 分治 | 快速选择 | 排序 | 堆(优先队列)
给定整数数组 nums 和整数 k,请返回数组中第 k 个最大的元素。 请注意,你需要找的是数组排序后的第 k 个最大的元素,而不是第 k 个不同的元素。 你必须设计并实现时间复杂度为 O(n) 的算法解决此问题。
/**
* @param {number[]} nums
* @param {number} k
* @return {number}
*/
var findKthLargest = function(nums, k) {
// 从大到小排序后,取第 k - 1 个
return (nums.sort((a, b) => b - a))[k - 1]
};
解题思路:排序从大到小排序后,取第 k - 1 个。
**Tips:**topK的问题可以使用堆排序来做,但是JS并不提供这种数据结构,得自己写一个堆
283. 移动零
分类:数组 | 双指针
给定一个数组 nums,编写一个函数将所有 0 移动到数组的末尾,同时保持非零元素的相对顺序。 请注意 ,必须在不复制数组的情况下原地对数组进行操作。
/**
* @param {number[]} nums
* @return {void} Do not return anything, modify nums in-place instead.
*/
var moveZeroes = function (nums) {
let current = 0; // current指针来遍历数组
let partition = 0; // partition指针用来分区,保证该指针之前的所有数都为非0的数
while (current < nums.length) {
// 如果遍历到非零数,交换`current`和`partition`的数,并且`partition++`、同时` current++`。
if (nums[current] !== 0) {
[nums[current], nums[partition]] = [nums[partition], nums[current]];
partition++;
current++;
} else {
// 遍历到数字零,仅`current++`(这个地方的零会有两种结果:1. 之后会被非0的数覆盖 2. 在这个位置保持不动,但是位置肯定也是正确的)。
current++;
}
}
};
解题思路:双指针(快慢指针) + 分区
定义两枚指针:
current指针来遍历数组、partition指针用来分区,保证该指针之前的所有数都为非0的数。如果遍历到非零数,交换
current和partition的数,并且partition++、同时current++。遍历到数字零,仅
current++(这个地方的零会有两种结果:1. 之后会被非0的数覆盖 2. 在这个位置保持不动,但是位置肯定也是正确的)。**Tips:**模式识别:
原地基本上意味着需要用交换来实现,而且为了不频繁移动数组,大概率使用双指针。
链表
2. 两数相加
分类:递归 | 链表 | 数学
给你两个 非空 的链表,表示两个非负的整数。它们每位数字都是按照
逆序的方式存储的,并且每个节点只能存储一位数字。请你将两个数相加,并以相同形式返回一个表示和的链表。
var addTwoNumbers = function(l1, l2) {
const dummyHead = new ListNode();
let temp = dummyHead;
let andOne = 0;
// 开始遍历l1、l2 结束的条件(l1和l2都遍历完了,还有一种情况是l1+l2 >= 10的情况,还需要添加一个)
while(l1 || l2 || andOne) {
const number1 = l1 ? l1.val : 0;
const number2 = l2 ? l2.val : 0;
const num = (number1 + number2 + andOne) % 10; // 逢10进1
andOne = number1 + number2 + andOne >= 10 ? 1 : 0; // 这里决定要不要进一(要把上一次进位的值加上)
const newNode = new ListNode(num); // 要插入的新节点
temp.next = newNode; // 将新节点挂在dummyHead节点上(temp是用来移动dummyHead节点的)
temp = temp.next; // 移动temp节点为下个节点;
l1 = l1 && l1.next;
l2 = l2 && l2.next;
}
return dummyHead.next; // 将dummyHead节点的next返回
}
解题思路:链表。把两个链表看成是相同长度的(不存在的位置用0代替),逢十进一。
**Tips:**做链表的题目一般来说会设置一个虚拟的头节点
dummyHead,最后返回dummyHead.next;并且会用一个temp来作为中间节点来链接新的节点。
19. 删除链表的倒数第 N 个结点
分类:链表 | 双指针
给你一个链表,删除链表的倒数第 n 个结点,并且返回链表的头结点。
/**
* @param {ListNode} head
* @param {number} n
* @return {ListNode}
*/
const removeNthFromEnd = function(head, n) {
if (!head.next) {
return head.next;
}
// 首先,有两枚指针,快指针和慢指针
let dummyHead = new ListNode(0, head);
let fast = slow = dummyHead;
// 首先,快指针先走n步,然后快慢指针同时走,直到快指针走到末尾(由于快指针一直比慢指针快n,此时慢指针就是倒数第n个,即要删除的那个位置)
while(n--) {
fast = fast.next;
}
if(!fast) return dummyHead.next; // n > 链表长度的边界情况
// 然后 fast和slow同时走
while(fast.next) {
fast = fast.next;
// 如果fast的下一次是最后一个元素了 slow就不走了
slow = slow.next;
}
// 这个时候,fast已经走到末尾了,此时slow的下一个元素就是要删除的元素。将其删除
slow.next = slow.next.next;
return dummyHead.next;
}
解题思路:快慢指针。我们希望的是:找到倒数第n个的节点,并且把这个节点删除,并且同时还需要记录头指针。试想一下,如果是单指针的话,遍历一次肯定是不够的,因为我永远不知道我遍历的是倒数第几个,所以可以换一个思路,设置一个间隔为n的双指针,当快指针指到最后一个时,慢指针就是倒数n个。
- 首先快指针先走n步
- 慢指针和快指针同时走,直到快指针走到末尾
- 那么此时的慢指针的下一个节点就是要删除的节点:
slow.next = slow.next.nextTips: 做链表删除的题目,推荐设置一个虚拟的头节点,因为我们有可能会将头结点删除。设置一个虚拟头结点就可以不需要考虑删除头结点的情况。
21. 合并两个有序链表
分类: 递归 | 链表
将两个升序链表合并为一个新的 升序 链表并返回。新链表是通过拼接给定的两个链表的所有节点组成的。
var mergeTwoLists = function (l1, l2) {
const dummyHead = new ListNode(-1);
let prev = dummyHead;
while (l1 != null && l2 != null) {
if (l1.val <= l2.val) {
prev.next = l1;
l1 = l1.next;
} else {
prev.next = l2;
l2 = l2.next;
}
prev = prev.next;
}
// 合并后 l1 和 l2 最多只有一个还未被合并完,我们直接将链表末尾指向未合并完的链表即可
prev.next = l1 === null ? l2 : l1;
return dummyHead.next;
};
解题思路:合并两个链表。同时遍历l1,l2,比较l1.val和l2.val的大小。如果其中一条遍历完了,就直接把剩下的那条接在新链表的后面。
24. 两两交换链表中的节点
分类:递归 | 链表
给你一个链表,两两交换其中相邻的节点,并返回交换后链表的头节点。你必须在不修改节点内部的值的情况下完成本题(即,只能进行节点交换)。
/**
* @param {ListNode} head
* @return {ListNode}
*/
var swapPairs = function(head) {
const dummyHead = new ListNode('', head);
let current = dummyHead;
// 当current后面只剩下一个节点的时候,或者没有节点的时候,无法进行交换,遍历结束。
while (current.next && current.next.next) {
// 先保存一下原来的节点,防止断开后找不到原来的节点
const beforeExNext = current.next;
const beforeExNextNext = current.next.next;
const beforeExNextNextNext = current.next.next.next;
// 进行交换
current.next = beforeExNextNext;
current.next.next = beforeExNext;
current.next.next.next = beforeExNextNextNext;
// current 向前两步走,遍历链表
current = current.next.next;
}
return dummyHead.next;
};
解题思路: 迭代:解题思路:
- 首先,交换两个节点,需要涉及到四个节点的指向改动:[ 被交换两个节点之前的那个节点、被交换节点1、被交换节点2、被交换两个节点后的那个点 ]。
- 创建虚拟头结点
dummyHead用来保存表头,创建current节点来遍历链表,current节点的下两个节点就是要进行交换的节点。当current后面只剩下一个节点的时候,或者没有节点的时候,无法进行交换,遍历结束。- 假设交换前的顺序是这样的
current -> node1 -> node2 -> next,那么交换后的顺序变为prev -> node2 -> node1 -> next具体做法:
- 创建
dummyHead、current节点用来保存表头、遍历链表(链表必备)- 先记录一下原来的节点,不然next一改,原来的
- beforeExNext = current.next;
- beforeExNextNext = current.next.next;
- beforeExNextNextNext = current.next.next.next;
- 进行交换:
- current.next = beforeExNextNext;
- current.next.next = beforeExNext;
- currnet.next.next.next = beforeExNextNextNext;
- current向前走两步: current = current.next.next;
141. 环形链表
分类:哈希表 | 链表 | 双指针
给你一个链表的头节点 head ,判断链表中是否有环。 如果链表中有某个节点,可以通过连续跟踪 next 指针再次到达,则链表中存在环。
/**
* 快慢指针法
* 假设操场上有两个人在跑步,A跑一圈要1分钟 B跑一圈要2分钟,那么A终究会把B套圈!!
*/
var hasCycle = function (head) {
let p1 = head;
let p2 = head;
while (p1 && p2 && p2.next) {
p1 = p1.next; // p1是满指针,每次跑一步
p2 = p2.next.next; // p2是快指针,每次跑两步
if (p1 === p2) {
return true;
}
}
return false;
};
解题思路: 双指针 + 快慢指针:用快慢两根指针,快的每次走2步,慢的每次走1步。
- 如果能遍历完,说明没有环
- 如果存在环,则快指针总有把慢指针追上的时候,这时候返回true
142. 环形链表 II
分类:哈希表 | 链表 | 双指针
给定一个链表的头节点 head ,返回链表开始入环的第一个节点。 如果链表无环,则返回 null。 如果链表中有某个节点,可以通过连续跟踪 next 指针再次到达,则链表中存在环。
var detectCycle = function (head) {
let p1 = head;
while (p1) {
if (p1.flag) {
return p1;
}
p1.flag = true;
p1 = p1.next;
}
return null;
};
解题思路: 遍历的时候记录一下: 每次遍历到了都记录一下。下次再遍历到,直接return
148. 排序链表
分类:链表 | 双指针 | 分治 | 排序 | 归并排序
给你链表的头结点 head ,请将其按 升序 排列并返回 排序后的链表 。
// 归并 + 快慢指针
function sortList(head) {
if (head === null || head.next === null) {
// 归并排序:这里返回的 head 是有序的(因为是单个节点)
return head;
}
// 通过快慢指针的方法将链表分成 [前半截,后半截]
let fast = head.next; // !!! 这个地方很难想到! 快指针要先走一步(因为当链表中有两个数的时候,会一直在死循环)
let slow = head;
while(fast !== null && fast.next !== null) {
fast = fast.next.next;
slow = slow.next;
}
let firstHalf = head;
let secondHalf = slow.next;
slow.next = null;
return merge(sortList(firstHalf), sortList(secondHalf));
}
// 合并两个有序列表
function merge(l1, l2) {
console.log('l1, l2', l1, l2);
const newList = new ListNode();
let temp = newList;
// 当l1、l2链表都还有下一个节点的时候,需要进行对比
while(l1 && l2) {
if (l1.val > l2.val) {
temp.next = l2;
l2 = l2.next;
} else {
temp.next = l1;
l1 = l1.next;
}
temp = temp.next;
}
temp.next = l1 === null ? l2 : l1;
return newList.next;
}
解题思路:归并 + 快慢指针:对链表进行排序:最适合链表的排序算法是归并排序(方法论来了)
归并排序的思想是:先递归的分解数组,再合并数组。链表中也是一样的:
- 先对链表进行递归分解,直到
head.next = null || head === null。这个时候的子链表可以看做是有序的了- 链表的拆分可以借助「快慢指针」,将链表拆成两份,直到不能分解为止
- 分解完成后,就做合并的操作,还可以借用21.合并两个有序链表来实现。
160. 相交链表
分类:哈希表 | 链表 | 双指针
给你两个单链表的头节点 headA 和 headB ,请你找出并返回两个单链表相交的起始节点。如果两个链表不存在相交节点,返回 null 。
var getIntersectionNode = function(headA, headB) {
if (!headA || !headB) {
return null;
}
// 用hash存
const hashMap = new Map();
while(headA) {
hashMap.set(headA, '');
headA = headA.next;
}
while(headB) {
if (hashMap.has(headB)) {
return headB;
}
headB = headB.next;
}
return null;
};
解题思路一:hash 表。先遍历一遍链表A,用hash表把每个节点都记录下来(注意要存节点引用而不是节点值)。然后再遍历节点B,找到在hash表中出现过的节点即为两个链表的交点。
// 双指针法
var getIntersectionNode = function (headA, headB) {
if (!headA || !headB) {
return null;
}
let pA = headA;
let pB = headB;
while(pA !== pB && pA && pB) {
if (pA === pB) {
return pA;
}
pA = pA.next ? pA.next : headB;
pB = pB.next ? pB.next : headA;
}
return null;
}
解题思路二:巧用双指针。使用两个指针pA、pB分别去遍历headA、headB。当headA遍历完了之后,遍历headB。headB遍历完了遍历headA。当headA和headB相等的时候就是相交的点。
证明:假设headA到相交点的距离为a。headB到相交点的距离为b。剩余长度为c。那么当第二次走到相交点的时候,pA走过的路程为 a + c + b。pB走过的路程为b + c + a。
206. 反转链表
分类:递归 | 链表
/**
* @param {ListNode} head
* @return {ListNode}
*/
var reverseList = function(head) {
let prev = null; // 可以理解为 prev 指针是current的前一个,刚刚开始为null
let current = head;
while(current) {
// 用于临时存储的 head 后继指针
const currentNext = current.next;
// 翻转
current.next = prev;
// prev 和 current 指针都向前走一步
prev = current;
current = currentNext;
}
// 最终 prev 指向了原链表的表尾,也就是翻转后链表的表头
return prev;
}
解题思路: 迭代。解题思路:
- 反转链表需要涉及到3个指针,[当前指针的前指针,当前指针,当前指针的后指针]。
- 由于做翻转操作,所以链表一定会断开,所以需要一个全局变量来记录一下已经反转好了的链表的表尾。
- 实际上,当前指针的前指针就是已经反转好了的链表的表尾,所以这一个变量有两层含义。
- 还需要一枚
current指针来遍历当前的链表。具体做法
current从链表的表头开始- 先把
current.next保存一下,不然反转一下,「当前指针的后指针」就拿不到了- 反转比较简单,直接改变
current.next就行prev,current都在原来的位置上后移一位- 最终 prev 指向了原链表的表尾,也就是翻转后链表的表头。最后返回 pre 指针就好。
234. 回文链表
分类:栈 | 递归 | 链表 | 双指针
给你一个单链表的头节点 head ,请你判断该链表是否为回文链表。如果是,返回 true ;否则,返回 false 。
/**
* @param {ListNode} head
* @return {boolean}
*/
var isPalindrome = function(head) {
const stack = [];
let temp = head;
while(temp) {
stack.push(temp);
temp = temp.next;
}
while(head) {
if(head.val !== stack.pop().val) {
return false;
}
head = head.next;
}
return true;
};
解题思路一:栈:利用栈的对称性。
- 首先,遍历链表,将值都存入stack中
- 再遍历一遍链表,利用栈后入先出的特点一一比对。
/**
* @desc 利用数组 + 双指针进行判断
*/
const isPalindrome = (head) => {
const vals = [];
while (head) { // 丢进数组里
vals.push(head.val);
head = head.next;
}
let start = 0, end = vals.length - 1; // 双指针
while (start < end) {
if (vals[start] != vals[end]) { // 如果不同,不是回文,直接返回false
return false;
}
start++;
end--; // 双指针移动
}
return true; // 循环结束也没有返回false,说明是回文
};
解题思路二: 数组 + 双指针: 遍历一遍,把值放入数组中,然后用双指针判断是否回文。
/**
* @desc 利用数组 + 双指针进行判断
*/
var isPalindrome = function (head) {
// 这句可有可无,毕竟题目中说了链表长度至少为1
if (!head) return true;
let slow = head,
fast = head.next; // 重点1:快指针刚开始要指向head.next
while (fast && fast.next) {
slow = slow.next;
fast = fast.next.next;
}
let back = reverseList(slow.next); // 重点2:反转是从slow.next开始
while (back) {
if (head.val !== back.val) {
return false;
}
head = head.next;
back = back.next;
}
return true;
};
var reverseList = function(head) {
let pre = null;
while(head) {
// 用于临时存储的 head 后继指针
const temp = head.next;
head.next = pre;
pre = head;
head = temp;
}
return pre;
}
解题思路三:快慢指针
- 定义两个指针
slow和fast初始分别指向head和head.next。- 我们可以思考一下:slow每次向后走一格,fast每次向后走两格,当fast或fast.next到链表末尾时,slow指针正好走到链表的中心位置(如果是奇数个节点,正好是中心;如果是偶数个节点,则是中心前一个节点)。
- 所以,当slow走到中心后,我们只需要,将slow.next也就是中心之后的链表段进行翻转。
- 再将原来的head和翻转后的slow.next进行一一比对即可。
Tips:这里有两个细节:快指针刚开始要指向head.next、反转是从slow.next开始
字符串
3. 无重复字符的最长子串
分类:哈希表 | 字符串 | 滑动窗口
给定一个字符串 s ,请你找出其中不含有重复字符的 最长子串 的长度。
/**
* @param {string} s
* @return {number}
*/
var lengthOfLongestSubstring = function (s) {
let subStr = "";
let maxLen = 0;
for (let i = 0; i < s.length; i++) {
// 如果存在,则从当前开始重新计算
if (subStr.includes(s[i])) {
const index = subStr.indexOf(s[i]);
subStr = subStr.substring(index + 1) + s[i];
// maxLen = Math.max(maxLen, subStr.length);
} else {
subStr += s[i]; // 如果没有重复的,则把子串加上当前的字符串
maxLen = Math.max(maxLen, subStr.length); // 更新maxLen
}
}
return maxLen;
};
解题思路一:遍历字符串,生成子串。遍历字符的时候对子串进行判断:
- 如果子串中不存在该字符:则加上该字符生成新的子串。并且更新maxLen。
- 如果子串中该字符已经存在过,舍弃前一个相同字符串之前的所有字符串,继续遍历生成子串。
/**
* @param {string} s
* @return {number}
*/
var lengthOfLongestSubstring = function(s) {
// 遍历字符串,将出现过字符串的下标记录到 Hash 表中。并且用 left 指针 和 right 指针表示当前子串的范围。
let maxLen = 0;
let left = 0;
let charCodeMap = new Map(); // charCodeMap存储的是该字符串最后一次出现的下标(Map对频繁添加删除键值对的场景表现更好)
for(let right = 0; right < s.length; right++) {
// 如果 Hash 表中存在该字符串 && 并且对应的下标在 [left,right]当前的子串范围内
// 认为出现了重复字符串,则将 left 指针移动到重复字符串下标的下一位。
if (charCodeMap.has(s[right]) && charCodeMap.get(s[right]) >= left) {
left = charCodeMap.get(s[right]) + 1; // 把慢指针移到前面的重复字符的下一位
charCodeMap.set(s[right], right); // 将字符和下标的对应关系记录在 Hash 表中
} else {
// 如果 Hash 表中不存在该字符串
charCodeMap.set(s[right], right); // 将字符和下标的对应关系记录在 Hash 表中
// 正常情况
maxLen = Math.max(maxLen, right - left + 1)
}
}
return maxLen;
}
解题思路二:快慢指针 + HashMap。遍历字符串,将出现过字符串的下标记录到 Hash 表中。并且用 left 指针 和 right 指针表示当前子串的范围。
- 如果 Hash 表中不存在该字符串:则将该字符和下标的对应关系维护到 Hash 表中。
- 如果 Hash 表中存在该字符串 && 对应的下标在 [left,right]当前的子串范围内:则认为出现了重复字符串,则将 left 指针移动到重复字符下标的下一位。
Tips: charCodeMap存储的是该字符串最后一次出现的下标。
5. 最长回文子串
分类:字符串 | 动态规划
给你一个字符串 s,找到 s 中最长的回文子串。
/**
* @desc 中心扩散法
*/
function longestPalindrome(s) {
if (s == null || s.length == 0) {
return "";
}
let strLen = s.length;
let maxLen = 1; // 初始化 - 最大回文字符串的长度
let maxStartIndex = 0; // 初始化 - 最大回文字符串的起始位置
for (var cPoint = 0; cPoint < strLen; cPoint++) {
let len = 1;
let leftP = cPoint;
let rightP = cPoint;
// 回文子串有两种形式: 1. aa这种形式 2.aba, baab这种形式
// 先向左右两边分别扩散,优先找出aa,bb这种格式,同时更新以当前 cPoint 为中心的最长回文子串的长度
while (leftP - 1 >= 0 && s.charAt(leftP - 1) === s.charAt(cPoint)) {
len += 1;
leftP -= 1;
}
while (rightP + 1 < strLen && s.charAt(rightP + 1) === s.charAt(cPoint)) {
len += 1;
rightP += 1;
}
// 向左右两边同时扩散
while (
leftP - 1 >= 0 &&
rightP + 1 < strLen &&
s.charAt(leftP - 1) === s.charAt(rightP + 1)
) {
len += 2;
leftP -= 1;
rightP += 1;
}
// 扩散结束了,更新 maxLen 和 maxStartIndex
if (len > maxLen) {
maxLen = len;
maxStartIndex = leftP;
}
}
return s.substring(maxStartIndex, maxStartIndex + maxLen);
}
解题思路:中心扩散法:遍历字符串,从每一个位置出发,向两边扩散即可。遇到不是回文的时候结束。需要注意的是:需要考虑:aa、aba这两种形式的回文字符串。
- 以当前位置为中心点,先分别向左右两边扩散,优先找出
aa,bb这种格式,同时更新以当前 cPoint 为中心的最长回文子串的长度,和左右边界。- 同时向左右两边进行扩散,同时更新以当前 cPoint 为中心的最长回文子串的长度,和左右边界。
- 遇到不是回文,扩散结束。更新一下
maxLen和maxStartIndex- 根据
maxLen和maxStartIndex返回回文字符串
14. 最长公共前缀
分类:字符串
编写一个函数来查找字符串数组中的最长公共前缀。 如果不存在公共前缀,返回空字符串 ""。
var longestCommonPrefix = function(strs) {
if (strs.lenth === 0) {
return '';
}
let ans = strs[0];
// 获取两个字符串的公共前缀
const getCommonPrefix = (str1, str2) => {
let overIndex = 0;
for (let i = 0; i < str1.length; i++) {
if (str1[i] !== str2[i]) {
overIndex = i; // 需要区分一下提前结束的情况,这时候的 i 是不包含的 所以overIndex = i
break;
}
overIndex = i + 1; // 这种情况下的 overIndex 是包含的,所以overIndex = i + 1
}
return str1.substring(0, overIndex);
}
for (let i = 1; i < strs.length; i++) {
ans = getCommonPrefix(ans, strs[i]);
// 这里有一个优化点,如果某两个相邻的子串没有公共子串,则直接返回空字符串
if (ans === '') {
return ans;
}
}
return ans;
}
解题思路:两两找出公共前缀:
- 初始化公共前缀,默认为
strs[0]- 两两比对字符串,找出公共前缀,最终结果即为最长公共前缀。
20. 有效的括号
分类:栈 | 字符串
给定一个只包括 '(',')','{','}','[',']' 的字符串 s ,判断字符串是否有效。 有效字符串需满足:
- 左括号必须用相同类型的右括号闭合。
- 左括号必须以正确的顺序闭合。
/**
* @param {string} s
* @return {boolean}
*/
var isValid = function (s) {
const leftArr = ["(", "{", "["];
const rightArr = [")", "}", "]"];
const tagMap = {
"(": ")",
"{": "}",
"[": "]",
};
const stack = [];
for (let i = 0; i < s.length; i++) {
if (leftArr.includes(s[i])) {
stack.push(s[i]);
} else if (rightArr.includes(s[i])) {
// 如果是属于右括号大类的,则弹出栈顶的元素。并且检查是否是能相互抵消的
if (!(s[i] === tagMap[stack.pop()])) {
return false;
}
}
}
// 遍历完成,如果是两两相对的话,则栈中的元素为空
return stack.length === 0;
};
解题思路:栈,利用栈的对称性:
- 对字符串进行遍历,碰到'(','{','['的左括号入栈,碰到')','}',']'弹栈。
- 字符串的下一个必须和将要弹出的字符串配对才行。如果有一个不符合,终止遍历,直接返回false
- 字符串遍历完了之后,判断栈中元素是否为空(如果是两两相对的话,则栈中的元素为空)
HashMap
哈希表是根据关键码的值而直接进行访问的数据结构。一般哈希表都是用来快速判断一个元素是否出现集合里。
例如要查询一个名字是否在这所学校里。要枚举的话时间复杂度是O(n),但如果使用哈希表的话, 只需要O(1) 就可以做到。
1. 两数之和
分类:数组 | 哈希表
给定一个整数数组
nums和一个整数目标值target,请你在该数组中找出 和为目标值target的那 两个 整数,并返回它们的数组下标。
var twoSum = function (nums, target) {
const map = new Map();
let res = [];
nums.forEach((num, index) => {
const otherNum = target - num;
if (map.has(otherNum)) {
res = [map.get(otherNum), index];
} else {
map.set(num, index);
}
});
return res;
};
解题思路: HashMap ,遍历一遍列表,存在Map中。
49. 字母异位词分组
分类:数组 | 哈希表 | 字符串 | 排序
你一个字符串数组,请你将
字母异位词组合在一起。可以按任意顺序返回结果列表。
字母异位词是由重新排列源单词的字母得到的一个新单词,所有源单词中的字母通常恰好只用一次。
var groupAnagrams = function (strs) {
const mapObj = {};
strs.forEach((item) => {
// 解题重点步骤: 将 item 排序,将排序结果作为key
// (字母异位词排序后的字符串是相同的)
const key = Array.from(item).sort().join('')
if (mapObj[key]) {
mapObj[key].push(item);
} else {
mapObj[key] = [item];
}
});
return Object.values(mapObj);
};
解题思路: HashMap。在遍历的时候,将字符串先排序一下作为key,然后遍历strs收集字符串。最后输出。
var groupAnagrams = function(strs) {
const map = new Object();
for (let s of strs) {
const count = new Array(26).fill(0);
for (let c of s) {
// 这里巧妙的运用了和字符 a 的距离来构建 key (节省了排序的时间)
count[c.charCodeAt() - 'a'.charCodeAt()]++;
}
// '1,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0' 作为key
const key = count.toString();
map[key] ? map[key].push(s) : map[key] = [s];
}
return Object.values(map);
};
解题思路: HashMap。在遍历的时候,用每个字符串出现的次数组成的数组作为key。(巧妙运用和字符 a 的距离来构建key)
128. 最长连续序列
分类:并查集 | 数组 | 哈希表
给定一个未排序的整数数组 nums ,找出数字连续的最长序列(不要求序列元素在原数组中连续)的长度。 请你设计并实现时间复杂度为 O(n) 的算法解决此问题。
/**
* @param {number[]} nums
* @return {number}
*/
var longestConsecutive = (nums) => {
const numsSet = new Set(nums) // set存放数组的全部数字,去重
let max = 0;
for (let i = 0; i < nums.length; i++) {
let curNum = nums[i];
if (numsSet.has(curNum - 1)) {
continue;
} else {
let count = 1;
// 是某个序列的起点
while(numsSet.has(curNum + 1)) {
count += 1;
curNum += 1; // 需要更新一下curNum
}
max = Math.max(count, max);
}
}
return max;
};
解题思路一:利用Set去重:我们需要寻找序列的起点,并且通过起点找最长的连续子序列
- 先用Set数据结构将原数组去重。
- 遍历原数组,寻找序列的起点(即数组中不存在 cur - 1的数)
- 通过序列的起点开始,直到找到不连续的为止。并更新max
var longestConsecutive = (nums) => {
if (nums.length === 0) return 0;
nums.sort((a, b) => a - b);
let max = 1;
let count = 1;
for (let i = 0; i < nums.length - 1; i++) {
let cur = i,
next = i + 1;
if (nums[cur] === nums[next]) continue; // 相同就跳过本次循环
if (nums[cur] + 1 === nums[next]) {
// 发现连续项 count++
count++;
} else {
// 否则,count重置1
count = 1;
}
max = Math.max(max, count);
}
return max;
};
解题思路二:排序 + 遍历
- 先将原数组排序
- 遍历数组。发现连续项
count++否则,count重置为1。遇到相同的元素时候,从相同元素的最后一个开始寻找连续子序列Tips:(感觉这种方法好像比Set效率更高一些)
136. 只出现一次的数字
分类:位运算 | 数组
给定一个非空整数数组,除了某个元素只出现一次以外,其余每个元素均出现两次。找出那个只出现了一次的元素。
var singleNumber = function(nums) {
var obj = {};
for(let i = 0; i < nums.length; i++) {
obj[nums[i]] ? delete obj[nums[i]] : obj[nums[i]] = 1;
}
return Object.keys(obj)[0];
}
解题思路一: HashMap。使用hash表,如果当前key不存在,则令obj[key] 为 1。如果存在则delete 当前属性。由于其余每个元素均出现两次,那么最后就剩下了只出现一次的元素。(消消乐!)
// 利用位运算 异或
var singleNumber = function(nums) {
let res;
for(let i = 0; i < nums.length; i++) {
res ^= nums[i];
}
return res;
}
解题思路二: 位运算。位运算中的异或运算 XOR,主要因为异或运算有以下几个特点:
- 一个数和 0 做 XOR 运算等于本身:a⊕0 = a
- 一个数和其本身做 XOR 运算等于 0:a⊕a = 0
- XOR 运算满足交换律和结合律:a⊕b⊕a = (a⊕a)⊕b = 0⊕b = b
所以,利用2,3两条特点,异或运算得到的结果就是那个落单的!
169. 多数元素
分类:数组 | 哈希表 | 分治 | 计数 | 排序
给定一个大小为 n 的数组 nums ,返回其中的多数元素。多数元素是指在数组中出现次数 大于 ⌊ n/2 ⌋ 的元素。 你可以假设数组是非空的,并且给定的数组总是存在多数元素。
/**
* @param {number[]} nums
* @return {number}
*/
var majorityElement = function(nums) {
// 一半的数
const half = nums.length / 2;
const numMap = new Map();
for (let i = 0; i < nums.length; i++) {
const number = nums[i]
numMap.get(number) ? numMap.set(number, numMap.get(number) + 1) : numMap.set(number, 1);
// 超过一半的数 直接返回
if (numMap.get(number) > half) {
return number
}
}
}
解题思路一:hash 表。遍历数组的同时,在hash表中存下每个元素出现的个数,当出现的次数大于半数时,直接返回结果。
var majorityElement = function (nums) {
// 初始化
let times = 1;
let target = nums[0];
for(let i = 1; i < nums.length; i++){
// 次数为0的时候,直接使用当前的数
if (times === 0) {
target = nums[i];
times = 1;
}
// 如果遇到相同的数 则+1
else if (nums[i] === target) {
times += 1;
}
// 如果遇到不同的数则 -1
else {
times -= 1;
}
}
return target;
}
解题思路二:摩尔投票法。摩尔投票法的核心思想就是对拼消耗。数量最多的为一个阵营,和其他阵营互相1:1消耗。最后剩下的就是大于一半的数。
242. 有效的字母异位词
分类:hash-table | sort
定两个字符串 s 和 t ,编写一个函数来判断 t 是否是 s 的字母异位词。
注意:若 s 和 t 中每个字符出现的次数都相同,则称 s 和 t 互为字母异位词。
/**
* @param {string} s
* @param {string} t
* @return {boolean}
*/
var isAnagram = function (s, t) {
if (s.length !== t.length) {
return false;
}
const map = {};
// 先将s中各个字符出现的次数放入map中
for (let i = 0; i < s.length; i++) {
const char = s.charAt(i);
if (map[char]) {
map[char]++;
} else {
map[char] = 1;
}
}
// 先将t中各个字符出现的在map中减去
for (let j = 0; j < t.length; j++) {
const char = t.charAt(j);
if (map[char]) {
map[char]--;
if (map[char] === 0) {
delete map[char];
}
} else {
return false;
}
}
return Object.keys(map).length === 0;
};
解题思路:HashMap:
- 先将第一个字符串中各个字符出现的次数记录到hash表中。
- 然后遍历第二个字符串去删减hash表中的出现次数(当出现次数为0的时候,删除该key)。
- 最后当hash表中key的长度为0时则为有效的字母异位词。
287. 寻找重复数
分类:位运算 | 数组 | 双指针 | 二分查找
给定一个包含 n + 1 个整数的数组 nums ,其数字都在 [1, n] 范围内(包括 1 和 n),可知至少存在一个重复的整数。 假设 nums 只有 一个重复的整数 ,返回 这个重复的数 。 你设计的解决方案必须 不修改 数组 nums 且只用常量级 O(1) 的额外空间。
/*
* @param {number[]} nums
* @return {number}
*/
var findDuplicate = function (nums) {
// 首次的想法,遍历的时候,用hashMap。但是好像不满足 O(1)的空间要求
// 模式识别: 出现次数: hash表
const hashMap = {}
for (let i = 0; i < nums.length; i++) {
if (hashMap[nums[i]]) {
return nums[i]
} else {
hashMap[nums[i]] = true;
}
}
};
解题思路一:Hash表,出现次数: 使用Hash表来存储,但是好像不满足O(1)的空间要求
var findDuplicate = function(nums) {
var fast = 0;
let slow = 0;
while(true) {
slow = nums[slow]; // 1. 慢指针走一步
fast = nums[nums[fast]]; // 1. 快指针走两步
// 2. 快慢指针首次相遇
if (slow === fast) {
fast = 0; // 3. 让快指针回到起点
if (nums[slow] === nums[fast]) { // 5. 如果再次相遇,就肯定是在入口处?!!(这个怎么理解????)
return slow
}
slow = nums[slow]; // 4. 两个指针每次都进一步 (因为)
fast = nums[fast];
}
}
}
解题思路二:快慢指针:题目说数组必存在重复数,所以 nums 数组肯定可以抽象为有环链表。
首先,如果有环的话,那么快慢指针一定会在环内相遇。
相遇时,慢指针走的距离:D+S1D+S1
假设相遇时快指针已经绕环 n 次,它走的距离:D+n(S1+S2)+S1D+n(S1+S2)+S1
因为快指针的速度是 2 倍,所以相同时间走的距离也是 2 倍:
D+n(S1+S2)+S1 = 2(D+S1)D+n(S1+S2)+S1=2(D+S1)
即 (n-1)S1+ nS2=D(n−1)S1+nS2=D
我们不关心绕了几次环,取 n = 1 这种特定情况,消掉 S1: D=S2
所以此时,让快指针回到原点,当下次慢指针和快指针相遇的时候,就是在环的入口处。
349. 两个数组的交集
分类:数组 | 哈希表 | 双指针 | 二分查找 | 排序
给定两个数组 nums1 和 nums2 ,返回 它们的交集 。输出结果中的每个元素一定是 唯一 的。我们可以 不考虑输出结果的顺序 。
/**
* @param {number[]} nums1
* @param {number[]} nums2
* @return {number[]}
*/
var intersection = function (nums1, nums2) {
const map = {};
const res = [];
nums1.forEach((num) => {
if (!map[num]) {
map[num] = true;
}
});
nums2.forEach((num) => {
if (map[num]) {
res.push(num);
delete map[num]; // 避免重复记录
}
});
return res;
};
解题思路:Hash Map: 从题意得知:当元素发生重复时,交集中也只展示一个。
- 所以我们遍历
nums1时只需要记录一下元素是否出现过。- 遍历
nums2的时候查看map中是否存在该元素,如果存在,记录一下,并且删除(避免重复记录)。
389. 找不同
分类:hash-table | bit-manipulation
给定两个字符串
s和t,它们只包含小写字母。字符串
t由字符串s随机重排,然后在随机位置添加一个字母。请找出在
t中被添加的字母。
/**
* @param {string} s
* @param {string} t
* @return {character}
*/
var findTheDifference = function(s, t) {
const map = {};
for (item of s) {
map[item] ? map[item] += 1 : map[item] = 1
}
for (item of t) {
// 包含了 map[item] 为0的情况
if (map[item]) {
map[item] -= 1;
} else {
return item;
}
}
};
解题思路:HashMap: 先遍历字符串s,将出现次数保存在 Hash 表中。然后遍历字符串t。
- 如果这个字符在 Hash 表中是存在的,则出现次数 - 1。
- 如果某个字符是 Hash 表中不存在的、或者出现次数为0的,则直接返回该字符。
454. 四数相加 II
分类:hash-table | binary-search
给你四个整数数组 nums1、nums2、nums3 和 nums4 ,数组长度都是 n ,请你计算有多少个元组 (i, j, k, l) 能满足:
0 <= i, j, k, l < nnums1[i] + nums2[j] + nums3[k] + nums4[l] == 0
/**
* @param {number[]} nums1
* @param {number[]} nums2
* @param {number[]} nums3
* @param {number[]} nums4
* @return {number}
*/
var fourSumCount = function(nums1, nums2, nums3, nums4) {
let res = 0;
const mapGroup1 = {};
// const mapGroup2 = {};
const len = nums1.length; // 每个nums的长度都一样
for (let i = 0; i < len; i++) {
for (let j = 0;j < len; j++) {
const count = nums1[i] + nums2[j];
mapGroup1[count] ? mapGroup1[count]+=1 : mapGroup1[count] = 1
}
}
for (let i = 0; i < len; i++) {
for (let j = 0;j < len; j++) {
const count = nums3[i] + nums4[j];
if (mapGroup1[0 - count]) {
res += mapGroup1[0 - count]
}
}
}
return res;
};
解题思路:Hash Map: 简单的说,将四数之和转化为两数之和。
- 列举出nums1和nums2的所有组合放入mapGroup1中。
- 将nums3和nums4进行组合,统计nums3和nums4的和与mapGroup1相加结果为0的个数
堆栈&队列
1021. 删除最外层的括号
分类:栈 | 字符串
有效括号字符串为空 ""、"(" + A + ")" 或 A + B ,其中 A 和 B 都是有效的括号字符串,+ 代表字符串的连接。
例如,"","()","(())()" 和 "(()(()))" 都是有效的括号字符串。
如果有效字符串 s 非空,且不存在将其拆分为 s = A + B 的方法,我们称其为原语(primitive),其中 A 和 B 都是非空有效括号字符串。 给出一个非空有效字符串 s,考虑将其进行原语化分解,使得:s = P_1 + P_2 + ... + P_k,其中 P_i 是有效括号字符串原语。 对 s 进行原语化分解,删除分解中每个原语字符串的最外层括号,返回 s 。
/**
* @param {string} s
* @return {string}
*/
var removeOuterParentheses = function (s) {
// 思路: 本题得利用栈的数据结构特点来解题
// 1. 左括号率先入栈底。当栈底的左括号出栈的时候,需要记录一下中间遍历的括号内容 加到str末尾
// 2. 如果有左括号再次进入栈底,则重复第一步过程
let resStr = '';
let strIndex = 0;
let topIndex = -1;
const stack = [];
let keyMap = {
')': '('
}
for (let i = 0; i < s.length; i++) {
// 栈底没有元素
if (topIndex === -1) {
stack.push(s[i]);
// 更新str的下标
strIndex = i;
topIndex = 0;
} else if (stack[topIndex] === keyMap[s[i]]) {
stack.pop();
topIndex -= 1;
// 栈为空,需要更新resStr(去最外层括号的操作)
if (topIndex === -1) {
resStr += s.substring(strIndex + 1, i)
}
} else {
stack.push(s[i]);
topIndex += 1;
}
}
return resStr;
};
解题思路:利用栈:
- 左括号率先入栈底。当栈底的左括号出栈的时候,需要记录一下中间遍历的括号内容追加到resStr末尾。
- 栈为空且左括号再次进入栈底,则重复第一步过程。
1047. 删除字符串中的所有相邻重复项
分类:栈 | 字符串
给出由小写字母组成的字符串 S,重复项删除操作会选择两个相邻且相同的字母,并删除它们。 在 S 上反复执行重复项删除操作,直到无法继续删除。 在完成所有重复项删除操作后返回最终的字符串。答案保证唯一。
/**
* @param {string} s
* @return {string}
*/
var removeDuplicates = function(s) {
const stack = [s[0]];
let topIndex = 0;
for (let i = 1; i < s.length; i++) {
// 1. 长度为0的情况,直接进入队列
if (topIndex === -1) {
stack.push(s[i]);
topIndex += 1;
// 2. 判断栈顶和当前的s[i]是否相等,相等的话则出栈
} else if (stack[topIndex] === s[i]) {
topIndex -= 1;
stack.pop();
} else {
// 不相等的话,入栈,topIndex + 1
topIndex += 1;
stack.push(s[i]);
}
}
return stack.join('');
};
解题思路:利用栈数据结构
- 长度为0的情况,直接进入队列。
- 判断栈顶和当前的s[i]是否相等,相等的话则出栈
- 不相等的话,入栈,topIndex + 1
- 最终利用
.join('')将stack转成字符串返回
二叉树
94. 二叉树的中序遍历
分类:栈 | 树 | 深度优先搜索 | 二叉树
给定一个二叉树的根节点 root ,返回 它的 中序 遍历 。
var inorderTraversal = function (root) {
let res = [];
const traversal = (node) => {
if (!node) {
return;
}
traversal(node.left);
res.push(node.val);
traversal(node.right);
};
traversal(root);
return res;
};
解题思路: 递归。写递归代码是有技巧的,递归三部曲:
- 结束条件
- 单层逻辑
- 传递参数
Tips:二叉树的遍历:所谓前序,中序,后续遍历命名的由来是我们访问二叉树根节点的顺序。前序遍历就是优先访问根节点,中序遍历是第二个访问根节点,后续遍历就是访问完左右节点之后,最后访问根节点。
98. 验证二叉搜索树
分类:树 | 深度优先搜索 | 二叉搜索树 | 二叉树
给你一个二叉树的根节点 root ,判断其是否是一个有效的二叉搜索树。 有效 二叉搜索树定义如下:
节点的左子树只包含 小于 当前节点的数。 节点的右子树只包含 大于 当前节点的数。 所有左子树和右子树自身必须也是二叉搜索树。
var isValidBST = function(root) {
const helper = (root, lower, upper) => {
// 递归三部曲: 2.确定递归结束的条件: 节点遍历完了
if (root === null) {
return true;
}
// 递归三部曲: 3.每一层递归要做的事情,左右节点和当前节点进行对比
if (root.val <= lower || root.val >= upper) {
return false;
}
return helper(root.left, lower, root.val) && helper(root.right, root.val, upper);
}
// 递归三部曲: 1.确定递归函数的入参和返回值。根节点,下限值,上限值
return helper(root, -Infinity, Infinity);
};
解题思路一: 递归。验证二叉搜索树其实就是递归验证左右节点和根节点值。
- 确定递归函数的入参和返回值:入参:节点,下限值,上限值。返回是否是验证通过
- 确定递归结束的条件: 节点遍历完了
- 每一层递归要做的事情:验证当前节点是否在上下限范围内。如果验证通过继续验证左右节点。
var isValidBST = function (root) {
// 验证二叉搜索树,中序遍历判断是否有序就行呢
let current;
let flag = true;
function traversal(node) {
if (!node) {
return;
}
traversal(node.left);
// current值为0的时候,也会进这里
if (current !== 0 && !current) {
current = node.val;
} else {
// 主要等于的情况
if (node.val <= current) {
flag = false;
}
current = node.val;
}
traversal(node.right);
}
traversal(root);
return flag;
};
解题思路二: 树的中序遍历。验证二叉搜索树其实就是中序遍历二叉树,判断是否有序就行。
101. 对称二叉树
分类:树 | 深度优先搜索 | 广度优先搜索 | 二叉树
给你一个二叉树的根节点 root , 检查它是否轴对称。
var isSymmetric = function(tree) {
const isMirror = function(leftNode, rightNode) {
// 递归三部曲: 2.确定递归结束的条件
if (leftNode === null && rightNode === null) {
return true
}
if ((leftNode && !rightNode) || (!leftNode && rightNode)) {
return false;
}
// 递归三部曲: 3.确定每次递归需要处理的问题
if (leftNode && rightNode) {
return leftNode.val === rightNode.val
&& isMirror(leftNode.right, rightNode.left)
&& isMirror(leftNode.left, rightNode.right);
}
}
// 递归三部曲: 1.确定递归函数的参数和返回值
return isMirror(tree.left, tree.right)
}
解题思路一: 递归。判断二叉树是否对称:
- 若 root == null, 直接返回 true;
- 否则,判断 root.left 与 root.right 这两棵子树是否对称:
- 判断 root.left 与 root.right 这两个节点的值是否相等
- 判断 root.left 的左子树与 root.right 的右子树是否对称
- 判断 root.left 的右子树与 root.right 的左子树是否对称
递归三部曲:
确定递归函数的参数和返回值: 因为我们要比较的是根节点的两个子树是否是相互翻转的,进而判断这个树是不是对称树,所以要比较的是两个树,参数自然也是左子树节点和右子树节点。
isMirror(tree.left, tree.right)确定终止条件:至少左右节点至少有一个为空
确定单层递归的逻辑:处理左、右节点都不为空,且数值相同的情况。这时候要递归比较
leftNode.right和rightNode.left&&leftNode.left和rightNode.right
var queueCheck = (root1, root2) => {
const nodeQueue = [];
// 初始化:将根节点入队两次(这个很难想到)
nodeQueue.push(root1);
nodeQueue.push(root2);
while(nodeQueue.length) {
const leftNode = nodeQueue.shift();
const rightNode = nodeQueue.shift();
if (!leftNode && !rightNode) {
continue;
}
// 左节点存在,右节点不存在 || 右节点存在、左节点不存在 || 左右节点的值不相等 三种情况的时候直接return false
if ((!leftNode || !rightNode) || leftNode.val !== rightNode.val) {
return false;
}
queueCheck.push(leftNode.right);
queueCheck.push(rightNode.left);
queueCheck.push(rightNode.left);
queueCheck.push(leftNode.right);
}
return true;
}
/**
* @desc 迭代法。利用队列先入先出
*/
var isSymmetric = function(tree) {
return queueCheck(tree, tree);
}
解题思路二:迭代:把递归程序改写成迭代程序
- 初始化时我们把根节点入队两次。每次提取两个结点并比较它们的值
- 然后将两个结点的左右子结点按相反的顺序插入队列中(这个时候队列中每两个连续的结点应该是相等的,而且它们的子树互为镜像)
- 每次从队列中弹出两个值,比较是否相等
102. 二叉树的层序遍历
分类:树 | 广度优先搜索 | 二叉树
给你二叉树的根节点 root ,返回其节点值的 层序遍历 。 (即逐层地,从左到右访问所有节点)。
/**
* @param {TreeNode} root
* @return {number[][]}
*/
var levelOrder = function (root) {
if (!root) {
return [];
}
const queue = []; // 用来记录记录遍历过程
const res = []; // 输出结果(二维数组)
queue.push(root); // 初始状态,将根节点入栈
while (queue.length) {
const length = queue.length; // 在每一层遍历开始前,先记录队列中的结点数量 n(否则,这一层还没遍历完,下一层的节点就已经push进来了)
const currentLayerVals = []; // 用来暂存这一层的节点内容
// 遍历 **当前层** 的所有子元素,存在
for (let i = 0; i < length; i++) {
const node = queue.shift();
// 弹出一个元素之后要做两件事:1.将内容暂存到 currentLayerVals 中 2.如果存在左右节点,入队
currentLayerVals.push(node.val);
node.left && queue.push(node.left);
node.right && queue.push(node.right);
}
res.push(currentLayerVals); // 存储当前层遍历出来的数据
}
return res;
};
解题思路:BFS广度优先遍历(层序遍历)
- 用一个队列来记录遍历树的过程
- 每次遍历这一层的所有元素,同时需要有一个额外的变量
currentLayerVals来暂存这一层的节点内容- 弹出一个元素之后要做两件事:1.将内容暂存到
currentLayerVals中 2.如果存在左右节点,入队
104. 二叉树的最大深度
分类:树 | 深度优先搜索 | 广度优先搜索 | 二叉树
给定一个二叉树,找出其最大深度。 二叉树的深度为根节点到最远叶子节点的最长路径上的节点数。 说明: 叶子节点是指没有子节点的节点。
* @param {TreeNode} root
* @return {number}
*/
var maxDepth = function(root) {
// 感觉本质上是一个子递归问题
if (!root) {
return 0
}
return Math.max(maxDepth(root.left), maxDepth(root.right)) + 1;
};
解题思路:递归。将问题分解成子问题:求根节点的最大深度 -> 根节点Max(左节点的深度,右节点的深度) + 1
**Tips:**可以和
111. 二叉树的最小深度联系起来看,用同一套模板来解决问题。
105. 从前序与中序遍历序列构造二叉树
分类:树 | 数组 | 哈希表 | 分治 | 二叉树
给定两个整数数组 preorder 和 inorder ,其中 preorder 是二叉树的先序遍历, inorder 是同一棵树的中序遍历,请构造二叉树并返回其根节点。
var buildTree = (preorder, inorder) => {
// 构建一个二叉树需要三部分: root、左子树、右子树
// 左子树、右子树的构建,又包括:root、左子树、右子树(有递归那味儿了吗?)
// 现在的问题就是根据根节点,划分出左、右子树。然后递归构建左右子树
if (inorder.length === 0) {
return null;
}
// preorder的第一项一定是根节点:因为前序遍历的顺序是[根 | 左 | 右]
const root = new TreeNode(preorder[0]);
// 根据根节点, 在中序遍历inorder([左 | 根 | 右])中划分出分别属于左、右子树的inorder序列。并求出左右子树的个数
const rootIndexInInorder = inorder.indexOf(preorder[0]);
const leftChildCount = rootIndexInInorder;
// 根据左右子树的个数,在preorder中划分出分别属于左、右子树的preorder序列
// 计算出左右子树的inorder、preorder序列后,递归构建左、右子树就好
root.left = buildTree(preorder.slice(1, leftChildCount + 1), inorder.slice(0, leftChildCount));
root.right = buildTree(preorder.slice(leftChildCount + 1), inorder.slice(leftChildCount + 1));
return root;
}
解题思路:递归:首先,明确一下思路:
构建一个二叉树需要三部分:root、左子树、右子树
左子树、右子树的构建,又需要root、左子树、右子树(有递归那味儿了吗?)
关键的问题在于如何在给定的序列中划分出root、左子树、右子树。然后递归构建左右子树。
具体做法:
- preorder的第一项一定是根节点:因为前序遍历的顺序是[根 | 左 | 右]
- 根据根节点, 在中序遍历
inorder(顺序是[左 | 根 | 右])中划分出分别属于左、右子树的inorder序列。并求出左子树的个数。- 根左右子树的个数,在
preorder中划分出分别属于左、右子树的preorder序列- 有了左子树、右子树的
inorder、preorder序列后,递归构建左、右子树**Tips:**可以使用4枚指针来替代
sliceAPI。
106. 从中序与后序遍历序列构造二叉树
分类:树 | 数组 | 哈希表 | 分治 | 二叉树
给定两个整数数组 inorder 和 postorder ,其中 inorder 是二叉树的中序遍历, postorder 是同一棵树的后序遍历,请你构造并返回这颗 二叉树 。
var buildTree = function(inorder, postorder) {
// inroder: [左 | 中 | 右]; postorder: [左 | 右 | 中]
if (inorder.length === 0) {
return null;
}
const rootVal = postorder[postorder.length - 1];
const root = new TreeNode(rootVal);
const leftChildCount = inorder.indexOf(rootVal);
root.left = buildTree(
inorder.slice(0, leftChildCount),
postorder.slice(0, leftChildCount)
);
root.right = buildTree(
inorder.slice(leftChildCount + 1),
postorder.slice(leftChildCount, postorder.length - 1)
);
return root;
};
解题思路:递归。中序遍历的顺序是[左 | 中 | 右],后序遍历的顺序是[左 | 右 | 中]
那么和
从前序与中序遍历序列构造二叉树这题的不同点就在于分割左右子树的节点方式不同。
111. 二叉树的最小深度
分类:树 | 深度优先搜索 | 广度优先搜索 | 二叉树
给定一个二叉树,找出其最小深度。 最小深度是从根节点到最近叶子节点的最短路径上的节点数量。 说明:叶子节点是指没有子节点的节点。
/**
* @param {TreeNode} root
* @return {number}
*/
var minDepth = function (root) {
if (root === null) {
return 0;
} else if (root.left === null) {
return minDepth(root.right) + 1;
} else if (root.right === null) {
return minDepth(root.left) + 1;
}
return Math.min(minDepth(root.left), minDepth(root.right)) + 1;
};
解题思路:递归遍历。和
二叉树的最大深度这题不一样,因为要遍历到叶子节点,所以要判断root.left或者root.right为空的情况。
114. 二叉树展开为链表
分类:栈 | 树 | 深度优先搜索 | 链表 | 二叉树
给你二叉树的根结点 root ,请你将它展开为一个单链表:
展开后的单链表应该同样使用 TreeNode ,其中 right 子指针指向链表中下一个结点,而左子指针始终为 null 。 展开后的单链表应该与二叉树 先序遍历 顺序相同。
var flatten = (root) => {
const traversal = (root) => {
if (root === null) {
return null;
}
// 如果存在右子节点,右子节点原地生成单链表
if (root.right) {
traversal(root.right);
}
// 如果存在左子节点,会比较复杂一点,除了原地生成单链表外还需要做两件事情:
// 1.要获取其尾结点,用来连接右子树生成的单链表(通过一直找右节点)
// 2.需要记录一下其头结点,连接到根节点
if (root.left) {
const leftHead = traversal(root.left); // 生成单链表,并记录头结点
let leftEnd = leftHead;
while(leftEnd.right) { // 获取尾结点
leftEnd = leftEnd.right;
}
// 开始做额外要做的两件事:
leftEnd.right = root.right; // 将尾结点和右子树生成的链表进行连接
root.right = leftHead; // 将头结点,连接到根节点
}
root.left = null; // 左子树一定要置为null!!! (放在root.left中也行,不过这样更好理解一点)
return root; // 返回已经原地排序好的单链表
}
traversal(root);
}
解题思路:递归:首先明确一下思路
- 对于树来说,展开根节点和其他子节点本质上做法是一样的,可以理解为根节点的子问题,所以这题首先想到用递归来实现。
- 如何展开?由于需要与二叉树 先序遍历 顺序相同。那么就需要先将左子树生成的链表接到根节点,右子树生成的链表再接到左子树生成的链表后面
- 于是我们可以递归生成一个链表,但是对于左右子树有不同的处理逻辑。
具体做法:
- 对于右子树转成的单链表,只要获取其头结点,等左子树的链表生成好,接在左子树的尾结点
- 左子树的生成稍微复杂一点:
- 需要记录一下其头结点,连接到根节点
- 要获取其尾结点,用来连接右子树生成的单链表(通过一直找右节点)
- 左子树生成的链表两端都接好后,root.left要置为null!不然root还拖着个左子树
226. 翻转二叉树
分类:树 | 深度优先搜索 | 广度优先搜索 | 二叉树
给你一棵二叉树的根节点 root ,翻转这棵二叉树,并返回其根节点。
/**
* @param {TreeNode} root
* @return {TreeNode}
*/
var invertTree = (root) => {
if (root === null) {
return root;
}
// 先交换左右子树
[root.left, root.right] = [root.right, root.left];
// 它们内部的子树还没翻转, 丢给递归去做
invertTree(root.right);
invertTree(root.left);
return root;
}
解题思路: 递归。翻转二叉树,不仅要翻转左右子树,左右子树的所有子树也需要翻转。
- 先将根节点的左右子树进行交换
- 左子树、右子树和根节点做同样的操作,直到没有子节点为止
236. 二叉树的最近公共祖先
分类:树 | 深度优先搜索 | 二叉树
给定一个二叉树, 找到该树中两个指定节点的最近公共祖先。 百度百科中最近公共祖先的定义为:“对于有根树 T 的两个节点 p、q,最近公共祖先表示为一个节点 x,满足 x 是 p、q 的祖先且 x 的深度尽可能大(一个节点也可以是它自己的祖先)。
var lowestCommonAncestor = function (root, p, q) {
let ans;
const dfs = (node, p, q) => {
if (node === null) return null;
const lson = dfs(node.left, p, q); // 该节点的左节点是否包含 p || q
const rson = dfs(node.right, p, q); // 该节点的右节点是否包含 p || q
// 1. 如果p,q分部在 node 两侧,那么 node 就是LCA
// 2. 还有一种情况,node就是p/q,且剩下的q/p是node的子节点,那么node就是LCA
if (
(lson && rson) ||
((node.val === p.val || node.val === q.val) && (lson || rson))
) {
ans = node;
}
return node.val === q.val || node.val === p.val || lson || rson
};
dfs(root, p, q);
return ans;
};
解题思路:递归:首先我们明确一下:
node为p、q的LCA的两种可能性:
- p,q分部在node两侧
- node为p || q,另外一个q || p 在node的子树中。
具体做法:
- 确定入参和返回值:入参为当前节点,返回值为当前节点是否为p || q,或者子节点中是否存在p || q
- 递归函数要做的事情:计算当前节点的的左右子树是否存在p || q,判断当前节点是否是
LCA- 递归的结束条件:当前节点为null
257. 二叉树的所有路径
分类:树 | 深度优先搜索 | 字符串 | 回溯 | 二叉树
给你一个二叉树的根节点 root ,按 任意顺序 ,返回所有从根节点到叶子节点的路径。 叶子节点 是指没有子节点的节点。 示例 1:
输入:root = [1,2,3,null,5] 输出:["1->2->5","1->3"]
示例 2: 输入:root = [1] 输出:["1"]
/**
* @param {TreeNode} root
* @return {string[]}
*/
var binaryTreePaths = function (root) {
console.log("root", root);
// 递归
const res = [];
const traversal = (node, currentPaths) => {
if (!node) {
// 空节点的时候结束递归
return;
}
if (node.left === null && node.right === null) {
// 路径末尾了,不用加箭头
currentPaths += node.val;
res.push(currentPaths);
return;
}
traversal(node.left, currentPaths + node.val + "->"); // 处理非叶子节点,要加箭头
traversal(node.right, currentPaths + node.val + "->");
};
traversal(root, "");
return res;
};
解题思路:递归: 只有当左右节点同时为空节点的时候才算遍历到叶子节点!
- 递归函数的参数和返回值 :传入当前节点、当前路径,无返回值
- 确定递归终止条件:遍历到空节点的时候直接return
||遍历到叶子节点的时候带上当前值(不要加箭头)- 确定每一次要做的事情:加上当前节点的值(需要加箭头)、同时继续遍历左节点和右节点
783. 二叉搜索树节点最小距离
分类:树 | 深度优先搜索 | 广度优先搜索 | 二叉搜索树 | 二叉树
给你一个二叉搜索树的根节点 root ,返回 树中任意两不同节点值之间的最小差值 。 差值是一个正数,其数值等于两值之差的绝对值。
/**
* @desc 二叉搜索树中序遍历得到的值序列是递增有序的
* 二叉搜索树,中序遍历的结果就是递增的,所以每次遍历的时候判断一下相邻值的差即可
*/
var minDiffInBST = function (root) {
let res = Number.MAX_SAFE_INTEGER;
let lastVal = -1;
const traversal = (node) => {
if (node === null) {
return;
}
if (node.left) {
traversal(node.left);
}
// 在这里访问 (!!!这里注意为0的情况)
if (lastVal === -1) {
// 刚开始遍历到根节点的时候,还没有lastVal,res保持不变,不跟新
lastVal = node.val;
} else {
// 接下来的每次都更新一下min
res = Math.min(res, Math.abs(node.val - lastVal));
lastVal = node.val;
}
if (node.right) {
traversal(node.right);
}
};
traversal(root);
return res;
};
解题思路:树的中序遍历:二叉搜索树中序遍历得到的值序列是递增有序的特点。得出:树中任意两不同节点值之间的最小差值 -> 相邻元素差的最小值
Tips:需要考虑
lastVal为0的情况 && 刚开始根节点的时候lastVal设为根节点的值
938. 二叉搜索树的范围和
分类:树 | 深度优先搜索 | 二叉搜索树 | 二叉树
给定二叉搜索树的根结点 root,返回值位于范围 [low, high] 之间的所有结点的值的和。
/**
* @param {TreeNode} root
* @param {number} low
* @param {number} high
* @return {number}
*/
var rangeSumBST = function(root, low, high) {
if (root === null) {
return 0;
}
// 说明左右节点的值都有可能是我们要的值
if (root.val >= low && root.val <= high) {
return root.val + rangeSumBST(root.left, low, high) + rangeSumBST(root.right, low, high);
} else if (root.val < low) {
// 值太小了,就往右子节点寻找
return rangeSumBST(root.right, low, high);
} else {
// 值太大了,就往左子节点寻找
return rangeSumBST(root.left, low, high);
}
}
解题思路:递归:将问题分解成子问题:根节点下所有在[low, high]中的和 = 左节点下[low, high] + 右节点下[low, high]中的和。
当前值如果符合
low <= root.val <= high的话,那么当前就是满足条件的节点,且左右节点都有可能是我们要的值:root.val + rangeSumBST(root.left, low, high) + rangeSumBST(root.right, low, high);利用二叉搜索树的特点,当前值 < low 的时候,仅遍历右节点:
return rangeSumBST(root.right, low, high);利用二叉搜索树的特点,当前值 > high的时候,仅遍历左节点,返回左节点下[low, high]的和:
return rangeSumBST(root.left, low, high);递归终点:当前值为null的时候,退出循环。
回溯
回溯本质是暴力搜索,在问题的解空间树中,用 DFS 的方式,从根节点出发搜索整个解空间。
如果要找出所有的解,则要搜索整个子树,如果只用找出一个解,则搜到一个解就可以结束搜索。
「找出所有可能的组合」的问题,适合用回溯算法。
回溯算法有三个要点:
- 选择:决定了你每个节点有哪些分支,帮助你构建出解的空间树
- 约束条件:用来剪枝,减去不满足约束条件的子树,避免无效的搜索
- 目标:决定了何时捕获解,或者剪去得不到解的子树,提前回溯
17. 电话号码的字母组合
分类:哈希表 | 字符串 | 回溯
给定一个仅包含数字 2-9 的字符串,返回所有它能表示的字母组合。答案可以按 任意顺序 返回。 给出数字到字母的映射如下(与电话按键相同)。注意 1 不对应任何字母。
/**
* @param {string} digits
* @return {string[]}
*/
var letterCombinations = function (digits) {
if (digits.length === 0) {
return [];
}
const res = [];
const map = {
2: ["a", "b", "c"],
3: ["d", "e", "f"],
4: ["g", "h", "i"],
5: ["j", "k", "l"],
6: ["m", "n", "o"],
7: ["p", "q", "r", "s"],
8: ["t", "i", "v"],
9: ["w", "x", "y", "z"],
};
const dfs = (digitsIndex, currentStr) => {
if (currentStr === digits.length) {
res.push(currentStr);
return;
}
const charCode = digits[digitsIndex];
const charCodes = map[charCode];
for (let i = 0; i < charCodes.length; i++) {
dfs(digitsIndex + 1, currentStr + charCodes[i]);
}
};
dfs(0, "");
return res;
};
解题思路:回溯。回溯三要素:
- 选择:本题的选择是每个数字对应的多个字母,选择翻译成其中一个字母,就继续递归
- 约束条件(剪枝):本题不需要进行剪枝操作
- 目标:当
currentStr的长度和digits的长度相同的时,就可以将currentStr加入解集
22. 括号生成
分类:字符串 | 动态规划 | 回溯
数字 n 代表生成括号的对数,请你设计一个函数,用于能够生成所有可能的并且 有效的 括号组合。
var generateParenthesis = function(n) {
const res = [];
// 1. 第一个关键点: '所有可能' 确定用递归的方式实现。
const dfs = function(lRemain , rRemain, str) {
// 2. 第二个关键点: 递归结束的条件:str的长度为 n 的两倍
if (str.length === n * 2) {
res.push(str);
return;
}
// 3. 第三个关键点: 有效的字符串必须是 左括号优先进入。且左括号的个数永远小于等于右括号
// 选择左括号的前提条件:只要左括号还有的剩,可以直接选择左括号
if (lRemain > 0) {
dfs(lRemain - 1, rRemain, str + '(');
}
// 选择右括号的前提条件: 剩余的右括号要大于左括号时,才可以让右括号进入,才能保证有效性
if (rRemain > lRemain) {
dfs(lRemain, rRemain - 1, str + ')');
}
}
dfs(n , n, '');
return res;
}
解题思路:回溯算法。提取关键字:
所有可能(一般使用递归的方式实现)
- 选择:我们每次递归,要么选择左括号、要么选择右括号,左括号 | 右括号的数量在减少,生成的字符串长度在增加。
- 约束条件(剪枝):为了保证生成括号的有效性,选择左括号和右括号是有条件的:
- 选择左括号的前提条件:只要左括号还有的剩,可以直接选择左括号
- 选择右括号的前提条件: 剩余的右括号要大于左括号时,才可以让右括号进入,才能保证有效性
- 目标:当lRemin,rRemind的长度都为0时,说明左右括号已经用完了,可以将
str加入到结果集。
39. 组合总和
分类:数组 | 回溯
给你一个 无重复元素 的整数数组 candidates 和一个目标整数 target ,找出 candidates 中可以使数字和为目标数 target 的 所有 不同组合 ,并以列表形式返回。你可以按 任意顺序 返回这些组合。 candidates 中的 同一个 数字可以 无限制重复被选取 。如果至少一个数字的被选数量不同,则两种组合是不同的。 对于给定的输入,保证和为 target 的不同组合数少于 150 个。
/**
* @param {number[]} candidates
* @param {number} target
* @return {number[][]}
*/
var combinationSum = function(candidates, target) {
const res = [];
const dfs = (canStartIndex, currentSum, currentArr) => {
if (currentSum > target) {
return;
}
if (currentSum === target) {
res.push([...currentArr])
return;
}
for (let i = canStartIndex; i < candidates.length; i++) {
dfs(i, currentSum + candidates[i], [...currentArr, candidates[i]]);
}
}
dfs(0, 0, []);
return res;
}
解题思路:回溯:回溯三要素:
- 选择:正常来说每一层的递归所有元素都可以选择,但是为了避免生成重复组合,下一层可选择的元素必须在当前选择元素的右侧。
- 约束条件(剪枝):当求和值 > target 的时候,可以提前返回
- 目标:当
currentSum === target的时候,将currentArr加入解集
46. 全排列
分类:数组 | 回溯
给定一个不含重复数字的数组 nums ,返回其 所有可能的全排列 。你可以 按任意顺序 返回答案。
/**
* @param {number[]} nums
* @return {number[][]}
*/
var permute = function(nums) {
const res = [];
const dfs = (usedIndexs, currentArr) => {
if (currentArr.length === nums.length) {
res.push([...currentArr]);
return;
}
for (let i = 0; i < nums.length; i++) {
// 如果已经选择过当前下标,那么就不做处理
if (usedIndexs[i]) {
continue;
}
dfs(
{...usedIndexs, [i]: true},
[...currentArr, nums[i]]
);
}
}
dfs({}, []);
return res;
};
解题思路:回溯:回溯三要素:
- 选择:只能选择没有被选择过的元素(所以这里需要用一个map来记录一下遍历到当前层时,被选择过的元素)
- 约束条件(剪枝):本题好像不需要进行剪枝操作
- 目标:当
currentArr.length === nums.length,将currentArr加入结果集
78. 子集
分类:位运算 | 数组 | 回溯
给你一个整数数组 nums ,数组中的元素 互不相同 。返回该数组所有可能的子集(幂集)。 解集 不能 包含重复的子集。你可以按 任意顺序 返回解集。
/**
* @param {number[]} nums
* @return {number[][]}
*/
var subsets = function(nums) {
const res = [];
const dfs = (startIndex, list) => {
res.push(list);
// 由于必须从选择过了的元素右侧开始选择
for (let i = startIndex; i < nums.length; i++) {
dfs(i + 1, [...list, nums[i]]);
}
};
dfs(0, []);
return res;
};
// []
// / | \
// [1] [2] [3]
// / \ |
// [1, 2] [1, 3] [2, 3]
// /
// [1, 2, 3]
解题思路:递归。在执行子递归之前,将结果加入结果集。回溯三要素:
- 选择:由于数组中的元素互不相同,也就是不能重复选择,所以限定下一层可选择的元素必须在当前选择元素的右侧。
- 约束条件:本题好像不需要进行剪枝操作
- 目标:每次在执行子递归之前,都是我们需要选择的子集,将
list加入结果集(可以参考代码末尾的树状图)
79. 单词搜索
分类:数组 | 回溯 | 矩阵
给定一个 m x n 二维字符网格 board 和一个字符串单词 word 。如果 word 存在于网格中,返回 true ;否则,返回 false 。 单词必须按照字母顺序,通过相邻的单元格内的字母构成,其中“相邻”单元格是那些水平相邻或垂直相邻的单元格。同一个单元格内的字母不允许被重复使用。
var exist = function (board, word) {
const row = board.length;
const columns = board[0].length;
let used = new Array(row).fill(false).map(() => new Array(columns).fill(false))
// 该函数计算从 i,j 这个点开始找能否找到
const canFind = (i, j, currentWordIndex) => {
if (currentWordIndex === word.length) {
return true;
}
if (i < 0 || j < 0 || i >= row || j >= columns) {
return false; // 1.下标越界的场景,不能走出框外 (下标越界的情况要最先判断、提前return。防止return)
}
if (used[i][j]) {
return false // 3. 可能上一步往上走、下一步又往下走了,不行。所以记录一下(不能走走过的点)
}
// 在dfs函数中,判断false场景
if (board[i][j] !== word[currentWordIndex]) {
return false // 2. 下一步的字符对不上
}
used[i][j] = true;
// 上、下、左、右都走走一下,试一试
const canFindRes =
canFind(i + 1, j, currentWordIndex + 1)
|| canFind(i, j + 1, currentWordIndex + 1)
|| canFind(i - 1, j, currentWordIndex + 1)
|| canFind(i, j - 1, currentWordIndex + 1)
if (canFindRes) {
return true;
} else {
// 重点!!!: 如果上下左右都走不通,这个点行不通,回撤路径
used[i][j] = false;
return false;
}
}
for (let i = 0; i < row; i++) {
for (let j = 0; j < columns; j++) {
// 先找到 '头'。这个总没错的
if (board[i][j] === word[0]) {
// 每到一个点做的事情是一样的。DFS 往下选点,构建路径。
if (canFind(i, j, 0)) {
// 一旦找到一个就立马return
return true;
}
}
}
}
return false;
};
console.log('res', exist([["A","B","C","E"],["S","F","C","S"],["A","D","E","E"]], 'SEE'));
解题思路: 递归。明确一下思路:
以"SEE"为例,找到改单词,首先必须找到首字母
起点可能不止一个,可能在矩阵中存在多个"S"
从该起点开始寻找剩下的"EE"字符串
寻找剩下的字符串有4个方向:上下左右
逐个尝试每一种选择。基于当前选择,为下一个字符选点,又有上下左右四种选择(有递归那味儿了?)。
具体做法:
- 首先找到首字母所在的位置,找到递归的起点。
- 进行递归寻找下一个字符
- 如果当前点是错的,不用往下递归了,返回false。否则继续递归四个方向,为剩下的字符选点。 那么,哪些情况说明这是一个错的点:
- 当前的点,越出矩阵边界。
- 之前访问过的点(可能上一步往上走、下一步又往下走了,不行)
- 字符串对不上的点
- 所以,需要一个
used二维数组来记录一下已经访问过的点,下次再选择访问这个点就直接返回false**Tips:**如果求出 canFindRest 为 false,说明基于当前点不能找到剩下的路径,所以当前递归要返回false,还要在used矩阵中把当前点恢复为未访问,让它后续能正常被访问。
93. 复原 IP 地址
分类:字符串 | 回溯
有效 IP 地址 正好由四个整数(每个整数位于 0 到 255 之间组成,且不能含有前导 0),整数之间用 '.' 分隔。
例如:"0.1.2.201" 和 "192.168.1.1" 是 有效 IP 地址,但是 "0.011.255.245"、"192.168.1.312" 和 "192.168@1.1" 是 无效 IP 地址。
给定一个只包含数字的字符串 s ,用以表示一个 IP 地址,返回所有可能的有效 IP 地址,这些地址可以通过在 s 中插入 '.' 来形成。你 不能 重新排序或删除 s 中的任何数字。你可以按 任何 顺序返回答案。
/**
* @param {string} s
* @return {string[]}
*/
var restoreIpAddresses = (s) => {
const res = [];
const dfs = (canStartIndex, selectArr) => {
const unCheckedRes = selectArr.join('.');
if (selectArr.length === 4 && unCheckedRes.length === s.length + 3) {
res.push(unCheckedRes);
}
for (let singleLen = 1; singleLen <= 3; singleLen++) {
const nextStartIndex = canStartIndex + singleLen;
// 假设下标越界了,直接返回
if (nextStartIndex > s.length) {
return;
}
const str = s.substring(canStartIndex, nextStartIndex);
// 假设当前截断的数超过255 或 02、033 这样的情况直接返回
if (Number(str) > 255 || (str.length > 1 && str[0] === '0')) {
return;
}
dfs(nextStartIndex, [...selectArr, str]);
}
}
dfs(0, []);
return res;
}
解题思路:递归: 先明确一下思路,以"25525511135"
- 首先我们明确每一步有三种选择:选择"2"、选择"25"、选择"255"
- 下一步选择的时候又有三种选择,这样向下进行,就会形成一棵树,我们用DFS去遍历所有选择,必要的时候提前回溯
- 提前回溯的情况:
- 下标越界了
- 递归的深度超过了4,也就是出现了"2.5.5.2.5"这样的情况
- 当切割了长度大于1时,但是切出来的是"02","025"这样的情况(测试用例跑出来)
具体做法:
- 选择:每次只能从起始下标切割长度为1、2、3的子串
- 约束条件(剪枝):当递归深度 > 4 的时候,可以提前回溯。选择的时候如果下标越界了,也可以提前回溯
- 捕获目标:当切割了4个子串,并且子串长度 === s 的长度时,加入结果集。
**Tips:**这里有个技巧,可以不需要提前加上
'.'字符,先放到数组中保存起来再'.join'一下。不然第一次切割子串的时候是不需要加上.的,需要分开处理。
深度优先搜索
139. 单词拆分
分类:字典树 | 记忆化搜索 | 哈希表 | 字符串 | 动态规划
给你一个字符串 s 和一个字符串列表 wordDict 作为字典。请你判断是否可以利用字典中出现的单词拼接出 s 。 注意:不要求字典中出现的单词全部都使用,并且字典中的单词可以重复使用。
200. 岛屿数量
分类:深度优先搜索 | 广度优先搜索 | 并查集 | 数组 | 矩阵
给你一个由 '1'(陆地)和 '0'(水)组成的的二维网格,请你计算网格中岛屿的数量。 岛屿总是被水包围,并且每座岛屿只能由水平方向和/或竖直方向上相邻的陆地连接形成。 此外,你可以假设该网格的四条边均被水包围。
/**
* @param {character[][]} grid
* @return {number}
*/
var numIslands = (grid) => {
let count = 0;
const row = grid.length; // 行
const col = grid[0].length; // 列
// 对当前块做沉岛操作,不仅如此,对其上下左右依次做沉岛操作
const turnToZero = (i, j) => {
// 停止递归的条件:下标越界、当前为0
if (
i < 0 || i >= row ||
j < 0 || j >= col ||
grid[i][j] === '0'
) {
return
} else {
// 否则,递归上下左右。并对当前做沉岛操作
grid[i][j] = '0';
turnToZero(i + 1, j);
turnToZero(i - 1, j);
turnToZero(i, j + 1);
turnToZero(i, j - 1);
}
}
for (let i = 0; i < row; i++) {
for (let j = 0; j < col; j++) {
if (grid[i][j] === '1') {
count += 1;
turnToZero(i, j)
}
}
}
return count;
}
解题思路:深度优先:对为"1"的块做”沉岛“操作。
- 首先找到1即遇到岛屿,计数 count + 1
- 将遍历过的岛屿标识为0(沉岛),否则下次遍历到会做重复计算(试想一下上下上下来回跳,肯定死循环)。
- 以当前的岛屿为入口,进行DFS,遇到以下几种情况结束递归
- 行下标越界
- 列下标越界
- 遇到0的时候
- 返回count
207. 课程表
分类:深度优先搜索 | 广度优先搜索 | 图 | 拓扑排序
你这个学期必须选修 numCourses 门课程,记为 0 到 numCourses - 1 。 在选修某些课程之前需要一些先修课程。 先修课程按数组 prerequisites 给出,其中 prerequisites[i] = [ai, bi] ,表示如果要学习课程 ai 则 必须 先学习课程 bi 。
例如,先修课程对 [0, 1] 表示:想要学习课程 0 ,你需要先完成课程 1 。
请你判断是否可能完成所有课程的学习?如果可以,返回 true ;否则,返回 false 。
var canFinish = (numCourses, prerequisites) => {
const inDegree = new Array(numCourses).fill(0); // 求课的初始入度值 (也就是需要提前完成几门前置课程)
const map = {}; // 邻接表 (也就是修完当前这门课程,有哪些课程的前置课程能 - 1)
for (let i = 0; i < prerequisites.length; i++) {
inDegree[prerequisites[i][0]]++;
if (map[prerequisites[i][1]]) {
// 当前课已经存在于邻接表
map[prerequisites[i][1]].push(prerequisites[i][0]); // 添加依赖它的后续课
} else {
// 当前课不存在于邻接表
map[prerequisites[i][1]] = [prerequisites[i][0]];
}
}
// inDegree: [0, 0, 0, 2, 2, 2]
// map: {
// 0: [3],
// 1: [3, 4],
// 2: [4],
// 3: [5],
// 4: [5],
// };
const queue = [];
for (let i = 0; i < inDegree.length; i++) {
// 所有入度为0的课入列 (不需要休前置课的先学了)
if (inDegree[i] == 0) queue.push(i);
}
// 那么 queue 就是先被选择的那几门课
let count = 0;
while (queue.length) {
const selected = queue.shift(); // 当前选的课,出列
count++; // 选课数+1
const toEnQueue = map[selected]; // 获取依赖这门课作为前置课程的课: [3]
if (toEnQueue && toEnQueue.length) {
// 确实有后续课
for (let i = 0; i < toEnQueue.length; i++) {
inDegree[toEnQueue[i]]--; // 依赖它的后续课的入度-1
if (inDegree[toEnQueue[i]] == 0) {
// 如果因此减为0,入列 queue
queue.push(toEnQueue[i]);
}
}
}
}
return count == numCourses; // 选了的课等于总课数,true,否则false
};
// 0
// 3
// 1 5
// 4
// 2
解题思路:拓扑图
- 一共有 n 门课要上,编号为 0 ~ n-1。
- 先决条件[1, 0],意思是必须先上课 0,才能上课 1。
- 给你 n 、和一个先决条件表,请你判断能否完成所有课程。
- 示例:n = 6,先决条件表:[[3, 0], [3, 1], [4, 1], [4, 2], [5, 3], [5, 4]],可以用有向图来展示这种依赖关系(代码中备注所示)
具体做法:
- 首先用一个inDegree来表示每门课的前置课程数
- 用一个map来记录修完当前这门课程,有哪些课程的前置课程能 - 1
- 用queue从inDegree中筛选出前置课程数为0的课程
- 不断将queue弹栈,表示将前置课数为0的课程上掉。此时count++
- 同时,从map中找出所有修完这门课,前置课程数能-1的课程,将其的前置课程数 - 1。
- 返回 count === numCourses
动态规划
53. 最大子数组和
分类:数组 | 分治 | 动态规划
给你一个整数数组 nums ,请你找出一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。 子数组 是数组中的一个连续部分。
/**
* @param {number[]} nums
* @return {number}
*/
var maxSubArray = function(nums) {
// 用动态规划的模板先做一遍。
// 1. 定义dp[i]:以i结尾的连续数组最大和
const dp = new Array(nums.length).fill(false);
// 3. 定义初始化状态
dp[0] = nums[0];
let res = dp[0] ;
// 2. 状态转移方程:dp[i] = Math.max(nums[i], dp[i - 1] + nums[i])
for (let i = 1; i < nums.length; i++) {
dp[i] = Math.max(nums[i], dp[i - 1] + nums[i]);
res = Math.max(dp[i], res);
}
return res;
};
解题思路:动态规划。动态规划三部曲:
- 定义
dp[i]:dp[i]指的是以当前下标结尾时,的「连续子数组的最大和」- 定义状态转移方程:
dp[i + 1] = Math.max(dp[i] + nums[i + 1], dp[i])- 初始化Base Case:
dp[0] = nums[0]Tips: 这题dp[i]有点不太明显,很可能第一反应不会想到动态规划。不过这题用动态规划还算是比较简单,所以采用动态规划三部曲这套招式能搞定。
62. 不同路径
分类:数学 | 动态规划 | 组合数学
一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为 “Start” )。 机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为 “Finish” )。 问总共有多少条不同的路径?
/**
* @param {number} m
* @param {number} n
* @return {number}
*/
var uniquePaths = function(m, n) {
// 特殊情况,单独处理
if (m * n === 1 || n * m === 2) {
return 1;
}
// 1. 定义dp[i][j]: 到达dp[i][j]有几种不同的路径
// 2. dp[i][j] = dp[i + 1][j] + dp[i][j + 1]
// 3. Base Case: dp[m - 1][*] = 1; dp[*][n - 1] = 1;
const dp = new Array(m).fill(false).map(() => new Array(n).fill(false));
for (let row = 0; row < m; row++) {
dp[row][0] = 1;
}
for (let col = 0; col < n; col++) {
dp[0][col] = 1;
}
for (let i = 1; i < m; i++) {
for (let j = 1; j < n; j++) {
// 考虑一下下标越界的情况
dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
}
}
console.log(dp);
return dp[m - 1][n - 1]
};
解题思路:动态规划 。解题思路:
- 由于右下角的点
potision[m - 1][n - 1]一定是从他的上边potision[m - 1][n - 2]或者左边potision[m - 2][n - 1]跳过来的。- 同理
potision[m - 1][n - 2]、potision[m - 2][n - 1]这两个点又是从他们的上边,左边跳过来的- 所以只需要步步为营、从起始点开始将到达每个点的可能路径计算出来
具体做法:动态规划三部曲
- 定义
dp[i][j]: 到达dp[i][j]有几种不同的路径- 定义状态转移方程:
dp[i][j] = dp[i - 1][j] + dp[i][j - 1]- 初始化Base Case:
dp[*][0] = 1、dp[0][*] = 1
64. 最小路径和
分类:数组 | 动态规划 | 矩阵
给定一个包含非负整数的 m x n 网格 grid ,请找出一条从左上角到右下角的路径,使得路径上的数字总和为最小。 说明:每次只能向下或者向右移动一步。
var minPathSum = (grid) => {
// 思路:其实这也是一个动态规划的题 要到[m,n]的最小路径,就算到 Math.min([m-1, n],[m. n -1])的最小路径
const columns = grid[0].length; // 列
const row = grid.length; // 行
// 自己维护一个二维数组来保存到各个点的最小路径
const dp = new Array(row)
.fill(null)
.map((item) => new Array(columns).fill(0));
dp[0][0] = grid[0][0];
// 向下和向右都只有一种走法,先将第一行和第一列填充好。(已知条件)
for (let i = 1; i < columns; i++) {
dp[0][i] = dp[0][i - 1] + grid[0][i];
}
for (let i = 1; i < row; i++) {
dp[i][0] = dp[i - 1][0] + grid[i][0];
}
// 开始计算到每个点的路径
for (let i = 1; i < row; i++) {
for (let j = 1; j < columns; j++) {
dp[i][j] = Math.min(dp[i - 1][j], dp[i][j - 1]) + grid[i][j];
}
}
return dp[row - 1][columns - 1];
};
解题思路:动态规划。解题思路:
由于要到达右下角,肯定是从他的上方
dp[m - 2][n - 1]或者左方dp[m - 1][n - 2]走下来的。对于dp[m - 2][n - 1]、dp[m - 1][n - 2]也是如此。
- 定义
dp[i][j]:到达当前点的数字总和最小为多少- 状态转移方程:
dp[i][j] = Math.min(dp[i - 1][j], dp[i][j - 1]) + grid[i][j]- 初始化Base Case:
dp[0][0] = grid[[0][0]、dp[0][x] = dp[0][x - 1] + grid[0][x]、dp[x][0] = dp[x - 1][0] + grid[x][0]
70. 爬楼梯
分类:记忆化搜索 | 数学 | 动态规划
假设你正在爬楼梯。需要 n 阶你才能到达楼顶。 每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?
/**
* @param {number} n
* @return {number}
*/
var climbStairs = function(n) {
const dp = new Array(n + 1).fill(false);
dp[1] = 1;
dp[2] = 2;
for (i = 3; i <= n; i++) {
dp[i] = dp[i - 1] + dp[i - 2];
}
return dp[n];
};
解题思路:动态规划。解题思路:
由于爬到n阶楼梯、且每次只能爬1或者2。那么爬到n的时候一定是n-1或者n-2的时候爬上来的。
- 定义
dp[i]: 爬到i的时候有几种方法- 状态转移方程:
dp[i] = dp[i - 1] + dp[i - 2]- 初始化Base Case:
dp[1] = 1、dp[2] = 2
96. 不同的二叉搜索树
分类:树 | 二叉搜索树 | 数学 | 动态规划 | 二叉树
给你一个整数 n ,求恰由 n 个节点组成且节点值从 1 到 n 互不相同的 二叉搜索树 有多少种?返回满足题意的二叉搜索树的种数。
/**
* @param {number} n
* @return {number}
*/
var numTrees = function(n) {
// dp[i]=∑dp[j]∗dp[i−j−1],0<=j<=i−1
const dp = new Array(n + 1).fill(false);
dp[0] = 1;
dp[1] = 1;
// 从2开始,计算 dp[i]。
for (let i = 2; i < n + 1; i++) {
let count = 0;
for (let j = 0; j < i; j++) {
// j 为左边分配的个数,那么右边分配的个数为 i - j - 1(还有一个根元素)
count += dp[j] * dp[i - j - 1];
}
dp[i] = count;
}
return dp[n];
};
解题思路: 动态规划。 解题思路
- 我们经过分析,假设 1~n中的k作为根节点。则小于k的会作为左节点。大于k的会作为右节点(二叉搜索树的特点)。
- 左节点假设有 n 种排列方法。 右节点有 m 种排列方法。 则总共有 m * n 中排列方法。
- 那么拿左节点距离,假设左节点有count个数,有几种二叉搜索树的排列方法:除去根节点,剩 i-1 个节点构建左、右子树,左子树分配 0 个,则右子树分配到
i−1个……以此类推。- 左子树用掉
j个,则右子树用掉i-j-1个,能构建出dp[j] * dp[i-j-1]种不同的二叉搜索树。- 所以:用连续的n个数所构建BST的个数为:
dp[i]=∑dp[j]∗dp[i−j−1],0<=j<=i−1动态规划三部曲:
- 定义
dp[i]:用连续的i个数,搜构建的二叉搜索树个数- 状态转移方程:
dp[i]=∑dp[j]∗dp[i−j−1],0<=j<=i−1(双重for循环)- 初始化Base Case:
dp[0] = 1、dp[1] = 1
121. 买卖股票的最佳时机
分类:数组 | 动态规划
给定一个数组 prices ,它的第 i 个元素 prices[i] 表示一支给定股票第 i 天的价格。 你只能选择 某一天 买入这只股票,并选择在 未来的某一个不同的日子 卖出该股票。设计一个算法来计算你所能获取的最大利润。 返回你可以从这笔交易中获取的最大利润。如果你不能获取任何利润,返回 0 。
/**
* @param {number[]} prices
* @return {number}
*/
var maxProfit = function(prices) {
// 1. dp[i]的定义
const dp = new Array(prices.length).fill(false);
// 3. Base Case
dp[0] = 0;
let prevMinPrice = prices[0];
let res = 0;
for (let i = 1; i < prices.length; i++) {
// 2. 状态转移方程
dp[i] = Math.max(dp[i - 1], prices[i] - prevMinPrice);
// 更新 prevMinPrice、res;
prevMinPrice= Math.min(prevMinPrice, prices[i]);
res = Math.max(dp[i], res);
}
return res;
};
解题思路:动态规划:试想一下我在第i天的最大收益,有两种情况:
- 我在之前某一天的最低点买入,然后我今天卖掉了,赚的彭满钵满
- 我今天啥也不干,我今天的最大收益和昨天的最大收益是一样的
具体做法:
- 定义
dp[i]:在第i天的最大收益- 状态转移方程:
dp[i] = Math.max(dp[i], prices[i] - prevMinPrice)- 初始化Base Case:
dp[0] = 0
152. 乘积最大子数组
分类:数组 | 动态规划
给你一个整数数组 nums ,请你找出数组中乘积最大的非空连续子数组(该子数组中至少包含一个数字),并返回该子数组所对应的乘积。 测试用例的答案是一个 32-位 整数。 子数组 是数组的连续子序列。
/**
* @param {number[]} nums
* @return {number}
*/
var maxProduct = function(nums) {
let res = nums[0];
// 存在负负得正的情况,所以dp[i]的最大值、最小值都要记录一下,因为nums[i]可能为正数、也可能为负数
const dp = new Array(nums.length).fill(false).map(() => new Array(2));
// base case
dp[0][0] = nums[0]; // 以下标为0结尾的子数组的最小值;
dp[0][1] = nums[0]; // 以下标为0结尾的子数组的最大值;
for (let i = 1; i < nums.length; i++) {
dp[i][0] = Math.min(nums[i], dp[i - 1][0] * nums[i], dp[i - 1][1] * nums[i]);
dp[i][1] = Math.max(nums[i], dp[i - 1][0] * nums[i], dp[i - 1][1] * nums[i]);
res = Math.max(dp[i][1], res);
}
return res;
}
解题思路:动态规划:求[start, end]的数组的最大乘积,和
53.最大数组和一样的思路,以end为末尾项的子数组的最大乘积。但是需要考虑到负负得正的情况,所以这里dp需要对最大最小值都记录一下动态规划三部曲:
- 定义
dp[i]:dp[i][0]为以下标i结尾乘积最小子数组。dp[i][1]为以下标i结尾乘积最大子数组。- 状态转移方程:
dp[i][0] = Math.min(nums[i], dp[i - 1][0] * nums[i], dp[i - 1][1] * nums[i]);、dp[i][1] = Math.max(nums[i], dp[i - 1][0] * nums[i], dp[i - 1][1] * nums[i]);- 初始化Base Case:
dp[0][0] = nums[0]、dp[0][1] = nums[0]**Tips:**在每次计算max的过程,都和res比对一下,记录全局最大值。
198. 打家劫舍
分类:数组 | 动态规划
你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。 给定一个代表每个房屋存放金额的非负整数数组,计算你 不触动警报装置的情况下 ,一夜之内能够偷窃到的最高金额。
/**
* @param {number[]} nums
* @return {number}
*/
var rob = function (nums) {
let dp = new Array(nums).fill(false);
dp[0] = nums[0];
dp[1] = Math.max(nums[0], nums[1]);
for (let i = 2; i < nums.length; i++) {
// 假设,当前的值和前前家的加起来大于偷上一家的最大值。
if (nums[i] + dp[i - 2] > dp[i - 1]) {
dp[i] = dp[i - 2] + nums[i]
} else {
dp[i] = dp[i - 1]
}
}
return dp[nums.length - 1];
};
解题思路:动态规划。思路:
由于不可以在相邻的房屋传入,所以在当前位置
n房屋可盗窃的最大值,要么就是n - 1房屋的可盗窃最大值,要么就是n - 2房屋可盗窃的最大值加上当前房屋的可盗窃值。**具体思路:**动态规划三部曲
- 定义
dp[i]:到当前位置房屋可盗窃的最大值- 状态转移方程:
dp[i] = Math.max(dp[i] + dp[i - 2], dp[i - 1])- 初始化 Base Case:
dp[0] = nums[0]、dp[1] = Math.max(nums[0], nums[1])
221. 最大正方形
分类:数组 | 动态规划 | 矩阵
在一个由 '0' 和 '1' 组成的二维矩阵内,找到只包含 '1' 的最大正方形,并返回其面积。
/**
* @param {character[][]} matrix
* @return {number}
*/
var maximalSquare = function (matrix) {
const row = matrix.length;
const column = matrix[0].length;
const dp = new Array(row).fill(false).map(() => new Array(column).fill(false));
let maxSideLength = 0;
// 初始化Base
for (let i = 0; i < row; i++) {
dp[i][0] = matrix[i][0];
maxSideLength = Math.max(dp[i][0], maxSideLength); // 考虑到 [["0","1"],["1","0"]] 这个case
}
for (let j = 0; j < column; j++) {
dp[0][j] = matrix[0][j];
maxSideLength = Math.max(dp[0][j], maxSideLength); // 考虑到 [["0","1"],["1","0"]] 这个case
}
for (let i = 1; i < row; i++) {
for (let j = 1; j < column; j++) {
// 状态转移方程
if (matrix[i][j] > 0) {
dp[i][j] = Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]) + 1;
} else {
dp[i][j] = 0;
}
maxSideLength = Math.max(dp[i][j], maxSideLength);
}
}
return maxSideLength * maxSideLength;
};
解题思路:动态规划:明确思路:
- 找出最大正方形等价于找出最大边长
- 如果是0的话则不是我们需要的、1则能形成边长为1的正方形
dp[i][j]: 以(i,j)为右下顶点所能形成的最大正方形的边长(这里明确为右下顶点很关键,这样就能保证我们的动态规划的单向性,不会重复计算)- 以
dp[i][j]为右下顶点形成的最大正方形,取决于它左边、上方、左上方的项所能形成的最小正方形 + 1具体做法
- 定义
dp[i][j]:以(i,j)为右下顶点所能形成的最大正方形的边长- 状态转移方程:
matrix[i][j] > 0 ? dp[i][j] = Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]) + 1 : dp[i][j] = 0- 初始化Base Case:
dp[*][0] = matrix[*][0]、dp[0][*] = matrix[0][*]
279. 完全平方数
分类:广度优先搜索 | 数学 | 动态规划
给你一个整数 n ,返回 和为 n 的完全平方数的最少数量 。 完全平方数 是一个整数,其值等于另一个整数的平方;换句话说,其值等于一个整数自乘的积。例如,1、4、9 和 16 都是完全平方数,而 3 和 11 不是。
/**
* @param {number} n
* @return {number}
*/
var numSquares = function(n) {
// 定义dp[i]:和为 i 的完全平方数的最小数量
const dp = new Array(n + 1).fill(false);
// 初始化 Base Case:
dp[0] = 0;
dp[1] = 1;
// 计算dp[i]
for (let i = 2; i < n + 1; i++) {
dp[i] = i; // 默认设一个最大值为i(比如4 = 1 + 1 + 1 + 1 就是最坏的情况)
// 枚举 [1, √i]的所有情况
for (let j = 1; i - j * j >= 0; j++) {
dp[i] = Math.min(dp[i - j * j] + 1, dp[i]);
}
}
console.log(dp);
return dp[n];
};
解题思路:动态规划。思路:
- 举个例子,要算和为 17 的完全平方数的最少数量。那么其中的完全平方数 sqrNum 应该在 1 <= sqrNum <= √17
- 所以我们可以枚举这些情况。
- 假设最后一个完全平方数为1,那么和为 17 的完全平方数的最少数量 = 和为 16 (17 - 1 * 1) 的完全平方数的最少数量 + 1
- 假设最后一个完全平方数为2,那么和为 17 的完全平方数的最少数量 = 和为 13(17 - 2 * 2)的完全平方数的最少数量 + 1
- ...... 一直可以枚举到 4
- 那么如何算和为16、13的完全平方数的最小数量呢?和上述过程一样,这符合了动态规划的思路,可以用动态规划三部曲来解题。
具体做法:
- 定义
dp[i]:和为 i 的完全平方数的最小数量- 状态转移方程:
dp[i] = Math.min(dp[i - 1 * 1] + 1, dp[i - 2 * 2] + 1, ...),注意dp的下标要大于零。- 初始化Base Case:
dp[0] = 0、dp[1] = 1
300. 最长递增子序列
分类:数组 | 二分查找 | 动态规划
给你一个整数数组 nums ,找到其中最长严格递增子序列的长度。 子序列 是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。例如,[3,6,2,7] 是数组 [0,3,1,6,2,2,7] 的子序列。 示例 1: 输入:nums = [10,9,2,5,3,7,101,18] 输出:4 解释:最长递增子序列是 [2,3,7,101],因此长度为 4 。
/**
* @param {number[]} nums
* @return {number}
*/
var lengthOfLIS = function(nums) {
// 含第 i 个元素的最长上升子序列的长度。
// 在这里顺便做了一个 初始化 Base Case
const dp = new Array(nums.length).fill(1);
let res = dp[0];
for (let i = 1; i < nums.length; i++) {
for (let j = 0; j < i; j++) {
// dp[i] 有个默认的初始化值 为1
if (nums[i] > nums[j]) {
dp[i] = Math.max(dp[i], dp[j] + 1)
} else {
dp[i] = Math.max(dp[i], dp[j]);
}
}
res = Math.max(res, dp[i]);
}
return res;
}
解题思路:动态规划,思路:
- 使用数组dp来保存到当前下标时的递增子序列长度
- 求解
dp[i]时,向前遍历比i小的元素j,如果当前值nums[i] > nums[j]则dp[i] = dp[j] + 1,每遍历到一个dp[j]时,都更新一下dp[i],保证dp[i]是最大值。具体做法:
- 定义
dp[i]:含第i个元素的最长上升子序列的长度。- 状态转移方程:
nums[i] > nums[j] ? dp[i] = dp[j] + 1 : dp[i] = dp[j],其中,0 <= j < i。- 初始化 Base Case:
dp[0] = 1
322. 零钱兑换
分类:广度优先搜索 | 数组 | 动态规划
给你一个整数数组 coins ,表示不同面额的硬币;以及一个整数 amount ,表示总金额。 计算并返回可以凑成总金额所需的 最少的硬币个数 。如果没有任何一种硬币组合能组成总金额,返回 -1 。 你可以认为每种硬币的数量是无限的。
/**
* @param {number[]} coins
* @param {number} amount
* @return {number}
*/
var coinChange = function (coins, amount) {
if (amount === 0) {
return 0;
}
// 初始化Base Case 1
const dp = new Array(amount + 1).fill(-1);
// 初始化Base Case 2
for (coin of coins) {
dp[coin] = 1;
}
for (i = 1; i < amount + 1; i++) {
for (coin of coins) {
// 考虑下标越界的情况 && 上一步的硬币数是可以组成的
if (i - coin >= 0 && dp[i - coin] > 0) {
// 先确保 dp[i] 能被组成不为 -1。 再考虑取较小值
if (dp[i] > 0) {
dp[i] = Math.min(dp[i], dp[i - coin] + 1)
} else {
dp[i] = dp[i - coin] + 1;
}
}
}
}
console.log(dp);
return dp[amount]
};
解题思路:动态规划:思路:
- 假设给出[1, 2, 5]面额的硬币数,目标是120,需要多少个硬币?
- 求总金额 120 有几种方法?三种。
- 拿一枚面值为 1 的硬币 + 总金额为 119 的最少的硬币个数
- 拿一枚面值为 2 的硬币 + 总金额为 118 的最少的硬币个数
- 拿一枚面值为 5 的硬币 + 总金额为 115 的最少硬币个数
- 那么面值为 119、118、115的最少硬币数是多少呢,那么就需要知道 119 - 1、 119 - 2、 119 -5 的最少硬币数
具体做法:
- 定义
dp[i]:面额为 i 的总金额,最少需要多少枚硬币- 状态转移方程:
dp[i] = Math.min(dp[i - coins[0]] + 1, d[i - coins[1]] + 1, ...)(注意!这里的dp[i]为 -1的时候,直接等于dp[i - coin] + 1,先确保 dp[i] 能被组成不为 -1。 再考虑取较小值)- 初始化Base Case:
dp[*] = -1、dp[coin[0]] = 1、dp[coin[1]] = 1、dp[coin[2]] = 1...Tips: 本类题目可以使用「自顶向下」思想来考虑这个题目,然后用「自底向上」的方法来解题。
647. 回文子串
分类:字符串 | 动态规划
给你一个字符串 s ,请你统计并返回这个字符串中 回文子串 的数目。 回文字符串 是正着读和倒过来读一样的字符串。 子字符串 是字符串中的由连续字符组成的一个序列。 具有不同开始位置或结束位置的子串,即使是由相同的字符组成,也会被视作不同的子串。
解题思路:动态规划。思路:
这题本质上是在问以[i, j]为边界的回文子串有几个?
那么s[i, j]什么时候是回文子串呢?有三种情况
i === j,比如 a、b、c。是回文子串。i - j === 1 && i === j,比如aa这种情况。是回文子串。i === j并且s[i, j]是回文子串。比如aba、aca、ccacc这种情况。具体做法:
- 定义
dp[i][j]:s[i, j]是否是回文字符串- 状态转移方程:
dp[i][j] = (i === j && dp[i + 1][j - 1] === true) || false- 初始化Base Case:
i === j时,dp[i][j] = trueTips: 注意本题的遍历顺序,是竖着遍历的!!!这样才能保证在计算
dp[i][j]的时候dp[i + 1][j - 1]已经被计算好了。
贪心算法
55. 跳跃游戏
分类:贪心 | 数组 | 动态规划
给定一个非负整数数组 nums ,你最初位于数组的 第一个下标 。 数组中的每个元素代表你在该位置可以跳跃的最大长度。 判断你是否能够到达最后一个下标。
/**
* @desc 跳跃游戏(贪心) 贪心算法
* 遍历数组中的每一个位置,实时计算最远可以到达的位置。
* 如果最大位置大于等于数组长度,则结束,返回true,否则返回false
*/
var canJump = function (nums) {
const n = nums.length;
let rightmost = 0;
for (let i = 0; i < n; i++) {
// 前提条件,当前的下表是可达到的。
if (i <= rightmost) {
// 实时计算最远可以到达的位置
rightmost = Math.max(rightmost, i + nums[i]);
// 提前结束
if (rightmost >= n - 1) {
return true;
}
} else {
return false;
}
}
return false;
};
解题思路:贪心算法。根据题目的描述,只要存在一个位置
i1,它本身可以到达并且它跳跃的最大长度为i1 + nums[i1],这个值大于等于i2,即i1 + nums[i1] >= i2,那么位置i2也可以到达。