递归与分治
递归
递归:直接或间接(A:这件事找 B;B:这件事找 A)地用到自己。
如何定义正整数?正整数是
是正整数- 如果
是正整数, 也是正整数
这种定义就是递归式的:在“正整数”还没有定义时,就用到了“正整数”的定义。递归式定义能让定义简洁而严密。
例题:P5739 [深基7.例7] 计算阶乘
解题思路
原来学过循环求解,用一个 for
循环,就可以完成递推了。
参考代码
#include <cstdio> int main() { int n; scanf("%d", &n); int ans = 1; for (int i = 1; i <= n; i++) ans *= i; printf("%d\n", ans); return 0; }
数学函数也可以递归定义,阶乘函数
参考代码
#include <cstdio> int f(int n) { return n == 0 ? 1 : f(n - 1) * n; } int main() { int n; scanf("%d", &n); printf("%d\n", f(n)); return 0; }
要注意:一定要有个递归边界,要不然就无限递归了,另外递归过程中每一层的变量(包括参数)是独立的,互不影响。
例题:P5743 【深基7.习8】猴子吃桃
解题思路
可以循环递推,设
也可以正着递推,设
当然,这两种递推方式也可以写成递归形式,类似于上一题。
参考代码1
#include <cstdio> int n; int calc(int x) { if (x == n) return 1; return 2 * (calc(x + 1) + 1); } int main() { scanf("%d", &n); printf("%d\n", calc(1)); return 0; }
参考代码2
#include <cstdio> int calc(int x) { if (x == 1) return 1; return 2 * (calc(x - 1) + 1); } int main() { int n; scanf("%d", &n); printf("%d\n", calc(n)); return 0; }
习题:B2142 求 1+2+3+...+N 的值
解题思路
与阶乘类似,
#include <cstdio> int f(int n) { return n == 0 ? 0 : f(n - 1) + n; } int main() { int n; scanf("%d", &n); printf("%d\n", f(n)); return 0; }
递归与递推
对于前面的问题,用递归和递推都解决了,那么两者有什么区别?
运行下面两份代码,输入
#include <cstdio> int f[45]; int main() { int n; scanf("%d", &n); f[1] = 1; f[2] = 1; for (int i = 3; i <= n; i++) f[i] = f[i - 1] + f[i - 2]; printf("%d\n", f[n]); return 0; }
循环递推,时间复杂度
#include <cstdio> int f(int x) { if (x <= 2) return 1; return f(x - 1) + f(x - 2); } int main() { int n; scanf("%d", &n); printf("%d\n", f(n)); return 0; }
递归,时间复杂度 f(1)
或 f(2)
返回。
要提升递归的效率,可以使用记忆化(用数组标记每一项是否已经算出,已经算出就直接返回)的方法,不过一定要确定真的不需要重复计算才行。
#include <cstdio> int ans[45]; bool vis[45]; int f(int x) { if (x <= 2) return 1; if (vis[x]) return ans[x]; vis[x] = true; return ans[x] = f(x - 1) + f(x - 2); } int main() { int n; scanf("%d", &n); printf("%d\n", f(n)); return 0; }
时间复杂度
通常建议简单递推式使用循环递推实现。
使用递归往往适用于如下情况:
- 难以找到循环的合适顺序时(递推式中出现的下标可能变大也可能变小)
- 从要求的第
项出发,有用的项很少(比如 )
例题:P1464 Function
解题思路
使用记忆化的方法就可以避免超时了,有效状态数是
不过需要注意当
参考代码
#include <cstdio> using ll = long long; const int N = 25; bool vis[N][N][N]; // 记录某状态是否被计算过 ll ans[N][N][N]; // 记录某状态下的计算结果 ll w(ll a, ll b, ll c) { if (a <= 0 || b <= 0 || c <= 0) return 1; if (a > 20 || b > 20 || c > 20) return w(20, 20, 20); if (vis[a][b][c]) return ans[a][b][c]; // 如果曾经计算过可以直接返回存下来的结果 // 需要展开计算则在计算完成后更新相应状态及计算结果 if (a < b && b < c) { ans[a][b][c] = w(a, b, c-1) + w(a, b-1, c-1) - w(a, b-1, c); vis[a][b][c] = true; return ans[a][b][c]; } ans[a][b][c] = w(a-1, b, c) + w(a-1, b-1, c) + w(a-1, b, c-1) - w(a-1, b-1, c-1); vis[a][b][c] = true; return ans[a][b][c]; } int main() { while (true) { ll a, b, c; scanf("%lld%lld%lld", &a, &b, &c); if (a == -1 && b == -1 && c == -1) break; printf("w(%lld, %lld, %lld) = %lld\n", a, b, c, w(a, b, c)); } return 0; }
例题:P1928 外星密码
分析:如果只有一层方括号,那么只需要找到方括号,就可以提取出重复次数,然后将重复部分按次数复制若干份拼接起来即可。如果方括号的“重复部分”里还有方括号呢?用同样的方式展开即可。可以发现,这个机制和递归非常吻合,因此本题适合用递归的方式来实现。
#include <iostream> #include <string> using std::string; using std::cin; using std::cout; string s; int len, idx; // idx为全局变量,用来控制整个字符串的处理进度 string solve() { string ret, tmp; int rep = 0; // 遇到']'说明本层压缩串需要重复若干份作为解压缩结果返回给上一层 while (idx < len && s[idx] != ']') { if (s[idx] == '[') { // 遇到'['说明需要往里展开一层压缩 idx++; tmp += solve(); } else if (s[idx] >= '0' && s[idx] <= '9') { // 更新重复次数 rep = rep * 10 + s[idx] - '0'; idx++; } else { tmp += s[idx]; idx++; } } idx++; ret += tmp; // 如果rep不等于0,说明这一层属于需要重复拼接展开的 for (int i = 1; i <= rep - 1; i++) ret += tmp; return ret; } int main() { cin >> s; len = s.size(); cout << solve() << "\n"; return 0; }
如果能将一个大的任务分解成若干规模较小的任务,而且这些任务的形式与结构和原问题一致,就可以考虑使用递归。当问题规模足够小或者达到了边界条件就要停止递归。分解完问题后还要将这些规模小的任务的处理结果合并,最后逐级上报,解决最大规模的问题。
分治
如果想知道我国的人口数量,就需要进行人口普查。让每一个省份都去统计本省有多少人,然后将各省人口累加起来,就可以获得全国的人口数量。而要想知道某一个省的人口数量,可以让省里的每一个城市统计本市有多少人,然后将各市人口累加起来,就可以获得这个省的人口数量……以此类推,层层细分,最后统计一个村子或者一个小区有多少人,这个任务就足够简单了。把一个复杂的问题细分成若干结构相同但规模更小的子问题,然后将每个子问题的解合并起来,就得到了复杂问题的解,这就是分治策略。
P5461 赦免战俘
#include <cstdio> const int N = 1050; int a[N][N]; // 左上角坐标(x,y),边长为len的正方形 void solve(int x, int y, int len) { // 先考虑边界条件 if (len==1) { a[x][y]=1; return; } // 拆分问题 // 左上角全为0(相当于不用处理) // 继续用同样的方式处理右上,左下,右下 solve(x,y+len/2,len/2); solve(x+len/2,y,len/2); solve(x+len/2,y+len/2,len/2); } int main() { int n; scanf("%d",&n); // n = (1<<n); int len=1; for (int i=1;i<=n;i++) len*=2; solve(1,1,len); for (int i=1;i<=len;i++) { for (int j=1;j<=len;j++) { printf("%d ",a[i][j]); } printf("\n"); } return 0; }
P1228 地毯填补问题
#include <cstdio> int px, py; int judge(int xx, int yy, int x, int y, int n) { // 判断残缺的块在哪个分区 if (xx < x + n / 2) return yy < y + n / 2 ? 1 : 2; // 左上/右上 return yy < y + n / 2 ? 3 : 4; // 左下/右下 } void solve(int n, int x, int y, int miss, int xx, int yy) { if (n == 1) return; n = n / 2; if (miss == 1) { printf("%d %d %d\n", x + n, y + n, 1); solve(n, x, y, judge(xx, yy, x, y, n), xx, yy); solve(n, x, y + n, 3, x + n - 1, y + n); solve(n, x + n, y, 2, x + n, y + n - 1); solve(n, x + n, y + n, 1, x + n, y + n); } else if (miss == 2) { printf("%d %d %d\n", x + n, y + n - 1, 2); solve(n, x, y, 4, x + n - 1, y + n - 1); solve(n, x, y + n, judge(xx, yy, x, y + n, n), xx, yy); solve(n, x + n, y, 2, x + n, y + n - 1); solve(n, x + n, y + n, 1, x + n, y + n); } else if (miss == 3) { printf("%d %d %d\n", x + n - 1, y + n, 3); solve(n, x, y, 4, x + n - 1, y + n - 1); solve(n, x, y + n, 3, x + n - 1, y + n); solve(n, x + n, y, judge(xx, yy, x + n, y, n), xx, yy); solve(n, x + n, y + n, 1, x + n, y + n); } else { printf("%d %d %d\n", x + n - 1, y + n - 1, 4); solve(n, x, y, 4, x + n - 1, y + n - 1); solve(n, x, y + n, 3, x + n - 1, y + n); solve(n, x + n, y, 2, x + n, y + n - 1); solve(n, x + n, y + n, judge(xx, yy, x + n, y + n, n), xx, yy); } } int main() { int k; scanf("%d%d%d", &k, &px, &py); int len = 1; for (int i = 1; i <= k; i++) len *= 2; solve(len, 1, 1, judge(px, py, 1, 1, len), px, py); return 0; }
归并排序
例题:P1177 【模板】排序
介绍一种新的排序算法——归并排序。要理解归并排序,首先要理解归并。考虑这样一个问题:给定两个有序的序列
算法的过程很简单,维护两个位置
比如有两个有序数组
有了归并算法之后,要对一个长度为
当两个子序列有序后,对这两个子序列进行归并,使当前这个长度为
归并排序的时间复杂度为
推导
#include <cstdio> const int N = 1e5 + 5; int a[N], tmp[N]; // tmp是合并时用的临时数组 void mergesort(int l, int r) { // 实现对a[l]~a[r]完成排序 if (l==r) { // 只剩一个数,无需排序,直接返回 return; } int mid=(l+r)/2; // a[l]~a[mid] a[mid+1]~a[r] mergesort(l,mid); mergesort(mid+1,r); // 递归到更小的子问题 // 上面这两个递归调用返回之后意味着左半边和右半边内部已经有序 // 接下来要解决合并的问题 // a[l]~a[mid] a[mid+1]~a[r] // 先合并到 tmp[l]~tmp[r] // 最后再搬回 a int i=l, j=mid+1; // 两部分的合并进度 int k=l; // 下一个数据合并到tmp的什么位置 while (i<=mid && j<=r) { if (a[i] <= a[j]) { tmp[k]=a[i]; i++; } else { tmp[k]=a[j]; j++; } k++; } // 上面循环结束时必然是左右半区的其中一个已经合并完成 // 另一个必然还剩下最后一段没有合并进去 while (i<=mid) { tmp[k]=a[i]; i++; k++; } while (j<=r) { tmp[k]=a[j]; j++; k++; } // 此时tmp[l]~tmp[r]已经合并完成,搬回原数组a for (int i=l;i<=r;i++) a[i]=tmp[i]; } int main() { int n; scanf("%d",&n); for (int i=1;i<=n;i++) scanf("%d",&a[i]); mergesort(1,n); for (int i=1;i<=n;i++) printf("%d ",a[i]); return 0; }
例题:P1908 逆序对
对于给定的一段正整数序列
,逆序对是序列中 且 的有序对。求一个长度为 的序列的逆序对个数,其中 。
分析:对于这个问题,可以在归并排序的过程中同时求出序列的逆序对数。
如果
当递归计算了两个子序列内部的逆序对数后,考虑怎么合并这两个子序列。可以发现逆序对还有一种来源,前一个序列中某个元素和后一个序列中某个元素所构成的逆序对,因此还要计算这部分的个数——这就是当每个子问题都处理完之后,合并子问题的答案得到原问题的答案。
那么如何算这种一前一后的情况呢?由于在序列位置中,前一个子序列中的元素一定在后一个子序列中的元素的前面,所以逆序对的
回顾归并排序的归并过程。对两个有序数组
时间复杂度和归并排序一样,为
#include <cstdio> using ll = long long; const int N = 5e5 + 5; int a[N], tmp[N]; // tmp是合并时用的临时数组 ll mergesort(int l, int r) { // 实现对a[l]~a[r]完成排序 if (l==r) { // 只剩一个数,无需排序 return 0; } int mid=(l+r)/2; // a[l]~a[mid] a[mid+1]~a[r] ll sum=0; sum += mergesort(l,mid); sum += mergesort(mid+1,r); // 上面这两个递归调用返回之后意味着左半边和右半边内部已经有序 // 接下来要解决合并的问题 // a[l]~a[mid] a[mid+1]~a[r] // 先合并到 tmp[l]~tmp[r] // 最后再搬回 a int i=l, j=mid+1; // 两部分的合并进度 int k=l; // 下一个数据合并到tmp的什么位置 while (i<=mid && j<=r) { if (a[i]<=a[j]) { // a[i]<=a[j]说明这次合并取左边的数 tmp[k]=a[i]; i++; } else { // a[i]>a[j] 取右边的数 // (a[i],a[j]) 构成了逆序对 // (a[i+1,...mid],a[j]) 都构成了逆序对 sum+=(mid-i+1); tmp[k]=a[j]; j++; } k++; } // 上面循环结束时必然是左右半区的其中一个已经合并完成 // 另一个必然还剩下最后一段没有合并进去 while (i<=mid) { tmp[k]=a[i]; i++; k++; } while (j<=r) { tmp[k]=a[j]; j++; k++; } // 此时tmp[l]~tmp[r]已经合并完成,搬回原数组a for (int i=l;i<=r;i++) a[i]=tmp[i]; return sum; } int main() { int n; scanf("%d",&n); for (int i=1;i<=n;i++) scanf("%d",&a[i]); ll ans=mergesort(1,n); printf("%lld\n",ans); return 0; }
快速排序
快速排序的思想是,找一个基准元素,想办法把数组进行划分,比基准元素小的元素放在它的左边,比基准元素大的元素放在它的右边。左右两边各自排序,排完以后整个数组就是有序的了,不需要再合并。显然快速排序也是基于分治思想解决问题的算法。
具体来说,假设数需要排序的数组为 i
和 j
,i
指向左边元素后面的第一个元素,j
指向最后一个元素。如果 a[i]<a[1]
,i
就指向下一个元素,即 i++
;若 a[i]>a[1]
,i
停在当前位置。另一侧,如果 a[j]>a[1]
,j
指向“下一个”元素,即 j--
;若 a[j]<a[1]
,则 j
停在当前位置。此时,a[i]
比较大,应该向右换,而 a[j]
比较小,应该向左换,正好可以把 i
和 j
位置上的元素交换。交换后,便可以得到 a[i]<a[1]
并且 a[j]>a[1]
。之后 i
加一,j
减一,继续以上操作,直至 i,j
相遇并错位。最后一步,将 a[j]
与 a[1]
进行交换,a[j]
成为整个数组的基准元素。整个数组被分成两个部分,左半部分 a[1]~a[j-1]
都小于 a[j]
,右半部分 a[j+1]~a[n]
都大于 a[j]
。这个划分过程的时间复杂度是
例如,原始数组为
首先是将
接下来将
继续进行上述操作,
划分操作完成后,左右两半的数据规模大致变为原来的一半,两部分分别进行递归排序,各自排好序之后,整个数组的顺序就排好了。需要注意的是,和归并排序一样,整个排序过程是递归进行的,所以每次划分需要指定当前要处理的数组区间范围。
// l和r表示区间的起点和终点 int pivot = a[l]; // 选择a[l]作为基准元素 int i = l + 1, j = r; // i指向基准后面的第一个元素,j指向最后一个元素 while (true) { while (i <= r && a[i] < pivot) i++; // i越过小于基准元素的数 while (j >= l && a[j] > pivot) j--; // j越过大于基准元素的数 if (i >= j) break; // 当i和j错位时,停止 swap(a[i], a[j]); i++; j--; // 交换a[i]和a[j],i向右走,j向左走 } swap(a[j], a[l]); // 交换a[j]和a[l]
前面的算法分析中没有考虑数组中有相同元素的情况,其实即使有相同的元素,算法的正确性依然没有问题。在
在快速排序过程中,每一次划分都是在一个区间范围内,用基准元素作为比对标准,比它小的元素都放在它的左边,比它大的元素都放在它的右边。各自递归左右两部分,直至区间剩下一个元素。划分操作的时间复杂度正比于区间长度,那么递归会有多少层呢?
考虑
如果待排序的数组不是散乱的,而是比较均匀的(极端情况,考虑一个已经排好序的数组),那么快速排序的效率是更高还是更低呢?直觉上,如果一个数组已经排好序了,再拿来排序,应该是不花时间的,或者是速度非常快的。但实际上,考虑对已经排好序的数组进行一次划分,由于基准元素一开始就在最左边,而区间里面没有比基准元素更小的元素了,划分完成以后基准元素还是在最左边。在下次递归调用时,基准元素左边没有元素,基准元素右边全部调用到下一层,下一层的区间长度只少了
一个简单的优化是,不选取第一个数作为基准元素,而是选取当前区间中间的元素。实际编程的时候,可以将当前区间中间的元素与区间第一个元素进行交换,之后的代码就不用改动了。这样即使输入的数据是有序的,依旧可以划分得比较均匀。
例题:P1177 【模板】排序
参考代码
#include <cstdio> #include <algorithm> using std::swap; const int N = 1e5 + 5; int a[N]; void quicksort(int l, int r) { int mid = (l + r) / 2; swap(a[l], a[mid]); // 把中间元素与第一个元素交换 int pivot = a[l]; // 选择a[l]作为基准元素 int i = l + 1, j = r; // i指向基准后面的第一个元素,j指向最后一个元素 while (true) { while (i <= r && a[i] < pivot) i++; // i越过小于基准元素的数 while (j >= l && a[j] > pivot) j--; // j越过大于基准元素的数 if (i >= j) break; // 当i和j错位时,停止 swap(a[i], a[j]); i++; j--; // 交换a[i]和a[j],i向右走,j向左走 } swap(a[j], a[l]); // 交换a[j]和a[l] if (l < j - 1) quicksort(l, j - 1); // 如果左边有不止一个数,递归对左边排序 if (j + 1 < r) quicksort(j + 1, r); // 如果右边有不止一个数,递归对右边排序 } int main() { int n; scanf("%d", &n); for (int i = 1; i <= n; i++) scanf("%d", &a[i]); quicksort(1, n); for (int i = 1; i <= n; i++) printf("%d ", a[i]); return 0; }
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· Manus爆火,是硬核还是营销?
· 终于写完轮子一部分:tcp代理 了,记录一下
· 震惊!C++程序真的从main开始吗?99%的程序员都答错了
· 别再用vector<bool>了!Google高级工程师:这可能是STL最大的设计失误
· 单元测试从入门到精通