每日一练:将一个数字表示成幂的和的方案数;动态规划、深度优先搜索
不算很简单的一道题,会在思路解析以及代码解析后附上关于动态规划以及DFS的讲解,一篇文章带你刷题还帮你刷算法OWO
语言选用C,毕竟更符合学习这类问题的本质
题目
给定两个正整数 n 和 x。
请计算将 n 表示为若干互不相同的正整数 x 次幂之和的方案数。具体来说,需要统计满足以下条件的互异整数集合 [n1, n2, ..., nk] 的数量:n = n1^x + n2^x + ... + nk^x。
由于结果可能很大,请将最终答案对 10^9 + 7 取模后返回。
例如,当 n = 160 且 x = 3 时,一个有效的表示方法是 n = 2^3 + 3^3 + 5^3。
示例
动态规划解法
一、题目的本质
这题本质是“子集和计数”的变种:先把所有不超过 n 的 x 次幂数列出来(例如 x=3 时,1^3, 2^3, 3^3, ...,直到超过 n 为止),然后统计用这些数各取0或1次(互不相同)恰好凑出 n 的方案数。
二、0/1背包计数问题
这个思路下与经典的 0/1 背包计数问题完全一致:
- 物品:每个 i^x(i 从 1 开始,直到 i^x > n)
- 背包容量:n
- 转移:dp[s] 表示和为 s 的方案数。枚举每个幂 p 时,从大到小更新:dp[s] += dp[s - p](取模),这样保证每个幂最多使用一次。
三、关键点
- 从大到小更新(倒序)才能保证“互不相同”(每个幂只能用一次)。
- 不要用浮点 pow 去算 i^x(会有精度误差),用整数乘法安全生成幂;为了防溢出与提前剪枝,我们让乘积一旦超过 n 就停止。
四、代码
int numberOfWays(int n, int x) {const int MOD = 1000000007;// 使用 C 的可变长数组(VLA);若编译器不支持,可改用 mallocint dp[n + 1];for (int i = 0; i <= n; ++i) dp[i] = 0;dp[0] = 1;for (long long base = 1; ; ++base) {// 计算 base^x,过程中若超过 n 就提前停止,并避免溢出long long val = 1;for (int i = 0; i < x; ++i) {if (val > n / base) { // 即将相乘会超过 n(或溢出)val = (long long)n + 1;break;}val *= base;}if (val > n) break; // base^x 已经大于 n,后面更大,整体停止int p = (int)val;// 0/1 背包:倒序更新,确保每个幂只使用一次for (int s = n; s >= p; --s) {int sum = dp[s] + dp[s - p];if (sum >= MOD) sum -= MOD;dp[s] = sum;}}return dp[n];
}
五、代码阐述
- dp[0] = 1 表示“什么都不选,和为 0”的一种方式。
- 逐个枚举 p = base^x,并在计算幂时用“val > n / base”来防止乘法溢出与无谓计算。
- 对每个 p 倒序更新 dp,保证每个幂最多使用一次(互不相同)。
六、时间复杂度
- 设 m = ⌊n^(1/x)⌋ 为可用的 x 次幂个数。
- 动态规划:O(m · n)。
- 幂计算:O(m · x)(通常远小于 DP 主开销)。
- 空间复杂度:O(n)。
深度优先搜索(DFS)解法
思路
目标:
把 n 拆成若干互不相同的 x 次幂之和。用 DFS 枚举每个底数 base 的“选/不选”,保证每个幂最多用一次(互不相同)。
去重方式:
按 base 从小到大遍历,只能向更大的 base 递归,这样不会重复。
剪枝:
预先把所有不超过 n 的 x 次幂按从小到大存到数组 pows。若当前最小可用幂 pows[idx] 已经大于剩余目标和 remaining,则后续更大,必然无解,直接返回 0。
记忆化:
定义 f(idx, remaining) 表示从 pows[idx..] 这些幂中选若干个,能否凑出 remaining 的方案数。用一张二维表 memo[idx][remaining] 记忆结果(取模值);未计算用 -1 标记。
代码
- 使用两个辅助函数:pow_leq_n 用整数安全计算 base^x(超过 n 直接返回 n+1 作为“大数”);dfs_memo 为 DFS + 记忆化。
完整可运行的代码演示:
#include <stdlib.h>static long long pow_leq_n(long long base, int x, int n) {// 计算 base^x;若中途超过 n,则直接返回 n+1 以便剪枝long long v = 1;for (int i = 0; i < x; ++i) {if (v > n / base) return (long long)n + 1; // 即将溢出/超过 nv *= base;}return v;
}static int dfs_memo(int idx, int remaining, int m, const int* pows, int n, int* memo, int MOD) {if (remaining == 0) return 1;if (idx >= m) return 0;if (pows[idx] > remaining) return 0; // 后面更大,直接无解int key = idx * (n + 1) + remaining;int cached = memo[key];if (cached != -1) return cached;// 不选当前幂int ways = dfs_memo(idx + 1, remaining, m, pows, n, memo, MOD);// 选当前幂int use = dfs_memo(idx + 1, remaining - pows[idx], m, pows, n, memo, MOD);ways += use;if (ways >= MOD) ways -= MOD;memo[key] = ways;return ways;
}int numberOfWays(int n, int x) {const int MOD = 1000000007;if (n == 0) return 1; // 空集之和// 1) 统计可用幂的个数 mint m = 0;for (long long base = 1; ; ++base) {long long v = pow_leq_n(base, x, n);if (v > n) break;++m;}if (m == 0) return 0;// 2) 预生成 pows[0..m-1],升序int* pows = (int*)malloc(sizeof(int) * m);int idx = 0;for (long long base = 1; idx < m; ++base) {long long v = pow_leq_n(base, x, n);if (v > n) break;pows[idx++] = (int)v;}// 3) 分配记忆化表(拉平成一维:idx*(n+1) + remaining)long long total = (long long)m * (n + 1);int* memo = (int*)malloc(sizeof(int) * total);if (!memo) { // 极端内存不足时的兜底free(pows);return 0;}for (long long i = 0; i < total; ++i) memo[i] = -1;// 4) DFS + 记忆化int ans = dfs_memo(0, n, m, pows, n, memo, MOD);free(pows);free(memo);return ans;
}
代码阐述
- pow_leq_n:
- 用整数乘法逐步计算 base^x,期间用 v > n / base 判断“下一步会超过 n(或溢出)”,提前返回 n+1。
- 预处理 pows:
- pows 为所有 ≤ n 的 x 次幂,升序保存。m = ⌊n^(1/x)⌋。
- DFS 状态 f(idx, remaining):
- 若 remaining=0,返回 1(找到一种选法)。
- 若 idx 走到尽头或 pows[idx] > remaining,返回 0。
- 否则:ways = f(idx+1, remaining) + f(idx+1, remaining - pows[idx])(对 MOD 取模)。
- 记忆化:
- memo 的键值用一维索引 key = idx*(n+1)+remaining。
- 初始为 -1 表示未计算,存储时保存取模后的计数。
时间复杂度
- 设 m = ⌊n^(1/x)⌋。
- 预处理幂:O(m · x)。
- DFS + 记忆化的状态数:至多 O(m · n),每个状态 O(1) 转移,因此时间复杂度 O(m · n)(通常因剪枝会更快)。
- 空间复杂度:O(m · n) 用于 memo,外加 O(m) 存 pows,递归栈深度 O(m)。
一、动态规划与深度优先搜索算法概述
1.1 什么是动态规划
动态规划是一种通过把原问题分解为相对简单的子问题的方式求解复杂问题的方法。它的核心思想是:
- 重叠子问题:问题可以分解为子问题,子问题会被多次使用
- 最优子结构:问题的最优解包含子问题的最优解
- 状态转移:通过已知状态推导未知状态
1.2 什么是DFS(深度优先搜索)
DFS是一种用于遍历或搜索树或图的算法。在解决问题时,DFS的特点是:
- 递归思想:不断深入探索,直到无法继续
- 回溯机制:当前路径无解时,退回上一步尝试其他选择
- 穷举所有可能:系统地尝试所有可能的解决方案
二、使用场景对比
2.1 动态规划的使用场景
(一)典型问题特征
计数问题
- 方案数统计
- 路径数计算
- 组合数求解
最优化问题
- 最大值/最小值
- 最长/最短路径
- 最优分配方案
判定问题
- 可行性判断
- 是否存在某种方案
(二)具体应用场景
- 背包问题系列
- 股票买卖问题
- 编辑距离
- 最长公共子序列
- 矩阵路径问题
2.2 DFS的使用场景
(一)典型问题特征
搜索所有解
- 全排列生成
- 组合枚举
- 子集生成
路径搜索
- 迷宫求解
- 图的遍历
- 棋盘问题
决策树遍历
- 游戏AI
- 解数独
- N皇后问题
(二)具体应用场景
- 图的连通性判断
- 拓扑排序
- 二叉树遍历
- 岛屿数量统计
- 括号生成
三、算法使用形式
3.1 动态规划的基本形式
(一)自底向上
// 1. 定义状态
int dp[MAX_SIZE];// 2. 初始化边界
dp[0] = base_case;// 3. 状态转移
for (int i = 1; i < n; i++) {dp[i] = f(dp[i-1], dp[i-2], ...);
}// 4. 返回结果
return dp[n-1];
(二)自顶向下(记忆化搜索)
int memo[MAX_SIZE];
memset(memo, -1, sizeof(memo));int dp(int state) {// 1. 边界条件if (base_case) return base_value;// 2. 查看缓存if (memo[state] != -1) return memo[state];// 3. 计算并缓存memo[state] = f(dp(sub_state1), dp(sub_state2), ...);return memo[state];
}
3.2 DFS的基本形式
(一)递归形式
void dfs(int state, /* 其他参数 */) {// 1. 终止条件if (终止条件) {处理结果;return;}// 2. 剪枝(可选)if (不满足条件) return;// 3. 遍历所有选择for (每个可能的选择) {// 做选择修改状态;// 递归dfs(新状态);// 撤销选择(回溯)恢复状态;}
}
(二)迭代形式(使用栈)
typedef struct {int state;// 其他必要信息
} Node;void dfs_iterative() {Node stack[MAX_SIZE];int top = 0;// 初始状态入栈stack[top++] = initial_state;while (top > 0) {Node current = stack[--top];// 处理当前节点process(current);// 生成子节点并入栈for (每个子节点) {stack[top++] = child_node;}}
}
四、选择策略指南
4.1 何时选择动态规划
(一)问题具有以下特征时
重叠子问题明显
- 递归解法中发现大量重复计算
- 子问题的解会被多次使用
最优子结构清晰
- 大问题的最优解包含小问题的最优解
- 可以通过组合子问题的解得到原问题的解
状态数量有限
- 状态空间不会指数级增长
- 可以用数组存储所有状态
4.2 何时选择DFS
(一)问题具有以下特征时
需要遍历所有可能
- 找出所有解决方案
- 生成所有排列组合
解空间是树形或图形结构
- 决策树明显
- 路径搜索问题
需要记录路径
- 不仅要知道是否可达
- 还要知道具体路径
相信大家这时已经对何时使用什么策略、如何使用,都已经有了比较系统性的了解。具体怎么运用,还是需要多多刷题。我这次这篇文章的题同时使用了这两种情况,学习完或者复习完大家可以多多练习,然后写自己的代码。
你的点赞,关注与收藏是我最大的动力。