LeetCode Hot 100
1. 两数之和
给定一个整数数组 nums
和一个整数目标值 target
,请你在该数组中找出 和为目标值 target 的那 两个 整数,并返回它们的数组下标。
示例:给定
ini
nums = [2, 7, 11, 15]
target = 9
要求:找出数组中两个数,使它们的和为 target
,并返回对应的下标。
核心思路
使用一个 哈希表 Map 来存储遍历过的数字和它们的下标,在每次遍历当前元素时: 查找是否有另一个数
target - 当前数
已经在哈希表中出现过。哈希表本质是:把"值"映射为"键",然后通过键快速访问值。
在 JavaScript 中
Map
/Object
都是哈希表结构- 查找
map.has(key)
的时间复杂度是 O(1)- 因此每次查找是否存在目标差值
target - num
的时间是常数级
步骤:
-
初始化一个空的哈希表:
map = new Map()
-
遍历数组:
第一次遍历(i = 0):
-
当前数是
2
= -
计算目标差值:
target - 2 = 7
-
哈希表中还没有
7
,所以:把当前数字和下标存入 map:
map.set(2, 0)
第二次遍历(i = 1):
- 当前数是
7
- 计算差值:
target - 7 = 2
- 查找 map 中是否有
2
?- 有说明我们已经遍历过了一个可以和
7
组成9
的数 - 所以返回
[map.get(2), 1]
即[0, 1]
找到了答案
- 有说明我们已经遍历过了一个可以和
javascriptfunction twoSum(nums, target) { const map = new Map(); // 创建一个哈希表用于存储数值到索引的映射 for (let i = 0; i < nums.length; i++) { const num = nums[i]; // 当前数 const complement = target - num; // 当前数需要的"配对数" if (map.has(complement)) { // 如果哈希表中已有配对数,说明找到了两个数相加等于 target return [map.get(complement), i]; } // 否则,将当前数加入哈希表 map.set(num, i); } return []; // 如果没有找到,返回空数组(题目保证有解时可省略) }
-
2. 三数之和
给你一个整数数组 nums
,判断是否存在三元组 [nums[i], nums[j], nums[k]]
满足 i != j
、i != k
且 j != k
,同时还满足 nums[i] + nums[j] + nums[k] == 0
。请你返回所有和为 0
且不重复的三元组。
示例 :
scss
输入:nums = [-1,0,1,2,-1,-4]
输出:[[-1,-1,2],[-1,0,1]]
解释:
nums[0] + nums[1] + nums[2] = (-1) + 0 + 1 = 0 。
nums[1] + nums[2] + nums[4] = 0 + 1 + (-1) = 0 。
nums[0] + nums[3] + nums[4] = (-1) + 2 + (-1) = 0 。
不同的三元组是 [-1,0,1] 和 [-1,-1,2] 。
注意,输出的顺序和三元组的顺序并不重要。
问题拆解思路 :转换为 Two Sum 问题
我们可以将问题转化为:
- 对于数组中的每一个元素
a = nums[i]
,- 在剩下的元素中查找两个数
b + c = -a
也就是把问题从
a + b + c = 0
转为b + c = -a
,这其实就是我们前面讲的 Two Sum 问题。
解题算法(排序 + 双指针)
1.先排序:方便跳过重复值,并使用双指针优化查找
遍历排序后的数组,用一个指针
i
固定第一个数nums[i]
2.固定一个数 a ,再用左右指针在
a
右侧查找b + c = -a
对于每一个固定的
nums[i]
,我们需要在i
之后的子数组中找到两个数nums[left]
和nums[right]
,使得nums[i] + nums[left] + nums[right] == 0
。这等价于找到nums[left] + nums[right] == -nums[i]
为了找到这两个数,我们使用两个指针:
left
指针从i + 1
开始,right
指针从数组末尾开始。
双指针移动规则:
计算当前三数之和
sum = nums[i] + nums[left] + nums[right]
。
- 如果
sum == 0
:找到了一个有效的三元组[nums[i], nums[left], nums[right]]
。将其添加到结果列表中。
- 然后,为了避免重复,需要移动
left
指针向右,跳过所有与nums[left]
相同的元素。- 同时,移动
right
指针向左,跳过所有与nums[right]
相同的元素。- 最后,
left
和right
指针各自再向前/后移动一步,继续寻找。- 如果
sum < 0
:说明三数之和太小了,需要增大和。由于数组已排序,增大和的唯一方法是增加left
指针,使其指向更大的数。因此,left++
。- 如果
sum > 0
:说明三数之和太大了,需要减小和。减小和的方法是减小right
指针,使其指向更小的数。因此,right--
。循环条件: 双指针循环直到
left >= right
。3.跳过重复元素,避免结果重复
- 对 i 的去重: 在遍历
i
时,如果i > 0
且nums[i] == nums[i-1]
,则说明当前的nums[i]
与上一个已经处理过的nums[i-1]
是重复的。跳过当前i
的循环,直接进入下一个i
。- 对 left 和 right 的去重: 当找到一个有效三元组后,需要移动
left
指针跳过所有与nums[left]
相同的元素,right
指针跳过所有与nums[right]
相同的元素。这是因为nums[i]
已经固定,如果nums[left]
或nums[right]
重复,它们组成的三元组也会重复。
步骤:
以 [-1, 0, 1, 2, -1, -4]
为例
- 排序后:[-4, -1, -1, 0, 1, 2]
- 从 i = 0 开始,固定 nums[i] = -4,左右指针在 [i+1, n-1] 上找满足 -4 + b + c = 0
- 没找到后移动到
i = 1
,nums[i] = -1
,继续双指针查找。 - 找到
[-1, -1, 2]
和[-1, 0, 1]
。 - 跳过重复的
-1
- 最终返回去重后的结果
javascript
function threeSum(nums) {
const result = [];
const n = nums.length;
// 第一步:排序
nums.sort((a, b) => a - b);
// 第二步:固定第一个数 nums[i]
for (let i = 0; i < n - 2; i++) {
// 如果当前值大于0,后面不可能存在三数之和为0
if (nums[i] > 0) break;
// 跳过重复值
if (i > 0 && nums[i] === nums[i - 1]) continue;
let left = i + 1;
let right = n - 1;
// 双指针查找
while (left < right) {
const sum = nums[i] + nums[left] + nums[right];
if (sum === 0) {
result.push([nums[i], nums[left], nums[right]]);
// 跳过重复
while (left < right && nums[left] === nums[left + 1]) left++;
while (left < right && nums[right] === nums[right - 1]) right--;
//移动指针,继续寻找
left++;
right--;
} else if (sum < 0) {
left++; // sum太小,左指针右移
} else {
right--; // sum太大,右指针左移
}
}
}
return result;
}
3. 无重复字符的最长子串
给定一个字符串 s
,请你找出其中不含有重复字符的 最长 子串 的长度。
示例 :
ini
输入: s = "abcabcbb"
输出: 3
解释: 因为无重复字符的最长子串是 "abc",所以其长度为 3。
-
核心思想(滑动窗口 + 哈希集合):
用两个指针
left
和right
维护一个动态窗口 ,这个窗口中始终不包含重复字符。如果遇到重复字符,就收缩左边界;否则扩大右边界。我们使用一个集合(
Set
)来记录当前窗口中的字符,判断是否重复。滑动窗口:
- 想象有一个"窗口"在字符串上滑动。窗口的左边界
left
和右边界right
定义了当前子串的范围。 - 我们的目标是维护一个窗口,使得窗口内的字符始终是不重复的。
- 当窗口内的字符出现重复时,我们就需要收缩左边界来消除重复,直到窗口内再次没有重复字符。
哈希集合/映射 :
- 作用: 快速判断一个字符是否已经在当前窗口内存在。哈希表的查找、插入和删除操作的平均时间复杂度都是 O(1)。
- 存储内容:
- 可以存储窗口内出现的字符本身 (使用
Set
)。 - 也可以存储字符及其在字符串中的最新索引 (使用
Map
),这在某些变体问题中很有用,但对于本问题,Set
足够。
- 可以存储窗口内出现的字符本身 (使用
- 想象有一个"窗口"在字符串上滑动。窗口的左边界
-
步骤:
- 初始化:
maxLength = 0
: 用于存储迄今为止找到的最长无重复子串的长度。left = 0
: 滑动窗口的左边界。charSet = new Set()
: 用于存储当前窗口内的字符,以便快速检查重复。
- 遍历 字符串(右指针
right
):- 如果
s[right]
不在set
中:加入 set,更新最大长度,right++
- 如果
s[right]
在set
中:说明出现重复,移除 s[left],然后左指针右移,直到窗口中不包含重复字符。
- 如果
- 检查重复 并调整窗口 (收缩左指针
left
): - 更新最大长度并返回,循环结束:
- 在每次
right
指针移动(且窗口内无重复)后,当前窗口的长度就是right - left + 1
。 - 用当前长度更新
maxLength = Math.max(maxLength, right - left + 1)
。 - 当
right
指针遍历完整个字符串时,循环结束,maxLength
存储的就是最终结果。
- 在每次
javascriptvar lengthOfLongestSubstring = function(s) { // 如果字符串为空或只有一个字符,直接返回其长度 if (s.length <= 1) { return s.length; } let maxLength = 0; // 存储最长无重复子串的长度 let left = 0; // 滑动窗口的左边界 const charSet = new Set(); // 用于存储当前窗口内的字符,快速判断重复 // right 指针遍历字符串 for (let right = 0; right < s.length; right++) { // 如果当前字符 s[right] 已经在 Set 中存在,说明有重复 // 需要收缩左边界,直到 Set 中不再包含 s[right] while (charSet.has(s[right])) { charSet.delete(s[left]); // 从 Set 中移除左边界的字符 left++; // 左边界向右移动 } // 当前字符 s[right] 不在 Set 中,可以安全地加入窗口 charSet.add(s[right]); // 更新最长子串的长度 // 当前窗口的长度是 right - left + 1 maxLength = Math.max(maxLength, right - left + 1); } return maxLength; };
- 初始化:
4. 最大子数组和
给你一个整数数组 nums
,请你找出一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。
子数组是数组中的一个连续部分。
示例 :
ini
输入:nums = [-2,1,-3,4,-1,2,1,-5,4]
输出:6
解释:连续子数组 [4,-1,2,1] 的和最大,为 6 。
-
核心思想:动态规划(Kadane 算法)
我们使用一个变量
currentSum
表示当前子数组的最大和 ,一个变量maxSum
表示全局最大和。状态转移方程:
currentSum = max(nums[i], currentSum + nums[i]) maxSum = max(maxSum, currentSum)
含义:当前这个数
nums[i]
要么-
自己重新开始一个新的子数组(丢弃之前的)
-
要么继续加入之前的子数组中(currentSum + nums[i])
-
maxSum
保留遍历过程中最大的currentSum
动态规划思路:
假设我们遍历数组,到当前元素
nums[i]
时:dp[i] 表示以nums[i]
结尾的连续子数组的最大和。那么,dp[i]
的值有两种可能:- nums[i] 自己作为新的子数组的开始:这意味着之前的所有元素都不值得包含在内,因为它们会使和变小。
- nums[i] 连接到之前的连续子数组中 :这意味着以
nums[i-1]
结尾的最大子数组和dp[i-1]
是正的,加上nums[i]
后仍然能够增大或保持和。
所以,状态转移方程为:
dp[i] = Math.max(nums[i], dp[i-1] + nums[i])
我们最终的目标是整个数组中所有
dp[i]
中的最大值。Kadane's Algorithm (卡丹算法):
Kadane's Algorithm 是对上述动态规划思想的空间优化。我们不需要存储整个
dp
数组,只需要维护两个变量:- currentMax (或 dp[i] 的当前值): 表示以当前元素
nums[i]
结尾的连续子数组的最大和。 - globalMax (或 maxSum): 表示迄今为止遍历过程中发现的整体最大子数组和。
-
-
步骤:
- 初始化:
globalMax = nums[0]
:初始化为数组的第一个元素。这是因为子数组至少包含一个元素,所以第一个元素本身就是一个合法的子数组。currentMax = nums[0]
:以第一个元素结尾的最大子数组和就是它自己。
- 遍历数组: 从第二个元素开始 (
i = 1
) 遍历数组直到结束。 - 更新 currentMax:
- 对于每个
nums[i]
,计算以它结尾的最大子数组和:currentMax = Math.max(nums[i], currentMax + nums[i]);
- 这意味着,如果
currentMax + nums[i]
变得比nums[i]
本身还小(即currentMax
变成负数了),那么我们宁愿从nums[i]
重新开始一个子数组。
- 对于每个
- 更新 globalMax:
- 在每次更新
currentMax
之后,比较currentMax
和globalMax
,取较大值:globalMax = Math.max(globalMax, currentMax);
- 因为
currentMax
只代表以当前元素结尾的最大和,而globalMax
需要记录整个过程中出现过的最大和。
- 在每次更新
- 返回 globalMax: 循环结束后,
globalMax
就是整个数组的最大连续子数组和。
- 初始化:
javascript
var maxSubArray = function(nums) {
// 处理空数组或只有一个元素的边界情况(虽然题目说至少包含一个元素,但好的实践是考虑)
if (nums === null || nums.length === 0) {
return 0; // 或者抛出错误,根据具体要求
}
// 如果只有一个元素,最大和就是它自己
if (nums.length === 1) {
return nums[0];
}
// 初始化:
// currentMax: 以当前元素结尾的最大子数组和
// globalMax: 迄今为止发现的整体最大子数组和
let currentMax = nums[0];
let globalMax = nums[0];
// 从第二个元素开始遍历数组
for (let i = 1; i < nums.length; i++) {
// 更新 currentMax:
// 比较:
// 1. nums[i] 自己作为新的子数组的开始
// 2. nums[i] 连接到之前的连续子数组中 (currentMax + nums[i])
currentMax = Math.max(nums[i], currentMax + nums[i]);
// 更新 globalMax:
// 比较当前以 nums[i] 结尾的最大和 (currentMax) 与全局最大和 (globalMax)
globalMax = Math.max(globalMax, currentMax);
}
return globalMax;
};