多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

C++贪心算法实战:从排序、优先队列到经典题全解析

C++贪心算法实战:从排序、优先队列到经典题全解析 作为常年在算法题和工程代码之间反复横跳的人我越来越觉得贪心算法是最接近“现实决策”的一类算法。它在C里的落地不只是背几个模板题而是训练一种观察问题的角度局部最优能不能推出全局最优怎么证明怎么用代码稳稳地把它实现出来。这篇东西不是教科书式的知识罗列我想从实际做题、写C代码、以及日常工程中踩过的坑出发把贪心算法这条线的核心逻辑讲透顺便把C实现里的细节也交代清楚。文章适合几类人刚学完C语法、准备开始啃算法的初学者正在准备笔试面试、想在短时间内把贪心题型的套路摸清楚的求职者以及工作中经常要做调度、排程、资源分配、删减数据等“效果最优”决策的开发者。我尽量不堆砌概念而是把每一道经典题的思考过程、代码写法、推导方式拆开给你看。1. 贪心算法到底在“贪”什么1.1 核心思想每一步都做当下最好的选择贪心算法的中文名字起得非常直白贪。它不让你的程序在全局范围内搜索全部状态而是每走一步都只挑当前看起来最优的那个选项然后一路走下去。听起来很像“走一步看一步”所以很多人第一印象是它太莽撞。其实贪心能成立靠的是问题本身具有两个性质贪心选择性质和最优子结构。前者是说你能通过一系列局部最优的选择拼出全局最优解后者是说问题的最优解里包含着子问题的最优解。只有这两个条件都满足贪心算法才是正确的。我用生活里的事来解释你去餐厅点菜如果菜单上每道菜的价格都是固定的你想在预算内尽量多吃几道不同的菜那最合理的做法是先把贵的菜挑出来不对是先看便宜的因为便宜菜的数量多。这就是一个典型的贪心策略。但如果菜单有“套餐优惠”“满减”便宜的菜可能不是最优因为组合起来可能更亏这时候贪心就不一定对得考虑别的方案。C里写贪心结果不等于思路思路再清晰代码里如果排序规则写错、边界没处理干净、数据类型选小了一个字符的差异就能让整题全错。所以学贪心绝对不只是学“套路”还要学怎么把策略精确翻译成代码。1.2 贪心选择性质局部最优如何通向全局最优怎么判断一个局部最优的选择最后一定能组成全局最优最常见的方法是“交换论证法”。假设你按某个规则选了一个局部最优解如果最终的全避最优解和你选的这个局部解不一样你就去证明这个“不一样”可以被一次交换修正过来而且修正后结果不会变差。反复交换最终能证明你的贪心选择和某个全局最优解“兼容”。举例来说活动选择问题里你按结束时间最早来选活动如果最优解的第一个活动不是最早结束的那个那这个活动一定结束得比贪心选的更晚把它换成最早结束的那个后续活动照样可以安排不会冲突。这样一次一次交换贪心策略就等价于某个最优解。我用C写活动安排时代码其实很短#include bits/stdc.h using namespace std; struct Activity { int start, end; }; bool cmp(const Activity a, const Activity b) { return a.end b.end; // 按结束时间升序 } int main() { int n; cin n; vectorActivity act(n); for (int i 0; i n; i) { cin act[i].start act[i].end; } sort(act.begin(), act.end(), cmp); int ans 0, lastEnd 0; for (const auto a : act) { if (a.start lastEnd) { // 只有不重叠才选 ans; lastEnd a.end; } } cout ans endl; return 0; }这里排序是核心按结束时间提前排好后面一遍扫描就完成选择。这就是贪心的典型发力方式先用排序把“全局顺序”整理好再线性扫描做决策。后面讲到的删数问题、区间覆盖、拼接最大数本质上全都是这个模式。1.3 和动态规划的分界线在哪很多初学者最困惑的就是什么时候用贪心什么时候用动态规划傻傻分不清。有一个朴素的判别标准如果每一个决策做完之后子问题形状没有改变状态空间不会膨胀那大概率可以试试贪心如果决策之后至少产生两个不同的子问题而且需要分别求解并比较才能拿到解那通常得用动态规划。举一个对比零钱兑换问题如果硬币面额是1、5、11要找15元贪心做是“先拿最大面额”拿一个11再拿四个1总共5枚。但最优是三个5一共3枚。贪心在这里就错了因为拿走11之后剩下4块钱的子问题已经和全局不可分割地连锁起来了需要动态规划去处理。但如果是找硬币的“硬币数量百分比”题或删数问题每个决策都只影响一个连续序列且剩余问题的结构和原问题完全一致贪心就成立。判断贪心是否有效最好的办法是拿动态规划做对照跑小数据量看两种方案结果是否一致。这个验证手段我后面会专门细讲。2. 手把手拆解两个入门必做经典题2.1 删数问题一个最容易写错的贪心“删数问题”几乎是每个学贪心的人都会遇到的题。题目描述通常是给出一个由数字组成的字符串删掉其中k个数让剩下的数字按原顺序组成的数尽可能小。比如1432219删掉3个数字能得到的最小数字是1219。这个题的贪心策略是从左到右扫描如果发现当前数字比后一个数字大那么为了把数字变小就应该“删掉”这个当前数字。反复操作直到删除次数用完。如果一轮扫下来还没删够再把末尾最大的数字删掉。为什么这个策略是对的因为高位数字对整个数值的影响远大于低位。从左到右第一次出现“下降沿”的位置比如143里的4和3那个高位4已经比后面的3大了高位大、低位小那为了让结果尽量小删掉4肯定比删掉3更划算。继续在剩余的序列里重复这个过程最终就能得到字典序最小的结果。但是在代码实现里这个题有好几个经典坑误用bool标记删除位置导致后续处理混乱越界访问s[i 1]忘记处理删除完以后前面的0如果k等于字符串长度忘记返回0。我在C里一般用一个栈来模拟“维护一个逐渐变小的单调栈”。string removeKDigits(string num, int k) { string res; // 用string模拟栈 for (char c : num) { while (!res.empty() k 0 res.back() c) { res.pop_back(); // 高位比低位大坚决删掉 --k; } res.push_back(c); } // 如果还有没删完的从尾部删掉大的数字 while (k 0 !res.empty()) { res.pop_back(); --k; } // 去掉前导0但至少要保留一位 int pos 0; while (pos res.size() res[pos] 0) { pos; } res res.substr(pos); return res.empty() ? 0 : res; }这个代码的巧妙之处在于用res的尾部作为“当前位置”每次新数字进来就和前面的比一旦形成逆序就弹出。整个过程只扫一遍时间复杂度是O(n)。我当时的教训是一开始我想用vectorchar来模拟其实没必要标准库的string本身就是动态数组push_back、pop_back、back这些操作全都有可以直接当栈用。能少引入一个容器代码更干净。2.2 删数问题背后的贪心证明光会写代码不够面试和复盘的时候还得能说出“为什么这么删是对”的理据。删数问题的交换论证比较直观。假设在某个位置出现了res.back() c这种情况我们可以想象一个最优答案。如果最优答案里保留了res.back()这个高位数字而删的是后面某个更低位的数字那么把这两个操作交换一下也就是删掉高位数字、保留低位数字结果只会更小而后续数字的顺序不变。通过这样的交换我们总能让最优解符合贪心策略的每一步所以贪心正确。这个证明过程比代码本身更能体现“为什么学贪心”的意义。工程中很多看似合理的策略都值得在脑子里这样“过一遍证明”防止凭感觉上线之后出大问题。2.3 区间调度把“贪心排序”用到熟区间调度类和删数问题的思路是一脉相承的先排序再扫描。最典型的题是给n个会议的开始时间和结束时间问最多能参加多少个不重叠的会议。策略是按结束时间从小到大排然后只要当前会议的开始时间不早于上一个选中会议的结束时间就选它。这个策略背后的直觉是把结束时间早的会议留出来能给后面的会议腾出更多的时间缝隙所以“局部最紧凑”的选择能带来“全局最多”的数量。int maxMeetings(vectorpairint, int meetings) { sort(meetings.begin(), meetings.end(), [](const pairint, int a, const pairint, int b) { return a.second b.second; }); int cnt 0, lastEnd 0; for (auto m : meetings) { if (m.first lastEnd) { cnt; lastEnd m.second; } } return cnt; }这里我特别强调一下C中lambda的排序写法。[](const pairint,int a, const pairint,int b)里的[ ]是捕获列表不捕获外部变量就留空在写竞赛和面试代码时这种匿名lambda比单独写一个全局比较函数更紧凑也不会污染命名空间。但lambda有个坑如果你在里面用了外部变量比如offset一定要写[]或者[]否则编译报错。如果你的comp函数是一个类成员函数排序的时候不能直接传成员函数指针得在外面套一层静态函数或者lambda。这些细节看起来和算法无关却真的是我在写C贪心代码时经常被打断的原因。3. C里实现贪心的实用技巧3.1 排序相关sort、stable_sort和自定义比较器贪心算法最依赖的操作就是排序。C的标准库sort排序是不稳定的而stable_sort能保持相等元素的原有相对顺序。有些贪心题里“相等时谁先谁后”很关键比如拼接数字求最大如果两个数字构成的组合一样它们谁在前结果都不变那用哪个都行。但如果题目要求按原顺序优先保留某个字段最好想清楚要不要用stable_sort。自定义比较器时要注意严格弱序。比较器的返回值描述的是“a是否应该排在b前面”如果a和b相等必须返回false而且在比较中不能让a b和b a同时为真。我曾经在写双关键字排序时把“按结束时间升序时间相同按开始时间降序”写成了两次比较导致sort结果乱跳。原因就是没有保证严格弱序。举一个实际例子区间覆盖问题给一些区间问最少用几个区间能覆盖一个目标区间。这个题要先按开始时间升序开始时间相同时按结束时间降序。为什么因为同样是起点能覆盖到更远的区间更优后续就能用更少的区间补上剩余部分。这个排序策略如果反了可能一整个贪心判断逻辑就崩了。3.2 优先队列动态取“当前最优”的利器不是所有贪心都靠数组排序解决有些问题的“当前最优”会随着处理过程不断变化这时候优先队列priority_queue就是首选。比如合并果子问题每次从一堆果子里挑两个重量最小的合并合并完了再放回去问最小总耗费。如果每次都用排序重新找最小复杂度太高正确做法是用小顶堆每次弹出两个最小值合并后压回去。priority_queueint, vectorint, greaterint pq; // 小顶堆 for (int x : nums) pq.push(x); int ans 0; while (pq.size() 1) { int a pq.top(); pq.pop(); int b pq.top(); pq.pop(); int sum a b; ans sum; pq.push(sum); }这里的类型写法是C新手很容易忘的priority_queueint, vectorint, greaterint默认情况下priority_queueint是大顶堆想变成小顶堆必须写完整的三段式。如果你觉得这个写法太啰嗦也可以直接存入负数用小技巧翻转大小关系但代码可读性会下降我一般不用。类似要用到优先队列的场景还包括每次取最大最小值、每次合并K个有序链表、哈夫曼编码等。形式不一本质都是“从动态变化的集合里反复提取最值”。3.3 结构体、tuple和pair的使用选择贪心题的排序对象经常是一个含有多个属性的事物。我建议优先用pair或tuple只有属性超过三到四个的时候再写结构体。太早写结构体会让代码整体看起来重量级。pairint,int默认先按first排再按second排。如果我自己定义了lambda比较器其实底层的存储方式无所谓。但是在竞赛环境里用pair可以减少不少代码量因为make_pair或者直接用花括号{}初始化都很方便。我记得有一次比较器的第二个排序维度写反了导致题目的样例都过不去排查了半天。后来我改成给结构体加上operator重载再配合sort逻辑一下子就清楚了。结构体写清楚的好处是几个字段都有名字读代码的时候不需要记“first到底代表开始时间还是结束时间”。3.4 返回值和边界条件设计的工程习惯贪心题普遍爱考边界空数组、单个元素、k等于长度、全是0、溢出。我在写代码时习惯在函数入口做一层前置检查优先保证数据合法性再进入贪心循环。if (num.empty() || k 0) return num; if (k num.size()) return 0;这种防御式写法在竞赛里也许显得略啰嗦但在工作中是标配。很多crash都发生在边界条件被忽略的时候。C的vector越界不会自动报错string的back()在空串上调用是未定义行为这些隐形的雷区都需要我们对边界有足够敏感。4. 常见陷阱与实战踩坑记录4.1 容易栽坑的排序规则我在指导别人写贪心题的时候发现90%的人第一次提交出错问题都出在排序上。举一个非常经典的反直觉题给定一组数字把它们按顺序拼成一个最大的数。比如[3, 30, 34, 5, 9]正确答案是9534330。如果按普通字典序排9最大5次之34和3谁在前直观上3比34小但3拼在34后面组成343而34拼在3后面组成343不行得换个例子3和32332和323明显3应该在32前面。正确的比较方式是自定义比较a b这个字符串和b a这个字符串谁大谁就排前面。写成lambda就是sort(nums.begin(), nums.end(), [](const int a, const int b) { string sa to_string(a), sb to_string(b); return sa sb sb sa; });这个细节是纯字典序解决不了的它会直接决定答案对错。所以碰到字符串拼接型问题千万别偷懒直接对整数排序。这个问题的实质是贪心的“局部最优比较”本身就要自定义不能指望C默认比较规则恰好契合题意。4.2 相等元素和保留顺序的问题之前提到过stable_sort在很多贪心题里排序只决定“优先级”并不要求改变相等元素的相对位置。如果你用的是sort那是快速排序的混合排序实现不保证稳定性如果题目要求量化比较之外还保留初始顺序请自觉用stable_sort。比如任务调度类问题中两个任务优先级完全一样但一个先出现在原数组里题目要求输出结果保持原顺序此时stable_sort就比sort更合适。贪心算法只决定选择策略要不要打破原顺序是另一层需求别混在一起处理。4.3 整数溢出与类型选择贪心题常常涉及对大量元素求和、求积。C的int通常是32位最大值约21亿。当数据规模是10^5、每项是10^5时乘积很容易爆int。如果你还在用int做累加最后答案错都不知道错在哪。一个比较常用的习惯是涉及累计和、累计差、最大值最小值比较时直接使用long long64位作为计算类型它能容纳到9.2×10^18绝大多数贪心题都能镇住。另一个隐蔽问题是priority_queue里的元素类型如果和累加变量类型不一致可能出现隐式转换导致溢出。比如堆里存int但是累加结果用long long依然可能在堆内相加时先按int计算再转。稳妥做法是从一开始就用long long存数。我自己有一次写区间覆盖的变体题区间长度最大能到10^9我用int存开始和结束时间结果两段长度相加时直接变成负数排序立刻错乱。排查一个小时后才发现修改成long long之后瞬间通过。这个经验让我在之后所有的贪心题里养成了“能用long long绝不用int”的习惯。4.4 模拟重复删除或重复选择导致超时初学删数问题时有人会写两层循环外层每删一个数就重新扫描整个字符串。字符串长度是10^5k也是10^5这就是O(n^2)的复杂度直接TLE。优化方向就是之前提到的栈思路一次扫描完成所有“删除”动作。贪心算法的精彩不只在于“策略正确”还在于“执行高效”。同样是贪心盲写和用正确数据结构实现的代码耗时差异可能是天壤之别。5. 验证贪心解法的几种可靠手段5.1 先用小规模数据做对照当我不确定一个贪心策略对不对时我会写一个暴力解法或动态规划解法用于小数据量比如n10的情况下做对照验证。暴力永远是对的因为它在正确性上做了穷举。以删数问题为例小于等于10个数字时用DFS枚举所有删法取最小值再去和贪心解法比对。如果所有随机样例一致说明贪心大概率正确如果发现不一致正好可以把那个反例拿出来分析看看贪心缺了什么条件。这种验证思路在竞赛训练里非常高效因为不等你证明直接跑几十组随机数据就能把错误暴露出来。而在工程里它相当于用回归测试去验证你的策略是不是真的按预期执行。5.2 尝试反证和交换论证如果随机测试都过了我还想从理论上再给自己一次交代。通常做两种推理反证法假设贪心策略给出的解不是最优解那么一定存在一个更优解从这个更优解出发找出能不能通过一次“局部修正”把它变成贪心解而不变差。如果能那就证明贪心解是最优。归纳法考虑规模为n的情况假设n-1时贪心正确证明n时贪心选择能保留一个最优解的“前序状态”然后递归成立。大多数教材和题解用的都是这两种论证。哪怕你觉得证明是形式化的“标配”我也建议至少在自己脑子里走一遍完整流程这样面对面试官追问的时候不会露怯。5.3 极端值和手工推导最好手动设计边界测试用例比随机测试更有针对性。比如删数问题我会专门测试数字串全是0删除任意k个数字串是递增序列比如123456此时不需要删“下降沿”只能删末尾数字串是递减序列比如54321此时应该从头部删k等于字符串长度字符串长度为1k为1。每次设计用例前我会先在纸上手算一遍预期结果再跑代码防止测试用例本身也是错的。这种习惯在真实项目中相当于“先定期望再写断言”能有效防止用错误用例把代码验证成假通过。6. 实战中的一些“非算法”经验6.1 从一道题扩展到一类题的建模方式我发现贪心题看似千变万化实际建模是可以套路化的第一步明确目标函数是让某个值最大、最小还是让数量最多、最省。第二步找“局部变量”什么事情做完以后不会牵一发而动全身可以当作单独决策。第三步确定决策规则并试着用交换论证证明它。第四步选择数据结构排序、堆、双端队列、栈、线段树哪个能在当前局面高效地获取局部最优。第五步处理边界零输入、巨大输入、相等输入等。把这套流程在五道题上各跑十遍你基本就能形成自己的“贪心直觉”比死记硬背几十道题来得更可靠。6.2 用C写贪心时的调试习惯我调试贪心题的顺序一般是加装断言检查排序后是否严格按预期序列排列对未排序前和排序后的状态打log写一个短小的暴力解在小样本上对拍如果答案是错的先检查类型和边界再检查比较逻辑最后检查算法本身。有几次我自己调试时发现问题根本不是贪心策略错而是我在定义比较器时用了引用绑定到临时变量导致比较结果不稳定。这样的小细节如果你没有良好的调试顺序会浪费大量的时间。所以我在代码里往往会专门抽一个printVector函数在关键步骤输出当前状态。C的调试不如Python那么方便适当打印能在关键时刻救你一命。6.3 工程中贪心的实际落地很多人误以为贪心只在算法竞赛中有用其实工程里的资源分配、缓存淘汰LRU的变种、任务优先调度、网页推荐排序、广告流量分配等场景都在用贪心思想。比如广告预算分配场景里多个广告位、多个出价方、有限预算选择哪个广告组合能让整体收益最大化如果约束足够简单完全可以用贪心按单位成本的收益排序从高到低分配。工程实现时还要考虑响应时间这时贪心算法因为只需要一次排序加一次扫描性能非常稳定很适合在线服务场景。C在工程中的优势是高性能和可控的内存布局加上标准库里的排序、堆、贪心配合起来写出的调度系统可以在单机承载很大的数据量。这也是为什么我建议做后端和基础架构的同学也把贪心玩熟。7. 由浅入深怎么安排自己的练习节奏如果你是从零开始我的建议是先完成下面这几组第一梯队删数问题、活动选择、跳跃游戏、康复区间找零如果硬币罚特殊就换题。目的是理解基础扫描排序。第二梯队区间覆盖、合并果子、任务调度、拼接最大数。这些题开始引入复杂的排序规则和堆。第三梯队哈夫曼编码、K次取反后最大化的数组和、分发糖果、加油站。这些题需要更多思维转化。第四梯队贪心动态规划混合题、贪心失败题。后者很重要能帮你理解贪心的边界。训练过程中我强烈推荐用“同一道题写两种解法”的方式提升一种是最朴素的暴力一种是贪心。用暴力检验贪心再用贪心的复杂度优势去挑战更大的数据范围。这样做题一个题消化三层比盲刷五十道题有用得多。很多同学问C写贪心要不要背模板我觉得不用但常用的几个框架可以滚瓜烂熟sort 单指针扫描的框架priority_queue的take-smallest框架以及用string当栈的框架。把这几个框架印在脑子里绝大多数贪心题都能很快找到切入点。8. 最后再分享几个我个人的习惯我在实际写C贪心代码时有一个坚持了很久的习惯把排序规则抽成独立的命名函数。虽然lambda写起来很快但如果排序规则有15行以上嵌套在sort调用里会显得很臃肿且不方便做单元测试。我会单独定义bool compareByEndTime(const Activity a, const Activity b) { if (a.end ! b.end) return a.end b.end; return a.start b.start; }这样调试的时候可以直接打印某几个元素的比较结果。工程上这叫“可测试性”竞赛上这叫“方便调错”反正都是适合自己的好习惯。另外我很少直接用#include bits/stdc.h以外的方式写竞赛代码但在工作项目里几乎不用这个头文件会逐个包含需要的标准库头文件。这个问题不算算法问题但如果你把竞赛代码搬进生产环境可能会带来编译隐患务必心里有数。最后再分享一个小技巧训练时把每一道贪心题的“策略一句话”写在注释里。比如// 删数从左到右删除第一个比右侧更大的数 // 活动按结束时间升序能选就选 // 合并果子每次取最小的两个合并别小看这一行注释它在复盘时能帮你迅速定位“当时是怎么想的”。我翻自己半年后的代码时经常靠这种注释快速回忆起整个决策过程。贪心算法的学习其实是锻炼一种“把直觉用逻辑固定下来”的能力。C只是载体真正有价值的是你学会在每一个决策面前停下来问一句这一步的局部最优真的能得到全局最优吗用代码验证它用证明说服自己这个过程练到位了后续再看动态规划、图论算法你都会有更强的底气。
返回列表