1. 从一道题看拓扑排序的“反直觉”设计
第一次碰到“菜肴制作”这道题的时候,我盯着题面看了很久,心里想的是:这不就是个拓扑排序吗,有什么难的。结果写完交上去,答案全错。后来才反应过来,这题根本不是普通的拓扑排序——它要求的是在满足所有先后约束的前提下,让编号小的菜肴尽可能早地被制作。注意,是“尽可能早”,不是“字典序最小”。这两个说法看起来差不多,实际上差了十万八千里。
这道题来自一个省级竞赛的题目,题面大致是这样的:有若干道菜需要制作,某些菜必须在另一些菜之前完成(比如某些食材需要提前处理),每道菜有个编号。要求给出一个制作顺序,使得在满足所有先后约束的前提下,编号小的菜尽量先做。如果有多组解,输出那个让小编号菜更靠前的方案。
关键词里提到了“拓扑排序”和“反图”,这两个词就是解题的核心。拓扑排序本身不复杂,但“反图”这个操作才是真正让这道题从“模板题”变成“思维题”的关键。我后来跟不少朋友聊过这道题,发现大部分人的第一反应都是:用优先队列维护当前入度为0的节点,每次取编号最小的不就行了?这个思路看起来很自然,但它恰恰是错的。
为什么错?因为“字典序最小”和“小编号尽量早”是两回事。举个简单的例子:假设有两道菜1和2,约束是“3必须在1之前”。如果按字典序最小的思路,第一步能选的只有2和3,选2的话序列是2,3,1;选3的话序列是3,1,2。字典序最小会选2开头的那个,但题目要求的是让1尽量早出现——显然3,1,2这个序列里1出现得更早。所以正着贪心是行不通的。
那正确的做法是什么?把图反过来,然后跑一个“字典序最大”的拓扑排序,最后把结果倒过来输出。这个操作第一次看到的时候会觉得有点绕,但想通了之后会发现非常优雅。它的本质是:正向要求“小编号尽量靠前”,等价于反向要求“大编号尽量靠后”。你把图反过来,约束方向变了,但约束关系还在,这时候用最大堆维护,每次取编号最大的节点,得到的序列倒过来就是答案。
这个思路我第一次理解的时候花了大概半个小时,在纸上画了好几组数据才彻底想通。下面我会把这个过程完整地拆开,包括为什么正着不行、反着为什么行、代码怎么写、有哪些坑要注意。如果你也在学拓扑排序,或者被这道题卡住了,希望接下来的内容能帮你把这块骨头啃下来。
2. 核心思路拆解:为什么正着贪心是错的
2.1 正着贪心的直觉陷阱
先说说为什么大部分人(包括我)第一反应都是正着贪心。拓扑排序的标准做法就是:统计每个节点的入度,把入度为0的节点放进队列,每次取出一个,删掉它的出边,把新产生的入度为0的节点加入队列。如果要让编号小的先输出,很自然就会想到把队列换成优先队列,每次取编号最小的。
这个思路在大多数“字典序最小拓扑序”的题目里是完全正确的。但“菜肴制作”这道题的要求不一样。题目说的是:在满足所有限制的前提下,让编号小的菜肴尽可能早地被制作。注意这个“尽可能早”的含义——它不是说整个序列的字典序最小,而是说对于编号1的菜,它出现的位置越靠前越好;在1的位置确定的前提下,再让2的位置越靠前越好,以此类推。
这两者的区别在哪里?字典序最小是逐位比较的,第一位越小越好,第一位相同再看第二位。而“小编号尽量早”是一个更全局的优化目标。用个不太严谨但很直观的说法:字典序最小关心的是“开头”,而这道题关心的是“小编号的位置”。
我举个具体的例子来说明。假设有4道菜,约束是:1必须在4之前,2必须在4之前。也就是说4依赖于1和2。现在考虑两个合法序列:
- 序列A:1, 2, 4, 3
- 序列B:1, 3, 2, 4
按字典序比较,序列A更小(第二位2小于3)。但按题目的要求呢?编号1在A和B里都在第一位,一样。编号2在A里是第二位,在B里是第三位,A更靠前。所以A更好。这个例子里两者一致。
再看另一个例子。约束是:3必须在1之前。合法序列有:
- 序列C:2, 3, 1
- 序列D:3, 1, 2
字典序比较,C更小(第一位2小于3)。但题目要求编号1尽量早,C里1在第三位,D里1在第二位,所以D才是正确答案。这就是正着贪心会出错的地方——它为了追求第一位的“小”,牺牲了小编号1的位置。
2.2 反图+最大堆的巧妙之处
那为什么反着来就对了?我们把图反向,约束“3在1之前”变成“1在3之前”。然后我们跑字典序最大的拓扑排序,也就是每次取编号最大的节点。在这个反向图里,入度为0的节点有哪些?原来的出度为0的节点。对于上面的例子,反向图里1指向3,所以1的入度是0,2的入度也是0,3的入度是1。
最大堆先取2,然后取1,最后取3,得到序列2, 1, 3。倒过来就是3, 1, 2,正好是正确答案。
这个操作的原理可以这样理解:正向要求“小编号尽量靠前”,等价于反向要求“大编号尽量靠后”。因为如果你把最终序列倒过来看,原来的“靠前”就变成了“靠后”。而“让大编号尽量靠后”在反向图里就变成了“让大编号尽量靠前”——这正好就是字典序最大的拓扑排序。
更严谨一点的解释是:正向的贪心之所以失败,是因为它在每一步只考虑了当前能选的节点里哪个编号最小,但没有考虑到选择这个节点会不会“堵住”后面小编号节点的路。而反向的最大堆贪心,每次选编号最大的节点放到序列末尾,实际上是在保证“大的节点不会占用前面的位置”,从而给小编号节点留出空间。
我个人的理解是:正向贪心是“从前往后填”,每一步都在猜;反向贪心是“从后往前填”,每一步都在确定。从后往前填的时候,你选的是当前能放到最后的节点里编号最大的那个,这个选择不会影响前面小编号节点的相对顺序,所以是安全的。
2.3 一个更直观的类比
如果上面的解释还是有点绕,我用一个生活中的类比来说。假设你要安排一群人排队,要求是“个子矮的人尽量站在前面”。现在有一堆约束,比如“A必须站在B前面”。如果你从前往后安排,每次从能站的人里挑最矮的,可能会出问题——因为最矮的那个人可能被某个约束“卡住”,你把他安排到前面了,结果后面有个更矮的人因为约束必须排在他后面,反而被推到了更后面。
但如果你从后往前安排,每次从能站的人里挑最高的放到当前最后的位置,就能保证矮的人不会被高的人挤到后面去。因为高的人本来就该在后面,你把他放到最后是合理的,不会影响矮的人往前站。
这个类比不完全严谨,但核心思想是对的:反向操作把“小编号靠前”这个全局目标转化成了“大编号靠后”这个更容易贪心实现的目标。
3. 完整实现与代码细节
3.1 建图与反图的操作
先说建图。题目给的是n道菜和m个约束,每个约束是“a必须在b之前”,对应一条从a到b的有向边。我们用邻接表存图,同时统计每个节点的入度。
反图的操作很简单:对于原图中的每条边a→b,在反图中建一条b→a的边。同时反图中b的入度加1(因为反图里b有一条出边指向a,a的入度加1)。等等,这里要仔细:原图a→b表示a在b之前,反图就是b→a。反图中每个节点的入度等于原图中该节点的出度。
我写代码的时候习惯用两个数组分别存原图和反图的邻接表,但其实只需要反图就够了,因为整个算法只在反图上跑。不过为了调试方便,我一般会把原图也存一份,方便打印出来检查。
#include <bits/stdc++.h> using namespace std; const int MAXN = 100005; vector<int> revG[MAXN]; // 反图 int indeg[MAXN]; // 反图中的入度 int ans[MAXN]; // 存储答案 int n, m; bool topoSort() { priority_queue<int> pq; // 最大堆 for (int i = 1; i <= n; i++) { if (indeg[i] == 0) pq.push(i); } int cnt = 0; while (!pq.empty()) { int u = pq.top(); pq.pop(); ans[++cnt] = u; for (int v : revG[u]) { if (--indeg[v] == 0) { pq.push(v); } } } return cnt == n; // 如果cnt小于n说明有环 }这段代码的核心就是优先队列默认是大顶堆,每次取最大的。如果你用的是小顶堆,那就错了。我见过有人用priority_queue<int, vector<int>, greater<int>>,那是取最小的,方向反了。
3.2 输出顺序的处理
跑完拓扑排序之后,ans数组里存的是反图的拓扑序,需要倒序输出才是原图的答案。倒序输出的时候要注意格式:最后一个数字后面不能有多余空格,否则有些评测系统会判格式错误。
// 倒序输出 for (int i = n; i >= 1; i--) { printf("%d", ans[i]); if (i > 1) printf(" "); } printf("\n");这个细节看起来很小,但我因为这个格式问题WA过好几次。有些题目对行末空格不敏感,但这道题比较严格,建议养成好习惯。
3.3 环的检测
题目一般会保证有解,但严谨起见还是应该检测环。如果拓扑排序结束后cnt < n,说明有环,应该输出无解。不过这道题的原题是保证有解的,所以这个检测更多是防御性编程。
我个人的习惯是:不管题目怎么说,都加上环检测。因为有时候题目描述会说“保证有解”,但实际上测试数据里可能有环,或者你理解错了题意。加上检测不会影响正确性,还能帮你发现潜在的问题。
3.4 多组数据的处理
这道题可能有多组测试数据,每组数据需要清空邻接表和入度数组。清空的时候要注意只清空用到的部分,不要整个数组memset,否则可能会超时。我一般用vector的clear(),或者手动把用到的节点重置。
void init() { for (int i = 1; i <= n; i++) { revG[i].clear(); indeg[i] = 0; } }如果n很大而m很小,用vector的clear()比memset快很多。但如果n也很小,memset也无所谓。这个优化在数据量大的时候能省不少时间。
4. 常见问题与排查技巧实录
4.1 为什么我的正着贪心过不了样例
这是最常见的问题。很多人写完正着贪心的代码,发现样例都过不了,然后开始怀疑人生。其实样例就是专门设计来卡正着贪心的。如果你用正着贪心,样例的输出会和正确答案不一样。这时候不要怀疑题目有问题,而是应该意识到自己的思路错了。
我当时的排查过程是这样的:先手算了一遍样例,发现正着贪心得到的序列确实不满足“小编号尽量早”的要求。然后我开始想,有没有一种方法能把“小编号尽量早”转化成某种已知的贪心策略。想了很久才想到反图这个操作。
4.2 反图之后为什么用最大堆而不是最小堆
这个问题我一开始也搞混了。反图之后,我们的目标是让大编号尽量靠前(因为倒过来之后大编号就靠后了)。所以应该用最大堆,每次取编号最大的。如果你用了最小堆,那就变成了反图的字典序最小拓扑序,倒过来之后既不是字典序最小也不是小编号尽量早,完全是错的。
我建议在写代码之前先在纸上模拟一遍。比如上面那个“3在1之前”的例子,反图之后用最大堆得到2,1,3,倒过来是3,1,2。如果你用最小堆,反图得到1,2,3,倒过来是3,2,1,编号1在第三位,显然不对。
4.3 输出格式导致的WA
这个问题很隐蔽。你的算法是对的,但输出格式不对,评测系统就会判WA。常见的格式问题有:行末多了空格、每组数据之间没有换行、最后一组数据后面多了一个空行。我建议在本地测试的时候把输出复制到文本编辑器里,打开“显示不可见字符”功能,检查一下行末有没有多余的空格。
4.4 数据范围与数组大小
这道题的数据范围一般是n和m都在10^5级别。如果你用邻接矩阵存图,空间会爆。必须用邻接表。我用的是vector数组,每个节点一个vector,存反图中的邻居。这样空间是O(n+m),完全够用。
另外,优先队列里最多同时存在n个元素,所以空间也是O(n)。整体空间复杂度是O(n+m),时间复杂度是O((n+m)log n),因为每个节点入队出队一次,每次操作是log n。
4.5 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 样例输出与答案不符 | 用了正着贪心 | 手算样例验证 | 改用反图+最大堆 |
| 反图后结果仍不对 | 用了最小堆 | 检查优先队列类型 | 改用默认大顶堆 |
| 部分测试点WA | 输出格式问题 | 检查行末空格 | 控制最后一个数字后不输出空格 |
| 运行超时 | 清空数组方式不当 | 检查init函数 | 用vector clear代替memset |
| 内存超限 | 用了邻接矩阵 | 检查存图方式 | 改用邻接表 |
| 有环时死循环 | 没有环检测 | 检查cnt是否等于n | 加上cnt==n的判断 |
4.6 几个容易忽略的细节
第一个细节是节点编号从1开始还是从0开始。这道题一般是从1开始,但如果你习惯从0开始,要注意转换。我建议直接按题目给的编号来,不要自己转换,避免出错。
第二个细节是多组数据的输出。有些题目要求每组数据之间输出一个空行,有些不要。一定要仔细读题。我一般会先按不加空行写,如果WA了再试试加空行。
第三个细节是优先队列的清空。如果你在全局定义了一个优先队列,多组数据的时候需要手动清空。我一般把优先队列定义在函数内部,这样每次调用都是新的。
第四个细节是入度数组的初始化。反图的入度数组需要在建图之前清零,建图的时候累加。如果你忘了清零,上一组数据的入度会残留,导致结果错误。
5. 从这道题延伸出的拓扑排序思考
5.1 拓扑排序的两种贪心方向
这道题让我意识到,拓扑排序的贪心方向其实有两种:正向贪心和反向贪心。正向贪心是“从前往后填”,每次选一个当前可选的节点放到前面;反向贪心是“从后往前填”,每次选一个当前可选的节点放到后面。
这两种贪心适用于不同的目标。如果题目要求字典序最小,正向贪心(最小堆)是对的。如果题目要求小编号尽量靠前,反向贪心(最大堆+反图)是对的。如果题目要求字典序最大,正向贪心(最大堆)是对的。如果题目要求大编号尽量靠后,反向贪心(最小堆+反图)是对的。
我总结了一个简单的对应关系:
| 题目要求 | 贪心方向 | 堆类型 | 是否需要反图 |
|---|---|---|---|
| 字典序最小 | 正向 | 最小堆 | 否 |
| 字典序最大 | 正向 | 最大堆 | 否 |
| 小编号尽量靠前 | 反向 | 最大堆 | 是 |
| 大编号尽量靠后 | 反向 | 最小堆 | 是 |
这个表格不一定完整,但覆盖了常见的几种情况。遇到新题的时候可以先判断属于哪一类,然后套用对应的策略。
5.2 反图操作的通用性
反图这个操作在拓扑排序里很常见,但不仅限于拓扑排序。在图论里,反图经常用来把“从起点出发”的问题转化成“从终点出发”的问题。比如最短路问题里,有时候把图反过来跑一遍会更方便。再比如强连通分量的问题里,反图也是常用工具。
这道题的反图操作给我最大的启发是:当正向贪心遇到困难的时候,不妨试试反向。很多时候,反向操作能把一个复杂的全局优化问题转化成一个简单的局部贪心问题。这个思路在算法设计里很通用,值得记住。
5.3 优先队列的选择
优先队列的选择取决于你要最大还是最小。C++的priority_queue默认是大顶堆,取最大。如果要取最小,需要指定比较函数。我见过很多人在这里搞混,包括我自己。建议写代码的时候把优先队列的类型显式写出来,比如priority_queue<int, vector<int>, greater<int>>表示小顶堆,这样一眼就能看出是取最大还是最小。
另外,如果节点编号不是整数,或者需要按其他规则排序,可以自定义比较函数。但在这道题里,编号就是整数,直接用默认的就行。
5.4 时间复杂度分析
这道题的时间复杂度是O((n+m)log n)。每个节点最多入队一次、出队一次,每次入队出队是O(log n)。每条边最多被遍历一次,遍历的时候可能触发入队操作,所以总的时间是O((n+m)log n)。对于n和m都是10^5的数据,这个复杂度完全够用。
空间复杂度是O(n+m),主要是邻接表和入度数组。优先队列最多存n个元素,也是O(n)。整体空间也在可接受范围内。
5.5 与其他拓扑排序题目的对比
普通的拓扑排序题目一般只要求输出任意一个合法序列,或者字典序最小的序列。这道题的难点在于它的目标不是字典序最小,而是“小编号尽量靠前”。这个目标更复杂,需要反向思考。
我做过另一道类似的题目,要求是“大编号尽量靠后”,其实和这道题是等价的。还有一道题要求“编号小的尽量靠后”,那就需要正向贪心+最大堆。这些题目看起来差不多,但贪心方向完全不同,很容易搞混。建议多做几道对比题,把各种情况都练一遍。
6. 实操中的经验与避坑指南
6.1 先手算再写代码
这道题我最大的教训就是:不要一上来就写代码。先拿纸和笔,把样例手算一遍,理解题目的要求到底是什么。我一开始就是直接写正着贪心,写完才发现理解错了题意。后来手算了几组数据,才慢慢想清楚“小编号尽量早”和“字典序最小”的区别。
手算的时候可以画个图,把约束关系标出来,然后尝试不同的序列,看看哪个满足要求。这个过程虽然慢,但能帮你建立直觉。等你把直觉建立起来了,再写代码就水到渠成了。
6.2 用对拍验证思路
如果你不确定自己的思路对不对,可以写一个暴力程序来对拍。暴力程序可以枚举所有合法的拓扑序,然后按题目要求选出最优的。虽然暴力程序的时间复杂度很高,但对于小数据是够用的。用暴力程序生成小数据,然后和你的贪心程序对比,如果结果一致,说明思路大概率是对的。
我写对拍的时候一般用Python写暴力,因为Python的排列组合很方便。然后用C++写贪心,两个程序跑同样的数据,对比输出。这个方法帮我发现了不少隐藏的错误。
6.3 注意多组数据的清空
多组数据的题目最容易出错的地方就是清空。我一般会写一个init函数,把所有用到的全局变量都重置一遍。清空的时候要注意:邻接表要清空,入度数组要清空,答案数组可以不用清空(因为会被覆盖),优先队列如果是全局的也要清空。
我习惯把优先队列定义在函数内部,这样每次调用都是新的,不用手动清空。但如果你把优先队列定义在全局,一定要记得在每组数据开始前清空。
6.4 输出格式的细节
输出格式的细节很容易被忽略,但往往就是这些细节导致WA。我总结了几点:最后一个数字后面不要有空格;每组数据输出一行;如果题目要求每组之间有空行,就在每组输出后加一个空行,但最后一组后面不要加。
这些细节看起来很小,但在竞赛里就是WA和AC的区别。建议在本地测试的时候把输出保存到文件,然后用文本编辑器检查格式。
6.5 关于反图的直觉培养
反图这个操作第一次见会觉得很不自然,但用多了就会发现它其实很符合直觉。你可以这样想:正向的约束是“a在b之前”,反向的约束就是“b在a之后”。如果你从后往前安排序列,那么“b在a之后”就意味着“b应该排在a的后面”,也就是“b应该更晚被安排”。所以反向贪心的时候,你每次选一个“应该更晚被安排”的节点放到当前最后的位置。
这个直觉需要多做题才能培养起来。我建议找几道类似的题目,都用反图的方法做一遍,做多了自然就有感觉了。
6.6 代码模板的整理
这道题做完之后,我把拓扑排序的几种变体整理成了一个模板。包括正向最小堆、正向最大堆、反向最小堆、反向最大堆四种情况。每次遇到新题,先判断属于哪一种,然后直接套模板。这样能节省很多时间,也能减少出错。
模板里我一般会包含:建图函数、拓扑排序函数、输出函数、清空函数。建图函数负责读入数据并构建反图,拓扑排序函数负责跑优先队列,输出函数负责倒序输出,清空函数负责多组数据的重置。这样结构清晰,不容易出错。
6.7 一个容易忽略的边界情况
如果n=1,也就是只有一道菜,没有约束。这时候反图里只有一个节点,入度为0,优先队列取出它,答案就是它本身。倒序输出还是它。这个情况一般不会出错,但如果你在代码里有什么特殊判断,可能会漏掉。
还有一种情况是m=0,也就是没有约束。这时候所有节点入度都是0,优先队列会按编号从大到小取出所有节点,倒序输出就是从1到n。这个结果是对的,因为小编号确实尽量靠前了。
6.8 关于优先队列的性能
优先队列的每次操作是O(log n),对于10^5的数据,log n大约是17,所以总操作次数大约是10^5 * 17,完全在可接受范围内。但如果你用的不是优先队列而是每次线性扫描找最大,那复杂度就变成O(n^2),会超时。
我见过有人用set来代替优先队列,其实也可以,set的插入删除查找都是O(log n),和优先队列差不多。但set支持删除任意元素,优先队列只能删除堆顶。在这道题里只需要删除堆顶,所以优先队列更合适。
6.9 调试技巧:打印中间状态
如果你实在找不到错误,可以在代码里加一些打印语句,把每一步的选择和入度变化打印出来。比如每次从优先队列取出一个节点的时候,打印这个节点的编号和当前所有入度为0的节点。这样你能清楚地看到贪心的过程,判断是否符合预期。
我调试这道题的时候就是这么做的。打印出来之后发现,正着贪心在第二步选了一个编号较小的节点,导致后面小编号的节点被堵住了。而反着贪心每次选最大的,就不会有这个问题。
6.10 从错误中学习
这道题我前前后后交了五六次才AC。前几次都是思路错误,后来思路对了但格式错了,再后来格式对了但多组数据清空有问题。每一次错误都让我对拓扑排序的理解更深一层。
我觉得做算法题最重要的不是一次做对,而是从错误中学习。每次WA之后,不要急着改代码,先想清楚为什么错了。是思路错了,还是实现错了,还是格式错了。想清楚了再改,这样才能真正进步。
7. 拓扑排序的更多应用场景
7.1 任务调度与依赖管理
拓扑排序最直接的应用就是任务调度。比如一个项目有很多任务,任务之间有依赖关系,你需要安排一个执行顺序。如果要求“编号小的任务尽量先执行”,就可以用这道题的方法。如果要求“总完成时间最短”,那就需要更复杂的调度算法。
在实际的构建系统里,比如编译一个大型项目,源文件之间有依赖关系,编译顺序就是一个拓扑排序。如果要求“先编译小的文件”,也可以用类似的思路。
7.2 课程安排与学习路径
另一个常见的应用是课程安排。大学里很多课程有先修要求,比如“学数据结构之前必须先学程序设计”。如果要求“先学编号小的课程”,就可以用这道题的思路。当然,实际的课程安排还要考虑学期、学分等因素,但拓扑排序是基础。
7.3 数据处理流水线
在数据处理里,多个处理步骤之间有依赖关系,比如“清洗数据之后才能做特征提取”。拓扑排序可以用来确定处理顺序。如果要求“先处理数据量小的步骤”,也可以用类似的贪心策略。
7.4 版本控制与依赖解析
软件包管理里,包之间有依赖关系,安装顺序就是一个拓扑排序。如果要求“先安装版本号小的包”,也可以用这道题的方法。当然,实际的包管理还要考虑版本冲突、循环依赖等问题,但拓扑排序是核心。
7.5 项目管理与关键路径
项目管理里的关键路径法(CPM)也用到拓扑排序。先确定任务之间的依赖关系,然后拓扑排序,再计算每个任务的最早开始时间和最晚开始时间,找出关键路径。如果要求“优先安排编号小的任务”,也可以在拓扑排序的时候用优先队列。
这些应用场景看起来差别很大,但核心都是“有向无环图上的顺序安排”。掌握了拓扑排序的几种变体,就能应对大部分场景。
8. 写在最后的一些个人体会
这道题让我对贪心算法有了新的认识。以前我觉得贪心就是“每次选当前最好的”,但这道题告诉我,贪心的方向很重要。同样的贪心策略,正着用和反着用,结果可能完全不同。当你发现正向贪心不对的时候,不妨试试反向。
另外,这道题也让我意识到“等价转化”的威力。把“小编号尽量靠前”转化成“大编号尽量靠后”,再转化成“反图上的字典序最大拓扑序”,每一步转化都让问题变得更简单。这种转化思维在算法设计里非常重要,值得反复练习。
最后说一个我个人的小习惯:每做完一道有代表性的题目,我都会写一篇解题笔记,把思路、代码、坑点都记下来。这道题的笔记我写了大概两千字,包括正着贪心为什么错、反着贪心为什么对、代码的每个细节、常见的错误。后来再遇到类似的题目,翻一翻笔记就能快速回忆起来。这个习惯帮我节省了很多时间,也让我对算法的理解越来越深。
如果你也在学拓扑排序,我建议你把这题多做几遍,正着做一遍,反着做一遍,对比一下结果。然后把代码整理成模板,以后遇到类似的题目直接套用。拓扑排序本身不难,难的是理解题目背后的要求,选择正确的贪心方向。希望这篇内容能帮你把这块搞清楚。