2213. Longest Substring of One Repeating Character
題目 / Problem
中文
給你一個 0-indexed 的字串 s。另外給你一個長度為 k 的字串 queryCharacters 和一個長度為 k 的整數陣列 queryIndices,它們一起描述 k 次查詢。
第 i 次查詢會把 s 中位於 queryIndices[i] 的字元改成 queryCharacters[i]。
請回傳一個長度為 k 的陣列 lengths,其中 lengths[i] 是在執行完第 i 次查詢之後,s 中「只由單一重複字元組成的最長子字串」的長度。
注意:每次查詢都是在前一次查詢的結果上繼續修改(修改會累積),不是每次都重置回原字串。
English
You are given a 0-indexed string s, a length-k string queryCharacters, and a length-k integer array queryIndices, together describing k queries.
The i-th query sets the character of s at index queryIndices[i] to queryCharacters[i].
Return an array lengths of length k, where lengths[i] is the length of the longest substring of s made of only one repeating character, measured after the i-th query. Updates are cumulative — each query modifies the string left by the previous one.
Constraints / 限制
- 1 <= s.length <= 10^5
- s 只含小寫英文字母 / s consists of lowercase English letters.
- k == queryCharacters.length == queryIndices.length
- 1 <= k <= 10^5
- queryCharacters 只含小寫英文字母。
- 0 <= queryIndices[i] < s.length
Worked example / 範例
s = "babacc", queryCharacters = "bcb", queryIndices = [1,3,3]
Output: [3,3,4]
- Query 1: 把 index 1 改成 'b' →
"bbbacc",最長單字元子字串是"bbb",長度 3。 - Query 2: 把 index 3 改成 'c' →
"bbbccc","bbb"或"ccc"都是長度 3。 - Query 3: 把 index 3 改成 'b' →
"bbbbcc","bbbb"長度 4。
名詞解釋 / Glossary
- 子字串 / substring:字串中「連續」的一段字元。例如
"bbbacc"的"bba"是子字串,但"bac"(跳著取)不是。 - 單一重複字元子字串 / run of one repeating character:整段都是同一個字元的子字串,例如
"bbbb"。本題要找最長的這種段落。 - 線段樹 / segment tree:一種二元樹狀資料結構,把陣列切成一半再一半地管理。它能在
O(log n)時間內完成「單點修改」和「區間查詢」。每個節點負責原陣列的一段區間,父節點的資訊由兩個子節點「合併」得到。 - 單點更新 / point update:只改動一個位置的值,然後沿著樹往上更新受影響的節點。線段樹更新一次只走一條從葉子到根的路徑,長度為
O(log n)。 - 節點合併 / merge:把左右兩個子區間的資訊組合成父區間的資訊。本題的關鍵就是設計好每個節點要存什麼,才能正確合併。
- 前綴 / prefix、後綴 / suffix:前綴是「從左端開始」的一段,後綴是「到右端結束」的一段。合併時,跨越左右邊界的最長段 = 左邊的後綴長度 + 右邊的前綴長度(前提是邊界兩字元相同)。
- 葉子節點 / leaf node:線段樹最底層、只負責單一位置的節點。修改字元時就是先改葉子,再往上重算。
- 遞迴 / recursion:函式呼叫自己來處理更小的子問題。這裡用遞迴自然地把區間對半拆分。
思路
最直接的暴力做法是:每次查詢後,重新掃描整個字串,一個一個字元比較,統計最長的連續相同字元段。單次掃描是 O(n),總共 k 次查詢就是 O(n·k)。在 n, k 都到 10^5 的情況下,這是 10^10 級別的運算,一定會超時。問題出在:每次只改一個字元,卻要重掃整條字串,浪費太多。我們需要一種能「只更新受影響的一小塊、再快速查全域最大值」的結構——這正是線段樹的強項。核心難點是:最長的單字元段可能橫跨線段樹某個節點的左右兩半,所以節點不能只存「這段內部的最長段」。我們讓每個節點額外存四件事:這段的長度 len、最左字元 lc、最右字元 rc、以及「從左端開始的最長同字元前綴 pref」和「到右端結束的最長同字元後綴 suf」,再加上「這段內部答案 best」。合併左右子節點時:父的前綴通常等於左子的前綴,但若「左子整段同字元且左子最右字元 = 右子最左字元」,前綴就能延伸吃進右子的前綴;後綴對稱處理;父的 best 取「左 best、右 best,以及若邊界字元相同時 左.suf + 右.pref(跨界段)」三者的最大值。這樣每次查詢只要改一個葉子、沿路往上重算 O(log n) 個節點,根節點的 best 就是答案,總複雜度降到 O((n+k)·log n)。
The brute-force idea is to rescan the whole string after every query and count the longest block of identical characters. One scan is O(n), so k queries cost O(n·k) — with n, k up to 10^5 that's around 10^10 operations and times out. The waste is obvious: we change a single character but re-examine everything. We want a structure that updates just the affected slice and answers the global maximum fast, which is exactly what a segment tree gives. The subtle part is that the longest single-character run can straddle the boundary between a node's two halves, so each node must store more than just "the best run inside me." Every node keeps: the segment length len, its leftmost char lc, its rightmost char rc, the longest same-character prefix pref, the longest same-character suffix suf, and the best run inside the segment best. When merging two children, the parent's prefix is normally the left child's prefix, but if the left child is entirely one character and its rightmost char equals the right child's leftmost char, the prefix extends into the right child's prefix; the suffix is symmetric; and the parent's best is the max of the left best, the right best, and — when the boundary characters match — the crossing run left.suf + right.pref. Each query then updates one leaf and recomputes O(log n) nodes along the path to the root, whose best is the answer, giving an overall O((n+k)·log n).
逐步走查 / Walkthrough
用範例 s = "babacc"(index 0..5 = b a b a c c),查詢 queryCharacters = "bcb", queryIndices = [1,3,3]。
建樹後的初始狀態 / after building. 初始各段:b(1), a(1), b(1), a(1), cc(2)。根節點 best = 2(來自 "cc")。但我們只在每次查詢後才記錄答案。
下表顯示「每次查詢改哪個位置、變成什麼字串、根節點各欄位」:
| 查詢 Query | 動作 Action | 字串 After string | 段落 Runs | 根 best |
輸出 Output |
|---|---|---|---|---|---|
| 1 | index 1 → 'b' | bbbacc |
bbb(3), a(1), cc(2) |
3 | 3 |
| 2 | index 3 → 'c' | bbbccc |
bbb(3), ccc(3) |
3 | 3 |
| 3 | index 3 → 'b' | bbbbcc |
bbbb(4), cc(2) |
4 | 4 |
看一次合併是怎麼算出來的 / one merge in detail (query 3, string bbbbcc).
假設把字串切成左半 bbb(index 0..2) 和右半 bcc(index 3..5):
- 左半 bbb:len=3, lc='b', rc='b', pref=3, suf=3, best=3。
- 右半 bcc:len=3, lc='b', rc='c', pref=1, suf=2, best=2。
- 合併:父 lc='b', rc='c', len=6。
- 前綴:左半整段同字元 (pref==len) 且左 rc='b' == 右 lc='b',所以 pref = 3 + 右.pref(1) = 4。
- 後綴:右半不是整段同字元,所以 suf = 右.suf = 2。
- best = max(左.best 3, 右.best 2, 跨界 左.suf(3)+右.pref(1)=4) = 4。✅ 與答案一致。
最終輸出 [3, 3, 4]。
Solution — C
// 演算法:線段樹。每個節點存這段的最長同字元段(best)、前綴(pref)、後綴(suf)、
// 長度(len) 與左右端字元(lc/rc)。合併時特別處理「跨越左右邊界」的段。
// Algorithm: segment tree. Each node stores the best single-char run, plus prefix/
// suffix runs, length, and boundary chars, so a run crossing the split is handled.
#include <string.h> // strlen
#include <stdlib.h> // malloc / free
// 一個線段樹節點要記錄的所有資訊 / all info a segment-tree node keeps
typedef struct {
int pref; // 從左端算起、同字元的最長前綴長度 / longest same-char prefix length
int suf; // 到右端為止、同字元的最長後綴長度 / longest same-char suffix length
int best; // 這段區間內部的最長同字元段 / best single-char run inside this segment
int len; // 這段區間的長度 / length of this segment
char lc; // 這段最左邊的字元 / leftmost character of this segment
char rc; // 這段最右邊的字元 / rightmost character of this segment
} Node;
// 把左子節點 a 與右子節點 b 合併成父節點 / merge left child a and right child b into a parent
static Node merge(Node a, Node b) {
Node r; // r 是要回傳的父節點 / r is the parent to return
r.len = a.len + b.len; // 父長度 = 兩段長度相加 / parent length = sum of the two
r.lc = a.lc; // 父最左字元 = 左子最左字元 / parent's left char = left child's left char
r.rc = b.rc; // 父最右字元 = 右子最右字元 / parent's right char = right child's right char
r.pref = a.pref; // 前綴預設等於左子的前綴 / prefix defaults to left child's prefix
// 若左子整段同一字元,且左子右端字元 == 右子左端字元,前綴可延伸進右子
// If the left child is all one char AND its right char equals the right child's left char, extend
if (a.pref == a.len && a.rc == b.lc)
r.pref = a.len + b.pref; // 吃進右子的前綴 / absorb right child's prefix
r.suf = b.suf; // 後綴預設等於右子的後綴 / suffix defaults to right child's suffix
// 對稱地:若右子整段同字元,且右子左端 == 左子右端,後綴延伸進左子
// Symmetric: extend the suffix into the left child when possible
if (b.suf == b.len && b.lc == a.rc)
r.suf = b.len + a.suf; // 吃進左子的後綴 / absorb left child's suffix
r.best = a.best > b.best ? a.best : b.best; // 先取兩子答案的較大者 / start from max of children's best
if (a.rc == b.lc) { // 邊界兩字元相同才可能有跨界段 / a crossing run needs matching boundary chars
int cross = a.suf + b.pref; // 跨界段 = 左後綴 + 右前綴 / crossing run = left suffix + right prefix
if (cross > r.best) r.best = cross; // 若更長就更新 / take it if longer
}
return r; // 回傳合併結果 / return the merged node
}
// 建樹:node 是樹陣列的下標,[l,r] 是這個節點負責的區間
// Build: node is the array index, [l,r] is the range this node covers
static void build(Node* tree, char* s, int node, int l, int r) {
if (l == r) { // 葉子:只負責一個字元 / leaf: covers a single character
tree[node].pref = tree[node].suf = tree[node].best = tree[node].len = 1; // 單字元各值皆為 1 / all length-like fields are 1
tree[node].lc = tree[node].rc = s[l]; // 左右端都是這個字元 / both ends are this character
return;
}
int mid = (l + r) / 2; // 取中點把區間對半 / split the range in half at mid
build(tree, s, 2 * node, l, mid); // 遞迴建左半 (下標 2*node) / build left half (index 2*node)
build(tree, s, 2 * node + 1, mid + 1, r); // 遞迴建右半 (下標 2*node+1) / build right half (index 2*node+1)
tree[node] = merge(tree[2 * node], tree[2 * node + 1]); // 由兩子合併出本節點 / combine children into this node
}
// 單點更新:把位置 pos 的字元改成 c,並沿路往上重算
// Point update: set the char at position pos to c, then recompute up the path
static void update(Node* tree, int node, int l, int r, int pos, char c) {
if (l == r) { // 到達目標葉子 / reached the target leaf
tree[node].lc = tree[node].rc = c; // 更新這個字元 / update the character (len/pref/suf/best stay 1)
return;
}
int mid = (l + r) / 2; // 中點 / midpoint
if (pos <= mid) update(tree, 2 * node, l, mid, pos, c); // 目標在左半就往左走 / go left if pos is in the left half
else update(tree, 2 * node + 1, mid + 1, r, pos, c); // 否則往右走 / otherwise go right
tree[node] = merge(tree[2 * node], tree[2 * node + 1]); // 子節點變了,重算本節點 / children changed, recombine this node
}
int* longestRepeating(char* s, char* queryCharacters, int* queryIndices,
int queryIndicesSize, int* returnSize) {
int n = strlen(s); // 字串長度 / length of the string
// 線段樹需要 4*n 個節點才夠用 / a segment tree needs up to 4*n nodes to be safe
Node* tree = (Node*)malloc(sizeof(Node) * 4 * n); // malloc 動態配置記憶體 / dynamically allocate memory
build(tree, s, 1, 0, n - 1); // 從下標 1 當根、負責 [0, n-1] 建樹 / build from root index 1 over [0, n-1]
int* ans = (int*)malloc(sizeof(int) * queryIndicesSize); // 存每次查詢答案 / holds each query's answer
for (int i = 0; i < queryIndicesSize; i++) { // 依序處理每個查詢 / process queries in order
update(tree, 1, 0, n - 1, queryIndices[i], queryCharacters[i]); // 改一個字元 / apply one character change
ans[i] = tree[1].best; // 根節點的 best 就是全字串的答案 / root's best is the whole-string answer
}
*returnSize = queryIndicesSize; // 透過指標回傳陣列長度 / report array length via the out-pointer
free(tree); // 釋放線段樹記憶體 / free the segment-tree memory
return ans; // 回傳答案陣列(呼叫端負責釋放)/ return the answer array (caller frees)
}
Solution — C++
// 演算法:線段樹,節點記錄最長同字元段與前綴/後綴,合併時處理跨界段。
// Algorithm: segment tree; each node stores the best run plus prefix/suffix so a
// run crossing the split of two children is merged correctly.
class Solution {
// 每個節點的資訊打包成一個結構 / a struct bundling one node's fields
struct Node {
int pref = 0, suf = 0, best = 0, len = 0; // 前綴/後綴/最佳/長度 / prefix/suffix/best/length
char lc = 0, rc = 0; // 左右端字元 / leftmost & rightmost chars
};
vector<Node> tree; // 用 vector 當線段樹陣列 / a vector as the segment-tree array
string s; // 目前的字串狀態 / the current string state
// 合併左右兩個子節點 / merge two child nodes into their parent
Node merge(const Node& a, const Node& b) {
// 若某一子是空段(len==0),直接回傳另一子 / if a child is empty, return the other
if (a.len == 0) return b;
if (b.len == 0) return a;
Node r;
r.len = a.len + b.len; // 長度相加 / lengths add up
r.lc = a.lc; // 左端來自左子 / left end from left child
r.rc = b.rc; // 右端來自右子 / right end from right child
r.pref = a.pref; // 前綴預設是左子前綴 / prefix defaults to left child's
if (a.pref == a.len && a.rc == b.lc) // 左子整段同字元且邊界相接 / left child all one char and boundary matches
r.pref = a.len + b.pref; // 前綴延伸進右子 / extend into right child
r.suf = b.suf; // 後綴預設是右子後綴 / suffix defaults to right child's
if (b.suf == b.len && b.lc == a.rc) // 右子整段同字元且邊界相接 / right child all one char and boundary matches
r.suf = b.len + a.suf; // 後綴延伸進左子 / extend into left child
r.best = max(a.best, b.best); // 先取兩子最佳 / max of children's best
if (a.rc == b.lc) // 邊界字元相同才有跨界段 / crossing run only if boundary chars match
r.best = max(r.best, a.suf + b.pref); // 跨界段 = 左後綴 + 右前綴 / crossing = left suffix + right prefix
return r;
}
// 建樹 / build the tree over range [l, r] at array index node
void build(int node, int l, int r) {
if (l == r) { // 葉子節點 / leaf node
tree[node] = {1, 1, 1, 1, s[l], s[l]}; // 單字元:各值為 1,兩端同字元 / single char: all 1s, both ends = s[l]
return;
}
int mid = (l + r) / 2; // 中點 / midpoint
build(2 * node, l, mid); // 建左半 / build left half
build(2 * node + 1, mid + 1, r); // 建右半 / build right half
tree[node] = merge(tree[2 * node], tree[2 * node + 1]); // 合併 / merge children
}
// 單點更新:把 pos 的字元設為 c / point update: set char at pos to c
void update(int node, int l, int r, int pos, char c) {
if (l == r) { // 目標葉子 / target leaf
tree[node].lc = tree[node].rc = c; // 改字元;其餘欄位仍為 1 / change char; other fields stay 1
return;
}
int mid = (l + r) / 2;
if (pos <= mid) update(2 * node, l, mid, pos, c); // 往左找 / recurse left
else update(2 * node + 1, mid + 1, r, pos, c); // 往右找 / recurse right
tree[node] = merge(tree[2 * node], tree[2 * node + 1]); // 重新合併 / recombine
}
public:
vector<int> longestRepeating(string s, string queryCharacters, vector<int>& queryIndices) {
this->s = s; // 保存字串到成員 / store the string in the member
int n = s.size(); // 字串長度 / string length
tree.assign(4 * n, Node{}); // 配置 4n 個節點(預設空節點)/ allocate 4n nodes (default-empty)
build(1, 0, n - 1); // 從根(下標1)建樹 / build from root (index 1)
vector<int> ans; // 答案陣列 / the answer array
ans.reserve(queryIndices.size()); // 預留空間避免多次擴容 / reserve to avoid reallocations
// range-for:依序取出每個查詢下標 i / range-for over each query index i
for (int i = 0; i < (int)queryIndices.size(); i++) {
update(1, 0, n - 1, queryIndices[i], queryCharacters[i]); // 套用一次修改 / apply one update
ans.push_back(tree[1].best); // 根的 best 即答案 / root's best is the answer
}
return ans; // 回傳結果 / return the results
}
};
複雜度 / Complexity
- Time:
O((n + k) · log n)— 建樹走訪每個節點一次是O(n)(n是字串長度);之後每次查詢只沿一條葉子到根的路徑更新,長度為O(log n),共k次,所以查詢部分是O(k · log n)。主導項是查詢與建樹的總和。/ Building visits each node once (O(n)); each of thekqueries only recomputes one root-to-leaf path of lengthO(log n).nis the string length,kthe number of queries. - Space:
O(n)— 線段樹用了4n個節點,每個節點是常數大小;答案陣列是O(k)。整體與輸入規模成線性。/ The segment tree uses4nconstant-size nodes; the answer array isO(k). Overall linear in the input size.
Pitfalls & Edge Cases
- 只存
best不夠 / storing onlybestis not enough:最長段可能橫跨節點的左右子,若不存前綴/後綴就無法算出跨界段,答案會偏小。務必六個欄位都存。/ A run can straddle two children; without prefix/suffix you miss the crossing run and under-count. - 合併時的邊界條件寫反 / mixing up the merge conditions:前綴能延伸的條件是「左子整段同字元 (
a.pref == a.len)」且「左子右端 == 右子左端」;後綴是對稱的另一組。搞混左右或漏掉相等判斷都會出錯。/ Extending the prefix requires the left child be all one char and boundary chars equal; the suffix is the mirror image — don't swap them. - 配置大小 / tree array size:線段樹要開
4 * n,開2 * n在某些n會越界。/ Allocate4 * nnodes;2 * noverflows for somen. n == 1的極小情況 / single-character string:只有一個葉子、沒有內部合併,程式仍需正確回傳長度 1;本解的葉子初始化已涵蓋。/ With one leaf and no merges, the answer is 1 — the leaf init already handles it.- 更新是累積的 / updates accumulate:每次查詢是在前一次結果上繼續改,不要每次重置回原字串;本解直接在同一棵樹上更新,天然滿足。/ Queries build on each other — don't reset the string each time; updating the same tree does this naturally.
- C 的記憶體釋放 / freeing in C:
tree用完要free,但回傳的ans由 LeetCode 呼叫端負責釋放,不要在函式內free(ans)。/ Freetree, but never freeans— the caller owns it. - 字元用
char比較即可 / compare chars directly:小寫字母直接比==就行,不需轉成0..25;但若要當陣列下標記得減'a'(本解未用到)。/ Comparing chars with==is fine; only subtract'a'if you index by letter.