分治算法(选择问题等)

分治算法由两部分组成:

分:将问题分解为较小的问题,并递归解决小问题(基本情况除外)。

治:从所有子问题的解中构建原问题的解。

一般认为,在程序中至少含有两个递归的算法就是分治算法,如果只有一个递归函数(例如,快速幂等),那么通常是将原问题转化为更简单的问题来解决,例如求阶乘的递归写法:只是将n的阶乘转化成了n乘(n-1)的阶乘,并没有将原问题进行分解。

在分治算法中,子问题通常是不相交的,如果子问题相交,例如斐波那契数列的递归写法f(n)=f(n-1)+f(n-2),实际上并没有将问题真正分解(因为f(n-1)=f(n-2)+f(n-3),f(n-2)=f(n-3)+f(n-4),可以看到在递归中出现了重复的计算,也就是子问题是相交的),这样就会使算法的效率变低,事实上使用递归算法的斐波那契数列求解在n>=35的情况下效率就已经非常低了。所以在使用分治算法时,子问题之间尽量不要有交集。

分治算法一般由两个递归函数(递归的解决子问题)以及一些附加的工作(用来处理两个子问题的解并得到最终解)组成。如果处理基本情况的语句时间复杂度为O(N),那么分治算法花费的时间就是:

T(N)=2*T(\frac{N}{2})+O(N) 

由主定理可知,这个表达式的最终结果为T(N)=O(NlogN))(也就是时间复杂度)。所以在一般情况下 ,分治算法解决问题的时间复杂度就为O(NlogN)

下面以两个例子来说明分治算法的思想:

最近点问题:

有一个平面点集P(如下图),我们假设点集是按照横坐标排好序的(就算对点集进行排序,最坏的时间界也为O(NlogN),这并不会影响分治算法的最终时间量级)。如果p_1=(x_1,y_1)p_2=(x_2,y_2),它们之间的欧几里得距离为d=\sqrt{(x_1-x_2)^2+(y_1-y_2)^2}。我们需要找到一对点,它们之间的距离是所有点对距离的最小值,如果p_1==p_2,那么这个距离就为0. 

点集P

 如果点集中有N个点,那么总共就有\frac{N*(N-1)}{2}个距离,如果使用穷举法来检查所有距离的话,算法的时间复杂度就为O(N^2),当N较小时,这个量级是可以接受的。但如果使用分治算法,那么时间复杂度就为O(NlogN)

据分治算法的思想,我们需要将问题化为两部分。由于点集已经根据横坐标排序,所以我们在横坐标的中点作一条垂线,这样就将点集划分为P_LP_R。最短距离的出现实际上就只有三种情况:出现在P_L;出现在P_R;一个点在P_L,一个点在P_R。它们的最短距离分别为:d_L,d_R,d_C。出现在左右两部分的情况可以递归的解决,而出现在中间的距离就是本问题的基本情况,需要我们使用O(N)的时间去解决,最后找到三者中的最小值,就是整个问题的解。

划分后的点集

 我们首先递归的求出d_L,d_R,并找出它们之间的小者\delta。然后从点集的分界线,向左向右分别找出距离为\delta的区域,那么距离最小的两个点一定落在这个带状区域间:

如果点集是均匀分布的,那么在数学上可以证明,落在带状区域间的点的个数平均就有\sqrt{N}个,这个时候对带状区域内的点使用穷举的方法来求得最小距离,所花费的时间也就是O(N)。但如果全部的点都落在这个带状区域内,花费的时间就变成了O(N^2)。所以需要对这个算法进行改进。

一个可行的方法是,在每次排序时对带状区域内的点按照纵坐标进行排序,然后依次求得每个点与其下方距离为\delta以内的点的最短距离(因为上方的点在之前已经考察过了),如果发现d_jd_i(j<i)之间的差距大于\delta,那么就可以继续处理第i个点,而不需要再考察第j个点。

处理P3节点

 对于任意节点P,实际上要计算的最多距离只有7个。由于在\delta *\delta的正方形区域内,每两个点的距离至少为\delta(因为两个正方形区域要么位于左半部分,要么位于右半部分,而\delta是这两个区域中的最短距离),所以一个正方形内最多会有四个点,分别位于四个顶点,两个正方形就有8个点(如下图,重合的点并不一定就是同一个点),其中一个点是P本身,这样即使在最坏情况下,求得d_C花费的时间也为O(N)

 

 但在每一次求d_C时,如果都对纵坐标进行排序,那么最后算法的时间复杂度就为O(Nlog^2N),因为排序总共进行了logN次,但这个时间复杂度比起O(N^2)来说也是优的。我们可以使用两个表,一个存放横坐标排序的点集,一个存放纵坐标排序的点集,只需要在算法进行前进行两次O(NlogN)时间的处理即可,这样算法最后的时间也就是O(NlogN),到此就完成了分治算法求解最近距离的问题。

选择问题:

分治算法的另一个例子是选择问题,即找到N个元素中第k大(小)的元素。

选择问题的思路与快速排序类似,首先选择一个枢纽元(pivot),将小于枢纽元的元素放在它的左边S_1,大于枢纽元的放在它的右边S_2,如果k==|S_1|+1,那么就返回枢纽元的值,如果k<=|S_1|,就对S_1递归的调用查找函数,找到S_1中第k大(小)元素,否则就对S_2递归的调用查找函数,找到S_2中第k-|S_1|-1大(小)元素。这个方法的问题在于枢纽元的选择,如果枢纽元选择的不够好,很容易造成O(N^2)的最坏时间界。对于枢纽元,有一个保证子问题最多是原问题大小的百分之七十的方法,称为五分化中项的中项(median-of-median-of-five partition),这个算法是在中项中寻找中项。

五分化中项的中项:

1.把N个元素分为\left \lfloor N/5 \right \rfloor组,5个元素一组,并略去剩下的元素(最多为4个)

2.找出每一组的中项,得到有\left \lfloor N/5 \right \rfloor个中项的表M

3.找到表M的中项,并作为枢纽元返回

下面证明这个算法找到的枢纽元可以保证子问题最多为原问题大小的百分之七十。

我们假设N可以被5整除,并且商为奇数。假设N为10k+5的形式,如果N=45,那么使用五分化中项的中项得到的结果如下图:

每一组的中项在图中已经标出,v为中项的中项,由于N/5为奇数,所以大于v的中项L和小于v的中项S的数量是一样的,并且由于L,S分别是该组的中项,所以在L所在组中会有两个元素大于它,也就大于枢纽元,记为H;在S所在组中,有两个元素小于它,所以也就小于枢纽元,记为T,那么就会有10个H以及10个T,已知的大于和小于枢纽元的元素就各为14个,那么在最坏情况下,子问题最大就占\frac{30}{45}=66%

现在推广到一般情况:N=10k+5,那么进行上述的分析后会发现,有大于和小于枢纽元的元素都为3k+2个,那么最坏的情况就为\frac{7k+2}{10k+5}<0.7N

如果\left \lfloor N/5 \right \rfloor为偶数,那么依然可以进行同样的分析,但是最终的结果不会改变。

所以在使用五分化中项的中项得到的枢纽元来实现算法时,其两个子问题的大小基本上相当,所以算法的运行时间就为O(NlogN)

目前的问题是,如何找到中项的中项?如果使用排序算法,那么对于\left \lfloor N/5 \right \rfloor规模的数据进行排序算法,时间复杂度显然为O(NlogN),那么分治算法的最终时间界就不再是O(NlogN),而是O(Nlog^2N).所以要花费线性时间找到中项的中项,方法就是对表M递归的使用选择算法来选择中项,这个方法得到枢纽元的时间为O(N)(应该与主定理有关)。五分化中项的中项并不是一种很实用的方法,但它仍是理论上的一种突破。

快排思想的选择问题代码实现:

void swap(int* a, int* b) {
	int tmp = *a;
	*a = *b;
	*b = tmp;
}

int select(int* arr, int left, int right, int k) {
	if (left < right) {//如果左坐标小于右坐标,说明子数组中的值多于两个,还需要进行选择
		int pivot = arr[left];//第一个元素为枢纽元
		int i = left, j = right + 1;
		for (;;) {
			while (arr[++i] < pivot);//左侧的元素要小于枢纽元,所以需要找到左边大于枢纽元的元素与右边小于枢纽元的元素交换
			while (arr[--j] > pivot);
			if (i < j) {//如果左<右,说明这两个值需要交换位置
				swap(&arr[i], &arr[j]);
			}
			else {//否则,右指针指向的地方就是枢纽元正确的位置
				swap(&arr[j], &arr[left]);
				break;
			}
		}
		if (j == k - 1) {//如果枢纽元的下标==k-1,数组下标从0开始,则枢纽元就是要找的元素
			return pivot;
		}
		else if (j < k - 1) {//如果下标<k-1,说明需要向右找
			return select(arr, j + 1, right, k);
		}
		else {//否则向左找
			return select(arr, left, j - 1, k);
		}
	}
	else {//如果左==右,说明这个值就是要找的值,如果左>右,说明不存在第k大(小)值
		if (left == right) {
			return arr[left];
		}
		return -1;
	}
}

 测试代码:

#define MAX 10//数组元素大小

void test() {
	int arr[MAX] = { 0 };
	srand((unsigned)time(NULL));
	for (int i = 0; i < MAX; i++) {
		arr[i] = rand() % 100;
	}

	printf("%d\n", select(arr, 0, MAX - 1, 1));

	return;
}

选择问题是分治算法的原因在于:在选择问题的实际代码中,好像只使用了一个递归,但实际上是因为我们已经人为的进行了处理,就不用再使用计算机去进行递归。对于第k大(小)的元素,实际上 它可能存在于枢纽元的左侧、右侧,或是枢纽元本身,这实际上与之前的一些分治算法的思想一致。但是利用枢纽元在数组中的正确位置,我们就可以人为的判断出这个元素到底在这三个位置中的哪一个,但实际上选择问题还是一个分治算法。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值