题目

划分

题目链接

解题思路(01背包子集和模型)

题目关键特征

给定 40 个正整数,将其划分为两个非空子集,使得两子集元素和的乘积最大。
设总和为 total,若一个子集和为 s,则乘积为 s * (total - s)
该函数在 s = total / 2 时取最大值 → 目标转化为:找出最接近 total/2 的可行子集和 s(0 < s < total)

问题转化思路

  • 物品:40 个数字,每个只能用一次
  • 背包容量上限total(所有数之和,约 25 万)
  • 状态定义目标:判断某个和 s 是否能被凑出 → 01 背包可行性问题(子集和)
  • 最终答案:枚举所有可达的 s,计算 s * (total - s) 的最大值

💡 为什么不能直接取 s = total/2
因为不一定存在子集和恰好等于 total/2,必须从实际可达的和中找最接近者。

动态规划设计

二维 DP 定义与转移
  • dp[i][j]:从前 i 个数中能否选出若干个,使其和恰好为 j

  • 初始化

    • dp[0][0] = true(0 个数可凑出和 0)
    • dp[0][j>0] = false
  • 递推公式

    dp[i][j] = dp[i-1][j] || (j >= nums[i-1] && dp[i-1][j - nums[i-1]])
    
    • 不选第 i 个数 → 继承 dp[i-1][j]
    • 选第 i 个数 → 需 j >= nums[i-1]dp[i-1][j - nums[i-1]] 为真
一维滚动数组优化
  • 状态压缩dp[j] 表示当前能否凑出和 j
  • 遍历顺序:内层循环 倒序jtotalnum
    • 防止同一物品被重复使用(正序会导致完全背包)
  • 初始化dp[0] = true,其余默认 false

✅ 外层物品内层容量,倒序遍历保 01;初始 dp[0]=true,可行性靠 || 更新。


参考代码

方法一:二维 DP(清晰展示状态转移逻辑)

import java.util.Scanner;

public class Main {
    public static void main(String[] args) {
        int[] nums = {
            5160, 9191, 6410, 4657, 7492, 1531, 8854, 1253, 4520, 9231,
            1266, 4801, 3484, 4323, 5070, 1789, 2744, 5959, 9426, 4433,
            4404, 5291, 2470, 8533, 7608, 2935, 8922, 5273, 8364, 8819,
            7374, 8077, 5336, 8495, 5602, 6553, 3548, 5267, 9150, 3309
        };
        int n = nums.length;
        // 计算总和作为背包容量上限
        int total = 0;
        for (int x : nums) total += x;

        // dp[i][j]: 前i个数能否凑出和j
        boolean[][] dp = new boolean[n + 1][total + 1];
        dp[0][0] = true; // 基础情况:0个数凑0

        // 状态转移:逐个考虑每个数字
        for (int i = 1; i <= n; i++) {
            for (int j = 0; j <= total; j++) {
                // 不选当前数字nums[i-1]
                dp[i][j] = dp[i - 1][j];
                // 选当前数字(需满足容量足够)
                if (j >= nums[i - 1]) {
                    dp[i][j] = dp[i][j] || dp[i - 1][j - nums[i - 1]];
                }
            }
        }

        // 枚举所有可行子集和s,求最大乘积
        long ans = 0;
        for (int s = 1; s < total; s++) { // 排除s=0或s=total(非空要求)
            if (dp[n][s]) {
                ans = Math.max(ans, (long) s * (total - s));
            }
        }
        System.out.println(ans);
    }
}

方法二:一维 DP(空间优化,推荐写法)

import java.util.Scanner;

public class Main {
    public static void main(String[] args) {
        int[] nums = {
            5160, 9191, 6410, 4657, 7492, 1531, 8854, 1253, 4520, 9231,
            1266, 4801, 3484, 4323, 5070, 1789, 2744, 5959, 9426, 4433,
            4404, 5291, 2470, 8533, 7608, 2935, 8922, 5273, 8364, 8819,
            7374, 8077, 5336, 8495, 5602, 6553, 3548, 5267, 9150, 3309
        };
        int n = nums.length;
        int total = 0;
        for (int x : nums) total += x;

        // 一维dp: dp[j]表示能否凑出和j
        boolean[] dp = new boolean[total + 1];
        dp[0] = true; // 初始状态

        // 01背包标准写法:外层物品,内层容量倒序
        for (int num : nums) {
            for (int j = total; j >= num; j--) {
                // 若j-num可达,则j也可达
                if (dp[j - num]) {
                    dp[j] = true;
                }
            }
        }

        // 找最大乘积
        long ans = 0;
        for (int s = 1; s < total; s++) {
            if (dp[s]) {
                ans = Math.max(ans, (long) s * (total - s));
            }
        }
        System.out.println(ans);
    }
}

📌 提交答案:运行任一代码输出结果为 12873625444,直接提交该整数即可。

砝码称重

题目链接

解题思路(三态砝码动态规划)

题目关键特征

你有一架天平和 ​ N 个砝码,重量依次是 ​ W_1, W_2, \ldots, W_N 。​每个砝码可以:

  • 放在左边托盘(和被称物体同侧)→ 相当于 +W_i
  • 放在右边托盘(和被称物体对侧)→ 相当于 -W_i
  • 不使用 → 相当于 0

目标是:能称出多少种不同的正整数重量

📌 注意:题目问的是“重量”,所以只统计 大于 0 的结果


暴力 DFS 思路(理解用,效率低)

我们可以用递归枚举每个砝码的三种选择:

  • dfs(i, sum) 表示处理到第 i 个砝码,当前总和为 sum
  • 对每个砝码,有三种操作:
    • 加:dfs(i+1, sum + w[i])
    • 减:dfs(i+1, sum - w[i])
    • 不选:dfs(i+1, sum)
  • i == n 时,如果 sum > 0,就把 sum 加入一个 Set 去重

但这种方法时间复杂度是 ​ O(3^N) ,当 ​ N = 30 时完全不可行。
所以我们需要用 动态规划 来高效解决。


动态规划核心思想

第一步:确定范围

设所有砝码总重量为 total = W₁ + W₂ + … + Wₙ

那么所有可能的带符号和的范围是:
-total+total,一共最多 2 * total + 1 种可能。

但数组下标不能是负数,所以我们引入一个偏移量(offset)

  • offset = total
  • 真实和为 x → 存储在数组下标 j = x + offset
  • 例如:
    • 真实和 -5 → 存在 dp[total - 5]
    • 真实和 0 → 存在 dp[total]
    • 真实和 +3 → 存在 dp[total + 3]

这样就能用普通数组表示负数状态了。


第二步:二维 DP 设计(帮助理解)
  • 定义 dp[i][j]:使用前 i 个砝码,是否能凑出 对应下标 j 的真实和(即真实和 = j - offset
  • 初始化:
    • dp[0][offset] = true:0 个砝码只能凑出和为 0
    • 其他 dp[0][j] = false
  • 状态转移(对第 i 个砝码,重量为 w = weights[i-1]):
    • 如果 dp[i-1][j] 为真,那么:
      • 不放dp[i][j] = true
      • 放左边(+w):如果 j + w <= 2 * total,则 dp[i][j + w] = true
      • 放右边(-w):如果 j - w >= 0,则 dp[i][j - w] = true

最后,统计 joffset + 12 * totaldp[n][j] == true 的个数,就是答案。


第三步:优化为一维 DP(实际写法)

因为每次只依赖上一轮的状态,我们可以用一维数组。

但注意:不能像 01 背包那样倒序更新
因为这里每个状态会同时影响 j + wj - w,如果直接在原数组修改,会导致同一个砝码被多次使用。

✅ 正确做法:每轮新建一个临时数组 newDp

步骤如下:

  1. 创建 dp 数组,大小为 2 * total + 1
  2. dp[offset] = true(初始和为 0)
  3. 对每个砝码 w
    • 创建 newDp 数组(全 false)
    • 遍历所有 j
      • 如果 dp[j] 为真,则:
        • newDp[j] = true(不放)
        • newDp[j + w] = true(放左边,+w)
        • newDp[j - w] = true(放右边,-w)
    • dp = newDp
  4. 最后统计 j = offset + 12 * totaldp[j] == true 的数量

参考代码

import java.util.Scanner;

public class Main {
    public static void main(String[] args) {
        Scanner sc = new Scanner(System.in);
        int n = sc.nextInt();
        int[] weights = new int[n];
        int total = 0;
        for (int i = 0; i < n; i++) {
            weights[i] = sc.nextInt();
            total += weights[i];
        }

        // dp[j] 表示是否能凑出“真实和 = j - total”
        // 数组下标范围:0 ~ 2*total,共 2*total+1 个位置
        boolean[] dp = new boolean[2 * total + 1];
        int offset = total;
        dp[offset] = true; // 初始状态:和为0可达

        // 逐个处理每个砝码
        for (int w : weights) {
            boolean[] newDp = new boolean[2 * total + 1]; // 新建临时数组
            for (int j = 0; j <= 2 * total; j++) {
                if (!dp[j]) continue; // 当前状态不可达,跳过
                // 三种选择
                newDp[j] = true; // 不放
                if (j + w <= 2 * total) 
                    newDp[j + w] = true; // 放左边(+w)
                if (j - w >= 0) 
                    newDp[j - w] = true; // 放右边(-w)
            }
            dp = newDp; // 更新状态
        }

        // 统计所有正整数重量(真实和 > 0)
        int count = 0;
        for (int j = offset + 1; j <= 2 * total; j++) {
            if (dp[j]) {
                count++;
            }
        }

        System.out.println(count);
    }
}

关键细节说明

Q1:为什么只统计正数?

因为题目问的是“能称出多少种不同的重量”,重量必须是正数。负数只是中间计算状态,不代表实际可称的重量。

Q2:为什么需要偏移量?

Java 数组下标不能为负数。通过 offset = total,我们将区间 [-total, total] 平移到 [0, 2*total],使得所有可能的和都能用非负下标表示。

Q3:为什么不能直接在原数组上更新?

因为每个砝码只能用一次。如果直接更新 dp[j + w]dp[j - w],后续遍历到这些位置时可能会再次使用同一个砝码,导致错误。用 newDp 能确保每轮只基于上一轮的完整状态进行转移。

Q4:时间复杂度如何?

  • 总重量 total 最大约为 ​ 10^5 (题目隐含)
  • 时间复杂度:​ O(N \times total)
  • 空间复杂度:​ O(total)
  • 对于 ​ N \leq 30 ,完全可以在 1 秒内运行完毕

倒水

题目链接

解题思路(分组背包)

本题要求在总水量不超过 m 的前提下,为 n 位客人分配倒水量,使得总满意度最大。每位客人有三种互斥的倒水策略:

  • 倒水量 < a[i] → 满意度为 e[i]
  • 倒水量 = a[i] → 满意度为 b[i]
  • 倒水量 ≥ c[i] → 满意度为 d[i]

由于每位客人只能选择一种策略,且策略之间互斥,这本质上是一个 分组背包问题:每组(客人)有三个物品(策略),每组至多选一个。

核心思想:默认选择 + 替换优化

我们采用一种巧妙的动态规划策略:

  1. 默认为当前客人选择“倒 0 毫升”(即满意度 e[i]),直接累加到当前状态;
  2. 然后尝试用更优的选项(b[i]d[i]替换这个默认选择。

这样,状态 dp[j] 始终表示:前 i 位客人,在总用水量恰好为 j 时的最大满意度

为什么必须倒序遍历容量?

在状态转移中,我们需要用到上一轮(前 i-1 位客人)的状态:

dp[j] = max(dp[j], dp[j - a[i]] + b[i])
  • 如果正序遍历,dp[j - a[i]] 可能已被当前轮加上了 e[i],导致同一个客人被重复计算(既算 e[i] 又算 b[i]),违反“每组仅选一个”的规则。
  • 倒序遍历保证 dp[j - vol] 尚未被当前轮修改,仍是上一轮的纯净状态。

状态定义与转移

  • 状态定义dp[j] 表示总用水量为 j 时的最大满意度。
  • 初始化dp[0...m] = 0,表示没有客人时满意度为 0。
  • 状态转移(对每位客人 i):
    1. dp[j] += e[i]:默认选择倒 0 毫升;
    2. j ≥ a[i],尝试:dp[j] = max(dp[j], dp[j - a[i]] + b[i])
    3. j ≥ c[i],尝试:dp[j] = max(dp[j], dp[j - c[i]] + d[i])

参考代码

import java.util.*;

public class Main {
    public static void main(String[] args) {
        Scanner sc = new Scanner(System.in);
        int n = sc.nextInt();  // 客人数量
        int m = sc.nextInt();  // 总水量上限

        // 使用 1-indexed 数组便于理解
        int[] a = new int[n + 1];
        int[] b = new int[n + 1];
        int[] c = new int[n + 1];
        int[] d = new int[n + 1];
        int[] e = new int[n + 1];

        // 读入每位客人的五元组 (a, b, c, d, e)
        for (int i = 1; i <= n; i++) {
            a[i] = sc.nextInt();
            b[i] = sc.nextInt();
            c[i] = sc.nextInt();
            d[i] = sc.nextInt();
            e[i] = sc.nextInt();
        }

        // dp[j]: 总用水量为 j 时的最大满意度
        // 初始化为 0: 没有客人时满意度为 0
        long[] dp = new long[m + 1];

        // 遍历每位客人 (分组)
        for (int i = 1; i <= n; i++) {
            // 倒序遍历水量 j, 防止同一客人被重复选择
            for (int j = m; j >= 0; j--) {
                // 默认策略: 倒 0 毫升, 满意度为 e[i]
                dp[j] += e[i];

                // 尝试策略2: 倒 a[i] 毫升
                if (j >= a[i]) {
                    dp[j] = Math.max(dp[j], dp[j - a[i]] + b[i]);
                }

                // 尝试策略3: 倒 c[i] 毫升 (倒 >= c[i] 即可得 d[i], 但只需考虑最小消耗 c[i])
                if (j >= c[i]) {
                    dp[j] = Math.max(dp[j], dp[j - c[i]] + d[i]);
                }
            }
        }

        long ans = dp[0];
        for (int j = 1; j <= m; j++) {
            if (dp[j] > ans) ans = dp[j];
        }
        System.out.println(ans);
    }
}

背包与魔法

题目链接

解题思路(背包问题进阶)

本题是经典的 01 背包问题的扩展,唯一区别在于:小蓝可以使用一次魔法,将某一件物品的重量增加 K,同时价值翻倍。
这引入了一个新的决策维度:是否使用魔法?用在哪个物品上?

核心思想:状态拆分 + 状态转移

由于魔法只能使用一次,我们可以将状态分为两类:

  • 未使用魔法:当前背包容量为 j,前 i 个物品中尚未使用魔法的最大价值。
  • 已使用魔法:当前背包容量为 j,前 i 个物品中已经使用过魔法的最大价值。

✅ 关键点:魔法只能用一次,所以一旦使用,后续物品不能再用。

状态定义

f[i][j][0] 表示考虑前 i 个物品、背包容量为 j尚未使用魔法时的最大价值;
f[i][j][1] 表示考虑前 i 个物品、背包容量为 j已经使用过魔法时的最大价值。

但为了节省空间,我们只需维护两个一维数组:f[j][0]f[j][1],分别表示当前状态。

状态转移(关键)

对每个物品 i,有三种情况:

  1. 物品无法放入背包w[i] > j → 忽略
  2. 不选该物品:无论是否使用魔法,状态不变
  3. 选该物品
    • 若未使用魔法:可正常放入,价值为 v[i]
    • 若已使用魔法:可选择不用魔法(之前用过,这次正常放)或用魔法(之前没用过这次用)(重量变为 w[i]+k,价值变为 2*v[i]

⚠️ 注意:魔法只能用一次,所以一旦使用,后续物品不能再用。

状态转移方程

  • 未使用魔法
    f[j][0] = max(f[j][0], f[j - w[i]][0] + v[i])
    
  • 已使用魔法
    f[j][1] = max(
        f[j][1],  // 不放
        f[j - w[i]][1] + v[i],    // 不用魔法放
        f[j - (w[i] + k)][0] + 2 * v[i]  // 用魔法放(从“未使用”状态转移)
    )
    

✅ 口诀:一个背包两种态,魔法只用一次;未用先放再用,已用可选可魔。


参考代码

import java.util.Scanner;

public class Main {
    public static void main(String[] args) {
        Scanner sc = new Scanner(System.in);
        int n = sc.nextInt();  // 物品数量
        int m = sc.nextInt();  // 背包容量
        int k = sc.nextInt();  // 魔法增重值

        int[] w = new int[n + 1];  // 重量
        int[] v = new int[n + 1];  // 价值

        for (int i = 1; i <= n; i++) {
            w[i] = sc.nextInt();
            v[i] = sc.nextInt();
        }

        // dp[j][0]: 容量为 j 且未使用魔法的最大价值
        // dp[j][1]: 容量为 j 且已使用魔法的最大价值
        long[][] dp = new long[m + 1][2];

        // 遍历每个物品
        for (int i = 1; i <= n; i++) {
            // 倒序遍历容量,防止重复选择
            for (int j = m; j >= w[i]; j--) {
                // 情况1: 未使用魔法,选择第 i 个物品
                dp[j][0] = Math.max(dp[j][0], dp[j - w[i]][0] + v[i]);

                // 情况2: 已使用魔法,选择第 i 个物品
                // 子情况 a: 不用魔法放(正常放)
                dp[j][1] = Math.max(dp[j][1], dp[j - w[i]][1] + v[i]);
                // 子情况 b: 用魔法放(重量变为 w[i]+k,价值变为 2*v[i])
                if (j >= w[i] + k) {
                    dp[j][1] = Math.max(dp[j][1], dp[j - (w[i] + k)][0] + 2 * v[i]);
                }
            }
        }

        // 最终答案:取两种状态下的最大值
        System.out.println(Math.max(dp[m][0], dp[m][1]));
    }
}

健身

题目链接

解题思路(区间连续任务背包模型)

本题看似是任务调度问题,实则可转化为 “区间选择型背包”
每个健身计划是一个“物品”,其“重量”是连续的 2^k 天,“价值”是增益 s
但与普通背包不同的是:物品只能放在特定位置(必须连续且空闲)

问题建模:从任务调度到背包

  • 容量:总天数 n
  • 物品:每个计划 (k, s),占用 len = 2^k
  • 约束:物品必须放在一段连续且完全空闲的区间内
  • 目标:选择若干不重叠的物品,使总价值最大

✅ 关键转化:将“能否安排计划”转化为“该长度区间是否空闲”

状态定义

dp[i] 表示考虑前 i 天时能获得的最大增益。

状态转移

对每一天 i,我们有两种选择:

  1. 第 i 天不训练dp[i] = dp[i - 1]

  2. 以第 i 天结尾完成一个计划
    枚举所有可能的计划长度 len = 2^k,若区间 [i - len + 1, i] 全空闲,则:

    dp[i] = Math.max(dp[i], dp[i - len] + maxGain[k])
    

⚠️ 注意:同一个 k 可能有多个计划,我们只需保留最大增益的那个(贪心优化)

如何快速判断区间是否空闲?

使用 前缀和数组 busy

  • busy[i] 表示前 i 天中有多少天被占用
  • 区间 [l, r] 空闲 ⇨ busy[r] - busy[l - 1] == 0

枚举优化

由于 2^kk 指数增长,当 2^k > i 时可提前终止循环。


参考代码

import java.util.*;

public class Main {
    public static void main(String[] args) {
        Scanner sc = new Scanner(System.in);
        int n = sc.nextInt();  // 总天数
        int m = sc.nextInt();  // 健身计划数量
        int q = sc.nextInt();  // 已有安排天数

        // 标记被占用的日期 (1-indexed)
        boolean[] occupied = new boolean[n + 1];
        for (int i = 0; i < q; i++) {
            int day = sc.nextInt();
            occupied[day] = true;
        }

        // 构建前缀和: busy[i] = 前 i 天中被占用的天数
        int[] busy = new int[n + 1];
        for (int i = 1; i <= n; i++) {
            busy[i] = busy[i - 1] + (occupied[i] ? 1 : 0);
        }

        // 对每个 k, 保存最大增益 (k 最大为 log2(n) ≈ 20)
        long[] maxGain = new long[32];  // 2^30 > 1e9, 足够覆盖 n <= 2e5
        Arrays.fill(maxGain, 0);

        for (int i = 0; i < m; i++) {
            int k = sc.nextInt();
            long s = sc.nextLong();
            if (s > maxGain[k]) {
                maxGain[k] = s;
            }
        }

        // dp[i]: 前 i 天的最大增益
        long[] dp = new long[n + 1];

        for (int i = 1; i <= n; i++) {
            // 选择1: 第 i 天不训练
            dp[i] = dp[i - 1];

            // 选择2: 尝试以第 i 天结尾完成一个计划
            for (int k = 0; k <= 31; k++) {
                int len = 1 << k;  // 计划所需天数 = 2^k
                if (len > i) break;  // 长度超过当前天数,后续 k 更大,直接退出

                int start = i - len + 1;  // 区间起始日
                // 判断 [start, i] 是否全空闲
                if (busy[i] - busy[start - 1] == 0) {
                    dp[i] = Math.max(dp[i], dp[i - len] + maxGain[k]);
                }
            }
        }

        System.out.println(dp[n]);
    }
}

买书

题目链接

解题思路(计数型完全背包)

本题是经典的 完全背包问题 的变形,目标不是求最大价值,而是求 恰好花完 n 元的不同方案数

问题建模:从“选物品”到“计方案”

  • 物品:四种书籍,价格分别为 10, 20, 50, 100
  • 容量:总钱数 n
  • 限制:每种书可以购买多次(完全背包)
  • 目标:求恰好花完 n 元的方案总数

✅ 关键点:状态转移表示“增加一种选择”带来的方案数变化

状态定义

dp[j] 表示花掉 j 元钱时的不同买书方案数。

初始状态

  • dp[0] = 1:花 0 元有一种方案 —— 什么都不买
  • 其余 dp[j] = 0

状态转移

对于每种书(价格为 p),我们有:

dp[j] += dp[j - p]

含义:当前方案数 = 不选这本书 + 选这本书

  • dp[j]:不选这本书的方案数(原值)
  • dp[j - p]:选这本书的方案数(即在 j-p 元的基础上再买一本)

注意:必须正序遍历!

因为完全背包允许重复选择,所以要 从小到大遍历容量,确保每个物品被多次使用。


参考代码

import java.util.*;

public class Main {
    public static void main(String[] args) {
        Scanner sc = new Scanner(System.in);
        int n = sc.nextInt();  // 总钱数

        // 四种书的价格
        int[] price = {10, 20, 50, 100};

        // dp[j]: 花 j 元的不同方案数
        long[] dp = new long[n + 1];
        dp[0] = 1;  // 花 0 元有一种方案:什么都不买

        // 枚举每种书
        for (int i = 0; i < price.length; i++) {
            int p = price[i];
            // 正序遍历容量,支持重复选择
            for (int j = p; j <= n; j++) {
                dp[j] += dp[j - p];
            }
        }

        System.out.println(dp[n]);
    }
}

2022

题目链接

解题思路(带数量约束的01 背包计数模型)

本题要求将整数 2022 拆分为 恰好 10 个互不相同的正整数 之和,求不同的拆分方案数(顺序无关)。
这本质上是一个 带数量限制的 01 背包计数问题

问题转化:从“拆分”到“选数”

我们可以把问题理解为:

  • 有物品 1, 2, 3, ..., 2022(每个正整数最多选一次)
  • 每个物品的“重量”等于其数值
  • 要求 恰好选择 10 个物品,且总重量为 2022
  • 求方案总数

✅ 这正是 01 背包的扩展:在标准“容量”维度基础上,新增“物品个数”维度。

三维状态定义(便于理解)

dp[i][j][k] 表示:

  • 考虑前 i 个正整数(即数字 1i
  • 总和为 j
  • 恰好选择了 k 个数
  • 的方案数目

状态转移方程为:

dp[i][j][k] = dp[i-1][j][k]                     // 不选第 i 个数
            + dp[i-1][j - i][k - 1]             // 选第 i 个数(需 j >= i 且 k >= 1)

初始条件:

  • dp[0][0][0] = 1
  • 其余为 0

二维滚动数组优化(实际实现)

观察转移方程可知:dp[i][...][...] 只依赖于 dp[i-1][...][...],因此可以省略第一维,使用滚动数组。

定义优化后的状态:

  • dp[j][k]:当前已考虑若干数字后,总和为 j、恰好选了 k 个不同正整数 的方案数

注意:为了防止同一个数字被重复使用,必须 倒序遍历 j 和 k(标准 01 背包技巧)。

💡 注意:这里 j 是总和(容量),k 是数量,与最终目标 dp[2022][10] 对应。

状态转移与遍历顺序

对每个数字 i(从 1 到 2022):

  • 若选择 i,则从状态 dp[j - i][k - 1] 转移而来
  • 转移式:dp[j][k] += dp[j - i[k - 1]

为防止同一个数字被重复使用(01 背包特性),必须 倒序遍历 jk

  • 外层:枚举物品 i
  • 中层:倒序遍历总和 j(从 2022 到 x)
  • 内层:顺序倒序都可以

✅ 倒序保证 dp[j - i][k - 1] 来自上一轮(未包含当前 i)的状态,避免重复选择。

初始化

  • dp[0][0] = 1:总和为 0、选 0 个数,有 1 种方案(空集)
  • 其余初始化为 0

最终答案为 dp[2022][10]


参考代码

public class Main {
    public static void main(String[] args) {
        int sum = 2022;
        int cnt = 10;

        // dp[j][k]: 和为 j,选了 k 个不同正整数的方案数
        long[][] dp = new long[sum + 1][cnt + 1];
        dp[0][0] = 1;  // 初始状态:和为 0,选 0 个数,有一种方案

        // 枚举每个可能的正整数 i(物品)
        for (int i = 1; i <= sum; i++) {
            // 倒序遍历和 j,防止重复使用 i
            for (int j = sum; j >= i; j--) {
                // k的顺序无所谓
                for (int k = cnt; k >= 1; k--) {
                    dp[j][k] += dp[j - i][k - 1];
                }
            }
        }

        System.out.println(dp[sum][cnt]);
    }
}

选数异或

题目链接

核心思想:01 背包的“异或”变形

这道题本质上是一个 动态规划问题,并且是经典的 01 背包模型的变种

传统 01 背包 本题(异或背包)
每个物品选或不选 每个数字选或不选
状态:总和为 j 状态:异或值为 j
转移:dp[j] += dp[j - w] 转移:dp[j ^ a] += dp[j]
目标:求和等于 W 目标:异或等于 x

✅ 共同点:每个元素只有“选”或“不选”两种选择,统计满足条件的方案数
❗ 不同点:运算从“加法”变为“异或”,状态转移方式需调整


动态规划设计

dp 数组定义

dp[j] 表示:当前已处理若干数字后,异或值恰好为 j 的子序列个数。
  • 状态范围:由于题目保证 nums[i] ≤ 63,而异或结果最大不会超过 63(即 2^6 - 1),所以只需开 64 个状态。
  • 初始化:dp[0] = 1,表示空子序列异或值为 0,有 1 种方案。

状态转移逻辑

对于当前数字 nums[i],我们考虑:

  • 不选它:所有已有状态保持不变(dp 数组已保留)
  • 选它:对于每一个已有异或值 j,会新增一个异或值为 j ^ nums[i] 的子序列

因此转移方程为:

newDp[j ^ nums[i]] += oldDp[j]

⚠️ 注意:必须基于上一轮的完整状态进行转移,否则会重复计算。
所以每次循环前,先用 oldDp = dp.clone() 保存旧状态。

3. 为什么不能像普通背包那样倒序?

在加法背包中,j - w < j,倒序可避免覆盖。
但在异或中,j ^ nums[i] 可能大于、小于或等于 j没有单调性,无法通过遍历顺序隔离新旧状态。

✅ 因此必须使用 临时数组(或二维 DP) 来保证正确性。


参考代码(Java)

import java.util.Scanner;

public class Main {
    public static void main(String[] args) {
        final int MOD = 998244353;
        final int MAX_XOR = 64; // 因为题目中 a[i] ≤ 63,异或结果最大为 63(2^6 - 1)

        Scanner sc = new Scanner(System.in);
        int n = sc.nextInt();
        int x = sc.nextInt();
        int[] nums = new int[n];
        for (int i = 0; i < n; i++) {
            nums[i] = sc.nextInt();
        }

        // dp[j] 表示当前异或值为 j 的子序列个数
        long[] dp = new long[MAX_XOR];
        dp[0] = 1; // 空子序列:异或值为 0,有 1 种方案

        // 遍历每个数字(每个物品选或不选)
        for (int i = 0; i < n; i++) {
            // 保存上一轮的状态,防止状态覆盖
            long[] oldDp = dp.clone();
            // 枚举所有可能的异或值状态 j
            for (int j = 0; j < MAX_XOR; j++) {
                if (oldDp[j] != 0) {
                    int newXor = j ^ nums[i]; // 选择当前数字后的新异或值
                    dp[newXor] = (dp[newXor] + oldDp[j]) % MOD;
                }
            }
        }

        System.out.println(dp[x]);
    }
}

一个敲代码的