背景

  • 业务中有一个功能,可以使用优惠券抵扣字数,优惠券面额有 300 字、500 字和 800 字,张数随机。在用户下单的时候需要为其自动推荐最佳优惠券组合,同时为了保证优惠券不浪费,最多只能溢出小于 300 字。

    • 例如 900 字的订单,有 2 张 800 字优惠券,若全部使用,则会浪费 700 字,对用户体验不好。对于这种情况,只为用户自动选择 1 张 800 字的优惠券即可。
  • 那么这个问题可以抽象为一个有条件限制的多重背包问题。

题目描述

  • 你有若干张优惠券,每张优惠券可以抵扣一定的字数,规则如下:
    1. 优惠券面额为300500800,每种张数有限
    2. 每次用户选择一个目标字数target(为100的整数倍)
  • 请你从宜有的优惠券中选出若干张,使得总字数sum满足
    1. sum >= target (必须覆盖目标字数)
    2. sum - target < 300(溢出必须严格小于300,即最小优惠券面额)
  • 如果找不到满足上述条件的组合,退而求其次,返回一个 sum <= target的组合,要求它尽可能接近target
  • 如果仍然无法组成任意组合,则返回null
  • 你需要输出所选优惠券的张数,格式为[used_300, used_500, used_800]

示例1

  • 输入:
    1
    2
    3
    4
    count300 = 2
    count500 = 1
    count800 = 1
    target = 400
  • 输出:
    1
    [0, 1, 0]

示例2

  • 输入:
    1
    2
    3
    4
    count300 = 0
    count500 = 0
    count800 = 2
    target = 900
  • 输出:
    1
    [0, 0, 1]
  • 解释:2800 总共 1600,溢出太大不满足要求;但用 1800 可以覆盖 800,是次优方案中最接近的。

示例3

  • 输入:
    1
    2
    3
    4
    count300 = 0
    count500 = 0
    count800 = 0
    target = 500
  • 输出:
    1
    null

解题思路

多重背包模型

  • 这是一个典型的多重背包问题(每种物品数量有限),不同点在于加入了“溢出限制”和“次优解兜底”逻辑。

转换分析

  • 将三种优惠券视为三类物品:每种物品有一个固定价值(300、500、800)和数量限制。

  • 目标是尽可能刚好填满一个区间 [target, target + 299],即我们在背包容量不止为 target,而是允许范围内多出来一点点。

  • 如果找不到上述组合,则考虑向下逼近 target

步骤分解

  1. 定义状态:使用一维布尔数组 dp[i] 表示当前是否能凑出字数 i;使用 path[i] 保存达到该字数所用的优惠券组合(记录 [300张数, 500张数, 800张数])。

  2. 初始化:dp[0] = true,表示不使用任何券时可以达到 0;其余为 false

  3. 状态转移:对于每种优惠券,进行多重背包枚举(按张数逐一添加),倒序更新 dp 状态。

  4. 路径记录:每次成功转移状态 s -> s + val,记录 path 中的券张数组合。

  5. 寻找最优解:从 target 开始往上查找至 target + 299,一旦发现有解即为最优。

  6. 次优策略:若无法满足条件 12,从 target - 1 反向查找最接近的组合。

  7. 无解情况:若所有路径都不可达,则返回 null

Java实现

  1. 定义状态存储结构

    • 用于记录到达某个金额时,三种面额分别用了多少张券。这样我们就能在求出最终结果时,知道怎么凑出来的。
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      static class State {
      int count300, count500, count800;

      State(int c300, int c500, int c800) {
      this.count300 = c300;
      this.count500 = c500;
      this.count800 = c800;
      }
      }

  2. 主函数声明

    • 参数:count300 / count500 / count800:每种券的数量

    • target:目标字数(必须是100整数倍)

      • 返回值:长度为3的数组,表示每种券用了多少张;或 null 表示无解。
        1
        public static int[] solve(int count300, int count500, int count800, int target)
  3. 初始化变量

    • values:每种优惠券面额
    • counts:每种面额可用张数
    • maxSum:最大搜索金额(比如 target=700,我们最多能接受 sum=999)
    • 为什么是 target + 299?因为溢出最多允许小于300,也就是 sum - target < 300。所以最多考虑到 target + 299。
      1
      2
      3
      int[] values = {300, 500, 800};
      int[] counts = {count300, count500, count800};
      int maxSum = target + 299;
  4. 初始化dp列表

    • dp[s] == true 表示:我们能用若干优惠券凑出总额 s
    • 初始时只有 dp[0] = true(表示啥也不用)
    • path[s] 记录达到金额 s 时所使用的券张数组合。
      1
      2
      3
      4
      boolean[] dp = new boolean[maxSum + 1];
      dp[0] = true;
      State[] path = new State[maxSum + 1];
      path[0] = new State(0, 0, 0);
  5. 状态转移

    • 这里用了多重背包朴素解法(O(n * sum)),核心点:
      • 遍历券种类 → 遍历张数 → 遍历背包容量(倒序)
      • 每次发现 dp[s] 可达,就尝试扩展到 dp[s + val]
      • 用 path[] 记录从哪一步转移来的,便于追溯券组合
        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        17
        18
        19
        20
        for (int i = 0; i < 3; i++) { // 遍历三种面额
        int val = values[i]; // 当前面额
        int num = counts[i]; // 当前种类最多可用张数

        for (int used = 0; used < num; used++) { // 按张数来转移
        for (int s = maxSum - val; s >= 0; s--) { // 倒序避免重复使用
        if (dp[s] && !dp[s + val]) {
        dp[s + val] = true;

        // 记录路径(怎么从 s -> s+val)
        State prev = path[s];
        path[s + val] = new State(
        prev.count300 + (i == 0 ? 1 : 0),
        prev.count500 + (i == 1 ? 1 : 0),
        prev.count800 + (i == 2 ? 1 : 0)
        );
        }
        }
        }
        }
  6. 从「合法区间」找最优解

    • 先查满足 ≥ target 且溢出小于 300 的
      1
      2
      3
      4
      5
      6
      for (int s = target; s <= target + 299; s++) {
      if (dp[s]) {
      State res = path[s];
      return new int[]{res.count300, res.count500, res.count800};
      }
      }
    • 再查满足 ≤ target 的最大值
      1
      2
      3
      4
      5
      6
      for (int s = target - 1; s >= 0; s--) {
      if (dp[s]) {
      State res = path[s];
      return new int[]{res.count300, res.count500, res.count800};
      }
      }
    • 如果目标区间内没有任何组合满足要求,就从目标往下找尽可能接近的组合。
  7. 完整代码

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    static class State {
    int count300, count500, count800;

    State(int c300, int c500, int c800) {
    this.count300 = c300;
    this.count500 = c500;
    this.count800 = c800;
    }
    }

    public static int[] solve(int count300, int count500, int count800, int target) {
    int[] values = {300, 500, 800};
    int[] counts = {count300, count500, count800};
    int maxSum = target + 299;

    boolean[] dp = new boolean[maxSum + 1];
    dp[0] = true;

    State[] path = new State[maxSum + 1];
    path[0] = new State(0, 0, 0);

    for (int i = 0; i < 3; i++) {
    int val = values[i];
    int num = counts[i];
    for (int used = 0; used < num; used++) {
    for (int s = maxSum - val; s >= 0; s--) {
    if (dp[s] && !dp[s + val]) {
    dp[s + val] = true;
    State prev = path[s];
    path[s + val] = new State(
    prev.count300 + (i == 0 ? 1 : 0),
    prev.count500 + (i == 1 ? 1 : 0),
    prev.count800 + (i == 2 ? 1 : 0)
    );
    }
    }
    }
    }

    for (int s = target; s <= target + 299; s++) {
    if (dp[s]) {
    State res = path[s];
    return new int[]{res.count300, res.count500, res.count800};
    }
    }

    for (int s = target - 1; s >= 0; s--) {
    if (dp[s]) {
    State res = path[s];
    return new int[]{res.count300, res.count500, res.count800};
    }
    }

    return null;
    }