[蓝桥杯]堆的计数

堆的计数

题目描述

我们知道包含 NN 个元素的堆可以看成是一棵包含 NN 个节点的完全二叉树。

每个节点有一个权值。对于小根堆来说,父节点的权值一定小于其子节点的权值。

假设 NN 个节点的权值分别是 1~NN,你能求出一共有多少种不同的小根堆吗?

例如对于 NN = 4 有如下 3 种:

1

/ \

2 3

/

4

1

/ \

3 2

/

4

1

/ \

2 4

/

3

由于数量可能超过整型范围,你只需要输出结果除以 109+9109+9 的余数。

输入描述

输入输出一个整数 N (1≤N≤105)N (1≤N≤105)。

输出描述

一个整数表示答案。

输入输出样例

示例

输入

复制代码
4

输出

复制代码
3

运行限制

  • 最大运行时间:1s
  • 最大运行内存: 256M

总通过次数: 372 | 总提交次数: 543 | 通过率: 68.5%

难度: 困难 标签: 2018, 快速幂, 省赛, 动态规划

算法思路

要计算由 1~N 的 N 个不同数字组成的小根堆数量,我们需要利用组合数学和动态规划。核心思路是:

  1. ​树形结构​:完全二叉树的结构固定,根节点为最小值 1
  2. ​子树分配​:剩余 N-1 个数字需要分配到左右子树
  3. ​递归计算​:每个子树也必须满足小根堆性质
  4. ​组合数学​:通过组合数计算分配方案,利用动态规划避免重复计算
关键公式:

对于以节点 i 为根的子树:

cpp 复制代码
dp[i] = C(size[i]-1, size[left]) × dp[left] × dp[right] mod MOD

其中:

  • size[i]:子树 i 的节点数
  • left/right:左右子节点
  • C(n,k):组合数,表示从 n 个元素选 k 个的方案数

算法演示

以 N=4 为例:

cpp 复制代码
     1          1          1
    / \        / \        / \
   2   3      3   2      2   4
  /          /          /
 4          4          3

三种不同的小根堆结构,均满足父节点 < 子节点的性质。

代码实现(C++)

cpp 复制代码
#include <iostream>
#include <vector>
using namespace std;

const long long MOD = 1000000009;
const int MAXN = 100010;

long long fact[MAXN], invFact[MAXN];
int sizes[MAXN];
long long dp[MAXN];

// 快速幂计算 a^b mod MOD
long long modExp(long long a, long long b) {
    long long res = 1;
    while (b) {
        if (b & 1) res = (res * a) % MOD;
        a = (a * a) % MOD;
        b >>= 1;
    }
    return res;
}

// 预处理阶乘和逆元
void precompute(int n) {
    fact[0] = invFact[0] = 1;
    for (int i = 1; i <= n; i++) {
        fact[i] = fact[i-1] * i % MOD;
    }
    invFact[n] = modExp(fact[n], MOD-2);
    for (int i = n-1; i >= 1; i--) {
        invFact[i] = invFact[i+1] * (i+1) % MOD;
    }
}

// 计算组合数 C(n,k) mod MOD
long long nCr(int n, int k) {
    if (k < 0 || k > n) return 0;
    return fact[n] * invFact[k] % MOD * invFact[n-k] % MOD;
}

int main() {
    int N;
    cin >> N;
    
    // 预处理阶乘和逆元
    precompute(N);
    
    // 计算每个节点的子树大小
    for (int i = N; i >= 1; i--) {
        sizes[i] = 1;
        int left = 2*i, right = 2*i+1;
        if (left <= N) sizes[i] += sizes[left];
        if (right <= N) sizes[i] += sizes[right];
    }
    
    // 初始化叶子节点的dp值
    for (int i = 1; i <= N; i++) {
        int left = 2*i, right = 2*i+1;
        if (left > N) dp[i] = 1; // 叶子节点
    }
    
    // 动态规划计算dp值(从下往上)
    for (int i = N; i >= 1; i--) {
        int left = 2*i, right = 2*i+1;
        if (left <= N) { // 非叶子节点
            int left_size = sizes[left];
            int right_size = (right <= N) ? sizes[right] : 0;
            int total = sizes[i] - 1;
            
            long long comb = nCr(total, left_size);
            dp[i] = comb * dp[left] % MOD;
            if (right <= N) {
                dp[i] = dp[i] * dp[right] % MOD;
            }
        }
    }
    
    cout << dp[1] << endl;
    
    return 0;
}

算法步骤详解

  1. ​预处理阶乘和逆元​

    • 计算 0!N! 的阶乘数组 fact[]
    • 通过费马小定理计算阶乘的逆元数组 invFact[](因为 MOD 是质数)
  2. ​计算子树大小​

    cpp 复制代码
    for (int i = N; i >= 1; i--) {
        sizes[i] = 1;
        if (2*i <= N) sizes[i] += sizes[2*i];
        if (2*i+1 <= N) sizes[i] += sizes[2*i+1];
    }
    • 从叶子节点向上计算每个节点的子树大小
    • 节点 i 的子树大小 = 1(自身) + 左子树大小 + 右子树大小
  3. ​初始化叶子节点​

    cpp 复制代码
    for (int i = 1; i <= N; i++) {
        if (2*i > N) dp[i] = 1; // 叶子节点方案数为1
    }
  4. ​动态规划计算方案数​

    cpp 复制代码
    for (int i = N; i >= 1; i--) {
        if (非叶子节点) {
            int left_size = 左子树大小;
            int total = sizes[i]-1; // 需要分配的总节点数
            long long comb = nCr(total, left_size); // 分配方案数
            dp[i] = comb * dp[左子节点] % MOD;
            dp[i] = dp[i] * dp[右子节点] % MOD; // 如果存在
        }
    }

实例验证

​输入 N=4 时:​

  1. 子树大小计算:

    • 节点4:size=1(叶子)
    • 节点3:size=1(叶子)
    • 节点2:size=1 + size[4] = 2
    • 节点1:size=1 + size[2] + size[3] = 4
  2. DP 计算:

    • dp[4] = 1(叶子)
    • dp[3] = 1(叶子)
    • dp[2] = C(1,1) × dp[4] = 1×1 = 1
    • dp[1] = C(3,2) × dp[2] × dp[3] = 3×1×1 = 3

​输出:3​​ ✓

注意事项

  1. ​组合数计算​

    • 使用预处理的阶乘和逆元保证 O(1) 时间复杂度
    • 组合数公式:C(n,k)=k!(n−k)!n!modMOD
  2. ​取模运算​

    • 所有乘法操作后立即取模,防止 long long 溢出
    • 使用 (a * b) % MOD 确保中间结果不溢出
  3. ​遍历顺序​

    • ​必须从后向前遍历​(叶子→根)
    • 确保计算 dp[i] 时子节点的 dp 值已计算完成

测试点设计

  1. ​边界值测试​

    • N=1:输出 1(单节点堆)
    • N=2:输出 1(唯一结构:根-左子)
    • N=3:输出 2(两种结构:根+左左/根+左右)
  2. ​完全二叉树测试​

    • N=7(满二叉树):输出 80
    • N=15(满二叉树):输出 21964800
  3. ​性能测试​

    • N=10^5:在 1s 内完成计算
    • 最大组合数计算:C(10^5, 50000) mod MOD
  4. ​取模验证​

    • 大 N 结果超过 long long 范围
    • 确保所有操作正确取模

优化建议

  1. ​空间优化​

    cpp 复制代码
    // 使用 vector 替代静态数组
    vector<long long> fact(MAXN), invFact(MAXN);
    vector<int> sizes(MAXN);
    vector<long long> dp(MAXN);
  2. ​组合数计算优化​

    cpp 复制代码
    // 使用卢卡斯定理处理更大的 N
    long long lucas(int n, int k) {
        if (k == 0) return 1;
        return nCr(n % MOD, k % MOD) * lucas(n / MOD, k / MOD) % MOD;
    }
  3. ​并行计算​

    cpp 复制代码
    // OpenMP 并行计算子树大小
    #pragma omp parallel for
    for (int i = N; i >= 1; i--) {
        // 计算 sizes[i]
    }
  4. ​记忆化搜索​

    cpp 复制代码
    // 存储已计算的子树方案数
    unordered_map<int, long long> memo;
    if (memo.count(i)) return memo[i];
相关推荐
Codebee12 分钟前
OneCode图生代码技术深度解析:从可视化设计到注解驱动实现的全链路架构
css·人工智能·算法
刘大猫2614 分钟前
Datax安装及基本使用
java·人工智能·算法
一定要AK1 小时前
萌新赛练习
数据结构
津津有味道1 小时前
Qt C++串口SerialPort通讯发送指令读写NFC M1卡
linux·c++·qt·串口通信·serial·m1·nfc
让我们一起加油好吗1 小时前
【C++】list 简介与模拟实现(详解)
开发语言·c++·visualstudio·stl·list
傅里叶的耶1 小时前
C++系列(二):告别低效循环!选择、循环、跳转原理与优化实战全解析
c++·visual studio
Vitta_U2 小时前
MFC的List Control自适应主界面大小
c++·list·mfc
Dovis(誓平步青云)3 小时前
基于探索C++特殊容器类型:容器适配器+底层实现原理
开发语言·c++·queue·适配器·stack
Gyoku Mint3 小时前
深度学习×第4卷:Pytorch实战——她第一次用张量去拟合你的轨迹
人工智能·pytorch·python·深度学习·神经网络·算法·聚类
葫三生4 小时前
如何评价《论三生原理》在科技界的地位?
人工智能·算法·机器学习·数学建模·量子计算