主題: 學習筆記

LeetCode 49:TypeScript Group Anagrams,從排序 key 到計數簽名

整理 Group Anagrams 的排序分組解法,找出重複複製陣列的成本,並比較固定字元集的計數簽名。

這次先用排序後的字串當群組 key。想法很直覺,互為 anagram 的字串排序後一定相同。不過實作時,更新群組的方式會影響實際複雜度。

題目

對應原題:LeetCode 49:Group Anagrams

給定只含小寫英文字母的字串陣列,將互為 anagram 的字串分到同一組。群組順序與同一群組內的字串順序不要求固定。

暴力解會重複比較群組

可以逐一讀取字串,掃描既有群組,並拿每個群組的第一個字串判斷是否為 anagram。若每次比較都靠排序,單次比較是 O(k log k),其中 k 是字串最大長度。最壞情況下每個字串都屬於不同群組,總時間會是 O(n² × k log k)

問題在於同一個字串可能被排序和比較很多次。建立一個能代表整個字串的 key,只要算一次就能直接找到群組。

我最先寫的排序 key 解法

function groupAnagrams(strs: string[]): string[][] {
  if (strs.length === 1) return [strs];

  const map = new Map<string, number[]>();

  for (let index = 0; index < strs.length; index += 1) {
    const sorted = strs[index].split("").sort().join("");

    if (map.has(sorted)) {
      map.set(sorted, [...map.get(sorted)!, index]);
    } else {
      map.set(sorted, [index]);
    }
  }

  const result: string[][] = [];

  map.forEach((indexes) => {
    result.push(indexes.map((index) => strs[index]));
  });

  return result;
}

這是我最先寫的解法。每個字串會轉成字元陣列、排序、再組回字串。"eat""tea""ate" 都會得到 "aet",所以可以用它當 Map key。value 儲存原始陣列索引,最後再轉回原本字串。

功能正確,但 strs.length === 1 的提前回傳不需要。通用迴圈本來就會回傳 [["a"]][[""]]

原始寫法的成本

這行每次遇到相同 key 都會建立新陣列:

map.set(sorted, [...map.get(sorted)!, index]);

如果某一群組有 m 個字串,會依序複製長度 12m - 1 的索引陣列。這一群就會多做 O(m²) 次索引複製。所有字串都屬於同一群時,這個成本會變成 O(n²),因此原始實作的最壞時間不是只有排序的 O(n × k log k),而是 O(n × k log k + n²)

這不會讓答案錯誤,但面試中值得主動提到。資料量小時不明顯,群組很大時才會出現差異。

從原解法縮短的版本

索引與最後的第二次轉換都不需要。直接把原始字串放進群組,取出既有陣列後用 push() 修改它。

function groupAnagramsBySorting(strs: string[]): string[][] {
  const groups = new Map<string, string[]>();

  for (const str of strs) {
    const key = [...str].sort().join("");
    const group = groups.get(key);

    if (group) {
      group.push(str);
    } else {
      groups.set(key, [str]);
    }
  }

  return [...groups.values()];
}

push() 直接改變 group 指向的陣列,不會重新設定 Map 的 value。以 n 為字串數量、k 為字串最大長度,排序每個字串的時間是 O(n × k log k)。面試通常把 Map 存取視為預期 O(1)。額外空間包含排序後的 key 與群組,最壞是 O(n × k)

JavaScript 規格不保證 Array.prototype.sort() 使用固定的排序演算法或複雜度,因此這裡的 O(k log k) 是演算法面試的比較排序模型。MDN 的 sort 文件也說明實際複雜度取決於實作。

為什麼 push() 會變成數字

下面兩段程式的效果不同:

const indexes = map.get(key)!;
indexes.push(index);
map.set(key, map.get(key)!.push(index));

第一段保留 Map 裡的陣列,push() 只改變它的內容。第二段把 push() 的回傳值寫回 Mappush() 的回傳值是新陣列長度,例如 2,不是陣列,因此 value 才會被覆蓋成數字。MDN 對 push() 的說明明確指出它會修改陣列並回傳新長度。

Map<string, string[]> 也能讓 TypeScript 在嘗試存入數字時報錯。原本的 new Map() 沒有型別參數,會失去這層保護。

利用題目限制的計數簽名

原題限定小寫英文字母,所以可以用 26 個計數值組成 key。字母出現次數相同時,這個 key 相同,不必排序每個字串。

function groupAnagrams(strs: string[]): string[][] {
  const groups = new Map<string, string[]>();
  const firstLowercaseCode = "a".charCodeAt(0);

  for (const str of strs) {
    const counts = new Array<number>(26).fill(0);

    for (const char of str) {
      counts[char.charCodeAt(0) - firstLowercaseCode] += 1;
    }

    const key = counts.join("#");
    const group = groups.get(key);

    if (group) {
      group.push(str);
    } else {
      groups.set(key, [str]);
    }
  }

  return [...groups.values()];
}

每個 key 保留 az 的出現次數。建立 key 的成本是掃描字串,再處理固定 26 個值,所以時間是 O(Σ(|str| + 26))。因為 26 是固定常數,面試中通常寫成 O(n × k)。這個版本只適用於題目的小寫英文字母限制。若字元範圍改成 Unicode,排序 key 或 Map 次數統計比較通用。

正確性與迴圈不變量

排序版本的迴圈不變量是:處理完 strs[0..i] 後,groups 中每個 key 對應的陣列,恰好包含此前綴中排序結果等於該 key 的所有字串。

初始時沒有處理任何字串,不變量成立。每次處理一個字串,都會算出它唯一的排序 key,然後加入對應群組或建立新群組,因此不變量仍成立。迴圈結束後,每個輸入字串都在且只在一個群組中;兩個字串在同一群組,等價於它們排序結果相同,也就是互為 anagram。

計數簽名版本的推理相同,只是 canonical key 從排序後字串改成 26 個字母次數。

最小驗證

LeetCode 不要求輸出順序,因此測試前先將每個群組和群組列表排序。

import assert from "node:assert/strict";

function normalize(groups: string[][]): string[][] {
  return groups
    .map((group) => [...group].sort())
    .sort((left, right) => left.join(",").localeCompare(right.join(",")));
}

assert.deepStrictEqual(
  normalize(groupAnagrams(["eat", "tea", "tan", "ate", "nat", "bat"])),
  normalize([["eat", "tea", "ate"], ["tan", "nat"], ["bat"]]),
);
assert.deepStrictEqual(normalize(groupAnagrams([""])), [[""]]);
assert.deepStrictEqual(normalize(groupAnagrams(["a"])), [["a"]]);
assert.deepStrictEqual(
  normalize(groupAnagrams(["abc", "bca", "abc", "xyz"])),
  normalize([["abc", "bca", "abc"], ["xyz"]]),
);

常見陷阱

  • map.set(key, [...old, value]) 累積群組。它可讀,但會反覆複製已有陣列。
  • map.get(key).push(value) 放進 map.set()push() 回傳的是數字長度。
  • 只用排序後字串的 Set。Set 只能判斷 key 是否出現過,不能保留完整群組。
  • 沒有在測試時正規化群組順序。題目允許任意順序,直接比較巢狀陣列可能出現假失敗。
  • 對 Unicode 輸入沿用 26 格計數陣列。這個最佳化只適用於題目給的小寫英文字母。

可遷移的思路

當多個資料要依照同一種結構分組時,先找出穩定的 canonical key。字串排序、字元次數、正規化 URL、排序後的 ID 集合都能是 key。key 一旦固定,Map 就能在一次走訪中完成分組。


外部參考連結