算法基础之分治法

算法原理

对于一个规模为 <math xmlns="http://www.w3.org/1998/Math/MathML"> n n </math>n 的子问题,若该问题可以容易地解决则直接解决,否则将其分解为 <math xmlns="http://www.w3.org/1998/Math/MathML"> k k </math>k 个规模较小的子问题,这些子问题相互独立且与原问题形式相同。递归地解决这些子问题,然后将各子问题的解合并得到原问题的解,这种算法设计策略叫分治法。

分治法所能解决的问题一般具有以下特征:

  • 该问题的规模缩小到一定的程度就可以容易地解决。
  • 该问题可以分解为若干个规模较小的相似问题。
  • 利用该问题分解出的子问题的解可以合并为该问题的解。
  • 该问题所分解出的各子问题是相互独立的,即子问题之间不包含公共的子问题。

分治法的一般算法设计如下:

c++ 复制代码
SolutionType Solve(ProblemType P) {
    if(Small(P)) return Result(P);
    else {
        Divector<int>de(P,P1,P2,...,Pk);
        return Merge(Solve(P1), Solve(P2),..., Solve(Pk));
    }
}

其中 <math xmlns="http://www.w3.org/1998/Math/MathML"> S m a l l ( P ) Small(P) </math>Small(P) 用来判断问题 <math xmlns="http://www.w3.org/1998/Math/MathML"> P P </math>P 的规模是否已经足够小,当问题 <math xmlns="http://www.w3.org/1998/Math/MathML"> P P </math>P 的规模足够小时,直接进行求解并返回结果 <math xmlns="http://www.w3.org/1998/Math/MathML"> R e s u l t ( P ) Result(P) </math>Result(P),否则,将问题 <math xmlns="http://www.w3.org/1998/Math/MathML"> P P </math>P 分解为若干子问题,并逐个进行求解,最后将所有子问题的解 <math xmlns="http://www.w3.org/1998/Math/MathML"> R i Ri </math>Ri 合并得到问题 <math xmlns="http://www.w3.org/1998/Math/MathML"> P P </math>P 的解并返回。

冒泡排序的交换次数

题目描述

给定一个数列 <math xmlns="http://www.w3.org/1998/Math/MathML"> a a </math>a,求对这个数列进行冒泡排序所需要的交换次数(此处冒泡排序指每次找到满足 <math xmlns="http://www.w3.org/1998/Math/MathML"> a i > a i + 1 a_i>a_{i+1} </math>ai>ai+1 的 <math xmlns="http://www.w3.org/1998/Math/MathML"> i i </math>i,交换 <math xmlns="http://www.w3.org/1998/Math/MathML"> a i a_i </math>ai 和 <math xmlns="http://www.w3.org/1998/Math/MathML"> a i + 1 a_{i+1} </math>ai+1,直到这样的 <math xmlns="http://www.w3.org/1998/Math/MathML"> i i </math>i 不存在为止的算法)。

输入输出

输入:数列元素个数 <math xmlns="http://www.w3.org/1998/Math/MathML"> n n </math>n 和 <math xmlns="http://www.w3.org/1998/Math/MathML"> n n </math>n 个数列元素。

输出:交换次数。

解题思路

求所需交换次数等价于求满足 <math xmlns="http://www.w3.org/1998/Math/MathML"> i < j i<j </math>i<j 且 <math xmlns="http://www.w3.org/1998/Math/MathML"> a i > a j a_i>a_j </math>ai>aj 的 <math xmlns="http://www.w3.org/1998/Math/MathML"> ( i , j ) (i,j) </math>(i,j) 数对的个数,也即求数列 <math xmlns="http://www.w3.org/1998/Math/MathML"> a a </math>a 的逆序数。

假设要统计数列 <math xmlns="http://www.w3.org/1998/Math/MathML"> A A </math>A 中逆序对的个数,为此,可以将数列 <math xmlns="http://www.w3.org/1998/Math/MathML"> A A </math>A 分成两半得到数列 <math xmlns="http://www.w3.org/1998/Math/MathML"> B B </math>B 和数列 <math xmlns="http://www.w3.org/1998/Math/MathML"> C C </math>C,于是,对于数列 <math xmlns="http://www.w3.org/1998/Math/MathML"> A A </math>A 中所有的逆序对 <math xmlns="http://www.w3.org/1998/Math/MathML"> ( a i , a j ) (a_i,a_j) </math>(ai,aj),必然属于以下情况之一:

  • ① <math xmlns="http://www.w3.org/1998/Math/MathML"> ( a i , a j ) (a_i,a_j) </math>(ai,aj)属于数列 <math xmlns="http://www.w3.org/1998/Math/MathML"> B B </math>B 的逆序对;
  • ② <math xmlns="http://www.w3.org/1998/Math/MathML"> ( a i , a j ) (a_i,a_j) </math>(ai,aj) 属于数列 <math xmlns="http://www.w3.org/1998/Math/MathML"> C C </math>C 的逆序对;
  • ③ <math xmlns="http://www.w3.org/1998/Math/MathML"> a i a_i </math>ai 属于数列 <math xmlns="http://www.w3.org/1998/Math/MathML"> B B </math>B 而 <math xmlns="http://www.w3.org/1998/Math/MathML"> a j a_j </math>aj 属于数列 <math xmlns="http://www.w3.org/1998/Math/MathML"> C C </math>C。

对于情况①和②,可以通过递归求得。对于情况③,需要做的就是对于 <math xmlns="http://www.w3.org/1998/Math/MathML"> C C </math>C 中的每一个元素,统计在数列 <math xmlns="http://www.w3.org/1998/Math/MathML"> B B </math>B 中比它大的元素的个数,再把结果相加。最后再将③中情况所得结果相加,便得到数列 <math xmlns="http://www.w3.org/1998/Math/MathML"> A A </math>A 的逆序数。

情况③进行统计时,如果采用普通方法,即使用两个 <math xmlns="http://www.w3.org/1998/Math/MathML"> f o r for </math>for 循环逐个比较,时间复杂度较高。因此,借鉴归并排序的思想,在进行统计的同时边将两个子数列进行归并,由递归的特性可知,进行归并时,两子数列也是有序的,如此,情况③统计只需扫描一遍数列。

代码实现

c++ 复制代码
typedef vector<int> vi;

int main() {
	// 输入
	int n;
	cin >> n;
	vi A(n);
	for (int i = 0; i < n; ++i)cin >> A[i];
	// 求解并输出
	cout << solve(A);
}
c++ 复制代码
/**
 * 求数列a的逆序数
 * @param a 数列
 * @return 	逆序数
 */
int solve(vector<int> &a) {
	int n = a.size();
	// 数列元素个数小于等于1,逆序数为0
	if (n <= 1)return 0;
	// 二分
	vector<int> b(a.begin(), a.begin() + n / 2);
	vector<int> c(a.begin() + n / 2, a.end());
	// 情况1与情况2(子问题)
	int cnt = solve(b) + solve(c);
	// 情况3
	int ai = 0, bi = 0, ci = 0;
	while (ai < n) {
		if (bi < b.size() && (ci == c.size() || b[bi] <= c[ci])) {
			a[ai++] = b[bi++];
		} else {
            // b[bi..n/2-1] 都比 c[ci] 大
			cnt += n / 2 - bi;
			a[ai++] = c[ci++];
		}
	}
	// 返回合并所得解
	return cnt;
}

时间复杂度: <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( n l o g ( n ) ) O(nlog(n)) </math>O(nlog(n))。

空间复杂度: <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( n l o g ( n ) ) O(nlog(n)) </math>O(nlog(n)),归并排序的空间复杂度实际可降至 <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( n ) O(n) </math>O(n)。

最近点对问题

题目描述

给定平面上的 <math xmlns="http://www.w3.org/1998/Math/MathML"> n n </math>n 个点,求距离最近的两个点的距离。

输入输出

输入:第一行输入点的个数 <math xmlns="http://www.w3.org/1998/Math/MathML"> n n </math>n,第二行输入 <math xmlns="http://www.w3.org/1998/Math/MathML"> n n </math>n 个点的横坐标 <math xmlns="http://www.w3.org/1998/Math/MathML"> x i x_i </math>xi,第三行输入 <math xmlns="http://www.w3.org/1998/Math/MathML"> n n </math>n 个点的纵坐标 <math xmlns="http://www.w3.org/1998/Math/MathML"> y i y_i </math>yi。

输出:距离最近的两个点的距离。

解题思路

将所有点按x坐标(按y坐标也可)分成左右两半,那么最近点对的距离就是下面三者的最小值。

  • ① <math xmlns="http://www.w3.org/1998/Math/MathML"> p p </math>p 和 <math xmlns="http://www.w3.org/1998/Math/MathML"> q q </math>q 同属于左半边时,点对 <math xmlns="http://www.w3.org/1998/Math/MathML"> ( p , q ) (p,q) </math>(p,q) 距离的最小值;
  • ② <math xmlns="http://www.w3.org/1998/Math/MathML"> p p </math>p 和 <math xmlns="http://www.w3.org/1998/Math/MathML"> q q </math>q 同属于右半边时,点对 <math xmlns="http://www.w3.org/1998/Math/MathML"> ( p , q ) (p,q) </math>(p,q) 距离的最小值;
  • ③ <math xmlns="http://www.w3.org/1998/Math/MathML"> p p </math>p 和 <math xmlns="http://www.w3.org/1998/Math/MathML"> q q </math>q 属于不同区域时,点对 <math xmlns="http://www.w3.org/1998/Math/MathML"> ( p , q ) (p,q) </math>(p,q) 距离的最小值。

对于情况①和②可以递归求解,情况③稍微复杂。假设情况①和②所求得的最小距离为 <math xmlns="http://www.w3.org/1998/Math/MathML"> d d </math>d,所以在情况③中便不需要考虑距离显然大于等于 <math xmlns="http://www.w3.org/1998/Math/MathML"> d d </math>d 的点对。

先考虑 <math xmlns="http://www.w3.org/1998/Math/MathML"> x x </math>x 坐标。假设将点划分为左右两半的直线为 <math xmlns="http://www.w3.org/1998/Math/MathML"> l l </math>l,其 <math xmlns="http://www.w3.org/1998/Math/MathML"> x x </math>x 坐标为 <math xmlns="http://www.w3.org/1998/Math/MathML"> x 0 x_0 </math>x0,只需考虑那些到直线 <math xmlns="http://www.w3.org/1998/Math/MathML"> l l </math>l 距离小于 <math xmlns="http://www.w3.org/1998/Math/MathML"> d d </math>d 的点,也即 <math xmlns="http://www.w3.org/1998/Math/MathML"> x x </math>x 坐标满足 <math xmlns="http://www.w3.org/1998/Math/MathML"> x 0 − d < x < x 0 + d x_0-d<x<x_0+d </math>x0−d<x<x0+d 的点。

再考虑 <math xmlns="http://www.w3.org/1998/Math/MathML"> y y </math>y 坐标。对于每个点,只考虑那些 <math xmlns="http://www.w3.org/1998/Math/MathML"> y y </math>y 坐标相差小于 <math xmlns="http://www.w3.org/1998/Math/MathML"> d d </math>d 的点,同时,为了避免重复计算,规定只考虑与 <math xmlns="http://www.w3.org/1998/Math/MathML"> y y </math>y 坐标不比自己大的点组成的点对。因此,对于每个点 <math xmlns="http://www.w3.org/1998/Math/MathML"> p p </math>p,只需要考虑与 <math xmlns="http://www.w3.org/1998/Math/MathML"> y y </math>y 坐标满足 <math xmlns="http://www.w3.org/1998/Math/MathML"> y p − d < y < y p y_p-d<y<y_p </math>yp−d<y<yp 的点组成的点对。

为了将所有点按 <math xmlns="http://www.w3.org/1998/Math/MathML"> x x </math>x 坐标分成左右两半,需要先将所有点按 <math xmlns="http://www.w3.org/1998/Math/MathML"> x x </math>x 坐标排序。为了避免重复考虑,在处理情况③前,需要将待考虑的点按 <math xmlns="http://www.w3.org/1998/Math/MathML"> y y </math>y 坐标排序(由于分治法求解最近点对问题与归并排序在结构上的相似性,此处借鉴归并排序的思想)。

代码实现

c++ 复制代码
#define INF 1.79E+308
typedef pair<double, double> pdd;

int main() {
	// 输入
	int n;
	cin >> n;
	pdd *ps = new pdd[n];
	for (int i = 0; i < n; ++i)cin >> ps[i].first;
	for (int i = 0; i < n; ++i)cin >> ps[i].second;
	// 按x坐标排序
	sort(ps, ps + n, compX);
	// 求解并输出最近点对的距离
	cout << solve(ps, n);
}
c++ 复制代码
/**
 * 求最近点对的距离
 * @param ps 所有点
 * @param n 点的个数
 * @return 最近点对的距离
 */
double solve(pdd *ps, int n) {

	if (n <= 1)return INF;
	// 中线
	int m = n >> 1;
	double x = ps[m].first;
	// 处理情况1和2,得到目前距离最小值d
	double d = min(solve(ps, m), solve(ps + m, n - m));
	// 按y坐标从小到大进行排序(归并排序)
	inplace_merge(ps, ps + m, ps + n, compY);
	// 处理情况3
	vector<pdd> pl;
	for (int i = 0; i < n; ++i) {
		// 排除到直线l的距离大于等于d的点
		if (fabs(ps[i].first - x) >= d)continue;
		// 从后往前检查b中y坐标相差小于d的点
		for (int j = pl.size() - 1; j >= 0; --j) {
			double dy = ps[i].second - pl[j].second;
			if (dy >= d)break;
			double dx = ps[i].first - pl[j].first;
			d = min(d, sqrt(dx * dx + dy * dy));
		}
		// 记录到直线l的距离小于d的点
		pl.push_back(ps[i]);
	}
	return d;
}
c++ 复制代码
// 按x坐标从小到大排序
bool compX(const pdd &p1, const pdd &p2) {
	return p1.first < p2.first;
}

// 按y坐标从小到大排序(归并排序)
bool compY(const pdd &p1, const pdd &p2) {
	return p1.second < p2.second;
}

时间复杂度: <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( n l o g ( n ) ) O(nlog(n)) </math>O(nlog(n))。

空间复杂度: <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( n ) O(n) </math>O(n)。

经验总结

由于递归特别适合解决结构自相似问题,故分治法通常采用递归实现,但并非只能采用递归实现。当采用递归实现时,在每层递归中,需要完成"分"、"治"、"合"三个步骤。

"分"指的是,将原问题分解为若干规模较小、相互独立、与原问题形式相同的子问题。子问题的规模应大致相同,子问题的个数 <math xmlns="http://www.w3.org/1998/Math/MathML"> k k </math>k 视具体情况而定,一般来说, <math xmlns="http://www.w3.org/1998/Math/MathML"> k k </math>k 可以取 <math xmlns="http://www.w3.org/1998/Math/MathML"> 2 2 </math>2。"分"这一步尤为重要,若不能将原问题分解为若干符合要求的子问题,说明此问题不适合采用分治法,若分解不恰当,会降低算法效率,甚至得出错误结果。数列通常按中点位置进行二分,树通常按重心分割(重心指使得删除该顶点后得到的最大子树的顶点数最少的顶点),平面通常按照坐标进行分割。

"治"指的是,若子问题规模 <math xmlns="http://www.w3.org/1998/Math/MathML"> n n </math>n 足够小则直接求解,否则,递归求解子问题。子问题规模 <math xmlns="http://www.w3.org/1998/Math/MathML"> n n </math>n 究竟小到何种程度才算足够小需要视具体问题而定。

"合"指的是,合并各个子问题的解,得到原问题的解。需要注意的是,当子问题所考虑到的解对于原问题来说不完整时,还需要考虑遗漏的解,如最近点对问题中的情况③。

当递归体中需要使用到排序时,可以借鉴归并排序的思想,从而做到边求解边排序,降低排序的时间复杂度。分治法的基本思想较为简单,就是不断地将问题划分成子问题,直到子问题能够快速求解,当然,需要满足实验原理中所列的条件。

需要注意的就是,划分子集时需要做到不遗漏、不重复。对于最值问题(最大值、最短距离等),有时为了代码编写方便,可以允许部分重复,但如果重复考虑的情况太多,可能会提高时间复杂度,这需要权衡,对于计数类问题(方案数等),一般情况下是不允许重复的,如果重复考虑某些情况,很可能就会得出错误的结果。

END

文章文档:公众号 字节幺零二四 回复关键字可获取本文文档。

相关推荐
ROBIN__dyc4 分钟前
数组
算法
手握风云-41 分钟前
零基础Java第十六期:抽象类接口(二)
数据结构·算法
笨小古1 小时前
路径规划——RRT-Connect算法
算法·路径规划·导航
<但凡.2 小时前
编程之路,从0开始:知识补充篇
c语言·数据结构·算法
f狐0狸x2 小时前
【数据结构副本篇】顺序表 链表OJ
c语言·数据结构·算法·链表
paopaokaka_luck2 小时前
基于Spring Boot+Vue的多媒体素材管理系统的设计与实现
java·数据库·vue.js·spring boot·后端·算法
视觉小萌新3 小时前
VScode+opencv——关于opencv多张图片拼接成一张图片的算法
vscode·opencv·算法
2的n次方_3 小时前
二维费用背包问题
java·算法·动态规划
simple_ssn3 小时前
【C语言刷力扣】1502.判断能否形成等差数列
c语言·算法·leetcode
寂静山林3 小时前
UVa 11855 Buzzwords
算法