京东面试:购物车 50 件商品、100 张券,怎么在 200ms 内算出“最省钱组合”?

0 阅读8分钟

写在开头

有个粉丝去面京东零售(交易链路),T7 架构岗,面试官抛出了一个极其业务化的算法题:

面试官: “用户购物车里有 50 件商品,账户里有 100 张各种各样的优惠券(满减、折扣、跨店、品类...)。 经过初步筛选,依然有 20 张券 是**‘可用但关系复杂’**的。 请在 200ms 内算出来:怎么组合这 20 张券,才能让最终支付金额最低?

图片

追问:“为什么不用动态规划(DP)?代码里怎么避免 GC 问题?

如果你只回答“回溯法”,面试官只能给你 60 分。

今天 Fox 带你拆解这套融合了**“算法辩证、图论降维、内存级优化”**的工业级方案。


一、 灵魂拷问:为什么不用动态规划 (DP)?

这是所有算法大佬最想挑战你的点。

标准的“凑单/背包问题”,最优解通常是 DP。如果你直接上回溯,说明你算法功底不深;但如果你解释了为什么不能用 DP,说明你是业务专家

1. DP 的局限性

动态规划的核心是 **状态转移方程 ****dp[i][v]**

在简单的背包问题里,状态只有“容量”和“价值”。

但在电商优惠券场景下,“状态”爆炸了

  • 互斥状态: 用了 A 券就不能用 B 券。

  • 门槛状态: 商品 X 的金额必须 > 199。

  • 叠加状态: 店铺券只能由店铺商品凑单,平台券由全平台凑单。

如果要写 DP,状态可能长这样:

dp[券索引][当前总价][店铺A金额][店铺B金额][已用互斥组ID集合...]

这个维度的 DP 表,内存直接溢出,且代码维护性为零。

图片

2. 架构师的结论

“在极度复杂的业务约束下,回溯法(Backtracking)虽然理论时间复杂度高,但配合强力剪枝,是工程上唯一可维护且性能可控的解法。”


二、 核心算法:图论降维与支配剪枝

1. 图论降维:寻找“连通分量”

我们不能把 100 张券混在一起算。第一步利用 图论 思想。

  • 构建 “券-商品”关系图

  • 切分孤岛: 发现 20 张券其实分裂成了 3 个互不相关的 连通分量(Connected Components)

  • 孤岛 A(手机):5 张券,涉及 2 个商品。

  • 孤岛 B(零食):10 张券,涉及 20 个商品。

  • 孤岛 C(其他):独立。

  • 效果: 计算复杂度从  骤降为 。

图片

2. 严谨的“支配剪枝”

之前的槽点修复: 不能盲目贪心。

我们定义 “A 支配 B” (A Dominates B),当且仅当:

  1. 作用范围相同: 都是针对同一批商品。

  2. 叠加类型相同: 都是店铺满减,且互斥规则一致。

  3. 门槛更低 且 优惠更大: A 满100减20,B 满100减10。

图片

策略: 只有当 A 严格支配 B 时,我们才在预处理阶段直接扔掉 B。否则,哪怕 B 优惠少,它可能是“可叠加券”,必须保留。

3. 位图互斥剪枝

这是代码中 usedMask 的理论基础。

  • 场景: 优惠券通常有“互斥组”的概念(例如:店铺券同店铺互斥,ID=5)。

  • **传统做法:**传 在递归里传一个 SetusedGroups。每次判断 set.contains(5)。

  • 代价:Set 是对象,涉及哈希计算、自动装箱、GC 开销。在 100 万次递归中,这是致命的。

  • **架构师解法:**位运算(Bitwise Operation)。

  • 我们规定互斥组 ID 为 0~63。

  • 使用一个 long 类型的变量 mask(64位)。

  • 判断互斥:(mask & (1L << group_id)) != 0。

  • 标记使用:mask | (1L << group_id)。

图片

  • 效果: 将复杂的哈希查找变成了 一条 CPU 指令,且零内存分配。

三、 代码落地:零 GC 的回溯实现

为了防止 Young GC 导致的 STW,我们在代码层面必须做到 “零对象创建”

  • 拒绝 subList 使用 index 索引传参。

  • 拒绝 BigDecimal 全程 long 运算。

  • 拒绝 Iterator 使用原生数组或 ArrayList 的索引遍历。

public class ZeroGCCouponOptimizer {    // 全局最优支付金额 (单位:分)    privatelong minPayAmount = Long.MAX_VALUE;    // 记录开始时间,用于超时截断    privatelong startTime;    /**     * 对外入口     */    public long calculateBestPrice(List<Coupon> coupons, long originalTotalAmount) {        this.startTime = System.currentTimeMillis();        this.minPayAmount = originalTotalAmount; // 初始为原价        // 1. 预处理:支配剪枝 & 排序 (金额大优先,利于快速剪枝)        List<Coupon> optimizedCoupons = preprocess(coupons);        // 2. 开启回溯        backtrack(0, 0, optimizedCoupons, originalTotalAmount, 0L);        return minPayAmount;    }    /**     * 核心回溯:基于索引递归,不创建任何 List 对象     * @param index        当前决策到第几张券     * @param usedMask     位图:记录已使用的互斥组 ID (BitMap 优化)     * @param coupons      券列表     * @param currentPay   当前待支付金额     * @param discountSum  当前已优惠总额     */    private void backtrack(int index, long usedMask, List<Coupon> coupons,                            long currentPay, long discountSum) {        // 【兜底】超时截断 (Time Boxing)        if ((index % 100 == 0) && (System.currentTimeMillis() - startTime > 180)) {            return;         }        // 【剪枝 1】最优解剪枝 (Bound Pruning)        // 如果当前支付金额 已经 >= 已知最低价,后面不用算了        // 注意:这里假设券不会导致负金额增加(返现除外)        if (currentPay >= minPayAmount) {            return;        }        // Base Case: 所有券都决策完了        if (index >= coupons.size()) {            minPayAmount = currentPay; // 更新全局最优            return;        }        Coupon coupon = coupons.get(index);        // --- 分支 A:不使用这张券 ---        backtrack(index + 1, usedMask, coupons, currentPay, discountSum);        // --- 分支 B:使用这张券 ---        // 1. 门槛校验 (使用 long 比较)        if (currentPay < coupon.getThresholdLimit()) {            return;        }        // 2. 互斥校验 (BitMap O(1) 极速校验)        // 假设 coupon.getMutexGroupId() 返回 0-63 的整数        long groupBit = 1L << coupon.getMutexGroupId();        if ((usedMask & groupBit) != 0) {            return; // 该组已经有券被使用了        }        // 3. 进入下一层        // 更新 mask,扣减金额        backtrack(index + 1, usedMask | groupBit, coupons,                   currentPay - coupon.getAmount(), discountSum + coupon.getAmount());    }}

四、 并发死穴:如何避免“线程爆炸”?

之前的槽点修复: 很多候选人为了炫技,会说“把每个连通孤岛丢进 ForkJoinPool 并行算”。

场景还原: 大促 QPS 50,000。如果平均每个用户拆出 3 个孤岛,瞬间产生 15 万个任务争抢 CPU。

后果: CPU 上下文切换(Context Switch)开销远超计算本身,系统吞吐量断崖式下跌。

架构师解法:自适应并行

我们必须根据计算量级动态决定策略:

  1. 小任务(99% 的场景):
  • 如果孤岛内的券数量 < 10 张:主线程串行计算!

  • 理由: 纯 CPU 整数运算极快,10 张券的递归耗时可能只有 0.1ms,而启动一个线程可能需要 0.5ms。串行反而更快。

  1. 大任务(1% 的场景):
  • 如果孤岛内的券数量 > 10 张:丢入独立线程池并行计算

  • 理由: 计算耗时可能达到 50ms+,利用多核 CPU 缩短响应时间是值得的。

图片

核心代码逻辑:

// 自适应调度逻辑if (island.getCouponCount() > PARALLEL_THRESHOLD) {    // 大任务:异步并行    completableFutures.add(CompletableFuture.supplyAsync(() -> compute(island), heavyWorkPool));} else {    // 小任务:当前线程直接算 (Zero Context Switch)    results.add(compute(island));}

五、 规则引擎死穴:LiteFlow/Drools 怎么用?

**规则引擎(LiteFlow/Drools)**是解释执行或反射调用,性能开销大。如果在回溯的递归里(10 万次循环)调用规则引擎,必死无疑。

架构师解法:阶段分离

我们将计算分为两个阶段

  1. 预处理阶段(Rule Engine Phase):
  • 调用规则引擎。

  • 任务: 判断券的基本可用性(是否过期、类目是否匹配、门槛是否满足)。

  • 产出: 一个**“纯净的”“参数化”**的券列表。将复杂的业务规则(如“仅限 Plus 会员且购买了生鲜”)转化为简单的数字(threshold=20000mutexGroup=5)。

  1. 核心计算阶段(Backtracking Phase):
  • 严禁调用规则引擎。

  • 任务: 仅进行 long 类型的加减比较和位运算(BitMap)。

总结: 规则引擎只负责**“进门安检”,核心计算全是“硬核数学”**。


六、 面试标准回答模板(建议背诵)

下次被问到“购物车最优优惠计算”,请按这个逻辑进行降维打击。这套话术融合了算法辩证、图论降维、内存级优化高并发稳定性

面试官,这是一个典型的 NP-Hard 组合优化问题。在 200ms 的硬性限制下,我采用了‘图论降维 + 启发式搜索 + 零 GC 工程化’的方案:

1. 算法选型辩证:

我没有选择动态规划(DP),因为在多维互斥和复杂叠加规则下,DP 的状态维度会爆炸,无法维护。我选择了 回溯法配合强力剪枝,这是业务复杂度和性能的最佳平衡点。

2. 核心降维(连通分量):

我构建了 ‘券-商品’关系图,将 20 张券拆解为多个独立的 连通孤岛。将指数级复杂度  拆解为多个极小的 。

3. 极致剪枝策略:

  • 支配剪枝(Dominance): 在预处理阶段,严格淘汰同组中“门槛高且优惠少”的劣质券。

  • 位图剪枝(BitMap): 利用 long 类型的位运算,在 O(1) 时间内完成互斥组校验。

  • 分支定界: 一旦当前金额超过已知最优解,立即回溯。

4. S 级工程优化(亮点):

  • 零 GC 设计: 全程使用 long 代替 BigDecimal,使用索引遍历代替 subList,避免 Young GC 导致的 STW。

  • 自适应并行: 拒绝盲目多线程。仅对计算量超阈值的孤岛启用线程池,防止高 QPS 下的线程爆炸。

  • Time Boxing: 严格的超时截断机制,优先保证接口可用性。


写在最后

为什么这道题能拿 50W+ 的年薪?

因为它考的不是你会不会写代码,而是你由于懂底层(JVM/CPU),所以知道怎么写代码不崩;由于懂业务(互斥/叠加),所以知道怎么把数学模型落地。

能把“凑单”算准,帮用户省了钱,提升了转化率(GMV);能把“凑单”算快,帮公司省了服务器(成本)。

这就是资深技术专家的核心价值。

觉得这篇干货满满的,点个赞,转发给正在被“凑单”折磨的兄弟!

关注公众号【Fox爱分享】,这里没有八股文,只有被坑出来的血泪经验。

>文章首发地址