前端学数据结构与算法(十二):有趣的算法 - 多指针与滑动窗口
前言
如果说如何用算法高效有趣的解决某些问题,那多指针和滑动算法绝对是算其中的佼佼者。这也是笔者最初接触算法时觉得最有意思的一点,因为解决的问题是熟悉的,但配方却完全不同,本章我们从一个简单的交集问题出发,一步步的认识到多指针及滑动窗口解决某些问题时的巧妙与高效,本章主要以解LeetCode
里高频题为参考~
多指针
349 - 两个数组的交集 ↓
给定两个数组,编写一个函数来计算它们的交集。 输入:nums1 = [1,2,2,1], nums2 = [2,2] 输出:[2] 输入:nums1 = [4,9,5], nums2 = [9,4,9,8,4] 输出:[9,4] 来源:力扣(LeetCode) 链接:https://leetcode-cn.com/problems/intersection-of-two-arrays
暴力解:
将两个数组共有的元素放入一个数组进行去重即可,去重需要使用set
,那直接存入set
完事。代码如下:
解法1: var intersection = function (nums1, nums2) { const set = new Set() nums1.forEach(num => { if (nums2.includes(num)) { set.add(num) } }) return [...set] };
以下简单假设两个数组的长度都是n
,该解法属于暴力解,需要两重的循环,includes
需要在数组里查找,所以内部也是遍历,最终的复杂度是O(n²)
,有没有更高效的解法?
二分查找:
当然有,因为是查找问题,我们可以对两个数组分别排序,然后运用上一章我们学习到的二分查找法进行查找,替换includes
的查找,那么最终的解法我们能优化到O(nlogn)
级别,代码如下:
解法2: var intersection = function (nums1, nums2) { nums1.sort((a, b) => a - b) nums2.sort((a, b) => a - b) const set = new Set() nums1.forEach(num => { if (binarySearch(nums2, num)) { set.add(num) } }) return [...set] }; function binarySearch(arr, target) { // 二分查找 let l = 0; let r = arr.length while (r > l) { const mid = l + (r - l) / 2 | 0 if (arr[mid] === target) { return true } else if (arr[mid] > target) { r = mid } else { l = mid + 1 } } return false }
首先对数据进行预处理,最终的代码行数比方法1
多了不少,不过总的复杂度是3
个O(nlogn)
,比O(n²)
快不少,还有更高效的解法么?
双指针:
当然,还可以使用一种双指针的解法,首先还是对两个数组进行排序,然后使用两个指针分别指着两个数组的开头,谁的数值小谁向后滑动,遇到相同的元素就放入set
内,直至两个数组中有一个到头为止。代码如下:
解法3: var intersection = function (nums1, nums2) { nums1.sort((a, b) => a - b) nums2.sort((a, b) => a - b) let i = 0; let j = 0; const set = new Set() while (i < nums1.length && j < nums2.length) { //有一个到头结束循环 if (nums1[i] === nums2[j]) { // 找到了交集 set.add(nums1[i]) // 放入set内 i++ j++ } else if (nums1[i] > nums2[j]) { // 谁数值小,+1 移动 j++ } else { i++ } } return [...set] };
整个复杂度需要对两个数组快排,然后双指针要走完两个数组,最终的复杂度是O(nlogn) + O(nlogn) + 2n
,虽然总的复杂度还是O(nlogn)
,不过效率会优于二分查找。
167 - 两数之和 II - 输入有序数组 ↓
给定一个已按照升序排列的有序数组,找到两个数使得它们相加之和等于目标数。 函数应该返回这两个下标值 index1 和 index2,其中 index1 必须小于 index2。 说明: 返回的下标值(index1 和 index2)不是从零开始的。 你可以假设每个输入只对应唯一的答案,而且你不可以重复使用相同的元素。 输入: numbers = [2, 7, 11, 15], target = 9 输出: [1,2] 解释: 2 与 7 之和等于目标数 9 。因此 index1 = 1, index2 = 2。 来源:力扣(LeetCode) 链接:https://leetcode-cn.com/problems/two-sum-ii-input-array-is-sorted
很自然的能想到暴力解,两层循环遍历,最终的复杂度是O(n²)
,但这不是我们想到的。
很显然暴力解并没有利用到题目描述里的升序排列这个特性,而最终的结果是需要查找的,所以我们很自然能想到使用二分查找法。这确实也是一种更快的解题思路,能将最终的复杂度降低到O(nlogn)
级别,大家可以尝试解决。
这里提供一种更巧妙的解题思路,对撞指针。我们可以设置头尾两个指针,每一次将它们的和与目标进行比较,如果比目标值大,尾指针向中间移动,减少它们相加的和;反之它们的和如果比目标值小则把头指针向中间移动,增加它们相加的和。因为是有序的数组,所以不用担心移动的过程中错过了目标值。代码如下:
var twoSum = function (numbers, target) { let l = 0 // 左指针 let r = numbers.length - 1 // 右指针 while (r > l) { // 不能 r >= l,因为不能使用同一个值两次 if (numbers[r] + numbers[l] === target) { return [l + 1, r + 1] } else if (numbers[r] + numbers[l] > target) { r-- // 右指针向中间移动 } else { l++ // 左指针向中间移动 } } return [] };
11 - 盛最多水的容器 ↓
给你 n 个非负整数 a1,a2,...,an,每个数代表坐标中的一个点 (i, ai) 。 在坐标内画 n 条垂直线,垂直线 i 的两个端点分别为 (i, ai) 和 (i, 0) 。 找出其中的两条线,使得它们与 x 轴共同构成的容器可以容纳最多的水。 示例: 输入:[1,8,6,2,5,4,8,3,7] 输出:49 解释:图中垂直线代表输入数组 [1,8,6,2,5,4,8,3,7]。 在此情况下,容器能够容纳水(表示为蓝色部分)的最大值为 49。 来源:力扣(LeetCode) 链接:https://leetcode-cn.com/problems/container-with-most-water
初看这题可能很懵逼,或者就是使用两层循环的暴力解,求出每种可能,找里里面最大值,面试官对这个解法肯定不会满意。
而这道经典题目,我们同样可以使用对撞指针解法,首先设置首尾两个指针,依次向中间靠近,但这题麻烦的地方在于两个指针之间谁动谁不动的问题。
经过观察不难发现,就是指针所指向的值,谁的数值小,谁就需要移动。因为如果数值大的指针向中间移动,小的那个值的指针并不会变,而它们之间的距离会缩短,乘积也会变小。一次遍历即可解决战斗,解题代码如下:
var maxArea = function (height) { let max = 0 // 保存最大的值 let l = 0; let r = height.length - 1 while (r > l) { // 不能相遇 const h = Math.min(height[r], height[l]) max = Math.max(h * (r - l), max) // 容量等于距离差 * 矮的那边条轴 height[r] > height[l] ? l++ : r-- // 移动矮轴的指针 } return max };
15 - 三数之和 ↓
给你一个包含 n 个整数的数组 nums,判断 nums 中是否存在三个元素a,b,c,使得a+b+c=0? 请你找出所有满足条件且不重复的三元组。 注意:答案中不可以包含重复的三元组。 nums = [-1, 0, 1, 2, -1, -4] 满足要求的三元组集合为: [ [-1, 0, 1], [-1, -1, 2] ] 来源:力扣(LeetCode) 链接:https://leetcode-cn.com/problems/3sum
很容易想到的就是暴力解,使用三层遍历,将三个数字累加和的可能性都计算一遍,提取需要的组合即可,暴力解的复杂度是O(n³)
。如果这题是要返回它们对应的下标,那还真没办法,不过既然是返回组合的数字,那我们就可以利用有序数组的特性,还是使用对撞指针更有效率的解决此题。
首先对数组进行排序,然后设置三个指针i、l、r
,每一轮的循环下标i
是固定不动的,让l
和j
对撞,最后根据它们相加的和来移动l
和r
指针。如果和正好等于0
,那就找到了一种组合结果;如果大于0
,就r--
让r
指针向中间移动;如果小于0
,就l++
让l
指针向中间移动,该解法的复杂度是O(n²)
。解题代码如下:
var threeSum = function (nums) { nums.sort((a, b) => a - b) // 排序 const res = [] for (let i = 0; i < nums.length; i++) { let l = i + 1 let r = nums.length - 1 if (nums[i] > 0) { // 如果第一个元素就大于0,后面也不用比了 break; } if (i > 0 && nums[i] === nums[i - 1]) { // 跳过相同的数字 continue } while (r > l) { const sum = nums[i] + nums[l] + nums[r]; if (sum === 0) { // 正好找到 res.push([nums[i], nums[l], nums[r]]) while (r > l && nums[l] === nums[l + 1]) { // 跳过相同的数字,一个元素只用一次 l++ } while (r > l && nums[r] === nums[r - 1]) { // 跳过相同的数字,一个元素只用一次 r-- } r-- // 缩小范围 l++ // 缩小范围 } else if (sum > 0) { r-- // 右指针移动,减少下次计算的和 } else { // sum < 0 l++ // 左指针移动,增加下次计算的和 } } } return res };
滑动窗口
643 - 子数组最大平均数 I ↓
给定 n 个整数,找出平均数最大且长度为 k 的连续子数组,并输出该最大平均数。 输入: [1,12,-5,-6,50,3], k = 4 输出: 12.75 解释: 最大平均数 (12-5-6+50)/4 = 51/4 = 12.75
以参数k
的长度为一个子数组,所以可以把k
看成是一个窗口,在原有数组上进行滑动,每经过一个子数组就求出的它的平均值。如果使用暴力解,会存在重复计算的问题,所以我们每次滑动一步,只需要加上新的元素,然后减去窗口最左侧的元素即可。
解题代码如下:
var findMaxAverage = function (nums, k) { let max = 0 let sum = 0 for (let i = 0; i < k; i++) { sum += nums[i] // 先求出第一个窗口 } max = sum / k // 第一个窗口的平均值 let j = k while (nums.length > j) { sum += nums[j] - nums[j - k] // 加上新元素,减去最左侧元素 max = Math.max(sum / k, max) // 与之前窗口的平均值比较 j++ // 向右滑动 } return max // 返回最大窗口平均值 };
674 - 最长连续递增序列 ↓
给定一个未经排序的整数数组,找到最长且连续递增的子序列,并返回该序列的长度。 输入:nums = [1,3,5,4,7] 输出:3 解释:最长连续递增序列是 [1,3,5], 长度为3。 尽管 [1,3,5,7] 也是升序的子序列, 但它不是连续的,因为 5 和 7 在原数组里被 4 隔开。 输入:nums = [2,2,2,2,2] 输出:1 解释:最长连续递增序列是 [2], 长度为1。 来源:力扣(LeetCode) 链接:https://leetcode-cn.com/problems/longest-continuous-increasing-subsequence
这题还是使用滑动窗口解决,为窗口定义两个下标l、r
,既然是递增的,那么我们就要两两相邻的进行比较,当遇到的元素大于窗口最右侧值时,将下标l
移至r
处,重新开始判断统计长度。图示如下:
代码如下:
var findLengthOfLCIS = function (nums) { let l = 0; let r = 0; let max = 0; while (nums.length > r) { // 只要窗口还在数组内活动 if (r > 0 && nums[r - 1] >= nums[r]) { // 如果遇到的元素大于窗口最右侧值 l = r // 重新统计长度 } max = Math.max(max, r - l + 1) // 统计最长的长度 r++ // 向右滑动 } return max };
209 - 长度最小的子数组 ↓
给定一个含有n个正整数的数组和一个正整数s,找出该数组中满足其和≥s的长度最小的连续子数组,并返回其长度。 如果不存在符合条件的子数组,返回 0。 输入:s = 7, nums = [2,3,1,2,4,3] 输出:2 解释:子数组 [4,3] 是该条件下的长度最小的子数组。 来源:力扣(LeetCode) 链接:https://leetcode-cn.com/problems/minimum-size-subarray-sum
题目的要求是要找一个连续子数组的和大于或等于传入是s
,所以我们还是可以使用滑动窗口,统计窗口内的和,如果已经大于或等于s
了,那么此时窗口的长度就是连续子数组的长度。
当找到一个连续子数组后,让左侧的窗口向右滑动,减去最左侧的值,减小窗口内的和,也让窗口右侧滑动。如果又找到了一个满足条件的子数组,与之前的子数组长度进行比较,更新最小窗口的大小即可。
有一个特例就是,如果整个数组加起来的和都比s
小,那就不能返回窗口的长度,而是直接返回0
。 代码如下:
var minSubArrayLen = function (s, nums) { let l = 0 let r = 0 let sum = 0 // 窗口里的和 let size = nums.length + 1 // 窗口的大小, 因为是要找到最小的窗口,所以设置一个比最大还 +1 的窗口 // 如果能找到一个符合条件的子数组才会更新窗口的大小 while (nums.length > l) { // 让左边界小于整个数组,为了遍历到每一个元素 if (s > sum) { sum += nums[r++] // 窗口和小于s,移动右窗口 } else { sum -= nums[l++] // 窗口大于s,移动左窗口 } if (sum >= s) { // 找到符合的子数组 size = Math.min(size, r - l) // 更新最小窗口的值 } } return size === nums.length + 1 ? 0 : size // 如果size等于初始值,表示没有符合要求的子数组,否则有找到 };
3 - 无重复字符的最长子串 ↓
给定一个字符串,请你找出其中不含有重复字符的 最长子串 的长度。 输入: "abcabcbb" 输出: 3 解释: 因为无重复字符的最长子串是 "abc",所以其长度为 3。 输入: "bbbbb" 输出: 1 解释: 因为无重复字符的最长子串是 "b",所以其长度为 1。
这题和上一题类似,滑动窗口不仅仅可以作用于数组,字符串也同样适用。
这题麻烦一点的地方在于还要定义一个set
用于查找,当新加入窗口的元素set
里没有时,就加入其中,窗口右移;如果有这个元素,需要将窗口移动到set
里出现的位置,也就是在set
里将其本身及窗口左侧的元素全部都移除,重复这个过程,直到窗口右侧到达字符串的末尾。如图所示:
解题代码如下:
var lengthOfLongestSubstring = function (s) { const set = new Set(); let l = 0; let r = 0; let max = 0; while (l < s.length && r < s.length) { if (!set.has(s[r])) { // 如果查找表里没有 set.add(s[r++]); // 添加右移窗口 } else { set.delete(s[l++]); // 从左侧开始删除,直到把新加入的且在查找表里有的元素删除为止 // 然后窗口才会继续开始右滑 } max = Math.max(max, r - l); // 更新最大的值 } return max; };
最后
以上很多题目也有其他的解法或暴力解,不仅仅局限只有多指针和滑动窗口才能解决,不过在应对难题时,有另一种解题的思路供参考,不过这两种算法对边界的处理能力要求挺高,要特别注意定义指针时开/闭区间的含义。
想起笔者之前在遇到算法题目之前要么暴力求解,或者就是使用各种遍历api
鼓捣一番,当时觉得代码量少还挺好。不过在深入理解了算法之后才明白,代码少不代表效率高,解题的逻辑思维能力才是最重要的。

低调大师中文资讯倾力打造互联网数据资讯、行业资源、电子商务、移动互联网、网络营销平台。
持续更新报道IT业界、互联网、市场资讯、驱动更新,是最及时权威的产业资讯及硬件资讯报道平台。
转载内容版权归作者及来源网站所有,本站原创内容转载请注明来源。
- 上一篇
技术译文 | MySQL 8 持久化系统变量
作者:Arunjith Aravindan 翻译:管长龙 本文来源:https://www.percona.com/blog/2020/10/27/using-mysql-8-persisted-system-variables/ MySQL 8 之前,使用的动态变量不是永久性的,并且在重启后会重置。可在运行时使用 SET 语句更改这些变量,以影响当前实例的操作,但是我们必须手动更新 my.cnf 配置文件以使其持久化。 在许多情况下,从服务端更新 my.cnf 并不是一个方便的选择,并且使变量仅被更新才能在后续重新启动时动态还原,而没有任何历史记录。 持久化系统变量是 MySQL 8 中引入的功能之一。新功能可帮助 DBA 动态更新变量并注册它们,而无需从服务器端访问配置文件。 如何持久化全局系统变量? 与SET GLOBAL一样,SET PERSIST是可用于在运行时更新系统变量并使它们在重新启动后保持不变的命令。当我们使用 PERSIST 关键字时,变量更改将更新到数据目录中的 mysqld-auto.cnf 选项文件。mysqld-auto.cnf 是仅在第一次执行PERSIS...
- 下一篇
redis服务又出现卡死,又是一次不当使用,这个锅你背定了
首先说下问题现象:内网sandbox环境API持续1周出现应用卡死,所有api无响应。刚开始当测试抱怨环境响应慢的时候 ,我们重启一下应用,应用恢复正常,于是没做处理。但是后来问题出现频率越来越频繁,越来越多的同事开始抱怨,于是感觉代码可能有问题,开始排查。首先发现开发的本地ide没有发现问题,应用卡死时候数据库,redis都正常,并且无特殊错误日志。开始怀疑是sandbox环境机器问题,测试环境本身就很脆!_!于是ssh上了服务器 执行以下命令top 这时发现机器还算正常,于是打算看下jvm 堆栈信息先看下问题应用比较耗资源的线程执行top -H -p 12798 找到前3个相对比较耗资源的线程jstack 查看堆内存 jstack 12798 |grep 12799的16进制 31ff 没看出什么问题,上下10行也看看,于是执行 看到一些线程都是处于lock状态。但没有出现业务相关的代码,忽略了。这时候没有什么头绪。思考一番,决定放弃这次卡死状态的机器。为了保护事故现场,先 dump了问题进程所有堆内存,然后debug模式重启测试环境应用,打算问题再出现时直接远程debug...
相关文章
文章评论
共有0条评论来说两句吧...