Start learning free

LESSON 346 OF 347 · RECURSION

Decompress File Paths in C#: Recursive Brace Expansion

Practice Read the full explanation

A comma separated list of strings can be compressed by finding common substrings and grouping those strings together into Token Lists

More formally, a Token List is a list where each item can be one of the following:

  • a string
  • a concatenation of Token Lists

Examples:

ab,ac => a{b,c}
ac,ad,bc,bd => {a,b}{c,d}
axc,axd,bxc,bxd => {a,b}x{c,d}

This technique is used to compress folder paths since a folder like

README.md
resources/
    js/
        app.js
        boot.js
        comp/
            a.js
            b.js

can be represented as a list of strings:
[
    "README.md",
    "resources/js/app.js",
    "resources/js/boot.js",
    "resources/js/comp/a.js",
    "resources/js/comp/b.js"
]

and further encoded into comma separated string
“README.md,resources/js/app.js,resources/js/boot.js,resources/js/comp/a.js,resources/js/comp/b.js”
and then we can compress them to the following Token List:
README.md,resources/js/{app,boot,comp/{a,b}}.js

Given a Token List representing a compressed folder, return the initial list of paths.

Example 1:

Input:
"resources/{js/{app.js,comp/{a.js,b.js},boot.js}},README.md"

Output: [
    "README.md",
    "resources/js/app.js",
    "resources/js/boot.js",
    "resources/js/comp/a.js",
    "resources/js/comp/b.js"
]

Example 2:

Input:
"my_{first,second}_folder/{a,b}{.js,.py}"

Output: [
    "my_first_folder/a.js,
    "my_first_folder/a.py,
    "my_first_folder/b.js,
    "my_first_folder/b.py,
    "my_second_folder/a.js,
    "my_second_folder/a.py,
    "my_second_folder/b.js,
    "my_second_folder/b.py
]

Note: The resulting array can be in any order


STEP 1 OF 19
    Ctrl + Enter to check

    C# · RECURSION

    Decompress File Paths in C#, explained

    Problem overview

    Decompress(input) receives a compressed list of paths, a "token list", and returns the original list of strings. Commas separate alternatives, and braces group them: a{b,c} stands for ab and ac, and a group can sit anywhere in a string, be followed by more text, and contain further groups. Several groups in a row multiply: {a,b}{c,d} gives ac, ad, bc and bd. The result may come back in any order.

    For "my_{first,second}_folder/{a,b}{.js,.py}", the first group has 2 options and the other two groups together have 2 * 2 = 4, so the result has 8 paths, from my_first_folder/a.js to my_second_folder/b.py.

    Approach: one recursive call per pair of braces

    The grammar is recursive: the inside of a pair of braces is itself a token list. That suggests a function, parsePaths, that reads one token list and returns all its expansions. When it meets {, it calls itself to expand the inside, and when it meets }, it stops and hands control back.

    The function keeps two lists while it scans:

    • strings: the partial paths for the current comma-separated alternative. It starts as [""], a single empty string, so that the first letters have something to attach to.
    • currentLevel: the finished paths of this nesting level, collected when a comma or the end of the level is reached.

    Each character is handled in one of four ways. A letter is appended to every string in strings. A { expands the nested list and multiplies it with strings. A , moves strings into currentLevel and starts again from [""]. A } ends the level.

    Trace a{b,c}. At the top level, strings becomes ["a"]. At {, a nested call starts with [""]: b gives ["b"], the comma moves it into the nested currentLevel, c gives ["c"], and } stops the call, which returns ["b", "c"]. Back at the top, every string in ["a"] is paired with every string in ["b", "c"], which gives ["ab", "ac"].

    Step by step

    1. Delegate. Decompress just returns parsePaths(input). Inside a class, one method can call another by its name alone.
    2. Declare parsePaths(string input), returning a List<string>. It has no access modifier, so it is private, which suits a helper.
    3. Create currentLevel, an empty list for this level's finished paths.
    4. Create strings with the collection initializer { "" }, which adds one empty string right after the list is constructed.
    5. Keep the position in a field. A recursive call must continue exactly where the previous one stopped, but a local variable restarts at 0 in every call. An int index field is shared by all calls on the same object. The constructor sets it to 0.
    6. Loop while index < input.Length.
    7. On '{': increment index so the nested call starts after the brace, call parsePaths(input) and store the result in nextLevel. When the call returns, index points at the '}' that ended it.
    8. Multiply. Replace strings with concatAllPairs(strings, nextLevel): every prefix in progress must pair with every nested option.
    9. On '}': break. The nested list is finished, and control returns to the caller.
    10. On ',': add everything in strings to currentLevel with AddRange, then reset strings to a fresh [""].
    11. Otherwise it's a character of a path. Append it to every string in strings with a for loop, writing each result back: strings[i] = strings[i] + input[index]. Strings are immutable, so the new string must be stored in the list.
    12. Advance. Increment index at the bottom of the loop. After a nested call, this is also what steps over the '}' that ended it.
    13. Finish the level. After the loop, strings may hold paths that never met a comma, so add them to currentLevel and return it.
    14. Write concatAllPairs(first, second). Two nested foreach loops add firstPart + secondPart to a result list for every pair, so the result has first.Count * second.Count strings.

    The solution in C#

    class Solution {
        List<string> concatAllPairs(List<string> first, List<string> second) {
            List<string> result = new List<string>();
            foreach (string firstPart in first) {
                foreach (string secondPart in second) {
                    result.Add(firstPart + secondPart);
                }
            }
            return result;
        }
    
        int index;
        public Solution() {
            index = 0;
        }
    
        List<string> parsePaths(string input) {
            List<string> currentLevel = new List<string>();
            List<string> strings = new List<string> { "" };
            while (index < input.Length) {
                if (input[index] == '{') {
                    ++index;
                    List<string> nextLevel = parsePaths(input);
                    strings = concatAllPairs(strings, nextLevel);
                } else if (input[index] == '}') {
                    break;
                } else if (input[index] == ',') {
                    currentLevel.AddRange(strings);
                    strings = new List<string> { "" };
                } else {
                    for (int i = 0; i < strings.Count; ++i) {
                        strings[i] = strings[i] + input[index];
                    }
                }
                index++;
            }
            currentLevel.AddRange(strings);
            return currentLevel;
        }
    
        public List<string> Decompress(string input) {
            return parsePaths(input);
        }
    }

    C# details worth knowing

    • new List<string> { "" } is a collection initializer: it calls the constructor and then Add("").
    • strings[i] = strings[i] + input[index]; works because string + char produces a new string with the character appended. A List can be both read and assigned through brackets.
    • Comparing input[index] == '{' compares two char values, so it uses single quotes. Double quotes would make it a string, which doesn't compare with a char.
    • index++ as a statement and ++index do the same thing here; only the old and new values differ when used inside an expression.
    • The constructor has the class's name and no return type. A field of type int already starts at 0, so setting index = 0 only makes the intent explicit.

    Complexity

    The output can be much larger than the input: every group of k options multiplies the number of paths by k, so three groups of two options give 8 paths, and ten of them would give 1,024. The running time is therefore dominated by the size of the result. Each character of the input is visited once, but a letter is appended to every string in progress, and each append copies a string. Memory is the size of the result plus the recursion depth, which equals the nesting depth of the braces.

    Edge cases and common mistakes

    • No braces and no commas. "abc" returns ["abc"]: the loop only appends letters, and the final AddRange moves the result into currentLevel.
    • Forgetting the final AddRange. The last alternative of every level ends at a } or at the end of the input, not at a comma, so without it that alternative is lost.
    • A local instead of a field for the position. Every recursive call would restart at 0, so a nested call could never continue where the outer one stopped.
    • Starting strings as an empty list. A letter would have nothing to be appended to, and the result would be empty.
    • Reusing one Solution object. The field index is left at the end of the input after a call. A second call to Decompress on the same object starts at the end and returns an empty list. Create a new object per input, or reset index at the start of Decompress.

    Testing

    ab,ac                                         ->  ab, ac
    a{b,c}                                        ->  ab, ac
    axc,axd,bxc,bxd                               ->  axc, axd, bxc, bxd
    README.md,resources/js/{app,boot,comp/{a,b}}.js
      ->  README.md, resources/js/app.js, resources/js/boot.js,
          resources/js/comp/a.js, resources/js/comp/b.js
    
    Keep going with C#About 340 step-by-step lessons, from your first line of code to coding interviews. Free to start.
    Start learning free