「分治入门」课件配套资料

写在前面

讲课课件配套资料。
课件下载地址:
链接:https://pan.baidu.com/s/1pbw5e4ReLrILjeG8REjfAA
提取码:88f0
隐去了部分涉及个人隐私的部分。


概念篇

什么是递归

基本思想是某个函数直接或者间接地调用自身,这样原问题的求解就转换为了许多性质相同但是规模更小的子问题。

求解时只需关注如何把原问题划分成符合条件的子问题,而不需要过分关注这个子问题是如何被解决的。

递归代码最重要的两个特征:结束条件自我调用
自我调用是在解决子问题,而结束条件定义了最简子问题的答案。


以下是一些例子:

  1. 一次令人满意的搜索体验

sb_baidu

  1. 如何给一堆数字排序?答:一个数字肯定是有序的,否则分成两半,先排左半边再排右半边,最后合并就行了,至于怎么排左边和右边,请重新阅读这句话。
  2. 你今年几岁?答:去年的岁数加一岁,1926 年我出生。
  3. 人类的本质是什么?人类的本质是什么?人类的本质是什么...

注意 其中第 1,4 个例子不是合法的递归,因为它们没有结束条件。


什么是分治

分治是一种解决问题的思想,其核心就是“分而治之”。
大概的流程可以分为三步:分解 -> 解决 -> 合并。

  1. 分解原问题为结构相同的子问题。
  2. 分解到某个容易求解的边界之后,进行递归求解。
  3. 将子问题的解合并成原问题的解。

分治与递归的联系与区别

递归是一种 编程技巧,一种解决问题的 思维方式
分治算法很大程度上是 基于递归 的,解决更具体问题的 算法思想


分治能解决什么问题

能解决的问题一般有如下特征:
该问题的规模 缩小到一定的程度 就可以容易地解决。
该问题可以分解为若干个规模较小的相同问题,即该问题具有最优子结构性质,利用该问题分解出的子问题的解 可以合并 为该问题的解。
该问题所分解出的各个子问题是 相互独立 的,即子问题之间不包含公共的子问题。

关键词:容易求解的边界,可合并的子问题,相互独立的子问题。
下面将根据这些词,给出一些典例。


典例篇

热身题

洛谷 P5461 赦免战俘

用到少许分治思想的模拟题。
将大矩阵分为四个等大小矩阵,对左上角的子矩阵进行赋值,再递归对其他三个子矩阵进行处理。

容易求解的边界:子矩阵无法被分解时终止。
相互独立的子问题:对一个子矩阵的处理不会影响其他子矩阵。
可合并的子问题:子矩阵的赋值情况合并成一个大矩阵。

//知识点:分治 
/*
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,初始时两个指针分别指向两有序数列第一个数。初始时答案数列为空。
比较两个指针指向的数的大小,将较小的数放到答案数列的尾部,并将指向它的指针向后移动一位。
重复第二步,直至一个指针变量指向一边的尾部。将另一个指针变量指向的数列剩余的数顺序放在答案数列的尾部。

洛谷 P1177 【模板】快速排序

//知识点:归并排序
/*
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;
}

归并排序的简单应用题

洛谷 P1908 逆序对

发现寻找逆序对这一过程,可以分治进行,考虑分别求得左右两边内部的逆序对,再考虑横跨分界线的逆序对。
显然分界线左侧的数的下标 都 小于右侧的数的下标,逆序对一定由左侧较大的数和右侧较小的数组成。

仅需考虑对于一个右侧的数,左侧的数有多少比它大。
想到上述归并排序的过程中的比较过程,若归并时右侧的某个数被放入了答案数列,则左侧剩余的数即为比它大的所有数,可直接统计贡献。

容易求解的边界:分解至仅剩一个数字。
相互独立的子问题:对两边内部的逆序对不会互相影响。
子问题的合并:通过归并求横跨分界线的逆序对。

//知识点:归并排序
/*
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;
}

应用篇

不满足子问题相互独立的例子

洛谷 P1255 数楼梯

相信你们都做过,考虑怎么直接用分治过掉它。
考虑到达一个位置最后走的一步。
最后一步上一阶,方案数即到达前一个位置的方案数。
最后一步上两阶,方案数即到达倒数第二个位置的方案数。
子问题即到达前两个位置的方案数,递归求解,就可以写出这样的代码:

//
/*
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; 
}

成功超时了,考虑分治的三个关键词:
容易求解的边界:没有台阶。
子问题的合并:答案累计。
子问题相互独立:并不满足

子问题不相互独立,虽然可以求解,但在重复计算了大量子问题的答案,从而导致代码效率奇低。
可以考虑把重复子问题的答案记录下来,需要解决的时候直接查询。这种处理问题的思想就是动态规划,至于动态规划怎么搞就是后话了。


有关子问题合并的例子

洛谷 P1115 最大子段和

考虑分治求解,处理出左右两侧的最大子段和,考虑如何合并出整个区间的最大子段和。

发现整个区间的最大子段和的位置有三种情况:

  1. 左侧的最大子段和。
  2. 右侧的最大子段和。
  3. 横跨左右区间的情况。

1,2 两部分都已递归得到,仅考虑部分3。


横跨左右区间的部分,实际上是由左区间的最大后缀和,和右区间的最大前缀和拼成的。
直接枚举求得上述两个值即可。

再考虑分治的三个关键词:

  1. 容易求解的边界:只有一个数。
  2. 子问题的合并:max(左右区间的最大子段和,跨区间的情况)。
  3. 子问题相互独立:左右侧的最大子段和不互相影响。

发现上述过程可优化。
发现区间的最大前后缀和可以在递归过程中顺便维护。
区间最大前缀和 = 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\)

考虑怎么用 分治 过掉这题。
先不考虑修改,仅考虑如何搞掉区间查询操作。考虑分成较小的不相交区间进行求和,再将它们的和累加。

检查三个关键字是否满足:

  1. 容易求解的边界:可以直接查询区间和。
  2. 子问题的合并:答案累计。
  3. 子问题相互独立:不相交区间的和互不影响。

发现可以搞,那就大胆搞。


上面提到的容易求解的边界:可以直接查询区间和。
直观想法是将边界设为 区间内仅有一个数时返回。
显然可以直接查询区间和,区间和即为该数的值。
但这样的效率太低,玩不动。

跳出常规思路想,可以直接查询区间和,并不意味着所有的信息都需要当场求得。
考虑先将原序列分割成多个等长的,较大的区间,并预处理它们的和。
查询时先查询区间内预处理好的大区间的和,再暴力枚举不完整的大区间的数并求和。
查询的复杂度与设置的较大区间的大小有关。

发现这样做,也可以很方便地维护单点修改操作。
仅需每次修改单点的值 和 单点所在大区间的和即可,\(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)

posted @ 2020-09-10 17:22  Luckyblock  阅读(236)  评论(1编辑  收藏  举报