大家好👋!这次更新的是关于数组的「滑动窗口」&「双指针」算法。在这篇文章之前应该还有一篇关于「排序」算法的文章,但因为排序算法种类有点多和其他原因,所以先跳过,下次一定再补回来!
在数组中有两类很经典的算法,就是「滑动窗口」和「双指针」。
事实上,「滑动窗口」是特殊的「双指针」,都是使用了两个「指针变量」来解决问题。
区别:
- 滑动窗口:两个变量同向移动;
- 双指针:两个变量一头一尾向中间移动;
⚠️:
- 这里说的「指针」并不是「C语言中的指针」,仅仅是一个「变量」,在使用这个「变量」时遵循「循环不变量」;
- 该两种算法都是从「暴力算法」中优化而来;
滑动窗口
「滑动窗口」两个「指针」的移动方向是相同的,形成了一个「窗口」在直线上「滑动」的效果。
大多数「滑动窗口」问题,先从「暴力解法」开始,从而进行优化。
「暴力解法」通常以「多重循环」出现,因此优化思路有以下两种:
- 以空间换时间,在遍历过程中,记录变量的值,使得相同区间的信息不必重新计算;
- 遍历过程中,排除不必要的方案,减低时间复杂度;
例题
以 leetcode 的「无重复字符的最长子串」为例:
暴力解法:
- 枚举这个字符串的所有子串;
- 找出所有没有重复字符的子串;
- 返回没有重复字符子串中最大长度;
var lengthOfLongestSubstring = function (s) {
const len = s.length;
let max = 1;
for (let left = 0; left < len - 1; left++) {
for(let right = 0; right < len; right ++) {
// 这里判断[left, right]之间是否有重复元素,再和max比较替换
}
}
return max;
};
优化:
- 双指针
left
与right
重合于数组头部,left
不动,right
尝试向右边扩张,直到[left, right]
中有恰有1
个重复元素; - 如果在子区间
[left, right]
中有重复元素,[left, right + 1]
、[left, right + 2]
一直到[left, len - 1]
一定包含重复元素,这一点是这问题可以使用「滑动窗口」的原因。此时考虑left
向右移动:left
不能向左移动:因为向左移动,仍然不能改变[left, right]
中有恰有1
个重复元素的现状;left
只能向右移动,直到left
刚刚好越过与right
指向的那个重复的重复元素坐标为止。
var lengthOfLongestSubstring = function (s) {
const len = s.length;
if (len < 2) return len;
const sArray = s.split("");
const sMap = new Map();
let left = 0,
max = 0;
for (let right = 0; right < len; right++) {
if (sMap.get(sArray[right]) !== undefined) {
left = Math.max(left, sMap.get(sArray[right]) + 1);
}
sMap.set(sArray[right], right);
max = Math.max(max, right - left + 1);
}
return max;
};
练习
⚠️:并非官方最优解,仅为笔者的实现方式。
最小覆盖子串- 困难
/**
* leetcode 76.最小覆盖子串 - 困难
*
* @remarks
* 给你一个字符串 s 、一个字符串 t 。返回 s 中涵盖 t 所有字符的最小子串。如果 s 中不存在涵盖 t 所有字符的子串,则返回空字符串 "" 。
*
* @example
* 输入:s = "ADOBECODEBANC", t = "ABC"
* 输出:"BANC"
* 解释:最小覆盖子串 "BANC" 包含来自字符串 t 的 'A'、'B' 和 'C'。
*/
var minWindow = function(s, t) {
const tArray = t.split("");
const tMap = {};
const sMap = {};
tArray.forEach((ele) = >{
tMap[ele] = tMap[ele] ? tMap[ele] + 1 : 1;
});
let distance = Object.keys(tMap).length;
const sArray = s.split("");
let left = 0,
right = 0,
len = sArray.length,
minLen = sArray.length + 1,
match = 0,
start = 0;
while (right < len) {
const curR = sArray[right];
if (tMap[curR] > 0) {
sMap[curR] = sMap[curR] ? sMap[curR] + 1 : 1;
if (sMap[curR] === tMap[curR]) {
match++;
}
}
right++;
while (match === distance) {
if (right - left < minLen) {
start = left;
minLen = right - left;
}
const curL = sArray[left];
if (tMap[curL] > 0) {
sMap[curL]--;
if (sMap[curL] < tMap[curL]) {
match--;
}
}
left++;
}
}
return minLen === len + 1 ? "": s.slice(start, start + minLen);
};
长度最小的子数组 - 中等
/**
* 209. 长度最小的子数组 - 中等
*
* @remarks
* 给定一个含有 n 个正整数的数组和一个正整数 target 。
* 找出该数组中满足其和 ≥ target 的长度最小的 连续子数组 [numsl, numsl+1, ..., numsr-1, numsr] ,并返回其长度。如果不存在符合条件的子数组,返回 0 。
*
* @example
* 输入:target = 7, nums = [2,3,1,2,4,3]
* 输出:2
* 解释:子数组 [4,3] 是该条件下的长度最小的子数组。
*/
var minSubArrayLen = function(target, nums) {
let total = 0,
left = 0,
right = 0,
len = nums.length,
minLen = nums.length + 1;
if (len === 0) {
return 0;
} else if (len === 1) {
return nums[0] >= target ? 0 : 1;
}
while (right < len) {
total += nums[right];
right++;
while (total >= target) {
if (right - left < minLen) {
minLen = right - left;
}
total -= nums[left];
left++;
}
}
return minLen === len + 1 ? 0 : minLen;
};
找到字符串中所有字母异位词 - 中等
/**
* 438. 找到字符串中所有字母异位词 - 中等
*
* @remarks
* 给定两个字符串 s 和 p,找到 s 中所有 p 的 异位词 的子串,返回这些子串的起始索引。不考虑答案输出的顺序。
* 异位词 指由相同字母重排列形成的字符串(包括相同的字符串)。
*
* @example
* 输入:s = "cbaebabacd", p = "abc"
* 输出:[0,6]
* 解释:
* 起始索引等于 0 的子串是 "cba", 它是 "abc" 的异位词。
* 起始索引等于 6 的子串是 "bac", 它是 "abc" 的异位词。
*/
var findAnagrams = function(s, p) {
const pArray = p.split("");
const pLen = pArray.length;
const pMap = {};
pArray.forEach((el) = >{
pMap[el] = pMap[el] ? pMap[el] + 1 : 1;
});
const sArray = s.split("");
const sLen = sArray.length;
const result = [];
let left = 0,
right = pLen - 1;
while (right < sLen) {
let tArray = sArray.slice(left, right + 1);
let tLen = pLen;
const sMap = Object.assign({},
pMap);
tArray.forEach((el) = >{
if (sMap[el] > 0) {
sMap[el] -= 1;
tLen -= 1;
}
});
if (!tLen) {
result.push(left);
}
left += 1;
right += 1;
while (right < sLen && !pMap[sArray[right]]) {
left = right + 1;
right += pLen;
}
}
return result;
};
字符串的排列 - 中等
/**
* 567. 字符串的排列 - 中等
*
* @remarks
* 给你两个字符串 s1 和 s2 ,写一个函数来判断 s2 是否包含 s1 的排列。如果是,返回 true ;否则,返回 false 。
* 换句话说,s1 的排列之一是 s2 的 子串 。
*
* @example
* 输入:s1 = "ab" s2 = "eidbaooo"
* 输出:true
* 解释:s2 包含 s1 的排列之一 ("ba").
*/
var checkInclusion = function(s1, s2) {
const s1Array = s1.split("");
const s1Map = {};
let distance = 0;
s1Array.forEach((el) = >{
s1Map[el] = s1Map[el] ? s1Map[el] + 1 : 1;
distance += 1;
});
const s2Array = s2.split("");
const s2Len = s2.length;
let s2Map = Object.assign({},
s1Map);
let left = 0,
right = 0,
match = 0,
result = false;
while (!result && right < s2Len) {
if (s2Map[s2Array[right]] === undefined) {
right++;
left = right;
match = 0;
s2Map = Object.assign({},
s1Map);
} else if (s2Map[s2Array[right]] === 0) {
left += 1;
s2Map[s2Array[left - 1]] += 1;
match -= 1;
} else if (s2Map[s2Array[right]]) {
s2Map[s2Array[right]] -= 1;
match++;
right++;
}
if (match === distance) return true;
}
return false;
};
替换后的最长重复字符 - 中等
/**
* 424. 替换后的最长重复字符 - 中等
*
* @remarks
* 给你一个字符串 s 和一个整数 k 。你可以选择字符串中的任一字符,并将其更改为任何其他大写英文字符。该操作最多可执行 k 次。
* 在执行上述操作后,返回包含相同字母的最长子字符串的长度。
*
* @example
* 输入:s = "ABAB", k = 2
* 输出:4
* 解释:用两个'A'替换为两个'B',反之亦然。
*/
var characterReplacement = function(s, k) {
const sArray = s.split("");
const sLen = s.length;
if (sLen === 1) return 1;
let left = 0,
right = 1,
tMap = { [sArray[left]] : 1,
},
result = 1;
while (right <= sLen) {
const tMapTotal = Object.values(tMap).reduce((acc, val) = >acc + val, 0);
const tMapMax = Object.keys(tMap).reduce((a, b) = >tMap[a] > tMap[b] ? a: b);
const time = tMapTotal - tMap[tMapMax];
if (time > k) {
tMap[sArray[left]] -= 1;
left++;
} else {
tMap[sArray[right]] = tMap[sArray[right]] ? tMap[sArray[right]] + 1 : 1;
result = Math.max(result, right - left);
right++;
}
}
return result;
};
双指针
「双指针」两个「指针」的移动方向是相反的,左右指针向中间靠拢。
例题
以 leetcode 的「三数之和」为例:
思路:利用数组的有序性,达到搜索所有解「剪枝」的目的。首先对输入数组排序,循环变量为i
,设置变量left
和right
分别位于i
后面的区间[i + 1, len - 1]
的头和尾。
枚举起点i
,在[i + 1, len - 1]
区间里查找两数之和为-nums[i]
,这里记 target = -nums[i]
。
- 如果
nums[left] + nums[right] > target
,说明两个数的和太大了,由于数组已经有序,nums[left + 1] + nums[right] > target、nums[left + 2] + nums[right] > target
一定成立,但是nums[left] + nums[right - 1] 与 target
的大小关系还不知道,此时需要考虑将right
左移,即right--
; - 如果
nums[left] + nums[right] < target
,说明两个数的和太小了,由于数组已经有序,nums[left] + nums[right - 1] < target、nums[left] + nums[right - 2] < target
一定成立,但是nums[left + 1] + nums[right] 与 target
的大小关系还不知道,此时需要考虑将left
右移,即left++
; - 如果
nums[left] + nums[right] == target
,说明得到了一组可行解。此时left
和right
同时向中间移动一格; - 如果
left
和right
移动以后的值和上一个一样,还必须继续移动,否则会将重复的结果输出到结果集中。
var threeSum = function(nums) {
const len = nums.length;
if (len === 3) return nums.reduce((total, cur) = >total + cur, 0) ? [] : [nums];
nums = nums.sort((prev, next) = >prev - next);
const result = [];
let left = 0,
right = 0,
target;
for (let i = 0; i < len - 2; i++) {
if (nums[i] > 0) {
break;
}
if (i > 0 && nums[i] == nums[i - 1]) {
continue;
}
left = i + 1;
right = len - 1;
target = nums[i];
while (left < right) {
if (target === -1) console.log(nums[left], nums[right]);
const sum = nums[left] + nums[right] + target;
if (sum === 0) {
result.push([target, nums[left], nums[right]]);
left++;
right--;
while (left < right && nums[left] === nums[left - 1]) {
left++;
}
while (left < right && nums[right] === nums[right + 1]) {
right--;
}
} else if (sum > 0) {
right--;
} else {
left++;
}
}
}
return result;
};
练习
⚠️:并非官方最优解,仅为笔者的实现方式。
最接近的三数之和 - 中等
/**
* 16. 最接近的三数之和 - 中等
*
* @remarks
* 给你一个长度为 n 的整数数组 nums 和 一个目标值 target。请你从 nums 中选出三个整数,使它们的和与 target 最接近。
* 返回这三个数的和。
* 假定每组输入只存在恰好一个解。
*
* @example
* 输入:nums = [-1,2,1,-4], target = 1
* 输出:2
* 解释:与 target 最接近的和是 2 (-1 + 2 + 1 = 2) 。
*/
var threeSumClosest = function(nums, target) {
const len = nums.length;
if (len === 3) return nums.reduce((total, cur) = >total + cur, 0);
nums = nums.sort((prev, next) = >prev - next);
const minSum = nums.slice(0, 3).reduce((total, cur) = >total + cur, 0);
let minClose = Math.abs(minSum - target);
let minAnswer = minSum;
let left,
right,
cur;
for (let i = 0; i < len - 2; i++) {
left = i + 1;
right = len - 1;
cur = nums[i];
while (left < right) {
const sum = cur + nums[left] + nums[right];
const gap = Math.abs(sum - target);
if (gap <= minClose) {
minClose = gap;
minAnswer = sum;
}
if (sum === target) return sum;
if (sum > target) {
right--;
} else {
left++;
}
}
}
return minAnswer;
};
两数之和 II - 输入有序数组 - 中等
/**
* 167. 两数之和 II - 输入有序数组 - 中等
*
* @remarks
* 给你一个下标从 1 开始的整数数组 numbers ,该数组已按 非递减顺序排列 ,请你从数组中找出满足相加之和等于目标数 target 的两个数。
* 如果设这两个数分别是 numbers[index1] 和 numbers[index2] ,则 1 <= index1 < index2 <= numbers.length 。
* 以长度为 2 的整数数组 [index1, index2] 的形式返回这两个整数的下标 index1 和 index2。
* 你可以假设每个输入 只对应唯一的答案 ,而且你 不可以 重复使用相同的元素。
* 你所设计的解决方案必须只使用常量级的额外空间。
*
* @example
* 输入:numbers = [2,7,11,15], target = 9
* 输出:[1,2]
* 解释:2 与 7 之和等于目标数 9 。因此 index1 = 1, index2 = 2 。返回 [1, 2] 。
*/
var twoSum = function(numbers, target) {
const len = numbers.length;
let[prev, next] = [0, len - 1];
let result = 0;
while (next > prev) {
result = numbers[prev] + numbers[next];
if (result > target) {
next--;
} else if (result < target) {
prev++;
} else {
return [prev + 1, next + 1];
}
}
return [];
};
接雨水 - 困难
/**
* 42. 接雨水 - 困难
*
* @remarks
* 给定 n 个非负整数表示每个宽度为 1 的柱子的高度图,计算按此排列的柱子,下雨之后能接多少雨水。
*
* @example
* 输入:height = [0,1,0,2,1,0,1,3,2,1,2,1]
* 输出:6
* 解释:上面是由数组 [0,1,0,2,1,0,1,3,2,1,2,1] 表示的高度图,在这种情况下,可以接 6 个单位的雨水(蓝色部分表示雨水)。
*/
var trap = function(height) {
const len = height.length;
if (len < 3) return 0;
let maxLeft = height[0],
maxRight = height[len - 1];
let left = 1,
right = len - 2;
let res = 0;
while (left <= right) {
if (maxRight > maxLeft) {
if (maxLeft > height[left]) {
res += maxLeft - height[left];
}
maxLeft = Math.max(maxLeft, height[left]);
left++;
} else {
if (maxRight > height[right]) {
res += maxRight - height[right];
}
maxRight = Math.max(maxRight, height[right]);
right--;
}
}
return res;
};
盛最多水的容器 - 中等
/**
* 11. 盛最多水的容器 - 中等
*
* @remarks
* 给定一个长度为 n 的整数数组 height 。有 n 条垂线,第 i 条线的两个端点是 (i, 0) 和 (i, height[i]) 。
* 找出其中的两条线,使得它们与 x 轴共同构成的容器可以容纳最多的水。
*
* @example
* 输入:[1,8,6,2,5,4,8,3,7]
* 输出:49
* 解释:图中垂直线代表输入数组 [1,8,6,2,5,4,8,3,7]。在此情况下,容器能够容纳水(表示为蓝色部分)的最大值为 49。
*/
var maxArea = function(height) {
const len = height.length;
let left = 0,
right = len - 1,
max = 0;
while (left < right) {
const min = Math.min(height[left], height[right]);
const gap = right - left;
const sum = gap * min;
max = Math.max(sum, max);
if (height[left] < height[right]) {
left++;
} else {
right--;
}
}
return max;
};
最后
参考文章:
很感谢大家抽空读完这篇文章,希望大家能有所收获。祝大家工作顺利,身体健康💪。
下一篇:链表。
「 ---------- The end ---------- 」