Longest Substring of One Repeating Character
HardPrompt
You are given a 0-indexed string s. You are also given a 0-indexed string queryCharacters of length k and a 0-indexed array of integer indices queryIndices of length k, both of which are used to describe k queries.
The ith query updates the character in s at index queryIndices[i] to the character queryCharacters[i].
Return an array lengths of length k where lengths[i] is the length of the longest substring of s consisting of only one repeating character after the ith query is performed.
Example 1:
Input: s = "babacc", queryCharacters = "bcb", queryIndices = [1,3,3]
Output: [3,3,4]
Explanation:
- 1st query updates s = "bbbacc". The longest substring consisting of one repeating character is "bbb" with length 3.
- 2nd query updates s = "bbbccc".
The longest substring consisting of one repeating character can be "bbb" or "ccc" with length 3.
- 3rd query updates s = "bbbbcc". The longest substring consisting of one repeating character is "bbbb" with length 4.
Thus, we return [3,3,4].Example 2:
Input: s = "abyzz", queryCharacters = "aa", queryIndices = [2,1]
Output: [2,3]
Explanation:
- 1st query updates s = "abazz". The longest substring consisting of one repeating character is "zz" with length 2.
- 2nd query updates s = "aaazz". The longest substring consisting of one repeating character is "aaa" with length 3.
Thus, we return [2,3].
Constraints:
1 <= s.length <= 105sconsists of lowercase English letters.k == queryCharacters.length == queryIndices.length1 <= k <= 105queryCharactersconsists of lowercase English letters.0 <= queryIndices[i] < s.length
Approaches
3 approaches with complexity analysis and trade-offs.
This approach directly simulates the process described in the problem. For each query, it first updates the character in the string. Then, it performs a full linear scan of the entire modified string to find the length of the longest substring composed of a single repeating character.
Algorithm
- Convert the input string
sto a mutablechararray,sChars. - Initialize an integer array
resultof sizekto store the answers. - Loop through each query from
i = 0tok-1:- Update the character in
sCharsat the given index:sChars[queryIndices[i]] = queryCharacters.charAt(i). - Initialize
maxLength = 0,currentLength = 0. - Perform a linear scan through the
sCharsarray. - During the scan, track the length of the current contiguous block of identical characters.
- If
sChars[j]is the same assChars[j-1], incrementcurrentLength. Otherwise, resetcurrentLengthto 1. - Continuously update
maxLengthwith the maximumcurrentLengthseen so far. - After the scan, store the final
maxLengthinresult[i].
- Update the character in
- Return the
resultarray.
Walkthrough
The brute-force method is the most straightforward way to solve the problem. We begin by converting the string s into a more flexible data structure like a character array, which allows for O(1) time complexity for character updates.
For each of the k queries, we perform two main steps:
- Update: We change the character at the specified
queryIndices[i]to the newqueryCharacters[i]. - Recalculate: We iterate through the entire character array from beginning to end. We use a variable, say
currentLength, to keep track of the length of the current substring of repeating characters. Another variable,maxLength, stores the maximum length found so far. As we iterate, if we encounter a character that is the same as the previous one, we incrementcurrentLength. If the character changes, we resetcurrentLengthto 1. At each step, we updatemaxLengthto be the maximum of its current value andcurrentLength.
After scanning the whole array, maxLength will hold the answer for the current query. We store this value and proceed to the next query. This entire process is repeated for all k queries.
class Solution { public int[] longestRepeating(String s, String queryCharacters, int[] queryIndices) { int k = queryIndices.length; int[] ans = new int[k]; char[] sChars = s.toCharArray(); for (int i = 0; i < k; i++) { sChars[queryIndices[i]] = queryCharacters.charAt(i); int maxLength = 0; if (sChars.length > 0) { maxLength = 1; int currentLength = 1; for (int j = 1; j < sChars.length; j++) { if (sChars[j] == sChars[j - 1]) { currentLength++; } else { currentLength = 1; } maxLength = Math.max(maxLength, currentLength); } } ans[i] = maxLength; } return ans; }}Complexity
Time
O(k * n), where `n` is the length of `s` and `k` is the number of queries. For each of the `k` queries, we scan the entire string of length `n`. This is too slow for the given constraints.
Space
O(n + k), where `n` is the length of the string and `k` is the number of queries. We need O(n) space for the character array and O(k) space for the result array.
Trade-offs
Pros
Simple to understand and implement.
Requires minimal auxiliary data structures, making it memory-efficient in terms of extra space.
Cons
Extremely inefficient for the given constraints, leading to a 'Time Limit Exceeded' verdict on most platforms.
It re-computes the result from scratch for every query, failing to leverage the fact that each update only affects the string locally.
This optimized approach avoids re-scanning the entire string. It intelligently maintains the contiguous blocks (intervals) of same characters and their lengths using balanced binary search trees (TreeMap in Java). When a character is updated, it only adjusts the specific intervals affected by the change, which is significantly faster.
Algorithm
- Initialize two
TreeMaps:intervalsto map a block's start index to its end index, andlengthsto map a block's length to its frequency. - Pre-process the initial string
sin O(n) time to populate bothTreeMaps with the initial blocks. - For each query
(idx, newChar):- If the character at
idxis not changing, the answer remains the same. - Decomposition: Find the block
[start, end]containingidxusingintervals.floorEntry(idx). Remove this block fromintervalsand its length fromlengths. Add back the two potential sub-blocks[start, idx-1]and[idx+1, end]if they are non-empty. - Update the character in the underlying character array:
sArr[idx] = newChar. - Composition: A new block of length 1 is formed at
idx. Check if this block can be merged with an adjacent block to the left (ending atidx-1) or to the right (starting atidx+1) if they consist ofnewChar. If a merge is possible, remove the old smaller blocks and add the new, larger merged block to theTreeMaps. - The answer for the query is the largest key in the
lengthsmap, which can be retrieved withlengths.lastKey().
- If the character at
- Store the answer and repeat for all queries.
Walkthrough
Instead of re-evaluating the entire string, we can focus only on the changes. An update at index idx can only affect the block of repeating characters that contains idx and potentially its immediate neighbors.
We use two TreeMap data structures to efficiently manage this:
intervals: ATreeMap<Integer, Integer>that maps the starting index of a block to its ending index. The sorted nature ofTreeMapallows us to find which block an indexidxbelongs to inO(log N_blocks)time usingfloorEntry(idx).lengths: ATreeMap<Integer, Integer>that serves as a multiset, mapping each block length to the count of blocks having that length. This lets us find the maximum length inO(log L_distinct)time by getting the last key (lastKey()).
Initialization: We first scan the string s once to identify all initial blocks and populate intervals and lengths.
Query Processing: For each query, we perform a two-phase update:
- Decomposition: The character update at
idxwill break the block it currently belongs to. We find this block, remove it from our data structures, and add back the two smaller pieces it splits into (e.g.,[start, idx-1]and[idx+1, end]). - Composition: After updating the character array, the new character at
idxforms a new block of length 1. This block might be able to merge with its left and/or right neighbors if they now share the same character. We check for this, and if merges are possible, we remove the old, smaller blocks and add the new, larger merged block.
After these adjustments, the maximum length is simply the largest key present in our lengths map.
import java.util.TreeMap;import java.util.Map; class Solution { char[] sArr; TreeMap<Integer, Integer> intervals; // {start -> end} TreeMap<Integer, Integer> lengths; // {length -> count} public int[] longestRepeating(String s, String queryCharacters, int[] queryIndices) { sArr = s.toCharArray(); int n = s.length(); intervals = new TreeMap<>(); lengths = new TreeMap<>(); // Initial build int start = 0; for (int i = 1; i <= n; i++) { if (i == n || sArr[i] != sArr[start]) { addInterval(start, i - 1); start = i; } } int k = queryIndices.length; int[] result = new int[k]; for (int i = 0; i < k; i++) { int idx = queryIndices[i]; char newChar = queryCharacters.charAt(i); if (sArr[idx] != newChar) { // --- Decomposition --- Map.Entry<Integer, Integer> entry = intervals.floorEntry(idx); int oldStart = entry.getKey(); int oldEnd = entry.getValue(); removeInterval(oldStart, oldEnd); if (idx > oldStart) { addInterval(oldStart, idx - 1); } if (idx < oldEnd) { addInterval(idx + 1, oldEnd); } // --- Composition --- sArr[idx] = newChar; int newStart = idx; int newEnd = idx; // Merge with left Map.Entry<Integer, Integer> leftEntry = intervals.floorEntry(idx - 1); if (leftEntry != null && leftEntry.getValue() == idx - 1 && sArr[idx - 1] == newChar) { newStart = leftEntry.getKey(); removeInterval(newStart, idx - 1); } // Merge with right Integer rightEnd = intervals.get(idx + 1); if (rightEnd != null && sArr[idx + 1] == newChar) { newEnd = rightEnd; removeInterval(idx + 1, newEnd); } addInterval(newStart, newEnd); } result[i] = lengths.isEmpty() ? 0 : lengths.lastKey(); } return result; } private void addInterval(int start, int end) { intervals.put(start, end); int len = end - start + 1; lengths.put(len, lengths.getOrDefault(len, 0) + 1); } private void removeInterval(int start, int end) { intervals.remove(start); int len = end - start + 1; lengths.put(len, lengths.get(len) - 1); if (lengths.get(len) == 0) { lengths.remove(len); } }}Complexity
Time
O(n + k * log n). The initial processing of the string takes O(n). Each query involves a constant number of `TreeMap` operations (lookups, insertions, deletions), each taking O(log N_blocks) time. Since the number of blocks `N_blocks` is at most `n`, each query is O(log n).
Space
O(n + k). In the worst case (e.g., a string like "ababab..."), we might have `n` distinct blocks, so the `intervals` and `lengths` maps can take up to O(n) space. O(k) is for the result array.
Trade-offs
Pros
Highly efficient, with logarithmic time complexity per query.
Correctly models the problem by only updating locally affected data.
Scales well for large inputs, easily passing the given constraints.
Cons
The logic for splitting and merging intervals can be complex and tricky to implement correctly without bugs.
Java's
TreeMaphas higher constant factors and memory overhead compared to array-based structures like a segment tree, which might result in slightly slower practical performance.
A segment tree is a powerful data structure well-suited for problems involving point updates and range queries. We build a segment tree over the string, where each node stores aggregated information about the longest repeating character substring within its corresponding range. An update to the string translates to an update on a single leaf of the tree, with changes efficiently propagated up to the root.
Algorithm
- Define a
Nodeclass to store segment information:maxLen,prefixLen,prefixChar,suffixLen,suffixChar, and totallen. - Implement a
merge(leftNode, rightNode)function that combines two child nodes into a parent node, calculating the parent's attributes based on the children's. - Implement a
build(s, ...)function to recursively construct the segment tree from the bottom up. Leaf nodes correspond to single characters ofs. - Implement an
update(idx, char, ...)function. When a query arrives, this function finds the corresponding leaf in the tree, updates its value, and then propagates the changes up to the root by calling themergefunction on each parent along the path. - For each query, call
update()and then the answer is simply themaxLenvalue stored in the root of the tree (tree[0]).
Walkthrough
This approach uses a segment tree, a classic data structure for this type of problem. Each node in the tree represents a range [L, R] of the string and stores crucial information about it.
For each node, we store:
maxLen: The length of the longest repeating character substring fully contained withins[L...R].prefixLen,prefixChar: The length and character of the uniform prefix ofs[L...R].suffixLen,suffixChar: The length and character of the uniform suffix ofs[L...R].len: The total length of the range,R - L + 1.
Build: The tree is built recursively. A leaf node for index i will have maxLen=1, prefixLen=1, suffixLen=1, etc. Parent nodes are formed by a merge function that combines information from its left and right children.
Merge Logic: The key is the merge function. The maxLen of a parent is the maximum of left.maxLen, right.maxLen, and a potential new maximum formed by joining the suffix of the left child with the prefix of the right child, which is possible only if left.suffixChar == right.prefixChar. The prefix and suffix of the parent are also computed based on whether they can extend across the child boundary.
Update: An update to s[idx] corresponds to updating a single leaf in the segment tree. We traverse down to the leaf, change its value, and then traverse back up to the root, applying the merge logic at each level to update the ancestor nodes.
Query: After each update, the answer for the entire string is readily available as the maxLen value stored in the root node of the segment tree.
class Solution { class Node { int maxLen; int prefixLen, suffixLen; char prefixChar, suffixChar; int len; Node(int maxLen, int prefixLen, char prefixChar, int suffixLen, char suffixChar, int len) { this.maxLen = maxLen; this.prefixLen = prefixLen; this.prefixChar = prefixChar; this.suffixLen = suffixLen; this.suffixChar = suffixChar; this.len = len; } } Node[] tree; char[] sArr; public int[] longestRepeating(String s, String queryCharacters, int[] queryIndices) { int n = s.length(); sArr = s.toCharArray(); tree = new Node[4 * n]; build(0, 0, n - 1); int k = queryIndices.length; int[] result = new int[k]; for (int i = 0; i < k; i++) { int idx = queryIndices[i]; char ch = queryCharacters.charAt(i); update(0, 0, n - 1, idx, ch); result[i] = tree[0].maxLen; } return result; } private Node merge(Node left, Node right) { if (left == null) return right; if (right == null) return left; int maxLen = Math.max(left.maxLen, right.maxLen); if (left.suffixChar == right.prefixChar) { maxLen = Math.max(maxLen, left.suffixLen + right.prefixLen); } int prefixLen = left.prefixLen; char prefixChar = left.prefixChar; if (left.prefixLen == left.len && left.prefixChar == right.prefixChar) { prefixLen += right.prefixLen; } int suffixLen = right.suffixLen; char suffixChar = right.suffixChar; if (right.suffixLen == right.len && right.suffixChar == left.suffixChar) { suffixLen += left.suffixLen; } return new Node(maxLen, prefixLen, prefixChar, suffixLen, suffixChar, left.len + right.len); } private void build(int node, int start, int end) { if (start == end) { tree[node] = new Node(1, 1, sArr[start], 1, sArr[start], 1); return; } int mid = start + (end - start) / 2; build(2 * node + 1, start, mid); build(2 * node + 2, mid + 1, end); tree[node] = merge(tree[2 * node + 1], tree[2 * node + 2]); } private void update(int node, int start, int end, int idx, char val) { if (start == end) { sArr[idx] = val; tree[node] = new Node(1, 1, val, 1, val, 1); return; } int mid = start + (end - start) / 2; if (start <= idx && idx <= mid) { update(2 * node + 1, start, mid, idx, val); } else { update(2 * node + 2, mid + 1, end, idx, val); } tree[node] = merge(tree[2 * node + 1], tree[2 * node + 2]); }}Complexity
Time
O(n + k * log n). Building the tree takes O(n) time. Each update requires traversing the height of the tree, which is O(log n). With `k` updates, the total time is dominated by the queries.
Space
O(n + k). The segment tree itself requires O(n) space (typically an array of size `4n` is allocated for simplicity). O(k) is for the result array.
Trade-offs
Pros
Highly efficient, with O(log n) time complexity per update.
A standard and very powerful technique for problems involving point updates and range queries.
Often has better practical performance than
TreeMap-based solutions due to its array-based implementation, which leads to better cache locality.
Cons
The implementation is more complex than the brute-force approach, particularly the
mergelogic, which requires careful handling of all cases.It requires more memory than the brute-force approach, typically an array of size
4nto store the tree nodes.
Solutions
Solution
class Node { int l ; int r ; int size ; int lmx ; int rmx ; int mx ; char lc ; char rc ; } class SegmentTree { private String s ; private Node [] tr ; public SegmentTree ( String s ) { int n = s . length (); this . s = s ; tr = new Node [ n << 2 ]; for ( int i = 0 ; i < tr . length ; ++ i ) { tr [ i ] = new Node (); } build ( 1 , 1 , n ); } public void build ( int u , int l , int r ) { tr [ u ]. l = l ; tr [ u ]. r = r ; if ( l == r ) { tr [ u ]. lmx = 1 ; tr [ u ]. rmx = 1 ; tr [ u ]. mx = 1 ; tr [ u ]. size = 1 ; tr [ u ]. lc = s . charAt ( l - 1 ); tr [ u ]. rc = s . charAt ( l - 1 ); return ; } int mid = ( l + r ) >> 1 ; build ( u << 1 , l , mid ); build ( u << 1 | 1 , mid + 1 , r ); pushup ( u ); } void modify ( int u , int x , char v ) { if ( tr [ u ]. l == x && tr [ u ]. r == x ) { tr [ u ]. lc = v ; tr [ u ]. rc = v ; return ; } int mid = ( tr [ u ]. l + tr [ u ]. r ) >> 1 ; if ( x <= mid ) { modify ( u << 1 , x , v ); } else { modify ( u << 1 | 1 , x , v ); } pushup ( u ); } Node query ( int u , int l , int r ) { if ( tr [ u ]. l >= l && tr [ u ]. r <= r ) { return tr [ u ]; } int mid = ( tr [ u ]. l + tr [ u ]. r ) >> 1 ; if ( r <= mid ) { return query ( u << 1 , l , r ); } if ( l > mid ) { return query ( u << 1 | 1 , l , r ); } Node ans = new Node (); Node left = query ( u << 1 , l , r ); Node right = query ( u << 1 | 1 , l , r ); pushup ( ans , left , right ); return ans ; } void pushup ( Node root , Node left , Node right ) { root . lc = left . lc ; root . rc = right . rc ; root . size = left . size + right . size ; root . mx = Math . max ( left . mx , right . mx ); root . lmx = left . lmx ; root . rmx = right . rmx ; if ( left . rc == right . lc ) { if ( left . lmx == left . size ) { root . lmx += right . lmx ; } if ( right . rmx == right . size ) { root . rmx += left . rmx ; } root . mx = Math . max ( root . mx , left . rmx + right . lmx ); } } void pushup ( int u ) { pushup ( tr [ u ], tr [ u << 1 ], tr [ u << 1 | 1 ]); } } class Solution { public int [] longestRepeating ( String s , String queryCharacters , int [] queryIndices ) { SegmentTree tree = new SegmentTree ( s ); int k = queryCharacters . length (); int [] ans = new int [ k ]; for ( int i = 0 ; i < k ; ++ i ) { int x = queryIndices [ i ] + 1 ; char c = queryCharacters . charAt ( i ); tree . modify ( 1 , x , c ); ans [ i ] = tree . query ( 1 , 1 , s . length ()). mx ; } return ans ; } }class Node { public: int l , r , size , lmx , rmx , mx ; char lc , rc ; }; class SegmentTree { private: string s ; vector < Node *> tr ; public: SegmentTree ( string & s ) { this -> s = s ; int n = s . size (); tr . resize ( n << 2 ); for ( int i = 0 ; i < tr . size (); ++ i ) tr [ i ] = new Node (); build ( 1 , 1 , n ); } void build ( int u , int l , int r ) { tr [ u ] -> l = l ; tr [ u ] -> r = r ; if ( l == r ) { tr [ u ] -> lmx = tr [ u ] -> rmx = tr [ u ] -> mx = tr [ u ] -> size = 1 ; tr [ u ] -> lc = tr [ u ] -> rc = s [ l - 1 ]; return ; } int mid = ( l + r ) >> 1 ; build ( u << 1 , l , mid ); build ( u << 1 | 1 , mid + 1 , r ); pushup ( u ); } void modify ( int u , int x , char v ) { if ( tr [ u ] -> l == x && tr [ u ] -> r == x ) { tr [ u ] -> lc = tr [ u ] -> rc = v ; return ; } int mid = ( tr [ u ] -> l + tr [ u ] -> r ) >> 1 ; if ( x <= mid ) modify ( u << 1 , x , v ); else modify ( u << 1 | 1 , x , v ); pushup ( u ); } Node * query ( int u , int l , int r ) { if ( tr [ u ] -> l >= l && tr [ u ] -> r <= r ) return tr [ u ]; int mid = ( tr [ u ] -> l + tr [ u ] -> r ) >> 1 ; if ( r <= mid ) return query ( u << 1 , l , r ); if ( l > mid ) query ( u << 1 | 1 , l , r ); Node * ans = new Node (); Node * left = query ( u << 1 , l , r ); Node * right = query ( u << 1 | 1 , l , r ); pushup ( ans , left , right ); return ans ; } void pushup ( Node * root , Node * left , Node * right ) { root -> lc = left -> lc ; root -> rc = right -> rc ; root -> size = left -> size + right -> size ; root -> mx = max ( left -> mx , right -> mx ); root -> lmx = left -> lmx ; root -> rmx = right -> rmx ; if ( left -> rc == right -> lc ) { if ( left -> lmx == left -> size ) root -> lmx += right -> lmx ; if ( right -> rmx == right -> size ) root -> rmx += left -> rmx ; root -> mx = max ( root -> mx , left -> rmx + right -> lmx ); } } void pushup ( int u ) { pushup ( tr [ u ], tr [ u << 1 ], tr [ u << 1 | 1 ]); } }; class Solution { public: vector < int > longestRepeating ( string s , string queryCharacters , vector < int >& queryIndices ) { SegmentTree * tree = new SegmentTree ( s ); int k = queryCharacters . size (); vector < int > ans ( k ); for ( int i = 0 ; i < k ; ++ i ) { int x = queryIndices [ i ] + 1 ; tree -> modify ( 1 , x , queryCharacters [ i ]); ans [ i ] = tree -> query ( 1 , 1 , s . size ()) -> mx ; } return ans ; } };class Node : def __init__ ( self ): self . l = 0 self . r = 0 self . lmx = 0 self . rmx = 0 self . mx = 0 self . size = 0 self . lc = None self . rc = None N = 100010 tr = [ Node () for _ in range ( N << 2 )] class SegmentTree : def __init__ ( self , s ): n = len ( s ) self . s = s self . build ( 1 , 1 , n ) def build ( self , u , l , r ): tr [ u ]. l = l tr [ u ]. r = r if l == r : tr [ u ]. lmx = tr [ u ]. rmx = tr [ u ]. mx = tr [ u ]. size = 1 tr [ u ]. lc = tr [ u ]. rc = self . s [ l - 1 ] return mid = ( l + r ) >> 1 self . build ( u << 1 , l , mid ) self . build ( u << 1 | 1 , mid + 1 , r ) self . pushup ( u ) def modify ( self , u , x , v ): if tr [ u ]. l == x and tr [ u ]. r == x : tr [ u ]. lc = tr [ u ]. rc = v return mid = ( tr [ u ]. l + tr [ u ]. r ) >> 1 if x <= mid : self . modify ( u << 1 , x , v ) else : self . modify ( u << 1 | 1 , x , v ) self . pushup ( u ) def query ( self , u , l , r ): if tr [ u ]. l >= l and tr [ u ]. r <= r : return tr [ u ] mid = ( tr [ u ]. l + tr [ u ]. r ) >> 1 if r <= mid : return self . query ( u << 1 , l , r ) if l > mid : return self . query ( u << 1 | 1 , l , r ) left , right = self . query ( u << 1 , l , r ), self . query ( u << 1 | 1 , l , r ) ans = Node () self . _pushup ( ans , left , right ) return ans def _pushup ( self , root , left , right ): root . lc , root . rc = left . lc , right . rc root . size = left . size + right . size root . mx = max ( left . mx , right . mx ) root . lmx , root . rmx = left . lmx , right . rmx if left . rc == right . lc : if left . lmx == left . size : root . lmx += right . lmx if right . rmx == right . size : root . rmx += left . rmx root . mx = max ( root . mx , left . rmx + right . lmx ) def pushup ( self , u ): self . _pushup ( tr [ u ], tr [ u << 1 ], tr [ u << 1 | 1 ]) class Solution : def longestRepeating ( self , s : str , queryCharacters : str , queryIndices : List [ int ] ) -> List [ int ]: tree = SegmentTree ( s ) k = len ( queryIndices ) ans = [] for i , c in enumerate ( queryCharacters ): x = queryIndices [ i ] + 1 tree . modify ( 1 , x , c ) ans . append ( tree . query ( 1 , 1 , len ( s )). mx ) return ansVideo walkthrough
Newsletter
One sharp idea, every week
System design and interview prep — short enough to finish.
No spam. Unsubscribe anytime.
Practice
Same difficulty — related problems to reinforce the pattern.