分治算法最重要的就是如何确定好划分方式,以及划分的构造
Theory&Thinkings #
分而治之 #
分解为性质相同的子问题,应该只是规模不同
- 如果性质不同怎么办?
- 进行预处理,转化为性质相同的子问题
归并排序
分治的分:不同的划分方法
- 黑盒划分:归并排序,逆序对
- 白盒划分:快速排序
平衡子问题
减而治之
- 当分出的子问题是完全相同时,避免重复递归调用
- 直到某个子问题不用递归求解
- 直接舍弃或者使用
- 减是精髓
快速排序中的划分 partition非常重要
快速归并排序 #enlighten
思考方法:先把题目降维,然后进行处理
递归三要素:
- 递归终止条件:递归需要一个明确的临界点,程序一旦到达了这个临界点,就不用继续往下递归,防止无限递归
- 终止处理办法:在递归的临界点对应一种简单的情景,这种情景下,应当直接给出问题的解决方案
- 递归处理方法:递归问题必须可以分解为若干个规模较小、与原问题 形式相同 的子问题,这些子问题可以用 相同的思路 来解决,并且合并得到原问题答案。
递归与循环
- 递归也是一种特殊的迭代,但是在迭代前不知道还要迭代多少次
- 递归函数一定有参数,且参数会在迭代的过程中步步逼近某个值
- 递归函数中一定有处理终点,而这个点就是递归出口
- 递归
- 有去有回(回到之前的栈帧上)
- 也就是递归这两个字的含义
- 循环
- 有去无回
分治算法基本思想
- 分 — 将大规模的原问题分割成 k 个更小规模的 子问题,如果子问题的规模仍然不 够小,则再划分为 k 个“子 子”问题,如此递归地进行下去,直到子问题规模足够 小 (基础问题),很容易求出其解为止。
- 治 — 求解规模足够小的基础问题。
- 合 — 将求出的小规模的问题的解合并为一个更大规模的问题的解,自底向上 逐步求出原来问题的解。
分治策略算法细分为三个阶段: Divide 、 Conquer 、 Combine 。 Divide 阶 段是把原问题分割成小问题, Conquer 阶段是递归处理流程, Combine 阶段 是运用小问题的答案合成出原问题的解答。
分治策略框架程序
(1) if ( | P | <= n0) adhoc(P); //递归出口,用特定程序解决基础问题
(2) divide P into smaller subinstances P1,P2,...,Pk;//分解出子问题
(3) for(i=1,i<=k,i++)
yi = divide-and-conquer(Pi); //递归求解各子问题
(4) return merge(y1,...,yk); //将各子问题的解合并为原问题的解 }
-
问题规模与递归出口:
- |P|表示问题 P 的规模,𝑛是一个预先定义的阈值,表示当问题 P 的规模不超过𝑛时,问题已 容易求解,不必要继续分解和递归调用。
- adhoc(P) 是分治算法中的子程序,对应治过程 (或者说 conquer),一般是常数时间复杂 度的子函数或者子过程。
-
划分策略:
- 设计划分策略,把原问题 P 分解成 k 个规模较小的子问题,这个步骤是分治算法的基础和关键, 人们往往遵循两个原则:
- 平衡子问题原则,分割出的 k 个子问题其规模最好大致相当;
- 独立子问题原则,分割出的 k 个子问题之间重叠越少越好,最好 k 个子问题是相互独立,不存在重叠子问题。
-
合并:
- merge 合并子程序,把 k 个子问题的解合并得到原问题的解。合并子程序因求解问题 而异,即使是同一个问题,如果第二步的划分策略不同,其合并子程序也往往不一样。
减而治之 #
Master 定理 #
e.g.s #
Stirling 数 #
n 个元素的集合{1,2,...,n }可以划分为若干个非空子集的集合。例如,当n=3 时, 集合{1,2,3}可以划分为5个不同的非空子集的集合如下:
{ {1},{2},{3} } { {1,2},{3} }
{ {1,3},{2} }
{ {2,3},{1} }
{ {1,2,3} }
给定正整数n 和 m,计算出n个元素的集合{1,2,...,n }可以划分为多少个不同的由 m 个非空子集构成的集合。比如上例中含有1个子集的集合有1个,2个子集的集合有3个, 3个子集的集合有1个。
假定有𝑆(𝑛, 𝑚) 种不同方法把 n 个元素的集合划分成 m 个非空子集构成的集合, 𝑆(𝑛, 𝑚) 种不同划分方法可以分为以下两类:
- 先把 n-1 个元素的集合划分成 m 个非空子集,按定义其划分数目为 𝑆(𝑛 − 1, 𝑚), 再将剩下的一个元素插入到 m 个子集中的任意一个,最后把这两步合起来则 可构成 n 个元素集合的 m 划分,总共有𝑚 ∗ 𝑆(𝑛 − 1, 𝑚) 中划分
- 先把 n-1 个元素的集合划分成 m-1 个子集,再将剩下的一个元素独立构造成 子集,显然,这两步合起来也可以构成有 n 个元素集合的 m 划分,总共有𝑆(𝑛 − 1, 𝑚 − 1) 种划分。
$$𝑆(𝑛, 𝑚) = 𝑚 ∗ 𝑆(𝑛 − 1, 𝑚) + 𝑆(𝑛 − 1, 𝑚 − 1)$$
逆序对问题 #
黑盒划分典型问题
问题描述:设𝐴[1,⋯,𝑛]是一个包含𝑛个不同非负整数的数组。如果在𝑖<𝑗的情况下,有𝐴𝑖>𝐴[𝑗],则(𝐴𝑖,𝐴[𝑗])就称为A中的一个逆序对。例如,数组(3,1,4,5,2)的“逆序对”有<3,1>、<3,2>、<4,2>、<5,2>共4个。
思考流程:
- 朴素方法:枚举
- 将全部的数对枚举出来,检查是否逆序
- 时间复杂度达到指数级别
- 继续思考:思考方法:降维
- 1 个元素:0 个逆序对
- 2 个元素:比较输出 1/0
- 大于 2 个元素:是否可以将其拆分为 1 个或 2 个元素的情况——分治思路
- 分——将整体元素分成 2 个部分
- 治——深入两个部分探究逆序对问题(递归直到归位 base 情况)
- 合——将处理好的问题合并计数(难点)
- 分割点左侧全部逆序对
- 分割点右侧全部逆序对
- 跨两个集合的逆序对(进一步的真正难点)
- 朴素方法:枚举——复杂度仍然为指数,没有减少搜索空间
- 排序——目的:减少比较次数(即搜索空间)
- 如何比较排序后的数组?左侧数组中从小到大比较右侧数组中的数,
O(n)
时间生成逆序总数量 - 随后使用双指针思路遍历选取即可
- 如何比较排序后的数组?左侧数组中从小到大比较右侧数组中的数,
棋盘覆盖 #
在一个2k×2k个方格组成的棋盘中,恰有一个方格与其他方格不同,称该方格为特殊方格,且称该棋盘为特殊棋盘。在棋盘覆盖问题中,要用图示的4种不同形态的L型骨牌覆盖给定的特殊棋盘上除特殊方格以外的所有方格,且任何2个L型骨牌不得重叠覆盖。
思考流程:
- 朴素方法:枚举
- 枚举甚至都无法实现,因为变量太多,且要求不能重叠,导致无法枚举
- 进阶思考:分治
- 思考方法 - 降维
- 分解到最基础形态 - 即 2x2 方格
- 情况 1:方格内存在一个特殊点
- 直接用 4 种里的一个做匹配即可
- 情况 2:方格里没有特殊点
- g 了——有一个空始终无法填满
- 目标:强制要求每个块内的那个不可覆盖的点组合起来,应该刚好就是 3 个方块组成的元素
- 把有特殊点的子块当作 indicator,其周围靠近的三个点设为虚拟的特殊点,围绕此展开覆盖,最后覆盖这中间三个特殊点 (或者首先)
- 目标:强制要求每个块内的那个不可覆盖的点组合起来,应该刚好就是 3 个方块组成的元素
- g 了——有一个空始终无法填满
- 情况 1:方格内存在一个特殊点
- 分解到最基础形态 - 即 2x2 方格
- 思考方法 - 降维
void ChessBoard(int tr, int tc, int dr, int dc, int size)
{
if (size = = 2)
fill(tr, tc, dr, dc); //覆盖2×2的棋盘
s = size/2; // 划分棋盘
if (dr < tr + s && dc < tc + s) {
ChessBoard(tr, tc, dr, dc, s);
//递归处理左上子棋盘
ChessBoard(tr+s, tc, tr+s, tc+s-1, s);
//递归处理左下子棋盘
ChessBoard(tr, tc+s, tr+s-1, tc+s+1, s);
//递归处理右上子棋盘
ChessBoard(tr+s, tc+s, tr+s+1, tc+s+1, s);
//递归处理右下子棋盘
}
if (dr < tr + s && dc >= tc + s) { …… }
// 特殊方格在右上角子棋盘中
if (dr >= tr + s && dc < tc + s) { …… }
// 特殊方格在左下角子棋盘中
if (dr >= tr + s && dc >= tc + s) { …… }
// 特殊方格在右下角子棋盘中
}
最近点对 #
任务描述:给定平面上n个点,找其中的一对点,使得在n个点组成的所有点对中,该点对间的距离最小。
思考流程:
- 朴素方法:枚举
- 枚举对象:全部点对;枚举过程:逐个挑选;验证:出来后比大小
- 时间复杂度必然达到指数级别
- 继续思考:
- 思考方法 - 降维
- 一维数组情况处理方法:将点从小到大排序,逐个测量两点间距离
- 但是对于二维情况无法操作,因为存在两个轴,无法有一个正确的排序方法
- 进阶思考方法:
- 如何使用分治策略
- 一维情况:在左侧找到最近点对,右侧找到最近点对,处理中间跨区操作点对后进行对比
- 二维情况:左侧区域 + 右侧区域 + 跨两区域——又回到了经典的分治问题解决策略
- 跨区情况如何计算最近点对?
- 朴素方法:枚举
- 时间复杂度一定还是指数级别
- 进阶思考:
- 目标:把已经计算过的内容,当前左右两侧的最近距离 d 利用起来
- 处在中间位置的最近点对如果存在,则一定小于等于 d,否则没有必要
- 以中心分割线为界,左右两侧划分长度为 d 的可能存在区域
- 进一步压缩计算区域:假设点 p 存在,则其所搜索的区域为一个半径为 d 的半圆
- 半圆无法方便地用程序表示,放宽条件:宽 d 长 2d 的矩形区域
- 这个矩形中最多只可能存在 6 个点——因为当前最短距离 d 的限制存在,因为一个 1/6 小矩形里如果存在两个点,其距离一定小于 d
- 则,只需考虑左侧区域中的点扩展为这个矩形后,所覆盖的右侧 6 个点即可——大幅压缩求解空间
- 以中心分割线为界,左右两侧划分长度为 d 的可能存在区域
- 朴素方法:枚举
- 跨区情况如何计算最近点对?
- 如何使用分治策略
- 思考方法 - 降维
线性时间选择 #
给定线性序集中n个元素和一个整数 j,1 ≤ j ≤ n,要求找出这n个元素中第 j 小的元素。 例:求数组A[1:9] = [65, 70, 75, 80, 85, 60, 55, 50, 45]第7小元素
partition[1:9]=[60,45,50,55,65,85,80,75,70] j = 7 k = 5 [6:9] = [85,80,75,70] j = 2
partition[6:9]=[70,80,75,85] j = 2 k = 4 [6:8] = [70,80,75] j = 2
partition[6:8]=[70,80,75] j = 2 k = 1 [7:8] = [80,75] j = 1
- 普通二分查找的问题:最好情况 O(n),最坏情况 O(n^2)
线性时间选择
- 如果能在线性时间内找到一个划分基准,使得按这个基准所划分出的 2 个子 数组的长度都至少为原数组长度的ε倍 (0 < ε < 1),那么就可以在最坏情况 下用 O(n) 时间完成选择任务。
- 关键问题: 怎样找到一个合适的划分基准元素?
Problem A. 集合划分 #
分治算法典型问题
#### 题目描述
n个元素的集合{1,2,..., n }可以划分为若干个非空子集。例如,当n=4 时,集合{1,2,3,4}可以划分为15 个不同的非空子集如下:
{{1},{2},{3},{4}},
{{1,2},{3},{4}},
{{1,3},{2},{4}},
{{1,4},{2},{3}},
{{2,3},{1},{4}},
{{2,4},{1},{3}},
{{3,4},{1},{2}},
{{1,2},{3,4}},
{{1,3},{2,4}},
{{1,4},{2,3}},
{{1,2,3},{4}},
{{1,2,4},{3}},
{{1,3,4},{2}},
{{2,3,4},{1}},
{{1,2,3,4}}
给定正整数n,计算出n 个元素的集合{1,2,..., n }可以划分为多少个不同的非空子集。
#### 输入数据
多组输入(<=10组数据,读入以EOF结尾) 每组一行输入一个数字,n(0<n<=18)
#### 输出数据
每组输出一行结果。
- 一种方式是添加元素
- 一种方式是添加集合
F(n,m)
——在有 n 个元素的集合 m 中进行插入
- 同样目的都是为了插入一个元素——
n-1
- 不同的两种方式
- 向已有集合中添加新元素:
m * F(n-1,m)
- 向已有结果中添加新集合:
1 * F(n-1,m-1)
- 向已有集合中添加新元素:
Problem B. 二叉树的后序遍历 #
#### 题目描述
给你一个二叉树,按照后序遍历的顺序输出这棵树。
#### 输入数据
第一行一个整数 n (1≤n≤1e4) ,表示这棵树的节点数。 接下来有 n-1 行,每行有两个整数 u,v ,表示节点 u 到节点 v 有一条边,输入保证树以 1 为根,且 u 为 v 的父节点。对于一个节点的多个子节点,将更早输入的那一个子节点的视为他的左子节点。
#### 输出数据
输出该树的后序遍历,节点编号之间用一个空格分隔。
- 构造树的方法——效果差,代码复杂
- 直接通过数组对树作快速处理 2
Problem C. 整数的幂次方表示 #
Problem D. 最近点对 #
#### 题目描述
有n个坐标点,问这些点之间最近的一对点的距离是多少?
#### 输入数据
多组输入(<=10组数据,读入以EOF结尾)。 每组第一行输入一个数字,n(1<=n<=100000) 表示坐标点的个数。 随后n行,为两个整数,表示对应的坐标点。
#### 输出数据
每组输出一行结果,保留两位有效数字
Problem E. 小明的散步路径 #
跟之前的方块问题一样,是非常标准非常漂亮的分治算法
Problem F. 气球游戏 #
#### 题目描述
刚刚今天去游乐场玩,发现了一个新的游戏项目,游戏是这样的,场上一共有 n 个气球,它们的编号是0到n-1,然后每个气球上还有一个数字,我们使用数组nums来保存这些数字。
现在游戏要求刚刚戳破所有的气球。每当刚刚戳破一个气球i时,刚刚可以获得nums[left] * nums[i] * nums[right]个积分。这里的left和right指的是和i相邻的两个气球的序号。(注意每当刚刚戳破了气球i后,气球left和气球right就变成了相邻的气球。)
求所能获得积分的最大值。
#### 输入数据
输入中有若干组测试样例,第一行为一个正整数T(T≤1000),表示测试样例组数。每组测试样例包含2部分: 第一部分有一行,包含1个正整数n(0≤n≤500),第二部分为一行,有n个数,第i个数表示num[i],(0≤num[i]≤100)。
#### 输出数据
对每组测试数据,单独输出一行答案,表示最大的积分值
利用递归分治算法的典型:结合必要的备忘录机制用于减少搜索/计算空间