JavaScript常见算法题分类
1. 哈希表
1.1. 两数之和
题目描述:给定一个整数数组 nums 和一个目标值 target,请你在该数组中找出和为目标值的那两个整数,并返回他们的数组下标。
解题思路:使用哈希表存储已经遍历过的数值和它们的下标。每遍历一个数,检查它的补数是否已经存在于哈希表中,若存在则返回。
代码实现:
function twoSum(nums: number[], target: number): number[] {const map = new Map<number, number>(); // 存储数值和下标的哈希表for (let i = 0; i < nums.length; i++) {const complement = target - nums[i]; // 计算补数if (map.has(complement)) { // 如果补数已经存在于哈希表中return [map.get(complement), i]; // 返回补数的下标和当前数的下标}map.set(nums[i], i); // 存入当前数和下标}return [];
}
2. 双指针
2.1. 最接近的三数之和
题目描述:给你一个长度为 n 的整数数组 nums 和一个目标值 target。请你从数组中选出三个整数,使它们的和与 target 最接近。返回这三个数的和。假定每组输入只存在唯一答案。
题目示例:
输入:nums = [-1, 2, 1, -4], target = 1
输出:2
解释:与目标值 1 最接近的和是 2 ,即 -1 + 2 + 1 = 2
思路分析:
1. 排序 + 双指针:这是解决此类问题的经典思路。首先我们对数组进行排序,然后利用双指针来缩小范围,找到最接近目标值的三数之和。
2. 步骤解析:
-
先对数组进行排序,这样方便后续使用双指针。
-
外层循环固定一个数,内层使用双指针扫描剩余的数,计算三数之和并比较与目标值的差值,记录最小差值。
-
根据当前和与目标值的大小关系,移动双指针来缩小差距。
3. 终止条件:当找到一个和恰好等于目标值时,可以直接返回。
代码实现:
function threeSumClosest(nums: number[], target: number): number {// 首先对数组进行排序nums.sort((a, b) => a - b);// 初始化最接近的和为一个较大的数let closestSum = Number.MAX_SAFE_INTEGER;// 遍历数组,固定第一个数for (let i = 0; i < nums.length - 2; i++) {// 使用双指针遍历后面的两个数let left = i + 1;let right = nums.length - 1;while (left < right) {// 计算当前三数的和const currentSum = nums[i] + nums[left] + nums[right];// 如果找到与目标值完全相等的和,直接返回if (currentSum === target) {return currentSum;}// 比较当前的和与之前记录的最接近的和if (Math.abs(currentSum - target) < Math.abs(closestSum - target)) {closestSum = currentSum; // 更新最接近的和}// 根据当前和与目标值的关系移动指针if (currentSum < target) {left++; // 当前和小于目标值,移动左指针使和增大} else {right--; // 当前和大于目标值,移动右指针使和减小}}}// 返回最终的最接近的三数之和return closestSum;
}
代码详解:
1. 排序:首先对数组进行升序排序,方便我们使用双指针。
nums.sort((a, b) => a - b);
2. 遍历数组:外层循环遍历数组,并且固定第一个数 nums[i],接着使用双指针 left 和 right。
for (let i = 0; i < nums.length - 2; i++) {let left = i + 1;let right = nums.length - 1;
3. 计算和:在每次双指针遍历时,计算三个数的和 currentSum,并且检查它是否比之前记录的 closestSum 更接近目标值。
const currentSum = nums[i] + nums[left] + nums[right];
4. 更新最接近的和:如果 currentSum 与目标值更接近,就更新 closestSum。
if (Math.abs(currentSum - target) < Math.abs(closestSum - target)) {closestSum = currentSum;
}
5. 移动双指针:根据 currentSum 与 target 的关系,移动指针。如果当前和小于目标值,说明需要增加和,则移动左指针;反之,移动右指针。
if (currentSum < target) {left++;
} else {right--;
}
扩展思路:
-
时间复杂度优化:当前算法的时间复杂度为 O(n^2),在面试中对于小规模的输入是可以接受的,但若输入数组很大,可能会超时。可以考虑使用其他更复杂的优化手段。
-
K Sum 问题:此题是经典的“三数之和”问题的变体,可以进一步扩展到 K Sum,如四数之和、五数之和可以通过增加递归层来实现。
2.2. 通过删除字母匹配到字典里最长单词
题目描述: 给你一个字符串 s 和一个字符串数组 dictionary,请你找出并返回 dictionary 中由 s 删除某些字符后可以形成的 最长单词。如果答案不止一个,返回字典序最小的那个单词。如果 dictionary 中没有单词可以由 s 删除一些字符后形成,返回空字符串。
题目示例:
输入:s = "abpcplea", dictionary = ["ale","apple","monkey","plea"]
输出:"apple"
解释:从字符串 s 中删除 "b" 和 "c",可以形成 "apple"。
输入:s = "abpcplea", dictionary = ["a","b","c"]
输出:"a"
思路分析:
1. 双指针匹配:遍历 dictionary 中的每个单词,使用双指针法来判断是否可以通过删除 s 中的某些字符形成该单词。对于每个字典单词,设一个指针 j 遍历它,另一个指针 i 遍历字符串 s,如果 s[i] == dictionary[j],那么两个指针都向前移动,直到匹配完整个单词或者 s 被遍历完。
2. 长度优先 + 字典序优先:对于每个可以匹配的单词,我们按照以下规则判断:
-
优先选择长度最长的单词。
-
如果有多个长度相同的匹配单词,选择字典序最小的。
代码实现:
function findLongestWord(s: string, dictionary: string[]): string {let result = "";// 遍历字典中的每一个单词for (let word of dictionary) {// 使用双指针来判断是否可以通过删除 s 中的某些字符形成该单词let i = 0, j = 0;while (i < s.length && j < word.length) {// 如果字符相同,两个指针都移动if (s[i] === word[j]) {j++;}i++; // s 的指针总是往前走}// 如果 j 能遍历完 word,说明 s 可以通过删除一些字符变成 wordif (j === word.length) {// 判断是否需要更新结果// 条件是:当前 word 的长度大于 result 或者相同长度时字典序更小if (word.length > result.length || (word.length === result.length && word < result)){result = word;}}}return result;
}
代码详解:
1. 初始化结果:初始时,result 为空字符串,用来存放当前找到的符合条件的最长单词。
for (let word of dictionary) {
2. 遍历字典:我们需要遍历 dictionary 中的每个单词,来检查它是否可以通过删除 s 中的某些字符形成。
while (i < s.length && j < word.length) {if (s[i] === word[j]) {j++;}i++;
}
3. 双指针判断匹配:
-
i 用于遍历字符串 s,j 用于遍历当前的字典单词 word。
-
如果 s[i] === word[j],说明当前字符匹配,j 指针前进;无论是否匹配,i 都会前进,直到 s 遍历完。
4. 判断是否更新结果:如果 j === word.length,说明字典单词完全匹配,可以通过删除 s 中的一些字符得到。接下来,我们需要根据长度和字典序更新 result:
-
如果当前的 word 比 result 长,直接更新。
-
如果长度相同,则比较字典序,字典序小的更新。
if (word.length > result.length || (word.length === result.length && word < result)) {result = word;
}
5. 返回结果:遍历完成后,返回最终找到的单词。
扩展思路:
- 排序优化:可以在处理 dictionary 之前,先按长度降序、字典序升序进行排序,这样在遍历的时候,只要找到一个匹配单词就可以提前结束循环,提高效率。
- 进一步优化:对于非常大的 dictionary,可以通过 Trie 树等数据结构来优化查找过程,但这需要较为复杂的实现。
2.3. 判断子序列
题目描述:给定字符串 s 和 t,判断 s 是否为 t 的子序列。一个字符串的 子序列 是指可以通过删除原字符串的一部分字符(可以不删除任何字符)而不改变剩余字符的相对顺序形成的新字符串。
题目示例:
// 示例1
输入: s = "abc", t = "ahbgdc"
输出: true
解释: s 是 t 的子序列// 示例2
输入: s = "axc", t = "ahbgdc"
输出: false
解释: s 不是 t 的子序列
思路分析:
1. 双指针法:
-
使用两个指针,一个指向 s,一个指向 t,从头开始比较每个字符。
-
如果 s[i] == t[j],则 i 和 j 同时后移,否则仅 j 后移。
-
最终判断 i 是否能遍历完整个 s 字符串,若能则说明 s 是 t 的子序列。
2. 复杂度分析:
-
时间复杂度:O(n),n 是 t 的长度,最坏情况下需要遍历整个 t。
-
空间复杂度:O(1),只需要常数空间来存储指针。
代码实现:
function isSubsequence(s: string, t: string): boolean {let i = 0; // 指向 s 的指针let j = 0; // 指向 t 的指针// 当 t 还没有遍历完时进行判断while (i < s.length && j < t.length) {if (s[i] === t[j]) {// 若字符相同,移动 s 的指针i++;}// 每次都移动 t 的指针j++;}// 如果 i 最终能够遍历完整个 s,则说明 s 是 t 的子序列return i === s.length;
}// 测试用例
console.log(isSubsequence("abc", "ahbgdc")); // true
console.log(isSubsequence("axc", "ahbgdc")); // false
详细注释与讲解:
1. 变量定义:
-
i 是指向字符串 s 的指针,初始值为 0。
-
j 是指向字符串 t 的指针,初始值也为 0。
2. 核心逻辑:
-
使用 while 循环遍历 t,在 i 尚未遍历完整个 s 时持续判断。
-
在每一次迭代中,判断 s[i] 和 t[j] 是否相同:
-
若相同,则 i++,即子序列成功匹配了一个字符,继续匹配下一个字符。
-
若不同,只移动 t 的指针 j++,继续在 t 中寻找与 s[i] 相匹配的字符。
-
3. 返回结果:
-
循环结束后,若 i === s.length,说明 s 中的所有字符都在 t 中找到并按照顺序匹配,返回 true;否则返回 false。
思考与扩展:
-
进阶问题:可以考虑字符串 t 非常长而 s 较短的情况,这时可以通过提前处理 t(如构建一个索引表)来加速匹配过程,从而优化算法的时间复杂度。
2.4. 合并两个有序数组
题目描述:给你两个按非递减顺序排列的整数数组 nums1 和 nums2,另有两个整数 m 和 n,分别表示 nums1 和 nums2 中的元素数目。请你合并 nums2 到 nums1 中,使合并后的数组同样按非递减顺序排列。
注意:最终,合并后数组不应由函数返回,而是存储在数组 nums1 中。为了应对这种情况,nums1 的初始长度为 m + n,其中前 m 个元素表示应合并的元素,后 n 个元素为 0,应忽略。nums2 的长度为 n 。
题目示例:
// 示例1
输入: nums1 = [1,2,3,0,0,0], m = 3, nums2 = [2,5,6], n = 3
输出: [1,2,2,3,5,6]// 示例 2
输入: nums1 = [1], m = 1, nums2 = [], n = 0
输出: [1]// 提示
nums1.length == m + n
nums2.length == n
0 <= m, n <= 200
1 <= nums1[i], nums2[j] <= 10^9
思路分析:
1. 从后往前合并:
-
由于 nums1 的后面部分有足够的空间可以存放 nums2 中的元素,因此我们可以从两个数组的末尾开始比较,较大的元素放入 nums1 的末尾。
-
这样可以避免每次插入元素后再去移动数组元素,提高效率。
2. 具体步骤:
-
设置三个指针,分别指向 nums1 和 nums2 的最后一个有效元素,以及合并后数组的最后一个位置。
-
每次比较 nums1 和 nums2 的最后一个有效元素,将较大的元素放入合并后数组的末尾。
-
当 nums2 还有剩余元素时,继续将剩余元素放入 nums1 。
3. 复杂度分析:
-
时间复杂度:O(m + n),需要遍历 nums1 和 nums2 中的所有元素。
-
空间复杂度:O(1),只使用了常数空间。
代码实现:
function merge(nums1: number[], m: number, nums2: number[], n: number): void {let i = m - 1; // nums1 的有效元素指针let j = n - 1; // nums2 的有效元素指针let k = m + n - 1; // 合并后数组的末尾指针// 从后向前遍历,比较 nums1 和 nums2 的元素while (i >= 0 && j >= 0) {if (nums1[i] > nums2[j]) {nums1[k] = nums1[i]; // 将较大值放入 nums1 的末尾i--; // nums1 指针向前移动} else {nums1[k] = nums2[j]; // 将较大值放入 nums1 的末尾j--; // nums2 指针向前移动}k--; // 合并后的数组指针向前移动}// 如果 nums2 还有剩余元素,将它们放入 nums1while (j >= 0) {nums1[k] = nums2[j];j--;k--;}// 无需处理 nums1 剩余元素,因为它们已经在适当位置上
}// 测试用例
const nums1 = [1, 2, 3, 0, 0, 0];
merge(nums1, 3, [2, 5, 6], 3);
console.log(nums1); // 输出: [1, 2, 2, 3, 5, 6]
详细注释与讲解:
1. 变量定义:
-
i 是指向 nums1 有效部分最后一个元素的指针,初始值为 m - 1。
-
j 是指向 nums2 最后一个元素的指针,初始值为 n - 1。
-
k 是合并后的数组的最后一个位置的指针,初始值为 m + n - 1。
2. 核心逻辑:
-
使用 while 循环比较 nums1[i] 和 nums2[j] 的大小,较大的元素放到 nums1[k] 位置。
-
每次比较后,将相应的指针向前移动。
-
当 nums2 中还有未处理的元素时,使用 while 循环将它们复制到 nums1 中。
3. 边界情况:
-
如果 nums2 为空,直接返回 nums1 原来的内容。
-
如果 nums1 的所有有效元素都比 nums2 小,不需要额外操作。
思考与扩展:
-
扩展问题:如何处理任意多个有序数组的合并?这种问题可以通过类似的方式解决,使用归并排序的思想逐步合并多个有序数组,时间复杂度为 O(kn log k),其中 k 是数组的数量。
3. 滑动窗口
3.1. 滑动窗口最大值
题目描述:给定一个数组 nums 和滑动窗口的大小 k,请找出所有滑动窗口里的最大值。
题目标例:
代码块
输入:nums = [1,3,-1,-3,5,3,6,7], k = 3
输出:[3,3,5,5,6,7]
解释:
滑动窗口的位置 最大值
---------------
[1 3 -1] -3 5 3 6 7 31 [3 -1 -3] 5 3 6 7 31 3 [-1 -3 5] 3 6 7 51 3 -1 [-3 5 3] 6 7 51 3 -1 -3 [5 3 6] 7 61 3 -1 -3 5 [3 6 7] 7// 提示:
1 <= nums.length <= 10^5
-10^4 <= nums[i] <= 10^4
1 <= k <= nums.length
思路分析:
1. 暴力解法:对每一个滑动窗口,找到其中的最大值,直接使用双重循环计算最大值,时间复杂度为 O(n * k),但当数组长度较大时,性能较差。
2. 双端队列(Deque)优化:为了提升性能,可以使用双端队列(Deque)来维护一个窗口中的最大值。队列中存储数组的索引值,并且保持队列中的元素按照从大到小排列:
-
对于每一个新元素,将队列尾部所有比当前元素小的元素移除。
-
保证队列头部始终是当前窗口的最大值。
-
当队列中的最大值元素超出窗口范围时,将其移除。
3. 复杂度分析:
-
时间复杂度:O(n),每个元素最多进出队列一次。
-
空间复杂度:O(k),队列中最多维护 k 个元素。
代码实现:
function maxSlidingWindow(nums: number[], k: number): number[] {const deque: number[] = []; // 双端队列,存储的是数组的索引const result: number[] = []; // 存放最大值的结果数组for (let i = 0; i < nums.length; i++) {// 当队列的第一个元素(最大值)超出窗口范围时,移除它if (deque.length && deque[0] < i - k + 1) {deque.shift();}// 保证队列中的元素值按照从大到小排列,移除小于当前元素的索引while (deque.length && nums[deque[deque.length - 1]] < nums[i]) {deque.pop();}// 将当前元素索引添加到队列尾部deque.push(i);// 当窗口长度达到 k 时,记录当前窗口的最大值(即队列的第一个元素)if (i >= k - 1) {result.push(nums[deque[0]]);}}return result;
}// 测试用例
const nums = [1, 3, -1, -3, 5, 3, 6, 7];
const k = 3;
console.log(maxSlidingWindow(nums, k)); // 输出: [3, 3, 5, 5, 6, 7]
详细注释与讲解:
1. 双端队列的用法:
-
deque 用来维护当前滑动窗口的最大值,它存储的不是值而是索引。这样可以通过 nums[deque[0]] 来获取窗口中的最大值。
-
由于队列中元素按照值从大到小的顺序排列,因此队列的头部总是滑动窗口中的最大值。
2. 核心逻辑:
-
每次处理一个新元素时,首先检查队列中头部的元素是否超出了滑动窗口的左边界(i - k + 1),如果超出则移除。
-
通过一个 while 循环,将队列尾部所有比当前元素小的元素移除,保证新元素插入后队列仍然是按降序排列的。
-
每次滑动窗口满 k 个元素时,队列头部元素就是当前窗口的最大值,将其记录到 result 数组中。