3. 无重复字符的最长子串

3. 无重复字符的最长子串 - 力扣(LeetCode)

class Solution {
  int DupFromEnd(string &S, int Begin, int End) {
    char Back = S[End - 1];
    for (int i = End - 2; i >= Begin; --i) {
      if (S[i] == Back)
        return i;
    }
    return -1;
  }

public:
  int lengthOfLongestSubstring(string s) {
    int Longest = 0;
    int Left = 0, Right = 0;
    while (Right < s.length()) {
      // Fetch
      int DupIdx = DupFromEnd(s, Left, Right + 1);

      // Move
      if (DupIdx == -1)
        ++Right;
      else
        Left = DupIdx + 1;

      Longest = std::max(Longest, Right - Left);
    }
    return Longest;
  }
};

我的题解时间复杂度好像是要高一点,从遍历的次数能看出来,最坏情况是没有一个重复字符,达到了 \(\mathrm O\left(n^2\right)\),但是平均好像还是接近 \(\mathrm O(n)\)?挺有意思的。但是其实还是官解这个方法更好一些:

class Solution {
public:
    int lengthOfLongestSubstring(string s) {
        // 哈希集合,记录每个字符是否出现过
        unordered_set<char> occ;
        int n = s.size();
        // 右指针,初始值为 -1,相当于我们在字符串的左边界的左侧,还没有开始移动
        int rk = -1, ans = 0;
        // 枚举左指针的位置,初始值隐性地表示为 -1
        for (int i = 0; i < n; ++i) {
            if (i != 0) {
                // 左指针向右移动一格,移除一个字符
                occ.erase(s[i - 1]);
            }
            while (rk + 1 < n && !occ.count(s[rk + 1])) {
                // 不断地移动右指针
                occ.insert(s[rk + 1]);
                ++rk;
            }
            // 第 i 到 rk 个字符是一个极长的无重复字符子串
            ans = max(ans, rk - i + 1);
        }
        return ans;
    }
};

看到「重复」其实就该想到 unordered_set 了。其实这种解法算是很有意思的,因为你会发现用了 unordered_set 和双指针之后,重复字符的位置 DupIdx 其实是不重要的——让左指针一直往右跑就好了,跑到 occ.count(s[rk + 1]) 为假(也即任意位置上的重复字符被去除)之后,留下的自然就是没有重复的极大子串。

二刷的时候用官解的思路重新做一遍。

二刷

「滑动窗口」不叫「跳跃窗口」是有原因的。我第一次解的时候写了一个用 unordered_map 的错误写法:

class Solution {
public:
  int lengthOfLongestSubstring(string s) {
    int Longest = 0;
    int Begin = 0;
    unordered_map<char, int> CharIdx;
    for (int i = 0; i < s.size(); ++i) {
      if (auto I = CharIdx.find(s[i]); I != CharIdx.end())
        Begin = I->second + 1;
      CharIdx[s[i]] = i;
      Longest = max(Longest, i - Begin + 1);
    }
    return Longest;
  }
};

事实上这个解法的问题就在于窗口不是滑动而是直接根据 it->second 进行跳跃,所以丢失了很多信息。真正的「滑动窗口」应该是逐个元素收窄的,如下。

class Solution {
public:
  int lengthOfLongestSubstring(string s) {
    int Longest = 0;
    int l = 0;
    unordered_set<char> Charset;
    for (int i = 0; i < s.size(); ++i) {
	  // If charset overlaps, erase from the left ONE BY ONE.
      while (Charset.count(s[i]))
        Charset.erase(s[l++]);
      Charset.insert(s[i]);
      Longest = max(Longest, i - l + 1);
    }
    return Longest;
  }
};

评论区给出了一种滑动窗口模板,非常好,很受用。

//外层循环扩展右边界,内层循环扩展左边界
for (int l = 0, r = 0 ; r < n ; r++) {
	//当前考虑的元素
	while (l <= r && check()) {//区间[left,right]不符合题意
        //扩展左边界
    }
    //区间[left,right]符合题意,统计相关信息
}

三刷

class Solution {
public:
  int lengthOfLongestSubstring(string s) {
    int Result = 0;
    int Left = 0;
    unordered_set<char> Set;
    for (int i = 0; i < s.length(); ++i) {
      while (Set.contains(s[i]))
        Set.erase(s[Left++]);
      Set.insert(s[i]);
      Result = max(Result, i - Left + 1);
    }
    return Result;
  }
};

我操,我要给自己一巴掌,怎么做题之前没看分类。以后记住这题是滑动窗口。然后说实话这个区间左端点还是写成小写字母l吧,写成Left我他妈还以为是左元素迭代器,直接一个Set.erase(Left++);起手,这谁能绷住。