
写这篇东西的起因是我最近在评审一批新人的算法作业时发现几乎所有人都会把“贪心算法”和“动态规划”搅在一起。题目明明是个动态规划非要用贪心去解样例过了提交就挂反过来一道经典的贪心题又有人掏出二维DP数组把复杂度写成O(n²)自己还觉得挺稳。这篇文章不是教科书式的原理复述而是想把我自己从“会用”到“理解分界”这一路上踩过的坑、总结出来的判断标准、以及真正管用的证明思路一次讲清楚。无论你是准备面试、刷题、打竞赛还是在项目里自己做资源调度和路径规划这篇文章应该都能帮你少走几个月的弯路。1. 先搞清楚这两兄弟到底在争什么1.1 贪心每一步都选当下最爽的那个贪心算法的思想一句话就能说完在每一步决策时选择当前看起来最优的方案并且一旦选了就不回头。它不需要记录历史状态不需要回溯也不需要考虑“如果选另一个会不会更好”。整个算法的正确性建立在一条非常强的假设上局部最优的累积恰好就是全局最优。这个假设听起来很美但现实很骨感。大部分问题都不满足这条性质。我在实际项目中见过太多“贪心翻车”的案例比如带宽分配时每次都把资源给当前需求最大的任务结果导致后续高优任务无资源可用再比如缓存淘汰时每次只踢最老的数据某些场景下反而会大幅降低命中率。贪心的优点和缺点都来自同一个地方——它把问题简化成了一条单行道走上去就不能回头。所以贪心算法真正的难点从来不是“选当下的最优”而是怎么知道这个问题值得用贪心。很多刚入门的朋友以为贪心就是“想一个策略然后碰运气”其实不是。能用贪心解决的问题背后一定有一条可以数学证明的逻辑链只是大多数教程没把这个“证明环节”讲透。1.2 动态规划把能走的路全走一遍然后把账算明白动态规划的思路和贪心正好相反。它不赌某一条路一定最好而是把所有可能的决策都枚举出来用状态记录中间结果最后从这些结果里挑一个最优解。它要求问题具备两个特征一是最优子结构即整个问题的最优解可以拆成子问题的最优解二是重叠子问题即不同决策路径会走到相同的子问题这样才有缓存的价值。很多人第一次接触动态规划是从斐波那契数列开始的。求F(n)需要F(n-1)和F(n-2)这两个子问题又共享了大量更小的子问题。如果用递归硬算复杂度是O(2ⁿ)但如果用一个数组把算过的F(i)存下来复杂度直接降到O(n)。这个“存下来”的动作就是动态规划最原始、也最核心的形态。但动态规划不止是“记忆化递归”。它的另一个关键点是状态设计。同一个问题状态定义得好不好直接决定能不能写出转移方程、复杂度能不能承受。我曾见过有人用三维状态去解明明二维状态就够的题也见过有人把状态定义成“当前累计收益”结果丢失了必要信息导致转移永远错误。状态设计这事本质上是在回答一个问题为了做后续决策我至少需要记住哪些信息1.3 为什么“看起来一样”却总被混淆说到底贪心和动态规划都要用到“最优子结构”这一性质。贪心决策之后剩余问题也是一个规模更小的子问题而且要能递推下去也必须满足最优子结构。这就让人产生了一种错觉贪心是动态规划的一种特殊情况或者二者可以互相替代。这个错觉的迷惑性很大。确实如果一个动态规划在每一步转移时某个分支永远比其他分支差那么这一步的最优选择就被“唯一确定”了动态规划的“全盘枚举”就退化成了一条固定路径——这个时候贪心就站出来了。但这种情况极其少见。多数问题里每一步的最优选择都依赖前面的选择前面的选择不同后面“哪个更好”的答案也不同这时候你没办法提前锁定一条路径只能老老实实把整棵决策树铺开。这就是动态规划。用一个生活化的类比来说贪心像一个“每次都从地铁站最近的出口出站”的人前提是你已经知道这个出口就是通往目的地的正确方向动态规划则像在手机地图上后台算完全部路线再比较耗时、距离和拥堵后告诉你最佳路径。前者快但要求你的局部方向判断永远正确后者慢一点但数据再复杂它也能给你全局最优。2. 用三个经典案例触摸分界线2.1 区间调度贪心赢得干脆利落先说一个我能闭着眼睛证明、也是我在教新人时最喜欢用的例子——区间调度问题。给定一系列活动每个活动有开始时间和结束时间同一时间只能做一个活动问最多能安排多少个完整活动。我第一次见到这题时本能地按“开始时间越早越优先”排序跑出来结果错得离谱。比如区间[1,10]、[2,3]、[4,5]按开始时间先选[1,10]后面一个都选不了实际最优解却是[2,3]和[4,5]两个。后来又试过按“持续时间越短越优先”依然能举出反例。最后才明白正确的贪心策略是按结束时间从早到晚排序每次选结束时间最早且与已选区间不冲突的活动。为什么这个策略对其他策略错因为结束得越早给后续活动留下的可用时间越长。这个结论不是玄学可以严格证明假设最优解第一个选的活动不是结束时间最早的那个那么把它替换成结束时间最早的活动不会减少剩余可用时间也不会影响后续活动数量。所以贪心解至少和最优解一样好。这就是所谓的交换论证法后面我会专门讲。def interval_scheduling(intervals): intervals.sort(keylambda x: x[1]) ans [] last_end -float(inf) for start, end in intervals: if start last_end: ans.append((start, end)) last_end end return ans这个例子的价值在于告诉我们当问题满足“选择只影响后续可用资源不影响后续每个选择的收益函数”时贪心就有戏。排序标准也不是拍脑袋而是围绕“怎样选对后续最有利”来设计的。2.2 硬币找零换一套币值贪心就翻车硬币找零是另一个经典问题给定若干面额的硬币和一个目标金额求最少用多少枚硬币凑出目标金额。美分体系里1、5、10、25贪心策略“每次尽量用大面额”是对的这也让很多人误以为这个策略普适。但只要把币值换成1、5、11目标金额凑15贪心就会翻车按“先用大面额”选11剩下4只能靠四个1一共5枚而正确答案是555只要3枚。我当年第一次看到这个反例时脑子嗡了一下——原来熟悉场景“包装”出来的贪心直觉换个参数就不成立了。问题出在哪面额为11的硬币虽然单次省下的硬币数最多但它会把剩余金额推到一个只能用低效小硬币填补的位置。这里的局部收益和全局收益之间没有稳定的单调关系贪心自然就失效了。这时候只能用动态规划设dp[x]表示凑出金额x需要的最少硬币数那么dp[x] min(dp[x - coin] 1)对每个coin尝试一次。目标金额是15状态只有15个复杂度极低而且自动覆盖了“中间态可能互相影响”的情况。def coin_change(coins, amount): dp [float(inf)] * (amount 1) dp[0] 0 for x in range(1, amount 1): for coin in coins: if x coin: dp[x] min(dp[x], dp[x - coin] 1) return dp[amount] if dp[amount] ! float(inf) else -1这个例子是理解“贪心和DP分界”的绝佳标本同一个问题改成不同参数解法类别直接改变。也就是说贪心不取决于问题长得像不像而取决于参数结构是否具备那种“局部最优可传播”的性质。2.3 01背包贪心错得明明白白DP才靠谱01背包问题可能是动态规划最出圈的题型了。有N个物品每个物品有重量w和价值v背包容量为W问能带走的最大总价值。很多人的第一反应是按单位价值从高到低装也就是贪心。我在真实业务里做资源分配方案时也犯过同样的错误——把“资源消耗少、产出高”的项先安排结果后面遇到了“单体产出巨大但消耗也大”的项时容量已经被占用。举一个账面反例背包容量50三个物品分别是重量10价值60、重量20价值100、重量30价值120。按单位价值排序第一个6、第二个5、第三个4贪心先装前两个总价值160剩余容量20装不下第三个。但最优解是装第二个和第三个总价值220。问题在于物品的价值不是可分的你没法只装“一半第三个”于是单位价值的排序失去了连续性。解决01背包的正确姿势是动态规划。设dp[i][j]表示前i个物品中容量恰好为j时能获得的最大价值转移时要么不选第i件dp[i-1][j]要么选它dp[i-1][j-w_i] v_i。复杂度O(NW)W比较大时需要用一维滚动数组优化空间。def knapsack_01(weights, values, capacity): n len(weights) dp [0] * (capacity 1) for i in range(n): for c in range(capacity, weights[i] - 1, -1): dp[c] max(dp[c], dp[c - weights[i]] values[i]) return dp[capacity]这里有个实操细节必须提醒内层循环要从大到小遍历容量。因为01背包每个物品只能选一次如果从前向后遍历dp[c - w_i]可能已经在当前物品被更新过等于变相允许了重复选取那就退化成了完全背包。我见过很多人在这一步踩坑面试时被当场指出非常尴尬。资源分配问题也是背包问题的变体。比如你要把一个总预算B分配给N个不同的项目每个项目投入x元能获得f_i(x)的产出。这类问题的动态规划转移是dp[i][b] max(dp[i-1][b-x] f_i(x))枚举分配给当前项目的资源x。它和01背包唯一的区别在于每个项目的投入不是一个固定档位而是一系列连续可选的档位。理解了这个从背包到资源分配的推广很多业务优化问题就都能套进同一个框架里。3. 理论分界的核心判据什么时候赌贪心什么时候老老实实DP3.1 最优子结构两兄弟都要过的关卡要判断一道题该用贪心还是动态规划第一个要确认的是问题是否具备最优子结构。所谓最优子结构就是整个问题的最优解一定可以由子问题的最优解组合而成。如果连这一点都不满足那贪心和动态规划都白搭得考虑搜索或者启发式算法了。最优子结构听起来抽象但它有个非常直白的检验方法假设你已经通过某种方式得到了全局最优解那么从最优解中抽掉“最后一次决策涉及的那一部分”剩下来的部分是不是刚好也是对应子问题的最优解如果是就说明这个问题的结构是递推友好的如果不是说明子问题之间存在纠缠或者当前状态定义漏掉了关键信息。举一个反例感受一下在带负权边的图里求最长简单路径全局最长路径的子路径未必是子图的最长路径因为子路径如果不能走回头路会受制于“访问过的节点集合”。这就是典型的不具备最优子结构动态规划不能直接套。碰到这种情况哪怕状态设计得再花哨也可能因为漏掉“已访问节点集合”这一维度而错误。3.2 贪心选择性质分界线的标志确认了最优子结构之后才轮到“贪心还是DP”的判断。判断标准只有一个核心问题你能不能在“只做出一次局部最优选择”之后仍然保证全局最优这个性质在算法理论里叫贪心选择性质。怎么验证你可以尝试构造一个“最优解的安全替换”假设你已经知道某个全局最优解然后检查贪心算法第一步会选的那个元素是否可以被塞进这个最优解同时不破坏它的可行性、不降低它的质量。如果能说明贪心第一步的选择“无害”。再对剩余子问题重复同样的论证贪心的正确性就由数学归纳法保底了。区间调度问题能这么证哈夫曼编码也能这么证它们都是经典的贪心问题。反过来如果尝试了半天找不到这种替换甚至能轻松举出“贪心选择堵死更优解”的反例那就别再纠结贪心了直接转动态规划。硬币找零和01背包都属于这一档。我自己的经验是反例越容易构造越说明这个问题本质上是DP。反例本质上是告诉你局部收益和全局收益之间存在非线性的冲突关系这种关系只能靠枚举全部状态来消除。3.3 复杂度与实现层面的现实考量理论上判定完算法类别实际操作中还卡着一道坎动态规划的状态数和转移代价到底扛不扛得住。贪心的复杂度通常是O(n log n)甚至O(n)而动态规划动不动就是O(NW)、O(N²)、O(N³)。有时候状态N是10万W也是10万直接开二维数组内存就爆了。这种时候有几种常见的降维手段。一是滚动数组如果转移只依赖上一层的状态可以用一维数组交替覆盖把O(NW)的空间降成O(W)。01背包的一维写法就是用它。二是记忆化搜索如果状态虽然很多但实际到达的很少用递归加缓存可能比自底向上填表更快。三是状态压缩如果某一个维度只有0/1或很小的取值空间可以把它并成一个整数位掩码这类技巧在“状态压缩DP”里非常常见。我自己在项目里做资源分配时还经常遇到状态空间“理论上爆炸实际剪枝后很小”的情况。这时候用自顶向下的记忆化搜索往往比填表更容易实现、也更灵活。但要注意递归深度问题Python里默认递归深度只有1000左右状态多的时候要用底层的循环迭代或者主动调高递归限制。4. 从直觉到理论把“我觉得贪心对”变成“我能证明贪心对”4.1 交换论证最常用的证明武器我在带新人时总说一句话“你猜这题是贪心我可以接受但你得能说服我。”而说服别人最常用的武器是交换论证。它的核心思想是任意给一个最优解通过若干次“相邻交换”可以把它一步步改造成贪心解而且每一次交换都不让结果变差。既然最优解经过调整后能变成贪心解贪心解自然也就不比最优解差。以区间调度为例设最优解的第一个区间是A贪心选的第一个区间是B且B的结束时间不晚于A。把A从最优解里删掉换成B因为B结束得早所以最优解里原本排在A后面的区间依然能和B兼容。一次交换结果数量没变甚至可能因为B结束更早而腾出更多空间。接下来重复这个过程把最优解逐步变成“完全按结束时间排序选出的解”。这个论证过程比背结论有用得多——它让你知道贪心的正确性来自哪种结构。写证明的时候我建议按这个顺序来假设存在一个最优解S。考察贪心解的第一次选择和S中对应的第一个位置。说明把S中的那个选择替换成贪心选择后S依然可行且质量不降。对剩余子问题递归地重复上述步骤最终得到贪心解。由贪心解与最优解质量相等得出贪心正确。这个套路覆盖面极广。判断一个策略能不能用贪心先尝试按这个流程走一遍往往走到第3步就能看出端倪。4.2 反证法和数学归纳法两种辅助套路交换论证不是唯一的路。有些问题更适合反证法先假设贪心解不是最优解那么最优解一定在某一步和贪心解产生了分歧然后从这个分歧点出发推导出矛盾。比如证明“每次取当前最大收益任务”的贪心策略时如果最优解在某个时刻没有选当前收益最大的任务那你就有机会把最优解后面的任务挪到前面反而获得更高收益这就和“最优”矛盾了。数学归纳法则是证明贪心正确性的骨架性工具。每一步贪心选择后问题规模变小且剩余问题的结构与原问题相同。你只需要证明“第一步贪心选择不会错”和“子问题的最优解加上这一步贪心选择等于原问题的最优解”这两件事归纳假设就能把正确性推广到任意规模。写证明时千万不要省略“子问题封闭性”的说明也就是“选了这一步后剩下的问题确实还是同一个类型的问题”。如果剩余问题变了形贪心策略就没有递推的基础了。4.3 对拍实验用暴力程序验证贪心直觉理论证明有难度尤其是初学者很难一次性写出严密论证。我的实践习惯是在正式采用贪心策略前先写一个绝对正确的暴力程序再写一个贪心程序用随机小数据反复对拍。如果几十万组随机测试都通过我对这个贪心的信心就会大幅提升。对拍的代码结构很简单。暴力解法用DFS/DP把所有可能搜一遍数据规模控制在极小范围内确保能跑完贪心解法写得和正式代码一模一样。然后随机生成数据不断比较两者输出import random def brute(arr): # 正确但慢的解法例如枚举全部子集 pass def greedy(arr): # 待验证的贪心策略 pass for _ in range(100000): arr [random.randint(1, 30) for _ in range(10)] a brute(arr) b greedy(arr) if a ! b: print(发现反例, arr, 暴力, a, 贪心, b) break对拍不是证明但它能在你写证明之前快速筛掉绝大多数错误直觉。我在给新人Review算法方案时第一句话永远是“你和小暴力对拍过没有”对拍能过滤掉“没想清楚”的大部分情况剩下的才值得花时间在纸上推数学证明。5. 常见误区和排查技巧整理5.1 写代码时最常见的贪心陷阱很多人会在写代码时不知不觉踩进贪心的坑我梳理几个最常见的陷阱一按单位价值/单位收益排序就一定对。01背包已经把这个否掉了。凡是需要拆分的价值都要警惕“连续性假设”是否成立。如果物品不可分割按单位收益排序往往是错的。陷阱二贪心选出来的答案刚好样例通过就以为正确。题目给的样例通常是最温和的情况反例往往藏在边界值里。我见过一道区间覆盖问题样例数据太小贪心用“按左端点排序”居然也过了直到第三十个数据点才暴露。所以别把样例当测试要用对拍和压力数据。陷阱三动态规划状态定义不完整就开写。尤其是在有“先后顺序”“是否访问过”等约束时状态里漏掉一个维度转移方程就把约束丢了结果自然是错误。排查这类问题的办法是问自己拿到当前状态时我是否知道所有和未来决策相关的信息如果还有不知道的就得加维度。陷阱四把完全背包写成01背包。一维滚动数组里正向遍历允许重复选择同一物品反向遍历则不允许。很多人背了代码但没理解方向的含义一换题型就挂。这个方向的选择本质是“枚举顺序和状态依赖关系”的体现不是死记硬背能解决的。5.2 问题排查速查表我把刷题和项目里遇到过的、和贪心/DP相关的典型问题整理成了一张表遇到类似的报错或性能问题可以对照着查症状可能原因应对策略样例能过提交挂贪心策略不满足贪心选择性质写暴力对拍尝试交换论证证明DP结果偏大或偏小状态定义丢失维度或转移方向错误检查状态是否包含全部决策所需信息内存溢出二维状态数组太大改用滚动数组、记忆化搜索或状态压缩运行超时状态转移复杂度太高寻找更紧凑的状态定义或者重新判断是否能用贪心反复出现“差一点点最优”局部最优遮蔽全局视角果断放弃贪心全面枚举状态答案和暴力不一致但基本接近排序标准搞错或比较器漏考虑平局情况检查排序依据是否覆盖所有影响因素这张表是我多年排查算法的真实经验浓缩希望能帮你省下一些对着屏幕发呆的时间。5.3 三个值钱的实操心得心得一先找反例再写代码。拿到一个疑似贪心的问题先别急着写排序和循环花五分钟试着构造反例。构造反例的技巧是让两个候选方案的当前收益很接近但后续潜力差别很大。如果构造不出来再考虑动手写。这个习惯帮我过滤了大量错误直觉。心得二动态规划的状态定义是“为了未来决策必须记住什么”。不要从“题目给了什么参数”出发硬凑状态要从决策角度倒推。比如背包问题你后续决策时关心的是“还剩多少容量”所以容量必须进状态。如果后续决策还受“上一步选了哪个区块”影响那这个信息也必须保留。想清楚这一点状态设计就不会跑偏。心得三不要把“复杂度低”当成“解法一定对”的理由。贪心之所以诱人是因为它代码短、跑得快。但在正式系统里一个错误但跑得飞快的算法比一个慢但正确的算法危险得多。凡是涉及线上资源分配、资金调度、路径规划之类的场景我宁可先用DP或者搜索保证正确性再针对热点路径做优化。先保证正确再追求效率。6. 怎么把这个分界用在自己的项目和学习里6.1 项目里遇到优化问题时的判断流程如果你正在做一个需要做决策优化的功能比如推荐位分配、任务调度、库存调拨、预算分配我建议按下面这个流程走一遍把目标函数和约束条件写清楚尤其是“资源总量有限”和“选择之间是否相互影响”。检查最优子结构去掉最后一次决策后剩余部分是否还是同类问题。尝试构造贪心反例如果能在两分钟内置出一个安全反例直接放弃贪心。如果找不到反例再尝试用交换论证证明贪心正确。证明不出来的也用DP。评估DP状态规模。状态数太多的考虑滚动数组或剪枝仍然太大才考虑启发式算法或近似算法。这个流程的关键在于“证明不出来就用DP”是个硬规则。很多工程同学觉得“跑起来结果没差多少”就接受了贪心方案但系统数据一旦增长错误会迅速放大到那时再返工成本和风险都高得多。6.2 学习路径建议从入门到能上考场如果你正准备面试或者竞赛我的学习建议是先确保自己能把区间调度、哈夫曼编码、最小生成树这三类经典贪心题目的证明过程手写一遍再确保能把01背包、最长递增子序列、编辑距离这三类动态规划题从状态定义到空间优化完整写对。之后做一个交叉练习把同一个问题的参数改一改观察解法类别会不会改变。比如硬币找零在特定币值下贪心是对的换一套币值就得用DP比如带权区间调度按权重不同贪心失效必须DP。这种“同一场景、不同参数、不同解法”的训练是对“理论分界”理解的最好检验。竞赛新手容易陷入“背模板”的误区我特别不建议。模板能应对标准题型但只要题目稍微变形比如背包加一个“每组至多选一个”或者区间调度加一个“每个点最多覆盖几次”模板就失效了。真正可靠的是对原理的理解理解那个“分界判据”远比记住几十个模板有用。6.3 一点个人体会写到这里我想起自己刚学算法时遇到一道很简单的“安排会议室”的题我用了动态规划状态多到爆炸代码写了一百多行最后还超时。后来才知道最优解就是按结束时间排个序十几行代码搞定。那种“原来我一直在做复杂事情”的顿悟到现在都记得。所以我最后的建议是碰到决策优化问题先花十分钟把“贪心的反例”和“DP的状态”这两个问题想透再动手。不要因为贪心代码短就轻视它也不要因为DP复杂就回避它。两种工具没有高低之分只有“用对地方”和“用错地方”的区别。掌握了分界判据你才算真正同时握住了这两把钥匙。