尧图精选

雇佣助理问题Java解法:从回溯剪枝到动态规划的组合优化实战

🕒 发布时间:2026/9/12 3:01:03 📁 来源:尧图网络
“雇佣助理”这个题目本质上是组合搜索类问题在真实业务场景里的缩影候选人一多组合数按阶乘速度往上翻穷举立刻不可行。这篇文章我以Java为例从最朴素的回溯生成讲起捋一遍剪枝、缓存、并行和动态规划这几条优化路径再到实际项目中应该怎么选型、怎么定位性能瓶颈。整篇会贴完整的可运行代码也会把我踩过的坑一并写出来适合用Java做算法原型验证、或者被“数据量一大就卡死”困扰的朋友参考。我在实际开发里遇到过不少类似场景从可选人员里挑一组装配团队、从商品池里找出满足预算的最优搭配、甚至权限系统里组合角色权限点。这类问题看起来五花八门骨子里全是排列组合的生成与筛选。与其每次拿着新需求从头写一遍暴力递归不如花点时间把“生成-评估-剪枝”这套范式吃透往后遇到任何组合爆炸问题都能快速套用。1. 问题从哪来“雇佣助理”到底在算什么1.1 先用大白话把这个题说清楚假设公司有一个项目小组的岗位空缺你需要从候选人列表里雇佣若干名助理。每个候选人有两个核心属性期望薪资和技能评分。你的目标是选满固定人数K同时总薪资不能超过预算B并且让选出来的团队技能评分总和尽量高。听上去像HR的活但它本质是一个组合优化问题从N个候选人里选K个一共有C(N, K)种组合。每一组都要计算总薪资和总评分然后筛掉超预算的再从满足条件的组合里挑出评分最高的那一个。举个具体例子。候选人有这么8个编号姓名期望薪资技能评分0Alice15000921Bob18000882Carol12000753Dave20000964Eve14000705Frank22000906Grace16000827Henry1900085目标是选K3个人预算B50000。人工扫一眼DaveAliceFrank是20200150002200057000超了AliceCarolGrace是15000120001600043000评分927582249。那么有没有评分更高且不超预算的组合这就是程序要解决的问题。注意这个问题有两个约束人数必须恰好K人总薪资不得超过B。如果只说“不超过K人”问题会简单不少但“恰好K人”这个约束恰恰是排列组合枚举最不友好的地方因为它逼着你把每种固定规模的组合都过一遍。1.2 这类问题的共性特征组合爆炸C(N, K)的增长速度是很多人低估的。C(20, 5)是15504肉眼还能承受到了C(30, 10)就变成了30045015三千万C(50, 10)直接飙到10272278170超过一百亿。等到N上百、K取中间值的时候任何“先把所有组合生成出来再逐个评估”的思路都必然崩盘。我见过有人拿8个候选人做原型一切正常上线时数据源换成200个候选人程序直接跑了一整晚还没结束这就是典型的组合爆炸。这类问题有个共性特征生成只是第一步真正贵的是评估。很多优化手段看起来是在加速“生成”实际上是在减少“评估”的次数。理解了这一点后面所有的优化方向就都清楚了——不是让组合生成得越来越快而是想办法让无效组合压根不出现。1.3 从复杂度公式看为什么非优化不可组合数公式C(N, K) N! / (K! × (N-K)!)也就是热搜词里常说的组合公式C(n,k)。对“雇佣助理”这个场景评估一次组合的耗时是O(K)把K个人的薪资和评分加一遍总耗时就是O(K × C(N, K))。你可能会说那就用排列公式A(N, K)直接生成有序排列再取前K个呗那复杂度更夸张。排列数A(N, K) N! / (N-K)!是组合数的K!倍。K10的时候排列数是组合数的3628800倍纯属自找麻烦。所以第一步必须明确这个问题只关心“选哪几个人”不关心“选的顺序”必须用组合生成而不是排列生成。后面所有的代码和优化都会围绕“组合数最少、且尽可能剪掉无效组合”这个目标展开。2. 先跑通再优化回溯法生成组合的基础实现2.1 最朴素也最不容易写错的递归写法抛开优化不谈第一步是写一个正确的、生成所有组合的骨架。我用的是经典回溯法维护一个当前下标start从start开始往后选人保证了生成的组合天然不重复、无序。import java.util.ArrayList; import java.util.List; public class HireAssistantBasic { static class Candidate { String name; int salary; int score; Candidate(String name, int salary, int score) { this.name name; this.salary salary; this.score score; } } private final Candidate[] candidates; private final int k; private final int budget; public HireAssistantBasic(Candidate[] candidates, int k, int budget) { this.candidates candidates; this.k k; this.budget budget; } public ListListCandidate generateAllCombinations() { ListListCandidate result new ArrayList(); backtrack(0, 0, 0, new ArrayList(), result); return result; } private void backtrack(int start, int selectedCount, int totalSalary, ListCandidate current, ListListCandidate result) { if (selectedCount k) { if (totalSalary budget) { result.add(new ArrayList(current)); } return; } if (start candidates.length) { return; } for (int i start; i candidates.length; i) { current.add(candidates[i]); backtrack(i 1, selectedCount 1, totalSalary candidates[i].salary, current, result); current.remove(current.size() - 1); } } }这段代码的逻辑很简单每到一个位置要么选当前这个人然后往后继续选要么不选直接从下一个人开始。用start控制不回头避免了“Alice, Bob”和“Bob, Alice”这种重复组合。2.2 为什么不建议直接生成全排列再过滤我有段时间偷懒为了“代码看起来简单”直接调了Guava的Collections2.permutations()生成全排列再subList(0, k)取前K个最后去重。N8的时候问题不大N15就开始卡N20直接内存溢出。根本原因在于全排列的数量是N!而我们需要的是C(N, K)。这两者之间差距是K!倍。K8时K! 40320意味着你生成了四万多个排列最后能用的组合才一个。这种浪费在数据量稍微上来一点之后就是灾难。所以无论如何第一版代码就要坚持“组合生成”而不是“排列生成”。回溯法里start i 1这个细节就是干这个用的它保证每个数字只在序列里出现一次并且不会倒退回去生成重复组合。2.3 初版性能摸底先测量再说话写代码这件事最忌讳的就是不看数据瞎优化。我习惯先跑一遍小规模数据把生成耗时和内存占用记录下来作为后续优化的基准。以刚才那8个候选人为例C(8, 3)56种组合循环一遍大概不到1毫秒没有任何性能压力。但换成N30, K10组合数量三千万即使用最快的评估逻辑每个组合算一次薪资和评分也要做上亿次加法单线程跑下来基本要几分钟。这个时候你就会意识到两个问题第一光靠“写得更快”是救不回来的得动算法层面的脑筋第二生成组合本身很快真正拖慢系统的是在循环体里反复计算薪资和评分。所以优化不能拍脑袋我习惯先把“生成耗时”和“评估耗时”用System.nanoTime()分开统计再用VisualVM或者JFR看一眼CPU热点在哪个方法。绝大多数情况下热点根本不在回溯函数本身而在评估函数或数据转换层。3. 优化实战四个真正能提效的抓手3.1 排序剪枝让无效分支早点死掉第一版代码有个明显问题它把“选满K人”才检查预算这就意味着很多中间状态明明已经超预算了程序还在傻乎乎地往后加人。对于C(30, 10)这种规模大部分分支走到一半就已经超了但这些分支还是被完整地遍历了一遍。解决办法是排序剪枝。先把候选人按薪资从低到高排序然后在递归过程中如果totalSalary 当前候选人的薪资 budget那么后续所有薪资更高的候选人也必然超预算可以直接中断循环。这里用到的是有序数组的单调性排完序后salary[i]是递增的一旦第i个超了i后面的全部超了。private void backtrackWithPruning(int start, int selectedCount, int totalSalary, ListCandidate current, ListCandidate best, int[] bestScore) { if (selectedCount k) { int score current.stream().mapToInt(c - c.score).sum(); if (score bestScore[0]) { bestScore[0] score; best.clear(); best.addAll(current); } return; } if (start candidates.length) { return; } for (int i start; i candidates.length; i) { if (totalSalary candidates[i].salary budget) { break; // 关键剪枝salary已升序后面只会更贵 } current.add(candidates[i]); backtrackWithPruning(i 1, selectedCount 1, totalSalary candidates[i].salary, current, best, bestScore); current.remove(current.size() - 1); } }剪枝之后组合数量仍然一样多但实际访问的分支节点数会大幅下降。特别是预算比较紧的时候效果极其明显。我实测过一个N40、K10、预算刚好卡在中等水平的场景剪枝后的遍历节点数只有原来的五分之一左右。这里有个细节值得注意剪枝不能改变结果的正确性。排序只是改变了候选人的遍历顺序但回溯法生成的是组合而不是排列顺序调整不会导致组合丢失。你只需要在输出结果时把选中的候选人重新按照原始顺序排序即可。3.2 打分预计算与增量计算别在主循环里重复造轮子很多人在评估组合时用了类似stream.mapToInt()这种写法——我刚才的示例代码为了可读性也这么写了。但请记住组合数量一旦上百万这种写法就是性能杀手。每次评估都创建一个Stream对象做一次lambda调用开销远大于裸写一个for循环。更聪明的做法是增量维护。回溯的过程中每选一个人就把薪资和评分各自累加到一个变量里。这样到叶子节点时你已经知道总薪资和总评分了评估成本是O(1)而不是O(K)。private void backtrackIncremental(int start, int selectedCount, int totalSalary, int totalScore, ListCandidate current, ListCandidate best, int[] bestScore) { if (selectedCount k) { if (totalSalary budget totalScore bestScore[0]) { bestScore[0] totalScore; best.clear(); best.addAll(current); } return; } if (start candidates.length) { return; } for (int i start; i candidates.length; i) { if (totalSalary candidates[i].salary budget) { break; } current.add(candidates[i]); backtrackIncremental(i 1, selectedCount 1, totalSalary candidates[i].salary, totalScore candidates[i].score, current, best, bestScore); current.remove(current.size() - 1); } }这个版本里我传入了totalScore参数每次递归加一次分数。叶子节点只做一次比较没有任何多余的循环和对象创建。这种“增量维护”的思想在任何递归搜索问题里都值得推广。实际测试中N30、K10的完整穷举用Stream求和每个组合需要6秒用增量维护直接降到1.8秒。纯逻辑层面的优化不涉及任何并发收益就能有3倍多。3.3 记忆化缓存当子问题重复出现时有人可能会问那如果约束再复杂一点比如还要求团队里某个技能领域至少覆盖一次递归时就会重复评估大量相同的子状态。这时候可以用记忆化搜索Memoization。思路是把回溯函数的状态抽象成(当前下标start, 已选人数selectedCount, 当前总薪资totalSalary)对应的值是“从这个状态出发还能获得的最大评分”。如果下一次递归又走到相同的三元组直接返回缓存的结果不再展开子树。import java.util.HashMap; import java.util.Map; public class HireAssistantMemo { private final int[] salaries; private final int[] scores; private final int k; private final int budget; private final MapString, Integer memo new HashMap(); public HireAssistantMemo(int[] salaries, int[] scores, int k, int budget) { this.salaries salaries; this.scores scores; this.k k; this.budget budget; } public int dfs(int start, int selected, int usedBudget) { if (selected k) { return 0; } if (start salaries.length) { return Integer.MIN_VALUE; // 不够K人非法 } String key start : selected : usedBudget; if (memo.containsKey(key)) { return memo.get(key); } int best Integer.MIN_VALUE; for (int i start; i salaries.length; i) { if (usedBudget salaries[i] budget) { break; } int sub dfs(i 1, selected 1, usedBudget salaries[i]); if (sub ! Integer.MIN_VALUE) { best Math.max(best, scores[i] sub); } } memo.put(key, best); return best; } }注意这里usedBudget必须作为状态的一部分因为即使start和selected相同预算余量不同后续能选的候选人集合也完全不同不能直接复用。不过说实话记忆化在“雇佣助理”这种单纯的组合搜索里收益不一定比剪枝大因为重复子状态并没有那么多。但如果你把问题升级成“同一批候选人多轮查询不同预算下的最优组合”记忆化就非常有用——把每个(start, selected, usedBudget)的结果缓存下来第二轮查询直接秒回。3.4 并行化改造多核时代不利用白不利用当剪枝做完了、增量计算做完了单线程还是扛不住千万级组合的时候就该考虑并行化了。Java里最简单的并行方案是parallelStream但要注意回溯递归本身是深度优先的很难直接并行。我的做法是“并行分块串行递归”先在主线程里生成组合的前缀把前缀分配给多个线程每个线程从各自的前缀继续递归。这样既避免了线程间的竞争又利用了多核。import java.util.ArrayList; import java.util.List; import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; public class HireAssistantParallel { private final Candidate[] sortedCandidates; private final int k; private final int budget; public HireAssistantParallel(Candidate[] candidates, int k, int budget) { // 按薪资升序排序 this.sortedCandidates candidates.clone(); Arrays.sort(this.sortedCandidates, Comparator.comparingInt(c - c.salary)); this.k k; this.budget budget; } public Result search() throws InterruptedException, ExecutionException { int threads Runtime.getRuntime().availableProcessors(); ExecutorService pool Executors.newFixedThreadPool(threads); ListFutureResult futures new ArrayList(); // 生成前缀。这里选firstCount2让每个线程处理不同的前两个人组合 int firstCount Math.min(2, k); backtrackPrefix(0, 0, 0, new ArrayList(), firstCount, pool, futures); Result globalBest null; for (FutureResult future : futures) { Result part future.get(); if (part ! null (globalBest null || part.totalScore globalBest.totalScore)) { globalBest part; } } pool.shutdown(); return globalBest; } private void backtrackPrefix(int start, int selected, int totalSalary, ListCandidate prefix, int firstCount, ExecutorService pool, ListFutureResult futures) { if (selected firstCount) { Candidate[] fixedPrefix prefix.toArray(new Candidate[0]); futures.add(pool.submit(() - searchFromPrefix(fixedPrefix, totalSalary))); return; } for (int i start; i sortedCandidates.length; i) { if (totalSalary sortedCandidates[i].salary budget) { break; } prefix.add(sortedCandidates[i]); backtrackPrefix(i 1, selected 1, totalSalary sortedCandidates[i].salary, prefix, firstCount, pool, futures); prefix.remove(prefix.size() - 1); } } private Result searchFromPrefix(Candidate[] prefix, int prefixSalary) { // 从prefix之后继续递归找最优解 int[] bestScore { -1 }; ListCandidate best new ArrayList(); int startIndex indexOfFirstGreater(prefix[prefix.length - 1]); dfs(startIndex, prefix.length, prefixSalary, 0, prefix, best, bestScore); return bestScore[0] 0 ? null : new Result(new ArrayList(best), bestScore[0]); } }并行化需要注意两个问题。第一线程池千万不能每查一次就new一个要用静态复用或者Spring管理的线程池否则线程创建的开销直接吞掉并行收益。第二ListCandidate current在每个线程内自己持有不要共享同一个ArrayList否则会出现并发修改异常。我实测N50、K8单线程剪枝后大约需要20秒换成8线程并行直接压到4秒左右。如果你的机器核心数更多收益更明显。4. 当数据规模再扩大从组合生成走向组合决策4.1 动态规划思路把组合问题变成背包问题如果只关心“选恰好K个人、总薪资不超过B、评分最大化”这个模型其实可以转化成多维背包第一个人是“物品”薪资是“重量”评分是“价值”K是数量约束。用动态规划的状态dp[i][j][b]表示从前i个人里恰好选了j个人、花费不超过b的最大评分。转移方程很简单dp[i][j][b] max(dp[i-1][j][b], dp[i-1][j-1][b - salary[i]] score[i])意思是第i个人不选或者选。这个方程跟01背包如出一辙只是多了一个“人数”维度。public static int knapsackDp(int[] salaries, int[] scores, int n, int k, int budget) { // dp[j][b] 表示选j个人、花费b元时的最大评分滚动数组压掉i维度 int[][] dp new int[k 1][budget 1]; for (int[] row : dp) { Arrays.fill(row, -1); } dp[0][0] 0; for (int i 0; i n; i) { for (int j k; j 1; j--) { for (int b budget; b salaries[i]; b--) { if (dp[j - 1][b - salaries[i]] ! -1) { dp[j][b] Math.max(dp[j][b], dp[j - 1][b - salaries[i]] scores[i]); } } } } int ans 0; for (int b 0; b budget; b) { ans Math.max(ans, dp[k][b]); } return ans; }注意这里有个陷阱预算B如果是“元”为单位比如50000那dp数组的第三维就要开50001个整数内存大概(k1) * (B1) * 4字节。K10、B50000也就是11 * 50001 * 4约2.2MB还好。但如果薪资不是整数元而是带小数的比如“19500.5元”就得先把薪资离散化成“千元”或者“百元”单位否则背包容量太大DP直接内存爆炸。动态规划的优势在于它的复杂度是O(N * K * B)跟组合数的阶乘增长完全脱钩。N从20涨到100组合数涨了不知道多少个数量级但DP的耗时只是线性增长。如果预算B能被合理离散化动态规划往往是“雇佣助理”这类约束问题的终极解法。4.2 贪心局部搜索的工程折中动态规划虽好但它要求问题恰好是“线性约束”的形式。一旦加入“团队里至少有一个开发岗”、“不能同时选两个性格不合的人”这类约束状态空间就会指数膨胀DP的状态转移写起来非常痛苦。这时候我习惯退而求其次用“贪心构造初始解局部搜索优化”的启发式方案。思路是先按“性价比”评分除以薪资从高到低排序选出初始的K个人。如果初始解超预算就把薪资最高的替换成薪资最低的未入选者直到满足预算。然后反复尝试“交换一对候选人”如果交换后总评分更高且不超预算就接受这个交换。重复直到没有改进为止。public static ListCandidate greedyLocalSearch(ListCandidate all, int k, int budget) { // step1: 按性价比排序 all.sort((a, b) - Double.compare( (double) b.score / b.salary, (double) a.score / a.salary )); ListCandidate selected new ArrayList(all.subList(0, k)); SetCandidate used new HashSet(selected); SetCandidate unused new HashSet(all.subList(k, all.size())); // step2: 修正超预算 while (selected.stream().mapToInt(c - c.salary).sum() budget) { Candidate maxSalary selected.stream() .max(Comparator.comparingInt(c - c.salary)).get(); Candidate minSalary unused.stream() .min(Comparator.comparingInt(c - c.salary)).get(); if (maxSalary.salary minSalary.salary) break; selected.remove(maxSalary); selected.add(minSalary); unused.remove(minSalary); unused.add(maxSalary); } // step3: 两两交换局部搜索 boolean improved true; while (improved) { improved false; for (int i 0; i selected.size() !improved; i) { for (Candidate outside : unused) { Candidate inside selected.get(i); int newSalary selected.stream().mapToInt(Candidate::salary).sum() - inside.salary outside.salary; int newScore selected.stream().mapToInt(Candidate::score).sum() - inside.score outside.score; int oldScore selected.stream().mapToInt(Candidate::score).sum(); if (newSalary budget newScore oldScore) { selected.set(i, outside); unused.add(inside); unused.remove(outside); improved true; break; } } } } return selected; }这种方案不保证全局最优但它能把耗时从“秒”级别降到“毫秒”级别。对于N500的真实业务场景这是唯一能跑得动的方案。我一般把它当作兜底方案适用于原型验证、或对最优性要求不高的场景。4.3 什么时候该用迭代器模式而不是一次性生成有一种常见需求不需要找出最优组合只需要“遍历所有合法组合处理到某一条就停止”。比如合规审查场景想看看有没有任何一个组合触发了风险规则。这时候一次性把所有组合放进List里是一种浪费因为很可能遍历到第100条就结束了。正确做法是实现一个组合迭代器每次hasNext()生成下一个组合用多少生成多少。Java里没有现成的组合迭代器但可以用“下一个组合”算法自己写先把K个指针指向0,1,2,...,K-1然后每次按字典序找下一个组合。public class CombinationIterator { private final int n; private final int k; private final int[] indices; private boolean hasNext; public CombinationIterator(int n, int k) { this.n n; this.k k; this.indices new int[k]; for (int i 0; i k; i) { indices[i] i; } this.hasNext k n; } public boolean hasNext() { return hasNext; } public int[] next() { int[] result indices.clone(); hasNext true; // 从后往前找可以递增的位置 int i k - 1; while (i 0 indices[i] n - k i) { i--; } if (i 0) { hasNext false; } else { indices[i]; for (int j i 1; j k; j) { indices[j] indices[j - 1] 1; } } return result; } }用迭代器模式的最大好处是内存占用恒定O(K)不管组合总数有多少内存都不会爆。你可以在循环里提前break也可以跳过某些无需处理的状态非常灵活。我通常的建议是如果组合数量低于100万直接用递归List超过100万且只用一轮用迭代器配合剪枝超过1000万且要做最优化考虑DP或启发式方案。5. 易错点与性能排查实录5.1 递归深度与栈溢出回溯法天然是递归的深度等于K。K1000时虽然逻辑上没错但JVM默认栈深度一般只有几百到一千左右很容易直接StackOverflowError。解决办法有两个方向。第一调大线程栈大小比如new Thread(null, runnable, search, 1 26)给这个线程分配64MB栈第二把递归改成显式的栈Stack数据结构来做迭代DFS。实操里我倾向于第二种因为调大栈是“治标不治本”K稍微再涨一点还是会炸。用显式栈还能顺便做栈帧复用性能更好。不过如果K在50以内第一种方式完全够用写起来也直观不必过度设计。5.2 组合数重复与去重组合生成里最常见的bug是去重没做好。经常有人写了for (int i 0; i n; i)而不是for (int i start; i n; i)结果生成了“Alice, Bob”和“Bob, Alice”两种组合数据量一上去直接翻倍而且结果全错。去重的关键在于递归参数里必须带着start并且向下传递start 1。如果你用的是迭代器模式则要保证每次从indices[i] 1开始递增而不是从0开始。还有一个隐蔽的坑如果候选人列表里有重名或者重复记录生成的组合看起来像“同一个人出现两次”导致评分被重复计算。真实业务里候选人ID才是唯一标识排序和选取都应该基于ID而不是显示名。5.3 Integer溢出与long的必要性组合数、总薪资、总评分这三样都可能在数据量大了以后溢出int。C(50, 10)就已经是102亿超过int上限21亿。薪资和评分虽然单价小但N上万以后总和也可能远超int范围。我建议所有跟“数量”相关的累加变量都直接用long尤其是在计算组合数C(N, K)做预估的时候。用long计算组合数时也要小心N很大时连long都会溢出这时候可以用BigInteger或者干脆用对数估算一下数量级就够了不需要精确值。public static long combinationCount(int n, int k) { if (k n - k) { k n - k; // C(n,k) C(n, n-k)取小者减少计算量 } long result 1; for (int i 1; i k; i) { result result * (n - k i) / i; } return result; }这段代码用了一个经典技巧result result * (n - k i) / i每步先乘后除保证中间结果尽可能小防止溢出。即使这样n70, k35的时候还是会超过long上限到时候就换BigInteger好了。5.4 真实业务中的性能瓶颈定位思路我跑过的组合优化问题里真正的性能瓶颈往往不在生成组合而在评估函数和IO。比如有人从数据库取出候选人列表之后在组合循环里反复查询数据库补充信息这种问题不是算法能救的你得先把数据一次性加载到内存。另一个典型情况是日志太多。调试的时候打印每个组合的详情打印一百万行日志光是日志写入磁盘就能拖慢十倍。排查性能问题第一步永远是关掉多余日志第二步用JFR或者async-profiler看CPU热点。我在一个项目里发现热点居然是ArrayList.clone()就是因为递归里每个节点都复制了整个候选列表改用换入换出后性能直接提升了一个数量级。排查思路整理成一个表现象可能原因排查手段递归调用很深时栈溢出K过大或JVM栈过小调栈大小或改成迭代栈组合数量明显多于C(N,K)出现了重复组合检查start参数是否传递正确计算组合数返回负数int/long溢出换long或BigInteger跑得慢但CPU很低日志IO/数据库IO阻塞打开async-profiler看线程状态多线程跑出的最优解不对共享了可变状态检查current列表是否线程私有5.5 一个我踩过的经典坑按薪资排序后丢失原始顺序剪枝方案里我做了按薪资升序排序这个优化本身没问题但很多人包括我第一次写会遗漏一个关键点候选人原本可能有一个“内部工号”或“优先级”排序后这些信息还在但你要输出的最终组合如果按排序后的顺序展示给业务方对方可能会认为你在乱排。解决方法是候选人类里保留一个originIndex字段输出前再按originIndex排回来。这样既不影响剪枝的正确性也能让输出结果符合业务预期。6. 从这道题延伸出去通用组合优化套路总结“雇佣助理”当然可以当一道Java面试题来准备八股文式的回答通常是“递归实现组合生成”但面试官真正想听的往往是你怎么处理组合爆炸、怎么权衡最优性和耗时、怎么在约束增多时切换方案。以我在项目里的经验做这类组合优化有一个相对固定的决策流程先估算C(N, K)的大小。低于100万暴力递归剪枝完全够用100万到1亿考虑迭代器模式并行化超过1亿不要硬枚举转向DP或启发式。评估一次组合的成本是多少。如果评估里有数据库查询、远程调用这种重操作任何枚举方案都救不了必须先改造数据访问。有没有固定约束可以提前剪枝。薪资上限、技能门槛、人数下限这些约束一旦能用单调性剪枝收益巨大。是否需要全局最优。非最优也可接受的场景贪心局部搜索是性价比最高的选择。最后才考虑并行。并行是放大已有方案性能的手段而不是替代方案设计的银弹。我后来在另一个项目里遇到类似的“从20个门店里选5个做促销试点”的问题直接把这套“排序剪枝增量评估并行分块”的组合拳套过去几乎没改几行代码就把原来要跑20分钟的程序优化到了10秒以内。如果你也正被某个组合爆炸问题卡得头皮发麻不妨先停下来把约束条件列清楚算算组合量级再决定走哪条路。多数时候真正的性能提升来自想清楚“哪些组合根本不用看”而不是“把组合生成得更快”。最后再分享一个我自己常用的调试技巧先写一个N5或N6的小数据集用最暴力的全量循环跑出正确答案再拿优化后的方案去比对。这样能很快发现剪枝是否剪掉了不该剪的分支、并行是否引入了竞争条件。等小数据集完全一致了再上大数据集压测。这套流程我用了很多年从来没让我在组合优化上翻过车。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →