「分治入门」课件配套资料
写在前面
讲课课件配套资料。
课件下载地址:
链接:https://pan.baidu.com/s/1pbw5e4ReLrILjeG8REjfAA
提取码:88f0
隐去了部分涉及个人隐私的部分。
概念篇
什么是递归
基本思想是某个函数直接或者间接地调用自身,这样原问题的求解就转换为了许多性质相同但是规模更小的子问题。
求解时只需关注如何把原问题划分成符合条件的子问题,而不需要过分关注这个子问题是如何被解决的。
递归代码最重要的两个特征:结束条件 和 自我调用。
自我调用是在解决子问题,而结束条件定义了最简子问题的答案。
以下是一些例子:
- 一次令人满意的搜索体验
- 如何给一堆数字排序?答:一个数字肯定是有序的,否则分成两半,先排左半边再排右半边,最后合并就行了,至于怎么排左边和右边,请重新阅读这句话。
- 你今年几岁?答:去年的岁数加一岁,1926 年我出生。
- 人类的本质是什么?人类的本质是什么?人类的本质是什么...
注意 其中第 1,4 个例子不是合法的递归,因为它们没有结束条件。
什么是分治
分治是一种解决问题的思想,其核心就是“分而治之”。
大概的流程可以分为三步:分解 -> 解决 -> 合并。
- 分解原问题为结构相同的子问题。
- 分解到某个容易求解的边界之后,进行递归求解。
- 将子问题的解合并成原问题的解。
分治与递归的联系与区别
递归是一种 编程技巧,一种解决问题的 思维方式。
分治算法很大程度上是 基于递归 的,解决更具体问题的 算法思想。
分治能解决什么问题
能解决的问题一般有如下特征:
该问题的规模 缩小到一定的程度 就可以容易地解决。
该问题可以分解为若干个规模较小的相同问题,即该问题具有最优子结构性质,利用该问题分解出的子问题的解 可以合并 为该问题的解。
该问题所分解出的各个子问题是 相互独立 的,即子问题之间不包含公共的子问题。
关键词:容易求解的边界,可合并的子问题,相互独立的子问题。
下面将根据这些词,给出一些典例。
典例篇
热身题
用到少许分治思想的模拟题。
将大矩阵分为四个等大小矩阵,对左上角的子矩阵进行赋值,再递归对其他三个子矩阵进行处理。
容易求解的边界:子矩阵无法被分解时终止。
相互独立的子问题:对一个子矩阵的处理不会影响其他子矩阵。
可合并的子问题:子矩阵的赋值情况合并成一个大矩阵。
//知识点:分治
/*
By:Luckyblock
*/
#include <cctype>
#include <cstdio>
#define ll long long
const int kMaxn = 1024 + 10;
//=============================================================
int map[kMaxn][kMaxn];
//=============================================================
inline int read() {
int f = 1, w = 0;
char ch = getchar();
for (; !isdigit(ch); ch = getchar())
if (ch == '-') f = -1;
for (; isdigit(ch); ch = getchar()) w = (w << 3) + (w << 1) + (ch ^ '0');
return f * w;
}
void Solve(int x_, int y_, int size_) {
int sub_size = size_ >> 1;
for (int i = x_; i < x_ + sub_size; ++ i) {
for (int j = y_; j < y_ + sub_size; ++ j) {
map[i][j] = 1;
}
}
if (sub_size == 1) return ;
Solve(x_ + sub_size, y_, sub_size);
Solve(x_, y_ + sub_size, sub_size);
Solve(x_ + sub_size, y_ + sub_size, sub_size);
}
//=============================================================
int main() {
int n = read(), size = 1 << n;
Solve(1, 1, size);
for (int i = 1; i <= size; putchar('\n'), ++ i) {
for (int j = 1; j <= size; ++ j) {
printf("%d ", ! map[i][j]);
}
}
return 0;
}
归并排序问题
一个数字肯定是有序的,否则分成两半,先排左半边再排右半边,最后合并就行了,至于怎么排左边和右边,请重新阅读这句话。
容易求解的边界:分解至仅剩一个数字。
相互独立的子问题:对两边分别排序的过程不会互相影响。
仅需考虑子问题的合并,即如何合并两个有序数列。
合并规则非常显然,手玩就能玩出来。
用形式化的语言对算法流程的描述如下:
设置两个指针变量 l 与 r,初始时两个指针分别指向两有序数列第一个数。初始时答案数列为空。
比较两个指针指向的数的大小,将较小的数放到答案数列的尾部,并将指向它的指针向后移动一位。
重复第二步,直至一个指针变量指向一边的尾部。将另一个指针变量指向的数列剩余的数顺序放在答案数列的尾部。
//知识点:归并排序
/*
By:Luckyblock
*/
#include <algorithm>
#include <cctype>
#include <cstdio>
#define ll long long
const int kMaxn = 5e5 + 10;
//============================================================
int n, a[kMaxn], tmp[kMaxn];
//=============================================================
inline int read() {
int f = 1, w = 0;
char ch = getchar();
for (; !isdigit(ch); ch = getchar())
if (ch == '-') f = -1;
for (; isdigit(ch); ch = getchar()) w = (w << 3) + (w << 1) + (ch ^ '0');
return f * w;
}
void Merge(int ll_, int lr_, int rl_, int rr_) {
//the point of left part,and the point of right part
int pl = ll_, pr = rl_, p = ll_;
while (pl <= lr_ && pr <= rr_) {
if (a[pl] <= a[pr]) {
tmp[p ++] = a[pl ++];
} else {
tmp[p ++] = a[pr ++];
}
}
while (pl <= lr_) tmp[p ++] = a[pl ++];
while (pr <= rr_) tmp[p ++] = a[pr ++];
for (int i = ll_; i <= rr_; ++ i) a[i] = tmp[i];
}
void Sort(int l_, int r_) {
if (l_ >= r_) return ;
int mid = (l_ + r_) >> 1;
Sort(l_, mid), Sort(mid + 1, r_);
Merge(l_, mid, mid + 1, r_);
}
//=============================================================
int main() {
n = read();
for (int i = 1; i <= n; ++ i) a[i] = read();
Sort(1, n);
for (int i = 1; i <= n; ++ i) printf("%d ", a[i]);
return 0;
}
归并排序的简单应用题
发现寻找逆序对这一过程,可以分治进行,考虑分别求得左右两边内部的逆序对,再考虑横跨分界线的逆序对。
显然分界线左侧的数的下标 都 小于右侧的数的下标,逆序对一定由左侧较大的数和右侧较小的数组成。
仅需考虑对于一个右侧的数,左侧的数有多少比它大。
想到上述归并排序的过程中的比较过程,若归并时右侧的某个数被放入了答案数列,则左侧剩余的数即为比它大的所有数,可直接统计贡献。
容易求解的边界:分解至仅剩一个数字。
相互独立的子问题:对两边内部的逆序对不会互相影响。
子问题的合并:通过归并求横跨分界线的逆序对。
//知识点:归并排序
/*
By:Luckyblock
*/
#include <algorithm>
#include <cctype>
#include <cstdio>
#define ll long long
const int kMaxn = 5e5 + 10;
//============================================================
int n, a[kMaxn], tmp[kMaxn];
ll ans;
//=============================================================
inline int read() {
int f = 1, w = 0;
char ch = getchar();
for (; !isdigit(ch); ch = getchar())
if (ch == '-') f = -1;
for (; isdigit(ch); ch = getchar()) w = (w << 3) + (w << 1) + (ch ^ '0');
return f * w;
}
void Merge(int ll_, int lr_, int rl_, int rr_) {
//the point of left part,and the point of right part
int pl = ll_, pr = rl_, p = ll_;
while (pl <= lr_ && pr <= rr_) {
if (a[pl] <= a[pr]) {
tmp[p ++] = a[pl ++];
} else {
tmp[p ++] = a[pr ++];
ans += (lr_ - pl + 1);
}
}
while (pl <= lr_) tmp[p ++] = a[pl ++];
while (pr <= rr_) tmp[p ++] = a[pr ++];
for (int i = ll_; i <= rr_; ++ i) a[i] = tmp[i];
}
void Sort(int l_, int r_) {
if (l_ >= r_) return ;
int mid = (l_ + r_) >> 1;
Sort(l_, mid), Sort(mid + 1, r_);
Merge(l_, mid, mid + 1, r_);
}
//=============================================================
int main() {
n = read();
for (int i = 1; i <= n; ++ i) a[i] = read();
Sort(1, n);
printf("%lld", ans);
return 0;
}
应用篇
不满足子问题相互独立的例子
相信你们都做过,考虑怎么直接用分治过掉它。
考虑到达一个位置最后走的一步。
最后一步上一阶,方案数即到达前一个位置的方案数。
最后一步上两阶,方案数即到达倒数第二个位置的方案数。
子问题即到达前两个位置的方案数,递归求解,就可以写出这样的代码:
//
/*
By:Luckyblock
*/
#include <algorithm>
#include <cctype>
#include <cstdio>
#include <cstring>
#define ll long long
//=============================================================
//=============================================================
inline int read() {
int f = 1, w = 0;
char ch = getchar();
for (; !isdigit(ch); ch = getchar())
if (ch == '-') f = -1;
for (; isdigit(ch); ch = getchar()) w = (w << 3) + (w << 1) + (ch ^ '0');
return f * w;
}
ll Solve(int n_) {
if (n_ <= 1) return 1ll;
return Solve(n_ - 1) + Solve(n_ - 2);
}
//=============================================================
int main() {
int n = read();
printf("%lld\n", Solve(n));
return 0;
}
成功超时了,考虑分治的三个关键词:
容易求解的边界:没有台阶。
子问题的合并:答案累计。
子问题相互独立:并不满足。
子问题不相互独立,虽然可以求解,但在重复计算了大量子问题的答案,从而导致代码效率奇低。
可以考虑把重复子问题的答案记录下来,需要解决的时候直接查询。这种处理问题的思想就是动态规划,至于动态规划怎么搞就是后话了。
有关子问题合并的例子
考虑分治求解,处理出左右两侧的最大子段和,考虑如何合并出整个区间的最大子段和。
发现整个区间的最大子段和的位置有三种情况:
- 左侧的最大子段和。
- 右侧的最大子段和。
- 横跨左右区间的情况。
1,2 两部分都已递归得到,仅考虑部分3。
横跨左右区间的部分,实际上是由左区间的最大后缀和,和右区间的最大前缀和拼成的。
直接枚举求得上述两个值即可。
再考虑分治的三个关键词:
- 容易求解的边界:只有一个数。
- 子问题的合并:max(左右区间的最大子段和,跨区间的情况)。
- 子问题相互独立:左右侧的最大子段和不互相影响。
发现上述过程可优化。
发现区间的最大前后缀和可以在递归过程中顺便维护。
区间最大前缀和 = max(左区间最大前缀和,左区间和+右区间最大前缀和)。
区间最大后缀和 = max(右区间最大后缀和,右区间和+左区间最大后缀和)。
不需要再通过枚举求得前后缀和,查询复杂度变为 \(O(\log_2 𝑛)\) 级别,甚至远小于读入的复杂度。
优化后
//知识点:分治
/*
By:Luckyblock
*/
#include <algorithm>
#include <cctype>
#include <cstdio>
#include <cstring>
#define ll long long
#define max std::max
const int kMaxn = 2e5 + 10;
//=============================================================
struct Solution {
int sum, ans, pre, suf;
};
int n, ans, a[kMaxn];
//=============================================================
inline int read() {
int f = 1, w = 0;
char ch = getchar();
for (; !isdigit(ch); ch = getchar())
if (ch == '-') f = -1;
for (; isdigit(ch); ch = getchar()) w = (w << 3) + (w << 1) + (ch ^ '0');
return f * w;
}
Solution Solve(int l_, int r_) { //单次询问 O(log^n)
if (l_ == r_) return (Solution) {a[l_], a[l_], a[l_], a[l_]};
int mid = (l_ + r_) >> 1;
Solution l, r, now;
l = Solve(l_, mid);
r = Solve(mid + 1, r_);
now.ans = max(l.ans, max(r.ans, l.suf + r.pre));
now.pre = max(l.pre, l.sum + r.pre);
now.suf = max(r.suf, r.sum + l.suf);
now.sum = l.sum + r.sum;
return now;
}
//=============================================================
int main() {
n = read();
for (int i = 1; i <= n; ++ i) a[i] = read();
ans = Solve(1, n).ans;
printf("%d\n", ans);
return 0;
}
有关容易求解的边界的例子
给定数列 \(a\),多次操作,每次操作是下列两种形式之一:
给定区间 \([l,r]\),询问 \(a_l\sim a_r\) 之和。
给定位置 \(k\),整数 \(v\),令 \(a_i\) 加上 \(v\)。
考虑怎么用 分治 过掉这题。
先不考虑修改,仅考虑如何搞掉区间查询操作。考虑分成较小的不相交区间进行求和,再将它们的和累加。
检查三个关键字是否满足:
- 容易求解的边界:可以直接查询区间和。
- 子问题的合并:答案累计。
- 子问题相互独立:不相交区间的和互不影响。
发现可以搞,那就大胆搞。
上面提到的容易求解的边界:可以直接查询区间和。
直观想法是将边界设为 区间内仅有一个数时返回。
显然可以直接查询区间和,区间和即为该数的值。
但这样的效率太低,玩不动。
跳出常规思路想,可以直接查询区间和,并不意味着所有的信息都需要当场求得。
考虑先将原序列分割成多个等长的,较大的区间,并预处理它们的和。
查询时先查询区间内预处理好的大区间的和,再暴力枚举不完整的大区间的数并求和。
查询的复杂度与设置的较大区间的大小有关。
发现这样做,也可以很方便地维护单点修改操作。
仅需每次修改单点的值 和 单点所在大区间的和即可,\(O(1)\) 即可完成。
//知识点:分治(迫真
/*
By:Luckyblock
*/
#include <algorithm>
#include <cctype>
#include <cmath>
#include <cstdio>
#include <cstring>
#define ll long long
const int kMaxn = 1e6 + 10;
const int kMaxSqrtn = 1010;
//=============================================================
int n, q;
int block_size, block_num, L[kMaxSqrtn], R[kMaxSqrtn], bel[kMaxn];
ll a[kMaxn], block_sum[kMaxSqrtn];
//=============================================================
inline int read() {
int f = 1, w = 0;
char ch = getchar();
for (; !isdigit(ch); ch = getchar())
if (ch == '-') f = -1;
for (; isdigit(ch); ch = getchar()) w = (w << 3) + (w << 1) + (ch ^ '0');
return f * w;
}
void GetMax(int &fir, int sec) {
if (sec > fir) fir = sec;
}
void GetMin(int &fir, int sec) {
if (sec < fir) fir = sec;
}
void PrepareBlock() {
block_size = (int) sqrt(n);
block_num = n / block_size;
for (int i = 1; i <= block_num; ++ i) {
L[i] = (i - 1) * block_size + 1;
R[i] = i * block_size;
}
if (R[block_num] < n) {
block_num ++;
L[block_num] = R[block_num - 1] + 1;
R[block_num] = n;
}
for (int i = 1; i <= block_num; ++ i) {
for (int j = L[i]; j <= R[i]; ++ j) {
bel[j] = i;
block_sum[i] += a[j];
}
}
}
void Modify(int pos_, int val_) {
a[pos_] += 1ll * val_;
block_sum[bel[pos_]] += 1ll * val_;
}
ll Query(int l_, int r_) {
ll ret = 0;
if (bel[l_] == bel[r_]) {
for (int i = l_; i <= r_; ++ i) {
ret += a[i];
}
return ret;
}
int bell = bel[l_], belr = bel[r_];
for (int i = bell + 1; i <= belr - 1; ++ i) {
ret += block_sum[i];
}
for (int i = l_; i <= R[bell]; ++ i) {
ret += a[i];
}
for (int i = L[belr]; i <= r_; ++ i) {
ret += a[i];
}
return ret;
}
//=============================================================
int main() {
n = read(), q = read();
for (int i = 1; i <= n; ++ i) a[i] = 1ll * read();
PrepareBlock();
for (int i = 1; i <= q; ++ i) {
int opt = read(), x = read(), y = read();
if (opt == 1) {
Modify(x, y);
} else {
printf("%lld\n", Query(x, y));
}
}
return 0;
}
发现还是很暴力,虽使用了多个预处理的不相交区间来覆盖查询区间,但还存在不完整的部分需枚举统计贡献。
考虑能不能调整预处理的区间,令被查询区间可以被精准覆盖。
地球人都知道任何一个数都可以被表示成 2 的幂的和,考虑预处理长度为 2 的幂的区间,来覆盖查询区间。
处理出所有长度为 \(2,4,8,16\cdots\) 的区间?承受不住。
远古的程序竞赛选手们想出了这样的方式,*上黑板口胡。
一个询问区间仅会被 \(O(\log_2 n)\) 级别个这样的区间覆盖。
单点修改仅会修改 \(O(\log_2 n)\) 级别个这样的区间。
修改查询的复杂度都是 \(O(\log_2 n)\) 级别。
总结
上面提到的两种解法容易求解的边界分别为:
预处理好的大区间,和预处理好的长度为 2 的幂的区间。
其实这两种解法分别是 分块与线段树。
是不是很简单啊
并不需要写这题的代码,主要体会其分治思想即可。
总结篇
学到了什么
三个关键词:
容易求解的边界,可合并的子问题,相互独立的子问题。
P5461 赦免战俘:分治的简单应用。
归并排序,逆序对问题:分治法实际应用的典例。
P1255 数楼梯:子问题不相互独立的后果。
P1115 最大子段和:不寻常的子问题的合并方式。
单点修改,区间查询问题:容易求解的边界的可调整性。
习题
洛谷 P5461 赦免战俘
洛谷 P1177 【模板】快速排序:用归并排序过掉它
洛谷 P1908 逆序对:用归并排序过掉它
洛谷 P1115 最大子段和
以及两道简单的分治模拟:
洛谷 P2799 国王的魔镜
洛谷 P1228 地毯填补问题
鸣谢
你们可爱的 yu__xuan 学姐。
《深入浅出程序设计-基础篇》
https://www.luogu.com.cn/
https://oi-wiki.org/basic/divide-and-conquer/
https://blog.csdn.net/zwhlxl/article/details/44086105
https://zh.wikipedia.org/wiki/%E7%BA%BF%E6%AE%B5%E6%A0%91_(%E5%8C%BA%E9%97%B4%E6%9F%A5%E8%AF%A2)