Facebook Pixel

3853. Merge Close Characters

MediumHash TableString
LeetCode ↗

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 a at index 0.
  • The next a would land at index 1, and 1 - 0 = 1 <= k, so it merges away.
  • Place b at index 1.
  • The next b would land at index 2, and 2 - 1 = 1 <= k, so it merges away.
  • The final a would land at index 2, but a was last at index 0, and 2 - 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.

Quick Interview Experience
Help others by sharing your interview experience
Have you seen this problem before?

How We Pick the Algorithm

Why Hash Table / Counting?

This problem maps to Hash Table / Counting through a short path in the full flowchart.

Fastlookup orcounting?yesLinkedlist?noHash Table /Counting

Hash table tracks last position of each character to determine merge eligibility.

Open in Flowchart

Intuition

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:

  1. 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.
  2. Iterate over each character c in s.

    • 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.
  3. Decide whether the character merges away or survives.

    • Check the condition c in last and cur - last[c] <= k.
      • c in last confirms that an equal character already exists in the result.
      • cur - last[c] <= k confirms that the previous equal character is close — within distance k of the current position.
    • If both hold, the character is absorbed by the earlier one, so we continue and skip it entirely.
  4. Keep the character when it cannot merge.

    • If the condition fails (the character is new or too far away), we append it to ans and update last[c] = cur to record its new latest position. Future characters will compare against this updated position.
  5. Build and return the result.

    • After the loop finishes, ans holds all surviving characters in order. We return "".join(ans) as the final merged string.

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' in last? No.
  • The character survives. Append 'a' to ans and set last['a'] = 0.
  • State: ans = ['a'], last = {'a': 0}.

Step 2 — Process 'b'

  • cur = len(ans) = 1.
  • Check: is 'b' in last? No.
  • The character survives. Append 'b' and set last['b'] = 1.
  • State: ans = ['a', 'b'], last = {'a': 0, 'b': 1}.

Step 3 — Process 'c'

  • cur = len(ans) = 2.
  • Check: is 'c' in last? No.
  • The character survives. Append 'c' and set last['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' in last? Yes, at position 0. Is cur - last['a'] = 3 - 0 = 3 <= k = 2? No.
  • The previous 'a' is too far away, so this character survives. Append 'a' and update last['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' in last? Yes, at position 1. Is cur - last['b'] = 4 - 1 = 3 <= k = 2? No.
  • Too far away, so it survives. Append 'b' and update last['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' in last? Yes, at position 3. Is cur - last['a'] = 5 - 3 = 2 <= k = 2? Yes.
  • This 'a' is close to the earlier one, so it merges away. We continue and skip it.
  • State unchanged: ans = ['a', 'b', 'c', 'a', 'b'], last = {'a': 3, 'b': 4, 'c': 2}.

Result

  • Join ans to 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)
23
1class 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}
29
1class 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};
27
1/**
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}
39

Time and Space Complexity

  • Time Complexity: O(n), where n is the length of the string s. The code iterates through each character of s exactly once. For each character, the operations performed—dictionary lookup (c in last), comparison, appending to the list, and updating the dictionary—all take O(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. The last dictionary 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 the ans list can grow up to O(n), it serves as the output, so the auxiliary space attributable to the dictionary is O(|\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 Roadmap
Discover Your Strengths and Weaknesses: Take Our 5-Minute Quiz to Get a Personalized Study Roadmap:

What is an advantages of top-down dynamic programming vs bottom-up dynamic programming?


Recommended Readings

Want a Structured Path to Master System Design Too? Don’t Miss This!

Load More