写在前面
讲课课件配套资料。
课件下载地址:
链接: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