主題: 學習筆記

LeetCode 125:TypeScript Valid Palindrome,從清理字串到原地雙指標

保留先清理字串的雙指標解法,再說明 charCodeAt 版本如何原地跳過非英數字元。

我一開始先把字串清理成只剩英數字元,再從兩端往中間比。這個做法直觀,也能正確解題。後來看到另一種寫法直接在原字串上跳過符號,才發現差別不在雙指標本身,而是要不要先建立另一份字串。

題目

對應原題:LeetCode 125:Valid Palindrome

給定字串 s,忽略非英數字元與英文大小寫後,判斷它是否為迴文。迴文從左到右和從右到左讀起來相同。

先清理字串的直接做法

這個版本不需要枚舉所有子字串或字元配對,時間本來就是線性。它的代價在於先建立 str,所以額外空間會隨輸入長度成長。

function isPalindromeAfterCleaning(s: string): boolean {
  if (s.length === 1) return true;

  const str = s.replace(/[^a-zA-Z0-9]/g, "").toLowerCase();
  let p1 = 0;
  let p2 = str.length - 1;

  while (p1 < p2) {
    if (str[p1] !== str[p2]) return false;

    p1 += 1;
    p2 -= 1;
  }

  return true;
}

replace() 移除非英數字元,toLowerCase() 統一大小寫。接著 p1 從左、p2 從右比對。若兩側不同,立刻回傳 false;若都相同,指標相遇後回傳 true

這裡的迴圈最多比較 str.length / 2 次,但漸近時間仍寫成 O(n),常數二不會改變 Big O。清理和轉小寫也各會走訪字串,因此總時間是 O(n)str 是一份新的字串,額外空間為 O(n)

單一字元的提前回傳可以刪除。空字串或只剩符號的字串在清理後會得到空字串,while 不會執行,最後自然回傳 true

我後來看到的原地雙指標版本

另一個做法不先清理整份字串。它在每次比較前,讓左右指標各自跳過不需要的字元。

function isAlphanumeric(char: string): boolean {
  const lowerCase = char.toLowerCase();
  const code = lowerCase.charCodeAt(0);

  return (
    (code >= "a".charCodeAt(0) && code <= "z".charCodeAt(0)) ||
    (code >= "0".charCodeAt(0) && code <= "9".charCodeAt(0))
  );
}

function isPalindrome(s: string): boolean {
  let left = 0;
  let right = s.length - 1;

  while (left < right) {
    while (left < right && !isAlphanumeric(s[left])) {
      left += 1;
    }

    while (left < right && !isAlphanumeric(s[right])) {
      right -= 1;
    }

    if (s[left].toLowerCase() !== s[right].toLowerCase()) {
      return false;
    }

    left += 1;
    right -= 1;
  }

  return true;
}

isAlphanumeric() 只是把「這個字元能不能參與比較」寫成一個明確條件。它先轉成小寫,再用 charCodeAt(0) 判斷是否落在 az09 的範圍。這不是需要硬背的雙指標公式,只是不用正規表示式時的一種英數字元判斷方式。

我把重複呼叫的 charCodeAt(0) 存在 code 裡,讓條件更容易讀。這個判斷依賴題目使用可列舉的英文字母和數字;若需求改成完整 Unicode 字母,判斷規則必須重新定義。

左右指標在跳過符號後,才比較兩側字元的大小寫無關形式。每個指標都只會向中間前進,不會倒退,因此每個字元最多被檢查固定次數。時間是 O(n),且只使用幾個索引與暫存字元,額外空間是 O(1)

為什麼原地版本正確

每一次真正比較前,leftright 都指向尚未處理區間中最外側的英數字元。兩個內層迴圈只跳過題目要求忽略的字元,不會略過任何應比較的字元。

若兩側轉小寫後不同,沒有任何忽略規則可以消除這個差異,字串不是迴文。若相同,兩個字元可以成對移除,剩下的問題仍是較小區間的相同判斷。直到指標相遇為止都沒有衝突,字串就是迴文。

最小驗證

import assert from "node:assert/strict";

assert.strictEqual(isPalindrome("A man, a plan, a canal: Panama"), true);
assert.strictEqual(isPalindrome("race a car"), false);
assert.strictEqual(isPalindrome(" "), true);
assert.strictEqual(isPalindrome("0P"), false);
assert.strictEqual(isPalindrome("a."), true);

" " 驗證所有字元都被忽略時的結果。"0P" 則確認數字和字母仍會參與比較。

常見陷阱

  • O(n / 2) 當成不同的 Big O。它仍是 O(n)
  • 宣稱先清理字串的版本是 O(1) 空間。新的 str 需要 O(n) 空間。
  • \w 判斷英數字元。它還會接受底線,和題目的英數字元定義不同。
  • 直接比較未轉小寫的字元,讓 "A""a" 被誤判為不同。
  • 跳過符號後沒有再次確認 left < right,可能讓索引處理變得難以閱讀。

可遷移的思路

雙指標題不一定要先轉換整份輸入。若規則只影響少數字元,可以在指標前進時即時略過。這能省下額外記憶體,也讓真正的比較規則留在同一個迴圈裡。


外部參考連結