1. 问题背景与核心挑战
这道蓝桥杯国赛题目看似简单,却蕴含着动态规划的经典思想。给定一个正整数N,我们可以对其每一位数字进行两种操作:加1(操作1)或减1(操作2),操作次数分别受限于A和B。数字在0-9之间循环变化(9加1变0,0减1变9)。我们的目标是通过合理分配操作次数,使得最终的数字尽可能大。
这个问题在实际编程竞赛中非常典型,它考察了以下几个关键点:
- 数字操作的特性理解(循环变化)
- 操作资源的优化分配(A和B的有限次数)
- 动态规划状态的设计能力
- 高位优先的贪心思想
提示:这类数字操作问题在密码学、游戏开发等领域都有实际应用,比如密码锁破解、数字拼图游戏等场景。
2. 解题思路与算法设计
2.1 关键性质分析
经过对问题的深入分析,我们得出两个重要性质:
性质一:高位优先原则
当最高位不是9时,我们应该优先处理高位数字。因为高位数字对数值大小的影响远大于低位数字。例如,百位增加1相当于整体数值增加100,而个位增加1只增加1。
性质二:操作互斥性
对同一位数字不会同时进行加法和减法操作,因为这会相互抵消,浪费宝贵的操作次数。例如,给某位数字先加1再减1,相当于没有操作,却消耗了两次操作机会。
2.2 动态规划状态设计
我们采用三维动态规划来解决这个问题:
-
状态表示:dp[m][a][b]
- m:已经处理完前m位数字
- a:剩余加法操作次数
- b:剩余减法操作次数
- 值:当前状态下能获得的最大数字
-
空间复杂度:O(M×A×B),其中M是数字位数
这种状态设计完美捕捉了问题的所有关键维度,确保我们不会遗漏任何可能的操作组合。
2.3 状态转移策略
对于每一位数字,我们有两种处理方式:
-
全加策略:
- 计算将该位数字加到9需要的加法次数
- 如果剩余加法次数足够,直接加到9
- 如果不足,则尽可能多地增加该位数字
-
全减策略:
- 通过减法操作将数字"绕回"到9
- 例如,当前数字是2,减3次:2→1→0→9
- 同样需要考虑剩余减法次数是否足够
转移时,我们只考虑这两种极端情况,因为中间状态不会产生更优解。这大大减少了需要考虑的状态数量。
3. 算法实现详解
3.1 核心数据结构
cpp复制vector<vector<vector<long long>>> dp(
M+1, vector<vector<long long>>(
A+1, vector<long long>(B+1, LLONG_MIN/2)));
这里使用三维数组存储状态,初始值设为LLONG_MIN/2(一个很小的数),表示不可达状态。
3.2 预处理工作
cpp复制string str = to_string(N);
const int M = str.size();
vector<long long> unit(M, 1);
for (int i = M - 2; i >= 0; i--) {
unit[i] = 10 * unit[i + 1];
}
- 将数字转换为字符串便于逐位处理
- 预计算位权值unit数组,unit[i]表示第i位的位权(10的幂次)
- 例如,数字"123"的unit数组是[100,10,1]
3.3 动态规划主循环
cpp复制for (int i = 0; i < M; i++) {
vector<vector<long long>> cur(A+1, vector<long long>(B+1, LLONG_MIN/2));
for (int a = 0; a <= A; a++) {
for (int b = 0; b <= B; b++) {
if (dp[i][a][b] == LLONG_MIN/2) continue;
// 加法转移
int add_needed = '9' - str[i];
int actual_add = min(add_needed, a);
cur[a-actual_add][b] = max(
cur[a-actual_add][b],
dp[i][a][b] + actual_add * unit[i]);
// 减法转移
int sub_needed = (str[i] - '0' + 1) % 10;
if (b >= sub_needed) {
cur[a][b-sub_needed] = max(
cur[a][b-sub_needed],
dp[i][a][b] + ('9' - str[i]) * unit[i]);
}
}
}
dp[i+1] = cur;
}
这段代码实现了动态规划的核心逻辑:
- 对每一位数字进行处理
- 遍历所有可能的剩余操作次数组合
- 尝试加法转移和减法转移
- 更新下一状态的最大值
3.4 结果提取
cpp复制long long ans = 0;
for (int a = 0; a <= A; a++) {
for (int b = 0; b <= B; b++) {
ans = max(ans, dp[M][a][b]);
}
}
return ans;
在所有位处理完成后,我们遍历所有可能的剩余操作次数组合,找出最大值作为最终结果。
4. 算法优化与注意事项
4.1 空间优化技巧
原始实现使用了O(MAB)的空间,可以优化为O(AB):
- 只保留当前位和前一位的状态
- 使用滚动数组技术交替更新
cpp复制vector<vector<long long>> pre(A+1, vector<long long>(B+1, LLONG_MIN/2));
pre[A][B] = N; // 初始状态
for (int i = 0; i < M; i++) {
vector<vector<long long>> cur(A+1, vector<long long>(B+1, LLONG_MIN/2));
// ...转移逻辑...
pre = move(cur); // 使用移动语义减少拷贝
}
4.2 边界条件处理
需要特别注意以下边界情况:
- N=0时的特殊处理
- A=0或B=0时的操作限制
- 数字位数超过long long范围的情况(题目保证N≤10^17)
4.3 时间复杂度分析
- 外层循环:M次(数字位数)
- 内层双重循环:A×B次
- 总时间复杂度:O(MAB)
对于题目给定的约束(M≤17,A,B≤100),这个复杂度是完全可接受的。
5. 完整代码实现与测试
5.1 核心代码整合
cpp复制#include <iostream>
#include <vector>
#include <algorithm>
#include <climits>
#include <string>
using namespace std;
class Solution {
public:
long long maxNumber(long long N, int A, int B) {
string str = to_string(N);
const int M = str.size();
vector<long long> unit(M, 1);
for (int i = M - 2; i >= 0; i--) {
unit[i] = 10 * unit[i + 1];
}
vector<vector<long long>> pre(A + 1, vector<long long>(B + 1, LLONG_MIN / 2));
pre[A][B] = N;
for (int i = 0; i < M; i++) {
vector<vector<long long>> cur(A + 1, vector<long long>(B + 1, LLONG_MIN / 2));
for (int a = 0; a <= A; a++) {
for (int b = 0; b <= B; b++) {
if (pre[a][b] == LLONG_MIN / 2) continue;
// 加法转移
int add_needed = '9' - str[i];
int actual_add = min(add_needed, a);
cur[a - actual_add][b] = max(
cur[a - actual_add][b],
pre[a][b] + actual_add * unit[i]);
// 减法转移
int sub_needed = (str[i] - '0' + 1);
if (b >= sub_needed) {
cur[a][b - sub_needed] = max(
cur[a][b - sub_needed],
pre[a][b] + ('9' - str[i]) * unit[i]);
}
}
}
pre = move(cur);
}
long long ans = 0;
for (const auto& row : pre) {
ans = max(ans, *max_element(row.begin(), row.end()));
}
return ans;
}
};
5.2 测试用例设计
cpp复制void test() {
Solution sol;
// 基础测试
assert(sol.maxNumber(123, 1, 2) == 933);
// 边界测试
assert(sol.maxNumber(999, 1, 1) == 999); // 已经是最大值
assert(sol.maxNumber(0, 10, 10) == 9); // 单个数字0
// 极端情况
assert(sol.maxNumber(11111111111111111, 100, 100) == 99999999999999999);
// 操作次数不足
assert(sol.maxNumber(1234, 0, 0) == 1234); // 无操作
cout << "所有测试用例通过!" << endl;
}
5.3 性能优化建议
- 位运算优化:对于频繁的乘除运算,可以用位运算替代
- 并行计算:内层a/b循环可以并行化处理
- 剪枝策略:当剩余操作次数无法改变后续位时提前终止
6. 常见问题与解决方案
6.1 为什么我的程序得到的结果比预期小?
可能原因:
- 没有正确处理数字的循环特性(9+1=0,0-1=9)
- 状态转移时漏掉了某些情况
- 初始状态设置不正确
解决方案:
- 仔细检查加减法操作对数字的影响
- 打印中间状态进行调试
- 确保初始状态dp[0][A][B] = N
6.2 如何处理大数溢出问题?
虽然题目保证N≤10^17,但在计算过程中可能会溢出。建议:
- 使用更大的数据类型(如__int128)
- 采用字符串直接处理数字,避免数值计算
- 在每次操作后检查是否溢出
6.3 为什么不能对同一位同时进行加减操作?
从数学角度证明:
假设对某位数字d进行a次加法和b次减法:
最终变化量 = (a - b) % 10
这等价于直接进行(a-b)次加法(或减法,如果结果为负)
因此分开操作只会浪费操作次数,不会得到更好的结果
7. 算法扩展与应用
这个动态规划思路可以扩展到许多类似问题:
- 最小数字问题:通过操作得到最小数字,只需修改状态转移逻辑
- 目标数字问题:判断能否通过操作得到特定数字
- 多操作类型问题:增加更多操作类型(如交换数字位)
- 概率版本:每次操作有成功概率,求最大期望值
在实际应用中,这种有限操作次数下的最优决策问题非常常见,比如:
- 资源分配优化
- 游戏中的技能点数分配
- 工业生产中的参数调整
提示:理解这个问题的核心在于掌握"资源有限情况下的最优分配"思想,这在实际工程决策中至关重要。