Problem overview
A list of paths can be written compactly by grouping common parts in braces. The string "my_{first,second}_folder/{a,b}{.js,.py}" stands for every combination of the choices inside the braces. The function decompress receives such a string, called a Token List, and returns the full list of paths it stands for. The order of the result doesn't matter.
A Token List is a sequence of pieces: plain text, or a brace group {...} whose items are separated by commas and are Token Lists themselves. A comma outside any braces separates whole paths. For example:
"ab,ac"gives["ab", "ac"], two separate paths."a{b,c}"gives["ab", "ac"]."{a,b}x{c,d}"gives["axc", "axd", "bxc", "bxd"]: every choice from the first group with every choice from the second.
Approach: recursion that mirrors the nesting
Braces nest, so the natural tool is recursion. Define one function, parsePaths, that reads one level of the string and returns the list of paths that level expands to. Whenever it meets {, it calls itself to expand the nested level, and when it meets the matching } it stops and hands control back. This style is called recursive descent.
Inside one level, the function keeps two lists:
strings, the partial paths built since the last comma, starting as[""]. Each plain letter is appended to every string in it.currentLevel, the finished paths of this level. At a comma,stringsis moved into it and restarted.
When a nested group returns a list nextLevel, every partial path must be combined with every item of the group. That product of two lists is what concatAllPairs computes. After {a,b}, for instance, strings goes from [""] to ["a", "b"], and a following group {c,d} turns it into ["ac", "ad", "bc", "bd"].
The recursive calls must keep reading where the previous call stopped, so the position can't be a local variable. It lives on the object as the property index, which all the calls share.
Step by step
- Delegate.
decompresssimply returnsparsePaths(input). The helper does the work. - Declare
parsePathswith aStringparameter and aMutableList<String>result. - Create
currentLevelas avalholding anArrayList<String>. Avalfixes the reference, not the contents, so the list can still grow. - Create
stringsas avarof typeMutableList<String>, started witharrayListOf(""). It needs the single empty string so the first letters have something to attach to, and it needsvarand the explicit type because it is later replaced by the list thatconcatAllPairsreturns. - Set up the shared position. The class declares
var index: Int, and a secondaryconstructor()sets it to 0. Kotlin requires a property to be initialised, and the property has no initial value here. - Loop while
index < input.length. - On
'{', incrementindexso the recursive call starts after the brace, callparsePaths(input)intonextLevel, and replacestringswithconcatAllPairs(strings, nextLevel). - On
'}',break. This level is finished, andindexpoints at the closing brace. - On
',', add all ofstringstocurrentLeveland resetstringstoarrayListOf(""). - Otherwise it's a letter: loop over
0 until strings.sizeand append the character to each string, writing the new string back into the list. - Advance. After the branches,
index++moves to the next character. After a nested call, this also steps past the}that ended it. - Flush and return. After the loop, add what is left in
stringstocurrentLeveland return it. The last path of a level never meets a comma. - Write
concatAllPairs(first, second), which creates an emptyArrayListand, for eachfirstPartinfirstand eachsecondPartinsecond, addsfirstPart + secondPart. It returns the list.
The solution in Kotlin
class Solution {
fun concatAllPairs(first: List<String>, second: List<String>): MutableList<String> {
val result = ArrayList<String>()
for (firstPart in first) {
for (secondPart in second) {
result.add(firstPart + secondPart)
}
}
return result
}
var index: Int
constructor() {
index = 0
}
fun parsePaths(input: String): MutableList<String> {
val currentLevel = ArrayList<String>()
var strings: MutableList<String> = arrayListOf("")
while (index < input.length) {
if (input[index] == '{') {
++index
val nextLevel = parsePaths(input)
strings = concatAllPairs(strings, nextLevel)
} else if (input[index] == '}') {
break
} else if (input[index] == ',') {
currentLevel.addAll(strings)
strings = arrayListOf("")
} else {
for (i in 0 until strings.size) {
strings[i] = strings[i] + input[index]
}
}
index++
}
currentLevel.addAll(strings)
return currentLevel
}
fun decompress(input: String): MutableList<String> {
return parsePaths(input)
}
}
The Kotlin details worth a closer look:
- A property needs a value. A Kotlin class property must be initialised when it is declared or in the constructor.
var index: Inthas no initialiser, so the secondaryconstructor()assignsindex = 0. Writingvar index = 0would do the same in one line. - Read-only
Listparameters, mutable results.concatAllPairsonly reads its inputs, so they are declaredList<String>, and theMutableListpassed in is accepted. It returns aMutableListso thatstringscan hold it and be edited. - Why
stringshas an explicit type.arrayListOf("")alone would give the variable the typeArrayList<String>. DeclaringMutableList<String>lets the same variable also hold the result ofconcatAllPairs. - Strings are immutable.
strings[i] = strings[i] + input[index]builds a newString(aStringplus aChar) and stores it in positioni. The old string is not changed. 0 until strings.sizeexcludes its upper bound, so it covers exactly the valid indices.++indexandindex++both add 1 here, because the result of the expression isn't used.- Comparing characters.
input[index] == '{'compares aCharto a character literal in single quotes. Double quotes would make aString, which can't be compared with aChar.
Complexity
Let n be the length of the input and R the total number of characters in the answer.
Time: proportional to the size of the output, roughly O(R). Every input character is read once, but a letter is appended to every partial path and each brace group multiplies the number of paths. The output itself can grow exponentially with the input ({a,b}{c,d}{e,f} and so on doubles with every group), so no algorithm can beat the output size.
Space: the result takes O(R). The recursion is as deep as the nesting of braces, at most O(n) frames.
Edge cases and common mistakes
- A path with no braces.
"abc"gives["abc"]. The final flush after the loop is what adds it, since no comma ever moves it intocurrentLevel. - Nested groups.
"a{b{c,d},e}"gives["abc", "abd", "ae"]. The inner group is expanded by a deeper call and combined with thebbefore it. - A group at the start.
"{a,b}"gives["a", "b"], becausestringsstarts as[""]and the product with the group keeps every item. - Missing the flush after the loop. The last path of every level would be lost.
- Forgetting
index++after a nested call. The closing}would be seen again by the caller and end the caller's level too early. - Using a local position variable. Every recursive call would start at 0 and the string would be re-read forever.
- Reusing one
Solutionobject.indexstays at the end of the string after a call. A seconddecompresson the same object starts there, finds nothing to read and returns a list with one empty string. Create a newSolution()for each input, or setindex = 0at the start ofdecompress. - Appending to
stringsin place. Strings can't change, so writingstrings[i] + input[index]without assigning it back leaves the list unchanged.
Testing
"ab,ac"gives["ab", "ac"]."{a,b}x{c,d}"gives["axc", "axd", "bxc", "bxd"]."resources/{js/{app.js,comp/{a.js,b.js},boot.js}},README.md"gives["resources/js/app.js", "resources/js/comp/a.js", "resources/js/comp/b.js", "resources/js/boot.js", "README.md"]in that order."my_{first,second}_folder/{a,b}{.js,.py}"gives the eight paths frommy_first_folder/a.jstomy_second_folder/b.py."a{b{c,d},e}"gives["abc", "abd", "ae"].