6.1 贪心算法 | 区间选点、Huffman树
这是我的一个算法网课学习记录,道阻且长,好好努力
可以尝试的做法:
区间问题重要的步骤就是排序 按左端点排序,按右端点排序,双关键字排序
区间问题
区间选点
例题: AcWing 905. 区间选点
给定 N 个闭区间 [ai,bi]
,请你在数轴上选择尽量少的点,使得每个区间内至少包含一个选出的点。
输出选择的点的最小数量。
位于区间端点上的点也算作区间内。
输入格式
第一行包含整数 N
,表示区间数。
接下来 N
行,每行包含两个整数 ai,bi
,表示一个区间的两个端点。
输出格式
输出一个整数,表示所需的点的最小数量。
数据范围
1 ≤ N ≤ 10^5,
−109 ≤ ai ≤ bi ≤ 109
输入样例:
3
-1 1
2 4
3 5
输出样例:
2
贪心思路:
将每个区间按有右端点从小到大排序
从前往后依次枚举每个区间
(如果当前区间已经包含点,则直接pass;否则,选择当前区间的右端点)
证明思路:
ans <= cnt
答案ans
是所有可行解点数cnt
中最小的,即所有可行方案中的最优解
ans >= cnt
答案如果要覆盖所有的区间,考虑极端情况,即每个区间都不重合,那么覆盖每一个区间至少需要cnt
个点
综上,可得ans == cnt
Ans
#include <iostream>
#include <algorithm>
using namespace std;
const int N = 100010;
int n;
// 定义结构体存储区间
struct Range
{
int l, r;
// 重载小于符号 用于sort函数进行结构体排序
bool operator< (const Range &W) const
{
return r < W.r;
}
}range[N];
int main()
{
scanf("%d", &n);
for (int i = 0; i < n; i ++ )
{
int l, r;
scanf("%d%d", &l, &r);
range[i] = {l, r};
}
// 快速排序
sort(range, range + n);
// 贪心算法求解
int res = 0, ed = -2e9; // res存储选择的点的数量 ed存储上一个选择的点的值(初始化正无穷)
for (int i = 0; i < n; i ++ )
if (range[i].l > ed) // 如果当前区间不包含上一个选择的点
{
res ++ ;
ed = range[i].r; // 选择当前区间的右端点
}
printf("%d\n", res);
return 0;
}
注意:对于双(多)标准的结构体排序,一般会有三种方法
-
比较函数重写,在
sort
函数中加入第三个函数cmp
-
结构体内部运算符重载
-
外部运算符重载
其中后两种方法的效率高于第一种
详细可以参考:C++结构体排序(运算符重载,比较函数重写)_Miller c的博客-CSDN博客_c++ 重写sort
最大不相交区间数量
例题: AcWing 908. 最大不相交区间数量
给定N
个闭区间[ai,bi]
,请你在数轴上选择若干区间,使得选中的区间之间互不相交(包括端点)。
输出可选取区间的最大数量。
输入格式
第一行包含整数N
,表示区间数。
接下来N
行,每行包含两个整数ai,bi
表示一个区间的两个端点。
输出格式
输出一个整数,表示可选取区间的最大数量。
数据范围
1 ≤ N ≤ 105
− 109 ≤ ai ≤ bi ≤ 109
输入样例:
3
-1 1
2 4
3 5
输出样例:
2
贪心思路:
将每个区间按有右端点从小到大排序
从前往后依次枚举每个区间
(如果当前区间已经包含点,则直接pass;否则,选择当前区间的右端点)
证明思路:
ans <= cnt
采用反证法。假设anx > cnt
,则说明有ans
个两两不相交的区间,至少需要ans
个点才能将这些区间全部覆盖。然而实际上只需要cnt
个点就能覆盖全部区间,也就是cnt < ans
,与题设矛盾。因此,ans <= cnt
。
ans >= cnt
答案Ans
是所有可行方案的最大值,由上述贪心思路可以选择出cnt
个区间,覆盖所有的区间,是一组可行方案。
综上,可得ans == cnt
。
最大不相交区间数==最少覆盖区间点数,原因是:
如果几个区间能被同一个点覆盖,说明他们相交了,与题意不符;有几个最少覆盖区间点数,就有对应的最大不相交区间数。
因此代码其实和上一题(区间选点)是一样的。
Ans
#include <iostream>
#include <algorithm>
using namespace std;
const int N = 100010;
int n;
// 定义结构体存储区间
struct Range
{
int l, r;
// 重载小于符号 用于sort函数进行结构体排序
bool operator< (const Range &W) const
{
return r < W.r;
}
}range[N];
int main()
{
scanf("%d", &n);
for (int i = 0; i < n; i ++ )
{
int l, r;
scanf("%d%d", &l, &r);
range[i] = {l, r};
}
// 快速排序
sort(range, range + n);
// 贪心算法求解
int res = 0, ed = -2e9; // res存储选择的点的数量 ed存储上一个选择的点的值(初始化正无穷)
for (int i = 0; i < n; i ++ )
if (range[i].l > ed) // 如果当前区间不包含上一个选择的点
{
res ++ ;
ed = range[i].r; // 选择当前区间的右端点
}
printf("%d\n", res);
return 0;
}
区间分组
例题: AcWing 906. 区间分组
题目描述
给定N个闭区间 [ai,bi],请你将这些区间分成若干组,使得每组内部的区间两两之间(包括端点)没有交集,并使得组数尽可能小。
输出最小组数。
输入格式
第一行包含整数N,表示区间数。
接下来N行,每行包含两个整数 ai, bi ,表示一个区间的两个端点。
输出格式
输出一个整数,表示最小组数。
数据范围
1≤N≤105,−109≤ai≤bi≤109
样例
输入样例:
3
-1 1
2 4
3 5
输出样例:
2
贪心思路:
- 将所有区间按左端点从小到大排序
- 从前往后处理每个区间,判断能否将其放到某个现有的组中
L[i] > Max_r
(当前区间的左端点是否大于组内右端点的最大值)
- 如果不存在这样的组,则开新组,然后在将其放进去
- 如果存在这样的组,将其放进去,并更新当前组的
Max_r
证明思路:
ans <= cnt
按照上述算法得到可以得到一个合法的分组方案,而每一组内所有区间两两之间互不相交,而ans
是所有合法方案中的最小值,因此可以证明ans <= cnt
。
ans >= cnt
在位于某个特殊的时刻,当在开第cnt
个组时,需要先检验前面cnt-1
个组,发现当前区间和前面cnt-1
个组都有交集,因此我们需要新开一个组,将当前区间存入该组,即第cnt
个组。
设当前区间的左端点是L[i]
,因为当前区间和前cnt-1
个组都有交集,因此前n-1
个组中每个组都可以找一个区间与当前区间相交,因此算上当前区间可以找到cnt
个区间包含L[i]
点。那么可以知道,无论这cnt
个区间如何分配,都不会分到一个组中,因此所有的可行方案一定要把这cnt
个区间完全分开,可以得到,所有可行方案一定大于cnt
,即ans >= cnt
。
综上,可得ans == cnt
。
Ans
#include <iostream>
#include <algorithm>
#include <queue> //用到了小根堆
using namespace std;
const int N = 1e5 + 10;
int n;
struct Range
{
int l, r;
bool operator < (const Range &w)const
{
return l < w.l;
}
}range[N];
int main()
{
cin >> n;
for (int i = 0; i < n; i ++ )
{
int l, r;
cin >> l >> r;
range[i] = {l, r};
}
sort(range, range + n);
priority_queue<int, vector<int>, greater<int>> heap; // 小根堆
for (int i = 0; i < n; i ++ )
{
auto r = range[i];
if (heap.empty() || r.l <= heap.top()) heap.push(r.r); // 如果堆为空或者当前区间的左端点位于之前的区间内 则增加一个分组
else
{
heap.pop();
heap.push(r.r); // 改变最小区间的右端点
}
}
cout << heap.size();
return 0;
}
区间覆盖
例题: AcWing 907. 区间覆盖
给定N
个闭区间[ai,bi]
以及一个线段区间[s,t]
,请你选择尽量少的区间,将指定线段区间完全覆盖。
输出最少区间数,如果无法完全覆盖则输出-1。
输入格式
第一行包含两个整数s
和t
,表示给定线段区间的两个端点。
第二行包含整数N
,表示给定区间数。
接下来N
行,每行包含两个整数ai,bi
,表示一个区间的两个端点。
输出格式
输出一个整数,表示所需最少区间数。
如果无解,则输出-1。
数据范围
1 ≤ N ≤ 105,
−109 ≤ ai ≤ bi ≤ 109,
−109 ≤ s ≤ t ≤ 109
输入样例:
1 5
3
-1 3
2 4
3 5
输出样例:
2
贪心思路:(猜+证明)
- 将所有区间按左端点从小到大排序
- 从前往后依次枚举每个区间,在所有能覆盖start的区间中选择一个右端点最大的区间,然后将start更新成右端点的最大值
证明思路:
ans == cnt
设最优解找到的区间按左端点从小到大数第一个与上述算法找到的解不一样的区间是I
,设算法找到的区间是J
,那么显然有I
的右端点小于等于J
的右端点,那么将最优解里的I
用J
去替换,显然仍然得到的是合法解,并且区间个数一样,所以也是最优解。我们可以一直这样替换下去,这样就能找到一个最优解,它包含算法解的所有区间(由于算法能保证一旦发现能覆盖t
了就退出循环了,所以算法解的区间个数不会比最优解更多),所以算法正确。
Ans
#include <algorithm>
#include <iostream>
using namespace std;
const int N = 100010;
int n;
int st, ed;
struct Range
{
int l, r;
bool operator<(const Range &w) const
{
return l < w.l;
}
}range[N];
int main() {
scanf("%d%d", &st, &ed);
scanf("%d", &n);
for (int i = 0; i < n; i ++ )
scanf("%d%d", &range[i].l, &range[i].r);
sort(range, range + n);
int res = 0;
bool success = false;
for (int i = 0; i < n; i ++ )
{
int j = i, r = -2e9;
// 找到一个左端点位于st左边且右端点最大的区间
while (j < n && range[j].l <= st)
{
r = max(r, range[j ++ ].r);
j ++ ;
}
if (r < st) // 如果所有区间的右端点都小于起始点 则找不到 返回-1
{
res = -1;
break;
}
res ++ ;
if (r >= ed)
{
success = true;
break;
}
st = r; // 更新循环起始条件
i = j - 1;
}
if (!success) res = -1;
printf("%d\n", res);
return 0;
}
Huffman树
哈夫曼树又称最优二叉树,是一种带权路径长度最短的二叉树。所谓树的带权路径长度,就是树中所有的叶结点的权值乘上其到根结点的路径长度(若根结点为0层,叶结点到根结点的路径长度为叶结点的层数)。
树的带权路径长度记为WPL=(W1*L1+W2*L2+W3*L3+...+Wn*Ln)
,N
个权值Wi(i=1,2,...n)
构成一棵有N
个叶结点的二叉树,相应的叶结点的路径长度为Li(i=1,2,...n)
。可以证明哈夫曼树的WPL
是最小的。
W P L = ∑ i = 0 n v a l ( i ) ∗ d e p t h ( i ) WPL = \sum_{i = 0}^{n} val(i)*depth(i) WPL=∑i=0nval(i)∗depth(i)
构造过程大概就是把权值最小的两个数先放在最下面,然后一次把剩下的最小的树慢慢加到这个二叉树上。
哈夫曼树的特点是:权值越大的叶子节点越靠近根节点,权值越小的叶子节点越远离根节点;只有度为0(叶子节点)和度为2(分支节点)的节点,没有度为1的节点。
合并果子
例题: AcWing 907. 区间覆盖
(在一个果园里,达达已经将所有的果子打了下来,而且按果子的不同种类分成了不同的 n 堆。
达达决定把所有的果子合成一堆。
每一次合并,达达可以把两堆果子合并到一起,消耗的体力等于两堆果子的重量之和。
可以看出,所有的果子经过 n−1次合并之后,就只剩下一堆了。
达达在合并果子时总共消耗的体力等于每次合并所耗体力之和。
因为还要花大力气把这些果子搬回家,所以达达在合并果子时要尽可能地节省体力。
假定每个果子重量都为 1,并且已知果子的种类数和每种果子的数目,
你的任务是设计出合并的次序方案,使达达耗费的体力最少,并输出这个最小的体力耗费值。)
给定n
个数,a1,…, an ,每次要合并两个数成为一个新数,代价就是它们的和。问把所有数两个两个合并最终成为一个数的最小总代价是多少。
输入格式:
输入包括两行,第一行是一个整数n
,表示果子的种类数。第二行包含n
个整数,用空格分隔,第i
个整数ai是第i
种果子的数目。
输出格式:
输出包括一行,这一行只包含一个整数,也就是最小代价。
数据范围:
1 ≤ n ≤ 10000
1 ≤ ai ≤ 20000
输入数据保证答案小于231。
样例输入
3
1 2 9
样例输出
15
注意与石子合并不同的地方在于石子合并是对相邻的两堆石子进行合并,而果子合并并不存在这样的限制,因此可以使用Huffman
树。
算法思路:
经典的Huffman
树问题。构造一个小根堆(最小优先队列),将队顶的元素进行加和得到res
,同时将res
插入小根堆中。通过不断的合并,最终的到合法的方案,同时也是最优的方案。
证明思路:
数学归纳法。
当只有1个或者2个数的时候,显然,算法正确。
假设对于k个数该算法也正确,考虑有k+1个数的时候。首先可以证明,对于任意最优解,最小数一定在最深的那一层,如果不然,就可以将最小数与最深的那一层的某个比他大的数交换,这样得到的总代价是最小的(因为val*depth变小了),而显然在最深的层的节点是先合并的。而先合并完两个最小数之后,问题就转化为了有k个数的情况,由我们的第二步假设,该算法对于k个数的时候正确,由数学归纳法可知,该算法对于任意个数都是正确的。
Ans
#include <iostream>
#include <queue>
using namespace std;
int main()
{
int n;
cin >> n;
priority_queue<int, vector<int>, greater<int>> min_heap;
for (int i = 0; i < n; i ++ )
{
int a;
cin >> a;
min_heap.push(a); // 依序插入果子堆
}
int res = 0;
while (min_heap.size() > 1) // 当还没有只剩下一个果子堆的时候
{
int x = min_heap.top(); // 取第一小堆
min_heap.pop();
int y = min_heap.top(); // 取第二小堆
min_heap.pop();
res += x + y; // 更新体力消耗值
min_heap.push(res); // 插入新的果子堆
}
cout << res << endl;
return 0;
}