数据结构&算法学习笔记——分治法

  

目录

  

分治法概述  

 设计思想 

适用条件

划分规则

求解步骤

分治法的算法设计模式

算法的分析

递归

递归的定义及相关概念

递归的定义

递归有两个基本要素

递归的分类

分治与递归

递归模型

递归算法一般格式

什么时候使用递归?

问题的定义是递归的

数据结构是递归的

问题的求解方法是递归的

递归函数的运行轨迹

递归函数的内部执行过程

斐波那契序列

递归方法小结

组合问题中的分治法

最大子段和问题

问题描述

问题分析

算法设计

算法分析

棋盘覆盖问题

问题描述

问题分析

 算法设计

算法实现

算法分析

几何问题中的分治法

最近对问题

问题描述

问题分析

算法设计

算法分析

分治法的特殊应用

寻找第k小元素问题(选择问题)

问题描述

问题分析

选择问题的例子

算法设计

算法分析

减治法

减治法的设计思想

减治法的时间复杂性

分治法小结

分治法的基本思想

分治法所解决问题的特征


分治法概述  

  •  设计思想 

将整个问题分成若干个小问题后,分而治之。

将一个难以直接解决的大问题,划分成k个较小规模的子问题,对这k个子问题分别求解。如果子问题的规模仍然不够小,则再将每个子问题划分为j个规模更小的子问题,如此分解下去,直到问题规模足够小,很容易求出其解为止,再将子问题的解合并为一个更大规模的问题的解,自底向上逐步求出原问题的解。

可以分成若干个与原问题性质相同的子问题 

可以分别求解

  • 适用条件

问题可以分解为若干个规模较小、相同性质子问题,即该问题具有最优子结构性质;

子问题易于求解;

子问题的解可以合并为原问题的解;

各个子问题是相互独立的,即子问题之间不包含公共的子问题。

  • 划分规则

1、独立子问题:各子问题之间相互独立——这涉及到分治法的效率,如果各子问题不是独立的,则分治法需要重复地解公共的子问题。

2、平衡子问题:最好使子问题的规模大致相同。也就是将一个问题划分成大小相等的k个子问题(通常k2),这种使子问题规模大致相等的做法是出自一种平衡(Balancing)子问题的思想,它几乎总是比子问题规模不等的做法要好。

  • 求解步骤

一般来说,分治法的求解过程由以下三个阶段组成:

(1)划分(divide:把规模为n的原问题划分为k个规模较小的子问题,并尽量使k个子问题的规模大致相同。

(2)求解子问题(conquer:各子问题的解法与原问题的解法通常相同,可以用递归方法(或循环)求解各个子问题。

(3)合并(merge:把各个子问题的解合并,合并的代价因情况不同有很大差异,分治算法的有效性很大程度上依赖于合并的实现。

  • 分治法的算法设计模式

  • 算法的分析

大小为n的原问题分成若干个大小为n/b的子问题,其中a个子问题需要求解,而f(n)是分解与合并各个子问题的解需要的工作量。

如果子问题的输入规模大致相等且一分为二,则分治法的计算时间可表示为:

说明:T(n)是输入规模为 n 的分治法的计算时间;

 g(n)是对足够小的 n 直接求解的时间;

 f(n)是Merge的计算时间。

递归

  • 递归的定义及相关概念

  • 递归的定义

递归(Recursion)就是子程序(或函数)直接调用自己或通过一系列调用语句间接调用自己。

递归是一种有效的算法设计方法。递归算法是解决很多复杂问题的有效方法!

  • 递归有两个基本要素

1、边界条件:确定递归到何时终止;

2、递归模式:大问题是如何分解为小问题的。

边界条件与递归方程是递归函数的两个要素,递归函数只有具备了这两个要素,才能在有限次计算后得出结果。

  • 递归的分类

1)直接递归:函数直接调用本身。

2)间接递归:函数A在调用其他函数B时,又产生了对自身的调用。

  • 分治与递归

由分治法产生的子问题往往是原问题的较小模式,这就为使用递归技术提供了方便。在这种情况下,反复应用分治手段,可以使子问题与原问题类型一致而其规模却不断缩小,最终使子问题缩小到很容易直接求出其解。这自然导致递归过程的产生。

分治与递归像一对孪生兄弟,经常同时应用在算法设计之中,并由此产生许多高效算法。

  • 递归模型

编写递归算法时,首先对问题的以下三个方面进行分析:

1)决定问题规模的参数。需要用递归算法解决的问题,其规模通常比较大,在问题中决定规模大小(或问题复杂程度)的量有哪些?

2)问题的边界条件及边界值。在什么情况下可以直接得出问题的解?即:问题的边界条件及边界值。

3)解决问题的通式。把规模大的、较难解决的问题变成规模较小、易解决的同一问题,需要通过哪些步骤或等式来实现?这是解决递归问题的难点。

  • 递归算法一般格式

递归算法的一般格式为:

  • 什么时候使用递归?

  • 问题的定义是递归的

有许多数学公式、数列等的定义是递归的。例如,求n!和Fibonacci数列等。这些问题的求解过程可以将其递归定义直接转化为对应的递归算法。

 例如:阶乘函数的定义

阶乘的另外一种定义方法

这时候递归的定义可以用如下的函数表示:

函数f(n)的定义用到了自己本身f(n-1)

阶乘递归算法的效率分析

  • 数据结构是递归的

有些数据结构是递归的。例如, 单链表是一种递归数据结构,其结点类型定义如下:

该定义中,结构体LNode的定义中用到了它自身,即指针域next是一种指向自身类型的指针,所以它是一种递归数据结构。

对于递归数据结构,采用递归的方法编写算法既方便又有效。

例如,求一个不带头结点的单链表head的所有data(假设为int型)之和的递归算法如下:

  • 问题的求解方法是递归的

一个典型的例子是在有序数组中查找一个数据元素是否存在的折半查找算法

经典的Hanoi塔解决方案

  • 递归函数的运行轨迹

在递归函数中,调用函数和被调用函数是同一个函数,需要注意的是递归函数的调用层次,如果把调用递归函数的主函数称为第0层,进入函数后,首次递归调用自身称为第1层调用;从第i层递归调用自身称为第i+1层。反之,退出第i+1层调用应该返回第i层。采用图示方法描述递归函数的运行轨迹,从中可较直观地了解到各调用层次及其执行情况。

  • 递归函数的内部执行过程

一个递归函数的调用过程类似于多个函数的嵌套调用,只不过调用函数和被调用函数是同一个函数。为了保证递归函数的正确执行,系统需设立一个工作栈。具体地说,递归调用的内部执行过程如下:

(1)运行开始时,首先为递归调用建立一个工作栈,其结构包括实参、局部变量和返回地址;

(2)每次执行递归调用之前,把递归函数的实参和局部变量的当前值以及调用后的返回地址压栈;

(3)每次递归调用结束后,将栈顶元素出栈,使相应的实参和局部变量恢复为调用前的值,然后转向返回地址指定的位置继续执行。

汉诺塔算法在执行过程中,工作栈的变化下图所示,其中栈元素的结构为(返回地址,n值,A值,B值,C值),返回地址对应算法中语句的行号。

  • 斐波那契序列

递归计算斐波那契序列

斐波那契数列Fib(n)的递归定义是:

求第n项斐波那契数列的递归函数如下:

斐波那契数列递归算法的效率分析

                                                                                    Fib(5)的递归调用树

用归纳法可以证明求Fib(n)的递归调用次数等于2n-1;计算斐波那契数列的递归函数Fib(n)时间复杂度为O(2n)

斐波那契数列的非递归解决方案

  • 递归方法小结

优点:----递归过程结构清晰

           ----程序可读性强

           ----算法的正确性容易证明(数学归纳法)

缺点:----时间效率低

           ----空间开销大,问题规模扩大时,噩梦来临。

           ----算法不容易优化

           ----对于频繁使用的算法,或不具备递归功能的程序设计语言,需要把递归算法转换为非递归算法。

解决方法:在递归算法中消除递归调用,使其转化为非递归算法。

1、采用一个用户定义的栈来模拟系统的递归调用工作栈。该方法通用性强,但本质上还是递归,只不过人工做了本来由编译器做的事情,优化效果不明显。

2、用递推来实现递归函数。

3、通过变换能将一些递归转化为尾递归,从而迭代求出结果。

后两种方法在时空复杂度上均有较大改善,但其适用范围有限。

组合问题中的分治法

  • 最大子段和问题

  • 问题描述

给定由n个整数组成的序列(a1, a2, …, an),最大子段和问题要求该序列形如     的最大值(1ijn),当序列中所有整数均为负整数时,定义最大子段和为0。例如,序列(-20, 11, -4, 13, -5, -2)的最大子段和为:

{5,-3,4,2}的最大子序列是 {5,-3,4,2},它的和是8,达到最大;

{5,-6,4,2}的最大子序列是{4,2},它的和是6。

  • 问题分析

最大子段和问题的分治策略是:

(1)划分:按照平衡子问题的原则,将序列(a1, a2, …, an)划分成长度相同的两个子序列(a1, …, an/2 )和(an/21, …, an),则会出现以下三种情况:

(2)求解子问题:对于划分阶段的情况①和②可递归求解,情况③需要单独计算:

a[n/2]、a[n/2+1]在最大子段中。

a[1:n/2]中计算出s1=max(a[n/2]+a[n/2-1]+…+a[i]),1<=i<=n/2。

a[n/2+1:n]中计算出s2= max(a[n/2+1]+a[n/2+2]+…+a[i])n/2+1<=i<=n。

s1+s2为出现情况(3)的最大子段和:

(3)合并:比较在划分阶段的三种情况下的最大子段和,取三者之中的较大者为原问题的解。

  • 算法设计

  • 算法分析

分析算法4.4.1.1的时间性能,对应划分得到的情况①和②,需要分别递归求解,对应情况③,两个并列for循环的时间复杂性是O(n),所以,存在如下递推式:

算法的时间复杂性为O(nlog2n)。

  • 棋盘覆盖问题

  • 问题描述

在一个2k×2k k≥0)个方格组成的棋盘中,恰有一个方格与其他方格不同,称该方格为特殊方格。棋盘覆盖问题要求用4种不同形状的L型骨牌覆盖给定棋盘上除特殊方格以外的所有方格,且任何2个L型骨牌不得重叠覆盖。

  • 问题分析

分治法求解棋盘覆盖问题的技巧在于划分棋盘,使划分后的子棋盘的大小相同,并且每个子棋盘均包含一个特殊方格,从而将原问题分解为规模较小的棋盘覆盖问题。

k>0时,可将2k×2k的棋盘划分为42k-1×2k-1的子棋盘

      ——原棋盘只有一个特殊方格,

      ——其余3个子棋盘中没有特殊方格。

采用递归方法求解,可以用一个L型骨牌覆盖这3个较小棋盘的会合处(目的是保证子问题性质相同)

      ——将原问题转化为4个较小规模的棋盘覆盖问题。递归地使用这种划分策略,直至将棋盘分割为1×1的子棋盘。

  •  算法设计

(1)棋盘:可以用一个二维数组board[size][size]表示一个棋盘,其中,size=2k。为了在递归处理的过程中使用同一个棋盘,将数组board设为全局变量;

(2)子棋盘:整个棋盘用二维数组board[size][size]表示,其中的子棋盘由棋盘左上角的下标trtc和棋盘大小s表示;

(3)特殊方格:用board[dr][dc]表示特殊方格,drdc是该特殊方格在二维数组board中的下标;

(4) L型骨牌:一个2k×2k的棋盘中有一个特殊方格,所以,用到L型骨牌的个数为(4k-1)/3将所有L型骨牌连续编号,用一个全局变量t表示。

  • 算法实现

#include<iostream>  
#include<iomanip>   
#include <math.h>  
using namespace std;
void tromino(int **a, int tr, int tc, int size, int dr, int dc);
int flag = 2;  //特殊方格编号为1,依次编号
int main()
{
	int k;
	cout << "请输入一个值,棋盘的大小为2的k次方:";
	cin >> k;
	int size = (int)pow((double)2, (double)k);
	int **chessboard = NULL;
	chessboard = new int*[size]; //动态开辟二维数组,先开辟第一维指针数组   
	for (int j = 0; j < size; j++)            //开辟第二维   
	{
		chessboard[j] = new int[size];
	}
	for (int j = 0; j < size; j++)            //初始化二维数组   
		for (int k = 0; k < size; k++)
			chessboard[j][k] = 0;
	cout << "输入开始时特殊方块的两个下标(小于" << size << "):";
	int a, b;   //a为特殊方块的第一维下标,b为第二维下标  
	cin >> a >> b;
	chessboard[a][b] = 1;            //初始特殊方块标为1   
	cout << "棋盘开始时特殊方块标为1,它在棋盘的第" << a + 1 << "行,第" << b + 1 << "列" << endl;
	tromino(chessboard, 0, 0, size, a, b);
	cout << "tromino解为:" << endl;
	for (int j = 0; j < size; j++)
	{
		for (int k = 0; k < size; k++)
			cout << setw(4) << left << chessboard[j][k];     cout << endl;
	}
	delete chessboard;
	system("pause");
	return 0;
}

//a为二维数组名,tr,tc为分块的左上角元素下标,size为分块的大小,dr,dc为特殊方块的下标   
void tromino(int **a, int tr, int tc, int size, int dr, int dc)
{
	int i = size / 2;
	if (i - 1 == 0)
	{
		if (dr <= tr + i - 1 && dc > tc + i - 1)         //特殊方块在第一象限
			a[tr + i - 1][tc + i - 1] = a[tr + i][tc + i - 1] = a[tr + i][tc + i] = flag++;
		//分别为2,3,4象限   
		else if (dr <= tr + i - 1 && dc <= tc + i - 1)   //特殊方块在第二象限
			a[tr + i - 1][tc + i] = a[tr + i][tc + i] = a[tr + i][tc + i - 1] = flag++;
		//分别为1,4,3象限   
		else if (dr > tr + i - 1 && dc <= tc + i - 1)    //特殊方块在第三象限
			a[tr + i - 1][tc + i - 1] = a[tr + i - 1][tc + i] = a[tr + i][tc + i] = flag++;
		//分别为2,1,4象限   
		else
			a[tr + i - 1][tc + i - 1] = a[tr + i - 1][tc + i] = a[tr + i][tc + i - 1] = flag++;
		//分别为2,1,3象限   
	}
	else
	{
		if (dr <= tr + i - 1 && dc > tc + i - 1) //特殊方块在第一象限   
		{
			a[tr + i - 1][tc + i - 1] = a[tr + i][tc + i - 1] = a[tr + i][tc + i] = flag++;
			tromino(a, tr, tc + i, i, dr, dc);                   //解第一象限
			tromino(a, tr, tc, i, tr + i - 1, tc + i - 1);       //解第二象限  
			tromino(a, tr + i, tc, i, tr + i, tc + i - 1);       //解第三象限   
			tromino(a, tr + i, tc + i, i, tr + i, tc + i);       //解第四象限  
		}
		else if (dr <= tr + i - 1 && dc <= tc + i - 1)   //特殊方块在第二象限
		{
			a[tr + i - 1][tc + i] = a[tr + i][tc + i] = a[tr + i][tc + i - 1] = flag++;
			tromino(a, tr, tc + i, i, tr + i - 1, tc + i);       //解第一象限
			tromino(a, tr, tc, i, dr, dc);                       //解第二象限 
			tromino(a, tr + i, tc, i, tr + i, tc + i - 1);       //解第三象限
			tromino(a, tr + i, tc + i, i, tr + i, tc + i);       //解第四象限
		}
		else if (dr > tr + i - 1 && dc <= tc + i - 1) //特殊方块在第三象限  
		{
			a[tr + i - 1][tc + i - 1] = a[tr + i - 1][tc + i] = a[tr + i][tc + i] = flag++;
			tromino(a, tr, tc + i, i, tr + i - 1, tc + i);       //解第一象限 
			tromino(a, tr, tc, i, tr + i - 1, tc + i - 1);       //解第二象限   
			tromino(a, tr + i, tc, i, dr, dc);                   //解第三象限   
			tromino(a, tr + i, tc + i, i, tr + i, tc + i);       //解第四象限   
		}
		else
		{
			a[tr + i - 1][tc + i - 1] = a[tr + i - 1][tc + i] = a[tr + i][tc + i - 1] = flag++;
			//分别为2,1,3   
			tromino(a, tr, tc + i, i, tr + i - 1, tc + i);       //解第一象限   
			tromino(a, tr, tc, i, tr + i - 1, tc + i - 1);       //解第二象限  
			tromino(a, tr + i, tc, i, tr + i, tc + i - 1);       //解第三象限  
			tromino(a, tr + i, tc + i, i, dr, dc);               //解第四象限  
		}
	}

}
  • 算法分析

设T(k)是覆盖一个2k×2k棋盘所需时间,从算法的划分策略可知,T(k)满足如下递推式:

解此递推式可得T(k)=O(4k)。由于覆盖一个2k×2k棋盘所需的骨牌个数为(4k-1)/3,所以,算法4.4.2.1是一个在渐进意义下的最优算法。

几何问题中的分治法

  • 最近对问题

  • 问题描述

设p1=(x1, y1), p2=(x2, y2), …, pn=(xn, yn)是平面上n个点构成的集合S,最近对问题就是找出集合S中距离最近的点对。

 严格地讲,最接近点对可能多于一对,简单起见,只找出其中的一对作为问题的解。

蛮力法 

  • 问题分析

用分治法解决最近对问题:

将集合S分成两个子集 S1S2,每个子集中有n/2个点。

在每个子集中递归地求其最接近的点对。

将两个子集中的最接近点合并。

问题:如果这两个点分别在 S1和 S2中,如何求解?

 

最近对问题的分治策略是:

划分

将集合S分成两个子集S1S2,设集合S的最近点对是pipj1£i,j£n),则会出现以下三种情况:

求解子问题

对于划分阶段的情况①和②可递归求解

情况③:最近点对分别在集合S1和S2中

合并

比较在划分阶段三种情况下最近点对,取三者之中较小者为原问题的解。 

 

简化问题——先考虑一维的情形。

S中的点退化为x轴上的n个点x1, x2, …, xn。

用x轴上的某个点mS划分为两个集合S1S2,并且S1S2含有点的个数相同

递归地在S1和S2上求出最接近点对 (p1, p2) (q1, q2)。

如果集合S中的最接近点对都在子集S1S2中,则d=min{(p1, p2), (q1, q2)}即为所求。

如果集合S中的最接近点对分别在S1S2中,则一定是(p3, q3)其中,p3是子集S1中的最大值,q3是子集S2中的最小值。

下面考虑二维的情形,此时S中的点为平面上的点。

以一维为例选取分割点m:

线性分割:S=S1∪S2 S1∩S2,且S1={ x|x≤m}S2={x|x>m}。

——划分方法:一维情况下,如选m=[max(S)+min(S)]/2,可以满足线性分割的要求。选取分割点后,再用O(n)时间即可将S划分成S1={ x∈S|x≤m}S2={x∈S|x>m}。

——存在问题:有可能造成划分出的子集S1S2的不平衡

——最坏情况:|S1|=1|S2|=n-1,此时计算时间T(n)应满足递归方程:  T(n)=T(n-1)+O(n)    它的解是T(n)=O(n2)。

解决方法:这种效率降低现象可通过分治法中“平衡子问题”方法加以解决。即通过适当选择分割点m,使S1和S2中有大致相等个数的点。

——中位数:S的n个点的坐标的中位数来作分割点。选取中位数的线性时间算法可以在O(n)时间内确定一个平衡的分割点m 

——二维情况下,mS中各点x坐标的中位数。即,居数列中间位置的元素。

分割线

——为了将平面上的点集S 分割为点的个数大致相同的两个子集S1S2,选取垂直线x=m来作为分割线,其中,mS中各点x坐标的中位数

——将S分割为S1={ pS | xpm}S2={ qS | xqm}。递归地在S1S2上求解最近对问题,分别得到S1中的最近距离d1S2中的最近距离d2。

 

考虑最近点对p和qpS1qS2)分属于S1和S2

令d=min(d1, d2),则S的最近对(p, q)之间的距离小于d,且pq距直线x=m的距离均小于d(否则就p,q的距离就大于d)

可以将求解限制在以x=m为中心、宽度为2d的垂直带P1P2中,垂直带之外的任何点对之间的距离都一定大于d。

 
 
  • 算法设计

  • 算法分析

应用分治法求解含有n个点的最近对问题,其时间复杂性可由下面的递推式表示:

情况③、合并子问题的解的时间f(n)O(n),根据2.1.5节的定理2.1,可得T(n)=O(nlog2n)。

分治法的特殊应用

  • 寻找第k小元素问题(选择问题)

  • 问题描述

设无序序列 T =(r1, r2, …, rn)T 的第k1≤kn)小元素定义为T按升序排列后在第k个位置上的元素。给定一个序列T和一个整数k,寻找 T 的第k小元素的问题称为选择问题

特别地,n个元素的无序数组中选择第k(1<=k<=n)小元素。

当k=1时,相当于找最小值。

当k=n时,相当于找最大值。

当k=n/2时,称中值。

  • 问题分析

考虑快速排序中的划分过程,一般情况下,设待划分的序列为ri rj,选定一个轴值将序列ri rj进行划分,使得比轴值小的元素都位于轴值的左侧,比轴值大的元素都位于轴值的右侧,假定轴值的最终位置是s,则: 

1)若k=s,则rs就是第k小元素;

2)若k<s,则第k小元素一定在序列ri ~ rs-1中;

3)若k>s,则第k小元素一定在序列rs+1 ~ rj中;

无论哪种情况,或者已经得出结果(如果轴值恰好是序列的中值),或者将选择问题的查找区间减少一半。

  • 选择问题的例子

选择问题的查找过程示例(查找第4小元素)

 A[1…28]={8,33,17,51,57,49,35,11,25,37,14,3,2,13,52,12,6,29,32,54,5,16,22,23,7,61,36,9},求A的中位数元素,即第14小元素。(k=14)

  • 算法设计

  • 算法分析

最好情况每次划分的轴值恰好是序列的中值,则可以保证处理的区间比上一次减半,由于在一次划分(O(n)后,只需处理一个子序列,所以,比较次数的递推式是:

最坏情况:每次划分的轴值恰好是序列中的最大值或最小值,则处理区间只能比上一次减少1个,所以,比较次数的递推式是:

平均情况:假设每次划分的轴值是划分序列中的一个随机位置的元素,则处理区间按照一种随机的方式减少,可以证明,算法的平均时间是O(n)

  • 减治法

  • 减治法的设计思想

规模为n的原问题的解与较小规模(通常是n/2)的子问题的解之间具有关系:

(1)原问题的解只存在于其中一个较小规模的子问题中;

(2)原问题的解与其中一个较小规模的解之间存在某种对应关系。

由于原问题的解与较小规模的子问题的解之间存在这种关系,所以,只需求解其中一个较小规模的子问题就可以得到原问题的解。

  • 减治法的时间复杂性

减治法只对一个子问题求解,并且不需要进行解的合并。应用减治法(例如减半法)得到的算法通常具有如下递推式:

所以,通常来说,应用减治法处理问题的效率是很高的,一般是O(log2n)数量级。 

 

分治法小结

  • 分治法的基本思想

将一个规模为n的问题分解为k个规模较小的子问题,这些子问题互相独立且与原问题相同;

对这k个子问题分别求解。如果子问题的规模仍然不够小,则再划分为k个子问题,如此递归的进行下去,直到问题规模足够小,很容易求出其解为止;

将求出的小规模的问题的解合并为一个更大规模的问题的解,自底向上逐步求出原来问题的解。

  • 分治法所解决问题的特征

1)该问题的规模缩小到一定的程度就可以容易地解决;

2)该问题可以分解为若干个规模较小的相同问题,即该问题具有最优子结构性质;

3)利用该问题分解出的子问题的解可以合并为该问题的解;

4)原问题所分解出的各个子问题是相互独立的,即子问题之间不包含公共的子问题。

能否利用分治法完全取决于问题是否具有第3条特征,如果具备了前两条特征,而不具备第3条特征,则可以考虑贪心算法或动态规划。

第4条特征涉及到分治法的效率,如果各子问题不独立,则分治法要做许多重复工作,重复地求解公共子问题,此时虽可用分治法,但一般用动态规划法较好。

       

  

猜你喜欢

转载自blog.csdn.net/weixin_45900618/article/details/108509652
今日推荐