蓝桥杯算法精讲:深剖分治算法及其经典应用

目录

  • 前言
  • 一、分治
    • [1.1 逆序对](#1.1 逆序对)
    • [1.2 求第 k 小的数](#1.2 求第 k 小的数)
    • [1.3 最大子段和](#1.3 最大子段和)
    • [1.4 地毯填补问题](#1.4 地毯填补问题)
  • 结语

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

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


前言

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

一、分治

1.1 逆序对

逆序对

分治,字面上的解释是「分而治之」,就是把一个复杂的问题分成两个或更多的相同或相似的子问题,直到最后子问题可以简单的直接求解,原问题的解即子问题的解的合并。


【解法】

「分治」是解决「逆序对」非常经典的解法,后续主播也会出文章利用「树状数组」或「线段树」解决逆序对问题。

如果把整个序列 [l,r] 从中间 mid 位置分成两部分,那么逆序对个数可以分成三部分:

  • l,mid\] 区间内逆序对的个数 c1;

  • 从 [l,mid] 以及 [mid+1,r] 各选一个数,能组成的逆序对的个数 c3

那么逆序对的总数就是 c1+c2+c3。其中求解 c1,c2 的时候跟原问题是一样的,可以交给「递归」去处理,那我们重点处理「一左一右」的情况。

如果在处理一左一右的情况时,两个数组「无序」,找逆序对的时候只能两层for循环,因为数组无序,右边序列固定一个数,在左边序列找比其大的数需要从前往后遍历,我们的时间复杂度其实并没有优化到哪去,每分一层需要在两个序列来两个for循环,整体时间复杂度为n^2logn,因为分治的整个过程其实是和归并是一样的,一共可以分logn层,每一层都是一个n2级别的时间复杂度,甚至还「不如暴力解法」。但是如果两个数组有序的话,我们就可以快速找出逆序对的个数。

先不管怎么求逆序对,我们能让左右两个数组有序嘛?当然可以,这不就是「归并排序」么。因此,我们能做到在求完 c1,c2 之后,然后让「左右两个区间有序」。

那么接下来问题就变成,已知两个「有序数组」,如何求出左边选一个数,右边选一个数的情况下的逆序对的个数。核心思想就是找到右边选一个数之后,左边区间内「有多少个比我大的」。

定义两个「指针」扫描两个有序数组:此时会有下面三种情况:

  1. a[cur1]≤a[cur2]:a[cur1] 不会与 [cur2,r] 区间内任何一个元素构成逆序对,cur1++;
  2. a[cur1]>a[cur2]:此时 [cur1,mid] 区间内所有元素都会与 a[cur2] 构成逆序对,逆序对个数增加 mid−cur1+1,此时 cur2 已经统计过逆序对了,cur2++;

重复上面两步,我们就可以在 O(N) 的时间内处理完「一左一右」时,逆序对的个数。而且,我们会发现,这跟我们「归并排序的过程」是高度一致的。所以可以一边排序,一边计算逆序对的个数。

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

const int N = 5e5 + 10;
typedef long long LL;
LL n;
LL a[N];
//辅助数组,帮助合并有序数组
LL tmp[N];

//求对应区间的逆序对个数+归并排序
LL merge(LL left, LL right)
{
	//区间不存在或只有一个元素
	if(left >= right) return 0;
	//标记逆序对个数
	LL ret = 0;
	LL mid = (left + right) / 2;
	ret += merge(left, mid);
	ret += merge(mid + 1, right);
	//一左一右的情况,在合并有序数组的过程中完成
	LL cur1 = left, cur2 = mid + 1, i = left;
	while(cur1 <= mid && cur2 <= right)
	{
		if(a[cur1] <= a[cur2]) tmp[i++]  = a[cur1++];
		else{
			ret += mid - cur1 + 1;
			tmp[i++] = a[cur2++];
		}
	}
	//cout << "1" << endl;
	//在上面的循环跳出的时候,左右两个区间必有一个是合并完成的
	//所以下面两个循环只会进入一个
	while(cur1 <= mid) tmp[i++] = a[cur1++];
	while(cur2 <= right) tmp[i++] = a[cur2++];
	
	//cout << "1" << endl;
	//将辅助数组中的内容拷贝到原始数组当中
	for(int j = left; j <= right; j++) a[j] = tmp[j];
	return ret;
}
 

int main()
{
	cin >> n;
	for(int i = 1; i <= n; i++) cin >> a[i];
	//1~n区间之间的逆序对个数 
	cout << merge(1, n) << endl;
	return 0;
}

这里再分享一个调试技巧,主播今天和一个程序员学的哈哈,如果程序死循环了,可以在代码的循环结束部分随便打印一个数据,比如说cout << "1" << endl;如果在该位置打印不出结果,则说明上面的代码没有问题,反之

1.2 求第 k 小的数

求第k小的数

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

const int N = 5e6 + 10;
typedef long long LL;
LL a[N];
LL n, k;

//区间内选择基准元素
LL get_random(LL left, LL right)
{
	return a[rand() % (right - left + 1) + left];
}

LL quick_select(LL left, LL right, LL k)
{
	//如果当前区间只有一个元素,第k小必定是当前元素 
	if(left >= right) return a[left];
	
	//只有当数组分完三块之后,最后部分才是快速选择算法
	//前面还是快排的思路
	
	//选择基准元素
	LL p = get_random(left, right);
	//数组分三块,三路快排 
	LL l = left - 1, i = left, r = right + 1;
	while(i < r)
	{
		if(a[i] < p) swap(a[++l], a[i++]);
		else if(a[i] == p) i++;
		else swap(a[--r], a[i]);
	}
	//选择存在最终结果的区间
	//[left, l] [l + 1, r - 1] [r, right] 
	LL a = l - left + 1, b = r - 1 - (l + 1) + 1, c = right - r + 1;
	if(k <= a) return quick_select(left, l, k);
	//不用写k > a了,上面if条件不成立k > a天然成立
	else if(k <= a + b) return p;
	else return quick_select(r, right, k - a - b);
}

int main()
{
	//随机选择基准元素,种随机数种子
	srand(time(0));
	//cin >> n >> k;
	scanf("%lld%lld", &n, &k);
	//和常识对标,题目最小的数是最0小
	k++;
	for(int i = 1; i <= n; i++) //cin >> a[i];
	{
		scanf("%lld", &a[i]);
	}
	//cout << quick_select(1, n, k) << endl;
	printf("%lld\n", quick_select(1, n, k));
	return 0;
}

接下来详细解析一下代码,这道题目我个人感觉还是有些难度的,其中还用到了三路快排的这种算法,不了解这个算法的详细见我这篇文章从三路快排到内省排序:探索工业级排序算法的演进

一、main函数:输入处理与参数转换

cpp 复制代码
srand(time(0));                  // 初始化随机数种子,用于后续随机选基准
scanf("%d%d", &n, &k);           // 读入n(元素个数)和k(题目定义:第0小是最小值)
k++;                             // 关键转换:题目k是0-based → 算法里用1-based
for(int i = 1; i <= n; i++)      // 读入n个元素到数组a(下标从1开始,1-based)
{
    scanf("%d", &a[i]);
}
printf("%d\n", quick_select(1, n, k)); // 调用快速选择,输出结果

为什么 k++?

题目规定「最小的数是第 0 小」(0-based),而quick_select函数是按1-based设计的(第 1 小 = 最小值,第 2 小 = 次小值...),所以需要把输入的 k 加 1,转换成算法能识别的 1-based 索引。

二、核心函数:quick_select(分治找第 k 小)

cpp 复制代码
int quick_select(int left, int right, int k)
{
    if(left >= right) return a[left]; // 递归终止:区间只剩一个元素,直接返回

    // 1. 随机选择基准元素p
    int p = get_random(left, right);

    // 2. 三向切分:把数组分成 <p、=p、>p 三块
    int l = left - 1, i = left, r = right + 1;
    while(i < r)
    {
        if(a[i] < p) swap(a[++l], a[i++]);   // 小于p → 移到左边
        else if(a[i] == p) i++;             // 等于p → 留在中间
        else swap(a[--r], a[i]);            // 大于p → 移到右边
    }

    // 3. 计算三块的大小
    int a_size = l - left + 1;    // 小于p的元素个数
    int b_size = r - 1 - l;       // 等于p的元素个数
    int c_size = right - r + 1;   // 大于p的元素个数

    // 4. 根据k的位置,选择递归区间或直接返回
    if(k <= a_size) return quick_select(left, l, k);          // k在「小于p」区
    else if(k <= a_size + b_size) return p;                   // k在「等于p」区
    else return quick_select(r, right, k - a_size - b_size);  // k在「大于p」区
}

1. 随机选择基准:get_random

cpp 复制代码
int get_random(int left, int right)
{
    return a[rand() % (right - left + 1) + left];
}

作用:在[left, right]区间内随机选一个元素作为基准p,避免了「数组已有序」时的最坏情况(O (n²)),保证期望时间复杂度 O (n)。

原理:rand() % (right-left+1)生成[0, right-left]的随机数,加上left后得到[left, right]的随机下标。

3. 三向切分(荷兰国旗问题)

这一步是把数组分成小于 p、等于 p、大于 p三个连续区域:

  • l:指向「小于 p」区域的最右边界(初始在区间左边界外left-1)
  • i:遍历指针,从left开始扫描
  • r:指向「大于 p」区域的最左边界(初始在区间右边界外right+1)

循环逻辑:

  • a[i] < p:把这个元素交换到「小于 p」区(l右移后交换),i继续右移
  • a[i] == p:元素属于中间区,直接i右移
  • a[i] > p:把这个元素交换到「大于 p」区(r左移后交换),i不动(因为交换来的新元素还没处理)

循环结束后,数组被划分为:

  • 小于 p:[left, l]
  • 等于 p:[l+1, r-1]
  • 大于 p:[r, right]

4. 分治选择区间

根据 k 落在哪个区域,决定下一步递归方向:

  1. k ≤ 小于 p 的元素个数:第 k 小在「小于 p」区,递归处理[left, l],k 不变
  2. k ≤ 小于 p + 等于 p 的元素个数:第 k 小在「等于 p」区,直接返回基准p(这部分所有元素都等于 p)
  3. 否则:第 k 小在「大于 p」区,递归处理[r, right],k 需要减去前两部分的数量(k - a_size - b_size)

三、样例走一遍

假设现在找第2小的数
第二步:进入 quick_select (1, 5, 2)

cpp 复制代码
int quick_select(int left=1, int right=5, int k=2)
{
    // 1. 递归终止条件:left >= right?1 >=5?不成立,继续
    if(left >= right) return a[left];

    // 2. 随机选基准值 p
    // 区间[1,5]随机选一个元素,我们假设选中 a[3]=2
    int p = 2;

    // 3. 初始化三向切分指针(核心!)
    int l = left - 1 = 0;   // 小于p区域的右边界(初始在区间外)
    int i = left = 1;       // 遍历指针(从左到右扫)
    int r = right + 1 = 6;  // 大于p区域的左边界(初始在区间外)

    // 4. 核心循环:三向切分,把数组分成 <2、=2、>2
    while(i < r)
    {

第三步:三向切分 逐次循环(关键)

当前数组:[ 4, 3, 2, 1, 5 ] 指针:l=0,i=1,r=6 基准:p=2
第 1 次循环:i=1,a [i]=4

cpp 复制代码
if(a[i] < p) → 4 < 2?不成立
else if(a[i]==p) →4==2?不成立
else → 执行 swap(a[--r], a[i])

第 2 次循环:i=1,a [i]=5

cpp 复制代码
a[i]=5 > 2 → 执行 swap(a[--r], a[i])

r=4

交换 a[4] 和 a[1]

数组变为:[1, 3, 2, 5, 4]

指针:l=0,i=1,r=4

第 3 次循环:i=1,a [i]=1

cpp 复制代码
a[i]=1 < 2 → 执行 swap(a[++l], a[i++])

l=1,交换 a[1] 和 a[1](无变化)

i 加 1 → i=2

数组:[1, 3, 2, 5, 4]

指针:l=1,i=2,r=4

第 4 次循环:i=2,a [i]=3

cpp 复制代码
a[i]=3 > 2 → 执行 swap(a[--r], a[i])

r=3

交换 a[3] 和 a[2]

数组变为:[1, 2, 3, 5, 4]

指针:l=1,i=2,r=3

第 5 次循环:i=2,a [i]=2

cpp 复制代码
a[i]==p → 直接 i++

i=3

指针:l=1,i=3,r=3

循环结束!

因为 i=3 不小于 r=3,退出循环 ✔️

第四步:划分最终区间(循环结束后的结果)

数组最终:[1, 2, 3, 5, 4]

三个区域严格划分:

  1. 小于 p (2):[left, l] = [1, 1] → 元素:1
  2. 等于 p (2):[l+1, r-1] = [2, 2] → 元素:2
  3. 大于 p (2):[r, right] = [3,5] → 元素:3,5,4

第五步:计算区间大小,判断 k 的位置

cpp 复制代码
// 计算三个区域的元素个数
int a_size = l - left + 1 = 1 - 1 +1 = 1  (小于2的有1个)
int b_size = r-1 - l = 2-1-1 =1          (等于2的有1个)
int c_size = right - r +1=5-3+1=3        (大于2的有3个)

// 判断k=2
if(k <= a_size) → 2<=1?不成立
else if(k <= a_size + b_size) → 2 <= 1+1=2 → 成立!
return p;

第六步:返回结果,程序结束

直接返回基准值 2

四、代码优化与注意点

  1. 输入输出效率:用scanf/printf而非cin/cout,因为 n 最大到 5×10⁶,C++ 标准输入输出流如果不关闭同步会很慢,这道题使用cin,cout就会超时。
  2. 随机化种子:srand(time(0))必须在main里调用一次,否则rand()会生成相同序列。

三路划分后,右边区间内部是乱的,为什么敢直接递归进去找第 k 小?不会找错吗?

我们找第 k 小,只需要知道:第 k 小的数,属于「最小的一批」「中间的一批」还是「最大的一批」 ,不需要知道它在区间里的具体位置

举个例子:假设我们要找 第 4 小(k=4)

左区间有 1 个元素(最小的 1 个数)

中区间有 1 个元素(次小的 1 个数)

前两个区间加起来一共 2 个数

k=4 > 2 → 说明第 4 小的数,一定在右区间里(最大的那一批数)。

👉 右区间里全是比前两个区间大的数,所以 ** 只需要在右区间里找「第 4-2=2 小」** 就行,哪怕右区间是乱的,递归进去后,会再次做三路划分,继续缩小范围,最终一定能找到正确值。

模拟:k=4(找第 4 小),递归右区间

原数组:[1,2,3,5,4]

右区间:[3,5,4](下标 3~5)

k=4,前两个区间共 2 个元素 → 新 k=4-2=2

  1. 进入递归:quick_select(3,5,2)
  2. 随机选基准,比如p=4
    三路划分右区间[3,5,4]:
    左区间[3](<4)
    中区间[4](=4)
    右区间[5](>4)
  3. 新 k=2:落在中区间,直接返回4

✅ 正确!数组排序后[1,2,3,4,5],第 4 小就是 4。

1.3 最大子段和

最大子段和

【解法】

第三次遇见它了~

如果把整个序列 [l,r] 从中间 mid 位置分成两部分,那么整个序列中「所有的子数组」就分成三部分:

  • 子数组在区间 [l,mid] 内;
  • 子数组在区间 [mid+1,r] 内;
  • 子数组的左端点在 [l,mid] 内,右端点在 [mid+1,r] 内。

那么我们的「最终结果」也会在这三部分取到,要么在左边区间,要么在右边区间,要么在跨越中轴线的区间。因此,我们可以先求出左边区间的最大子段和,再求出右边区间的最大子段和,最后求出中间区间的最大子段和。最终结果就是三段中的max一段。其中求「左右区间」时,可以交给「递归」去解决。

那我们重点处理如何求出「中间区间」的最大子段和。可以把中间区间分成两部分:

  • 左边部分是从 mid 为起点,「向左延伸」的最大子段和;
  • 右边部分是从 mid+1 为起点,「向右延伸」的最大子段和。

分别求出这两个值,然后相加即可。求法也很简单,直接「固定起点」,一直把「以它为起点的所有子数组」的和都计算出来(找一左一右的时候相当于一个枚举过程,从mid为尾,向左枚举,以mid + 1为头,向右枚举),取最大值即可。

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

const int N = 2e5 + 10;
typedef long long LL;
LL n;
LL a[N];

LL dfs(LL left, LL right)
{
	//只有一个元素的时候最大子段和必定是这个元素
	if(left >= right) return a[left];
	LL mid = (left + right) / 2;
	//分别去左右区间找最大子段和
	LL ret = max(dfs(left, mid), dfs(mid + 1, right));
	//求一左一右的最大子段和
	//sum记录从mid向左的区间和
	//lmax记录向左求区间和时候的最大值
	LL sum = a[mid], lmax = a[mid];
	for(int i = mid - 1; i >= left; i--)
	{
		sum += a[i];
		lmax = max(lmax, sum);
	}
	//求右边的最大子段和
	sum = a[mid + 1]; LL rmax = a[mid + 1];
	for(int i = mid + 2; i <= right; i++)
	{
		sum += a[i];
		rmax = max(rmax, sum);
	}
	ret = max(ret, lmax + rmax);
	return ret;
}

int main()
{
	cin >> n;
	for(int i = 1; i <= n; i++) cin >> a[i];
	cout << dfs(1, n) << endl;
	return 0;
}

时间复杂度分析

整个过程依旧类似归并排序,处理一左一右的时候,情况是遍历整个数组一遍,因此分治解这道题的时间复杂度也是nlogn,和归并排序的时间复杂度一样

1.4 地毯填补问题

地毯填补问题



【解法】

非常经典的一道分治题目,也可以说是一道递归题目。

一维分治的时候,我们是从中间把整个区间切开,分成左右两部分(其实有时候我们可以三等分,就看具体问题是什么)。二维的时候,我们可以横着一刀竖着一刀,分成左上、右上、左下、右下四份。而这道题的矩阵长度正好是 2k,能够被不断平分下去。像是在暗示我们,要用分治,要用分治,要用分治......

当我们把整个区间按照中心点一分为四后,四个区间里面必然有一个区间有缺口(就是公主的位置),那这四个区间不一样,那就没有相同子问题了。别担心,只要我们在中心位置放上一块地毯,四个区间就都有一个缺口了。如下图所示:

无论缺口在哪里,我们都可以在缺口对应的区间的角落,放上一个地毯。接下来四个区间都变成只有一个缺口的形式,就可以用递归处理子问题。

因此,我们拿到一个矩阵后的策略就是:

  • 先四等分;
  • 找出缺口对面的区间,放上一块地毯;
  • 递归处理四个子问题
cpp 复制代码
#include<iostream>
using namespace std;

int k, x, y;

void dfs(int a, int b, int len, int x, int y)
{
	//长度为1,放不下毯子 
	if(len == 1) return;
	len /= 2;
	if(x < a + len && y < b + len) //障碍物在左上角
	{
		//摆上1号地毯
		cout << a + len << " " << b + len << " " << 1 << endl;
		dfs(a, b, len, x, y);//左上角 
		dfs(a, b + len, len, a + len - 1, b + len);//右上角 
		dfs(a + len, b, len, a + len, b + len - 1);//左下角 
		dfs(a + len, b + len, len, a + len, b + len);//右下角 
	}
	else if(x >= a + len && y >= b + len)
	{
		//摆上4号地毯
		cout << a + len - 1 << " " << b + len - 1 << " " << 4 << endl;
		dfs(a, b, len, a + len - 1, b + len - 1);//左上角
		dfs(a, b + len, len, a + len - 1, b + len);//右上角
		dfs(a + len, b, len, a + len, b + len - 1);//左下角
		dfs(a + len, b + len, len, x, y);//右下角
	}
	//若前面两个条件不成立,只有左下角和右上角了
	//此时只需限定一个横/纵坐标即可
	else if(x >= a + len)//障碍物在左下角
	{
		cout << a + len - 1 << " " << b + len << " " << 3 << endl;
		dfs(a, b, len, a + len - 1, b + len - 1);//左上角
		dfs(a, b + len, len, a + len - 1, b + len);//右上角
		dfs(a + len, b, len, x, y);//左下角
		dfs(a + len, b + len, len, a + len, b + len);//右下角
	}else{
		cout << a + len << " " << b + len - 1 << " " << 2 << endl;
		dfs(a, b, len, a + len - 1, b + len - 1);//左上角
		dfs(a, b + len, len, x, y);//右上角
		dfs(a + len, b, len, a + len, b + len - 1);//左下角
		dfs(a + len, b + len, len, a + len, b + len);//右下角
	}
}

int main()
{
	cin >> k >> x >> y;
	//矩阵长度
	k = (1 << k);
	//矩阵起始位置,矩阵的长度,障碍物位置
	dfs(1, 1, k, x, y);
	return 0;
}

len表示矩阵初始长度,研究的时候是研究1/2的位置,当k = 3的时候,len就是4个格子的长度,当障碍物在左上角的时候,障碍物坐标的取值范围如图,也就是横纵坐标小于画点的格子的坐标

代码中障碍物在左上角对应图中情况,障碍物在左上角是在 (a + len, b + len) 放1号类型的地毯,摆上地毯之后要处理四个大格子(16)的子问题,之后就是4个dfs

第一个子问题即左上角的子问题,矩阵的起始位置是(a, b),长度是len(前面已经除2),障碍物依旧在 (x, y) 的位置上

第二个子问题即右上角的位置,其左上角的坐标 (a, b + len),长度为len,障碍物的坐标位置是(a + len - 1, b + len)

...

障碍物在右下角的时候,其横纵坐标的限制也比较好找(x >= a + len, y >= b + len)

障碍物在左下角的情况:

障碍物在右上角

补充一下这道题的测试方法是Special Judge,我们的代码的输出结果就算与样例不同,顺序是乱的也没有关系


结语

相关推荐
志摩凛2 小时前
范畴论——前端与计算机领域的“抽象工具箱”:该用则用,该弃则弃
算法·架构
2401_857918292 小时前
C++与自动驾驶系统
开发语言·c++·算法
乐分启航2 小时前
【无标题】
深度学习·算法·目标检测·transformer·迁移学习
GfovikS061002 小时前
C++中的函数式编程
开发语言·c++·算法
2401_857918292 小时前
C++中的构建器模式
开发语言·c++·算法
酉鬼女又兒2 小时前
零基础快速入门前端JavaScript Array 常用方法详解与实战(可用于备赛蓝桥杯Web应用开发)
开发语言·前端·javascript·chrome·蓝桥杯
穿条秋裤到处跑2 小时前
每日一道leetcode(2026.03.25):等和矩阵分割 I
算法·leetcode·矩阵
实心儿儿2 小时前
算法9:相同的树
算法·leetcode·职场和发展
Zarek枫煜2 小时前
zig与c3的算法 -- 静态队列
开发语言·stm32·单片机·嵌入式硬件·算法·51单片机