階梯數字 (APCS 2018-02 高級) 的題解


前置知識

這題是「按位數計數」的入門題(常被歸在數位 DP),下面的講解直接把這些當成已知,沒讀過的先讀;同一個主題兩邊都有的,對照著讀會更快上手:

  • 動態規劃的基本概念:NTUCPC Guide〈基本概念〉與〈狀態與轉移〉——「先定義好一個表格每格的意思,再寫出格子之間的關係」;吳邦一《AP325》第 6 章的 P-6-19 正是本題,用的也是下面這張「開頭數字 × 位數」的表(教材 PDF 第 201~205 頁;Python 版第 6 章(III))。
  • 把數字拆成一位一位:語法書 10.9(讀成字串最省事)。

簡潔題意

十進位數字由左到右不下降的正整數叫階梯數字(\(9\)、\(112\)、\(777\) 都是;\(0\) 不算、不能有前導零)。求不超過 \(N\) 的階梯數字有幾個(\(1 \le N \le 10^{18}\);APCS 逐筆給分)。

先拿下小測資:一個一個檢查

\(N\) 不大時直接從 \(1\) 數到 \(N\),每個數拆位檢查「每一位都不小於左邊那位」。寫一個判斷函式,從個位往高位拆,只要出現「高位比低位大」就不是:

#include <bits/stdc++.h>
using namespace std;

// x 是不是階梯數字:從個位往左拆,左邊那位不能比右邊大
bool isStair(long long x) {
    int right = 9;                       // 比個位更右邊沒有數字,先放一個最大的 9
    while (x > 0) {
        int d = x % 10;
        if (d > right) return false;     // 左邊這位比右邊大:不是
        right = d;
        x /= 10;
    }
    return true;
}

int main() {
    long long n;
    cin >> n;
    long long count = 0;
    for (long long x = 1; x <= n; x++)   // N 大時跑不完
        if (isStair(x)) count++;
    cout << count << '\n';
    return 0;
}

\(N\) 到 \(10^{18}\) 這份程式永遠跑不完,但 \(N\) 在百萬以內的測資它都對——逐筆給分之下這幾筆的分數穩穩到手(本題的計分測資裡 \(N \le 10^6\) 的有 \(12\) 筆,\(60\) 分)。

從小測資到 100 分:按「位數」和「開頭數字」分群數

把階梯數字按位數分群:一位數 \(9\) 個;二位數有幾個?開頭是 \(d\) 的二位階梯數字,第二位可以是 \(d \sim 9\),共 \(10 - d\) 個——開頭 \(1\) 有 \(9\) 個、開頭 \(2\) 有 \(8\) 個……總共 \(45\) 個。推廣成一張表:

\[\mathrm{cnt}[L][d] = \text{開頭是 } d \text{ 的 } L \text{ 位階梯數字個數} = \sum_{e = d}^{9} \mathrm{cnt}[L - 1][e]\]

意思是「開頭 \(d\) 之後接的那個 \(L - 1\) 位階梯數字,開頭 \(e\) 不能比 \(d\) 小」;\(\mathrm{cnt}[1][d] = 1\)。\(L\) 最多 \(18\)、\(d\) 只有 \(1 \sim 9\),表格一下就填完。

有了表,不超過 \(N\) 的怎麼數?分三群(圖 1 用 \(N = 358\) 示範):

圖 1:不超過 358 的階梯數字分三群——位數比 N 少的 54 個、三位數開頭比 3 小的 81 個、開頭正好是 3 的 17 個,共 152
  1. 位數比 \(N\) 少的:整群都比 \(N\) 小,查表全加——\(N\) 有 \(L\) 位就是 \(\sum_{\ell < L} \sum_d \mathrm{cnt}[\ell][d]\)。
  2. 位數相同、第一位比 \(N\) 的第一位小的:不管後面接什麼都比 \(N\) 小,查表加 \(\mathrm{cnt}[L][d]\)(\(d\) 從 \(1\) 到 \(N\) 的第一位減一)。
  3. 第一位跟 \(N\) 一樣的:那就「貼著 \(N\)」看第二位——第二位比 \(N\) 的第二位小的(而且不小於第一位,不然就不是階梯了),整群查表加進來;第二位也跟 \(N\) 一樣的,再貼著看第三位……一路走到底。

走到底有兩種結局:某一位 \(N\) 的數字比前一位還小——貼著 \(N\) 的數字從這裡起不可能是階梯數字,停;或者 \(N\) 的每一位都走完了,代表 \(N\) 本身就是階梯數字,再加 \(1\)。

#include <bits/stdc++.h>
using namespace std;

long long cnt[20][10];      // cnt[L][d]:開頭是 d 的 L 位階梯數字個數

int main() {
    string s;
    cin >> s;                                       // N 讀成字串,一位一位看
    int L = s.size();

    for (int d = 0; d <= 9; d++) cnt[1][d] = 1;
    for (int len = 2; len <= L; len++)
        for (int d = 0; d <= 9; d++)
            for (int e = d; e <= 9; e++)            // 下一位 e 不能比 d 小
                cnt[len][d] += cnt[len - 1][e];

    long long count = 0;
    for (int len = 1; len < L; len++)               // 群 1:位數比 N 少的,全算
        for (int d = 1; d <= 9; d++)
            count += cnt[len][d];

    int prev = 1;                                   // 這一位至少要是 prev(第一位至少 1:不能有前導零)
    bool stillStair = true;
    for (int i = 0; i < L; i++) {                   // 群 2、3:位數相同,貼著 N 走
        int digit = s[i] - '0';
        for (int d = prev; d < digit; d++)          // 這一位比 N 小、又不小於前一位:整群加
            count += cnt[L - i][d];
        if (digit < prev) {                         // N 自己在這一位往下掉:貼著走的都不是階梯了
            stillStair = false;
            break;
        }
        prev = digit;                               // 跟 N 一樣,繼續貼著走
    }
    if (stillStair) count++;                        // 每一位都走完:N 本身是階梯數字
    cout << count << '\n';
    return 0;
}

答案最大是 \(10^{18}\) 以下全部的階梯數字,共 \(4686824\) 個,int 夠但表格和累加統一用 long long 省得想。cnt[1][0] = 1 讓表的 \(d = 0\) 那一欄也有值,但數的時候 \(d\) 永遠從 \(1\)(或前一位)起跳,前導零不會混進來。

測過再交:範例的 N 都是「貼著走就掉下去」

範例 1 的 \(25\) 本身是階梯數字(貼著走到底要 \(+1\)),範例 2 的 \(101\) 在第二位 \(0 < 1\) 掉下去。沒蓋到的:\(N\) 是一位數、\(N\) 剛好 \(10\)(二位數開頭 \(1\) 但第二位 \(0\) 掉下去)、\(N\) 全部同一個數字、\(N = 10^{18}\) 這種 \(1\) 後面全是 \(0\)。

輸入 正確輸出 這一筆在測什麼
7 7 一位數:\(1 \sim 7\)(小測資版就能驗)
10 9 \(10\) 不是階梯數字,答案跟 \(N = 9\) 一樣(小測資版就能驗)
111 55 \(N\) 本身是階梯數字:二位以下 \(54\) 個,加上 \(111\) 自己
99 54 二位數全部:\(9 + 45\)
1000000000000000000 4686824 \(N = 10^{18}\):第二位就掉下去,答案是 \(18\) 位以內全部

五筆都親眼看過正確,這題就穩了。

常犯錯誤
  • 第一位允許從 \(0\) 起跳(前導零混進來,一個數被算很多次):範例 1 印 32、範例 2 印 109。
  • 把 \(0\) 本身也算一個:每筆都多 \(1\)——範例 1 印 23、範例 2 印 55。
  • 「不下降」寫成「嚴格上升」(相等不算):\(11\)、\(22\) 都被漏掉——範例 1 印 20、範例 2 印 45。
  • 走到底忘了加 \(N\) 本身:\(N\) 是階梯數字時少 \(1\)——範例 1 印 21。
  • 貼著走時 \(N\) 的這一位比前一位小還繼續往下加:加進不存在的群——範例 2 印 55。
  • 用 int 讀 \(N\):\(10^{18}\) 讀不進去;讀成字串或 long long 都可以。
  • 一個一個檢查硬上 \(10^{18}\):永遠跑不完。