Problem 1004: Balanced Integer

View on Project Euler

Project Euler Problem 1004 Solution

EulerSolve provides an optimized solution for Project Euler Problem 1004, Balanced Integer, with C++, Python, Java, and a step-by-step mathematical explanation.

Problem Summary A positive integer is called balanced when two subsequence lengths in its decimal digit word are equal: the length of the longest strictly decreasing subsequence and the length of the longest non-strictly increasing subsequence. For example, \(77429\) is balanced because \(742\) is a strictly decreasing subsequence of length \(3\), while \(779\) is a non-strictly increasing subsequence of length \(3\). The problem gives the checkpoint that there are \(2274\) balanced positive integers below \(10^4\), and asks for the total number of balanced positive integers modulo \(10^9+7\). The word "total" is not a typo. There are infinitely many positive integers, but only finitely many balanced ones. The solution has to explain that finiteness and then count all of them without iterating through integers. Mathematical Approach Turning integers into digit words Write the decimal expansion of a positive integer as a word \(w\) over the ordered alphabet \(\{0,1,\dots,9\}\), with no leading zero. Let $$I(w)=\text{length of the longest non-strictly increasing subsequence},$$ and $$D(w)=\text{length of the longest strictly decreasing subsequence}.$$ The integer is balanced exactly when \(I(w)=D(w)\). Direct dynamic programming over all words is not viable because the length is not fixed in the statement....

Detailed mathematical approach

Problem Summary

A positive integer is called balanced when two subsequence lengths in its decimal digit word are equal: the length of the longest strictly decreasing subsequence and the length of the longest non-strictly increasing subsequence.

For example, \(77429\) is balanced because \(742\) is a strictly decreasing subsequence of length \(3\), while \(779\) is a non-strictly increasing subsequence of length \(3\). The problem gives the checkpoint that there are \(2274\) balanced positive integers below \(10^4\), and asks for the total number of balanced positive integers modulo \(10^9+7\).

The word "total" is not a typo. There are infinitely many positive integers, but only finitely many balanced ones. The solution has to explain that finiteness and then count all of them without iterating through integers.

Mathematical Approach

Turning integers into digit words

Write the decimal expansion of a positive integer as a word \(w\) over the ordered alphabet \(\{0,1,\dots,9\}\), with no leading zero. Let

$$I(w)=\text{length of the longest non-strictly increasing subsequence},$$

and

$$D(w)=\text{length of the longest strictly decreasing subsequence}.$$

The integer is balanced exactly when \(I(w)=D(w)\). Direct dynamic programming over all words is not viable because the length is not fixed in the statement. The key is to replace each word by the Young diagram that records these two extremal subsequence lengths at once.

The RSK correspondence

The Robinson-Schensted-Knuth correspondence maps every word \(w\) over a totally ordered alphabet to a partition \(\lambda\), also called the insertion shape. For the version used for words, the first row of \(\lambda\) records the longest weakly increasing subsequence, and the first column records the longest strictly decreasing subsequence:

$$\lambda_1=I(w),\qquad \lambda'_1=D(w).$$

If \(\ell(\lambda)\) denotes the number of rows of the partition, then \(\lambda'_1=\ell(\lambda)\). Therefore a word is balanced exactly when

$$\lambda_1=\ell(\lambda).$$

So the task becomes a sum over Young diagram shapes whose width equals their height. This is why the C++ code never explicitly computes subsequences for long words; it counts all words having each possible RSK shape.

Why there are only finitely many balanced integers

The digit alphabet has only \(10\) symbols. A strictly decreasing subsequence can contain each digit at most once, so \(D(w)\le 10\). If \(w\) is balanced, then \(I(w)=D(w)\le 10\) as well.

Under RSK, this means both the width and the height of \(\lambda\) are at most \(10\). Hence \(\lambda\) fits inside a \(10\times10\) square, and the word length \(|w|=|\lambda|\) is at most \(100\). Every balanced positive integer therefore has at most \(100\) decimal digits. The problem is finite, and the code sets MAX_CELLS to \(10\cdot10=100\).

Counting words of a fixed shape

For a fixed shape \(\lambda\) with \(n=|\lambda|\) cells, RSK gives a pair \((P,Q)\). Here \(Q\) is a standard Young tableau of shape \(\lambda\), while \(P\) is a semistandard Young tableau of the same shape using the \(10\) digit symbols.

The number of standard Young tableaux is given by the hook-length formula:

$$f^\lambda=\frac{n!}{\prod_{c\in\lambda}h(c)}.$$

The number of semistandard tableaux over an alphabet of size \(10\) is the hook-content formula:

$$s_\lambda(1^{10})=\prod_{(i,j)\in\lambda}\frac{10+j-i}{h(i,j)},$$

where rows and columns are indexed from \(1\). Thus the number of digit words with RSK shape \(\lambda\) is

$$N(\lambda)=f^\lambda s_\lambda(1^{10}) =n!\prod_{(i,j)\in\lambda}\frac{10+j-i}{h(i,j)^2}.$$

This is exactly what shape_word_count evaluates modulo \(10^9+7\): it multiplies all hook lengths, multiplies all contents \(10+j-i\), and uses modular inverses for the two hook products.

Enumerating only possible shapes

A partition is stored as non-increasing row lengths \(\lambda_1\ge\lambda_2\ge\cdots\). The recursive enumeration chooses the next row length no larger than the previous one, stops after \(10\) rows, and rejects any shape with more than \(100\) cells.

For every generated shape the code records two sums. If width equals height, it contributes to the count of balanced digit words. If height equals width plus one, it contributes to a separate correction term used for leading zeros:

$$\lambda_1=\ell(\lambda)\quad\text{or}\quad \ell(\lambda)=\lambda_1+1.$$

Only these two categories are needed by the final positive-integer count.

Removing leading-zero representations

The hook-content count includes all digit words over \(\{0,\dots,9\}\), including words that start with \(0\). A positive integer, however, has a unique decimal representation with no leading zero.

Let \(B(k)\) be the number of balanced digit words of length at most \(k\), counted with leading zeros allowed. Consider a word \(0v\). Because the leading \(0\) is the smallest possible digit, it can be placed before any non-strictly increasing subsequence of \(v\), so

$$I(0v)=I(v)+1.$$

It cannot extend a strictly decreasing subsequence, since no later digit is smaller than \(0\), so

$$D(0v)=D(v).$$

Therefore \(0v\) is balanced exactly when \(D(v)=I(v)+1\), which is the shape condition \(\ell(\lambda)=\lambda_1+1\). The invalid balanced words with a leading zero and total length at most \(k\) are counted by that "decreasing excess" category among tails of length at most \(k-1\).

The one-digit word \(0\) also has to be removed because it represents zero, not a positive integer. Consequently the final count for at most \(k\) digits is

$$T(k)=B(k)-E(k-1)-1,$$

where \(E(k-1)\) is the sum over shapes with height exactly one more than width and at most \(k-1\) cells. The checkpoint \(T(4)=2274\) matches the value given in the problem statement.

How the Code Works

factorial precomputes \(n!\) modulo \(10^9+7\) up to \(100\). mod_pow and mod_inverse provide modular division, using Fermat's little theorem because the modulus is prime.

shape_word_count loops over every cell of a partition. For each cell it computes the hook length \(h(i,j)\), the content factor \(10+j-i\), and then combines the hook-length and hook-content formulas into \(N(\lambda)\).

enumerate_partitions recursively lists all partitions fitting inside the \(10\times10\) box. It adds the shape count to balanced when width equals height, and to decreasing_excess when height is one larger than width.

positive_balanced_count(k) first counts all balanced words of length at most \(k\). It then subtracts the leading-zero correction computed from tails of length at most \(k-1\), and subtracts the single word \(0\). The program checks \(k=4\), then evaluates \(k=100\).

Complexity Analysis

The algorithm enumerates partitions inside a \(10\times10\) box, not integers. This is a tiny finite set compared with \(10^{100}\) possible digit strings. For each shape, at most \(100\) cells are processed.

In asymptotic terms for an alphabet of size \(a\), the method enumerates partitions inside an \(a\times a\) square and processes \(O(a^2)\) cells per shape. In this problem \(a=10\), so the running time and memory usage are effectively constant.

Footnotes and References

  1. Problem page: Project Euler 1004 - Balanced Integer
  2. RSK correspondence: Wikipedia - Robinson-Schensted-Knuth correspondence
  3. Young tableau: Wikipedia - Young tableau
  4. Hook-length formula: Wikipedia - Hook-length formula
  5. Schur polynomial: Wikipedia - Schur polynomial

Problem 1004 source code

C++

#include <array>
#include <cassert>
#include <cstdint>
#include <iostream>
#include <vector>

namespace {

using i64 = std::int64_t;

constexpr i64 MOD = 1'000'000'007LL;
constexpr int DIGITS = 10;
constexpr int MAX_CELLS = DIGITS * DIGITS;

std::array<i64, MAX_CELLS + 1> factorial;

i64 mod_pow(i64 base, i64 exp) {
    i64 result = 1;
    while (exp > 0) {
        if ((exp & 1LL) != 0) {
            result = result * base % MOD;
        }
        base = base * base % MOD;
        exp >>= 1LL;
    }
    return result;
}

i64 mod_inverse(const i64 value) {
    return mod_pow(value, MOD - 2);
}

i64 shape_word_count(const std::vector<int>& partition) {
    int cells = 0;
    for (const int row : partition) {
        cells += row;
    }

    i64 hook_product = 1;
    i64 content_product = 1;
    for (int i = 0; i < static_cast<int>(partition.size()); ++i) {
        for (int j = 0; j < partition[i]; ++j) {
            int below = 0;
            for (int r = i + 1; r < static_cast<int>(partition.size()); ++r) {
                if (partition[r] > j) {
                    ++below;
                }
            }

            const int right = partition[i] - j - 1;
            const int hook = right + below + 1;
            const int content = DIGITS + (j + 1) - (i + 1);
            hook_product = hook_product * hook % MOD;
            content_product = content_product * content % MOD;
        }
    }

    const i64 inv_hooks = mod_inverse(hook_product);
    return factorial[cells] * content_product % MOD * inv_hooks % MOD * inv_hooks % MOD;
}

void enumerate_partitions(const int max_part,
                          const int max_rows,
                          std::vector<int>& partition,
                          const int max_cells,
                          i64& balanced,
                          i64& decreasing_excess) {
    if (!partition.empty()) {
        int cells = 0;
        for (const int row : partition) {
            cells += row;
        }

        if (cells <= max_cells) {
            const i64 ways = shape_word_count(partition);
            const int width = partition.front();
            const int height = static_cast<int>(partition.size());
            if (width == height) {
                balanced += ways;
                if (balanced >= MOD) {
                    balanced -= MOD;
                }
            }
            if (height == width + 1) {
                decreasing_excess += ways;
                if (decreasing_excess >= MOD) {
                    decreasing_excess -= MOD;
                }
            }
        }
    }

    if (static_cast<int>(partition.size()) == max_rows) {
        return;
    }

    int used = 0;
    for (const int row : partition) {
        used += row;
    }

    for (int next = max_part; next >= 1; --next) {
        if (used + next > max_cells) {
            continue;
        }
        partition.push_back(next);
        enumerate_partitions(next, max_rows, partition, max_cells, balanced, decreasing_excess);
        partition.pop_back();
    }
}

std::pair<i64, i64> count_all_words(const int max_cells) {
    i64 balanced = 0;
    i64 decreasing_excess = 0;
    std::vector<int> partition;
    enumerate_partitions(DIGITS, DIGITS, partition, max_cells, balanced, decreasing_excess);
    return {balanced, decreasing_excess};
}

i64 positive_balanced_count(const int max_digits) {
    const auto [balanced, ignored] = count_all_words(max_digits);
    const auto [unused, decreasing_excess] = count_all_words(max_digits - 1);
    (void)ignored;
    (void)unused;

    i64 result = (balanced - decreasing_excess - 1) % MOD;
    if (result < 0) {
        result += MOD;
    }
    return result;
}

void run_checkpoints() {
    assert(positive_balanced_count(4) == 2274);
}

}  // namespace

int main() {
    factorial[0] = 1;
    for (int i = 1; i <= MAX_CELLS; ++i) {
        factorial[i] = factorial[i - 1] * i % MOD;
    }

    run_checkpoints();
    std::cout << positive_balanced_count(MAX_CELLS) << '\n';
    return 0;
}

Python

#!/usr/bin/env python3
"""Project Euler Problem 1004 - Balanced Integer."""

from __future__ import annotations

MOD = 1_000_000_007
DIGITS = 10
MAX_CELLS = DIGITS * DIGITS

factorial = [1] * (MAX_CELLS + 1)
for i in range(1, MAX_CELLS + 1):
    factorial[i] = factorial[i - 1] * i % MOD


def mod_inverse(value: int) -> int:
    return pow(value, MOD - 2, MOD)


def shape_word_count(partition: list[int]) -> int:
    cells = sum(partition)
    hook_product = 1
    content_product = 1

    for i, row_len in enumerate(partition):
        for j in range(row_len):
            below = sum(1 for later_row in partition[i + 1 :] if later_row > j)
            right = row_len - j - 1
            hook = right + below + 1
            content = DIGITS + (j + 1) - (i + 1)
            hook_product = hook_product * hook % MOD
            content_product = content_product * content % MOD

    inv_hooks = mod_inverse(hook_product)
    return factorial[cells] * content_product % MOD * inv_hooks % MOD * inv_hooks % MOD


def enumerate_partitions(
    max_part: int,
    max_rows: int,
    partition: list[int],
    max_cells: int,
) -> tuple[int, int]:
    balanced = 0
    decreasing_excess = 0

    if partition:
        cells = sum(partition)
        if cells <= max_cells:
            ways = shape_word_count(partition)
            width = partition[0]
            height = len(partition)
            if width == height:
                balanced = (balanced + ways) % MOD
            if height == width + 1:
                decreasing_excess = (decreasing_excess + ways) % MOD

    if len(partition) == max_rows:
        return balanced, decreasing_excess

    used = sum(partition)
    for next_part in range(max_part, 0, -1):
        if used + next_part > max_cells:
            continue
        partition.append(next_part)
        sub_balanced, sub_excess = enumerate_partitions(
            next_part, max_rows, partition, max_cells
        )
        balanced = (balanced + sub_balanced) % MOD
        decreasing_excess = (decreasing_excess + sub_excess) % MOD
        partition.pop()

    return balanced, decreasing_excess


def count_all_words(max_cells: int) -> tuple[int, int]:
    return enumerate_partitions(DIGITS, DIGITS, [], max_cells)


def positive_balanced_count(max_digits: int) -> int:
    balanced, _ = count_all_words(max_digits)
    _, decreasing_excess = count_all_words(max_digits - 1)
    return (balanced - decreasing_excess - 1) % MOD


def run_checkpoints() -> None:
    assert positive_balanced_count(4) == 2274


def main() -> None:
    run_checkpoints()
    print(positive_balanced_count(MAX_CELLS))


if __name__ == "__main__":
    main()

Java

import java.util.ArrayList;
import java.util.List;

public class Euler1004 {
    private static final long MOD = 1_000_000_007L;
    private static final int DIGITS = 10;
    private static final int MAX_CELLS = DIGITS * DIGITS;
    private static final long[] FACTORIAL = new long[MAX_CELLS + 1];

    private static final class Counts {
        long balanced;
        long decreasingExcess;
    }

    private static long modPow(long base, long exp) {
        long result = 1;
        while (exp > 0) {
            if ((exp & 1L) != 0) {
                result = result * base % MOD;
            }
            base = base * base % MOD;
            exp >>= 1;
        }
        return result;
    }

    private static long modInverse(long value) {
        return modPow(value, MOD - 2);
    }

    private static long shapeWordCount(List<Integer> partition) {
        int cells = 0;
        for (int row : partition) {
            cells += row;
        }

        long hookProduct = 1;
        long contentProduct = 1;
        for (int i = 0; i < partition.size(); ++i) {
            int rowLength = partition.get(i);
            for (int j = 0; j < rowLength; ++j) {
                int below = 0;
                for (int r = i + 1; r < partition.size(); ++r) {
                    if (partition.get(r) > j) {
                        ++below;
                    }
                }

                int right = rowLength - j - 1;
                int hook = right + below + 1;
                int content = DIGITS + (j + 1) - (i + 1);
                hookProduct = hookProduct * hook % MOD;
                contentProduct = contentProduct * content % MOD;
            }
        }

        long invHooks = modInverse(hookProduct);
        return FACTORIAL[cells] * contentProduct % MOD * invHooks % MOD * invHooks % MOD;
    }

    private static Counts enumeratePartitions(
            int maxPart,
            int maxRows,
            List<Integer> partition,
            int maxCells) {
        Counts result = new Counts();

        if (!partition.isEmpty()) {
            int cells = 0;
            for (int row : partition) {
                cells += row;
            }

            if (cells <= maxCells) {
                long ways = shapeWordCount(partition);
                int width = partition.get(0);
                int height = partition.size();
                if (width == height) {
                    result.balanced = (result.balanced + ways) % MOD;
                }
                if (height == width + 1) {
                    result.decreasingExcess = (result.decreasingExcess + ways) % MOD;
                }
            }
        }

        if (partition.size() == maxRows) {
            return result;
        }

        int used = 0;
        for (int row : partition) {
            used += row;
        }

        for (int next = maxPart; next >= 1; --next) {
            if (used + next > maxCells) {
                continue;
            }
            partition.add(next);
            Counts child = enumeratePartitions(next, maxRows, partition, maxCells);
            result.balanced = (result.balanced + child.balanced) % MOD;
            result.decreasingExcess = (result.decreasingExcess + child.decreasingExcess) % MOD;
            partition.remove(partition.size() - 1);
        }

        return result;
    }

    private static Counts countAllWords(int maxCells) {
        return enumeratePartitions(DIGITS, DIGITS, new ArrayList<>(), maxCells);
    }

    private static long positiveBalancedCount(int maxDigits) {
        Counts all = countAllWords(maxDigits);
        Counts leadingZeroTails = countAllWords(maxDigits - 1);
        long result = (all.balanced - leadingZeroTails.decreasingExcess - 1) % MOD;
        if (result < 0) {
            result += MOD;
        }
        return result;
    }

    private static void runCheckpoints() {
        if (positiveBalancedCount(4) != 2274) {
            throw new AssertionError("checkpoint failed");
        }
    }

    public static void main(String[] args) {
        FACTORIAL[0] = 1;
        for (int i = 1; i <= MAX_CELLS; ++i) {
            FACTORIAL[i] = FACTORIAL[i - 1] * i % MOD;
        }

        runCheckpoints();
        System.out.println(positiveBalancedCount(MAX_CELLS));
    }
}