关卡名 |
理解与贪心有关的高频问题 |
我会了✔️ |
内容 |
1.理解区间问题如何解决 |
✔️ |
2.理解字符串分割问题 |
✔️ | |
3.理解加油站问题如何解决 |
✔️ |
区间问题也是面试中经常遇到的情况,这类面试题目还挺讨巧的,很容易考察出应聘者到底会不会写代码。我们来看几个例子。两个区间所有可能的关系如下所示,对于所有区间的问题,我们都在大脑里装着这张图,然后再看题,会容易很多。
LeetCode252.给定一个会议时间安排的数组 intervals ,每个会议时间都会包括开始和结束的时间 intervals[i] = [starti, endi] ,请你判断一个人是否能够参加这里面的全部会议。
示例 1::
输入: intervals = [[0,30],[15,20],[5,10]]
解释: 存在重叠区间,一个人在同一时刻只能参加一个会议。
因为一个人在同一时刻只能参加一个会议,因此题目实质是判断是否存在重叠区间,将区间按照会议开始时间进行排序,然后遍历一遍判断后面的会议开始的时候是否前面的还没有结束即可。也就是上图中如果出现重叠的部分就直接返回false即可。
public boolean canAttendMeetings(int[][] intervals) {
// 将区间按照会议开始实现升序排序
Arrays.sort(intervals, (v1, v2) -> v1[0] - v2[0]);
// 遍历会议,如果下一个会议在前一个会议结束之前就开始了,返回 false。
for (int i = 1; i < intervals.length; i++) {
if (intervals[i][0] < intervals[i - 1][1]) {
return false;
}
}
return true;
}
LeetCode56.以数组 intervals 表示若干个区间的集合,其中单个区间为 intervals[i] = [starti, endi] 。请你合并所有重叠的区间,并返回一个不重叠的区间数组,该数组需恰好覆盖输入中的所有区间。
示例1:
输入:intervals = [[1,3],[2,6],[8,10],[15,18]]
输出:[[1,6],[8,10],[15,18]]
解释:区间 [1,3] 和 [2,6] 重叠, 将它们合并为 [1,6].
为了方便理解,我们将上述序列画成序列图:
和上一题一样,首先对区间按照起始端点进行升序排序,然后逐个判断当前区间是否与前一个区间重叠,如果不重叠的话将当前区间直接加入结果集,反之如果重叠的话,就将当前区间与前一个区间进行合并。
public int[][] merge(int[][] intervals) {
// 先按照区间起始位置排序
Arrays.sort(intervals, (v1, v2) -> v1[0] - v2[0]);
// 遍历区间
int[][] res = new int[intervals.length][2];
int idx = -1;
for (int[] interval: intervals) {
// 如果结果数组是空的,或者当前区间的起始位置 > 结果数组中最后区间的终止位置,说明不重叠。
// 则不合并,直接将当前区间加入结果数组。
if (idx == -1 || interval[0] > res[idx][1]) {
res[++idx] = interval;
} else {
// 反之说明重叠,则将当前区间合并至结果数组的最后区间
res[idx][1] = Math.max(res[idx][1], interval[1]);
}
}
return Arrays.copyOf(res, idx + 1);
}
LeetCode57,给定一个区间的集合,找到需要移除区间的最小数量,使剩余区间互不重叠。
示例 1::
输入: intervals = [[1,3],[6,9]], newInterval = [2,5]
输出: [[1,5],[6,9]]
解释: 新区间[2,5] 与 [1,3]重叠,因此合并成为 [1,5]。
示例 2::
输入: intervals = [[1,2],[3,5],[6,7],[8,10],[12,16]], newInterval = [4,8]
输出: [[1,2],[3,10],[12,16]]
解释: 新区间 [4,8] 与 [3,5],[6,7],[8,10] 重叠,因此合并成为 [3,10]。
本题就是上一题的再拓展,本题中的区间已经按照起始端点升序排列,因此我们直接遍历区间列表,寻找新区间的插入位置即可。具体步骤如下:
public int[][] insert(int[][] intervals, int[] newInterval) {
int[][] res = new int[intervals.length + 1][2];
int idx = 0;
// 遍历区间列表:
// 首先将新区间左边且相离的区间加入结果集
int i = 0;
while (i < intervals.length && intervals[i][1] < newInterval[0]) {
res[idx++] = intervals[i++];
}
// 判断当前区间是否与新区间重叠,重叠的话就进行合并,直到遍历到当前区间在新区间的右边且相离,
// 将最终合并后的新区间加入结果集
while (i < intervals.length && intervals[i][0] <= newInterval[1]) {
newInterval[0] = Math.min(intervals[i][0], newInterval[0]);
newInterval[1] = Math.max(intervals[i][1], newInterval[1]);
i++;
}
res[idx++] = newInterval;
// 最后将新区间右边且相离的区间加入结果集
while (i < intervals.length) {
res[idx++] = intervals[i++];
}
return Arrays.copyOf(res, idx);
}
这个题第一次做会感觉特别难, 无从下手,或者想的很复杂,但简单的方法就能解决。
LeetCode763 . 字符串 S 由小写字母组成。我们要把这个字符串划分为尽可能多的片段,同一字母最多出现在一个片段中。返回一个表示每个字符串片段的长度的列表。
输入:S = "ababcbacadefegdehijhklij"
输出:[9,7,8]
解释:
划分结果为 "ababcbaca", "defegde", "hijhklij"。
每个字母最多出现在一个片段中。
像 "ababcbacadefegde", "hijhklij" 的划分是错误的,因为划分的片段数较少。
我们在回溯部分会讲到,分割问题是回溯算法的典型应用,但是这个题如果用回溯将非常复杂。题目要求同一字母最多出现在一个片段中,那么如何把同一个字母的都圈在同一个区间里呢?
该遍历过程相当要找每一个字母的边界,如果找到之前遍历过的所有字母的最远边界,说明这个边界就是分割点了。此时前面出现过所有字母,最远也就到这个边界了。所以可以这么做:
public List partitionLabels(String S) {
List list = new LinkedList<>();
int[] edge = new int[26];
char[] chars = S.toCharArray();
for (int i = 0; i < chars.length; i++) {
edge[chars[i] - 'a'] = i;
}
int idx = 0;
int last = -1;
for (int i = 0; i < chars.length; i++) {
idx = Math.max(idx,edge[chars[i] - 'a']);
if (i == idx) {
list.add(i - last);
last = i;
}
}
return list;
}
加油站问题也是贪心的热门问题之一。LeetCode134.题目要求:在一条环路上有 N 个加油站,其中第 i 个加油站有汽油 gas[i] 升。你有一辆油箱容量无限的的汽车,从第 i 个加油站开往第 i+1 个加油站需要消耗汽油 cost[i] 升。你从其中的一个加油站出发,开始时油箱为空。如果你可以绕环路行驶一周,则返回出发时加油站的编号,否则返回 -1。
示例1
输入:
gas = [1,2,3,4,5]
cost = [3,4,5,1,2]
输出: 3
解释:
从 3 号加油站(索引为 3 处)出发,可获得 4 升汽油。此时油箱有 = 0 + 4 = 4 升汽油
开往 4 号加油站,此时油箱有 4 - 1 + 5 = 8 升汽油
开往 0 号加油站,此时油箱有 8 - 2 + 1 = 7 升汽油
开往 1 号加油站,此时油箱有 7 - 3 + 2 = 6 升汽油
开往 2 号加油站,此时油箱有 6 - 4 + 3 = 5 升汽油
开往 3 号加油站,你需要消耗 5 升汽油,正好足够你返回到 3 号加油站。
因此,3 可为起始索引。
我们说做贪心题目时,不用管啥是贪心,直接做题就行。我们先理解一下这个题是什么意思。示例给的两个数组只是代表从当前站可以提供多少油料,以及从前一个位置过来需要消耗多少油料,因为是环。所以这里gas[0]=1和cost[0]=3就表示第一个站有1升汽油,从第一个站到第二个站需要消耗3升汽油。
很显然在第一个站只能加1升汽油,但是从第一到第二个站需要消耗3升汽油,因此不能从第一个站开始走。为此我们可以从第二个,第三个、第四个开始,因此我们想到了暴力解法,如下图所示:
我们一直向后找,只要能找到就行。
这里很明显的问题就是需要大量的重复计算,我们来优化一下。
首先,如果总油量减去总消耗大于等于零,那么应该能跑完一圈,具体到每一段就是各个加油站的剩油量rest[i]相加一定是大于等于零的。
每个加油站的剩余量rest[i]为gas[i] - cost[i]。i从0开始累加rest[i],和记为curSum,一旦curSum小于零,说明[0, i]区间都不能作为起始位置,起始位置必须从i+1开始重新算,只有这样才能保证我们有可能完成。
这个操作能让搜索从O(n^2)降低到O(n),代码如下:
public int canCompleteCircuit(int[] gas, int[] cost) {
int curSum = 0;
int totalSum = 0;
int start = 0;
for (int i = 0; i < gas.length; i++) {
curSum += gas[i] - cost[i];
totalSum += gas[i] - cost[i];
// 当前累加rest[i]和 curSum一旦小于0
if (curSum < 0) {
// 更新起始位置为i+1
start = i + 1;
// curSum从0开始
curSum = 0;
}
}
// 说明怎么走都不可能跑一圈了
if (totalSum < 0) return -1;
return start;
}