Problem overview
The method decompress receives a string called input that holds a Token List: pieces of text separated by commas, where braces group alternatives. It returns an ArrayBuffer[String] with every path the token list stands for, in any order.
The rules are small. A comma separates alternatives. Text next to a group is joined with every alternative inside it, and two groups next to each other produce every combination. So a{b,c} means ab and ac, and {a,b}{c,d} means ac, ad, bc and bd. Groups can be nested, as in README.md,resources/js/{app,boot,comp/{a,b}}.js, which expands to README.md, resources/js/app.js, resources/js/boot.js, resources/js/comp/a.js and resources/js/comp/b.js.
Approach: one recursive call per pair of braces
Nested braces have the same shape as the whole input: a list of alternatives separated by commas. That is a hint for recursion. A function parsePaths reads characters and produces all the paths for one level, stopping at the } that closes it or at the end of the input. When it meets a {, it calls itself to parse the inside, and uses the result as one more building block.
Two collections do the bookkeeping at each level:
currentLevelholds the finished paths of this level, the ones completed by a comma.stringsholds the paths being built since the last comma. It starts with a single empty string. A plain character is appended to every string in it. A group multiplies it: every string in progress is combined with every string the group produced.
The recursive calls must continue where the previous call stopped, so the position in the input is shared by all calls. The solution keeps it in a field of the class, index. After a nested call returns, index points at the } that ended it, and the loop's final index += 1 steps past that brace.
Step by step
- Delegate.
decompressonly callsparsePaths(input). The call is its last expression, so Scala returns it. - Declare
parsePathsabove it. It takes theinputand returns anArrayBuffer[String]. - Create
currentLevelas avalholding an emptyArrayBuffer[String]. Avalfixes the reference, and the buffer can still grow. - Create
stringsas avarholding a buffer with one empty string. It is avarbecause it is replaced later. The empty string gives the first characters something to be appended to. - Add the shared position.
var index: Int = 0goes in the class body, aboveparsePaths. A local variable would start again at 0 in every recursive call. - Loop with
while (index < input.length)inside abreakableblock, because the loop must be able to end early at a closing brace. - Open brace. If
input(index) == '{', moveindexpast it withindex += 1. - Recurse. The call
parsePaths(input)returnsnextLevel, all paths of the group. - Combine.
concatAllPairs(strings, nextLevel)pairs every string in progress with every string of the group, and the result replacesstrings. - Close brace. If the character is
'}', the group that this call was asked to parse is finished, sobreak()leaves the loop. - Comma. The paths in
stringsare complete. Append them tocurrentLevelwith++=and resetstringsto a buffer with one empty string. - Any other character. Append it to every string in
strings. Strings are immutable, so the loop overstrings.indiceswrites the new string back into the buffer. - Advance with
index += 1after all the branches. After a nested call, this also steps past the}that ended it. - Flush. When the loop ends,
stringsmay hold paths that no comma ever completed. Append them tocurrentLeveland return it. - Write
concatAllPairs. It takes twoIterable[String]values, loops overfirstand, inside it, oversecond, and appendsfirstPart + secondPartto a newresultbuffer, which it returns.
The solution in Scala
class Solution {
def concatAllPairs(first: Iterable[String], second: Iterable[String]): ArrayBuffer[String] = {
val result = ArrayBuffer[String]()
for (firstPart <- first) {
for (secondPart <- second) {
result += (firstPart + secondPart)
}
}
result
}
var index: Int = 0
def parsePaths(input: String): ArrayBuffer[String] = {
val currentLevel = ArrayBuffer[String]()
var strings = ArrayBuffer[String]("")
breakable {
while (index < input.length) {
if (input(index) == '{') {
index += 1
val nextLevel = parsePaths(input)
strings = concatAllPairs(strings, nextLevel)
} else if (input(index) == '}') {
break()
} else if (input(index) == ',') {
currentLevel ++= strings
strings = ArrayBuffer[String]("")
} else {
for (i <- strings.indices) {
strings(i) = strings(i) + input(index)
}
}
index += 1
}
}
currentLevel ++= strings
currentLevel
}
def decompress(input: String): ArrayBuffer[String] = {
parsePaths(input)
}
}
The code uses ArrayBuffer and breakable, so the file needs import scala.collection.mutable.ArrayBuffer and import scala.util.control.Breaks._. The Scala details worth a closer look:
break()works only insidebreakable. Scala has nobreakkeyword. The call throws a control exception, and the nearest enclosingbreakablecatches it. Each recursive call has its ownbreakable, so a}ends only the call that is reading that group.- Characters.
input(index)is aChar, written with single quotes:'{'. Appending it to aStringwith+adds its text. +=and++=. On anArrayBuffer,+=appends one element and++=appends all the elements of another collection.Iterable[String].ArrayBuffer[String]is anIterable[String], sostringsandnextLevelcan be passed straight toconcatAllPairs.- State on the object. Because
indexlives in theSolutionobject, a second call todecompresson the same object starts withindexalready at the end of the previous input. On oneSolutioninstance,decompress("a{b,c}")givesabandac, and calling it again returns a buffer with only the empty string. Create a newSolutionfor each input, or resetindexto 0 first.
Complexity
Time and space: proportional to the size of the output. Let T be the total number of characters in all returned paths. Every input character is appended to each string in progress at its level, and concatAllPairs builds each combined string once per level of nesting that it passes through. The work is therefore about T times a factor up to the nesting depth. The output can be much longer than the input: a token list such as {a,b}{c,d}{e,f} has 15 characters and expands to 8 paths.
The recursion depth equals the nesting depth of the braces, which is small for real paths.
Edge cases and common mistakes
- No braces or commas.
"abc"gives["abc"]. Without the final flush, nothing would be returned. - Empty input.
""gives a buffer holding one empty string, becausestringsstarts with"". - Empty alternative.
a{b,}givesabanda, anda{}givesa. - Duplicates are kept.
{a,a}givesatwice. The method doesn't remove duplicates. - Side by side groups.
my_{first,second}_folder/{a,b}{.js,.py}gives eight paths, the product 2 * 2 * 2. - Order. Results come out in the order the alternatives appear. For
resources/{js/{app.js,comp/{a.js,b.js},boot.js}},README.md,README.mdcomes last. Any order is accepted. - Using a local index. A variable declared inside
parsePathsrestarts at 0 in every call, so the nested call would read the input again from the beginning. - Forgetting to reset
stringsafter a comma. The paths already moved tocurrentLevelwould be extended with the next alternative's characters.
Testing
ab,acgivesabandac.{a,b}x{c,d}givesaxc,axd,bxcandbxd.README.md,resources/js/{app,boot,comp/{a,b}}.jsgivesREADME.md,resources/js/app.js,resources/js/boot.js,resources/js/comp/a.jsandresources/js/comp/b.js.a{b,}givesabanda.
