Hướng dẫn giải của Tổng các số từ L đến R


Chỉ dùng lời giải này khi không có ý tưởng, và đừng copy-paste code từ lời giải này. Hãy tôn trọng người ra đề và người viết lời giải.
Nộp một lời giải chính thức trước khi tự giải là một hành động có thể bị ban.

Lời giải này đang bị ẩn cho đến khi bạn chọn mở ra.

Chúng tôi khuyên bạn nên tự thử giải bài trước. Việc mở lời giải có thể làm lộ mất ý tưởng chính trước khi bạn có cơ hội tự giải.

Bạn phải đăng nhập để mở lời giải này.

Đăng nhập

Tác giả: Hiếu Nguyễn, Kael, WTrungs, Nwos9706

Editorial for dpd1: Tổng các số từ L đến R

Hiểu bài toán

Bài toán yêu cầu tính tổng các số nguyên dương trong đoạn [L, R] (1 ≤ L ≤ R ≤ 10^18) mà số lượng chữ số phân biệt của chúng không vượt quá k (k ≤ 10). Kết quả cần được in ra dưới dạng lấy dư cho 998244353.

Ví dụ: L=10, R=50, k=2. Các số từ 10 đến 50 có số chữ số phân biệt ≤ 2:

  • 10, 11, ..., 19 (chữ số {1,0} hoặc {1})
  • 20, ..., 29 (chữ số {2,0})
  • ...
  • 50 (chữ số {5,0})
  • Các số như 100, 101... không nằm trong đoạn [10, 50]. Tổng các số này là 1230.

Bài toán này có thể được giải quyết bằng kỹ thuật Quy hoạch động có giới hạn (Digit DP).

Các cách tiếp cận

Cách Quy hoạch động theo Digit (Digit DP) - Gộp đếm và tính tổng
#include <bits/stdc++.h>
using namespace std;

typedef long long ll;
const int MOD = 998244353;

ll L, R, k;
ll arr[22]; // Lưu các chữ số của số đang xét
ll pow10[22]; // Mảng lũy thừa 10

// F[id][mask][leadingZero]: pair<count, sum>
// id: vị trí chữ số đang xét (từ cao xuống thấp)
// mask: bitset thể hiện các chữ số đã xuất hiện
// leadingZero: boolean, true nếu các chữ số trước đều là 0
pair<ll, ll> f[22][1 << 10][2];
bool exist[22][1 << 10][2];

pair<ll, ll> Calc(int id, int mask, bool tight, bool leadingZero) {
    if (id == 0) { // Đã xét hết các chữ số
        // Nếu không phải toàn bộ số 0 và số lượng chữ số phân biệt <= k
        if (!leadingZero && __builtin_popcount(mask) <= k) {
            return {1, 0}; // 1 cách, tổng là 0 (vì đây là chữ số cuối)
        }
        return {0, 0};
    }

    // Nếu không bị giới hạn (tight) và đã tính rồi thì trả về kết quả nhớ
    if (!tight && exist[id][mask][leadingZero]) {
        return f[id][mask][leadingZero];
    }

    pair<ll, ll> res = {0, 0};
    int maxDigit = tight ? arr[id] : 9;

    for (int digit = 0; digit <= maxDigit; digit++) {
        bool newTight = tight && (digit == maxDigit);
        bool newLz = leadingZero && (digit == 0);
        int newMask = mask;

        // Nếu không phải là số 0 đứng đầu thì cập nhật mask
        if (!newLz) {
            newMask |= (1 << digit);
        }

        pair<ll, ll> cur = Calc(id - 1, newTight, newMask, newLz);

        // Cập nhật số lượng
        res.first = (res.first + cur.first) % MOD;

        // Cập nhật tổng
        // cur.second là tổng các số tạo thành từ các chữ số sau
        // cur.first là số lượng các số đó
        // digit * pow10[id-1] là giá trị đóng góp của chữ số hiện tại
        res.second = (res.second + cur.second + (ll)digit * pow10[id - 1] % MOD * cur.first % MOD) % MOD;
    }

    if (!tight) {
        exist[id][mask][leadingZero] = true;
        f[id][mask][leadingZero] = res;
    }
    return res;
}

ll solve(ll n) {
    if (n == 0) return 0;
    // Phân tích n thành các chữ số
    int len = 0;
    while (n > 0) {
        arr[++len] = n % 10;
        n /= 10;
    }
    // Đảo mảng để arr[1] là chữ số cao nhất
    for (int i = 1; i <= len / 2; i++) swap(arr[i], arr[len - i + 1]);

    // Reset DP state (hoặc chỉ cần thay đổi cách đánh dấu)
    // Ở đây ta dùng mảng exist, cần clear trước
    memset(exist, 0, sizeof(exist));

    return Calc(len, 0, true, true).second;
}

int main() {
    // Tính lũy thừa 10
    pow10[0] = 1;
    for (int i = 1; i <= 19; i++) pow10[i] = (pow10[i-1] * 10) % MOD;

    cin >> L >> R >> k;

    ll ansR = solve(R);
    ll ansL = solve(L - 1);

    ll ans = (ansR - ansL + MOD) % MOD;
    cout << ans << endl;
    return 0;
}
  • Time Complexity: O(19 * 1024 * 2 * 10) ~ 400,000 phép tính cho mỗi truy vấn (R hoặc L-1)
  • Space Complexity: O(19 * 1024 * 2) ~ 40KB

Đây là phương pháp chuẩn để giải các bài toán đếm hoặc tính tổng trên một đoạn số với điều kiện phức tạp.

  1. Chuyển đổi bài toán: Tính tổng các số thỏa mãn trong đoạn [1, R] trừ đi tổng các số trong đoạn [1, L-1].
  2. Quy hoạch động: Hàm Calc(id, mask, tight, leadingZero) trả về một cặp (số lượng, tổng) các số có thể tạo ra từ vị trí id trở về sau.
    • mask: Dùng bitset để lưu các chữ số đã xuất hiện (ví dụ bit 0 bật nếu chữ số 0 đã xuất hiện).
    • tight: Biến giới hạn, nếu true thì chữ số hiện tại không được vượt quá chữ số tương ứng của n.
    • leadingZero: Xử lý các số 0 đứng đầu (ví dụ số 5 là 00...05).
  3. Công thức chuyển trạng thái: Với mỗi chữ số d có thể chọn:
    • Gọi đệ quy để lấy (count, sum) của phần đuôi.
    • Tổng mới = sum + d * 10^(id-1) * count + sum của đuôi.
  4. Độ phức tạp: Do k tối đa là 10, nên số lượng các tổ hợp chữ số phân biệt là tổng tổ hợp của 10 chữ số, nhưng thực tế mask có 2^10 = 1024 trạng thái. Độ dài số là 19. Nên độ phức tạp chấp nhận được.
Cách Quy hoạch động tách biệt (Đếm và Tính tổng riêng)
#include <iostream>
#include <string>
#include <vector>
#include <cstring>
#include <algorithm>

using namespace std;

typedef long long ll;
const int MOD = 998244353;

string S;
int K;
ll power_of_10[20];

// dp[pos][mask][tight][is_leading_zero]
// Lưu số lượng số hợp lệ
ll dp_cnt[20][1 << 10][2][2];
// Lưu tổng của các số hợp lệ
ll dp_sum[20][1 << 10][2][2];
bool visited_cnt[20][1 << 10][2][2];
bool visited_sum[20][1 << 10][2][2];

ll count_valid(int pos, int mask, bool tight, bool is_leading_zero) {
    if (pos == S.size()) {
        if (!is_leading_zero && __builtin_popcount(mask) <= K) return 1;
        return 0;
    }
    if (visited_cnt[pos][mask][tight][is_leading_zero]) return dp_cnt[pos][mask][tight][is_leading_zero];

    ll res = 0;
    int upper = tight ? (S[pos] - '0') : 9;

    for (int d = 0; d <= upper; ++d) {
        bool new_tight = tight && (d == upper);
        bool new_leading = is_leading_zero && (d == 0);
        int new_mask = new_leading ? mask : (mask | (1 << d));
        res = (res + count_valid(pos + 1, new_mask, new_tight, new_leading)) % MOD;
    }

    visited_cnt[pos][mask][tight][is_leading_zero] = true;
    dp_cnt[pos][mask][tight][is_leading_zero] = res;
    return res;
}

ll sum_valid(int pos, int mask, bool tight, bool is_leading_zero) {
    if (pos == S.size()) {
        return 0; // Base case sum is 0 (no digit added)
    }
    if (visited_sum[pos][mask][tight][is_leading_zero]) return dp_sum[pos][mask][tight][is_leading_zero];

    ll res = 0;
    int upper = tight ? (S[pos] - '0') : 9;

    for (int d = 0; d <= upper; ++d) {
        bool new_tight = tight && (d == upper);
        bool new_leading = is_leading_zero && (d == 0);
        int new_mask = new_leading ? mask : (mask | (1 << d));

        // Nếu chọn d tại đây, d sẽ là chữ số có trọng số là 10^(len - 1 - pos)
        // Ví dụ: 123, pos=0 (chữ số 1), tổng trọng số là 100. Pos=2 (chữ số 3), tổng trọng số là 1.
        ll weight = power_of_10[S.size() - 1 - pos];
        ll contribution = (d * weight) % MOD;

        // Số lượng các số hợp lệ tạo thành từ phần đuôi
        ll count = count_valid(pos + 1, new_mask, new_tight, new_leading);

        // Tổng các số tạo thành từ phần đuôi (chữ số đầu là 0 trong phần đuôi)
        ll sub_sum = sum_valid(pos + 1, new_mask, new_tight, new_leading);

        // Tổng tại bước này = (d * weight * count) + sub_sum
        ll term = (contribution * count) % MOD;
        term = (term + sub_sum) % MOD;

        res = (res + term) % MOD;
    }

    visited_sum[pos][mask][tight][is_leading_zero] = true;
    dp_sum[pos][mask][tight][is_leading_zero] = res;
    return res;
}

ll solve(ll n) {
    if (n == 0) return 0;
    S = to_string(n);
    memset(visited_cnt, 0, sizeof(visited_cnt));
    memset(visited_sum, 0, sizeof(visited_sum));
    return sum_valid(0, 0, true, true);
}

int main() {
    power_of_10[0] = 1;
    for (int i = 1; i < 20; ++i) power_of_10[i] = (power_of_10[i - 1] * 10) % MOD;

    ll L, R;
    cin >> L >> R >> K;

    ll ans = (solve(R) - solve(L - 1) + MOD) % MOD;
    cout << ans << endl;
    return 0;
}
  • Time Complexity: Tương tự cách 1, nhưng do tách DP nên số phép tính tăng lên một chút nhưng vẫn nằm trong giới hạn.
  • Space Complexity: Tương tự cách 1.

Phương pháp này tách biệt logic tính số lượng (count_valid) và logic tính tổng (sum_valid).

  • count_valid chỉ đếm số lượng cách tạo ra số.
  • sum_valid sử dụng kết quả của count_valid để tính tổng.

Công thức tính tổng: Khi chọn chữ số d tại vị trí pos (có trọng số là 10^p): Tổng = d * 10^p * (số lượng số hợp lệ ở phần đuôi) + (tổng các số hợp lệ ở phần đuôi).

Cách này giúp tách rõ ràng logic, nhưng thường dễ mắc lỗi hơn trong việc truyền trạng thái mask (phải đảm bảo không update mask nếu là leading zero) và việc gọi chéo nhau giữa 2 hàm DP có thể gây overhead.

Phân tích độ phức tạp

Cách tiếp cận Time Space Tên
1 O(19 * 1024 * 2 * 10) ~ 400,000 phép tính cho mỗi truy vấn (R hoặc L-1) O(19 * 1024 * 2) ~ 40KB Quy hoạch động theo Digit (Digit DP) - Gộp đếm và tính tổng
2 Tương tự cách 1, nhưng do tách DP nên số phép tính tăng lên một chút nhưng vẫn nằm trong giới hạn. Tương tự cách 1. Quy hoạch động tách biệt (Đếm và Tính tổng riêng)

Bài học kinh nghiệm

  • Bài toán có thể quy về bài toán đếm và tính tổng các số có đặc điểm nào đó trong đoạn [1, X] bằng cách sử dụng công thức: Kết quả = f(R) - f(L-1).
  • Biến mask (thường dùng bitmask 10 bit) là chìa khóa để kiểm soát điều kiện 'không quá k chữ số phân biệt'.
  • Kỹ thuật Digit DP yêu cầu xử lý các trường hợp đặc biệt của số 0 (số 0 tự nhiên, số 0 đứng đầu).

Lỗi thường gặp

  • Sai sót khi chuyển đổi từ số nguyên sang chuỗi hoặc mảng chữ số.
  • Quên xử lý trường hợp L=1 (tức là tính từ f(R) - f(0)).
  • Tràn số nguyên khi tính tổng các số lớn (cần lấy dư liên tục trong quá trình tính toán).

Bình luận

Please read the guidelines before commenting.


Không có bình luận tại thời điểm này.