Facebook Pixel

1079. Letter Tile Possibilities

MediumHash TableStringBacktrackingCounting
Leetcode Link

Problem Description

You are given n tiles, where each tile has one letter printed on it, represented by the string tiles[i].

Your task is to find the total number of possible non-empty sequences of letters that can be formed using these tiles. Each tile can be used at most once in any sequence.

For example, if you have tiles "AAB":

  • Length 1 sequences: "A", "B" (2 possibilities)
  • Length 2 sequences: "AA", "AB", "BA" (3 possibilities)
  • Length 3 sequences: "AAB", "ABA", "BAA" (3 possibilities)
  • Total: 2 + 3 + 3 = 8 possible sequences

The solution uses a depth-first search approach with backtracking. It maintains a counter of available tiles and explores all possible sequences by:

  1. Trying each available tile as the next character in the sequence
  2. Recursively building longer sequences with the remaining tiles
  3. Backtracking to try different combinations

The key insight is that for each recursive call, we:

  • Count the current sequence (add 1 to answer)
  • Try extending the sequence with each available tile type
  • Decrease the count of that tile, recurse, then restore it (backtracking)
  • Sum up all possibilities from different branches

The Counter keeps track of how many of each letter tile we have available at any point during the recursion.

Flowchart Walkthrough

First, let's pin down the algorithm using the Flowchart. Here's a step-by-step walkthrough:

Is it a graph?

  • No: The problem involves arranging letter tiles into sequences, not nodes and edges as in graph problems.

Need to solve for kth smallest/largest?

  • No: We need to count all possible sequences, not find a specific kth element.

Involves Linked Lists?

  • No: The problem works with a string of tiles, not linked list data structures.

Does the problem have small constraints?

  • Yes: The problem involves generating sequences from a limited set of tiles. The string tiles has length at most 7 (as per typical constraints), and we need to explore all possible arrangements. This is a combinatorial problem with factorial-like growth, which is only feasible with small inputs.

Brute force / Backtracking?

  • Yes: Since we need to explore all possible sequences of different lengths using the available tiles, backtracking is the perfect approach. We systematically try each available tile, place it in our sequence, recursively build longer sequences with remaining tiles, then backtrack to try other combinations.

Conclusion: The flowchart correctly leads us to use a Backtracking approach. This aligns perfectly with the solution, which uses DFS with backtracking to enumerate all possible non-empty sequences by:

  • Trying each available tile type
  • Reducing its count and exploring further possibilities
  • Restoring the count (backtracking) to try other branches
  • Counting all valid sequences encountered during the exploration
Quick Interview Experience
Help others by sharing your interview experience
Have you seen this problem before?

Intuition

When faced with finding all possible sequences from a set of tiles, we need to think about how sequences are built step by step. Each sequence starts empty, then we add one tile at a time until we've used the tiles we want.

The key insight is that this is fundamentally a choice problem at each step: which tile should we use next? Since we want ALL possible sequences, we need to explore every valid choice systematically.

Consider tiles "AAB". To build sequences:

  • We can start with 'A' (2 available) or 'B' (1 available)
  • If we choose 'A', we now have "A" as a valid sequence (count it!)
  • From here, we can extend with another 'A' (1 remaining) or 'B' (1 remaining)
  • Each extension gives us a new valid sequence to count

This naturally suggests a recursive approach where at each level:

  1. We count the current sequence we've built
  2. We try extending it with each type of available tile
  3. We explore all possibilities from that extended state

The challenge is handling duplicate tiles efficiently. Instead of tracking individual tiles (which would create duplicate work for identical tiles), we track the count of each unique letter. This way, two 'A' tiles are interchangeable, avoiding redundant computation.

The backtracking pattern emerges because after exploring all sequences that start with a particular choice, we need to "undo" that choice (restore the tile) to explore other branches. This ensures we systematically cover all possibilities without missing any or counting duplicates.

The recursive structure naturally handles sequences of all lengths - from single tiles up to using all available tiles - because each recursive call represents adding one more tile to our current sequence.

Learn more about Backtracking patterns.

Solution Approach

The implementation uses a depth-first search (DFS) with backtracking to systematically generate and count all possible sequences.

Data Structure Choice: We use a Counter to track the frequency of each letter in the tiles. This is crucial because it:

  • Treats identical letters as interchangeable (avoiding duplicate sequences)
  • Allows efficient tracking of available tiles during recursion
  • Enables easy increment/decrement operations for backtracking

Core Algorithm: The dfs function takes the current state (counter of available tiles) and returns the total count of sequences that can be formed from those tiles.

Step-by-step Implementation:

  1. Initialize the counter: cnt = Counter(tiles) creates a frequency map of all available tiles.

  2. Recursive DFS function:

    def dfs(cnt: Counter) -> int:
        ans = 0

    This accumulator will store the total count of valid sequences.

  3. Iterate through each unique tile type:

    for i, x in cnt.items():
        if x > 0:

    For each letter that still has tiles available, we consider using it.

  4. Count and explore:

    ans += 1          # Count the sequence ending with this tile
    cnt[i] -= 1       # Use one tile of this type
    ans += dfs(cnt)   # Recursively count longer sequences
    cnt[i] += 1       # Backtrack: restore the tile

    The magic happens here:

    • ans += 1: Every time we pick a tile, we form a valid sequence (the current path from root to this node)
    • cnt[i] -= 1: Temporarily remove the tile from available pool
    • ans += dfs(cnt): Recursively explore all sequences that can be built after using this tile
    • cnt[i] += 1: Restore the tile for exploring other branches (backtracking)

Why this works:

  • Each recursive call represents a state where we've built a partial sequence
  • The ans += 1 counts the current sequence
  • The recursive call dfs(cnt) explores all possible extensions
  • Backtracking ensures we explore all branches of the decision tree
  • Using a counter prevents counting duplicate sequences (e.g., using the first 'A' vs the second 'A' in "AAB" produces the same sequence)

Time Complexity: O(N! × N) in the worst case where all tiles are unique, as we explore all permutations and each recursive call iterates through the counter.

Space Complexity: O(N) for the recursion stack and counter storage.

Ready to land your dream job?

Unlock your dream job with a 3-minute evaluator for a personalized learning plan!

Start Evaluator

Example Walkthrough

Let's trace through the algorithm with tiles = "AAB" to see how it counts all 8 possible sequences.

Initial State: Counter = {'A': 2, 'B': 1}

We start our DFS from the root. The function will explore each available tile type:

Level 1 - Choose first tile:

  1. Choose 'A' (2 available):

    • Count this sequence: "A" ✓ (total = 1)
    • Update counter: {'A': 1, 'B': 1}
    • Recurse to Level 2...

    Level 2 - After choosing 'A':

    • Choose another 'A' (1 available):
      • Count sequence: "AA" ✓ (total = 2)
      • Update counter: {'A': 0, 'B': 1}
      • Recurse to Level 3...
      Level 3 - After "AA":
      • Choose 'B' (1 available):
        • Count sequence: "AAB" ✓ (total = 3)
        • Update counter: {'A': 0, 'B': 0}
        • No more tiles, return 0
        • Backtrack: restore 'B'
      • Return to Level 2
      • Backtrack: restore second 'A'
    • Choose 'B' (1 available):
      • Count sequence: "AB" ✓ (total = 4)
      • Update counter: {'A': 1, 'B': 0}
      • Recurse to Level 3...
      Level 3 - After "AB":
      • Choose 'A' (1 available):
        • Count sequence: "ABA" ✓ (total = 5)
        • Update counter: {'A': 0, 'B': 0}
        • No more tiles, return 0
        • Backtrack: restore 'A'
      • Return to Level 2
      • Backtrack: restore 'B'
    • Return to Level 1
    • Backtrack: restore first 'A'
  2. Choose 'B' (1 available):

    • Count this sequence: "B" ✓ (total = 6)
    • Update counter: {'A': 2, 'B': 0}
    • Recurse to Level 2...

    Level 2 - After choosing 'B':

    • Choose 'A' (2 available):
      • Count sequence: "BA" ✓ (total = 7)
      • Update counter: {'A': 1, 'B': 0}
      • Recurse to Level 3...
      Level 3 - After "BA":
      • Choose another 'A' (1 available):
        • Count sequence: "BAA" ✓ (total = 8)
        • Update counter: {'A': 0, 'B': 0}
        • No more tiles, return 0
        • Backtrack: restore 'A'
      • Return to Level 2
      • Backtrack: restore 'A'
    • Return to Level 1
    • Backtrack: restore 'B'

Final Result: 8 sequences found: "A", "AA", "AAB", "AB", "ABA", "B", "BA", "BAA"

The key insight is that each time we pick a tile, we:

  1. Count the current sequence (the path from root to current node)
  2. Try extending with each remaining tile type
  3. Backtrack to explore alternative branches

This ensures we count every possible non-empty sequence exactly once.

Solution Implementation

1from collections import Counter
2from typing import Dict
3
4class Solution:
5    def numTilePossibilities(self, tiles: str) -> int:
6        def dfs(char_count: Dict[str, int]) -> int:
7            # Initialize result counter for this recursion level
8            result = 0
9          
10            # Try using each available character
11            for char, count in char_count.items():
12                if count > 0:
13                    # Count the current sequence (using this character)
14                    result += 1
15                  
16                    # Use one instance of this character
17                    char_count[char] -= 1
18                  
19                    # Recursively count all possible sequences that can be formed
20                    # with the remaining characters
21                    result += dfs(char_count)
22                  
23                    # Backtrack: restore the character count
24                    char_count[char] += 1
25          
26            return result
27      
28        # Create a frequency counter for all characters in tiles
29        char_count = Counter(tiles)
30      
31        # Start DFS to count all possible non-empty sequences
32        return dfs(char_count)
33
1class Solution {
2    /**
3     * Calculate the number of possible tile sequences that can be formed
4     * @param tiles String containing tile letters
5     * @return Number of possible non-empty sequences
6     */
7    public int numTilePossibilities(String tiles) {
8        // Count frequency of each letter (A-Z)
9        int[] letterCount = new int[26];
10        for (char c : tiles.toCharArray()) {
11            letterCount[c - 'A']++;
12        }
13      
14        // Use DFS to explore all possible sequences
15        return dfs(letterCount);
16    }
17  
18    /**
19     * Depth-first search to count all possible sequences
20     * @param letterCount Array containing count of each letter
21     * @return Number of sequences that can be formed with remaining letters
22     */
23    private int dfs(int[] letterCount) {
24        int result = 0;
25      
26        // Try using each available letter
27        for (int i = 0; i < letterCount.length; i++) {
28            if (letterCount[i] > 0) {
29                // Count current letter as a valid sequence
30                result++;
31              
32                // Use this letter and explore further possibilities
33                letterCount[i]--;
34                result += dfs(letterCount);
35              
36                // Backtrack: restore the letter count
37                letterCount[i]++;
38            }
39        }
40      
41        return result;
42    }
43}
44
1class Solution {
2public:
3    int numTilePossibilities(string tiles) {
4        // Count frequency of each letter (A-Z)
5        int letterCount[26]{};
6        for (char c : tiles) {
7            ++letterCount[c - 'A'];
8        }
9      
10        // Define recursive DFS function to count all possible sequences
11        function<int(int*)> dfs = [&](int* count) -> int {
12            int result = 0;
13          
14            // Try using each available letter
15            for (int i = 0; i < 26; ++i) {
16                if (count[i] > 0) {
17                    // Count this single letter as one possibility
18                    ++result;
19                  
20                    // Use this letter and recursively count longer sequences
21                    --count[i];
22                    result += dfs(count);
23                  
24                    // Backtrack: restore the letter count
25                    ++count[i];
26                }
27            }
28          
29            return result;
30        };
31      
32        // Start DFS with initial letter counts
33        return dfs(letterCount);
34    }
35};
36
1/**
2 * Counts the number of possible non-empty sequences that can be formed from the given tiles
3 * @param tiles - A string representing the available tiles (uppercase letters A-Z)
4 * @returns The total number of possible tile arrangements
5 */
6function numTilePossibilities(tiles: string): number {
7    // Initialize frequency array for 26 uppercase letters (A-Z)
8    const letterFrequency: number[] = new Array(26).fill(0);
9  
10    // Count the frequency of each letter in the tiles string
11    for (const letter of tiles) {
12        const index: number = letter.charCodeAt(0) - 'A'.charCodeAt(0);
13        letterFrequency[index]++;
14    }
15  
16    /**
17     * Depth-first search to count all possible arrangements
18     * Uses backtracking to explore all valid combinations
19     * @param frequency - Current frequency array of available letters
20     * @returns Number of possible arrangements from current state
21     */
22    const countArrangements = (frequency: number[]): number => {
23        let totalArrangements: number = 0;
24      
25        // Try using each available letter
26        for (let letterIndex = 0; letterIndex < 26; letterIndex++) {
27            if (frequency[letterIndex] > 0) {
28                // Count this single letter as one arrangement
29                totalArrangements++;
30              
31                // Use this letter and explore further arrangements
32                frequency[letterIndex]--;
33                totalArrangements += countArrangements(frequency);
34              
35                // Backtrack: restore the letter for other branches
36                frequency[letterIndex]++;
37            }
38        }
39      
40        return totalArrangements;
41    };
42  
43    // Start the DFS with the initial frequency array
44    return countArrangements(letterFrequency);
45}
46

Time and Space Complexity

Time Complexity: O(n! * n) where n is the length of the tiles string.

The algorithm explores all possible permutations of the tiles. In the worst case where all tiles are unique:

  • At the first level of recursion, we have n choices
  • At the second level, we have n-1 choices
  • This continues until we have 1 choice
  • Total number of recursive calls is approximately n!
  • Each recursive call iterates through the counter which can have up to n unique characters
  • Therefore, the time complexity is O(n! * n)

When there are duplicate tiles, the actual number of permutations is less than n! due to pruning, but the worst-case complexity remains O(n! * n).

Space Complexity: O(n) where n is the length of the tiles string.

The space complexity consists of:

  • The Counter object which stores at most n unique characters: O(n)
  • The recursion stack depth which can go up to n levels deep (when we use all tiles): O(n)
  • Each recursive call maintains its local variables but doesn't create new data structures
  • Overall space complexity is O(n)

Learn more about how to find time and space complexity quickly.

Common Pitfalls

1. Attempting to Generate Actual Sequences Instead of Counting

A common mistake is trying to build and store all possible sequences in a list or set, then returning the count. This approach is inefficient and can lead to memory issues.

Incorrect Approach:

def numTilePossibilities(self, tiles: str) -> int:
    sequences = set()
  
    def dfs(current_seq, remaining):
        if current_seq:
            sequences.add(current_seq)
        for i in range(len(remaining)):
            dfs(current_seq + remaining[i], remaining[:i] + remaining[i+1:])
  
    dfs("", tiles)
    return len(sequences)

Problems with this approach:

  • Memory inefficient: stores all sequences
  • Time inefficient: string concatenation and slicing operations
  • May generate duplicates that need to be filtered

Correct Solution: Count sequences without generating them, as shown in the original solution.

2. Forgetting to Count the Current Sequence

A critical pitfall is forgetting the ans += 1 line that counts the current valid sequence before recursing deeper.

Incorrect Implementation:

def dfs(cnt: Counter) -> int:
    ans = 0
    for i, x in cnt.items():
        if x > 0:
            # Missing: ans += 1 (forgot to count current sequence!)
            cnt[i] -= 1
            ans += dfs(cnt)  # Only counting deeper sequences
            cnt[i] += 1
    return ans

Why this fails: This would only count sequences of maximum length, missing all intermediate-length sequences. For "AAB", it would miss counting "A", "B", "AA", "AB", "BA".

Correct Solution: Always add 1 before recursing to count the sequence formed by taking the current tile.

3. Not Using a Frequency Counter (Treating Identical Letters as Distinct)

Using indices or treating each tile as unique leads to counting duplicate sequences.

Incorrect Approach:

def numTilePossibilities(self, tiles: str) -> int:
    used = [False] * len(tiles)
  
    def dfs(depth):
        if depth == 0:
            return 0
        ans = 0
        for i in range(len(tiles)):
            if not used[i]:
                ans += 1
                used[i] = True
                ans += dfs(depth + 1)
                used[i] = False
        return ans
  
    return dfs(0)

Problem: This treats "AAB" as having three distinct tiles (A₁, A₂, B), so it would count "A₁B" and "A₂B" as different sequences, when they're actually the same "AB".

Correct Solution: Use a Counter to group identical letters together, ensuring each unique sequence is counted exactly once.

4. Incorrect Backtracking (Not Restoring State)

Forgetting to restore the counter after recursion breaks the backtracking mechanism.

Incorrect Implementation:

def dfs(cnt: Counter) -> int:
    ans = 0
    for i, x in cnt.items():
        if x > 0:
            ans += 1
            cnt[i] -= 1
            ans += dfs(cnt)
            # Missing: cnt[i] += 1 (forgot to restore!)
    return ans

Why this fails: Without restoring the count, subsequent iterations in the loop won't have access to tiles that were used in earlier iterations, leading to incorrect counts.

Correct Solution: Always restore the state (cnt[i] += 1) after the recursive call to ensure proper exploration of all branches.

Discover Your Strengths and Weaknesses: Take Our 3-Minute Quiz to Tailor Your Study Plan:

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

Want a Structured Path to Master System Design Too? Don’t Miss This!

Load More