第一章 贪心算法
算法解释:顾名思义,贪心算法或贪心思想采用贪心的策略,保证每次操作都是局部最优的,从而使最后得到的结果是全局最优的。
1.1 分配问题
455.分发饼干
有一群孩子和一堆饼干,每个孩子有一个饥饿度,每个饼干都有一个大小。每个孩子只能吃 一个饼干,且只有饼干的大小不小于孩子的饥饿度时,这个孩子才能吃饱。求解最多有多少孩子 可以吃饱。
输入输出样例:
输入两个数组,分别代表孩子的饥饿度和饼干的大小。输出最多有多少孩子可以吃饱的数量。
题解
因为饥饿度最小的孩子最容易吃饱,所以我们先考虑这个孩子。为了尽量使得剩下的饼干可以满足饥饿度更大的孩子,所以我们应该把大于等于这个孩子饥饿度的、且大小最小的饼干给这个孩子。满足了这个孩子之后,我们采取同样的策略,考虑剩下孩子里饥饿度最小的孩子,直到没有满足条件的饼干存在。
简而言之,这里的贪心策略是,给剩余孩子里最小饥饿度的孩子分配最小的能饱腹的饼干。
因为我们需要获得大小关系,一个便捷的方法就是把孩子和饼干分别排序。 这样我们就可以从饥饿度最小的孩子和大小最小的饼干出发,计算有多少个对子可以满足条件。
135.分发糖果
一群孩子站成一排,每一个孩子有自己的评分。现在需要给这些孩子发糖果,规则是如果一个孩子的评分比自己身旁的一个孩子要高,那么这个孩子就必须得到比身旁孩子更多的糖果;所有孩子至少要有一个糖果。求解最少需要多少个糖果。
输入输出样例:
输入是一个数组,表示孩子的评分。输出是最少糖果的数量。
在这个样例中,最少的糖果分法是 [2,1,2]。
题解
虽然这一 道题也是运用贪心策略,但我们只需要简单的两次遍历即可:
- 把所有孩子的糖果数初始化为 1;
- 先从左往右遍历一遍,如果右边孩子的评分比左边的高,则右边孩子的糖果数更新为左边孩子的糖果数加 1。
- 再从右往左遍历一遍,如果左边孩子的评分比右边的高,且左边孩子当前的糖果数不大于右边孩子的糖果数,则左边孩子的糖果数更新为右边孩子的糖果数加 1。
通过这两次遍历,分配的糖果就可以满足题目要求了。这里的贪心策略即为,在每次遍历中,只考虑并更新相邻一 侧的大小关系。在样例中,我们初始化糖果分配为 [1,1,1],第一次遍历更新后的结果为 [1,1,2],第二次遍历 更新后的结果为[2,1,2]。
605.种花问题
假设有一个很长的花坛,一部分地块种植了花,另一部分却没有。可是,花不能种植在相邻的地块上,它们会争夺水源,两者都会死去。
给你一个整数数组flowerbed 表示花坛,由若干 0
和 1
组成,其中 0
表示没种植花,1
表示种植了花。另有一个数 n
,能否在不打破种植规则的情况下种入 n
朵花?能则返回 true
,不能则返回 false
。
题解
这里在一次循环遍历中判断能否种花。
如果一个地方可以种花,意味着该地方周围都没有种植花,也就是数组中可以种花的位置,左右两边也都是0。
所以在循环中一次性判断三个条件,当前下标,当前下标-1,当前下标+1。但是数组的第一个和最后一个元素也要判断,边界情况只需要判断两个值即可。
1.2 区间问题
435.无重叠区间
给定多个区间,计算让这些区间互不重叠所需要移除区间的最少个数。起止相连不算重叠。
输入输出样例:
输入是一个数组,数组由多个长度固定为 2 的数组组成,表示区间的开始和结尾。输出一个整数,表示需要移除的区间数量。
在这个样例中,我们可以移除区间 [1,3],使得剩余的区间 [[1,2], [2,4]] 互不重叠。
题解
求最少的移除区间个数,等价于尽量多的保留不重叠的区间。
因此在选择要保留区间时,区间的结尾十分重要:选择的区间结尾越小,余留给其它区间的空间就越大,就越能保留更多的区间。
因此,我们采取的贪心策略为:优先保留结尾小且不相交的区间。 具体实现方法为:
先把区间按照结尾的大小进行增序排序,每次选择结尾最小且和前一个选择的区间不重叠的区间。我们这里使用 C++ 的 Lambda,结合 std::sort() 函数进行自定义排序。
在样例中,排序后的数组为 [[1,2], [1,3], [2,4]]。按照我们的贪心策略,首先初始化为区间 [1,2];由于 [1,3] 与 [1,2] 相交(这里[1,3]区间的起始位置1小于[1,2]区间的结尾位置2,因此删除)
,我们跳过该区间;由于 [2,4] 与 [1,2] 不相交,我们将其保留。因此最终保留的区间为 [[1,2], [2,4]]。
452.用最少数量的箭引爆气球
有一些球形气球贴在一堵用 XY 平面表示的墙面上。墙面上的气球记录在整数数组 points
,其中points[i] = [xstart, xend]
表示水平直径在 xstart
和 xend
之间的气球。你不知道气球的确切 y 坐标。
一支弓箭可以沿着 x 轴从不同点 完全垂直 地射出。在坐标 x
处射出一支箭,若有一个气球的直径的开始和结束坐标为 xstart,xend,且满足 xstart ≤ x ≤ xend
,则该气球会被 引爆 。可以射出的弓箭的数量 没有限制 。 弓箭一旦被射出之后,可以无限地前进。
给你一个数组 points
,返回引爆所有气球所必须射出的 最小 弓箭数 。
题解
这道题其实和435.无重叠区间相似,那道题是判断区间是否重叠,如果重叠就删去。这道题则是判断区间是否重叠,如果重叠说明可以用一支箭射穿气球。
那么思路还是一样,首先我需要把区间按照右端排序,在开始选择排序后的第一个区间作为起始判断依据。
判断条件是,如果遍历的当前区间的 左端点 <= 我选择的区间的右端点 ,说明这两个区间有重叠部分,那么continue。
如果不满足条件,那么count++,同时当前判断依据更新为当前所遍历的区间的右端点值。
763.划分字母区间
给你一个字符串 s
。我们要把这个字符串划分为尽可能多的片段,同一字母最多出现在一个片段中。
注意,划分结果需要满足:将所有划分结果按顺序连接,得到的字符串仍然是 s
。
返回一个表示每个字符串片段的长度的列表。
题解
aabbccddeeffggiijjjkkkll
122.买卖股票的最佳时机II
给你一个整数数组 prices
,其中 prices[i]
表示某支股票第 i
天的价格。
在每一天,你可以决定是否购买和/或出售股票。你在任何时候 最多 只能持有 一股 股票。你也可以先购买,然后在 同一天 出售。
返回 你能获得的 最大 利润 。
题解
第二章 双指针
双指针主要用于遍历数组,两个指针指向不同的元素,从而协同完成任务。
也可以延伸到多个数组的多个指针。
若两个指针指向同一数组,遍历方向相同且不会相交,则也称为滑动窗口(两个指针包围的区域即为当前的窗口),经常用于区间搜索。
若两个指针指向同一数组,但是遍历方向相反,则可以用来进行搜索,待搜索的数组往往是排好序的。
对于 C++ 语言,指针还可以玩出很多新的花样。一些常见的关于指针的操作如下:
指针与常量
int x;
int* p1 = &x; // 指针可以被修改,值也可以被修改
const int * p2 = &x; // 指针可以被修改,值不可以被修改(const int)
int* const p3 = &x; // 指针不可以被修改(* const),值可以被修改
const int* const p4 = &x; // 指针不可以被修改,值也不可以被修改
指针函数与函数指针
// addition是指针函数,一个返回类型是指针的函数
int* addition(int a, int b)
{
int* sum = new int(a + b);
return sum;
}
//subtraction是普通函数,返回两个变量的减法
int subtraction(int a, int b)
{
return a - b;
}
//operation的第三个参数是一个返回值为int,参数为两个int的函数指针
int operation(int x, int y, int (*func)(int, int))
{
return (*func)(x,y);
}
// minus是函数指针,指向函数的指针
int (*minus)(int, int) = subtraction;
int* m = addition(1, 2);
int n = operation(3, *m, minus);
2.1 Two Sum
167.两数之和
在一个增序的整数数组里找到两个数,使它们的和为给定值。已知有且只有一对解。
输入输出样例:
输入是一个数组(numbers)和一个给定值(target)。输出是两个数的位置,从 1 开始计数。
题解
因为数组已经排好序,我们可以采用方向相反的双指针来寻找这两个数字,一个初始指向最小的元素,即数组最左边,向右遍历;一个初始指向最大的元素,即数组最右边,向左遍历。
指针移动条件:
如果两个指针指向元素的和等于给定值,那么它们就是我们要的结果。如果两个指针指向元素的和小于给定值,我们把左边的指针右移一位,使得当前的和增加一点。如果两个指针指向元素的和大于给定值,我们把右边的指针左移一位,使得当前的和减少一点。
2.2 归并两个有序数组
88.归并两个有序数组
给定两个有序数组,把两个数组合并为一个。
输入输出样例:
输入是两个数组和它们分别的长度 m 和 n。其中第一个数组的长度被延长至 m + n,多出的 n 位被 0 填补。题目要求把第二个数组归并到第一个数组上,不需要开辟额外空间。
题解
因为这两个数组已经排好序,我们可以把两个指针分别放在两个数组的末尾,即nums1的m − 1位和nums2的n − 1位。每次将较大的那个数字复制到nums1的后边,然后向前移动一位。 因为我们也要定位 nums1 的末尾,所以我们还需要第三个指针,以便复制。
2.3 快慢指针
142.环形链表II
给定一个链表,如果有环路,找出环路的开始点
输入输出样例:
输入是一个链表,输出是链表的一个节点。如果没有环路,返回一个空指针。
题解
对于链表找环路的问题,有一个通用的解法——快慢指针(Floyd 判圈法)。给定两个指针, 分别命名为 slow 和 fast,起始位置在链表的开头。每次 fast 前进两步,slow 前进一步。如果 fast 可以走到尽头,那么说明没有环路;如果 fast 可以无限走下去,那么说明一定有环路,且一定存在一个时刻 slow 和 fast 相遇。当 slow 和 fast 第一次相遇时,我们将 fast 重新移动到链表开头,并 让 slow 和 fast 每次都前进一步。当 slow 和 fast 第二次相遇时,相遇的节点即为环路的开始点。
2.4 滑动窗口
76.最小覆盖子串
给定两个字符串 S 和 T,求 S 中包含 T 所有字符的最短连续子字符串的长度,同时要求时间 复杂度不得超过 O(n)。
输入输出样例:
输入是两个字符串 S 和 T,输出是一个 S 字符串的子串。
在这个样例中,S 中同时包含一个 A、一个 B、一个 C 的最短子字符串是“BANC”。
题解
string minWindow(string s, string t)
{
vector<int>chars(128,0);
vector<bool>exist(128,false);
//初始化统计字符情况的数组
for (int i = 0; i < t.size(); i++)
{
chars[t[i]]++;
exist[t[i]] = true;
}
//移动滑动窗口
int l = 0, r = 0, min_len = s.size() + 1;
int min_l = 0;
int count = 0;
for (; r < s.size(); r++)
{
if (exist[s[r]])
{
if (--chars[s[r]] >= 0)
{
count++;
}
}
// 若目前滑动窗口已包含t中全部字符,
// 则尝试将l右移,在不影响结果的情况下获得最短子字符串
while (count == t.size())
{
if ((r - l + 1) < min_len)
{
min_len = r - l + 1;
min_l = l;
}
if (exist[s[l]] && ++chars[s[l]]> 0)
{
--count;
}
l++;
}
}
return min_len < s.size() + 1 ? s.substr(min_l, min_len) : "";
}
第三章 二分查找
二分查找也常被称为二分法或者折半查找,每次查找时通过将待查找区间分成两部分并只取一部分继续查找,将查找的复杂度大大减少。对于一个长度为 O(n) 的数组,二分查找的时间复 杂度为 O(log n)。
举例来说,给定一个排好序的数组 {3,4,5,6,7},我们希望查找 4 在不在这个数组内。第一次 折半时考虑中位数 5,因为 5 大于 4, 所以如果 4 存在于这个数组,那么其必定存在于 5 左边这一 半。于是我们的查找区间变成了 {3,4,5}。第二次折半时考虑新的中位数 4,正好是我们需要查找的数字。于是我们发现,对于一个长度为5的数组,我们只进行了2次查找。如果是遍历数组,最坏的情况则需要查找5次。
具体到代码上,二分查找时 区间的左右端取开区间还是闭区间在绝大多数时候都可以,因此有些初学者会容易搞不清楚如何定义区间开闭性。这里我提供两个小诀窍,第一是尝试熟练使用 一种写法,比如左闭右开(满足 C++、Python 等语言的习惯)或左闭右闭(便于处理边界条件), 尽量只保持这一种写法;第二是在刷题时思考如果最后区间只剩下一个数或者两个数,自己的写法是否会陷入死循环,如果某种写法无法跳出死循环,则考虑尝试另一种写法。
3.1 求开方
69.X的平方根
给定一个非负整数,求它的开方,向下取整。
输入输出样例:
输入一个整数,输出一个整数。
8 的开方结果是 2.82842…,向下取整即是 2。
题解
3.2查找区间
34.在排序数组中查找元素第一个和最后一个位置
给定一个增序的整数数组和一个值,查找该值第一次和最后一次出现的位置。
输入输出样例:
输入是一个数组和一个值,输出为该值第一次出现的位置和最后一次出现的位置(从0开始);如果不存在该值,则两个返回值都设为-1。
题解
整数,输出一个整数。
[外链图片转存中…(img-KOpdToHB-1709605102902)]
8 的开方结果是 2.82842…,向下取整即是 2。
题解
[外链图片转存中…(img-XlauXEQg-1709605102902)]
3.2查找区间
34.在排序数组中查找元素第一个和最后一个位置
给定一个增序的整数数组和一个值,查找该值第一次和最后一次出现的位置。
输入输出样例:
输入是一个数组和一个值,输出为该值第一次出现的位置和最后一次出现的位置(从0开始);如果不存在该值,则两个返回值都设为-1。
[外链图片转存中…(img-7A3J7YKO-1709605102902)]