LeetCode 3869 - Count Fancy Numbers in a Range
We are given two integers l and r, and we must count how many integers in the inclusive range [l, r] are fancy. The definition of a fancy number is based on the concept of a good number. A number is good if its digits form a strictly monotone sequence.
Difficulty: 🔴 Hard
Topics: Math, Dynamic Programming
Solution
LeetCode 3869 - Count Fancy Numbers in a Range
Problem Understanding
We are given two integers l and r, and we must count how many integers in the inclusive range [l, r] are fancy.
The definition of a fancy number is based on the concept of a good number.
A number is good if its digits form a strictly monotone sequence. This means that the digits are either:
- Strictly increasing, where every digit is larger than the previous digit.
- Strictly decreasing, where every digit is smaller than the previous digit.
Additionally, every single digit number is automatically considered good.
A number is fancy if either:
- The number itself is good.
- The sum of its digits is good.
For example:
1234is good because digits are strictly increasing.4321is good because digits are strictly decreasing.1223is not good because the sequence is not strictly monotone.12340is not good, but its digit sum is10, and10has digits[1,0], which are strictly decreasing. Therefore12340is fancy.
The constraint
1 <= l <= r <= 10^15
is extremely important.
The range can contain up to one quadrillion numbers, making any approach that iterates through every value impossible. We must count valid numbers mathematically rather than enumerate them.
Since 10^15 contains at most 16 decimal digits, this strongly suggests a digit DP solution. Digit DP allows us to count numbers satisfying digit-based properties up to a bound in roughly O(number_of_digits × state_space) time.
Important edge cases include:
- Single digit numbers, which are always good.
- Numbers containing repeated adjacent digits, which immediately break strict monotonicity.
- Numbers whose digit sum is good even though the number itself is not.
- Very large values close to
10^15. - Avoiding double counting numbers that are both good and whose digit sum is also good.
Approaches
Brute Force
The most direct solution is to iterate through every integer in [l, r].
For each number:
- Check whether its digits are strictly increasing.
- Check whether its digits are strictly decreasing.
- If neither is true, compute its digit sum.
- Check whether the digit sum is good.
- Count the number if either condition holds.
This approach is obviously correct because it directly implements the definition.
Unfortunately, it is completely infeasible.
The range may contain up to:
10^15 numbers
and even a constant amount of work per number would take far beyond practical limits.
Key Insight
The crucial observation is that the property depends only on digits.
Instead of examining every number individually, we count:
fancy numbers ≤ X
and then compute:
answer = F(r) - F(l - 1)
This is a standard digit-DP range counting pattern.
A number is fancy when:
(number is good)
OR
(digit_sum(number) is good)
Using inclusion-exclusion:
fancy
=
good_numbers
+
numbers_with_good_digit_sum
-
numbers_satisfying_both
Therefore we need three counting functions:
A(X) = count(good numbers ≤ X)
B(X) = count(numbers ≤ X whose digit sum is good)
C(X) = count(numbers ≤ X that are good and whose digit sum is good)
Then:
F(X) = A(X) + B(X) - C(X)
The key observation that makes this tractable is that:
- Maximum digit count is only 16.
- Maximum digit sum is only:
16 × 9 = 144
Therefore digit-sum states are tiny.
The monotonicity state can also be represented compactly with digit DP.
Approach Comparison
| Approach | Time Complexity | Space Complexity | Notes |
|---|---|---|---|
| Brute Force | O((r-l+1) · 16) | O(1) | Checks every number individually |
| Optimal Digit DP | O(16 × state space) | O(state space) | Counts valid numbers without enumeration |
Algorithm Walkthrough
Precomputation
Before running digit DP, precompute all good digit sums.
Since every number up to 10^15 has at most 16 digits:
maximum digit sum = 144
For every value 0...144, determine whether its decimal representation is good.
Store the result in:
good_sum[s]
Counting Good Numbers
We build a digit DP that processes digits from left to right.
The state stores:
- Current position.
- Whether we are still tight to the upper bound.
- Whether a non-leading digit has started.
- Previous digit.
- Current trend:
- unknown
- increasing
- decreasing
Whenever a new digit is chosen:
- If no previous digit exists, simply record it.
- Otherwise compare against the previous digit.
- Equal digits are forbidden.
- Once a trend is established, future digits must continue following it.
At the end of the number, every formed number is good.
Counting Numbers With Good Digit Sum
Another digit DP tracks:
- Position.
- Current digit sum.
- Tight flag.
At the end:
good_sum[current_sum]
determines whether the constructed number contributes.
Counting Numbers Satisfying Both Conditions
Combine both ideas into a single DP.
Track:
- Position.
- Digit sum.
- Previous digit.
- Monotonic trend.
- Started flag.
- Tight flag.
At the terminal state:
good_sum[digit_sum]
must be true, and the digit sequence must also satisfy the monotonic condition.
Inclusion-Exclusion
For any upper bound X:
- Compute
A(X). - Compute
B(X). - Compute
C(X). - Return:
A(X) + B(X) - C(X)
Finally:
answer = F(r) - F(l-1)
Why it Works
Digit DP enumerates every valid number up to a bound exactly once. The monotonicity state precisely captures whether the currently constructed prefix can still become a strictly increasing or strictly decreasing sequence. The digit-sum state captures the exact sum of digits. Since inclusion-exclusion counts the union of two sets by adding both counts and subtracting their intersection, the final result counts every fancy number exactly once.
Python Solution
from functools import lru_cache
from typing import List
class Solution:
def countFancy(self, l: int, r: int) -> int:
MAX_SUM = 16 * 9
def is_good_value(x: int) -> bool:
s = str(x)
if len(s) == 1:
return True
inc = True
dec = True
for i in range(1, len(s)):
if s[i] <= s[i - 1]:
inc = False
if s[i] >= s[i - 1]:
dec = False
return inc or dec
good_sum = [False] * (MAX_SUM + 1)
for s in range(MAX_SUM + 1):
good_sum[s] = is_good_value(s)
def count_up_to(n: int) -> int:
if n <= 0:
return 0
digits = list(map(int, str(n)))
m = len(digits)
@lru_cache(None)
def count_good(
pos: int,
tight: int,
started: int,
prev: int,
trend: int,
) -> int:
if pos == m:
return int(started)
limit = digits[pos] if tight else 9
ans = 0
for d in range(limit + 1):
ntight = tight and d == limit
if not started and d == 0:
ans += count_good(
pos + 1,
ntight,
0,
10,
0,
)
continue
if not started:
ans += count_good(
pos + 1,
ntight,
1,
d,
0,
)
continue
if d == prev:
continue
if trend == 0:
ntrend = 1 if d > prev else 2
ans += count_good(
pos + 1,
ntight,
1,
d,
ntrend,
)
elif trend == 1:
if d > prev:
ans += count_good(
pos + 1,
ntight,
1,
d,
1,
)
else:
if d < prev:
ans += count_good(
pos + 1,
ntight,
1,
d,
2,
)
return ans
@lru_cache(None)
def count_sum(
pos: int,
tight: int,
digit_sum: int,
) -> int:
if pos == m:
return int(good_sum[digit_sum])
limit = digits[pos] if tight else 9
ans = 0
for d in range(limit + 1):
ans += count_sum(
pos + 1,
tight and d == limit,
digit_sum + d,
)
return ans
@lru_cache(None)
def count_both(
pos: int,
tight: int,
started: int,
prev: int,
trend: int,
digit_sum: int,
) -> int:
if pos == m:
return int(started and good_sum[digit_sum])
limit = digits[pos] if tight else 9
ans = 0
for d in range(limit + 1):
ntight = tight and d == limit
if not started and d == 0:
ans += count_both(
pos + 1,
ntight,
0,
10,
0,
digit_sum,
)
continue
if not started:
ans += count_both(
pos + 1,
ntight,
1,
d,
0,
digit_sum + d,
)
continue
if d == prev:
continue
if trend == 0:
ntrend = 1 if d > prev else 2
ans += count_both(
pos + 1,
ntight,
1,
d,
ntrend,
digit_sum + d,
)
elif trend == 1:
if d > prev:
ans += count_both(
pos + 1,
ntight,
1,
d,
1,
digit_sum + d,
)
else:
if d < prev:
ans += count_both(
pos + 1,
ntight,
1,
d,
2,
digit_sum + d,
)
return ans
a = count_good(0, 1, 0, 10, 0)
b = count_sum(0, 1, 0)
c = count_both(0, 1, 0, 10, 0, 0)
return a + b - c
return count_up_to(r) - count_up_to(l - 1)
The implementation follows the inclusion-exclusion formula directly.
The helper is_good_value determines whether a digit sequence is strictly increasing or strictly decreasing. Since digit sums never exceed 144, all good digit sums are precomputed once.
count_good performs a digit DP that counts numbers whose digits are strictly monotone.
count_sum performs a digit DP that only tracks digit sums and checks whether the final sum belongs to the precomputed good set.
count_both merges both state spaces and counts numbers satisfying both properties simultaneously.
The final result for a bound n is:
good + good_digit_sum - intersection
and the requested range answer is obtained using:
F(r) - F(l-1)
Go Solution
package main
func countFancy(l int64, r int64) int64 {
const maxSum = 16 * 9
goodSum := make([]bool, maxSum+1)
isGoodValue := func(x int) bool {
s := []byte{}
if x == 0 {
s = append(s, '0')
} else {
tmp := x
digits := []byte{}
for tmp > 0 {
digits = append(digits, byte(tmp%10)+'0')
tmp /= 10
}
for i := len(digits) - 1; i >= 0; i-- {
s = append(s, digits[i])
}
}
if len(s) == 1 {
return true
}
inc := true
dec := true
for i := 1; i < len(s); i++ {
if s[i] <= s[i-1] {
inc = false
}
if s[i] >= s[i-1] {
dec = false
}
}
return inc || dec
}
for i := 0; i <= maxSum; i++ {
goodSum[i] = isGoodValue(i)
}
var countUpTo func(int64) int64
countUpTo = func(n int64) int64 {
if n <= 0 {
return 0
}
digits := []int{}
tmp := n
for tmp > 0 {
digits = append(digits, int(tmp%10))
tmp /= 10
}
for i, j := 0, len(digits)-1; i < j; i, j = i+1, j-1 {
digits[i], digits[j] = digits[j], digits[i]
}
type State struct {
pos, tight, started, prev, trend, sum int
}
memoGood := map[[5]int]int64{}
var dfsGood func(int, int, int, int, int) int64
dfsGood = func(pos, tight, started, prev, trend int) int64 {
if pos == len(digits) {
if started == 1 {
return 1
}
return 0
}
key := [5]int{pos, tight, started, prev, trend}
if v, ok := memoGood[key]; ok {
return v
}
limit := 9
if tight == 1 {
limit = digits[pos]
}
var ans int64
for d := 0; d <= limit; d++ {
ntight := 0
if tight == 1 && d == limit {
ntight = 1
}
if started == 0 && d == 0 {
ans += dfsGood(pos+1, ntight, 0, 10, 0)
continue
}
if started == 0 {
ans += dfsGood(pos+1, ntight, 1, d, 0)
continue
}
if d == prev {
continue
}
if trend == 0 {
ntrend := 1
if d < prev {
ntrend = 2
}
ans += dfsGood(pos+1, ntight, 1, d, ntrend)
} else if trend == 1 {
if d > prev {
ans += dfsGood(pos+1, ntight, 1, d, 1)
}
} else {
if d < prev {
ans += dfsGood(pos+1, ntight, 1, d, 2)
}
}
}
memoGood[key] = ans
return ans
}
memoSum := map[[3]int]int64{}
var dfsSum func(int, int, int) int64
dfsSum = func(pos, tight, sum int) int64 {
if pos == len(digits) {
if goodSum[sum] {
return 1
}
return 0
}
key := [3]int{pos, tight, sum}
if v, ok := memoSum[key]; ok {
return v
}
limit := 9
if tight == 1 {
limit = digits[pos]
}
var ans int64
for d := 0; d <= limit; d++ {
ntight := 0
if tight == 1 && d == limit {
ntight = 1
}
ans += dfsSum(pos+1, ntight, sum+d)
}
memoSum[key] = ans
return ans
}
memoBoth := map[State]int64{}
var dfsBoth func(int, int, int, int, int, int) int64
dfsBoth = func(pos, tight, started, prev, trend, sum int) int64 {
if pos == len(digits) {
if started == 1 && goodSum[sum] {
return 1
}
return 0
}
key := State{pos, tight, started, prev, trend, sum}
if v, ok := memoBoth[key]; ok {
return v
}
limit := 9
if tight == 1 {
limit = digits[pos]
}
var ans int64
for d := 0; d <= limit; d++ {
ntight := 0
if tight == 1 && d == limit {
ntight = 1
}
if started == 0 && d == 0 {
ans += dfsBoth(pos+1, ntight, 0, 10, 0, sum)
continue
}
if started == 0 {
ans += dfsBoth(pos+1, ntight, 1, d, 0, sum+d)
continue
}
if d == prev {
continue
}
if trend == 0 {
ntrend := 1
if d < prev {
ntrend = 2
}
ans += dfsBoth(pos+1, ntight, 1, d, ntrend, sum+d)
} else if trend == 1 {
if d > prev {
ans += dfsBoth(pos+1, ntight, 1, d, 1, sum+d)
}
} else {
if d < prev {
ans += dfsBoth(pos+1, ntight, 1, d, 2, sum+d)
}
}
}
memoBoth[key] = ans
return ans
}
a := dfsGood(0, 1, 0, 10, 0)
b := dfsSum(0, 1, 0)
c := dfsBoth(0, 1, 0, 10, 0, 0)
return a + b - c
}
return countUpTo(r) - countUpTo(l-1)
}
The Go version mirrors the Python logic exactly. Maps are used instead of lru_cache, and all counts use int64 to safely accommodate values up to the size of the search space.
Worked Examples
Example 1
l = 8
r = 10
Numbers in range:
| Number | Good? | Digit Sum | Sum Good? | Fancy? |
|---|---|---|---|---|
| 8 | Yes | 8 | Yes | Yes |
| 9 | Yes | 9 | Yes | Yes |
| 10 | Yes (1>0) | 1 | Yes | Yes |
Result:
3
Example 2
l = 12340
r = 12341
For 12340:
| Property | Value |
|---|---|
| Digits | 1 2 3 4 0 |
| Good? | No |
| Digit Sum | 10 |
| Is 10 Good? | Yes |
| Fancy? | Yes |
For 12341:
| Property | Value |
|---|---|
| Digits | 1 2 3 4 1 |
| Good? | No |
| Digit Sum | 11 |
| Is 11 Good? | No |
| Fancy? | No |
Answer:
1
Example 3
l = 123456788
r = 123456788
| Property | Value |
|---|---|
| Digits | 1 2 3 4 5 6 7 8 8 |
| Good? | No |
| Digit Sum | 44 |
| Is 44 Good? | No |
| Fancy? | No |
Answer:
0
Complexity Analysis
| Measure | Complexity | Explanation |
|---|---|---|
| Time | O(D × S) | Digit DP visits each state once |
| Space | O(D × S) | Memoization tables store DP states |
Where:
D ≤ 16is the number of digits.Sis the combined state space involving digit sum, trend, previous digit, and tight status.
Since digit sum never exceeds 144, the total state space remains small, making the solution easily fast enough.
Test Cases
sol = Solution()
assert sol.countFancy(8, 10) == 3 # example 1
assert sol.countFancy(12340, 12341) == 1 # example 2
assert sol.countFancy(123456788, 123456788) == 0 # example 3
assert sol.countFancy(1, 9) == 9 # all single digits
assert sol.countFancy(10, 10) == 1 # strictly decreasing
assert sol.countFancy(12, 12) == 1 # strictly increasing
assert sol.countFancy(11, 11) == 0 # repeated digits
assert sol.countFancy(21, 21) == 1 # decreasing
assert sol.countFancy(123, 123) == 1 # increasing
assert sol.countFancy(321, 321) == 1 # decreasing
assert sol.countFancy(100, 100) == 1 # digit sum = 1
assert sol.countFancy(98, 102) >= 0 # range crossing digit length
assert sol.countFancy(999999999999999, 999999999999999) >= 0 # upper bound stress
assert sol.countFancy(1, 1000) >= sol.countFancy(1, 100) # monotonic range growth
Test Summary
| Test | Why |
|---|---|
(8,10) |
Official example 1 |
(12340,12341) |
Official example 2 |
(123456788,123456788) |
Official example 3 |
(1,9) |
Single digit numbers |
(10,10) |
Strictly decreasing two-digit number |
(12,12) |
Strictly increasing two-digit number |
(11,11) |
Repeated digit rejection |
(21,21) |
Basic decreasing sequence |
(123,123) |
Basic increasing sequence |
(321,321) |
Multi-digit decreasing sequence |
(100,100) |
Not good itself, good digit sum |
(98,102) |
Crosses power of ten boundary |
| Large upper bound | Maximum constraint stress |
| Nested ranges | Consistency check |
Edge Cases
Single-Digit Numbers
Every single-digit number is automatically good. A common bug is treating monotonicity as requiring at least two digits and accidentally rejecting values from 1 to 9. The implementation explicitly treats all one-digit numbers as good, both in the precomputation and in the digit-DP construction.
Repeated Digits
Strict monotonicity does not allow equality. Numbers such as 11, 122, and 4331 are not good. During DP transitions, any choice where current_digit == previous_digit is immediately discarded, ensuring strictness is enforced.
Numbers That Are Not Good but Have Good Digit Sums
Values such as 12340 are the main reason the problem is interesting. The number itself is not monotone, but its digit sum is good. The inclusion-exclusion formulation correctly counts such numbers through the digit-sum DP even when they are excluded by the monotonic DP.
Large Values Near 10^15
A brute-force solution would be impossible near the upper bound. The digit DP only depends on the number of digits and the digit-sum state space, both of which remain tiny even at 10^15. This guarantees efficient performance across the entire input range.
Numbers Satisfying Both Conditions
A number such as 123 is good, and its digit sum 6 is also good. Without inclusion-exclusion it would be counted twice. The intersection DP computes exactly those numbers satisfying both properties, and subtracting that count removes all duplicates.