2957. Remove Adjacent Almost-Equal Characters
Problem Description
You are given a string word
that is 0-indexed (meaning the first character is at index 0).
In this problem, you can perform operations on the string. In each operation, you can:
- Pick any index
i
in the string - Change the character at position
i
to any lowercase English letter
Your goal is to find the minimum number of operations needed to ensure there are no adjacent characters in the string that are "almost-equal".
Two characters a
and b
are considered almost-equal if:
- They are the same character (
a == b
), OR - They are adjacent letters in the alphabet (for example, 'a' and 'b' are adjacent, 'x' and 'y' are adjacent)
For example:
- 'a' and 'a' are almost-equal (same character)
- 'a' and 'b' are almost-equal (adjacent in alphabet)
- 'm' and 'n' are almost-equal (adjacent in alphabet)
- 'a' and 'c' are NOT almost-equal (not adjacent in alphabet)
The solution uses a greedy approach: it traverses the string from left to right, and whenever it finds two adjacent characters that are almost-equal, it counts one operation (to change one of them) and skips the next character to avoid unnecessary operations. This works because changing one character in a pair of almost-equal adjacent characters is sufficient to break the "almost-equal" relationship.
Intuition
When we encounter two adjacent characters that are almost-equal, we need to change one of them. The key insight is: which character should we change?
Let's think about this with an example. Suppose we have the string "aabc":
- At index 0 and 1, we have 'a' and 'a' which are almost-equal
- We could change either the first 'a' or the second 'a'
If we change the character at index 0, we only fix the relationship between indices 0 and 1. But if we change the character at index 1, we fix the relationship between indices 0 and 1, AND we also prevent any potential issues between indices 1 and 2.
This leads us to a greedy strategy: when we find two adjacent almost-equal characters at positions i-1
and i
, we should change the character at position i
. Why? Because:
- Changing
word[i]
fixes the almost-equal relationship betweenword[i-1]
andword[i]
- We can choose a character for
word[i]
that is also different fromword[i+1]
, potentially preventing another operation - After changing
word[i]
, we can safely skip checkingword[i+1]
againstword[i]
because we've already ensured they won't be almost-equal
This is why the solution increments i
by 2 (skipping the next character) after finding an almost-equal pair - we've already handled that position optimally. If no almost-equal pair is found, we simply move to the next character by incrementing i
by 1.
The condition abs(ord(word[i]) - ord(word[i - 1])) < 2
checks if two characters are almost-equal by computing the absolute difference of their ASCII values. If this difference is 0 (same character) or 1 (adjacent in alphabet), the characters are almost-equal.
Learn more about Greedy and Dynamic Programming patterns.
Solution Approach
The solution implements a greedy algorithm that traverses the string from left to right, making local optimal decisions to achieve the global minimum number of operations.
Algorithm Steps:
-
Initialize variables:
ans = 0
to count the number of operations neededi = 1
to start checking from the second character (index 1)n = len(word)
to store the length of the string
-
Traverse the string using a while loop
while i < n
:a. Check if characters are almost-equal:
- Calculate
abs(ord(word[i]) - ord(word[i - 1]))
- If this value is less than 2, the characters are almost-equal
- The ASCII difference being 0 means same character, being 1 means adjacent in alphabet
b. If almost-equal characters are found:
- Increment
ans
by 1 (we need one operation to changeword[i]
) - Skip the next position by setting
i += 2
- This skip is crucial: since we're changing
word[i]
, we can choose a character that's not almost-equal toword[i+1]
, so no need to checkword[i+1]
against the changedword[i]
c. If characters are not almost-equal:
- Simply move to the next position with
i += 1
- Continue checking the next pair
- Calculate
-
Return the result: Return
ans
which contains the minimum number of operations
Why this greedy approach works:
When we find an almost-equal pair at positions (i-1, i)
, we optimally handle it by:
- Counting one operation to change
word[i]
- Skipping position
i+1
because we can smartly choose the replacement character at positioni
to avoid conflicts withword[i+1]
This ensures we never use more operations than necessary, as we handle each conflict optimally and avoid creating new conflicts through our character choices.
Ready to land your dream job?
Unlock your dream job with a 5-minute evaluator for a personalized learning plan!
Start EvaluatorExample Walkthrough
Let's walk through the algorithm with the string word = "aabc"
:
Initial state:
ans = 0
(no operations yet)i = 1
(start checking from index 1)n = 4
(length of string)
Step 1: Check index 1 (comparing 'a' at index 0 with 'a' at index 1)
- Calculate:
abs(ord('a') - ord('a')) = 0
- Since 0 < 2, these characters are almost-equal (same character)
- Increment
ans
to 1 (we need to change the 'a' at index 1) - Skip the next position:
i = i + 2 = 3
Step 2: Check index 3 (comparing 'b' at index 2 with 'c' at index 3)
- Calculate:
abs(ord('c') - ord('b')) = 1
- Since 1 < 2, these characters are almost-equal (adjacent in alphabet)
- Increment
ans
to 2 (we need to change the 'c' at index 3) - Skip the next position:
i = i + 2 = 5
Step 3: Loop terminates
- Since
i = 5
is not less thann = 4
, we exit the loop - Return
ans = 2
Result: We need 2 operations to remove all almost-equal adjacent pairs.
The transformed string could be "axbd" where:
- We changed index 1 from 'a' to 'x' (fixing the 'a'-'a' pair)
- We changed index 3 from 'c' to 'd' (fixing the 'b'-'c' pair)
Note how after each change, we skip checking the next character because we can choose our replacement character wisely to avoid creating new almost-equal pairs.
Solution Implementation
1class Solution:
2 def removeAlmostEqualCharacters(self, word: str) -> int:
3 # Initialize counter for minimum operations needed
4 operations_count = 0
5
6 # Start from index 1 to compare with previous character
7 index = 1
8 word_length = len(word)
9
10 # Iterate through the string
11 while index < word_length:
12 # Check if current and previous characters are almost equal
13 # (same character or adjacent in alphabet)
14 if abs(ord(word[index]) - ord(word[index - 1])) < 2:
15 # Found almost equal characters, need one operation
16 operations_count += 1
17 # Skip the next character since we're modifying current position
18 # This ensures we don't create new conflicts
19 index += 2
20 else:
21 # Characters are sufficiently different, move to next position
22 index += 1
23
24 return operations_count
25
1class Solution {
2 public int removeAlmostEqualCharacters(String word) {
3 int operationCount = 0;
4 int length = word.length();
5
6 // Iterate through the string starting from index 1
7 for (int i = 1; i < length; i++) {
8 // Check if current character and previous character are "almost equal"
9 // Two characters are almost equal if they are the same or adjacent in ASCII
10 // (difference is 0 or 1)
11 if (Math.abs(word.charAt(i) - word.charAt(i - 1)) < 2) {
12 // Found a pair of almost equal characters
13 // Increment operation count (one character needs to be changed)
14 operationCount++;
15
16 // Skip the next character to avoid overlapping operations
17 // (changing one character affects two adjacent pairs)
18 i++;
19 }
20 }
21
22 return operationCount;
23 }
24}
25
1class Solution {
2public:
3 int removeAlmostEqualCharacters(string word) {
4 int operationCount = 0; // Count of operations needed
5 int n = word.size(); // Length of the input string
6
7 // Iterate through the string starting from index 1
8 for (int i = 1; i < n; ++i) {
9 // Check if current character and previous character are "almost equal"
10 // Two characters are almost equal if they are the same or adjacent in alphabet
11 // abs(diff) < 2 means diff is 0 or 1 (same char or adjacent chars)
12 if (abs(word[i] - word[i - 1]) < 2) {
13 ++operationCount; // Increment operation count to change this character
14 ++i; // Skip the next character to avoid consecutive operations
15 }
16 }
17
18 return operationCount;
19 }
20};
21
1/**
2 * Removes almost equal characters from a string by counting the minimum number
3 * of operations needed to ensure no two adjacent characters are almost equal.
4 * Two characters are considered almost equal if their ASCII values differ by at most 1.
5 *
6 * @param word - The input string to process
7 * @returns The minimum number of operations needed
8 */
9function removeAlmostEqualCharacters(word: string): number {
10 // Initialize counter for minimum operations needed
11 let operationCount: number = 0;
12
13 // Iterate through the string starting from the second character
14 for (let i: number = 1; i < word.length; ++i) {
15 // Check if current character and previous character are almost equal
16 // (their ASCII values differ by less than 2)
17 if (Math.abs(word.charCodeAt(i) - word.charCodeAt(i - 1)) < 2) {
18 // Increment operation count as we need to change one character
19 ++operationCount;
20
21 // Skip the next character to avoid counting overlapping pairs
22 // This greedy approach ensures we handle the conflict optimally
23 ++i;
24 }
25 }
26
27 // Return the total number of operations needed
28 return operationCount;
29}
30
Time and Space Complexity
The time complexity is O(n)
, where n
is the length of the string word
. The algorithm iterates through the string once using a while loop. In the worst case, it visits each character exactly once (when no adjacent characters are almost equal). In the best case, it might skip some characters when i += 2
is executed, but this still results in linear time complexity since each character is processed at most once.
The space complexity is O(1)
. The algorithm only uses a constant amount of extra space for the variables ans
, i
, and n
, regardless of the input size. No additional data structures that scale with the input are created.
Learn more about how to find time and space complexity quickly.
Common Pitfalls
Pitfall 1: Using a For Loop Instead of While Loop
The Problem: Many developers instinctively reach for a for loop when iterating through a string:
# INCORRECT APPROACH
def removeAlmostEqualCharacters(self, word: str) -> int:
operations_count = 0
for i in range(1, len(word)):
if abs(ord(word[i]) - ord(word[i - 1])) < 2:
operations_count += 1
# Can't skip the next iteration in a for loop!
return operations_count
This fails because when we find almost-equal characters, we need to skip the next position (jump by 2), but a for loop will always increment by 1. This leads to overcounting operations.
Example where this fails:
- Input:
"aaaa"
- Incorrect for loop result: 3 operations (counts at indices 1, 2, 3)
- Correct result: 2 operations (handles indices 1 and 3, skipping 2)
The Solution: Use a while loop with manual index control:
index = 1
while index < word_length:
if abs(ord(word[index]) - ord(word[index - 1])) < 2:
operations_count += 1
index += 2 # Skip next position
else:
index += 1
Pitfall 2: Misunderstanding the "Almost-Equal" Definition
The Problem: Developers might incorrectly implement the almost-equal check:
# INCORRECT - only checks for same characters
if word[i] == word[i - 1]:
operations_count += 1
# INCORRECT - wrong boundary for adjacent characters
if abs(ord(word[i]) - ord(word[i - 1])) <= 2: # Should be < 2, not <= 2
operations_count += 1
The second mistake would incorrectly consider 'a' and 'c' as almost-equal (difference of 2), when they should not be.
The Solution: Remember that almost-equal means:
- Difference of 0 (same character)
- Difference of 1 (adjacent in alphabet)
Therefore, use < 2
not <= 2
:
if abs(ord(word[index]) - ord(word[index - 1])) < 2:
Pitfall 3: Starting from Wrong Index
The Problem: Starting iteration from index 0:
# INCORRECT
index = 0
while index < word_length:
if abs(ord(word[index]) - ord(word[index - 1])) < 2: # word[-1] is wrong!
This causes an index error or incorrect behavior when accessing word[index - 1]
at index 0.
The Solution: Always start from index 1 since we need to compare with the previous character:
index = 1 # Start from second character while index < word_length:
Which algorithm should you use to find a node that is close to the root of the tree?
Recommended Readings
Greedy Introduction div class responsive iframe iframe src https www youtube com embed WTslqPbj7I title YouTube video player frameborder 0 allow accelerometer autoplay clipboard write encrypted media gyroscope picture in picture web share allowfullscreen iframe div When do we use greedy Greedy algorithms tend to solve optimization problems Typically they will ask you to calculate the max min of some value Commonly you may see this phrased in the problem as max min longest shortest largest smallest etc These keywords can be identified by just scanning
What is Dynamic Programming Prerequisite DFS problems dfs_intro Backtracking problems backtracking Memoization problems memoization_intro Pruning problems backtracking_pruning Dynamic programming is an algorithmic optimization technique that breaks down a complicated problem into smaller overlapping sub problems in a recursive manner and uses solutions to the sub problems to construct a solution
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
Want a Structured Path to Master System Design Too? Don’t Miss This!