1. 反悔贪心
贪心本质上是不可能反悔的,这和贪心算法的定义是矛盾的:贪心每次都选取当前的最优解,所以一旦反悔了,被反悔的那次选择就不叫贪心了。
但有时候因为题目的特殊性质,在明确了一个贪心算法之后,我们使用数据结构记录下每次的贪心选择,在之后做出贪心选择时查询记录,如果不满足“全局贪心”的结论,就置换贪心选择,称为反悔贪心。
1.1. LC 1642 可以到达的最远建筑
贪心的思路是:对于小的高度差用砖块,对于大的高度差用梯子。这是一个很直观的结论,用反证法也能轻易证明。这里出于直观就不写严谨的数学证明了。
假设存在一个策略,其中存在至少一次在使用梯子和使用砖块时,前者对应的高度差小于后者。形如(b,x)和(l,y),其中x>y。那么我们交换在这两次中使用梯子和使用砖块的时机,也即(b,y)和(l,x),既然y个砖块能满足后者的高度差,那么对于x>y显然能满足。而梯子本身是对任意高度差都适用的,因此对于前者,交换后仍满足。
因此对于小的高度差用砖块,对于大的高度差用梯子,一定不比反过来差。但反之不亦然。则贪心思路正确。
这里所谓的“全局贪心”,就是对于经历过的所有高度差,小的用砖大的用梯。但是线搜的时候,我们可不知道当前的高度差属于“小”还是“大”。因此需要全局记录这些高度差,以便后续反悔。
思路是,记录所有使用梯子时的高度差,当使用砖块时,可以查询梯子对应的高度差看有没有更小的选择,如果有则置换梯子和砖块的使用。
这里的trick是,上来无论是什么高度差,都用梯子。然后把这些高度差加入一个小根堆中,作为可以用来置换的选择。当梯子用完之后,需要使用砖块时,我们比较当前小根堆的堆顶和本轮高度差,如果堆顶更小,说明之前的某次使用梯子的场景其实应该使用砖块。pop出堆顶,尝试使用砖块踏过那一次的高度差。如果成功,则还要将本轮的高度差置入堆(因为堆本身维护的是梯子对应的高度差)。
import java.util.PriorityQueue;
class Solution {
public int furthestBuilding(int[] heights, int bricks, int ladders) {
PriorityQueue<Integer> pq = new PriorityQueue<>(Integer::compare);
int diff,ans;
ans = 0;
for(int i=1;i<heights.length;i++){
diff = heights[i] - heights[i-1];
if(diff<=0){
ans++;
}else{
// 先用梯子,砖块不够用了开始置换
if(ladders>0){
ladders--;
pq.offer(diff);
ans++;
}else{
// 置换,检查堆顶高度差<当前高度差,不然没有任何置换的必要
if(pq.isEmpty() || pq.peek()>= diff){
if(bricks>=diff){
bricks-=diff;
ans++;
}else{
return ans;
}
}else{
if(bricks < pq.peek()){
return ans;
}
bricks -= pq.poll();
// diff 对应了梯子
pq.offer(diff);
ans++;
}
}
}
}
return heights.length-1;
}
}
1.2. LC 3049 标记所有下标的最早秒数Ⅱ
历史最难的题。灵神估分3100。
-
首先这题和3048肯定都是能用二分的,因为答案具有单调性
-
灵神问了一个问题:在以下三种操作中,哪个最“紧急”?
- 减一
- 置零(能置数肯定是选0的)
- 标记
在这个题中,是置零最紧急。因为置零的位置是根据changeIndices来决定的,而另外两个都是你想选哪里就选哪里,只要在总时长用掉时/前能把所有的都标记上就可以了。而置零是过了这个村没这个店了。
和3048一样,需要选择倒着遍历操作数组changeIndices。为什么不能正着,举个例子。如果正着遍历到一个可以置零的操作位置,那么是选择置零还是不置零。如果置零了,就需要抢占后续一个位置去标记,导致后续的某个可以置零的位置无法操作了,就只能慢慢减一,导致超时。
-
下一个问题:倒着遍历遇到能置零的位置的话,要置零吗?如果在比较靠后的位置置零,那么可能就没时间标记了。如果一个位置上需要的慢慢减一的操作次数太多的话,如果把置零和标记的机会让给后面的其他位置,就会导致这个位置超时。所以怎么平衡置零与否是这题的关键。
-
那么什么情况下置零呢?这个就用到反悔贪心。首先,如果这个位置上的操作次数≤1,那么就无所谓置零还是减1了,因为对于1来说二者都一样,对于0来说不需要操作。而在这之外,我们就选择在能够置零的所有机会中选择时间点最早的一个置零,这能给后面尽可能多的让出标记的时间(这就导致3中说的,后面位置抢占置零和标记的机会,前面操作次数大的可能就超时了),这个时候我们就要维护我们贪心的记录,当发现到某个位置没有剩余的时间了,那么就要反悔。
-
具体怎么反悔呢?很直观的是,可以选择当前贪心记录中减一操作次数最小的,让它让出自己的置零和标记机会,转而变成减一并标记。
-
在具体实现上,采用cnt记录能够用来给减一和标记操作的剩余用时。如果置零了,也要消耗cnt中的1个单位,因为要标记一次,并且逐渐减一并标记的总用时要减掉这些时间。如果反悔了,那么就让出了两次机会,cnt+=2;最终如果cnt≥剩余的逐渐减一并标记的剩余时间。那么就成功了。
import java.util.Arrays;
import java.util.PriorityQueue;
class Solution {
public int earliestSecondToMarkIndices(int[] nums, int[] changeIndices) {
int n = nums.length;
int m = changeIndices.length;
if(n>m){
return -1;
}
long least = n; // 标记和减一需要的总操作次数
for (int num : nums) {
least += num;
}
int[] firstT = new int[n];
Arrays.fill(firstT,-1);
for (int i = m-1; i >= 0; i--) {
firstT[changeIndices[i]-1] = i;
}
int l,r,mid,ans;
ans = -1;
l = n;
r = m+1;
while(l<r){
mid = ((r-l)>>>1)+l;
if(check(nums,changeIndices,firstT,least,mid)){
ans = mid;
r = mid;
}else{
l = mid+1;
}
}
return ans;
}
private boolean check(int[] nums,int[] changeIndices,int[] firstT,long least,int mid){
int cnt = 0; // 留给逐渐减一及其对应标记的总时间
PriorityQueue<Integer> pq = new PriorityQueue<>();
for(int i=mid-1;i>=0;i--){
int j = changeIndices[i]-1; // 哪一个位置可以置0
int v = nums[j]; // 这个位置如果要逐渐减一要用掉多少操作次数
if(v<=1 || i!=firstT[j]){
// 只需要操作至多一次 或者 不是把把nums[j]置0的所有机会中最靠前的
cnt++;
continue;
}
// 需要反悔
if(cnt==0){
if(pq.isEmpty() || v<=pq.peek()) {
cnt++; // 当前的这个逐渐减一比 反悔堆堆顶的逐渐减一要快,就没必要反悔
continue;
}
least += pq.poll()+1;// 反悔
cnt += 2; // 反悔的置0和标记的时间就解放出来了
}
least -= v+1; // 逐渐减一和标记的总时间减掉
cnt--; // 用直接置零和标记替代 注意本次是置零 cnt--的减一是标记
pq.offer(v);
}
return cnt>=least;
}
}
这里举个例子好了:
nums = [3,2,3]
changeIndices = [1,3,2,2,2,2,3]
鉴于答案就是6,所以我们玩的极限一点,就看前6个操作单位好了。
也就是:
nums = [3,2,3]
changeIndices = [1,3,2,2,2,2]
-
i=6,非最早
-
i=5,非最早
-
i=4,非最早
-
i=3,最早,且操作次数>1
-
i=2,最早,且操作次数>1
-
i=1,最早,且操作次数>1
-
i=6 cnt=1 pq=null
-
i=5 cnt=2 pq=null
-
i=4 cnt=3 pq=null
-
i=3 cnt=2 pq=[2](这个2是nums[ changeIndices[3] ],不是changeIndices[3])
-
i=2 cnt=1 pq=[2,3]
-
i=1 cnt=0 pq=[2,3,3]
最后需要逐渐减1并标记的等于3+2+3+3-(3+2+3+3)=0,cnt≥0。于是成功。
这个例子还是没出现反悔的情况。有机会可以。
1.3. LC 2813 子序列最大优雅度
好久没更刷题记录噜。
这题反悔贪心+哈希表。先逆序排序,然后把前k个累积profit求和,并且维护一个以category为key的哈希表。
随后开始反悔,继续从后面n-k个的第一个遍历。
- 如果其category已经存在,则他前面的前辈都比他大,没必要反悔。
- 如果其category不存在,反悔,寻找前辈中最小的非单独的profit,踢走。注意一定要非单独!假设你找到的前辈中最小的是v,但是对应的category里就只有他一个,你把他踢了本质上distinct_category没有增加,反而还降profit。
可能会疑惑反悔遍历是不是需要判停/跳过。实际上不需要。因为这个东西要做就一直做下去,两个原因:
- 首先你不知道一直反悔下去有没有可能更大
- 其次,你不可能跳过某个元素不反悔。注意我们逆序排序了,所以如果你要跳过前面某个元素不反悔,更没有理由用后面的元素反悔,因为同样对distinct_category增1,但是后面的元素的profit更小。
from typing import List
import heapq
class Solution:
def findMaximumElegance(self, items: List[List[int]], k: int) -> int:
m = {}
n = len(items)
res = 0
items = sorted(items,key = lambda tup:tup[0])
# print(items)
for i in range(k):
index = n-1-i
cat = items[index][1]
profit = items[index][0]
if cat not in m:
m[cat] = [profit]
else:
heapq.heappush(m[cat],profit)
res += profit
# print(m)
res += pow(len(m),2)
if len(m)==k:
return res
ans = res
for i in range(n-k-1,-1,-1):
cat = items[i][1]
profit = items[i][0]
if cat in m:
continue
else:
min_k = -1
min_v = 1_000_000_000+1
for k,v in m.items():
# print(v)
if len(v)==1:
continue
if min_v > v[0]:
min_k = k
min_v = v[0]
if min_k!=-1:
ele = heapq.heappop(m[min_k])
m[cat] = [profit]
res += 2*len(m)-1-ele+profit
ans = max(ans,res)
return ans
这里维护和的时候,每次反悔加上2*distinct_category-1-ele+profit。
- ele是被反悔的,profit是反悔的元素的profit。这个是profit的置换
- 对于distinct_category,假设原先是c,现在是c+1,那么(c+1)^2-c^2 = 2c+1 = 2(c+1)-1
复杂度:空间O(k)没啥好说的,时间O(n)的遍历里套了一个O(k)的遍历以及O(logk)的小根堆维护,总体O(n(k+logk)) = O(n*max(k,logk)) = O(nk)
To Counter That…
这样写有点繁,其实数据结构没必要哈希表套小根堆,可以直接哈希集合加列表维护前k大。
- 当新增一个category,不在列表中维护
- 当category已经存在,再push到列表中
这样本来1个的在列表中变0个,2个的变1个…不用担心把自己单独一个category的前辈踢走了。
class Solution:
def findMaximumElegance(self, items: List[List[int]], k: int) -> int:
items.sort(key=lambda x: -x[0])
tot = 0
vis = set()
dup = []
for p, c in items[:k]:
tot += p
if c not in vis:
vis.add(c)
else:
dup.append(p)
ans = tot + len(vis) ** 2
for p, c in items[k:]:
if c in vis or not dup:
continue
vis.add(c)
tot += p - dup.pop()
ans = max(ans, tot + len(vis) ** 2)
return ans
复杂度:空间O(k),时间O(n),不用维护小根堆和遍历找最小(因为append根据逆序尾插尾出的,每次出最小非单独),遥遥领先
2. 树上贪心
2.1. 双周赛120 T3 LC 2973 树中每个节点放置的金币数量
事后诸葛亮的说法是,我被这题吓到了,因为这把打烂掉了。T3一直卡着出不来,信心直接没了。其实这题补的时候发现难度不高(就是对java选手不友好)。
对于有正有负的长度≥3的数组,从中选三个节点,乘积最大,从数学上来说只可能:
- 两个最小的负数,一个最大的正数
- 三个最大的正数
这样只要判断5个数就可以了,其他数压根不关心,所以这题排序的话耗时没那么严重,因为最多排序排5个元素。
思路上来说:
- 深搜子树,子树返回其本身以及其子树的节点列表。当返回数目大于5,挑两个最小的和三个最大的组成列表返回。不用理会是正是负,因为我们挑的是最大值。
- 拿到5个数,两种组合加上一个兜底的0,更新结果数组
- 返回结果数组即可。
无向树深搜防环的trick是,深搜时加一个前面的父节点,邻接表判到父节点就直接跳过防入环。由于树确定了整棵树的根后,不可能有节点拥有两个父节点(这会出现环,很简单的证明,不证了),所以判单一父节点足够防环了。
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
class Solution {
static long[] ans;
static int[] c;
public long[] placedCoins(int[][] edges, int[] cost) {
int n = cost.length;
c = cost;
// 建图 邻接表
List<Integer>[] g = new ArrayList[n];
Arrays.setAll(g,e->new ArrayList<Integer>());
for (int[] edge : edges) {
g[edge[0]].add(edge[1]);
g[edge[1]].add(edge[0]);
}
ans = new long[n];
Arrays.fill(ans,1);
dfs(g,0,-1);
return ans;
}
private List<Integer> dfs(List<Integer>[] g,int node,int fa){
// 返回给父节点的列表
List<Integer> children = new ArrayList<>();
children.add(c[node]);
for (Integer child : g[node]) {
if(child!=fa){
children.addAll(dfs(g,child,node));
}
}
Collections.sort(children);
int size = children.size();
if(size >=3){
ans[node] = Math.max(0,Math.max((long)children.get(0)*children.get(1)*children.get(size -1), (long) children.get(size - 1) *children.get(size -2)*children.get(size -3)));
}
List<Integer> ret = new ArrayList<>();
if(size > 5){
// 可以List.of顶掉,java这门语言嘛,写起来有爽点,但很多时候笨得出奇
// 隔壁python3 ret = children[:2] + children[-3:]秒了
ret.add(children.get(0));
ret.add(children.get(1));
ret.add(children.get(size-1));
ret.add(children.get(size-2));
ret.add(children.get(size-3));
}else{
ret = children;
}
return ret;
}
}
由于边的数量恒为n-1,所以其实是O(n)的复杂度。(排序排了常数长度,等于常数时间不考虑)
这题真的是信心被打没了,其实真的可以尝试做的(懊悔
3. 贪心DP
3.1. LC 1553 吃掉N个橘子的最少天数
这是个好题。贪心我做过,DP我也做过,但是俩合起来的,我第一次做,就这题。
问题:如果现在有7个橘子,怎么吃才最快?
很显然不会傻傻的一个一个吃,吃7次。肯定是想办法吃到2或3的倍数,然后一口气吃一半,或者吃掉三分之二。
但是你说是吃到2的倍数对半砍还是吃到3的倍数砍掉三分之二哪个快,这个不好说。
所以思路就是:每次吃到2或3的倍数,然后按那个值对半砍或者吃掉三分之二,继续递归。
可以记搜优化。
import java.util.HashMap;
import java.util.Map;
class Solution {
Map<Integer,Integer> memo = new HashMap<>();
public int minDays(int n) {
if(n<=1){
return n;
}
Integer cost = memo.get(n);
if(cost!=null){
return cost;
}
int res = Math.min(minDays(n/2)+n%2,minDays(n/3)+n%3)+1;
memo.put(n,res);
return res;
}
}
4. General
4.1. LC 2589 完成所有任务的最少时间
贪心思路:尽可能让任意两个区间重叠的部分变多。
按照结束时间排序,每个任务查询区间内已经使用的点,任务实际的执行时间就是总时间减去已经使用的时间(因为可以并行),再从剩下可用的时间点中从后往前安排时间。
import java.util.Arrays;
import java.util.Comparator;
class Solution {
public int findMinimumTime(int[][] tasks) {
Arrays.sort(tasks, Comparator.comparingInt(o -> o[1]));
boolean[] rec = new boolean[2001];
Arrays.fill(rec,false);
if(tasks.length==0){
return 0;
}
int duration = tasks[0][2];
int back = tasks[0][1];
while(duration>0){
rec[back--] = true;
duration--;
}
int ans = tasks[0][2];
for(int i=1;i<tasks.length;i++){
int end = tasks[i][1];
int start = tasks[i][0];
duration = tasks[i][2];
int cnt = nnz_range(start, end, rec);
duration = Math.max(0,duration-cnt);
ans += duration;
back = end;
while(duration>0){
if(rec[back]){
back--;continue;
}
rec[back--] = true;
duration--;
}
}
return ans;
}
private int nnz_range(int start,int end,boolean[] rec){
int res = 0;
for(int i = start;i<=end;i++){
if(rec[i]){
res++;
}
}
return res;
}
}
4.2. LC 1953 你可以工作的最大周数
唯一能产生影响的是最大值,如果最大值比剩下的所有数之和还大,那么只能剩下的全做完的同时,最大的做等量的周数;否则,可以全部做完。构造做完的方法是:每一轮每个任务做一个阶段,直到只剩下一个任务或全部做完。
class Solution {
public long numberOfWeeks(int[] milestones) {
long sum = 0L;
int max = 0;
for (int milestone : milestones) {
sum+=milestone;
max = Math.max(max,milestone);
}
return sum>=max*2L?sum:(sum-max)*2+1;
}
}