3853. Merge Close Characters
Problem Description
You are given a string s consisting of lowercase English letters and an integer k.
Two equal characters in the current string s are considered close if the distance between their indices is at most k.
When two characters are close, the right one merges into the left. Merges happen one at a time, and after each merge, the string updates until no more merges are possible.
Return the resulting string after performing all possible merges.
Note: If multiple merges are possible, always merge the pair with the smallest left index. If multiple pairs share the smallest left index, choose the pair with the smallest right index.
In other words, you process the string from left to right and build up a result. For each character, you check its last position in the result you have built so far. If the same character appeared recently enough — that is, the distance between the current position in the result and its previous position is at most k — then this character is merged away and discarded. Otherwise, the character is kept and becomes part of the result, updating its latest recorded position.
For example, suppose s = "aabba" and k = 1:
- Place
aat index0. - The next
awould land at index1, and1 - 0 = 1 <= k, so it merges away. - Place
bat index1. - The next
bwould land at index2, and2 - 1 = 1 <= k, so it merges away. - The final
awould land at index2, butawas last at index0, and2 - 0 = 2 > k, so it is kept.
The resulting string is "aba".
The task is to compute this final merged string after no further merges can be performed.
How We Pick the Algorithm
Why Hash Table / Counting?
This problem maps to Hash Table / Counting through a short path in the full flowchart.
Hash table tracks last position of each character to determine merge eligibility.
Open in FlowchartIntuition
At first glance, the problem describes a complicated process: repeatedly find the closest pair of equal characters, merge the right one into the left, and rebuild the string after every single merge. Simulating this literally would be slow and tricky to manage, because every merge changes the indices of all the characters that follow.
The key insight comes from carefully reading the merge rule. We must always merge the pair with the smallest left index first, and among those, the pair with the smallest right index. This ordering means the merging effectively happens in a strict left-to-right order. As we scan the string from the beginning, each character is decided before we move on to the next one — there is no need to go back.
This naturally suggests building the answer incrementally. We maintain a result list ans that holds the characters we have decided to keep. When we look at a new character c, the position it would occupy in the result is cur = len(ans). The question becomes: should this character merge into a previous equal character, or should it survive?
A character c merges away only if an equal character already exists in the result and is close to it — meaning the distance cur - last[c] is at most k, where last[c] is the most recent position of c in the result. If that condition holds, the current character is absorbed and we simply skip it. Otherwise, it is far enough away (or has never appeared), so we keep it.
To check the closeness condition instantly, we only need to remember where each character last landed in the result. A hash table last mapping each character to its latest position in ans does exactly this. Every time we keep a character, we update its recorded position, so future checks always compare against the correct, most recent location.
This transforms a seemingly heavy simulation into a single clean pass over the string. By processing characters one at a time and tracking only the last position of each character, we capture the entire merging behavior without ever rebuilding the string, arriving at the final result in linear time.
Solution Approach
Solution 1: Hash Table
We use a hash table last to record the last occurrence position of each character within the result we are building. We iterate over each character in the string. If the current character has appeared before and the difference between its position in the result and its last recorded position is at most k, we skip the character; otherwise, we add the character to the answer and update its position in the hash table.
Let's walk through the implementation step by step:
-
Initialize the data structures.
last = {}is a hash table mapping each character to its most recent position in the result list.ans = []is a list that stores the characters we decide to keep, which will be joined into the final string.
-
Iterate over each character
cins.- Compute
cur = len(ans), which is the index this character would take if it were appended to the result. This value represents the character's position in the current string after all prior merges, which is exactly what the closeness check needs.
- Compute
-
Decide whether the character merges away or survives.
- Check the condition
c in last and cur - last[c] <= k.c in lastconfirms that an equal character already exists in the result.cur - last[c] <= kconfirms that the previous equal character is close — within distancekof the current position.
- If both hold, the character is absorbed by the earlier one, so we
continueand skip it entirely.
- Check the condition
-
Keep the character when it cannot merge.
- If the condition fails (the character is new or too far away), we append it to
ansand updatelast[c] = curto record its new latest position. Future characters will compare against this updated position.
- If the condition fails (the character is new or too far away), we append it to
-
Build and return the result.
- After the loop finishes,
ansholds all surviving characters in order. We return"".join(ans)as the final merged string.
- After the loop finishes,
Because each character's position in the result is fixed the moment it is kept, the value last[c] always reflects the correct distance for the next comparison. This avoids ever rebuilding the string after a merge.
The time complexity is O(n), where n is the length of the string s, since we make a single pass and each hash table operation is O(1). The space complexity is O(|\Sigma|), where |\Sigma| is the size of the character set (at most 26 for lowercase English letters), used by the hash table last. The result list itself takes O(n) space in the worst case.
Example Walkthrough
Let's trace through a small example with s = "abcaba" and k = 2.
We maintain two structures: last (a hash table mapping each character to its most recent position in the result) and ans (the list of kept characters). Both start empty.
Step 1 — Process 'a'
cur = len(ans) = 0.- Check: is
'a'inlast? No. - The character survives. Append
'a'toansand setlast['a'] = 0. - State:
ans = ['a'],last = {'a': 0}.
Step 2 — Process 'b'
cur = len(ans) = 1.- Check: is
'b'inlast? No. - The character survives. Append
'b'and setlast['b'] = 1. - State:
ans = ['a', 'b'],last = {'a': 0, 'b': 1}.
Step 3 — Process 'c'
cur = len(ans) = 2.- Check: is
'c'inlast? No. - The character survives. Append
'c'and setlast['c'] = 2. - State:
ans = ['a', 'b', 'c'],last = {'a': 0, 'b': 1, 'c': 2}.
Step 4 — Process 'a'
cur = len(ans) = 3.- Check: is
'a'inlast? Yes, at position0. Iscur - last['a'] = 3 - 0 = 3 <= k = 2? No. - The previous
'a'is too far away, so this character survives. Append'a'and updatelast['a'] = 3. - State:
ans = ['a', 'b', 'c', 'a'],last = {'a': 3, 'b': 1, 'c': 2}.
Step 5 — Process 'b'
cur = len(ans) = 4.- Check: is
'b'inlast? Yes, at position1. Iscur - last['b'] = 4 - 1 = 3 <= k = 2? No. - Too far away, so it survives. Append
'b'and updatelast['b'] = 4. - State:
ans = ['a', 'b', 'c', 'a', 'b'],last = {'a': 3, 'b': 4, 'c': 2}.
Step 6 — Process 'a'
cur = len(ans) = 5.- Check: is
'a'inlast? Yes, at position3. Iscur - last['a'] = 5 - 3 = 2 <= k = 2? Yes. - This
'a'is close to the earlier one, so it merges away. Wecontinueand skip it. - State unchanged:
ans = ['a', 'b', 'c', 'a', 'b'],last = {'a': 3, 'b': 4, 'c': 2}.
Result
- Join
ansto produce"abcab".
This walkthrough shows how each character's fate is decided in a single left-to-right pass: a character is discarded only when an equal character is recorded within distance k of its would-be position, and otherwise it is kept while we update its latest position. No string rebuilding is ever required.
Solution Implementation
1class Solution:
2 def mergeCharacters(self, s: str, k: int) -> str:
3 # Maps each character to the index (in result) where it was last kept
4 last_index: dict[str, int] = {}
5 # Holds the characters that survive the merging process
6 result: list[str] = []
7
8 for char in s:
9 # Current position in the result list (index of next append)
10 current_pos = len(result)
11
12 # Skip the character if it appeared recently:
13 # it exists in result AND the gap to its last position is within k
14 if char in last_index and current_pos - last_index[char] <= k:
15 continue
16
17 # Otherwise, keep the character and record its position
18 result.append(char)
19 last_index[char] = current_pos
20
21 # Join the surviving characters into the final string
22 return "".join(result)
231class Solution {
2 public String mergeCharacters(String s, int k) {
3 // Records the last index in the result where each character was appended
4 Map<Character, Integer> lastIndex = new HashMap<>();
5 // Builds the resulting merged string
6 StringBuilder result = new StringBuilder();
7
8 // Process every character of the input string
9 for (char c : s.toCharArray()) {
10 // Current length of the result equals the index of the next position
11 int currentIndex = result.length();
12
13 // Skip the character if it appeared before and the gap to its
14 // last appended position is within the threshold k
15 if (lastIndex.containsKey(c) && currentIndex - lastIndex.get(c) <= k) {
16 continue;
17 }
18
19 // Append the character to the result
20 result.append(c);
21 // Update the last position where this character was appended
22 lastIndex.put(c, currentIndex);
23 }
24
25 // Return the final merged string
26 return result.toString();
27 }
28}
291class Solution {
2public:
3 string mergeCharacters(string s, int k) {
4 // Records the index in `result` where each character was last placed.
5 unordered_map<char, int> lastIndex;
6 // Stores the resulting string after merging.
7 string result;
8
9 for (char ch : s) {
10 // Current length of `result`, i.e. the index where `ch` would be placed.
11 int currentIndex = static_cast<int>(result.size());
12
13 // Skip the character if it appeared before and the distance
14 // between the current position and its last position is within `k`.
15 if (lastIndex.count(ch) && currentIndex - lastIndex[ch] <= k) {
16 continue;
17 }
18
19 // Append the character to the result and update its last index.
20 result += ch;
21 lastIndex[ch] = currentIndex;
22 }
23
24 return result;
25 }
26};
271/**
2 * Merges nearby duplicate characters in the string based on a distance threshold.
3 *
4 * For each character, if it has already been added to the result and the gap
5 * between the current result length and the position where it was last added
6 * is less than or equal to k, the character is skipped (merged).
7 * Otherwise, the character is appended and its latest position is recorded.
8 *
9 * @param s - The input string to process.
10 * @param k - The maximum distance within which duplicate characters are merged.
11 * @returns The resulting string after merging nearby duplicates.
12 */
13function mergeCharacters(s: string, k: number): string {
14 // Records the last position (index in result) where each character was added.
15 const lastPosition = new Map<string, number>();
16 // Holds the characters that survive the merging process.
17 const result: string[] = [];
18
19 for (const char of s) {
20 // Current length of the result, used as the position reference.
21 const currentPosition = result.length;
22
23 // Skip the character if it was recently added within the distance threshold k.
24 if (
25 lastPosition.has(char) &&
26 currentPosition - lastPosition.get(char)! <= k
27 ) {
28 continue;
29 }
30
31 // Append the character and update its most recent position.
32 result.push(char);
33 lastPosition.set(char, currentPosition);
34 }
35
36 // Combine the collected characters into the final string.
37 return result.join('');
38}
39Time and Space Complexity
-
Time Complexity:
O(n), wherenis the length of the strings. The code iterates through each character ofsexactly once. For each character, the operations performed—dictionary lookup (c in last), comparison, appending to the list, and updating the dictionary—all takeO(1)time on average. Therefore, the overall time complexity is linear with respect to the length of the string. -
Space Complexity:
O(|\Sigma|), where|\Sigma|is the size of the character set. Thelastdictionary stores at most one entry per distinct character, so its size is bounded by the number of unique characters. In this problem, the character set consists of lowercase English letters, so|\Sigma|is a constant (at most 26). Note that although theanslist can grow up toO(n), it serves as the output, so the auxiliary space attributable to the dictionary isO(|\Sigma|).
Pattern Learn more about how to find time and space complexity quickly.
Common Pitfalls
Pitfall 1: Comparing against the original string index instead of the result position
The most common mistake is computing the "distance" using the character's index in the original string s rather than its position in the result being built. Because merges remove characters, positions shift left as you go, so the meaningful distance is the one measured in the current (already-merged) string.
Incorrect approach:
class Solution:
def mergeCharacters(self, s: str, k: int) -> str:
last_index = {}
result = []
for i, char in enumerate(s): # uses original index i
if char in last_index and i - last_index[char] <= k:
continue
result.append(char)
last_index[char] = i # stores original index, not result index
return "".join(result)
For s = "aabba", k = 1, this records the final a as being at original index 4 versus 0, giving distance 4 > k, so it keeps it — which happens to match here. But consider cases where earlier merges remove several characters: the original-index gap will be larger than the true gap in the current string, causing characters that should merge to survive incorrectly.
Solution: Always use current_pos = len(result) for the distance check, since the surviving characters' positions in result exactly mirror the indices in the current merged string.
current_pos = len(result)
if char in last_index and current_pos - last_index[char] <= k:
continue
result.append(char)
last_index[char] = current_pos
Pitfall 2: Using a strict inequality (<) instead of <=
The problem states two characters are close if the distance is at most k, i.e. distance <= k. Writing the condition as current_pos - last_index[char] < k is an off-by-one error that fails exactly when the gap equals k.
For s = "aba", k = 2: the second a would land at result position 2, and 2 - 0 = 2 == k, so it must merge. A strict < keeps it and returns "aba" instead of the correct "ab".
Solution: Use <= to include the boundary case where the distance is exactly k.
Pitfall 3: Forgetting to update last_index after keeping a character
If you only set last_index[char] the first time a character appears (or never update it after subsequent keeps), later comparisons reference a stale position. Each time a character survives, its recorded position must be refreshed to the position it now occupies in the result.
Solution: Update last_index[char] = current_pos on every keep, not just the first occurrence — this guarantees future distance checks reflect the most recent surviving copy.
Pitfall 4: Not removing a character's record when it merges away (a conceptual non-issue here, but worth noting)
One might worry that when a character merges away, its last_index should change. It should not — the character merging away leaves the earlier surviving copy intact, and that earlier copy's position is exactly what stays in last_index. Skipping the update (via continue) is correct precisely because the merged-away character contributes nothing to the result and the reference position must remain the surviving copy's position.
Solution: Use continue to skip both the append and the last_index update when a merge occurs, preserving the surviving copy's recorded position.
Ready to land your dream job?
Unlock your dream job with a 5-minute quiz for a personalized study roadmap!
Get My RoadmapWhat is an advantages of top-down dynamic programming vs bottom-up dynamic programming?
Recommended Readings
Coding Interview Patterns Your Personal Dijkstra's Algorithm to Landing Your Dream Job The goal of AlgoMonster is to help you get a job in the shortest amount of time possible in a data driven way We compiled datasets of tech interview problems and broke them down by patterns This way
Recursion If you prefer videos here's a video that explains recursion in a fun and easy way Recursion is one of the most important concepts in computer science Simply speaking recursion is the process of a function calling itself Using a real life analogy imagine a scenario where you invite your friends to lunch https assets algo monster recursion jpg You first call Ben and ask him
Runtime Overview When learning about algorithms and data structures you'll frequently encounter the term time complexity This concept is fundamental in computer science and offers insights into how long an algorithm takes to complete given a certain input size What is Time Complexity Time complexity describes how the time needed
Want a Structured Path to Master System Design Too? Don’t Miss This!