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:
- a → c
- a → b
- c → b
- a → c
- b → a
- b → c
- 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;
}