算法进阶:贪心策略证明全攻略与二进制倍增思想深度解析

目录

1.贪心

1.1简单贪心

[例题: 货仓选址](#例题: 货仓选址)

1.2推公式

[例题: 拼数](#例题: 拼数)

1.3哈夫曼编码

例题:哈夫曼编码

1.4区间问题

例题:线段覆盖

2.倍增思想

模板:快速幂


1.贪心

贪心算法,是一种企图用局部最优找出全局最优的一种算法:

  1. 把解决问题的过程分成若干步
  2. 解决每一步时,都选择"当前看起来最优"的解法
  3. "期望"得到全局的最优解

对于大多数题目,贪心策略的提出并不难,难的是证明其正确,因为局部最优不代表全局最优,所以我们必须要能严谨的证明我们的贪心策略是正确的。一般来说证明方法有:反证法,数学归纳法,交换论证法等。

当问题的场景不同时,提出的贪心策略也不尽相同,因此,贪心策略的提出是没有模板和套路的,即使是被划分到了同一类题目策略也可能会相差很大。

1.1简单贪心

例题: 货仓选址

题目要求货仓到每一个商店的距离之和最小,我们很容易就可以想出把货仓放在商店的中间来得到最小距离,那么如何证明呢?

先来讨论一下只有两个商店的情况,我们得到当货仓在商店之间时取到最小距离

再来看看三个商店的情况

接下来我们尝试推广

这下我们就可以得出结论,仓库必定在最中间的商店或者最中间两个商店中间,最短距离则可以计算得到。

代码如下,

cpp 复制代码
#include <iostream>
#include <algorithm>
using namespace std;

typedef long long LL;
const int N = 1e5 + 10;

int n;
int a[N];

int main()
{
    // 读入数据
	cin >> n;
	for(int i = 1; i <= n; i++) cin >> a[i];
	// 读入数据并非有序,所以排序
	sort(a + 1, a + 1 + n);
	
	LL ret = 0;
	for(int i = 1; i <= n / 2; i++)
	{
        // 计算两端商店距离
		ret += a[n + 1 - i] - a[i];
	}
	cout << ret << endl;
	return 0;
 } 

1.2推公式

这一个部分可以说是"推公式+排序 "。其中推公式就是寻找排序规律 ,排序就是在该排序规则下,对整个对象进行排序

在解决某些问题的时候,当我们发现最终结果需要调整每个对象先后顺序,也就是对整个对象排序时,那么我们就可以用推公式的方法,得出我们的排序规则,进而对整个对象排序。

例题: 拼数

这道题目要求拼接数字,所以我们可以用string来储存数字,拼接更加方便。

同时我们有一个想法,大的那个字符串放在前面拼接会不会更大呢?至少从题目中给出的两个样例来看,答案就是按照这个规则排序的。

但是,这个想法是错误的,我们来看如下反例

以7 和 72 为例

按照字符串的比较规则,7 < 72

所以结果为727

但是772显然比727大

所以我们需要寻找新的规则,从上面的反例,我们找到了旧规则的漏洞,那么新规则如何避免呢?

仍然以 7 和 72为例

先进行拼装 x + y = 772, y + x = 727

这时候我们让x + y 和 y + x 进行比较

这时候我们绝对能得到大的那个

所以我们的规则为 x + y > y + x

所以我们可以得到代码,

cpp 复制代码
#include <iostream>
#include <algorithm>
#include <string>
using namespace std;

typedef long long LL;
const int N = 30;

int n;
string a[N];

// 新规则
bool cmp(string& x, string& y)
{
	return x + y > y + x;	
}

int main()
{
	cin >> n;
	for(int i = 1; i <= n; i++)
	{
		cin >> a[i];
	}
	
    // 排序
	sort(a + 1, a + 1 + n, cmp);

	// 直接输出即可
	for(int i = 1; i <= n; i++)
	{
		cout << a[i];
	}
	
	return 0;
}

关于正确性:

利用排序解决问题,最重要的就是需要证明"在新的排序规则下,整个集合可以排序 "。这需要用到离散数学中"全序关系"的知识。

但是证明过程很麻烦,后续碰见的题目中我们只要发现该题最终结果需要排序,并且交换相邻两个元素的时候,对其余元素不会产生影响,那么我们就可以推导出排序的规则,然后直接去排序,就不用去证明了。

关于全序关系,简单来说就是一种能比大小 的严格顺序关系,比如,在一个集合中任意两个元素可以进行比较 ,且这种比较是可以传递的,那么这种顺序就是全序。

证明:

定义:对于任何两个正整数 x 和 y

  • 若 xy > yx, 记为 x ≻ y (表示x排在y前面)
  • 若 xy = yx, 记为 x ∼ y (表示两者等价,顺序任意)
  • 若 xy < yx, 记为 x ≺ y

1.自反性

对于任何 x ,显然有 xx = xx 。满足自反性

2.反对称性

若 x ≻ y , 则有 xy > yx, 有 yx < xy, 即 x ≺ y。

若 x ~ y, 即 xy = yx, 即 x ~ y。

满足反对称性

3.完全性

对于任意的 x, y, xy 和 yx 都是确定的,因此它们的大小关系只有 xy > yx, xy = yx,yx < yx。意味着 x, y 总是可比的。满足完全性

4.传递性

要证传递性就是要证,对于任意正整数x, y, z, 若 x ≻ y 且 y ≻ z ,则 x ≻ z 。

设函数lens(int x),返回 10 ^ (x 这个数的长度)。

由 x ≻ y 得:xy > xy <==> x * lens(y) + y > y * lens(x) + x

整理得 :(x - 1) / lens(x) > (y - 1) / lens(y)

由 y ≻ z 得:yz >zy <==> y * lens(z) + z > z * lens(y) + y

整理得 :(y - 1) / lens(y) > (z - 1) / lens(z)

显然有 :(x - 1) / lens(x) > (z - 1) / lens(z)

还原得 :x * lens(z) > z * lens(x)

即 :x ≻ z

满足传递性

故该策略可以排序。

1.3哈夫曼编码

哈夫曼编码,是一种优雅且高效的无损压缩算法,它的思想是:为出现频率高的符号分配短的码字,为出现频率低的符号分配长的码字 。它的精妙之处在于它生成的是一种"前缀码",任何一个字符的编码都不是另一个字符编码的前缀。这就完全消除了解码时的歧义,无需任何分隔符就能正确解码。

哈夫曼编码是通过构建一棵二叉树(称为哈夫曼树)来完成的。其过程就是贪心。而**带权路径长(WPL)**就是求和所有叶子节点 * 到根节点有几条边,哈夫曼树有最小的WPL。

而我们如果想要得到最优二叉树,就是要让频率出现高的元素靠近根,而让频率出现低的元素远离根。所以我们可以得到一个贪心策略:进行节点合并的时候选择代价最小的两棵树

以元素:10 15 15 20 40为例构建哈夫曼树

每步都选取了当下代价最小的元素进行合并,最终达到整体代价最小。

那么如何说明这种策略的正确性呢?

要证明算法的成立,就必须证明局部最优选择能导致全局最优,我们可以反证一下,如果不是局部最优呢?

我们取最深的结点 x ,与任意一个节点进行交换,明显的,WPL的变化总是 >= 0,所以当前的位置就是节点最优的位置,从而我们可以确定最深层节点都处在最优的位置。当最后一层确定时,最后一层的结点不能动,从倒数第二层的结点开始重新与其上面的结点进行交换,始终可以确定最优位置不变,综上可以确定,该局部最优解就是全局最优解。

例题:哈夫曼编码

这道题就是要求带权路径长,这难点就在于,如何计算边的个数。我们来观察一下建树的过程,1 2 3为例

在左边这棵树里,1 和 2 形成的这个新节点的值 3 ,就是这棵树的WPL;而在右边的这棵树里,6 是 左3 和 右3(整体) 的WPL,如果两个节点 3 和 6 相加,可以看到 1 和 2 两个叶子的被加了两回, 3 被加了一回,正好满足了WPL的定义,所以我们可以得出结论,WPL = 构建树过程中新节点值的和。

可以有如下代码

cpp 复制代码
#include <iostream>
#include <queue>
#include <vector>
using namespace std;

typedef long long LL;
const int N = 2e5 + 10;

int n;
// 建小堆用于取小元素
priority_queue<LL, vector<LL>, greater<LL>> a;

int main()
{
    cin >> n;
    // 读数据入堆
    for(int i = 1; i <= n; i++) 
    {
        LL x; cin >> x;
        a.push(x);
    }
    
    LL ret = 0;
    // 模拟建树
    while(a.size() > 1)
    {
        LL x = a.top(); a.pop();
        LL y = a.top(); a.pop();
        // 累加节点
        ret += x + y;
        // 新节点入堆
        a.push(x + y);
    }
    
    cout << ret << endl;
    
	return 0;
}

1.4区间问题

对于区间问题,就是给出n段区间,对其进行排序,找到最合适的解法。

其中排序是有讲究的,因为区间有左右两个端点,有升序降序两种排法,所以在没有其他附加条件的情况下是至少有四种排序,而我们要做的就是找到可以解题的哪一种排序。一般来说找到的吻合排序就是正确的那个。

例题:线段覆盖

由题目可以知道比赛区间为[a, b)的形式,所以对于题中的样例可以做到比赛在2时刻结束同时参加下一个2时刻开始的比赛。

接下来就是找到合适的排序方法,对于这道题,选择的是按左端点排升序。接下来我们需要移除区间范围大的,所以对于有重叠的区间,选择右端点最小的,而对于不重叠的区间,那么就能多选中一个区间,此时以新区间为基准继续向后遍历。

如下代码

cpp 复制代码
#include <iostream>
#include <algorithm>
using namespace std;

const int N = 1e6 + 10;

int n;
struct node
{
	int l, r;
}a[N];

// 排序规则
bool cmp(node& x, node& y)
{
	return x.l < y.l;
}
int main()
{
	cin >> n;
	for(int i = 1; i <= n; i++)
	{
		cin >> a[i].l >> a[i].r; 
	}
	
	sort(a + 1, a + 1 + n, cmp);
	
	int right = a[1].r, ret = 1;
	for(int i = 2; i <= n; i++)
	{
        // 下一个线段左端点小于选中线段的右端点,说明有重叠
		if(a[i].l < right)
		{
			right = min(right, a[i].r);
		}
        // 不重叠的区间
		else 
		{
			right = a[i].r;
			ret++;
		}
	}
	cout << ret << endl;
	return 0;
}

证明和哈夫曼编码类似,故不再赘述。

2.倍增思想

先来了解一下模运算规则的性质

以下性质在模 m下成立(假设所有运算在整数范围内):

  • 加法:(a + b) mod m = (a mod m + b mod m) mod m

  • 减法:(a − b) mod m = (a mod m − b mod m) mod m(结果通常调整为非负)

  • 乘法:(a * b) mod m = (a mod m * b mod m) mod m

  • 幂运算:a^n mod m 可以通过快速幂算法高效计算。

  • 注意:除法不简单,需要逆元,这里暂时不讨论。

模板:快速幂

观察一下这道题的数据范围,注意到乘法的结果可以超过任何一个类型的范围。

所以我们要做的第一步是对 a^b 进行二进制拆分,比如

11 = 1 * 2^3 + 0 * 2^2 + 1 * 2^1 + 1 * 2^0

所以11的二进制表示为 1011

那么对于3^11,可以拆分为

3^(1011) = 3^8 * 3^0 * 3^2 * 3^1

这时候我们就能快速计算出 3^11 ,因为变成二进制之后从右往左看,右边的数是前面数的平方

也就是:

3^1 = 3

3^2 = 3^1 * 3^1 = 9

3^3 = 3^2 * 3^2 = 81

......

因此计算3^11,我们只需要将11的二进制表示中1所对应的幂乘起来即可。

而要计算3^11 mod 7,那么就会变成

(3^8 mod 7 * 3^0 mod 7 * 3^2 mod 7 * 3^1 mod 7) mod 7

那么如何实现这个算法呢?以a^b mod m为例

  • 提取b的二进制位,如果为1乘上a,取模
  • 让a = a * a mod m,不断变成平方(倍增思想)

代码如下,

cpp 复制代码
#include <iostream>
using namespace std;
typedef long long LL;
LL a, b, p;

LL qpow(LL a, LL b, LL p)
{
	int ret = 1;
	while(b)
	{
        // 为1时按照模运算性质进行相乘取模运算
		if(b & 1) ret = ret * a % p;
        // 倍增
		a = a * a % p;
		b >>= 1;
	}
	return ret;
}
int main()
{
	cin >> a >> b >> p;
	printf("%lld^%lld mod %lld=%lld\n", a, b, p, qpow(a, b, p));
	return 0;
}
相关推荐
2301_792674862 小时前
java学习day27(算法)
java·学习·算法
CoderMeijun2 小时前
CMake 入门笔记
c++·笔记·编译·cmake·构建工具
zhangrelay2 小时前
蓝桥云课一分钟-星界战纪-Stellar Combat-make
笔记·学习
楼田莉子2 小时前
设计模式:创建型设计模式简介
服务器·开发语言·c++·设计模式
cui_win2 小时前
Ollama 实战笔记:本地大模型安装配置全教程
笔记·ollama
啦啦啦!2 小时前
c++AI大模型接入SDK项目
开发语言·数据结构·c++·人工智能·算法
lcj25112 小时前
【C语言】自定义类型1:结构体
c语言·开发语言·算法
jaysee-sjc2 小时前
十七、Java 高级技术入门全解:JUnit、反射、注解、动态代理
java·开发语言·算法·junit·intellij-idea
淬炼之火2 小时前
笔记:对MoE混合专家模型的学习和思考
人工智能·笔记·学习·语言模型·自然语言处理