「力扣」第 3 题:无重复字符的最长子串(滑动窗口)

滑动窗口

这一节,我们给大家提供一个比较容易思考的方向,以解决这一类问题。

  • 绝大多数「滑动窗口」问题,一般而言,总是先从最容易想到的情况入手,即先思考一个最容易想到的解决方案,但是这样的方案,通常来说执行效率并不高,接下来,就要想方设法优化这个朴素的解法;
  • 「暴力枚举」通常以「二重循环」、「三重循环」的形式出现,而通常优化的思考路径是:
  1. 以空间换时间:在遍历的过程中,记录变量的值,以使得枚举“相邻”问题规模的计算不必从头开始;

  2. 利用题目给出的性质,在枚举的过程中,能够一下子排除很多不必要的可选方案,以降低时间复杂度。


来源:力扣(LeetCode)

给定一个字符串,请你找出其中不含有重复字符的 最长子串 的长度。

示例 1:

输入: "abcabcbb"
输出: 3 
解释: 因为无重复字符的最长子串是 "abc",所以其长度为 3。

示例 2:

输入: "bbbbb"
输出: 1
解释: 因为无重复字符的最长子串是 "b",所以其长度为 1。

示例 3:

输入: "pwwkew"
输出: 3
解释: 因为无重复字符的最长子串是 "wke",所以其长度为 3。
     请注意,你的答案必须是 子串 的长度,"pwke" 是一个子序列,不是子串。

分析:这道题给我们一个字符串,要求我们找出这个字符串中不含有重复字符的 最长子串 的长度。

首先我们得弄清楚一个概念:「子串」,它区别于「子序列」。

  • 子串(substring):一定连续,保持顺序。

  • 子序列(subsequeue):不一定连续,保持顺序。

方法一:暴力解法

一个最直接的思考方案是:

1、枚举这个字符串的所有子串;

2、对于每一个子串都判断一下这个子串是否有重复字符;

3、再从没有重复字符的所有子串中找出长度最长的那个,返回即可。

伪代码如下:

public class Solution {

    public int lengthOfLongestSubstring(String s) {
        int len = s.length();

        int maxLen = 1;
        for (int left = 0; left < len - 1; left++) {
            for (int right = 0; right < len; right++) {
                String subString = s.substring(left, right + 1);
                if (subString 不包含重复元素){
                    maxLen = Math.max(maxLen, subString.length());
                }
            }
        }
        return maxLen;
    }
}

这是一个二重循环,并且最里层的判断「subString 不包含重复元素」这个方法的时间复杂度是 O ( N ) O(N) 的,所以整体的复杂度就变为 O ( N 3 ) O(N^3)

下面我们就来分析,是否有不去枚举左右边界。

方法二:滑动窗口

一开始的时候,left 不动,right 尝试向右边扩张,直到 [left, right] 中有恰有 1 个重复元素;

如果在子区间 [left, right] 里有重复字符,[left, right + 1][left, right + 2] 一直到 ``[left, len - 1]这些子区间一定包含重复元素,注意:**这就排除了一大堆不可能的解**,此时就得考虑移动left` 变量。

left 变量不能向左移动:因为向左移动,仍然不能改变 [left, right] 中有恰有 1 个重复元素的现状。因此 left 只能向右移动,直到 left 刚刚好越过 right 指向的那个重复的元素为止。

接着是否有必要继续移动 left 呢?不可以,因为我们要求的是最长的子串的长度,此时的子串是局部最长的。接着我们应该移动 right 以期待获得更长的不重复子串。

这样的过程一直进行下去,直到 right 到达字符串的末尾。(此时 left 有没有必要收缩呢?)

我们分析一下,这个过程为什么比暴力枚举要快。

1、当我们得到了一个有重复元素的子串的时候,和它有相同前缀的所有子串都会一下子被排除;(做动画,画删除线);

2、在判断子区间 [left, right] 是否有重复字符的时候,我们不必每一次都做扫描;事实上,我们只需要开辟一个字符频数数组,让右边界进来的时候,字符频数加 1,此时检测是否有重复。当左边界滑出的时候,字符频数减 1,此时检测是否无重复。

  • 有重复字符的时候,尝试左边界右移,试图让区间内无重复字符;
  • 无重复的时候,尝试右边界右移,以试图让区间长度更长。

以上就是解决这道问题的基本思路。

这种 right 主动向前移动,left 被动向前移动的方式就是滑动窗口的思想,也叫「尺取法」。这个名字可以说是非常形象了,一个资深的裁缝为你量体裁衣,他很可能就是用右手大拇指在你的肩膀上做「滑动窗口」的样子,例如你现在看到的这个视频片段。

在编码的时候设计循环不变量:[left, right)。注意,区间是「左闭右开」的,在一开始的时候,right 之前的元素已知,在本轮循环中,希望把 right 纳入,保持区间内无重复元素这一性质。

Java 代码:

public class Solution {

    public int lengthOfLongestSubstring(String s) {
        int len = s.length();
        // 特判
        if (len < 2) {
            return len;
        }
        // 当 window 中某个字符的频数为 2 时,表示滑动窗口内有重复字符
        int[] cnt = new int[128];
        // 右边界滑动到刚刚好有重复的时候停下
        // 左边界滑动到刚刚好没有重复的时候停下
        int left = 0;
        int right = 0;
        // 滑动窗口内是否重复
        boolean repeating = false;
        int res = 1;
        // 循环不变式,保持不变的性质是:[left, right) 内没有重复元素
        while (right < len) {
            // 不能写在后面,因为数组下标容易越界
            if (cnt[s.charAt(right)] == 1) {
                repeating = true;
            }
            cnt[s.charAt(right)]++;
            right++;

            // 此时 [left, right) 内如果没有重复元素,就尝试扩张 right
            // 否则缩小左边界,while 循环体内不断缩小边界
            while (repeating) {
                if (cnt[s.charAt(left)] == 2) {
                    // 如果满足滑动窗口内有重复的元素,尝试不断删除左边元素
                    repeating = false;
                }
                // 只有有重复元素,就得缩短左边界
                cnt[s.charAt(left)]--;
                left++;
            }
            // 此时 [left, right) 内没有重复元素
            res = Math.max(res, right - left);
        }
        return res;
    }
}

复杂度分析

时间复杂度: O ( N ) O(N) leftright 各扫过数组一次。

空间复杂度: O ( 128 ) O(128) ,字符是有限的,因此与字符长度无关。

技巧:

1、创建字符频数数组,ACSII 码表;

2、右边界移动做加法:只要字符频数数组超过 1,刚刚好等于 2 的时候,就说明子区间内有重复元素;

3、左边界移动做减法:因为我们的算法在子区间刚刚好有 1 个重复字符的时候,就想方设法让子区间没内有重复元素,因此重复元素的个数有且仅有 1 个,字符频数数组内单个字符的个数最多为 2,

当左边界指向字符刚刚好减到 1 的时候,就说明子区间没内有重复元素了。

4、在右边移动的过程中记录最大值。

  • 要特别注意这里记录最大值的位置,不能在 while (repeating) 之前,因为此时滑动窗口内可能有重复元素,因此,只能在 while (repeating) 之后。注意边界。

方法三:滑动窗口(优化)

一步一步来到重复元素出现过的地方太慢了,我们是不是可以一下子来到重复元素的后面呢?完全可以的。

我们在遍历的过程中,不是记录元素的频数,而是记录一下元素出现的位置。

当有重复元素出现的时候,只要这个元素之前出现的位置在当前滑动窗口左边界(可以等于左边界)的右边,就可以直接跳过来;如果重复元素之前出现的位置在在当前滑动窗口左边界的左边,左边界不用移动;

这两种情况,都要更新位置为当前的最新位置。

Java 代码:滑动窗口的优化

public class Solution {

    public int lengthOfLongestSubstring(String s) {
        // 重复元素上一次出现的位置很重要
        int len = s.length();
        if (len < 2) {
            return len;
        }
        int[] window = new int[128];
        for (int i = 0; i < 128; i++) {
            window[i] = -1;
        }

        int res = 1;
        int left = 0;
        for (int i = 0; i < len; i++) {
            if (window[s.charAt(i)] != -1) {
                left = Math.max(left, window[s.charAt(i)] + 1);
            }
            res = Math.max(res, i - left + 1);
            window[s.charAt(i)] = i;
        }
        return res;
    }
}

事实上,对于建立字符和某个数值的映射,可以数值上做,也可以使用哈希表做。相信大家不难体会它们二者的差别。

发布了455 篇原创文章 · 获赞 348 · 访问量 126万+

猜你喜欢

转载自blog.csdn.net/lw_power/article/details/105077627