滑动窗口双指针🪡,移动方向要区分🖖

454 阅读5分钟

大家好👋!这次更新的是关于数组的「滑动窗口」&「双指针」算法。在这篇文章之前应该还有一篇关于「排序」算法的文章,但因为排序算法种类有点多和其他原因,所以先跳过,下次一定再补回来!

在数组中有两类很经典的算法,就是「滑动窗口」和「双指针」。

事实上,「滑动窗口」是特殊的「双指针」,都是使用了两个「指针变量」来解决问题。

区别:

  • 滑动窗口:两个变量同向移动;
  • 双指针:两个变量一头一尾向中间移动;

⚠️:

  1. 这里说的「指针」并不是「C语言中的指针」,仅仅是一个「变量」,在使用这个「变量」时遵循「循环不变量」;
  2. 该两种算法都是从「暴力算法」中优化而来;

滑动窗口

「滑动窗口」两个「指针」的移动方向是相同的,形成了一个「窗口」在直线上「滑动」的效果。

大多数「滑动窗口」问题,先从「暴力解法」开始,从而进行优化。

「暴力解法」通常以「多重循环」出现,因此优化思路有以下两种:

  1. 以空间换时间,在遍历过程中,记录变量的值,使得相同区间的信息不必重新计算;
  2. 遍历过程中,排除不必要的方案,减低时间复杂度;

例题

以 leetcode 的「无重复字符的最长子串」为例:

暴力解法:

  1. 枚举这个字符串的所有子串;
  2. 找出所有没有重复字符的子串;
  3. 返回没有重复字符子串中最大长度;
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;
};

优化:

  1. 双指针leftright重合于数组头部,left不动,right尝试向右边扩张,直到[left, right]中有恰有1个重复元素;
  2. 如果在子区间[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,设置变量leftright分别位于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,说明得到了一组可行解。此时leftright同时向中间移动一格;
  • 如果leftright移动以后的值和上一个一样,还必须继续移动,否则会将重复的结果输出到结果集中。
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 ---------- 」