跳转至

P1028 [NOIP 2001 普及组] 数的计算

题目描述

给出正整数 \(n\),要求按如下方式构造数列:

  1. 只有一个数 \(n\) 的数列是一个合法的数列。
  2. 在一个合法的数列的末尾加入一个正整数,但是这个正整数不能超过该数列最后一项的一半,可以得到一个新的合法数列。

请你求出,一共有多少个合法的数列。两个合法数列 \(a, b\) 不同当且仅当两数列长度不同或存在一个正整数 \(i \leq |a|\),使得 \(a_i \neq b_i\)

输入格式

输入只有一行一个整数,表示 \(n\)

输出格式

输出一行一个整数,表示合法的数列个数。

输入输出样例 #1

输入 #1

6

输出 #1

6

说明/提示

样例 1 解释

满足条件的数列为:
- \(6\)
- \(6, 1\)
- \(6, 2\)
- \(6, 3\)
- \(6, 2, 1\)
- \(6, 3, 1\)

数据规模与约定

对于全部的测试点,保证 \(1 \leq n \leq 10^3\)

说明

本题数据来源是 NOIP 2001 普及组第一题,但是原题的题面描述和数据不符,故对题面进行了修改,使之符合数据。原题面如下,谨供参考:

我们要求找出具有下列性质数的个数(包含输入的正整数 \(n\))。

先输入一个正整数 \(n\)\(n \le 1000\)),然后对此正整数按照如下方法进行处理:

  1. 不作任何处理;
  2. 在它的左边拼接一个正整数,但该正整数不能超过原数,或者是上一个被拼接的数的一半;
  3. 加上数后,继续按此规则进行处理,直到不能再加正整数为止。

📌 题目分析

给定序列的初始值 n(最大 1000)。 每次可以在序列末尾追加一个新数字,要求新数字必须 小于等于 上一个数字的一半。 求一共能构造出多少种不同的合法数列。

👉 本质:

将一个大问题拆解成若干个规模更小的同类问题。

👉 类型:

经典 动态规划(DP) / 记忆化搜索 / 递推

🧠 解题思路

1️⃣ 状态定义

我们可以定义一个数组:

dp[i] 表示以数字 i 为开头的合法数列总数

题目要求的是以 n 为开头的数列个数,也就是求 dp[n]

2️⃣ 状态转移方程

对于任意一个数字 i,它构成的合法数列可以分为两类: * 只有它自己本身: 例如数列 {i},这算作 1 种情况。 * 后面追加了数字: 可以在后面追加 1i/2 之间的任意一个数字 j。只要追加了 j,后面能构成的数列个数就完全等同于 dp[j]

因此,递推公式为: \(dp[i] = 1 + \sum_{j=1}^{\lfloor i/2 \rfloor} dp[j]\)

3️⃣ 边界与计算顺序

最小的数字是 1,以 1 开头的合法数列只有 {1},因此:

dp[1] = 1

由于 dp[i] 的计算依赖于比它小的 dp[j],所以我们只需要从小到大遍历 1n 进行计算即可。

4️⃣ 时间复杂度

外层循环 n 次,内层循环最多 n/2 次。 整体时间复杂度为 O(n²)。 对于最大 1000 的数据规模,计算量仅为几十万级别,在 1 秒时限内绰绰有余。


💻 C++代码

#include <iostream>
#include <vector>
using namespace std;

// 使用 long long 防止累加过程中溢出
long long dp[1005];

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n;
    cin >> n;

    // 边界条件:以 1 开头的数列只有 1 种
    dp[1] = 1;

    // 从 2 开始从小到大递推
    for (int i = 2; i <= n; i++) {
        dp[i] = 1; // 1 代表只有数字 i 本身的这一种数列
        for (int j = 1; j <= i / 2; j++) {
            dp[i] += dp[j]; // 累加所有合法分支的数列个数
        }
    }

    cout << dp[n] << "\n";

    return 0;
}

⚠️ 易错点

❌ 1. 忘记算上数字本身的 1 种情况

递推时必须初始化 dp[i] = 1。 很多初学者会写成 dp[i] = sum(dp[j]),这样漏掉了不追加任何数字(只有 {i} 本身)的基础情况。

❌ 2. 纯暴力递归导致超时 (TLE)

如果直接写一个递归函数 dfs(n),并在里面用 for 循环继续调用 dfs(i) 而不记录结果,会产生大量的重复计算。 必须使用 DP 数组保存已经算过的结果(即记忆化),或者像上面代码那样直接从小到大递推。

❌ 3. 循环范围写错

题目规定追加的数字不能超过上一项的一半。 内层循环必须写成 j <= i / 2,漏掉等号会导致结果偏小。


🚀 一句话总结

以 i 开头的数列数 = 1 (它自身) + 所有以 j 开头的数列数之和 (其中 1 ≤ j ≤ i/2)

🔥 补充

其实这道题还有一个 O(n) 的终极数学优化思路(利用前缀和的规律):

如果仔细观察奇偶性,会发现递推式可以被极大化简: * 当 i 为奇数时: i / 2 向下取整的结果和 (i - 1) / 2 完全一样。所以 dp[i] = dp[i-1]。 * 当 i 为偶数时:dp[i-1] 刚好只多出了一项 dp[i/2]。所以 dp[i] = dp[i-1] + dp[i/2]

如果使用这个规律,内层循环可以直接省掉,代码变成:

dp[1] = 1;
for (int i = 2; i <= n; i++) {
    if (i % 2 == 1) {
        dp[i] = dp[i - 1];
    } else {
        dp[i] = dp[i - 1] + dp[i / 2];
    }
}
这样时间复杂度直接降维打击变为 O(n),也是进阶选手最喜欢用的解法。