【算法】算法设计与分析 课程笔记 第二章 递归与分治策略

2.1 递归

递归就是用自己来定义自己,

直接或间接地调用自身的算法称为递归算法。

用函数自身给出定义的函数称为递归函数。

用栈来理解最直观,想象现在有一个开头朝上的栈道,第一个放进去的块就是最开始调用的函数,这个函数不能一下就解决问题(未触发临界返回条件,可以想象成没到栈道口),于是它就调用自身,往栈道里再放一块,这时候比之前堆高了,但是还没达到栈道口,于是继续堆和之前一样的块,直到这些块的高度达到了栈道口,触发了临界条件,再从上往下依次返回,也就是依次出栈。

一般来说,要用到递归的,都会有一个分段函数,也就是递归函数,其中包含临界条件和调用自身的函数段。

边界条件和递归方程是递归函数的两个要素。

2.1.1 阶乘

首先得想到一个求阶乘的函数:

这个函数的下面那个式子就用到了调用自身,所以可以用递归来实现,将主问题拆分成若干层的子问题,最底层的一定是当 n=0 时,阶乘的值,由此可以设计以下程序:

#include<bits/stdc++.h>
using namespace std;
int jiecheng(int n){
	if(n==0)
		return 1;//最底层必然返回1
	else
		return n*jiecheng(n-1);//不是最底层,那就继续向下求阶乘
}
int main(){
	int n;
	cin>>n;
	cout<<jiecheng(n);
	return 0;
}

 

可以发现,递归其实是一个先分解再解决的过程,先把问题分解到足够小,再从小问题开始逐一向上击破。

2.1.2 斐波那契数列

首先还是得想到这个递归的分段函数:

可以看出这个函数和上面的求阶乘函数十分相似,只不过临界条件有了两个,调用自身的函数段中也用了两次自身的调用,其实道理还是一样,请看代码:

#include<bits/stdc++.h>
using namespace std;
fb(int n){
	if(n==1||n==2) return 1;
	return fb(n-1)+fb(n-2);
}
int main(){
	int n;
	cin>>n;
	for(int i=1;i<=n;i++){
		cout<<fb(i)<<endl;
	}
	return 0;
}

2.1.3 汉诺塔

先从最简单的三层汉诺塔入手,三层的话,只需要7步就可以把塔从 a 借助 b 移到 c:

  1. a → c
  2. a → b
  3. c → b
  4. a → c
  5. b → a
  6. b → c
  7. a → c

从图中还可以看到,要想把 a 上的 n 个盘子移到 c ,总需要先把 a 上面的 n-1 个盘子,移到 b ,再把 a 最底下的那个移到 c ,最后把 b 上的盘子都移到 c 。

每次多个盘子从一根柱子到另一根柱子的整体移动,都要借助第三个柱子,因为这时候第三个柱子要么没盘子,要么上面的盘子比其他两个柱子上的盘子都大。

根据这个,我们就可以设计一个递归函数了,但是这个函数不好用数学式子表示:

  • 临界条件:只需要移动一个盘子时,直接把盘子从 a 移动到 c ;
  • 递归函数:每次将 a 柱上除了最底下的所有盘子借助 c 移到 b,再把 a 柱最底下的盘子移到 c,最后将 b 柱上的盘子全部移到 c ,每次的整体移动都需要递归应用自身,要注意的是,两次递归运用中移动的盘子数相等,只是比上一层小一;

接下来看代码表示,就很轻松了:

#include<bits/stdc++.h>
using namespace std;
void move(char a,char b){
	cout<<a<<"->"<<b<<endl;
}
void hanoi(int n,char a,char b,char c){
	if(n==1) move(a,c);
	else{
		hanoi(n-1,a,c,b);
		move(a,c);
		hanoi(n-1,b,a,c);
	}
}
int main(){
	int n;
	char a,b,c;
	cin>>n>>a>>b>>c;
	hanoi(n,a,b,c);
	return 0;
}

2.1.4 递归函数的时间复杂度

以汉诺塔为例,时间复杂度的函数为:

在这里,我们使用递推法来得到具体的时间复杂度。递推法和数学归纳法类似,都是一步一步推来找规律:

由此可见,递归算法的时间复杂度大得惊人,这导致其运行效率极低,而且无论是耗费的计算时间,还是占用的存储空间都比非递归算法要多得多。

2.2 分治

分治法的设计思想是,将一个难以直接解决的大问题,分割成一些规模较小的相同问题,以便各个击破,分而治之。

其中的划分再击破,和递归的分解再解决异曲同工,其实同样用到了递归的思想,只不过分治法先分再治,最后还得合并。

分治的算法设计模板如下:

divide_and_conquer ( P ) {  

        if ( P <= n0 ) return conquer ( P ) ;      当P的规模不超过阈值n0时,直接求解。

        divide P into P1, P2, P3 ... Pk ;           分解问题P为各个子问题

        for ( i = 1 ; i <= k ; i++ )                 

                yi = divide_and_conquer ( Pi ) ;   递归求解子问题

        return merge ( y1, y2, y3 ... yk ) ;         合并子问题的解为P的解

}

分治法的时间复杂性

分治法的时间复杂性为:

其中,设子问题规模为 n/b ,divide 和 merge 的时间为 f(n) 。下面是主定理

例3中,log(4,3)< 1,而nlogn > n^1,所以T(n) = O(nlogn) 。

这道题用主定理来做很简单,以A选项为例,其中 a = 2,b = 3,d = 1,log b a = log 3 2 < 1,所以T(n) = O(n);其他选项类似。

分治法实例

1. 求 a 的 n 次幂

#include<bits/stdc++.h>
using namespace std;
double Pow (double a, int n) {
	if (n == 0)
		return 1;
	else if (n % 2 == 0)
		return Pow (a, n / 2) * Pow (a, n / 2);
	else
		return Pow (a, (n - 1) / 2) * Pow (a, (n - 1) / 2) * a;
}
int main () {
	double a;
	int n;
	cin >> a >> n;
	cout << Pow (a, n);
	return 0;
}

 2. 猜测最优解

分析

浮点数二分的应用,每次二分绳子长度,使得左右边界逼近同一个值,这个值就是绳子能切割的最大长度,每次切割后需要对能切割出来的绳子数量进行统计,如果数量过多,说明切小了,把这次二分的中间值作为下次二分的左边界;如果数量过少,说明切大了,把这次二分的中间值作为下次二分的右边界。

debug

首先,题目给出的每根绳子的长度都是浮点数,所以存储的数组也要设置为double,二分的起止点以及middle中间值也全都要是double;

其次,切割的精度作为循环的条件,需要从低到高逐一尝试,最后得到最佳的精读。

#include<bits/stdc++.h>
using namespace std;
const int N = 1e6;
double a[N];
int n, k;
int main () {
	cin >> n >> k;
	for (int i = 0; i < n; i++)
		cin >> a[i];
	double left = 0, right = 100000;
	while (right - left >= 1e-4) {
		double middle = (left + right) / 2;
		int cnt = 0;
		for (int i = 0; i < n; i++)
			cnt += a[i] / middle;
		if (cnt >= k)
			left = middle;
		else
			right = middle;
	}
	cout << fixed << setprecision (2) << (int) (right * 100) / 100.0;
	return 0;
}

这里可以学到一个关于保留几位小数和保留几位有效数字的小技巧:

	// 保留到小数点后两位 
	cout << fixed << setprecision (2) << (int) (right * 100) / 100.0;
	// 四舍五入到小数点后两位 
	cout << fixed << setprecision (2) << right;

2.3 二分搜索

基础模式

要求:给定已按升序拍好序的 n 个元素,需要在这 n 个元素中找出一个特定元素 x 。

分析:逐一对比来搜索的话,时间复杂度为O(n),但是使用二分搜索,每次折半查找,时间复杂度仅为O(log n),理论存在,实践开始!

思路:使用递归的思想,临界条件是最后找不到目标元素,递归函数就是每次折半,若折半后取到目标元素,就直接返回,如果折半取到的元素大于目标元素,就搜索前半部分,否则搜索后半部分。

代码:有两种写法,分别是递归和循环,因为同时二分搜索,所以时间复杂度都为O(log n)。

递归写法:

#include<bits/stdc++.h>
using namespace std;
const int N = 1e6;
int n,x,a[N];
int bisearch (int x, int a[], int left, int right) {
	if (left > right) return -1;
	int middle = left + right >> 1;
	if (a[middle] == x) 
		return middle;
	else if (a[middle] > x)
		return bisearch (x, a, left, middle-1);
	else
		return bisearch (x, a, middle+1, right);
}
int main() {
	cin >> n;
	for (int i = 0; i < n; i++)
		cin  >> a[i];
	cin  >> x;
	cout << bisearch (x, a, 0, n-1);
	return 0;
}

循环写法:

#include<bits/stdc++.h>
using namespace std;
const int N = 1e6;
int n, x, a[N];
int bisearch (int x, int a[], int left, int right) {
	while(left <= right) {
		int middle = left + right >> 1;
		if (a[middle] == x)
			return middle;
		else if (a[middle] > x)
			return bisearch (x, a, left, middle-1);
		else
			return bisearch (x, a, middle+1, right);
	}
	return -1;
}
int main () {
	cin >> n;
	for (int i = 0; i < n; i++)
		cin >> a[i];
	cin >> x;
	cout << bisearch (x, a, 0, n-1);
	return 0;
}

加强模式

如果这个非递减序列中,存在多个连续排列的相同元素,这时候要求返回最左边的目标元素,或者返回大于目标元素的最小数。

这里的思路依然是二分搜索,首先使用递归的方法来做,临界条件是两个指针指向同一个地方,因为题目的意思是找左边界嘛,所以必须是这样;

然后就是递归函数,递归函数和之前的有所不同,这里我们必须不断缩小搜索区域内目标元素的范围,也就是把它右边的都砍掉,所以第一个分支就是当目标元素小于或等于折半数时,缩小搜索范围到折半数(包括折半数,因为你不知道这是不是最左边的,如果是最左边的,你又没考虑,那答案就错了);第二个分支就是搜索大于折半数的区域,因为目标元素在这个分支里面已经比折半数大了,所以这里就不用考虑折半数了。

递归写法如下:

#include<bits/stdc++.h>
using namespace std;
const int N = 1e6+10;
int n, x, a[N];
int lower_bound (int x, int a[],int left, int right) {
	if (left == right)
		return left;
	int middle = left + right >> 1;
	if (x <= a[middle]) //对找边界来说,不能找到就返回,而是得不断缩小范围,直到两个指针指向同一处 
		return lower_bound (x, a, left, middle); //当目标数小于或等于折半数时, 缩小范围要包括折半数 
	else
		return lower_bound (x, a, middle + 1, right); // 当目标数大于折半数时,就从折半数右边一个数开始找 
}
int main () {
	cin >> n;
	for (int i = 0; i < n; i++)
		cin >> a[i];
	cin >> x;
	cout << lower_bound (x, a, 0, n - 1);
	return 0;
}

循环写法如下:

#include<bits/stdc++.h>
using namespace std;
const int N = 1e6+10;
int n, x, a[N];
int lower_bound (int x, int a[], int left, int right) {
	while (left != right){
		int middle = left + right >> 1;
		if (x <= a[middle])
			right = middle;
		else
			left = middle + 1;
	}
	return left;
}
int main () {
	cin >> n;
	for (int i = 0; i < n; i++)
		cin >> a[i];
	cin >> x;
	cout << lower_bound (x, a, 0, n - 1);
	return 0;
}

2.7 归并排序

 这里直接用acwing的模板,简单好记:

#include<iostream>
using namespace std;
const int N = 1e6;
int n;
int q[N], tmp[N]; // q是需要排序的数组,tmp是排序中用来暂存的数组 
void merge_sort (int q[], int l, int r) {
	if (l >= r) return; // 如果数组中只有一个数或者一个数也没有,那就不需要排序了 
	int mid = (l + r) / 2 ; // 每次将数组划分成两半,分而治之 
	merge_sort (q, l, mid);
	merge_sort (q, mid + 1, r); //这就是递归函数了,调用自身 
	int k = 0, i = l, j = mid + 1; //k作为tmp数组的指针,i和j分别是q数组前半部分和后半部分的指针 
	while (i <= mid && j <= r) {
		if (q[i] <= q[j])  // 小的先进暂存数组 
			tmp[k++] = q[i++];
		else 
			tmp[k++] = q[j++];
	}
	while (i <= mid)
		tmp[k++] = q[i++];
	while (j <= r)
		tmp[k++] = q[j++];
	for (int i = l, j = 0; i <= r; i++, j++) // 这里i用l和r,就不需要用到数组的长度了 
		q[i] = tmp[j];
}
int main () {
	cin >> n;
	for (int i = 0; i < n; i++)
		cin >> q[i];
	merge_sort (q, 0, n - 1);
	for (int i = 0; i < n; i++)
		cout << q[i] << " ";
	return 0;
}

归并排序算法的时间复杂度:

错题分析

这道题很难一眼看出来,但是可以排除没学的堆排序,显然也不是选择排序,只需要考虑学过的快排和归并,快排里第一趟肯定是把数组划分成一半小一半大的情况,这里面并不是这样,所以只能选归并,按归并来看,序列中的元素确实是分成了四组,每组已经排好序了,一小一大。

这道题的答案为 A. O(log N)。

做题时没有认真审题,考的不是归并排序的时间复杂度,而是归并趟数的数量级

归并趟数其实就是拆分子问题和合并子问题的数量级,我们我们知道每次归并都是将大问题拆成两个小问题,所以显然这里的数量级是 O(log N)。

2.8 快速排序

同样是用递归分治的方法求解,复习时画图模拟排序过程帮助很大!下面是代码及解析:

#include<iostream>
using namespace std;
const int N = 1e6;
int n, q[N];
int split (int q[], int l, int r) {
	int x = q[l], i = l, j = r + 1; //x是数组最左边的那个数,用它来当作划分的中间数 
	while (1) {
		while (q[++i] < x && i < r); //因为最左边的数不参与划分,所以第一次也可以用++i,同时要保证指针不能越界,所以i要小于r 
		while (q[--j] > x); //这里就是因为把j放到最右边的右边,所以可以用--j,因为数组最左边就是x,所以j不会越界,不用给限制 
		if (i >= j) break; // 一直循环直到左右指针相等或者左指针大于右指针 
		swap (q[i], q[j]); //直到左边的i指向第一个大于等于x的数,右边的j指向第一个小于等于x的数,两者交换 
	}
	swap(q[l], q[j]); //到最后j要么在i的左边,要么和i指向一致,在左边的话,j指向的数就比x小了,所以换一下没问题,一致的话换也没问题 
	return j;
}
void QuickSort (int q[], int l, int r) {
	if (l < r) { //如果等于的话,就相当于只有一个数,那就不需要排序了 
		int mid = split (q, l, r); //这个split操作把数组分为两部分,左边是小于mid的,右边是大于mid的 
		QuickSort (q, l, mid - 1);
		QuickSort (q, mid + 1, r); //对左右两部分再进行快排 
	}
} 
int main () {
	cin >> n;
	for (int i = 0; i < n; i++)
		cin >> q[i];
	QuickSort (q, 0, n - 1);
	for (int i = 0; i < n; i++)
		cout << q[i] <<" /n"[i==n]; // 一个输出技巧,当i和n相等时,输出回车 
	return 0;
}

PTA 编程题选讲

1. 最大子段和

思路

判断最大子段和,可以用分治的思想,每次将序列一分为二,选择两个序列的最大子段和。

但是这里还有一种可能,就是子段可以横跨两个子序列,所以我们的最大子段和就是:

MAX(左边序列最大字段和,横跨两序列的最大子段和,右边序列的最大子段和)。

对于左右两边的最大子段和,可以用分治递归的方法来做,临界条件就是序列中只剩一个数了,这时候最大子段和就是这个数,而递归函数就是对左右两边分别求最大子段和(调用自身),而且还得求跨序列的最大子段和,取三者的最大值来返回。

那么怎么求跨序列的最大子段和呢?其实很简单,首先要对原来的大序列添加几个指针,开头的是指针l,最右边的是指针r,因为要分治,所以再设置一个中间的指针mid,此时序列就可以分为两个部分,分别是(l,mid)和(mid+1,r),这时候的跨序列子段,必须包含mid和mid+1这两个地方,当然也可以向左或向右延申,所以,我们只需要求出从mid开始向左延申的最大字段和,还有从mid+1开始向右延申的最大子段和,将两者相加,就能得到跨序列的最大子段和了。

思路很好理解,照着上面的描述画出图来就一目了然了。下面来看看代码实现吧。

代码

#include<bits/stdc++.h>
using namespace std;
const int N = 1e5;
int n, a[N];
int maxSum (int left, int right) {
	if (left == right)
		return a[left];
	int mid = left + right >> 1;
	int lmax = maxSum (left, mid);
	int rmax = maxSum (mid + 1, right);
	int sum = a[mid];
	int clmax = a[mid];
	for (int i = mid - 1; i >= left; i--) {
		sum += a[i];
		if (sum > clmax)
			clmax = sum;
	}
	sum = a[mid + 1];
	int crmax = a[mid + 1];
	for (int i = mid + 2; i <= right; i++) {
		sum += a[i];
		if (sum > crmax)
			crmax = sum;
	}
	int cmax = clmax + crmax;
	int maxsum = max (cmax, max (lmax, rmax));
	if (maxsum < 0)
		maxsum = 0;
	return maxsum;
}
int main () {
	cin >> n;
	for (int i = 0; i < n; i++)
		cin >> a[i];
	cout << maxSum (0, n - 1);
	return 0;
}

2. 两个有序序列的中位数

思路

求两个有序序列的中位数,其实用归并排序的方法很快就求出来了,但是时间复杂度不太完美,才O(nlogn),所以这里用了新方法,使用递归的思想来做,时间复杂度仅为O(log n),具体是怎么做的呢?让我来解释一下吧。

首先我们知道这里有两个有序序列,既然是有序的,那我们可以直接取两者的中位数进行比较,如果相等,那就直接得出答案了,那如果不相等呢?那就得比较一下了。

我们想象一下,现在有两个有序序列,序列a放上面,序列b放下面,两个序列的长度是相等的,都为n,我们可以试着想象一下,如果把两个序列按顺序拼在一起,那这个大序列的中位数就在第n个位置上(我们取中间两个数的前一个作为中位数),从这里我们可以知道,中位数必须大于 n-1 个数,小于 n 个数。

那现在来比较一下两个序列各自的中位数吧,对于比较大小的话,我们只需要看其中一种情况就可以,另外一种情况就同理了。在此之前还需要分类讨论一下,分为n为奇数和偶数这两种情况,因为不同的情况等会儿的边界会有所不同。

如果n为奇数,可以作图来排除掉序列a中位数之前的那些数和序列b中位数之后的那些数;

如果n为偶数,可以作图排除掉序列a的中位数以及它之前的那些数和序列b中位数之后的那些数。

由此就产生了第一次的递归,因为排除掉那些数后形成的两个序列还是一样长,所以接着按照这样的方法来排除,直到每个序列都只剩下一个数,选择较小的那个作为最终的中位数。

这就是一个递归的过程,下面就是具体的代码实现。

代码

#include<iostream>
using namespace std;
const int N = 1e6;
int n, a[N], b[N];
int midNum (int a[], int al, int ar, int b[], int bl, int br) {
	if (al == ar) 
		return a[al] < b[bl] ? a[al] : b[bl];
	int am = al + ar >> 1, bm = bl + br >> 1;
	int even = (ar - al + 2) % 2;
	if (a[am] == b[bm])
		return a[am];
	else if (a[am] < b[bm])
		return midNum (a, am + even, ar, b, bl, bm);
	else
		return midNum (a, al, am, b, bm + even, br);
}
int main () {
	cin >> n;
	for (int i = 0; i < n; i++)
		cin >> a[i];
	for (int i = 0; i < n; i++)
		cin >> b[i];
	cout << midNum (a, 0, n - 1, b, 0, n - 1);
	return 0;	
}

3. 找第k小的数

思路

大体和提示一样,就是一个判断mid和k的find函数不同,我做得更简单。

代码

#include<iostream>
using namespace std;
const int N = 1e5;
int n, k, a[N];
int find (int a[], int mid, int k){
	if(mid == k)
		return 0;
	else if (mid > k)
		return 1;
	else
		return -1;
}
int partition (int a[], int left, int right) {
	int x = a[left];
	int i = left, j = right + 1;
	while (i < j) {
		while (a[++i] < x && i < right);
		while (a[--j] > x);
		if (i >= j) break;
		swap (a[i], a[j]); 
	}
	int mid = j;
	swap(a[left], a[j]);
	int ans = find (a, mid, k);
	if (ans == -1 )
		return partition (a, mid + 1, right);
	else if (ans == 1)
		return partition (a, left, mid - 1);
	else
		return a[mid]; 
}
int main () {
	cin >> n >> k;
	k -= 1;
	for (int i = 0; i < n; i++)
		cin >> a[i];
	cout << partition (a, 0, n - 1);
	return 0;
}

4. 改写二分搜索算法

思路

主要在于求边界的代码,这道题我分别用两个函数来求边界,lower_bound用来求左边界,left_bound用来求右边界,左边界中有一个地方容易死循环,要注意。

在写这种题的时候最后画出来,手推比较好。

代码

#include<bits/stdc++.h>
using namespace std;

const int N = 1e6 + 10;
int n, x, a[N];

int higher_bound(int x, int a[], int left, int right) {
	while (left != right) {
		int middle = (left + right) / 2;
		if (a[middle] >= x)
			right = middle;
		else
			left = middle + 1;
	}
	return left;
} 

int lower_bound(int x, int a[], int left, int right) {
	while (left != right) {
		int middle = (left + right + 1) / 2;
		if (a[middle] <= x)
			left = middle;
		else
			right = middle - 1;
	}
	return right;
}

int main() {
	cin >> n >> x;
	for (int i = 0; i < n; i++)
		cin >> a[i];
	if (x < a[0])
		cout << "-1 0";
	else if (x > a[n - 1])
		cout << n - 1 << " " << n;
	else
		cout << lower_bound(x, a, 0, n - 1) << " " << higher_bound(x, a, 0, n - 1) << " " ;
	return 0;
}

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值