1079. Letter Tile Possibilities
On LeetCode ->Reformulated question¶
Given a string of tile letters, count how many distinct non-empty sequences can be formed by using each tile at most once.
- Example:
tiles="AAB" -> 8- Distinct sequences:
A, B, AA, AB, BA, AAB, ABA, BAA
- Distinct sequences:
Key trick¶
Use backtracking on letter counts, not on tile indices.
- At each step:
- pick any letter whose remaining count is positive
- count that new sequence
- recurse
- restore the count
- This avoids generating duplicate permutations from repeated letters.
Trap¶
Common mistakes:
- Generating all permutations of positions and deduplicating with a set.
- Works, but is wasteful.
- Forgetting that sequences can have any length from
1ton. - Double counting duplicates like the two
Atiles in"AAB".
Why this question is interesting¶
It looks like permutations, but the real idea is DFS over a multiset.
- Good test of:
- backtracking
- duplicate handling
- modeling with counts instead of raw arrays
Solve the problem with idiomatic python¶
from collections import Counter
def num_tile_possibilities(tiles: str) -> int:
counts = Counter(tiles)
def dfs() -> int:
total = 0
# Try placing each available letter next.
for ch in counts:
if counts[ch] == 0:
continue
# Using this letter creates one new non-empty sequence.
total += 1
counts[ch] -= 1
total += dfs()
counts[ch] += 1
return total
return dfs()
Library alternative:
- No standard-library function solves this exact counting problem directly with the same efficiency.
Pytest test¶
import pytest
@pytest.mark.parametrize(
("tiles", "expected"),
[
("A", 1),
("AB", 4),
("AA", 2),
("AAB", 8),
("ABC", 15),
("AAABBC", 188),
("V", 1),
("AAAAAAA", 7),
],
)
def test_num_tile_possibilities(tiles, expected):
assert num_tile_possibilities(tiles) == expected
Extra¶
First time I saw a recursion call inside a loop. I wasn't able to wrap my head arround this (while the code being small and simple). I ask AI for help telling I was missing something. Here's part of the responses.
You are not missing something small.
You are missing the model of what the program is doing.
Once that model is clear, recursion, DFS, and backtracking all become much less mysterious.
First: what is the program really exploring?¶
It is exploring all possible sequences you can build, one letter at a time.
For "AAB":
- Start with empty sequence:
"" - From there, you may add:
AB
If you picked A, your sequence is now:
"A"
From "A", you may still add:
Aagain, making"AA"B, making"AB"
From "AA", you may still add:
B, making"AAB"
From "AB", you may still add:
A, making"ABA"
If you picked B first, your sequence is:
"B"
From "B", you may still add:
A, making"BA"
From "BA", you may still add:
A, making"BAA"
So the whole exploration is:
Those are exactly the 8 sequences:
AAAAABABABABBABAA
Why this is called DFS¶
DFS means Depth-First Search.
It is not only for trees given in input.
It means:
- you have some state
- from that state, you can go to next states
- you explore one path as deep as possible before coming back
That is exactly what happens here.
The state is:
- what letters are still available
Or, if you prefer:
- the current built sequence
- plus the remaining counts
For "AAB":
- state 1: current
"", remainingA:2, B:1 - state 2: current
"A", remainingA:1, B:1 - state 3: current
"AA", remainingA:0, B:1 - state 4: current
"AAB", remainingA:0, B:0
That is one path.
DFS goes down that path fully before trying another one.
So the order is like:
- go to
"A" - then
"AA" - then
"AAB" - no more move possible
- go back
- try
"AB" - then
"ABA" - go back
- go back
- try
"B" - then
"BA" - then
"BAA"
That is depth-first.
Why recursion inside a loop is normal here¶
The loop means:
- from the current state, try every possible next move
The recursive call means:
- after choosing one move, continue solving the smaller subproblem
That pattern is extremely common.
General shape:
That is almost the definition of backtracking.
So recursion inside a loop is not weird here.
It is the natural way to say:
- "try each next option"
- "and from each option, keep exploring"
What backtracking means here¶
Backtracking means:
- choose something
- go deeper with that choice
- then undo the choice
- then try another choice
In your code:
This is the backtracking part.
Read it as:
- use one
ch - explore everything possible after using it
- put it back so other branches can use it
Example with "AAB".
Suppose current counts are:
You choose A:
Now you recursively explore all sequences starting with that A.
When done, you restore:
Now the program can try starting with B.
Without restoring, the next branch would be wrong.
So backtracking = "do, recurse, undo".
What exactly is being counted¶
This line:
means:
- "the moment I choose one letter, I have formed one new non-empty sequence"
That is the most important line to understand.
Suppose current built sequence is:
""
If I choose A, I have formed:
"A"
That is one valid sequence, so count +1.
Then I recurse to see if "A" can be extended to longer sequences:
"AA""AB""AAB""ABA"
Same idea if current sequence is "A".
If I choose B, I form:
"AB"
That is one more valid sequence, so count +1.
Then recurse to extend "AB" into "ABA".
So the algorithm counts:
- all length-1 sequences
- all length-2 sequences
- all length-3 sequences
- ...
- up to length
n
It does that naturally because every time you append one letter, you count the newly formed sequence.
Why duplicates are not counted¶
This is the other big idea.
You do not think of the tiles as separate objects like:
- first
A - second
A B
Instead you think only in terms of counts:
A:2B:1
So from the empty sequence, the possible next letters are only:
AB
Not:
- first
A - second
A B
That is why "A" is counted once, not twice.
If you were choosing tile positions, you might do:
- choose A at index 0 ->
"A" - choose A at index 1 ->
"A"
That would duplicate the same sequence.
But with counts, there is only one branch called "choose A".
That is the whole trick.
Compare with the wrong mental model¶
Bad model:
- "I have 3 tiles: A1, A2, B"
Then from the start you might branch into:
- choose A1
- choose A2
- choose B
But choose A1 and choose A2 both produce the same visible sequence:
"A"
So you would need a set or duplicate skipping.
Good model:
- "I have counts: A appears 2 times, B appears 1 time"
Then from the start the branches are only:
- choose
A - choose
B
Now duplicates never appear.
Let us add the missing state: the current sequence¶
Your print only shows counts, but not the sequence being built.
That makes it much harder to follow.
The recursion becomes much clearer if you print the path.
Here is a version made for understanding:
from collections import Counter
def num_tile_possibilities(tiles: str) -> int:
counts = Counter(tiles)
def dfs(path: str) -> int:
print(f"ENTER path={path!r}, counts={dict(counts)}")
total = 0
for ch in counts:
if counts[ch] == 0:
continue
print(f" choose {ch!r} from path={path!r}")
counts[ch] -= 1
new_path = path + ch
print(f" formed {new_path!r} -> +1")
total += 1
total += dfs(new_path)
counts[ch] += 1
print(f" undo {ch!r}, back to path={path!r}, counts={dict(counts)}")
print(f"RETURN path={path!r}, total={total}")
return total
return dfs("")
For "AAB", this matches the tree:
That tree is the recursion.
Now let us connect that to the total values in code¶
Here is the key:
Meaning:
+1for the sequence formed right now+dfs()for all longer sequences extending it
Example at root with A:
- choosing
Acreates"A"->+1 - recursive call counts all extensions of
"A":"AA""AAB""AB""ABA"
- so the whole
Abranch contributes5
That is why total is not just counting leaves.
It is counting every non-empty node in the tree.
Why the empty string is not counted¶
We start with an empty path "", but we do not count it.
We only count when we choose a letter:
So:
- empty sequence
""is not counted - every time we append one letter, we create one non-empty sequence and count it
That is exactly what the problem asks.
Why there is no explicit base case¶
You may expect something like:
But here the base case is implicit.
If no letter has positive count, then the loop does nothing:
So total stays 0, and the function returns 0.
That means:
- "from this state, there are no more longer sequences to build"
For example at "AAB":
- counts are
A:0, B:0 - no choice possible
- return
0
That is the stopping point.
Your trace is hard to read because one crucial thing is missing¶
Your trace prints:
countschtotal
But not:
- the current sequence being built
- the recursion depth
Without those, many different calls look almost the same.
A much better debug version is this:
from collections import Counter
class Solution:
def numTilePossibilities(self, tiles: str) -> int:
counts = Counter(tiles)
def dfs(path: str, depth: int) -> int:
indent = " " * depth
print(f"{indent}ENTER path={path!r}, counts={dict(counts)}")
total = 0
for ch in counts:
if counts[ch] == 0:
continue
print(f"{indent}choose {ch!r}")
counts[ch] -= 1
new_path = path + ch
print(f"{indent}formed {new_path!r} -> +1")
total += 1
total += dfs(new_path, depth + 1)
counts[ch] += 1
print(f"{indent}undo {ch!r}, back to path={path!r}")
print(f"{indent}RETURN path={path!r}, total={total}")
return total
return dfs("", 0)
This will show you:
- where you are in the tree
- what sequence has been built
- when you go deeper
- when you come back up
That is the easiest way to see DFS and backtracking.
The shortest correct intuition¶
If you want one compact mental model, use this:
dfs(path)means:- "count all sequences that can be formed by extending
path"
- "count all sequences that can be formed by extending
Inside it:
- try every possible next letter
- each next letter creates one new sequence immediately
- then recursively count longer sequences from there
- undo the choice to try the next letter
So the skeleton is:
for each next possible letter:
use it
count the new sequence
count everything longer starting from it
undo it
Tiny concrete example with "AA"¶
This one is even simpler.
Possible distinct sequences:
AAA
Count = 2.
How the algorithm sees it:
Start:
- counts =
A:2
Choose A:
- formed
"A"->+1
Recurse with counts A:1.
Choose A:
- formed
"AA"->+1
Recurse with counts A:0.
No more choices -> return 0.
So total:
1 + 1 = 2
Why not duplicate "A"?
- because there is only one branch "choose A"
- not two branches "choose first A" and "choose second A"
Tiny concrete example with "AB"¶
Possible sequences:
ABABBA
Count = 4.
Tree:
Count all non-empty nodes:
AABBBA
So 4.
If you want, forget DFS and think "decision tree"¶
Sometimes "DFS" sounds too abstract.
You can think of it as a decision tree:
- every node = current sequence
- every edge = choose one next letter
- recursion = walking that tree
- depth-first = follow one branch fully before moving to the next
- backtracking = walk back up after finishing a branch
This problem becomes much easier if you picture that tree.
The code again, but annotated only with the meaning¶
from collections import Counter
def num_tile_possibilities(tiles: str) -> int:
counts = Counter(tiles)
def dfs() -> int:
total = 0
# Try each distinct letter that is still available.
for ch in counts:
if counts[ch] == 0:
continue
# Use one copy of ch.
counts[ch] -= 1
# We just formed one new non-empty sequence.
total += 1
# Count all longer sequences starting from this one.
total += dfs()
# Undo the choice so other branches start from the old state.
counts[ch] += 1
return total
return dfs()
A hidden detail is:
- the current sequence is not stored explicitly
- but it is implicit in the decremented counts along the recursion path
For learning, it is often better to store it explicitly as path.
Best learning version¶
I recommend this version for understanding:
from collections import Counter
def num_tile_possibilities(tiles: str) -> int:
counts = Counter(tiles)
def dfs(path: str) -> int:
total = 0
for ch in counts:
if counts[ch] == 0:
continue
counts[ch] -= 1
new_path = path + ch
print("formed:", new_path, "remaining:", dict(counts))
total += 1
total += dfs(new_path)
counts[ch] += 1
return total
return dfs("")
For "AAB" it will print the actual sequences as they are formed:
AAAAABABABABBABAA
That often makes everything click.
Final summary¶
What the program is doing:
- it builds sequences one letter at a time
Why recursion is inside the loop:
- because from one state, there are several possible next letters
- for each one, you recursively explore what happens next
Why this is DFS:
- it explores one branch fully before trying the next branch
What backtracking is here:
- choose a letter
- recurse
- restore the count
What is counted:
- every newly formed non-empty sequence
Why duplicates are avoided:
- you branch by letter type and remaining count
- not by physical tile position
If you want to really internalize it, use the path-printing version and trace "AAB" manually side by side with the tree.