目录

题目描述

1449. 数位成本和为目标值的最大数字

题意分析

给定长度为 9 的数组 costcost[d-1] 是写下数字 dd 从 1 到 9)要花的代价,以及一个正整数 target。要拼出一个整数,使得所有数位的代价之和恰好等于 target(不是不超过,是严格相等),并且这个整数尽可能大。拼不出来就返回字符串 "0"

有几个约束把解法框得很死。第一,可用数位只有 1 到 9,没有 0,所以不存在前导零问题,任何数位排列都是合法数字。第二,每个数字可以重复使用任意多次,这是完全背包的形态而不是 0/1 背包。第三,代价必须恰好用完,所以「不可达」是一个必须被显式表示的状态,不能用 0 混过去。

「尽可能大」这个目标要拆成两个层次,顺序不能颠倒:位数多的数一定更大(因为没有 0,任何 9 位数都大于任何 8 位数);位数相同时,从高位开始逐位比较,高位越大越好。这个词典序规则是整道题的骨架——它告诉我们优化目标其实是「先最大化长度,再在最长的前提下最大化字典序」。

规模上 target 到 5000,cost[i] 到 5000,只有 9 种数字,所以 $O(9 \cdot target)$ 的表格完全可行。答案的位数最多约 5000 位,远超 long 范围,必须用字符串返回,任何试图用数值累加的写法都会溢出。

边界:target 小于所有 cost 的最小值时一位都写不出,返回 "0";某些 target 即使大于最小代价也可能因为余数凑不齐而不可达,例如 cost 全是 2 而 target 是奇数。

解法:DP 长度 + 贪心重建

核心思路

直接搜索所有数位组合是指数级的。第一层观察是把目标拆成两阶段:既然位数优先于字典序,那就先只求最大位数,再在保证位数最大的前提下贪心地决定每一位填什么。这两步可以完全解耦。

第一阶段是标准的完全背包求最大件数。定义状态:dp[t] 表示恰好花掉代价 t 时,能写出的最多数位个数;若代价 t 无法被恰好凑出,则 dp[t] 为负无穷(不可达)

转移就是枚举最后写下的那一位是哪个数字:

\[dp[t] = \max_{1 \le d \le 9,\ t \ge cost[d-1]} \big(dp[t - cost[d-1]] + 1\big)\]

初始条件 dp[0] = 0:花掉 0 代价能写出 0 位数字,这是唯一的合法起点。其余状态设为 -target-1。从一个不可达状态出发,即使沿转移错误地累计最多 target+1,值仍小于 0;因此它既不会压过合法状态,也不会被误判为可达。这里不能初始化为 0,否则「凑不出 t」会被当成「凑出了 0 位」,进而污染整张表。

外层枚举 t 从小到大、内层枚举 d,这个顺序天然满足完全背包「同一物品可重复取」的要求——dp[t - cost] 在本轮之前就已经算完,且它自身也可能已经用过数字 d

第二阶段是贪心重建。有了 dp 表,从最高位开始逐位决定:优先尝试大的数字。对当前剩余代价 t,从 d = 9 往下试,只要 t >= cost[d-1]dp[t] == dp[t - cost[d-1]] + 1,就说明「在位置上写下 d,剩下的代价 t - cost[d-1] 仍能凑出 dp[t] - 1 位」,即写 d 不会损失任何长度,那就贪心地写它,并且while 一直写到写不动为止

这个贪心之所以正确,正是因为条件 dp[t] == dp[t-c] + 1 保证了长度不受损;在长度不受损的所有选择里,越靠前的位越大就越优,而当前正是最高位。贪心不变量可以写成:每次追加一个数字后,tdp[t] 依旧同步——剩余代价 t 恰好还能凑出 dp[t] 位,且已输出的前缀是所有最优解中字典序最大的前缀。因为从 9 递减枚举,先被写满的一定是尽可能多的大数字。

解题步骤

  • 开表并初始化dp 长度为 target + 1,先全部填成 -target-1,再令 dp[0] = 0。这个哨兵由输入上界直接推出:任何转移链最多追加 target 位,所以不可达值无论经过多少次 +1 都仍为负数,不需要魔法常量。
  • 正序填表t 从 1 到 target,内层 d 从 1 到 9,先判断 t >= cost[d-1] 再松弛。这里不需要额外判断 dp[t - c] 是否可达:不可达时它是一个巨大的负数,+1 后仍然远小于任何合法值,不会被 max 选中,负无穷起到了自动过滤的作用。
  • 判无解dp[target] < 0 说明代价凑不齐,按题意返回字符串 "0"。这一步必须在重建之前做,否则重建循环会因为条件永不成立而返回空串。
  • 从高位到低位贪心重建t = targetd 从 9 递减到 1;内层 while (t >= c && dp[t] == dp[t - c] + 1) 时追加字符 dt -= c。用 while 而不是 if 是关键——同一个大数字能连写几位就连写几位,先把高位铺满大数字才是字典序最大。
  • 拼接返回:Java 用 StringBuilder 追加,Go 用 []byte 追加后转字符串,避免 $O(n^2)$ 的字符串拼接。重建自然是从高位往低位产生的,不需要反转。

cost = [4, 3, 2, 5, 6, 7, 2, 5, 5]target = 9 走一遍。也就是写 1 花 4、写 2 花 3、写 3 花 2、写 4 花 5、写 5 花 6、写 6 花 7、写 7 花 2、写 8 花 5、写 9 花 5。

填表阶段,最小代价是 2(数字 3 和 7 都是 2):
dp[0] = 0
dp[1]:没有代价 ≤ 1 的数字,不可达。
dp[2] = dp[0] + 1 = 1(用 3 或 7)。
dp[3] = dp[0] + 1 = 1(用 2)。
dp[4] = max(dp[2] + 1, dp[0] + 1) = 2(两个 2 代价的数字,或一个 1)。
dp[5] = max(dp[3] + 1, dp[2] + 1, dp[0] + 1) = 2
dp[6] = dp[4] + 1 = 3
dp[7] = dp[5] + 1 = 3
dp[8] = dp[6] + 1 = 4
dp[9] = dp[7] + 1 = 4

所以最长能写 4 位。dp[9] = 4 >= 0,有解,进入重建。

重建阶段t = 9
d = 9(代价 5):dp[9] = 4dp[4] = 22 + 1 = 3 ≠ 4,写 9 会让总长掉到 3,跳过。
d = 8(代价 5):同样不成立,跳过。
d = 7(代价 2):dp[7] = 33 + 1 = 4 = dp[9],成立!追加 7t 变成 7。再试:dp[5] = 22 + 1 = 3 = dp[7],成立,追加 7t 变成 5。再试:dp[3] = 11 + 1 = 2 = dp[5],成立,追加 7t 变成 3。再试:dp[1] 不可达,dp[1] + 1 是巨大负数,不等于 dp[3] = 1,停止。当前结果 "777"
d = 6(代价 7):t = 3 < 7,跳过。d = 5(代价 6)、d = 4(代价 5)同样跳过。
d = 3(代价 2):dp[1] 不可达,不成立,跳过。
d = 2(代价 3):dp[0] = 00 + 1 = 1 = dp[3],成立,追加 2t 变成 0。再试:t = 0 < 3,停止。结果 "7772"
d = 1(代价 4):t = 0,跳过。

返回 "7772",正好 4 位,代价 2 + 2 + 2 + 3 = 9,与 target 严格相等。可以对照一下:同样是 4 位的还有 7773 吗?写 3 也花 2,但四个 2 代价的数字总共只花 8,剩 1 凑不齐,所以 4 位方案里代价分配只能是 2+2+2+3,末位必须是代价 3 的数字 2。而把 7 换成 3 只会让高位变小。贪心从 9 往下、能连写就连写,正好选中了 7772

代码实现

import java.util.Arrays;

class Solution {
    public String largestNumber(int[] cost, int target) {
        // dp[t]:恰好花掉代价 t 时能写出的最多位数;负数代表不可达。
        int[] dp = new int[target + 1];
        Arrays.fill(dp, -target - 1);
        dp[0] = 0;

        for (int t = 1; t <= target; t++) {
            for (int d = 1; d <= 9; d++) {
                int c = cost[d - 1];
                if (t >= c) {
                    // 不可达状态是极小负数,+1 后不会被 max 选中,无需额外判断。
                    dp[t] = Math.max(dp[t], dp[t - c] + 1);
                }
            }
        }

        if (dp[target] < 0) {
            return "0";
        }

        StringBuilder sb = new StringBuilder(dp[target]);
        int t = target;
        for (int d = 9; d >= 1; d--) {
            int c = cost[d - 1];
            // 只要写下 d 不损失总长度,就一直写,先把高位铺满大数字。
            while (t >= c && dp[t] == dp[t - c] + 1) {
                sb.append(d);
                t -= c;
            }
        }
        return sb.toString();
    }
}
func largestNumber(cost []int, target int) string {
	// dp[t]:恰好花掉代价 t 时能写出的最多位数;负数代表不可达。
	dp := make([]int, target+1)
	for i := range dp {
		dp[i] = -target - 1
	}
	dp[0] = 0

	for t := 1; t <= target; t++ {
		for d := 1; d <= 9; d++ {
			c := cost[d-1]
			if t >= c {
				// 不可达状态是极小负数,+1 后不会被选中,无需额外判断。
				if dp[t-c]+1 > dp[t] {
					dp[t] = dp[t-c] + 1
				}
			}
		}
	}

	if dp[target] < 0 {
		return "0"
	}

	res := make([]byte, 0, dp[target])
	t := target
	for d := 9; d >= 1; d-- {
		c := cost[d-1]
		// 只要写下 d 不损失总长度,就一直写,先把高位铺满大数字。
		for t >= c && dp[t] == dp[t-c]+1 {
			res = append(res, byte('0'+d))
			t -= c
		}
	}
	return string(res)
}

复杂度分析

  • 时间复杂度:$O(9 \cdot target)$,即 $O(target)$。填表阶段是 target 乘以固定的 9 种数字;重建阶段每追加一位至少消耗 1 点代价,总追加次数不超过 target,外层 9 次循环也是常数,所以重建同样是 $O(target)$。
  • 空间复杂度:$O(target)$。一维 dp 数组占 target + 1 个整数;答案字符串最长约 target / min(cost) 位,最坏也是 $O(target)$ 量级,属于必需的输出开销。相比记录完整转移路径的写法,本解法靠「用 dp 表反推」省掉了额外的决策数组。

关键点总结

  • 把「求最大数」拆成「先最大化位数、再最大化字典序」,是本题的核心分解。位数是可加的、适合 DP;字典序不可加、适合贪心。看到「构造最优字符串」的题,先想能不能这样分层是通用打法。
  • 不可达必须用负无穷显式表示,不能用 0。恰好装满型背包和「不超过」型背包的唯一区别就在初始化上,这是面试里最常被追问的一点。
  • dp[t] == dp[t-c] + 1 反推路径,不需要额外的 choice 数组。这个技巧在所有「求方案而不只求最优值」的 DP 里都能用,省一个数组也省一次同步维护。
  • 重建时用 while 而非 if:字典序最大要求尽可能多的高位是大数字,能连写就连写。写成 if 会让每个数字最多出现一次,答案立刻变小。
  • 答案必须是字符串target 到 5000 意味着结果可能有数千位,用 longint 存都会溢出。看到「返回最大数字」先确认返回类型,是很实用的审题习惯。
  • 完全背包的「外层容量、内层物品」和「外层物品、内层容量」在本题都能算出正确的 dp,但前者更贴合「枚举最后一位是谁」的推导叙述,面试时讲起来更顺。

易错点总结

  • dp 全部初始化为 0cost = [2,2,2,2,2,2,2,2,2]target = 3 时,错误转移会由不可达的 dp[1] = 0 推出 dp[3] = 1,并重建出代价只有 2 的 "9";正确答案是 "0"
  • 负无穷离 0 太近:若哨兵只取 -1,上面的不可达状态加一次就变成 0,继续转移后甚至会变成正数。用 -target-1 可由「最多追加 target 位」直接证明安全。
  • 重建时用 if 代替 whilecost = [4,3,2,5,6,7,2,5,5]target = 9 会先写一个 7t 变 7),然后 d 继续往下递减,再也回不到 7,最终得到位数不足的短串(如 "732"),而正确答案是 "7772"
  • 重建时从 d = 1 递增到 9:同一用例会优先塞小数字,得到 "2333" 这个同样 4 位但字典序更小的结果。方向必须是 9 → 1。
  • 忘记 dp[target] < 0 的无解判断cost = [5,5,5,5,5,5,5,5,5]target = 3 时重建循环里所有条件都不成立,返回空字符串 "",而题目要求返回 "0"
  • 把结果按数值累加(如 answer = answer * 10 + d):target = 5000 且最小代价为 2 时答案有 2500 位,long 早在第 19 位就溢出,输出完全是垃圾值。
  • String+= 拼接:答案可达数千位,每次拼接都重新分配并复制,时间退化到 $O(target^2)$,target = 5000 时明显变慢。
  • cost 的下标偏移写错,把 cost[d] 当成数字 d 的代价:cost 长度只有 9,d = 9cost[9] 直接越界;即使不越界,每个数字都对应错了代价,答案全错。
  • 误以为可以用数字 0:题目只给了 1 到 9 的代价,若在重建时允许写 0,"70" 这类结果既没有对应代价也不合题意。

相似题目

题目 难度 考察点
322. 零钱兑换 中等 恰好装满求最少件数,负无穷换成正无穷,是本题填表阶段的镜像
518. 零钱兑换 II 中等 求方案而非最优值,循环顺序决定组合还是排列,初始化改为 dp[0]=1
377. 组合总和 Ⅳ 中等 同样计数但顺序不同视为不同方案,必须外层容量内层物品
279. 完全平方数 中等 物品集合由平方数动态生成,其余与最少件数背包完全一致
面试题 08.11. 硬币 中等 固定四种面额的计数型完全背包,额外考察取模防溢出
LCR 103. 零钱兑换 中等 与 322 同题,可用来练习恰好装满的初始化写法