【基础算法】- 贪心
贪心
定义
贪心算法适用于最优子结构问题。意思是问题在分解成子问题来解决时,子问题的最优解能递推到最终问题的最优解。常见的符合这种性质的问题如:
- 「我们将 XXX 按照某某顺序排序,然后按某种顺序(例如从小到大)选择。」
- 「我们每次都取 XXX 中最大/小的东西,并更新 XXX。」
但比如在大部分只能用动规求解的问题上,贪心算法目光短浅的后果就是答案错误(如石子合并)。所以碰到动规题目以为有贪心做法时最好三思有无反例。
而在考场实际运用贪心时通常都是感性证明,但其实严格证明贪心的话会使用 反证&归纳法 进行推导。
过程
一般我们会有两种解法:邻项交换(排序)&反悔贪心 。
- 前者一般可以用反证法,证明出交换方案中相邻元素后答案不优后,将最终状态通过定义 \(cmp\) 函数简单 \(sort\) 比较后得出。或者给出的信息有良好性质,按某种顺序依次选取即可。
- 后者分为人工模拟和自动策略,都要求在发现亏损后及时反悔,将当时错误决策撤回,而这个“错误决策”在需人工模拟的问题中常常对应为已作选择中最XXX的一个,使用堆优化。或者制定一个完全的策略,使得程序对特情自动处理,执行反悔策略。一般需要通过做差巧妙达成目的。
Johnson算法
这里以这个非常典型的邻项交换(排序)类贪心算法为例展示解决贪心题的过程。问题描述为:有 \(M_1、M_2\) 两台机器和 \(n\) 个作业,每个作业 \(i\) 要求先花 \(a_i\) 时间在 \(M_1\) 上加工,然后花 \(b_i\) 时间在 \(M_2\) 上加工。
自然,我们有一个待加工的集合 \(S=\{J_1,J_2,...,J_n\}\)。特殊地,我们考虑这样一个事实:在工序1完成之前,工序2不可能开始,但在某一作业的工序1完成之后,工序2 可能 因为 之前作业 的 \(b\) 耗时过久,作业积压 导致不得不延缓该作业工序2开始时间。我们设这个 “工序1完成后,工序2还剩的积压作业完成时刻” 为 \(t\) 。
显然,初始时 \(t=0\) 。简单模拟一下,不难发现取出一个作业进行操作后 \(t\) 可能对应两种情况:这一作业的 \(a\) 耗时本身太久,\(M_2\) 已经闲置对下一作业影响只有自身的 \(b\),\(t\) 赋为 \(b+0\);\(M_2\) 在这一作业的 \(a\) 时间里不停工作,但仍然多出了 \(t-a\) 的积压作业,现又叠加上该作业影响,\(t\) 赋为 \(b+t-a\) 。综上,\(t\) 变化为 \(b+\max (t-a,0)\)。
使用类似动规的状态定义和转移方式:设 \(f(S,T)\) 表示当前待加工集合为 \(S\),对应 \(t\) 的值为 \(T\) 的答案。最终状态为 \(f(\varnothing,t)=t\),状态转移为:\(f(S,T)=\min \bigg\{a_x+f\Big(S-\{J_x\},\; b_x+\max (T-a_x,0)\Big)\bigg\}\quad (J_x\in S)\) 。
使用邻项交换法,假设眼下最前的两个相邻作业:设前一个作业为 \(i\),后一个为 \(j\) 的顺序是最优的。自然,
我们将先后处理了 \(i、j\) 后的 \(T\) 表述为 \(T_{ij}\), \(f(S,T)\to a_i+a_j+T_{ij}\),
提出 \(b_i+b_j-a_i-a_j\), \(T_{ij}\to b_i+b_j-a_i-a_j+\max\{T,a_i,a_i+a_j-b_i\}\)。
应用邻项交换,同理可得 \(f^{'}(S,T)\to a_i+a_j+T_{ji}\),
\(T_{ji}\to b_i+b_j-a_i-a_j+\max\{T,a_j,a_i+a_j-b_j\}\)。
要求交换后答案不优,\(f(S,T)\le f^{'}(S,T)\) 。
所以有:\(\max\{T,a_i,a_i+a_j-b_i\}\le \max\{T,a_j,a_i+a_j-b_j\}\)。
于是有:\(a_i+a_j+\max\{-b_i,-a_j\}\le a_i+a_j+\max\{-b_j,-a_i\}\),\(\min\{b_j,a_i\}\le \{b_i,a_j\}\)。
那么得到的结论就是答案序列满足前式即可。
应用
诸如Prim、Dijkstra等图论算法都会用到贪心思想,但这里只讨论单纯的贪心算法。对贪心的常见题型(以洛谷上基本都有n倍经验为标准)进行概括:
例题
这里收录一些推式子得出结论的邻项交换法或排序法贪心神仙题,解法常常独树一帜。
以下主要归纳典型贪心例题及方法:
货币支付
用最少的基本货币凑出指定金额
能用大钞就先用大钞,直到只剩零头了再换小钞。
排队接水
有一个水龙头,若干人有接水时间,求最小等待总和
接得快的先接。例题:排队接水。只是简单贪心,但有其动规变种:
有两个食堂窗口,若干人有打饭时间和吃饭时间,求所有人吃完的最早时刻
吃得慢的先打。例题:午餐。以 \(f[i,j]\) 表示前 \(i\) 个人在 \(1\) 号窗口打饭总时间 \(j\) 的答案。通过打饭时间前缀和做差得到在 \(2\) 号窗口打饭总时间 \(k\)。
- 将第 \(i\) 个人放在 \(1\) 号窗口:
if(j>=s[i].a) f[i][j] = min(f[i][j], max(f[i-1][j-s[i].a], j+s[i].b));
- \(f[i-1][j-s[i].a]\) 是 \(i\) 号人打饭+吃饭的时间不足 \(i-1\) 号人吃饭的时间, 所以没有影响。 \(j+s[i].b\) 就是造成了影响。
- 将第 \(i\) 个人放在 \(2\) 号窗口:
f[i][j] = min(f[i][j], max(f[i-1][j], sum[i]-j+s[i].b));
这里也是一样的 (\(sum[i]-j\) 就是 \(k\))
区间厚度
多个区间,可能互相交错,求最大的“厚度”
经典入门题,左端点为 \(+1\),右端点为 \(-1\)。求最大前缀和。例题:[USACO06FEB] Stall Reservations S。
区间选点
多个区间,可能互相交错。选一些点,每个区间都要求至少包含其中若干。求最少的选点
每个区间都有指标。自然地,一个位置能满足越多区间越好。按右端点从左往右排序。这样容易发现一个区间与之后区间可能的重叠位置都会从这一区间右端点开始。故而贪心地从右端点开始选取即可,注意统计上之前已选点的影响。过程可用线段树优化,也有差分约束做法。例题:种树、元旦晚会、序列、Intervals、INTERVAL - Intervals。
点选区间
多个点,多个区间。一个点可以与包含它的区间配对。求最多的配对。
类似地,区间按右端点从左往右排序。维护点的可重集,每次贪心地选取满足条件的点中最左边的一个。例题:[USACO07NOV] Sunscreen G、[USACO17FEB] Why Did the Cow Cross the Road I。
区间覆盖
多个区间,选出若干完全覆盖目标段,求最少选择个数
对于每个结尾(初始为目标段左端点,之后取贪心选得区间的右端点),每次都选择左端点能“接上”的区间中右端点最大的。实现时按左端点排序以方便 \(O(n)\) 枚举。例题:[USACO04DEC] Cleaning Shifts S。有其动规变种:
多个区间,每个区间有花费。选出若干完全覆盖目标段,求最小花费
左端点排序后,定义出状态 \(f(i)\) 表示覆盖到 \(i\) 的最小花费。用线段树可以方便的在枚举区间时查询 \(f(l-1)\) 的值,并尝试用 \(f(l-1)+w\) 更新 \([l,r]\) 一段的答案。例题:[USACO05DEC] Cleaning Shifts S。
区间分配(区间选择)
每个位置都有容量。在给出的所有区间中选出尽量多的区间,使得每个位置被覆盖总数不超过容量
将区间按右端点从左到右,左端点从右到左排序,贪心选择,区间减一用线段树维护。特殊地,容量为1时问题就是选出最多不相交区间,右端点排序后直接选即可。而当所有位置容量相等时则可以排序后维护一个已选区间右端点集合,加入区间时如若左端点有前驱则直接将其替换为右端点,否则非要加入此区间一定不优,因为其右端点更大,例题:公平班车、牛舍分配、节目录音、自适应PVZ等。
基站覆盖
给定形态的一棵树,每个节点都可以设立基站。信号覆盖范围为 \(k\),求覆盖整棵树的最小基站数
树上的贪心问题。很明显,按节点深度排序。从底部向上更新,每个节点维护一个到最近基站的距离 \(len\) 。枚举当前节点时先通过祖先节点更新 \(len\)。若未被覆盖(\(len>k\))则贪心找到祖先中深度最浅且能覆盖该节点的节点(\(k\) 级祖先)设立基站,并更新覆盖范围内节点的 \(len\)。因为两种更新都会遍历一条长为 \(k\) 的链,所以复杂度 \(O(nk)\)。例题:[HNOI2003] 消防局的设立(k=2)、[USACO08JAN] Cell Phone Network G(k=1)、将军令(k<=20)。有其动规变种:
给定形态的一棵树,每个节点都可以设立基站,代价为 \(k\)。信号覆盖范围为 \(1\),求覆盖整棵树的最小代价
使用树形DP,在带权后我们需要分讨三种情况:1.选,被自己覆盖;2.不选,被儿子覆盖;3.不选,被父亲覆盖。转移时 \(f[u][0] = \sum_{v\in son(u)}\min\{f[v][0],f[v][1],f[v][2]\}\;+k_u\),\(f[u][1]=\sum\min\{f[v][1],f[v][0]\} \;+[没有选f[v][0]]\min\{f[v][0]-f[v][1]\}\),\(f[u][2]=\sum\min\{f[v][1],f[v][0]\}\)。最后输出 \(\min\{f[root][0],f[root][1]\}\)。
负载平衡
有 \(n\) 个仓库位于一环上。每个仓库储量不同,每次可以在相邻仓库间搬运,求使各仓库储量相同的最小搬运量
容易发现目标为平均数 \(ave\)。每个仓库原有 \(A_i\),设每个点 \(i\) 搬到左边点的数量为 \(X_i\) ,有规律 \(A_i+X_{i+1}-X_i=ave\),\(X_i=(i-1)\times ave-\sum_{j=1}^{i-1}A_j+X_1\)。设 \(C_i=\sum_{j=1}^iA_i-i\times ave\),有规律 \(X_i=X_1-C_i\)。最小化 \(|X_1|+|X_2|+...+|X_n|\),即最小化 \(|X_1-C_1|+|X_1-C_2|+...+|X1-C_n|\)。其本质是给出 \(n\) 个点,试找一点到各点距离和最小。初中学绝对值时的经典问题,找中位数即可。例题:[HAOI2008] 糖果传递、负载平衡问题。
缓存交换
缓存只有有限的 \(m\) 容量,给出接下来 \(n\) 次单元访问要求,求最少缺失次数
记录下每次访问单元之后最近的一次出现位置,使用数组记录是否处于缓存中,用堆模拟缓存,内部按下一次访问的先后排序,并记录缓存内的存储量(因为可能更新后堆内同时存在多个同类,被覆盖后的元素会一直“淤积”在堆底,不能使用size())。满额需删除时自然弹出最不紧急的,留下下一次访问得近的。例题:[JSOI2010] 缓存交换、[POI2005] SAM-Toy Cars。
商店买卖
每天都会进货,每天都有订单。供大于求时就能交易。求一种交易方案使得成交的订单最多
最基础的反悔贪心,每天能交易就交易,不能就把要求最多的剔除。下面的任务调度其实是其变种。反悔策略基本相同。例题:[POI2012] HUR-Warehouse Store。
任务调度
每个任务都有时限和耗时。求一种安排使得能在对应时限前完成的任务总数最多
每个任务都有时限和奖励。求一种安排使得能在对应时限前完成的任务利润最大
每个任务都有时限和惩罚。求一种安排使得能在对应时限前完成的任务惩罚最小
维护一个 已选任务的堆 和 所用时间/所得利润,按时限先后排序,贪心选择能做的任务。没得选就把已选中 最耗时的/最不赚的 踢掉。因为已按时限排序,故而保证了删除一个后就一定符合时限条件了。其实这是一种典型的“性价比”思想。
也有不反悔的排序解法:先按代价排序,这样在处理一个任务时能做就做,且尽量安排到最靠后的空闲时刻。例:工作调度、建筑抢修、智力大冲浪等。
流水作业
有 A、B 两个生产线和 \(n\) 个作业,要求先经过 A 处理才能进入 B 。给出对每个作业完成 A、B 处理所需的时间 \(a_i、b_i\),求加工完的总时间最短的作业顺序。
Johnson 算法:对于 \(a<b\) 的作业集合 \(S\) 按 \(a\) 递增排序, \(b\le a\) 的集合 \(T\) 按 \(b\) 递减排序。先 \(S\) 后 \(T\) 作业最优。例题:加工生产调度。
选坑种树
在一条路上有 \(n\) 个坑可以种树。每个坑有其美观度,但土壤肥力有限,不能连续种树。给出 \(m\) 棵要种的树,求最美丽的种植方案。
反悔贪心的一种典型解法:在直接选择最赚的坑种下一棵树后可能不如选旁边两个优,故而维护一个记录左右相邻的坑位的双向链表和一个记录未选坑位的美观度的堆。每次选大根堆的根并将它及其左右标记为不可种。然后 将左右邻点权值和减去已选点权值后作为新点权值,将新点插回原来三点位置 。这就是一个自动贪心策略了。例:种树(环)、种树(链)、数据备份(点转线段)等。
如上例,选 \(7、7\) 一定比 \(1、2、9\) 更优。但是我们贪心选得了 \(9\)。为了留下反悔余地,我们新开一个权值为 \(7+7-9=5\) 的点代替 \(7、9、7\) 三点:
这样,我们自然又选得了5。
至此,所有点选完,算法结束。
复盘一下,不难发现人工选择 \(7、7\) 与自动选择 \(9、5\) 的结果是不变的,因为对于连续三点 \(a、b、c\),选 \(b\) 后插入了 \(a+c-b\)。一旦后面反悔,\(b+(a+c-b)=a+c\),不就等效于选了 \(a、b\) 了吗。且得益于双向链表,最后每个点的可选状态(以黑白区分)也相同,所以这种做法可以扩展开来,在两边继续加长甚至形成一个环。
股票买卖
给出每天股票价格。每天你可以买进一股股票,卖出一股股票,或者什么也不做。\(n\) 天之后卖出所有股票,使得这 \(n\) 天内赚的钱最多。
对于每一个形如「在第 \(i\) 天买入,在第 \(j\) 天卖出」的决策,假想出一股第 \(j\) 天的克隆股票,使得支持「在第 \(i\) 天买入,在第 \(j\) 天卖出,同时买入这个虚拟股票,并在第 \(k\) 天卖出」的操作,因为第 \(j\) 天卖出后立马选择买入,等价于「在第 \(i\) 天买入,在第 \(k\) 天卖出」了。\(j\) 反悔成 \(k\) 的操作得以实现。
于是,我们便设计出了一个新的算法:维护一个可重集合,代表「可选股票的价格」。从前向后遍历每一天,对于第 \(i\) 天,向集合中插入 \(price_i\),代表 \(price_i\) 这一股可选。再找到集合中最小的价格 \(price_j\),若 \(price_i>price_j\) 就有的赚。贪心选择「在第 \(j\) 天买入,在第 \(i\) 天卖出」,把答案加上 \(price_i−price_j\),并向集合中再次加入 \(price_i\),代表假想的反悔股票,并将 \(a_j\) 从集合中删除。我们可以使用堆维护这个集合。例题:低买高卖。
哈夫曼算法
给出 \(n\) 堆果子重量。每次你可以选择两堆合并,代价为重量的和。求合并成一堆的最小代价
经典的合并果子问题,维护可选堆的优先队列。每次选出最小的两个合并,将合并后的一堆再加入堆中。例题:合并果子(哈夫曼树)、荷马史诗(哈夫曼编码)。这其实是 \(2-\)叉哈夫曼树的经典运用,接下来将介绍哈夫曼树、\(k-\)叉哈夫曼树及哈夫曼编码。
哈夫曼树
简介
哈夫曼树是贪心算法的一个经典应用,最初是为解决 树的带权路径长度 问题(WPL)。
对于一棵具有 \(n\) 个带权叶结点的二叉树,其从根结点到各叶结点的路径长度与相应叶节点权值的乘积之和为该树的带权路径长度:\(WPL=\sum _{i=1}^nw_i\times l_i\) ,其中 \(w_i\) 表示第 \(i\) 个叶节点的权值,\(l_i\) 表示第 \(i\) 个叶节点的深度。
而构造出的二叉树中 WPL 最小的二叉树 称为 哈夫曼树。同理,哈夫曼树可以被拓展至 \(k-\)叉哈夫曼树,定义类比。
结构
对于哈夫曼树来说,其叶结点权值越小,离根越远,叶结点权值越大,离根越近,此外其仅有叶结点的度为 \(0\),其他结点度均为 \(k\)。
哈夫曼树永远是一棵 \(k-\)叉树,对于父亲节点,构造其权值为子节点权值之和。
性质
对于给定一组具有确定权值的叶结点,可以构造出不同的树,所以哈夫曼树可能并不唯一。这就是 同权不同构 。但是这只是保证它们的 WPL 相同,叶节点最大深度可能不同,注意题目是否对深度做出限制。
哈夫曼算法
哈夫曼算法用于构造一棵哈夫曼树,算法步骤如下:
- 初始化:由给定的 \(n\) 个权值构造 \(n\) 棵只有一个根节点的 \(k-\)叉树,得到一个 \(k-\)叉树集合 \(F\)。
- 选取与合并:从 \(k-\)叉树集合 \(F\) 中选取根节点权值 最小的 \(k\) 棵 \(k-\)叉树作为子树构造一棵新的 \(k-\)叉树,这棵新二叉树的根节点的权值为其所有子树根结点的权值和。
- 删除与加入:从 \(F\) 中删除作为子树的 \(k\) 棵二叉树,并将新建立的 \(k-\)叉树加入到 \(F\) 中。
- 重复 2、3 步,当集合中只剩下一棵树时,这棵树就是哈夫曼树。
注意:在构造哈夫曼树时,不难注意到 2、3 步会使根节点总数 \(n\) 减少 \(k-1\) 个,在构造完成后只会剩下一个根节点,所以一棵 完美哈夫曼树 应该满足 \(k-1|n-1\) ,否则贪心构造过程中可能将某一较大权值过早下放到较大深度,而造成较小深度出现空缺。这样肯定不符 \(k\) 叉树性质,答案不优。所以在哈夫曼树不完美时我们可以补位 \(k-1-((n-1)\mod(k-1))\) 个 \(0\) 权叶节点以保证构造出 完美哈夫曼树 。
哈夫曼编码
哈夫曼编码是针对字符使用频率,构造出的 最短不等长前缀编码 ,节省空间(用的多的字符编码短,自然空间占用就少)的同时避免歧义(前缀编码意即任一编码都不是其他任何编码前缀,保证解码唯一)。
哈夫曼树用于构造哈夫曼编码,构造步骤如下:
- 设需要编码的字符集为:\(S (|S|=n)\),给出各个字符在字符串中出现的频率。
- 以每个字符作为叶结点,出现频率作为叶结点的权值,构造一棵哈夫曼树。
- 规定哈夫曼编码树的第 \(k\) 分支代表 \(k-1\),则从根结点到每个叶结点所经过的路径组成的数字序列即为该叶结点对应字符的编码。
注意:在出现同权不同构时应以根节点高度为第二关键字比较,使得构造出来的哈夫曼树高度最小,对应地,最长编码长度也就最短。