20天 hot 100 速通计划-day16
堆
295. 数据流的中位数
中位数是有序整数列表中的中间值。如果列表的大小是偶数,则没有中间值,中位数是两个中间值的平均值。
- 例如
arr = [2,3,4]
的中位数是3
。 - 例如
arr = [2,3]
的中位数是(2 + 3) / 2 = 2.5
。
实现 MedianFinder 类:
MedianFinder()
初始化MedianFinder
对象。void addNum(int num)
将数据流中的整数num
添加到数据结构中。double findMedian()
返回到目前为止所有元素的中位数。与实际答案相差10-5
以内的答案将被接受。
示例 1:
输入
["MedianFinder", "addNum", "addNum", "findMedian", "addNum", "findMedian"]
[[], [1], [2], [], [3], []]
输出
[null, null, null, 1.5, null, 2.0]
解释
MedianFinder medianFinder = new MedianFinder();
medianFinder.addNum(1); // arr = [1]
medianFinder.addNum(2); // arr = [1, 2]
medianFinder.findMedian(); // 返回 1.5 ((1 + 2) / 2)
medianFinder.addNum(3); // arr[1, 2, 3]
medianFinder.findMedian(); // return 2.0
提示:
-105 <= num <= 105
- 在调用
findMedian
之前,数据结构中至少有一个元素 - 最多
5 * 104
次调用addNum
和findMedian
数据结构构造,纯技巧题,主要是堆的应用:使用一个大顶堆和一个小顶堆来维护整个数据结构。大顶堆用来保存较小一半的元素,小顶堆用来保存较大一半的元素。通过这种方式,中位数可以在常数时间内计算得出。
要求中位数,可以将列表拆分成两个区间,看作求小区间最大值和大区间最小值的中间值
- 用大顶堆维护小区间元素,保证常数时间可以得到小区间最大值
- 用小顶堆维护大区间元素,保证常数时间可以得到大区间最小值
- 保证
| 小区间 | >= | 大区间 |
,求中位数- 小区间最大值 + 大区间最小值 / 2 = 中位数 (
| 小区间 | == | 大区间 |
,列表长度为偶数时) - 小区间最大值 = 中位数 (
| 小区间 | > | 大区间 |
,列表长度为奇数时)
- 小区间最大值 + 大区间最小值 / 2 = 中位数 (
抽象成两个操作
- 增加元素:每当向数据结构中添加一个元素时,我们将元素插入到适当的堆中,并平衡两个堆的大小,以保持中位数的计算正确。
- 计算中位数:当需要计算中位数时,如果大顶堆的大小大于小顶堆的大小,中位数为大顶堆的堆顶元素;否则,中位数为大顶堆和小顶堆的堆顶元素的平均值。
class MedianFinder {
private:
priority_queue<int> maxHeap; // 保存较小一半的元素,大顶堆
priority_queue<int, vector<int>, greater<int>> minHeap; // 保存较大一半的元素,小顶堆
public:
MedianFinder() {
// 构造函数,初始化最大堆和最小堆
}
void addNum(int num) {
// 向数据结构中添加一个元素
if (maxHeap.empty() || num <= maxHeap.top()) {
// 如果大顶堆为空或者元素num小于等于大顶堆堆顶元素,将元素num插入大顶堆
maxHeap.push(num);
// 平衡堆的大小
if (maxHeap.size() > minHeap.size() + 1) {
// 如果大顶堆的大小超过小顶堆的大小1个,将大顶堆堆顶元素移动到小顶堆
minHeap.push(maxHeap.top());
maxHeap.pop();
}
} else {
// 元素num大于大顶堆堆顶元素,将元素num插入小顶堆
minHeap.push(num);
// 平衡堆的大小
if (minHeap.size() > maxHeap.size()) {
// 如果小顶堆的大小超过大顶堆的大小,将小顶堆堆顶元素移动到大顶堆
maxHeap.push(minHeap.top());
minHeap.pop();
}
}
}
double findMedian() {
// 找到数据结构中的中位数
if (maxHeap.size() > minHeap.size()) {
// 如果大顶堆的大小大于小顶堆的大小,中位数为大顶堆的堆顶元素
return maxHeap.top();
} else {
// 否则,大顶堆和小顶堆的大小相等,中位数为大顶堆和小顶堆的堆顶元素的平均值
return (maxHeap.top() + minHeap.top()) / 2.0;
}
}
};
贪心算法
121. 买卖股票的最佳时机
给定一个数组 prices
,它的第 i
个元素 prices[i]
表示一支给定股票第 i
天的价格。
你只能选择 某一天 买入这只股票,并选择在 未来的某一个不同的日子 卖出该股票。设计一个算法来计算你所能获取的最大利润。
返回你可以从这笔交易中获取的最大利润。如果你不能获取任何利润,返回 0
。
示例 1:
输入:[7,1,5,3,6,4]
输出:5
解释:在第 2 天(股票价格 = 1)的时候买入,在第 5 天(股票价格 = 6)的时候卖出,最大利润 = 6-1 = 5 。
注意利润不能是 7-1 = 6, 因为卖出价格需要大于买入价格;同时,你不能在买入前卖出股票。
示例 2:
输入:prices = [7,6,4,3,1]
输出:0
解释:在这种情况下, 没有交易完成, 所以最大利润为 0。
提示:
1 <= prices.length <= 105
0 <= prices[i] <= 104
只能买进一次和卖出一次(无状态),低买高卖就是最优解(单步决策最优解唯一),而贪心本质上也算低买高卖。我们总是选择当前最优的,即在当前价格之前找到最小的价格作为买入价格,然后在当前价格之后找到最大的价格作为卖出价格。这样就能保证我们选择的价格对能够获得最大利润的贡献最大。
贪心的目标就是扫一遍出结果,过程就是丢芝麻捡西瓜
目标是扫一次出结果
→ 结构固定,关键在于明确自己想要贪什么
→ 要贪利润,而利润 = 当前价格 - 买入价格,要保证利润最大,就要控制买入价格尽量小,保证利润尽量大(双变量)
→ 维护一个最小的买入价格和一个最大利润,每次扫描都实时更新最小买入和最大利润
class Solution {
public:
int maxProfit(vector<int>& prices) {
int minVal = prices[0]; // 初始化最小值为第一个价格
int maxProfit = 0; // 初始化最大利润为0
// 遍历数组
for (int i = 1; i < prices.size(); i++) {
// 如果当前的价格比最小值小,更新最小值
if (prices[i] < minVal) {
minVal = prices[i];
}
// 如果当前的价格减去最小值的差值大于最大利润,更新最大利润
else if (prices[i] - minVal > maxProfit) {
maxProfit = prices[i] - minVal;
}
}
return maxProfit;
}
};
55. 跳跃游戏
给你一个非负整数数组 nums
,你最初位于数组的 第一个下标 。数组中的每个元素代表你在该位置可以跳跃的最大长度。
判断你是否能够到达最后一个下标,如果可以,返回 true
;否则,返回 false
。
示例 1:
输入:nums = [2,3,1,1,4]
输出:true
解释:可以先跳 1 步,从下标 0 到达下标 1, 然后再从下标 1 跳 3 步到达最后一个下标。
示例 2:
输入:nums = [3,2,1,0,4]
输出:false
解释:无论怎样,总会到达下标为 3 的位置。但该下标的最大跳跃长度是 0 , 所以永远不可能到达最后一个下标。
提示:
1 <= nums.length <= 104
0 <= nums[i] <= 105
说实话,本题的贪心特征不明显(判断可行性,可以转换为最值问题,但而非直接求最值)
目标是扫一次出结果
→ 结构固定,关键在于明确自己想要贪什么
→ 要贪距离:我们要尽可能地贪距离,即在每一步都选择能够到达最远距离的位置。
→ 维护一个最远可以到达的位置:我们使用一个变量farthest来表示当前最远可以到达的位置。
每次遍历更新最远可以到达的位置:在遍历过程中,我们每次都更新farthest的值,以保证它表示的是当前最远可以到达的位置。
- 若最远可以到达的位置达到或超过数组末尾:如果最远可以到达的位置达到或超过数组末尾,说明可以成功到达末尾,返回true。
- 遍历结束后仍未到达末尾:如果遍历结束时,最远可以到达的位置仍未达到末尾,说明无法成功到达末尾,返回false。
class Solution {
public:
bool canJump(vector<int>& nums) {
int n = nums.size(); // 获取数组长度
int farthest = 0; // 最远可以到达的位置
for (int i = 0; i < n; ++i) { // 遍历数组
if (i <= farthest) { // 如果当前位置能够到达
farthest = max(farthest, i + nums[i]); // 更新最远可以到达的位置
if (farthest >= n - 1) { // 如果最远可以到达的位置达到或超过数组末尾
return true; // 返回true表示可以到达末尾
}
}
}
return false; // 遍历结束后仍未到达末尾,返回false表示无法到达末尾
}
};
45. 跳跃游戏 II
给定一个长度为 n
的 0 索引整数数组 nums
。初始位置为 nums[0]
。
每个元素 nums[i]
表示从索引 i
向前跳转的最大长度。换句话说,如果你在 nums[i]
处,你可以跳转到任意 nums[i + j]
处:
0 <= j <= nums[i]
i + j < n
返回到达 nums[n - 1]
的最小跳跃次数。生成的测试用例可以到达 nums[n - 1]
。
示例 1:
输入: nums = [2,3,1,1,4]
输出: 2
解释: 跳到最后一个位置的最小跳跃数是 2。
从下标为 0 跳到下标为 1 的位置,跳 1 步,然后跳 3 步到达数组的最后一个位置。
示例 2:
输入: nums = [2,3,0,1,4]
输出: 2
提示:
1 <= nums.length <= 104
0 <= nums[i] <= 1000
- 题目保证可以到达
nums[n-1]
相较于上一题,本题保证了一定能到,要求最小跳跃步数,本题相较于上一题的贪心性质更明显(求最值)
目标是扫一次出结果
→ 结构固定,关键在于明确自己想要贪什么
→ 要贪距离:因为一定能到结尾,我们要尽可能地贪距离,即在每一步都选择能够到达最远距离的位置,以保证步数最小
→ 维护一个最远可以到达的位置:我们使用一个变量 maxPosition 来表示当前最远可以到达的位置。扫描过程中记录跳的步数
int jump(vector<int>& nums) {
int n = nums.size();
int end = 0; // 当前能够到达的最远位置
int jump = 0; // 跳跃的步数
int maxPosition = 0; // 下一步能够到达的最远位置
for (int i = 0; i < n - 1; ++i) {
maxPosition = max(maxPosition, i + nums[i]);
if (i == end) {
end = maxPosition;
++jump;
}
}
return jump;
}
763. 划分字母区间
给你一个字符串 s
。我们要把这个字符串划分为尽可能多的片段,同一字母最多出现在一个片段中。
注意,划分结果需要满足:将所有划分结果按顺序连接,得到的字符串仍然是 s
。
返回一个表示每个字符串片段的长度的列表。
示例 1:
输入:s = "ababcbacadefegdehijhklij"
输出:[9,7,8]
解释:
划分结果为 "ababcbaca"、"defegde"、"hijhklij" 。
每个字母最多出现在一个片段中。
像 "ababcbacadefegde", "hijhklij" 这样的划分是错误的,因为划分的片段数较少。
示例 2:
输入:s = "eccbbbbdec"
输出:[10]
提示:
1 <= s.length <= 500
s
仅由小写英文字母组成
看似出现了两次循环,但是贪心的目标仍然是扫一遍出结果,结构仍然固定
之所以出现两次循环是因为需要将过程划分为代价记录和贪心执行两个过程是可行的。
首先,代价记录过程(cost recording process)是为了记录每个字符的最后出现位置。我们使用一个长度为26的数组last
来存储每个字符的最后出现位置。遍历字符串s
,对于每个字符s[i]
,将其最后出现的位置记录在last
数组中的对应位置last[s[i] - 'a']
。通过代价记录过程,我们能够得到每个字符的最后出现位置。
然后,贪心执行过程(greedy execution process)是根据最后出现位置来划分字母区间。我们使用两个指针start
和end
来表示当前字母区间的起始位置和结束位置。初始时,将start
和end
都设置为0。接下来,我们遍历字符串s
,对于每个字符s[i]
,更新end
的值为s[i]
的最后出现位置last[s[i] - 'a']
和当前end
的较大值。如果当前遍历到的字符s[i]
就是当前字母区间的结束位置,即i == end
,则说明找到了一个字母区间,将该字母区间的长度end - start + 1
加入到结果数组partitions
中,并更新start
为i + 1
,开始下一个字母区间的搜索。通过贪心执行过程,我们能够得到所有的字母区间。
最后,将代价记录过程和贪心执行过程结合起来,即可得到划分字符串的函数partitionLabels
。该函数返回一个整数数组partitions
,其中存储了划分字符串s
的所有字母区间的长度。
注:贪心的思想在于每次都使得当前的字母区间尽量长,从而最大化划分出的区间数目。
vector<int> partitionLabels(string s) {
vector<int> last(26); // 用于记录每个字母最后出现的位置
int length = s.length(); // 字符串的长度
// 遍历字符串,统计每个字母最后出现的位置
for (int i = 0; i < length; i++) {
last[s[i] - 'a'] = i;
}
vector<int> partitions; // 用于存储每个划分区间的长度
int start = 0; // 当前划分区间的起始索引
int end = 0; // 当前划分区间的结束索引
// 遍历字符串,根据最后出现的位置来划分字母区间
for (int i = 0; i < length; i++) {
end = max(end, last[s[i] - 'a']); // 更新当前区间的结束索引
if (i == end) { // 当当前索引与结束索引相等时,表示已经到达当前划分区间的末尾
partitions.push_back(end - start + 1); // 将当前划分区间的长度添加到结果数组中
start = i + 1; // 更新下一个划分区间的起始索引
}
}
return partitions; // 返回结果数组
}