蓝桥杯算法精讲:倍增思想与离散化深度剖析

目录

🎬 云泽Q个人主页
🔥 专栏传送入口 : 《C语言》《数据结构》《C++》《Linux》《蓝桥杯系列

⛺️遇见安然遇见你,不负代码不负卿~


前言

大家好啊,我是云泽Q,欢迎阅读我的文章,一名热爱计算机技术的在校大学生,喜欢在课余时间做一些计算机技术的总结性文章,希望我的文章能为你解答困惑~

一、倍增思想

倍增与其说是一种算法,不如说是一种思想。

倍增,顾名思义就是翻倍。它能够使线性的处理转化为对数级的处理,极大的优化时间复杂度

1.1 快速幂

快速幂


一、题目分析

这道题的核心是计算 ab mod p(即 a 的 b 次方对 p 取余),但数据范围非常大:

  • 0 ≤ a,b <231,2 ≤ p < 231
  • 直接暴力循环 b 次计算 ab 会超时(时间复杂度 O(b),当 b=109 时根本跑不完),且直接计算会导致数值溢出(ab 会远超 64 位整数范围)。

所以必须用 快速幂算法 来优化时间和防止溢出。

  1. ret 是最终答案:初始值必须是 1(因为任何数的 0 次方是 1,乘法的 "单位元");
  2. 每一步都要 %p :不管是 ret*a 还是 a*a,都要取模 ------ 目的是防止数值太大溢出,记住 "取模就对了";
  3. b >>= 1 就是把 b 砍半:比如 b=10→5→2→1→0,循环到 b=0 就停。
cpp 复制代码
#include <iostream>
using namespace std;
typedef long long LL; // 防止数值溢出,用64位整数
LL a, b, p;

// 快速幂函数:计算 (a^b) mod p
LL qpow(LL a, LL b, LL p)
{
    LL ret = 1;  // 结果初始值(任何数的0次方是1,相当于乘法的"单位元")
    while(b)     // 只要指数b还没拆完,就继续循环
    {
        // 第一步:判断当前b的最后一位二进制是不是1(是不是需要乘这个幂次)
        if(b & 1) ret = ret * a % p;
        
        // 第二步:把a翻倍(算下一个2的幂次),取模防止溢出
        a = a * a % p;
        
        // 第三步:把b右移一位(去掉最后一位二进制,拆剩下的指数)
        b >>= 1;
    }
    return ret;
}

int main()
{
    cin >> a >> b >> p; // 输入2 10 9
    printf("%lld^%lld mod %lld=%lld",a, b, p, qpow(a, b, p));
    return 0;
}

说实话这道题原理很好懂,但是代码不太好懂,如果上面部分还看不懂的话,接下来我就以最简单的讲解方式将代码和原理结合起来,包你看懂:

我们就用最直观的例子:计算 210 mod 9(对应代码输入 a=2, b=10, p=9)。
核心原理的 3 个要点

  1. 指数拆成二进制 :10 的二进制是 1010,也就是 8 + 2,所以 210 = 2(8+2) = 28 × 22(这一步是 "减少计算次数" 的关键);
  2. 倍增算幂次 :不用一个个算 21、22、23... 而是 "翻倍算":
    21 = 2(初始的 a)
    22 = (21) × (21)(a 平方)
    24 = (22) × (22)(a 再平方)
    28 = (24) × (24)(a 再平方)
    这样算 28 只需要 3 次平方,而不是 8 次乘法;
  3. 只乘需要的项 :二进制位是 1 的位置才需要乘到结果里(1010 的第 2 位和第 4 位是 1,对应 22 和 28),二进制位是 0 的跳过。

现在,我们模拟 qpow(2, 10, 9) 的执行全过程

初始化:ret=1,a=2,b=10(二进制 1010),p=9

把原理和代码的对应关系总结成 "一句话"

最后要点补充

原代码的输出行也可替换为cout

cpp 复制代码
//printf 输出 long long 必须用 %lld(小写 L),写错会输出错误值;
printf("%lld^%lld mod %lld=%lld",a, b, p, qpow(a, b, p));

替换成 cout 的写法:

cpp 复制代码
cout << a << "^" << b << " mod " << p << "=" << qpow(a, b, p) << endl;

1.2 大整数乘法

64位整数乘法

这道题的数据范围是1018,即使使用long long存a * b的值也是会溢出的,此时%p之后的结果也是不对的,所以还是要用倍增的思想来解决该问题

这道题也可以使用上道题的思想,先把b的二进制表示写出来,当二进制的某一位权值(比如说是8)所对应的数是1,就把8a的结果累加上,如果某一位的权值所对应的数是0的话跳过不累加即可,所以说该题目和上一道题目的解法是一摸一样的,只不过上一个是累乘,该题是累加

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

typedef long long LL;
LL a, b, p;

LL qmul(LL a, LL b, LL p)
{
	LL sum = 0;
	while(b)
	{
		if(b & 1) sum = (sum + a) % p;
		a = (a + a) % p;
		b >>= 1;
	}
	return sum;
}

int main()
{
	cin >> a >> b >> p;
	cout << qmul(a, b, p) << endl;
	return 0;
}

二、离散化

当题目中数据的范围很大,但是数据的总量不是很大。此时如果需要用数据的值来映射数组的下标时,就可以用离散化的思想先预处理一下所有的数据,使得每一个数据都映射成一个较小的值。之后再用离散化之后的数去处理问题。

示例:[99, 9, 9999, 999999] 离散之后就变成 [2, 1, 3, 4]

案例
题目描述

给定 n 个数,离散化之后,输出每一个数离散化之后的值。

cpp 复制代码
10 数据个数 - n
1999999
12
1999999
-48444
568
12
-100
-2845630
100000001
263
共10个数
// 输出
7 4 7 2 6 4 3 1 8 5

离散化模板一:排序 + 去重 + 二分查找离散之后的结果

cpp 复制代码
#include<iostream>
//包含sort、unique等算法
#include<algorithm>
using namespace std;

const int N = 1e5 + 10;
typedef long long LL;
LL n, pos;
LL a[N];
//辅助数组:用来存放所有需要离散化的元素,后续排序去重
LL disc[N];

LL find(LL x)
{
	LL l = 1, r = pos;
	while(l < r)
	{
		LL mid = (l + r) / 2;
		if(disc[mid] >= x) r = mid;
		else l = mid + 1;
	}
	return l;
}

int main()
{
	cin >> n;
	for(int i = 1; i <= n; i++)
	{
		cin >> a[i];
		disc[++pos] = a[i];
	}
	//步骤1:排序
	sort(disc + 1, disc + 1 + pos);
	//步骤2:去重
	pos = unique(disc + 1, disc + 1 + pos) - (disc + 1);
	//步骤3:遍历原始数组,输出每个元素的离散化结果
	for(int i = 1; i <= n; i++)
	{
		cout << a[i] << "离散化之后" << find(a[i]) << endl;
	}
	return 0;
}

这段代码核心完成 3 件事:

  1. 把原始数组的所有元素复制到辅助数组disc中;
  2. 对disc排序 + 去重,得到 "唯一且有序" 的离散化基准数组;
  3. 用二分查找,为原始数组每个元素找到其在基准数组中的位置/下标(这个位置就是离散化后的值)。

最终效果:把范围可能极大的原始数值(比如 - 2845630、100000001)映射成连续的小整数(比如 1、9),且保持数值的相对大小关系。

要点补充

  1. unique的底层原理
    unique是 C++ STL 中的算法,必须在「有序数组」上使用 ,否则无法正确去重。
    核心逻辑:unique不是 "删除" 重复元素,而是将相邻的重复元素移动到数组末尾,返回「去重后最后一个有效元素的下一个位置」的指针 / 迭代器。

代码中pos的计算逻辑

cpp 复制代码
pos = unique(disc + 1, disc + 1 + pos) - (disc + 1);
  • unique(disc+1, disc+1+pos):处理disc[1]~disc[pos],返回有效区域的下一个地址(比如有效元素有 8 个,返回disc+9);
  • 减去disc+1:把地址差转换成元素个数(disc+9 - (disc+1) = 8),最终pos就是去重后的元素个数。

离散化模板二:排序 + 哈希表去重以及记录最终的位置

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

const int N = 1e5 + 10;
typedef long long LL;
LL n, pos;
LL a[N], disc[N];
unordered_map<LL, LL> id;

int main()
{
	cin >> n;
	for(int i = 1; i <= n; i++)
	{
		cin >> a[i];
		disc[++pos] = a[i];
	}
	sort(disc + 1, disc + 1 + pos);
	LL cnt = 0;
	//遍历排序后的disc数组,构建哈希映射
	for(int i = 1; i <= pos; i++)
	{
		//取排序后的当前值
		LL x = disc[i];
		//关键:如果x已在哈希表中,跳过(去重)
		if(id.count(x)) continue;
		//编号+1(保证连续)
		cnt++;
		//存入映射:原始值x → 离散编号cnt
		id[x] = cnt;
	}
	for(int i = 1; i <= n; i++)
	{
		cout << a[i] <<"离散化后:"<< id[a[i]] << endl;
	}
	return 0;
}

这个模板是离散化的哈希表实现版本,核心逻辑和模板一(排序 + unique + 二分)一致 ------ 将大范围的原始值映射为连续的小整数,但用「哈希表」替代了「unique 去重 + 二分查找」

要点补充:

  • unordered_map核心特性:
    key唯一:同一个key只能存一个value,重复插入会覆盖,这是「去重」的关键
  • id.count(x):检查哈希表中是否存在key=x,返回1表示存在,0表示不存在;

哈希版 vs 模板一(unique + 二分)对比

注意事项

  1. 离散化是一种「思想」,模版其实不用背,根据算法思想就可以实现。而且实现离散化的方式也可以在上述模板的基础上「修改」,千万「不要生搬硬套」(大家也会看到有些题解里面是借助「结构体」离散化的,但是核心的思想都是不变的);
  2. 前期学习离散化的时候可能会被「绕」进去,会把「离散前」和「离散后」的值搞混,分不清楚是用离散前的值还是离散后的值。觉得迷惑是「很正常」,一定要根据最基础的模板画图分析整个流程,搞清楚每一个变量的作用,以及达到的目的。离散化「使用的多」了,慢慢的就不会迷了。

2.1 火烧赤壁

火烧赤壁

【解法】

抛开数据范围不看,这就是一道「差分」题目:

  • 给定一个区间,我们可以全部执行 +1 操作;
  • 最后看看整个数组中,大于 0 的位置有多少个。

因此可以创建一个原数组的「差分」数组,然后执行完「区间修改」操作之后,还原原数组,「统计大于 0」的区间长度。

但是,这道题的「数据范围/区间长度」不允许我们直接差分,因为「开不了那么大」的数组;即使能开那么大的数组,「时间」也不够用,但是n的范围是2×104,区间的端点数最多是4×104

我们发现,区间的范围虽然很大,区间的「个数」却只有 2×104 级别。此时我们就可以:

  1. 先将所有的「区间信息」离散化;
  2. 然后在「离散化的基础」上,处理所有的「区间修改」操作;
  3. 处理完之后找出「原始数组对应的区间端点」,计算相应的「长度」。

下面展示两种写法,第一种是直接套模板写出来的代码

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

const int N = 2e4 + 10;
typedef long long LL;
unordered_map<LL, LL> id;
int n;
int pos;
LL a[N], b[N];
LL disc[N * 2];
LL f[N * 2]; 

int main()
{
	cin >> n;
	for(int i = 1; i <= n; i++)
	{
		cin >> a[i] >> b[i];
		disc[pos++] = a[i]; disc[pos++] = b[i];
	}
	//排序
	sort(disc + 1, disc + pos + 1);
	//离散化
	LL cnt = 0;
	for(int i = 1; i <= pos; i++)
	{
		LL x = disc[i];
		//去重 
		if(id.count(x)) continue;
		cnt++;
		id[x] = cnt;
	}
	//离散化的基础上做差分
	for(int i = 1; i <= n; i++)
	{
		//针对当前的着火信息找到离散化后对应的值
		LL l = id[a[i]], r = id[b[i]];
		f[l]++; f[r]--;
	}
	//还原数组,前缀和 
	for(int i = 1; i <= cnt; i++) f[i] += f[i - 1];
	
	//统计结果
	//记录着火区间的长度
	LL ret = 0;
	//遍历整个还原之后的数组 
	for(int i = 1; i <= cnt; i++)
	{
		int j = i;
		while(j <= cnt && f[j] > 0) j++;
		ret += disc[j] - disc[i];
		i = j;
	}
	cout << ret << endl;
	return 0;
}

该代码是有bug的,这里排序的时候disc数组还是没有去重的,sort处没有给disc去重的话ret += disc[j] - disc[i];就会有bug,此时在第i个位置想找之前未离散化的值的时候就会出错,比如说disc数组中有两个-1,第一个-1对应映射的位置是1,第二个-1映射的位置就是2了,此时就会出错

修改后的逻辑:

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

const int N = 2e4 + 10;
typedef long long LL;
unordered_map<LL, LL> id;
int n;
int pos;
LL a[N], b[N];
LL disc[N * 2];
LL f[N * 2];

int main()
{
	cin >> n;
	for(int i = 1; i <= n; i++)
	{
		cin >> a[i] >> b[i];
		disc[++pos] = a[i]; disc[++pos] = b[i];
	}
	//排序
	sort(disc + 1, disc + 1 + pos);
	//去重
	pos = unique(disc + 1, disc + 1 + pos) - (disc + 1);
	//离散化
	for(int i = 1; i <= pos; i++)
	{
		LL x = disc[i];
		id[x] = i;
	}
	//离散化的基础上做差分
	for(int i = 1; i <= n; i++)
	{
		LL l = id[a[i]], r = id[b[i]];
		f[l]++; f[r]--;
	}
	//对差分数组做还原
	for(int i = 1; i <= pos; i++) f[i] += f[i - 1];
	//统计结果
	LL ret = 0;
	for(int i = 1; i <= pos; i++)
	{
		int j = i;
		while(j <= pos && f[j] > 0) j++;
		ret += disc[j] - disc[i];
		i = j;
	}
	cout << ret << endl;
	 
	return 0;
}

这里的要点分析就以我当时看到该题的疑惑来解答:
在离散化的基础上做差分这里的循环区间为什么是1 到 n?

差分操作的目的是:对每一个原始区间 [a[i], b[i]) 标记它的覆盖范围

  • 代码里 a[i] 和 b[i] 是第 i 个原始区间的起点和终点,所以必须遍历 i=1~n,才能拿到所有原始区间的起止点。
  • 对每个原始区间,我们通过 id[a[i]] 和 id[b[i]] 映射到离散化后的索引 l 和 r,然后执行 f[l]++、f[r]--,完成一次差分标记。

为什么不能是 1~pos?

如果循环写成 1~pos,就会变成遍历离散化后的点,而不是原始区间:

  • 此时 i 代表的是离散点的索引,没有对应的 a[i] 和 b[i](a 和 b 数组只有 n 个元素),会直接数组越界或逻辑错误。
  • 差分标记只需要对每个原始区间做一次,和离散点的数量 pos 完全无关。

为什么差分数组大小是 N*2?

离散化后,每个端点对应一个 1~pos 的索引(pos 是去重后的端点数量,最多为 2n)。 差分数组的大小必须匹配离散化后的索引范围

差分数组 f 是用来标记区间 [a, b) 的:对离散化后的 l = id[a]、r = id[b],执行 f[l]++、f[r]--。

由于离散化后的索引最大为 2n,所以差分数组 f 必须和 disc 数组一样开 N*2 的大小,才能覆盖所有可能的索引,避免数组越界访问(比如 n=2e4 时,2n=4e4,如果 f 只开 N 大小,就会超出范围)。

2.2 贴海报

贴海报


【解法】

根据题意「模拟」即可。

由于「区间的长度」很大,暴力模拟的时候会超时。但是我们发现,虽然区间长度很大,但是「区间的个数」是很少的,所以我们可以「离散化」处理一下区间的端点值,然后在「离散化的基础上」模拟覆盖情况。

注意注意注意,重要的注意说三遍

离散化在离散「区间问题」的时候一定要小心!因为我们离散化操作会把区间缩短,从而导致丢失一些点。在涉及「区间覆盖」问题上,离散化会导致「结果出错」。

比如我们这道题,如果有三个区间分别为:[2,5]、[2,3]、[5,6],离散化之后为:[1,3]、[1,2]、[3,4],区间覆盖如图所示:

为了避免出现上述情况,我们可以在离散化的区间 [x,y] 时,不仅考虑 x,y 这两个值,也把「x+1,y+1」也考虑进去。此时「单个区间内部」就出现空隙,「区间与区间之间」也会出现空隙。就可以避免上述情况出现。

可见,离散化之后可能会导致结果错误,使用的时候还是需要「谨慎」一点。

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

const int N = 1010;
unordered_map<int, int> id;
int n, m;
//海报的起始位置,终止位置
int a[N], b[N];
//记录离散化之后有多少数
int pos;
//不仅要离散化海报的左右端点
//还要离散化对应左右端点的值 + 1,大小要开4倍
int disc[N * 4];
//海报墙
int w[N * 4];
//全局bool类型数组,默认值为0->false
bool st[N * 4];//标记那些海报已经出现过

int main()
{
	cin >> n >> m;
	for (int i = 1; i <= m; i++)
	{
		cin >> a[i] >> b[i];
		disc[++pos] = a[i]; disc[++pos] = a[i] + 1;
		disc[++pos] = b[i]; disc[++pos] = b[i] + 1;
	}
	//离散化
	sort(disc + 1, disc + 1 + pos);
	int cnt = 0;
	for (int i = 1; i <= pos; i++)
	{
		int x = disc[i];
		if (id.count(x)) continue;
		cnt++;
		id[x] = cnt;
	}
	//在离散化的基础上模拟贴海报的过程
	for (int i = 1; i <= m; i++)
	{
		//针对当前海报找到离散化之后的值
		int l = id[a[i]], r = id[b[i]];
		for (int j = l; j <= r; j++)
		{
			w[j] = i;
		}
	}
	int ret = 0;
	//统计结果 - 数组中不同数的个数
	for (int i = 1; i <= cnt; i++)
	{
		int x = w[i];
		//格子为0,当前位置没有海报
		//当前海报出现过,均continue
		if (!x || st[x]) continue;
		ret++;
		st[x] = true;
	}
	cout << ret << endl;
	return 0;
}

要点补充:

代码中 const int N = 1010 表示:海报的最大数量 m 不超过 1010(题目约束)。

1. 离散化的「4 倍值」来源

代码中对每个海报,都往 disc 数组中存入了 4 个值:

cpp 复制代码
// 每个海报i,存入4个值
disc[++pos] = a[i];     // 海报左端点
disc[++pos] = a[i] + 1; // 左端点+1
disc[++pos] = b[i];     // 海报右端点
disc[++pos] = b[i] + 1; // 右端点+1

1 张海报 → 4 个离散化候选值

m 张海报(最多 N 张)→ 最多 N * 4 个候选值

因此,disc 数组需要开 N * 4 来容纳所有候选值,这是「4 倍」的核心来源。

3. w 数组:必须开 N*4 的原因

w 数组的作用是:存储离散化后每个位置被哪张海报覆盖

  • 离散化后,所有候选值会被去重、排序,得到唯一的「离散化编号 cnt」(代码中 cnt 是离散化后的总位置数)。
  • cnt 的最大值 = 所有候选值的数量上限 = N * 4(极端情况下所有海报的 4 个值都不重复)。
  • 为了让 w 数组能覆盖所有离散化后的位置 ,必须把大小设为 N * 4(否则会出现数组越界)。
    4. st 数组:开 N*4 是「冗余但安全」的写法
    st 数组的作用是:标记某张海报是否已经被统计过(避免重复计数)。
  • 理论上,海报的编号 i 最大是 m <= N,所以 st 数组只需要开 N 就足够(比如 st[1010] 就能覆盖所有海报)。
  • 代码中开 N * 4 是「偷懒但安全」的写法:
    • 不需要精确计算 st 的最小大小,直接复用 N*4 的大小(和 w 保持一致);
    • 内存开销极小(bool st[4040] 仅占 4KB 左右),不会有性能问题;
    • 避免因边界判断失误导致数组越界。

结语

相关推荐
m0_569881473 小时前
基于C++的数据库连接池
开发语言·c++·算法
.select.3 小时前
c++ auto
开发语言·c++·算法
2401_884563243 小时前
C++中的访问者模式高级应用
开发语言·c++·算法
清风徐来QCQ3 小时前
全栈开发面试1
面试·职场和发展
智者知已应修善业3 小时前
【51单片机用两个定时计数器级联实现定时】2023-04-12
c语言·经验分享·笔记·算法·51单片机
君义_noip3 小时前
信息学奥赛一本通 1613:打印文章
c++·算法·信息学奥赛·csp-s
nglff3 小时前
蓝桥杯抱佛脚第四天|前缀和,差分对应练习
算法·职场和发展·蓝桥杯
freexyn4 小时前
Matlab入门自学七十四:坐标系转换,直角坐标、极坐标和球坐标的转换
开发语言·算法·matlab
咱就是说不配啊4 小时前
3.20打卡day34
数据结构·c++·算法