555. Split Concatenated Strings π
Problem Description
You are given an array of strings strs
. Your task is to create a loop by concatenating these strings together, where you have the option to reverse any string before concatenation. After forming the loop, you need to find a cutting point that produces the lexicographically largest possible string.
The process involves two main steps:
-
Form a loop: Concatenate all strings from
strs
in their given order to form a circular arrangement. Before concatenating, you can choose to reverse any string or keep it as is. For example, ifstrs = ["abc", "xyz"]
, you could form loops like "abcxyz", "cbaxyz", "abczyx", or "cbazyx". -
Cut the loop: Choose any position in the loop as a breakpoint. This converts the circular string into a regular linear string starting from the cut position. For instance, if you have a loop "abcxyz" and cut between 'c' and 'x', you get "xyzabc".
Your goal is to find the combination of string reversals and cutting position that produces the lexicographically largest possible result among all valid configurations.
For example, with strs = ["abc", "xyz"]
:
- You could reverse "abc" to get "cba" and keep "xyz" to form loop "cbaxyz"
- Cutting at different positions gives different results: "cbaxyz", "baxyzc", "axyzcp", "xyzcba", "yzcbax", "zcbaxy"
- Among all possible combinations of reversals and cuts, you need to return the largest string
The output should be the lexicographically largest string achievable through this process.
Intuition
To find the lexicographically largest string, we need to make locally optimal choices at each step. Let's think about this problem systematically.
First, consider the strings we're working with. For each individual string in strs
, we have the choice to reverse it or not. Since we want the largest possible result, we should choose the version (original or reversed) that is lexicographically larger for most strings. This gives us a better starting point.
However, there's a catch. The string where we make the cut is special because it will be split into two parts. For the cut string, we might want to use the "worse" version if it allows us to position a better character at the beginning of our final result.
Let's break down the strategy:
-
Preprocessing: For all strings except the one we're cutting, we want the lexicographically larger version between the original and reversed. This maximizes the middle portion of our result.
-
Finding the cut position: We need to try every possible string as the cut position and every possible character within that string as the exact cut point. Why? Because the character immediately after the cut becomes the first character of our result, which has the highest impact on lexicographical ordering.
-
Handling the cut string: When we cut a string at position
j
, we get:- Suffix part
a = s[j:]
(this becomes the beginning of our result) - Prefix part
b = s[:j]
(this goes to the end of our result)
We need to consider both orientations of the cut string:
- Using the string as-is: result =
a + middle + b
- Using the reversed string: result =
b[::-1] + middle + a[::-1]
- Suffix part
The key insight is that we want to maximize the beginning of our final string. By trying all possible cut positions and both orientations of the cut string, we ensure we find the configuration that places the lexicographically largest possible substring at the beginning of our result.
This greedy approach works because lexicographical comparison prioritizes earlier characters - finding the best possible beginning is more important than optimizing later parts of the string.
Learn more about Greedy patterns.
Solution Approach
Let's walk through the implementation step by step:
Step 1: Optimize each string individually
strs = [s[::-1] if s[::-1] > s else s for s in strs]
We first preprocess the array by replacing each string with its lexicographically larger version (original or reversed). This ensures that for most positions, we're working with the best possible strings. The comparison s[::-1] > s
automatically compares strings lexicographically in Python.
Step 2: Initialize the answer
ans = ''.join(strs)
We start with a baseline answer by simply joining all the optimized strings. This represents one possible configuration without considering different cut positions.
Step 3: Try each string as the cut position
for i, s in enumerate(strs):
t = ''.join(strs[i + 1 :]) + ''.join(strs[:i])
For each string at position i
, we treat it as the string that will be cut. We construct t
, which represents all the other strings in order:
strs[i + 1:]
- all strings after positioni
strs[:i]
- all strings before positioni
This t
forms the middle portion of our result (the part that won't be split).
Step 4: Try each character position within the cut string
for j in range(len(s)):
a = s[j:] # suffix starting from position j
b = s[:j] # prefix up to position j
Within the selected string s
, we try cutting at each position j
. This splits s
into:
- Suffix
a
: becomes the beginning of our result - Prefix
b
: goes to the end of our result
Step 5: Consider both orientations
ans = max(ans, a + t + b)
ans = max(ans, b[::-1] + t + a[::-1])
For each cut position, we consider two cases:
- Using the current orientation:
a + t + b
- This uses the string
s
as it currently is (already optimized in Step 1)
- This uses the string
- Using the opposite orientation:
b[::-1] + t + a[::-1]
- This effectively uses the reverse of string
s
- When we reverse
s
, the prefixb
becomesa[::-1]
and the suffixa
becomesb[::-1]
- This effectively uses the reverse of string
We update ans
with the maximum of these possibilities using Python's built-in max()
function, which compares strings lexicographically.
Time Complexity: O(n * m^2)
where n
is the number of strings and m
is the average length of strings. We iterate through each string (O(n)
), and for each string, we try each cutting position (O(m)
) and perform string concatenations (O(m)
).
Space Complexity: O(n * m)
for storing the concatenated strings and temporary results.
Ready to land your dream job?
Unlock your dream job with a 3-minute evaluator for a personalized learning plan!
Start EvaluatorExample Walkthrough
Let's walk through a concrete example with strs = ["abc", "xyz"]
.
Step 1: Optimize each string
- For "abc": Compare "abc" vs "cba" β "cba" > "abc", so use "cba"
- For "xyz": Compare "xyz" vs "zyx" β "zyx" > "xyz", so use "zyx"
- Now
strs = ["cba", "zyx"]
Step 2: Initialize baseline answer
ans = "cbazyx"
Step 3-5: Try all cut positions
Cut in first string "cba":
i = 0
,s = "cba"
,t = "zyx"
(remaining strings)- Cut at j=0:
a = "cba"
,b = ""
- Current orientation: "cba" + "zyx" + "" = "cbazyx"
- Opposite orientation: "" + "zyx" + "abc" = "zyxabc"
- Update:
ans = max("cbazyx", "cbazyx", "zyxabc") = "zyxabc"
- Cut at j=1:
a = "ba"
,b = "c"
- Current orientation: "ba" + "zyx" + "c" = "bazyxc"
- Opposite orientation: "c" + "zyx" + "ab" = "czyxab"
- Keep:
ans = "zyxabc"
(still largest)
- Cut at j=2:
a = "a"
,b = "cb"
- Current orientation: "a" + "zyx" + "cb" = "azyxcb"
- Opposite orientation: "bc" + "zyx" + "a" = "bczyxa"
- Keep:
ans = "zyxabc"
- Cut at j=0:
Cut in second string "zyx":
i = 1
,s = "zyx"
,t = "cba"
(remaining strings)- Cut at j=0:
a = "zyx"
,b = ""
- Current orientation: "zyx" + "cba" + "" = "zyxcba"
- Opposite orientation: "" + "cba" + "xyz" = "cbaxyz"
- Update:
ans = max("zyxabc", "zyxcba", "cbaxyz") = "zyxcba"
- Cut at j=1:
a = "yx"
,b = "z"
- Current orientation: "yx" + "cba" + "z" = "yxcbaz"
- Opposite orientation: "z" + "cba" + "xy" = "zcbaxy"
- Keep:
ans = "zyxcba"
- Cut at j=2:
a = "x"
,b = "zy"
- Current orientation: "x" + "cba" + "zy" = "xcbazy"
- Opposite orientation: "yz" + "cba" + "x" = "yzcbax"
- Keep:
ans = "zyxcba"
- Cut at j=0:
Final Result: "zyxcba"
This example shows how the algorithm systematically explores all possible configurations. The winning configuration came from cutting the second string at position 0 with its current (optimized) orientation, which placed 'z' (the largest possible character) at the beginning of the result.
Solution Implementation
1class Solution:
2 def splitLoopedString(self, strs: List[str]) -> str:
3 # Step 1: For each string, choose the lexicographically larger between itself and its reverse
4 # This ensures we start with the best possible version of each string
5 optimized_strs = []
6 for string in strs:
7 reversed_string = string[::-1]
8 if reversed_string > string:
9 optimized_strs.append(reversed_string)
10 else:
11 optimized_strs.append(string)
12
13 # Initialize the answer with the concatenation of all optimized strings
14 max_result = ''.join(optimized_strs)
15
16 # Step 2: Try different cut points by considering each string position
17 for index, current_string in enumerate(optimized_strs):
18 # Build the middle part: strings after current + strings before current
19 # This represents the fixed part when we cut at position 'index'
20 after_current = ''.join(optimized_strs[index + 1:])
21 before_current = ''.join(optimized_strs[:index])
22 middle_part = after_current + before_current
23
24 # Step 3: For the current string, try all possible cut positions
25 for cut_position in range(len(current_string)):
26 # Split current string at position 'cut_position'
27 prefix = current_string[cut_position:] # Part that goes to the beginning
28 suffix = current_string[:cut_position] # Part that goes to the end
29
30 # Try configuration 1: Use the optimized version of current string
31 # Format: prefix + middle_part + suffix
32 candidate1 = prefix + middle_part + suffix
33 max_result = max(max_result, candidate1)
34
35 # Try configuration 2: Use the reverse of current string
36 # When reversed: suffix becomes prefix (reversed) and prefix becomes suffix (reversed)
37 candidate2 = suffix[::-1] + middle_part + prefix[::-1]
38 max_result = max(max_result, candidate2)
39
40 return max_result
41
1class Solution {
2 public String splitLoopedString(String[] strs) {
3 int arrayLength = strs.length;
4
5 // Step 1: For each string, keep the lexicographically larger between itself and its reverse
6 for (int i = 0; i < arrayLength; ++i) {
7 String original = strs[i];
8 String reversed = new StringBuilder(original).reverse().toString();
9
10 // If reversed string is lexicographically larger, replace the original
11 if (original.compareTo(reversed) < 0) {
12 strs[i] = reversed;
13 }
14 }
15
16 String maxResult = "";
17
18 // Step 2: Try each string as the "cut point" string
19 for (int cutIndex = 0; cutIndex < arrayLength; ++cutIndex) {
20 String currentString = strs[cutIndex];
21
22 // Build the middle part: concatenate all other strings in order
23 // Starting from the string after cutIndex, wrapping around to before cutIndex
24 StringBuilder middlePart = new StringBuilder();
25
26 // Append strings from cutIndex+1 to end
27 for (int j = cutIndex + 1; j < arrayLength; ++j) {
28 middlePart.append(strs[j]);
29 }
30
31 // Append strings from start to cutIndex-1
32 for (int j = 0; j < cutIndex; ++j) {
33 middlePart.append(strs[j]);
34 }
35
36 String middle = middlePart.toString();
37
38 // Step 3: Try all possible cut positions within the current string
39 for (int splitPos = 0; splitPos < currentString.length(); ++splitPos) {
40 // Split current string at position splitPos
41 String prefix = currentString.substring(splitPos); // Part after split
42 String suffix = currentString.substring(0, splitPos); // Part before split
43
44 // Option 1: Use the string as-is (prefix + middle + suffix)
45 String candidate1 = prefix + middle + suffix;
46 if (maxResult.compareTo(candidate1) < 0) {
47 maxResult = candidate1;
48 }
49
50 // Option 2: Use the reversed string
51 // When reversed, suffix becomes prefix (reversed) and prefix becomes suffix (reversed)
52 String candidate2 = new StringBuilder(suffix)
53 .reverse()
54 .append(middle)
55 .append(new StringBuilder(prefix).reverse().toString())
56 .toString();
57
58 if (maxResult.compareTo(candidate2) < 0) {
59 maxResult = candidate2;
60 }
61 }
62 }
63
64 return maxResult;
65 }
66}
67
1class Solution {
2public:
3 string splitLoopedString(vector<string>& strs) {
4 // Step 1: For each string, keep the lexicographically larger one between itself and its reverse
5 for (auto& str : strs) {
6 string reversed{str.rbegin(), str.rend()};
7 str = max(str, reversed);
8 }
9
10 int n = strs.size();
11 string result = "";
12
13 // Step 2: Try cutting at each string position
14 for (int i = 0; i < n; ++i) {
15 auto& currentStr = strs[i];
16
17 // Build the middle part: concatenate all other strings in order (i+1 to n-1, then 0 to i-1)
18 string middlePart;
19 for (int j = i + 1; j < n; ++j) {
20 middlePart += strs[j];
21 }
22 for (int j = 0; j < i; ++j) {
23 middlePart += strs[j];
24 }
25
26 // Step 3: Try all possible cut positions within the current string
27 for (int cutPos = 0; cutPos < currentStr.size(); ++cutPos) {
28 // Case 1: Use the current string as is, cut at position cutPos
29 string prefix = currentStr.substr(cutPos); // Part after cut point
30 string suffix = currentStr.substr(0, cutPos); // Part before cut point
31 string candidate = prefix + middlePart + suffix;
32
33 // Update result if we found a lexicographically larger string
34 if (result < candidate) {
35 result = candidate;
36 }
37
38 // Case 2: Use the reverse of current string, cut at the same position
39 reverse(prefix.begin(), prefix.end());
40 reverse(suffix.begin(), suffix.end());
41 candidate = suffix + middlePart + prefix;
42
43 // Update result if we found a lexicographically larger string
44 if (result < candidate) {
45 result = candidate;
46 }
47 }
48 }
49
50 return result;
51 }
52};
53
1function splitLoopedString(strs: string[]): string {
2 // Step 1: For each string, keep the lexicographically larger one between itself and its reverse
3 for (let i = 0; i < strs.length; i++) {
4 const reversed = strs[i].split('').reverse().join('');
5 strs[i] = strs[i] > reversed ? strs[i] : reversed;
6 }
7
8 const n = strs.length;
9 let result = "";
10
11 // Step 2: Try cutting at each string position
12 for (let i = 0; i < n; i++) {
13 const currentStr = strs[i];
14
15 // Build the middle part: concatenate all other strings in order (i+1 to n-1, then 0 to i-1)
16 let middlePart = "";
17 for (let j = i + 1; j < n; j++) {
18 middlePart += strs[j];
19 }
20 for (let j = 0; j < i; j++) {
21 middlePart += strs[j];
22 }
23
24 // Step 3: Try all possible cut positions within the current string
25 for (let cutPos = 0; cutPos < currentStr.length; cutPos++) {
26 // Case 1: Use the current string as is, cut at position cutPos
27 let prefix = currentStr.substring(cutPos); // Part after cut point
28 let suffix = currentStr.substring(0, cutPos); // Part before cut point
29 let candidate = prefix + middlePart + suffix;
30
31 // Update result if we found a lexicographically larger string
32 if (result < candidate) {
33 result = candidate;
34 }
35
36 // Case 2: Use the reverse of current string, cut at the same position
37 const reversedPrefix = prefix.split('').reverse().join('');
38 const reversedSuffix = suffix.split('').reverse().join('');
39 candidate = reversedSuffix + middlePart + reversedPrefix;
40
41 // Update result if we found a lexicographically larger string
42 if (result < candidate) {
43 result = candidate;
44 }
45 }
46 }
47
48 return result;
49}
50
Time and Space Complexity
The time complexity is O(nΒ²)
, where n
is the total length of all strings combined in the input array strs
.
Breaking down the analysis:
- The list comprehension
[s[::-1] if s[::-1] > s else s for s in strs]
takesO(n)
time, where each string reversal and comparison isO(length of string)
. - The outer loop iterates through each string in
strs
, which isO(m)
iterations wherem
is the number of strings. - For each string at position
i
, we concatenate strings using''.join(strs[i + 1 :]) + ''.join(strs[:i])
, which takesO(n)
time. - The inner loop iterates through each character position
j
in the current string, up to the length of that string. - Inside the inner loop, we perform string slicing operations (
s[j:]
,s[:j]
) and reversals (b[::-1]
,a[::-1]
), each takingO(length of current string)
time. - The
max
comparisons with string concatenations takeO(n)
time each.
Since we iterate through every character position across all strings (total n
positions) and perform O(n)
operations for each position, the overall time complexity is O(nΒ²)
.
The space complexity is O(n)
:
- The modified
strs
array stores strings with total lengthn
. - The variable
t
stores a concatenated string of length up ton
. - The variables
a
,b
, andans
store strings of length up ton
. - All intermediate string operations create temporary strings but don't exceed
O(n)
space at any given time.
Learn more about how to find time and space complexity quickly.
Common Pitfalls
Pitfall 1: Not Considering Both Orientations for the Cut String
The Problem: A common mistake is to only use the pre-optimized version of each string when trying different cut positions. Since we initially optimize each string to its lexicographically larger version, developers might assume this optimization is sufficient for all cases.
# INCORRECT approach - only using the optimized version
for index, current_string in enumerate(optimized_strs):
middle_part = ''.join(optimized_strs[index + 1:]) + ''.join(optimized_strs[:index])
for cut_position in range(len(current_string)):
prefix = current_string[cut_position:]
suffix = current_string[:cut_position]
# Only considering one orientation
candidate = prefix + middle_part + suffix
max_result = max(max_result, candidate)
Why It's Wrong: The initial optimization chooses the best version when the string is kept whole. However, when we cut a string at different positions, the opposite orientation might produce a better result for specific cut points.
Example:
Consider strs = ["lc", "love"]
:
- Initial optimization: "lc" stays as "lc" (since "cl" < "lc")
- If we cut "lc" at position 1: we get "c" + rest + "l"
- But if we use "cl" (reverse) and cut at position 1: we get "l" + rest + "c"
- The second option starting with "l" might be lexicographically larger than starting with "c"
Solution: Always test both the optimized orientation and its reverse for the string being cut:
for cut_position in range(len(current_string)):
prefix = current_string[cut_position:]
suffix = current_string[:cut_position]
# Test with current orientation
candidate1 = prefix + middle_part + suffix
max_result = max(max_result, candidate1)
# Test with reversed orientation
candidate2 = suffix[::-1] + middle_part + prefix[::-1]
max_result = max(max_result, candidate2)
Pitfall 2: Incorrect String Reconstruction After Cutting
The Problem: When cutting a loop at a specific position within a string, developers might incorrectly reconstruct the final string by misunderstanding how the pieces should be arranged.
# INCORRECT - wrong order of concatenation
for cut_position in range(len(current_string)):
prefix = current_string[:cut_position] # Wrong slice
suffix = current_string[cut_position:] # Wrong slice
# Incorrect assembly
candidate = middle_part + prefix + suffix
Why It's Wrong:
When we cut at position j
in string s
:
- The part from position
j
to the end (s[j:]
) becomes the beginning of our result - The part from start to position
j
(s[:j]
) goes to the end of our result - The middle part (other strings) stays in between
Solution: Ensure correct slicing and ordering:
for cut_position in range(len(current_string)):
prefix = current_string[cut_position:] # This goes to the beginning
suffix = current_string[:cut_position] # This goes to the end
candidate = prefix + middle_part + suffix # Correct order
Pitfall 3: Not Initializing with a Valid Answer
The Problem: Starting with an empty string or not considering the case where no cut provides a better result:
# INCORRECT - starting with empty string max_result = ""
Why It's Wrong: The concatenation of optimized strings without any special cutting is a valid answer. If no cut position improves upon this, it should be returned.
Solution: Initialize with the simple concatenation of optimized strings:
max_result = ''.join(optimized_strs)
This ensures we always have a valid baseline answer that represents cutting at position 0 of the first string.
In a binary min heap, the maximum element can be found in:
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!