算法设计 - 分治

一、CDQ 分治

因为时间的关系,我也不确定我理解的这个套路是否是所谓的 “CDQ 分治”,还是只是一种具有二维偏序特征的模板。

在这种题目中,我们一般会对于一个二维结构体去排序,比如说

struct Node
{
int x;
int y;
};

而且排序一般会发生两次,第一次是在开始前先对某个维度进行一遍排序,然后在分治过程中,利用归并排序的思想,在二分的过程中对于另一个维度再次进行排序。也就是如下模板

struct Node
{
int x;
int y;
};
// 处理数组
Node a[MAXN];

bool cmp_x(const Node &a, const Node &b)
{
return a.x < b.x;
}

bool cmp_y(const Node &a, const Node &b)
{
return a.y < b.y;
}

void rec(int left, int right)
{
// 边界条件的处理,也可能是其他的
if (left == right)
{
return;
}

int mid = (left + right) >> 1;
rec(left, mid);
rec(mid + 1, right);

// 利用此时 [left, mid], [mid + 1, right] 已经相对于 y 有序的性质,进行一些操作
// process
// .....

// inplace_merge 是 std 里的一个方法,他会将有序的 [left, mid) 和 [mid, right) 数组合并成有序的 [left, right) 数组,同时不需要用临时数组
inplace_merge(a + left, a + mid + 1, a + right + 1, cmp_y);
}

int main()
{
// input a
sort(a + left, a + right + 1, sort_x);
rec(left, right);
}

按照 CDQ 分支的标准定义,它是 “每一次划分出来的两个子问题,前一个子问题用来解决后一个子问题,而不是其本身”,这个说法就比较的费解,不过考虑到二分的性质,所有的 “后一个问题” 就可以组成问题的全体,所以也不是太难理解。

至于为什么前一个问题可以解决后一个问题,我觉得它这里是侧重于两点,一个是在 rec 的操作过程中(就是注释的部分),经常遍历 [mid + 1, right] 这个区间上的元素,同时每次都考虑的是该区间上的元素与 [left, mid] 前面这个区间上全体元素的作用。那么具体利用的性质有两点:

  • [mid + 1, right] 上的所有元素都相对于 [left, mid] 上的元素在 x 维上有序,这是第一次排序导致的。
  • [left, mid] 上的元素是相对于 y 维有序的,这是第二次排序导致的。

我们利用第一条性质确定了作用关系,也就是说,因为右侧元素在 x 维上一定比左侧元素大,所以我们可以进行一定的处理。,第二条性质确定了作用方式,也就是说,因为左侧元素是有序的,所以我们无论是查找还是累加还是贪心,都是很方便的。

在算法运行的过程中,会发现原本相对于 x 有序的数组,逐渐变成了相对于 y 有序的数组,但是因为分治 “一层层” 进行的特性,所以即使有序性逐渐丧失和重构,也会在母问题中保证对于 x 的有序性,在子问题中保证低于 y 的有序性。

在复杂性分析方面,因为 mergeO(n)O(n) 的,所以只要 process 的过程是一个复杂度不超过 O(n2)O(n^2) 的过程,这要求对于右侧每个元素的处理时间复杂度要小于 O(n)O(n) ,也就意味着基本上要利用到有序的性质,那么就可以达到优化的目的。

首先看求解逆序数的题目

逆序对

题目描述

猫猫 TOM 和小老鼠 JERRY 最近又较量上了,但是毕竟都是成年人,他们已经不喜欢再玩那种你追我赶的游戏,现在他们喜欢玩统计。

最近,TOM 老猫查阅到一个人类称之为 “逆序对” 的东西,这东西是这样定义的:对于给定的一段正整数序列,逆序对就是序列中 ai>aja_i>a_ji<ji<j 的有序对。知道这概念后,他们就比赛谁先算出给定的一段正整数序列中逆序对的数目。注意序列中可能有重复数字。

输入格式

第一行,一个数 nn,表示序列中有 nn 个数。

第二行 nn 个数,表示给定的序列。序列中每个数字不超过 10910^9

输出格式

输出序列中逆序对的数目。

样例 #1

样例输入 #1
6
5 4 2 6 3 1
样例输出 #1
11

提示

对于 25%25\% 的数据,n2500n \leq 2500

对于 50%50\% 的数据,n4×104n \leq 4 \times 10^4

对于所有数据,n5×105n \leq 5 \times 10^5

这道题的答案如下

#include <cstdio>
#include <iostream>
using namespace std;
int n, a[500010], c[500010];
long long ans;

void rec(int left, int right)
{
if (left == right)
return;

int mid = (left + right) / 2, i = left, j = mid + 1, k = left;
rec(left, mid), rec(mid + 1, right);

// 这里将 process 和 merge 的过程融合到了一起
while (i <= mid && j <= right)
if (a[i] <= a[j])
{
c[k++] = a[i++];
}
else
{
c[k++] = a[j++];
// 统计答案,利用的是 [left, mid] 的有序性,所以可以直接计算出在 [left, mid] 中大于 a[j] 的元素的个数
ans += mid - i + 1;
}
while (i <= mid)
c[k++] = a[i++];
while (j <= right)
c[k++] = a[j++];
for (int l = left; l <= right; l++)
a[l] = c[l];
}

int main()
{
scanf("%d", &n);
for (int i = 1; i <= n; i++)
scanf("%d", &a[i]);
rec(1, n);
printf("%lld", ans);
return 0;
}

这道题有一些相对于模板的变异:

  • 没有对第一维进行排序:这是因为本质上第一维就是数组下标 index ,所以一定是已经排序好的。
  • mergeprocess 是混合在一起的:这是因为是可以混合在一起的,其实分开也可以。

那么这道题是如何利用二次排序的性质的呢?我们首先回顾逆序数的定义

i<j,a[i]>a[j]i < j, \quad a[i] > a[j]

所以有

  • 第一次排序(实际上没有排序)保证了右侧区间的 index 一定是大于左侧区间的 index 的,所以也就是一定有 i < j (如果 i 在左侧区间,j 在右侧区间),所以我们的下一步就是找到 a[i] > a[j] 的个数(固定 j)。
  • 如果一个个遍历左侧区间去获得 a[i] > a[j] 的个数,那么是不可以的,因为此时这样对于每个右侧元素的时间复杂度就变成了 O(n)O(n) ,但是幸亏保证了左侧区间是是有序的,所以我们可以直接利用一个指针进行计算(也就是 a[j + 1] 一定比 a[j] 的逆序数对数多或者相等 )。

然后看平面最小点对,这个题目严格意义上并不是 CDQ 分治

平面上的最接近点对

题目描述

给定平面上 nn 个点,找出其中的一对点的距离,使得在这 nn 个点的所有点对中,该距离为所有点对中最小的。

输入格式

第一行一个整数 nn,表示点的个数。

接下来 nn 行,每行两个整数 x,yx,y ,表示一个点的行坐标和列坐标。

输出格式

仅一行,一个实数,表示最短距离,四舍五入保留 44 位小数。

样例 #1

样例输入 #1
3
1 1
1 2
2 2
样例输出 #1
1.0000

提示

数据规模与约定

对于 100%100\% 的数据,保证 1n1041 \leq n \leq 10^40x,y1090 \leq x, y \leq 10^9,小数点后的数字个数不超过 66

题解如下

#include <bits/stdc++.h>

using namespace std;

struct Point
{
double x;
double y;
};

const int MAXN = 1e4 + 5;
Point p[MAXN];
int n;
double min_dist = 1e20;

inline void update_ans(const Point &a, const Point &b)
{
double dist = sqrt((a.x - b.x) * (a.x - b.x) + (a.y - b.y) * (a.y - b.y));
// cout << "dist: " << dist << endl;
if (min_dist > dist)
{
min_dist = dist;
}
}

bool cmp_x(const Point &a, const Point &b)
{
if (a.x == b.x)
{
return a.y < b.y;
}
else
{
return a.x < b.x;
}
}

bool cmp_y(const Point &a, const Point &b)
{
if (a.y == b.y)
{
return a.x < b.x;
}
else
{
return a.y < b.y;
}
}

void rec(int left, int right)
{
// 基准情况,当区间缩小为 3 的时候,进行逐一比较,至于为啥不到 2 ,我也不清楚
if (right - left <= 3)
{
for (int i = left; i <= right; i++)
{
for (int j = i + 1; j <= right; j++)
{
update_ans(p[i], p[j]);
}
}
// 对于 [left, right] 区间进行按照 y 轴的排序
sort(p + left, p + right + 1, cmp_y);
return;
}

int mid = (left + right) >> 1;
// 在分治前需要记录相对于 x 轴排序的 mid 的中点的 x 坐标,用于在中线处比较
double mid_x = p[mid].x;
// 进行分治,分治的结果是 [left, mid], [mid + 1, right] 两个区间的点都按照 y 轴排序
rec(left, mid);
rec(mid + 1, right);

// inplace_merge 表示一种就地的 merge 行为,即将 [left, mid), [mid, right) 两个序列整合成 [left, right) 的有序序列
// 这种 merge 本来应该是需要临时数组的,但是 inplace_merge 并不需要
// 经过 merge 后 [left, right) 都是按照 y 轴排列的了
inplace_merge(p + left, p + mid + 1, p + right + 1, cmp_y);

// 这个 vector 中存储着所有的离 mid 近的点
vector<Point> near;
// 遍历所有的点,填写 near,更新答案
for (int i = left; i <= right; i++)
{
// 这是满足进入 near 的条件,新的 min_dist 有可能在 near 中产生
if (abs(p[i].x - mid_x) < min_dist)
{
// 遍历已有的 near,让 p[i] 与 near 相比较,near 一定是 y 升序的
for (int j = near.size() - 1; j >= 0 && p[i].y - near[j].y < min_dist; j--)
{
update_ans(near[j], p[i]);
}
// 填充 near
near.push_back(p[i]);
}
}
}

int main()
{
int n;
cin >> n;
for (int i = 0; i < n; i++)
{
cin >> p[i].x >> p[i].y;
}
sort(p, p + n, cmp_x);
rec(0, n - 1);

printf("%.4lf", min_dist);
return 0;
}

这个题目十分符合两次排序的特征:

  • 第一次排序对于 x 轴进行,这样使得点可以被划分成左右两侧。
  • 第二次排序对于 y 轴进行,并且是在分治过程中完成的。

但是这道题的答案并不是利用左区间的性质解决右区间得出来的,但是依然利用了两次排序的性质:

  • 根据第一次排序的结果,我们找到了 x 轴中线的位置,这样才方便结束了对于两个子问题的二分后,讨论中线附近依然可能存在最小点对的可能。
  • 我们是先进行 merge,然后进行处理,也就是说,处理的时候 [left, right] 已经是一个关于 y 轴有序的数组了,此时我们再进行遍历,可以利用有序性简化 near 数组间的比较(看上去应该是一个 O(n)O(n) 的时间,实际上 O(1)O(1) 就可以完成)。

下面一道题可以说是 CDQ 分治的板子题,如下所示

[USACO04OPEN] MooFest G

题目描述

约翰的 nn 头奶牛每年都会参加 “哞哞大会”。

哞哞大会是奶牛界的盛事。集会上的活动很多,比如堆干草,跨栅栏,摸牛仔的屁股等等。

它们参加活动时会聚在一起,第 ii 头奶牛的坐标为 xix_i,没有两头奶牛的坐标是相同的。

奶牛们的叫声很大,第 ii 头和第 jj 头奶牛交流,会发出 max{vi,vj}×xixj\max\{v_i,v_j\}\times |x_i − x_j | 的音量,其中 viv_ivjv_j 分别是第 ii 头和第 jj 头奶牛的听力。

假设每对奶牛之间同时都在说话,请计算所有奶牛产生的音量之和是多少。

输入格式

第一行:单个整数 nn1n2×1041\le n\le2\times 10^4

第二行到第 n+1n + 1 行:第 i+1i + 1 行有两个整数 viv_ixix_i1vi,xi2×1041\le v_i,x_i\le2\times 10^4)。

输出格式

单个整数:表示所有奶牛产生的音量之和

样例 #1

样例输入 #1
4
3 1
2 5
2 6
4 3
样例输出 #1
57

其题解如下

#include <bits/stdc++.h>
using namespace std;

struct Cow
{
int x;
int v;
};

const int MAXN = 1e5 + 5;
Cow a[MAXN], tmp[MAXN];
int n;
long long ans;

bool cmp(const Cow &a, const Cow &b)
{
return a.v < b.v;
}

bool cmp_x(const Cow &a, const Cow &b)
{
return a.x < b.x;
}

void CDQ(int left, int right)
{
if (left >= right)
{
return;
}

int mid = (left + right) >> 1;
CDQ(left, mid);
CDQ(mid + 1, right);
long long s1 = 0, s2 = 0;

// 此时 s1 是 [left, mid] 的 x 的和
for (int i = left; i <= mid; i++)
{
s1 += a[i].x;
}

int cur = left;
// 遍历右侧区间,计算右侧区间每个元素对左侧区间所有元素的声音
// 对于右侧的每个元素 a[i],其公式为:
// a[i].x * cnt1 - s2 - a[i].x * cnt2 + s1
// cnt1 是左侧区间中小于 a[i].x 的元素的个数
// cnt2 是左侧区间中大于 a[i].x 的元素的个数
// s1 是左侧区间中小于 a[i].x 的元素的和
// s2 是左侧区间中大于 a[i].x 的元素的和
// 至于为啥要这样计算,是因为可以避免遍历
// 计算利用了左右侧区间的 x 升序特性
for (int i = mid + 1; i <= right; i++)
{
// cur 是左侧区间的一个分界线,其左侧都是小于 a[i].x
while (cur <= mid && a[cur].x < a[i].x)
{
s2 += a[cur].x;
s1 -= a[cur].x;
cur++;
}
long long cnt1 = cur - left;
long long cnt2 = mid - cur + 1;
ans += ((cnt1 * a[i].x - s2) + (-cnt2 * a[i].x + s1)) * a[i].v;
}

// 进行归并排序
inplace_merge(a + left, a + mid + 1, a + right + 1, cmp_x);
}

int main()
{
cin >> n;
for (int i = 0; i < n; i++)
{
cin >> a[i].v >> a[i].x;
}

sort(a, a + n, cmp);

CDQ(0, n - 1);

cout << ans;
return 0;
}

这道题属于是,无论是 max(vi,vj)max(v_i, v_j) 还是 xixj\vert x_i - x_j \vert 都在暗示,必须将枚举 (i,j)(i, j) 对,才可以解决这个问题,但是实际上,我们可以利用两次排序,避免枚举:

  • 首先对于 v 进行排序
  • 然后在分治过程中,对于 x 进行排序。

我们是这样利用性质的(设 i 为左区间元素,j 为右区间元素):

  • 因为右区间的 v 一定是大于左区间的 v 的,所以 max(vi,vj)=vjmax(v_i, v_j) = v_j ,所以我们可以考虑对于每一个 j ,其左区间上是如何的(可以合并同类项了)
  • 因为左区间在 x 上是有序的,所以对于 xixj\vert x_i - x_j \vert 这个问题,当固定 j 的时候,一定存在一个 cur 使得当 i < cur 的时候,xi<xjx_i < x_j ,当 i > cur 的时候,xi>xjx_i > x_j ,那么就可以将绝对值去掉了,并且不再是加法而改成了乘法。