leetcode(3)——无重复字符的最长子串

2019年9月16日 · 341 字 · 2 分钟

本文是力扣算法的第三篇,讲解无重复字符的最长子串问题。

Question

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

示例1:

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

示例2:

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

示例3:

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

遍历法

最容易想到的一种算法,也是效率最低的一种算法

  1. 通过两次遍历得到所有可能的 子字符串 列表
  2. 将每个字符串传入一个函数检测是否包含重复字符,如果不包含则更新最长子串的长度
// 判断给定的子串是否包含重复字符
function isUnique(str, start, end) {
  const chars = [];
  for(let i = start; i < end; i++) {
    const char = str[i];
    if(chars.indexOf(char) !== -1) { // 字符已存在,本字符串不符合条件
      return false;
    }
    chars.push(char); // 添加字符
  }
  return true;
}

// 获取字符串最长子串长度
function lengthOfLongestSubstring(s) {
  let max = 0;
  for(let i = 0; i < s.length; i++) {
    for(let j = i+1; j <= s.length; j++) {
      if(isUnique(s, i, j)) { // 判断子串是否唯一
        max = Math.max(max, j - i); // j - i 为当前子串长度
      }
    }
  }
  return max;
}

时间复杂度$O(n^3)$

i循环,j循环,isUnquie中的循环,3次循还嵌套

空间复杂度$O(min(n,m))$

isUnique函数中定义了一个数组来存储不重复的子串字符,长度为$k$,$k$的长度取决于字符串$s$的大小$n$以及 字符串$s$包含的不重复字符数大小$m$

滑动窗口法

暴力法中我们会重复检查一个子串是否包含重复的字符,如果从$i$ ~ $j-1$ 之间的子串已经被检查过没有重复字符了,那么只需要检查$s[j]$是否在这个子串就行了。

子串使用js自带的数据结构Set存储

如果不在该子串,那么子串长度+1,$j+1$,继续往后走

如果在这个子串,证明出现了重复,我们需要将$s[i]$移出来之后$i+1$,继续往后走

function lengthOfLongestSubstring(s) {
  const set = new Set();
  const max = 0;
  let i = 0;
  let j = 0;
  
  while(i < s.length && j < s.length) {
    if(!set.has(s[j])) { // j 不在set中,set中添加s[j],j后移,同时更新最大子串长度
      set.add(s[j]);
     	j++;
      max = Math.max(max, j - i);
    } else {
      set.delete(s[i]); // 移除set左边的数据,i后移一位
      i++;
    }
  }
  return max;
}

时间复杂度 $O(2n) \approx O(n)$

最好的情况是j一次走完没有出现重复,最坏的情况是i和j都走到了末尾

空间复杂度 $O(min(n,m))$

与暴力法相似,也需要一个Set存储不重复字符,$n$ 是字符串$s$长度,$m$是字符串$s$中不重复的字母个数

优化的滑动窗口

在滑动窗口解法中,$i$的后移可以优化一下,如果 s$[j]$ 在 s[$i$] ~ s[$j$] 内与字符 $c$ (随便取的名字)重复,$i$ 不需要一步一步$i$++,直接把 $i$ 定位到 $c$ + 1的位置即可。这样可以将算法时间复杂度稳定在 $O(n)$

function lengthOfLongestSubstring(s) {
	const map = {}; // 保存 字符和下标的映射关系,如果字符重复,从map拿到位置,i直接跳到这个位置
  let max = 0;
  
  for(let i = 0, j = 0;j < s.length;j++) {
    const char = s[j];
    if(map[char] !== undefined) { // 当前字符存在重复,需要将i更新
      i = Math.max(i, map[char]); // 如果i的当前位置大于map[char],不能更新为map[char]
    }
    max = Math.max(max, j - i + 1); // 由于j最大是s.length-1,所以最大子串长度需要+1
    map[char] = j + 1; // 保存映射关系
  }
  return max;
}

时间复杂度 $O(n)$

只遍历了j

空间复杂度 $O(min(n,m))$

与之前的方法相同

Q: 为什么第8行的 i = Math.max(i, map[char]) 不能直接是 i = map[char]?

A: $i$ 的位置比map[char]大的情况下如果直接赋值会导致 $i$ 往前面走,会导致返回的子串长度大于实际的子串长度

错误例子 abba

ijs[j]s[i] ~ s[j]Max
00aa1
01bab2
2(map中没有s[j],所以这里的位置直接是当前j的值)2bb2
1(map中有s[j],第1个字符就是a,直接拿来用)3abba3

可以看到第4次循环中 i 的位置已经出现了问题,把位置1的a拿过来进行计算了,窗口的起始左边也从2变成了1,往回走了。

结尾

本问题主要是考察对滑动窗口算法的实际应用,掌握之后解题问题不大。