3106. Lexicographically Smallest String After Operations With Constraint
Problem Description
You are given a string s
consisting of lowercase English letters and an integer k
.
The problem defines a distance function between two strings of the same length. For two strings s1
and s2
of length n
, the distance is calculated as the sum of minimum distances between corresponding characters at each position i
(from 0 to n-1).
The key point is that the alphabet is treated as cyclic - meaning after 'z' comes 'a' again. So for any two characters, you can calculate the distance in two ways:
- Going forward in the alphabet
- Going backward (wrapping around)
The minimum of these two distances is used.
For example:
distance("ab", "cd") = 4
because 'a' to 'c' has distance 2, and 'b' to 'd' has distance 2, totaling 4distance("a", "z") = 1
because you can go backward from 'a' to 'z' in just 1 step
Your task is to transform the string s
by changing any of its characters to any other lowercase letter (you can make multiple changes). The goal is to find the lexicographically smallest possible string t
such that the distance between the original string s
and the new string t
is at most k
.
A lexicographically smaller string means it would appear earlier in dictionary order - for instance, "abc" is lexicographically smaller than "abd".
You need to return this lexicographically smallest string that can be obtained with a total distance cost not exceeding k
.
Intuition
To get the lexicographically smallest string, we should try to make characters as small as possible, starting from the leftmost position. This is because the leftmost characters have the highest priority in lexicographical ordering - changing the first character from 'c' to 'a' makes the string smaller than changing any later character.
The greedy approach works here: for each position from left to right, we want to change the current character to the smallest possible character we can afford with our remaining budget k
.
For each character in the original string, we check all characters that are smaller than it (from 'a' up to but not including the current character). Why only smaller characters? Because we want the lexicographically smallest result, and changing to a character that's equal or larger wouldn't help achieve that goal.
When considering changing character c1
to a smaller character c2
, we need to calculate the cost. Due to the cyclic nature of the alphabet, there are two possible paths:
- Direct path:
ord(c1) - ord(c2)
(going backward in the alphabet) - Wrap-around path:
26 - ord(c1) + ord(c2)
(going forward and wrapping around)
We take the minimum of these two as our actual cost d
.
If we can afford this change (i.e., d <= k
), we make it immediately, deduct the cost from our budget, and move to the next position. We don't need to consider other characters for this position because we've already found the smallest character we can change to within our budget.
This greedy strategy ensures we get the lexicographically smallest result because we're maximizing the "smallness" of earlier positions, which have more weight in lexicographical comparison.
Learn more about Greedy patterns.
Solution Approach
The implementation follows a greedy traversal approach where we process each character position from left to right:
-
Convert string to list: First, we convert the input string
s
to a listcs = list(s)
to make it mutable, since strings in Python are immutable and we need to modify characters. -
Iterate through each position: We use
enumerate(s)
to get both the indexi
and characterc1
at each position. -
Try smaller characters: For each position, we iterate through all lowercase letters using
ascii_lowercase
(which gives 'a' to 'z'). We only consider charactersc2
that are smaller than the current characterc1
:- The condition
if c2 >= c1: break
stops the iteration once we reach characters that are not smaller thanc1
- The condition
-
Calculate minimum distance: For each candidate character
c2
that is smaller thanc1
, we calculate the minimum cyclic distance:d = min(ord(c1) - ord(c2), 26 - ord(c1) + ord(c2))
ord(c1) - ord(c2)
: Direct backward distance26 - ord(c1) + ord(c2)
: Forward distance with wrap-around
-
Make the change if affordable: If the distance
d
is within our budget (d <= k
):- Update the character at position
i
:cs[i] = c2
- Deduct the cost from our budget:
k -= d
- Break from the inner loop to move to the next position
- Update the character at position
-
Return the result: After processing all positions, we join the list back into a string using
"".join(cs)
and return it.
The algorithm ensures optimality because it greedily makes each position as small as possible from left to right, using the available budget efficiently. The time complexity is O(26n) where n is the length of the string, since for each character we potentially check up to 26 lowercase letters.
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 solution with s = "zbbz"
and k = 3
.
Initial state: cs = ['z', 'b', 'b', 'z']
, k = 3
Position 0 (character 'z'):
- We try to change 'z' to smaller characters starting from 'a'
- For 'z' → 'a':
- Direct backward distance:
ord('z') - ord('a') = 25
- Forward wrap-around distance:
26 - ord('z') + ord('a') = 26 - 25 + 0 = 1
- Minimum distance:
min(25, 1) = 1
- Since
1 ≤ 3
, we can make this change!
- Direct backward distance:
- Update:
cs[0] = 'a'
,k = 3 - 1 = 2
- Result so far:
['a', 'b', 'b', 'z']
Position 1 (character 'b'):
- We try to change 'b' to 'a' (the only smaller character)
- For 'b' → 'a':
- Direct backward distance:
ord('b') - ord('a') = 1
- Forward wrap-around distance:
26 - ord('b') + ord('a') = 26 - 1 + 0 = 25
- Minimum distance:
min(1, 25) = 1
- Since
1 ≤ 2
, we can make this change!
- Direct backward distance:
- Update:
cs[1] = 'a'
,k = 2 - 1 = 1
- Result so far:
['a', 'a', 'b', 'z']
Position 2 (character 'b'):
- We try to change 'b' to 'a'
- For 'b' → 'a':
- Minimum distance:
1
(as calculated above) - Since
1 ≤ 1
, we can make this change!
- Minimum distance:
- Update:
cs[2] = 'a'
,k = 1 - 1 = 0
- Result so far:
['a', 'a', 'a', 'z']
Position 3 (character 'z'):
- We have
k = 0
, so we cannot make any more changes - The character remains 'z'
Final result: "aaaz"
The algorithm successfully transformed "zbbz" into "aaaz" using exactly 3 units of distance, creating the lexicographically smallest possible string within the budget.
Solution Implementation
1class Solution:
2 def getSmallestString(self, s: str, k: int) -> str:
3 """
4 Returns the lexicographically smallest string by replacing characters in s
5 with a total distance cost not exceeding k.
6
7 Args:
8 s: Input string to modify
9 k: Maximum total distance allowed for all character replacements
10
11 Returns:
12 The lexicographically smallest possible string
13 """
14 # Import ascii_lowercase for iterating through 'a' to 'z'
15 from string import ascii_lowercase
16
17 # Convert string to list for in-place modifications
18 char_list = list(s)
19
20 # Process each character position from left to right
21 for i, original_char in enumerate(s):
22 # Try each possible replacement character from 'a' to 'z'
23 for replacement_char in ascii_lowercase:
24 # Skip if replacement is not lexicographically smaller
25 if replacement_char >= original_char:
26 break
27
28 # Calculate minimum distance between characters (considering wrap-around)
29 # Distance can be direct (c1 - c2) or wrap-around (26 - c1 + c2)
30 direct_distance = ord(original_char) - ord(replacement_char)
31 wrap_distance = 26 - ord(original_char) + ord(replacement_char)
32 min_distance = min(direct_distance, wrap_distance)
33
34 # If we have enough budget, make the replacement
35 if min_distance <= k:
36 char_list[i] = replacement_char
37 k -= min_distance
38 break
39
40 # Convert list back to string and return
41 return "".join(char_list)
42
1class Solution {
2 public String getSmallestString(String s, int k) {
3 // Convert string to character array for in-place modification
4 char[] characters = s.toCharArray();
5
6 // Process each character from left to right
7 for (int i = 0; i < characters.length; ++i) {
8 char originalChar = characters[i];
9
10 // Try each character from 'a' up to (but not including) the original character
11 for (char targetChar = 'a'; targetChar < originalChar; ++targetChar) {
12 // Calculate the minimum distance between two characters
13 // Distance can be either direct (c1 - c2) or wrap around (26 - c1 + c2)
14 int distance = Math.min(
15 originalChar - targetChar, // Direct distance
16 26 - originalChar + targetChar // Wrap-around distance
17 );
18
19 // If we have enough operations remaining to make this change
20 if (distance <= k) {
21 // Replace the character with the smaller one
22 characters[i] = targetChar;
23 // Deduct the used operations from remaining count
24 k -= distance;
25 // Move to the next character position
26 break;
27 }
28 }
29 }
30
31 // Convert the modified character array back to string
32 return new String(characters);
33 }
34}
35
1class Solution {
2public:
3 string getSmallestString(string s, int k) {
4 // Iterate through each character in the string
5 for (int i = 0; i < s.size(); ++i) {
6 char currentChar = s[i];
7
8 // Try to replace current character with a lexicographically smaller one
9 // Starting from 'a' up to (but not including) the current character
10 for (char targetChar = 'a'; targetChar < currentChar; ++targetChar) {
11 // Calculate the minimum distance to change currentChar to targetChar
12 // Two possible paths: direct distance or wrap around through 'z' to 'a'
13 int directDistance = currentChar - targetChar;
14 int wrapAroundDistance = 26 - currentChar + targetChar;
15 int minDistance = min(directDistance, wrapAroundDistance);
16
17 // If we have enough operations remaining, make the replacement
18 if (minDistance <= k) {
19 s[i] = targetChar;
20 k -= minDistance;
21 break; // Move to the next character in the string
22 }
23 }
24 }
25
26 return s;
27 }
28};
29
1/**
2 * Finds the lexicographically smallest string by replacing characters in the input string
3 * with a total distance of at most k
4 * @param s - The input string to modify
5 * @param k - The maximum total distance allowed for all character replacements
6 * @returns The lexicographically smallest possible string
7 */
8function getSmallestString(s: string, k: number): string {
9 // Convert string to character array for easier manipulation
10 const charArray: string[] = s.split('');
11
12 // Iterate through each character position in the string
13 for (let i = 0; i < s.length; ++i) {
14 // Get ASCII code of current character
15 const currentCharCode = s[i].charCodeAt(0);
16
17 // Try each possible lowercase letter starting from 'a' (ASCII 97)
18 // Stop before reaching the current character
19 for (let targetCharCode = 97; targetCharCode < currentCharCode; ++targetCharCode) {
20 // Calculate the minimum distance to transform current character to target
21 // Distance can be calculated in two ways:
22 // 1. Direct distance: currentCharCode - targetCharCode
23 // 2. Wrap-around distance: go backward from current char to 'z', then from 'a' to target
24 const directDistance = currentCharCode - targetCharCode;
25 const wrapAroundDistance = 26 - currentCharCode + targetCharCode;
26 const minDistance = Math.min(directDistance, wrapAroundDistance);
27
28 // If we have enough remaining k to make this transformation
29 if (minDistance <= k) {
30 // Replace the character with the smaller one
31 charArray[i] = String.fromCharCode(targetCharCode);
32 // Decrease k by the distance used
33 k -= minDistance;
34 // Move to the next character position
35 break;
36 }
37 }
38 }
39
40 // Join the character array back into a string
41 return charArray.join('');
42}
43
Time and Space Complexity
Time Complexity: O(n × |Σ|)
where n
is the length of the string s
and |Σ|
is the size of the character set (at most 26 for lowercase English letters).
The algorithm iterates through each character in the string s
(outer loop runs n
times). For each character position, it iterates through the lowercase alphabet checking for potential replacements (inner loop runs at most |Σ| = 26
times). The inner loop breaks as soon as it finds a valid replacement or when c2 >= c1
, but in the worst case, it may check all 26 letters. All operations inside the nested loops (comparisons, arithmetic operations, and character replacement) take O(1)
time.
Space Complexity: O(n)
where n
is the length of the string s
.
The algorithm creates a list cs
from the input string s
, which requires O(n)
space to store all characters. The variable ascii_lowercase
is a constant string of 26 characters (O(1)
space), and other variables like i
, c1
, c2
, d
, and k
use O(1)
space. The final string construction with "".join(cs)
creates a new string of length n
, but this is typically considered part of the output rather than auxiliary space.
Learn more about how to find time and space complexity quickly.
Common Pitfalls
Pitfall 1: Not Considering All Possible Replacements
The Issue: The current implementation only tries to replace characters with lexicographically smaller ones. However, this misses opportunities where we might need to replace a character with a lexicographically larger one at an earlier position to save budget for more impactful changes at later positions.
Example: Consider s = "zzzzzz"
with k = 3
. The optimal answer is "aaazzz"
(changing first 3 'z's to 'a's, each costing 1 due to wrap-around). However, if we had a string like "lmnxyz"
with limited k, we might want to keep early characters unchanged or even change them to slightly larger characters if it helps us make more significant improvements to later positions.
The Real Issue: Actually, the main pitfall is that the code stops looking for replacements once it finds a character >= the original. This means it won't consider changing a character to itself (distance 0) or to a larger character that might have a small cyclic distance.
Corrected Solution:
class Solution:
def getSmallestString(self, s: str, k: int) -> str:
from string import ascii_lowercase
char_list = list(s)
for i, original_char in enumerate(s):
best_char = original_char
best_cost = 0
# Try all possible characters, not just smaller ones
for replacement_char in ascii_lowercase:
# Calculate minimum cyclic distance
diff = abs(ord(original_char) - ord(replacement_char))
min_distance = min(diff, 26 - diff)
# If this replacement is affordable and better
if min_distance <= k:
# Choose the lexicographically smallest character within budget
if replacement_char < best_char:
best_char = replacement_char
best_cost = min_distance
# Break early if we found 'a' (can't do better)
if best_char == 'a':
break
# Apply the best replacement found
if best_char != original_char:
char_list[i] = best_char
k -= best_cost
return "".join(char_list)
Pitfall 2: Incorrect Distance Calculation for Wrap-Around
The Issue: When calculating wrap-around distance, the formula 26 - ord(c1) + ord(c2)
assumes going from c1 forward to c2. This works when c2 < c1, but if we're considering all possible replacements, we need a more general formula.
Correct Approach: Use min(abs(ord(c1) - ord(c2)), 26 - abs(ord(c1) - ord(c2)))
which correctly handles all cases regardless of which character is larger.
What does the following code do?
1def f(arr1, arr2):
2 i, j = 0, 0
3 new_arr = []
4 while i < len(arr1) and j < len(arr2):
5 if arr1[i] < arr2[j]:
6 new_arr.append(arr1[i])
7 i += 1
8 else:
9 new_arr.append(arr2[j])
10 j += 1
11 new_arr.extend(arr1[i:])
12 new_arr.extend(arr2[j:])
13 return new_arr
14
1public static List<Integer> f(int[] arr1, int[] arr2) {
2 int i = 0, j = 0;
3 List<Integer> newArr = new ArrayList<>();
4
5 while (i < arr1.length && j < arr2.length) {
6 if (arr1[i] < arr2[j]) {
7 newArr.add(arr1[i]);
8 i++;
9 } else {
10 newArr.add(arr2[j]);
11 j++;
12 }
13 }
14
15 while (i < arr1.length) {
16 newArr.add(arr1[i]);
17 i++;
18 }
19
20 while (j < arr2.length) {
21 newArr.add(arr2[j]);
22 j++;
23 }
24
25 return newArr;
26}
27
1function f(arr1, arr2) {
2 let i = 0, j = 0;
3 let newArr = [];
4
5 while (i < arr1.length && j < arr2.length) {
6 if (arr1[i] < arr2[j]) {
7 newArr.push(arr1[i]);
8 i++;
9 } else {
10 newArr.push(arr2[j]);
11 j++;
12 }
13 }
14
15 while (i < arr1.length) {
16 newArr.push(arr1[i]);
17 i++;
18 }
19
20 while (j < arr2.length) {
21 newArr.push(arr2[j]);
22 j++;
23 }
24
25 return newArr;
26}
27
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
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 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
Want a Structured Path to Master System Design Too? Don’t Miss This!