主題: 學習筆記

LeetCode 3:TypeScript 最長不重複子字串,從回退重掃到 sliding window

保留一次回退重掃的嘗試,說明為何最壞為 O(n²),再改為單次走訪的 Map sliding window。

這次先用兩個指標和 Map 解題。第一版遇到重複字元時會重設視窗,再從較早的位置重新掃描。它能得到正確答案,但右指標會倒退。第二版只讓右指標向前,才是這題需要的 O(n) 解法。

題目

對應原題:LeetCode 3:Longest Substring Without Repeating Characters

給定字串 s,回傳不含重複字元的最長連續子字串長度。子字串必須連續,因此 "pwke" 不是 "pwwkew" 的有效答案。

暴力解

從每個起點向右延伸,直到遇到重複字元。對每個起點都建立一個 Set,時間最壞是 O(n²),額外空間最多 O(n)

function lengthOfLongestSubstringBruteForce(s: string): number {
  let longest = 0;

  for (let start = 0; start < s.length; start += 1) {
    const seen = new Set<string>();

    for (let end = start; end < s.length; end += 1) {
      if (seen.has(s[end])) break;

      seen.add(s[end]);
      longest = Math.max(longest, end - start + 1);
    }
  }

  return longest;
}

我第一版的回退重掃作法

第一版先處理空字串和單一字元,接著用 p1p2 表示視窗兩端,並用 Map 記錄字元上次出現的索引。以下保留原本控制流程,只補上 Map 的型別。

function lengthOfLongestSubstring(s: string): number {
  if (s.length === 0) return 0;
  if (s.length === 1) return 1;

  let res = 1;
  const map = new Map<string, number>();
  let p1 = 0;
  let p2 = 1;

  map.set(s[p1], p1);

  while (p2 < s.length) {
    if (map.has(s[p2])) {
      p1 = map.get(s[p2])! + 1;
      p2 = p1 + 1;
      res = Math.max(map.size, res);
      map.clear();
      map.set(s[p1], p1);
    } else {
      map.set(s[p2], p2);
      res = Math.max(map.size, res);
      p2 += 1;
    }
  }

  return res;
}

這版不是 O(n log n),因為沒有排序。問題在於遇到重複字元時,p2 被設回 p1 + 1,已掃過的字元會再被掃一次。

例如字串先有一段很長的不重複前綴,後面又重複同一段。每次偵測到重複字元,都可能從前一個位置重走大段字串。最壞情況是 O(n²)Map 最多也會保存目前視窗內的不同字元,所以額外空間是 O(n),不是 O(1)

我改良的 Map sliding window

改良版保留每個字元最近一次出現的位置。右指標每回合只前進一格,不清空 Map,也不回退。若字元上次出現的位置仍在目前視窗裡,左指標直接跳到那個位置的下一格。

function lengthOfLongestSubstring(s: string): number {
  const lastSeen = new Map<string, number>();
  let left = 0;
  let longest = 0;

  for (let right = 0; right < s.length; right += 1) {
    const char = s[right];
    const previous = lastSeen.get(char);

    if (previous !== undefined && previous >= left) {
      left = previous + 1;
    }

    lastSeen.set(char, right);
    longest = Math.max(longest, right - left + 1);
  }

  return longest;
}

空字串不需要特別處理,因為迴圈不會執行,初始值 0 就是答案。單一字元也會自然得到長度 1

Map 保存每個字元最近的位置。它會留下已經在視窗左側的舊索引,因此判斷時一定要保留 previous >= left。少了這個條件,像 "abba" 這種字串會把左指標往回移,視窗重新包含重複字元。

為何這版是正確的

每輪迴圈開始時,s[left..right - 1] 沒有重複字元,lastSeen 保存各字元最近一次出現的索引。

讀到 s[right] 後,若它最近一次出現的位置在目前視窗內,將 left 移到該位置右側,就能移除唯一的衝突。若最近位置已在視窗左側,維持 left 不動。更新 lastSeen 後,s[left..right] 仍沒有重複字元,因此可以用 right - left + 1 更新最長長度。

右指標總共走 n 次,左指標只會向右走,兩者都不會回退。面試中通常把 Map 查詢和寫入視為平均 O(1),因此總時間是 O(n)Map 最多保存不同字元的數量,額外空間是 O(min(n, c)),一般以 O(n) 表示,c 是可出現的不同字元數。

ASCII 固定陣列版本

若輸入已保證只含 7-bit ASCII,可以用固定 128 格陣列保存上次位置。它沒有改變漸近時間,仍是 O(n),但避開了 Map 的雜湊與動態配置成本。因為陣列固定為 128 格,額外空間是 O(1)

這不是通用 JavaScript 字串的替代品。charCodeAt() 讀的是 UTF-16 code unit,非 ASCII 文字應繼續使用 Map,或先確認需求要以 code unit 還是 Unicode code point 計算。

function lengthOfLongestSubstringAscii(s: string): number {
  const lastSeen = new Int32Array(128).fill(-1);
  let left = 0;
  let longest = 0;

  for (let right = 0; right < s.length; right += 1) {
    const code = s.charCodeAt(right);

    if (code >= lastSeen.length) {
      throw new RangeError("This implementation only accepts ASCII input.");
    }

    left = Math.max(left, lastSeen[code] + 1);
    lastSeen[code] = right;
    longest = Math.max(longest, right - left + 1);
  }

  return longest;
}

最小驗證

import assert from "node:assert/strict";

assert.equal(lengthOfLongestSubstring("abcabcbb"), 3);
assert.equal(lengthOfLongestSubstring("bbbbb"), 1);
assert.equal(lengthOfLongestSubstring("pwwkew"), 3);
assert.equal(lengthOfLongestSubstring("abba"), 2);
assert.equal(lengthOfLongestSubstring("tmmzuxt"), 5);
assert.equal(lengthOfLongestSubstring(""), 0);
assert.equal(lengthOfLongestSubstringAscii("a b!a"), 4);

常見陷阱

  • 只要 Map 有該字元就移動左指標。上次出現的位置可能已經在視窗外,必須比較 previous >= left
  • 遇到重複字元就清空 Map。這會遺失可重用的索引,也讓右指標回頭掃描。
  • Map 的空間說成 O(1)。除非字元集大小固定,例如 ASCII 的 128 格陣列,否則它可能保存 n 筆資料。
  • 把 subsequence 當成 substring。這題只接受連續片段。
  • 把 ASCII 陣列直接套到任意 Unicode 文字。先確認字元範圍和計數單位。

可遷移的思路

這題的視窗條件是「不含重複字元」。當右側加入新字元導致條件失效時,不必重建整個視窗,只要把左側移到足以消除衝突的位置。這是可變長 sliding window 的基本模式。


外部參考連結