第二章递归
第二章
目标:
- 对递归建立感觉
- 学会评估算法性能
- 能大致评估程序的执行时间
递归设计经验
- 找重复(子问题)
- 找重复中的变化量➡️参数
- 找参数变化趋势➡️设计出口
练习策略:
- 循环改递归
- 经典递归
- 大量练习,总结规律,掌握套路
- 找到感觉,挑战高难度
递归、查找和排序补充
什么是递归
Exception in thread "main" java.lang.StackOverflowError(无限调用)
设计出口!层层调用层层返回。
定义:
自身调用自身。
递归基础练习
求阶乘
代码:
//求n的阶乘
/*找重复:用n*n-1的阶乘,求n-1的阶乘是原问题的重复,不同点在于规模更小(子问题)
*
* 找变换:n越变越小,变化的量作为参数
* 找边界:出口
* */
static int f1(int n) {
if(n==1) {//出口
return 1;
}
return n*f1(n-1);
}
递归更通俗的理解:假设f1是完成某个功能,假设它已完成,你心安理得的调用它。
f(n)是求n的阶乘,f(n-1)是求n-1的阶乘
打印i到j
// 打印i到j
/*找重复:
* 找变化:i在变
* 找边界:
* */
static void f2(int i,int j) {
if(i>j) {
return;
}
System.out.print(i+" ");
f2(i+1,j);
}
自己处理一部分,委托别人处理一部分。最终综合起来就是结果。
对数组求和
// 数列求和
/*找重复:划一块,自己弄。另一段给别人。
* 找变化:数组区间长度变化int begin
* 找边界:
* */
static int f3(int arr[],int begin) {
if(begin == arr.length-1) {
return arr[arr.length-1];
}
return arr[begin] + f3(arr,begin+1);
}
注:变化之中去找参数和加参数,是递归的一个难点
反转字符
//反转字符,好像是有一刀,给它拼成四个子问题
static String reverse(String src,int end) {
if(end == 0) {
return ""+src.charAt(0);
}
return src.charAt(end)+reverse(src,end-1);
}
斐波那契数列
代码:
//斐波那契数列
/*找重复:
* 找变化:n
* 找边界:
* */
static int fib(int n) {
if(n == 1||n == 2) {//只有一项
return 1;
}
return fib(n-1) + fib(n-2);//n-1的求解是包括n-2的,划分并不均匀
}
思路:
区别于之前的递归,之前的递归都是我自己解决其中的一点点,然后将剩下的递归委托给我的小弟。他给我一个结果我给他一合并就能得出最终结果。
这个题需要委托两次,但是也符合递归将一个大的任务拆解为更小的子任务,一个小弟给我求F(N-1)另一个小弟给我求F(N-2),我负责将他们合起来。
等价于两个子问题:
- 求前一项
- 求前两项
- 两项求和
1. 分解为:直接量+小规模子问题
2. 分解为:多个小规模子问题
汉诺塔
思路:
1~N从A移动B,C作为辅助
等价于:
-
1~N-1从A移动到C,B为辅助
-
把N从A 移动到B
-
1~N-1从C移动到B,A为辅助
<img src="https://gitee.com/forgetc77/blog-img/raw/master/img/20210319185429.png" alt="image-20210319185429564" style="zoom:50%;" />
> 注:B为目标柱子
代码:
public class TowerOfHanoi {
public static void main(String[] args) {
// TODO Auto-generated method stub
printHanoiTower(2,"A","B","C");
}
/*
*parameter n :圆盘数
*parameter origin:原来的盘子
*parameter end:目标盘
*parameter help:辅助盘
* */
private static void printHanoiTower(int n, String origin, String end, String help) {
if(n==1) {
System.out.println("移动"+n+"从"+origin+"到"+end+"盘");
return;
}else {
printHanoiTower(n-1,origin,help,end);//把前n-1个盘子移动到辅助空间上
System.out.println("移动"+n+"从"+origin+"到"+help+"盘");//N可以顺利到达target,此时最后一个经过上面的if分支也完成了移动
printHanoiTower(n-1,help,end,origin);//让n-1个盘子从辅助空间到原空间上去
}
}
}
二分查找
代码:
private static int binarySearch(int[] x, int low, int high, int target) {
//当left>right说明没有找到数据,这是跳出递归的条件
if(low>high) {
return -1;
}
//在这个过程中,mid = (left+right)/2、以及下面递归中的mid+1或mid-1可以看成是递归条件
// 的一个变换,通过这些递归条件的不断变换,当达到left>right时结束递归逐层返回-1,表明
// 目标元素不存在
// int mid = (low+high)/2;
int mid = low + ((high - low)>>1);//(low + high)>>>;防止溢出,移位也更高效
int midVal = x[mid];
if(midVal>target) {
//目标元素小于中间的元素,说明目标元素在中间元素的左边,对左半部分的元素进行递归二分查找
return binarySearch(x, low, mid-1, target);
}else if(midVal<target) {
// /目标元素大于中间元素,说明目标元素在中间元素的右边,对右半部分的元素进行递归二分查找
return binarySearch(x, mid+1, high, target);
}else {
return mid;
}
}
注意:它有一个前提,就是必须在有序数据中进行查找。
等价于三个子问题
-
左边找(递归)
-
中间比
-
右边找(递归)
左查找和右查找只选其一!
最大公约数
//最大公约数
static int gcd(int m,int n) {
if(n==0)return m;//即m%n == 0
return gcd(n,m%n);
}
插入排序
希尔排序
思路:
希尔排序本身也是一个插入排序,也称为缩小增量排序,是直接插入排序算法的一种更高效的改进版本,希尔排序是非稳定的排序算法。
增量是用于分组的,增量为n就会被分为n组。
一趟一个增量,用增量来分组,组内执行插入排序。
冒泡是交换,选择是求最大最小,插入挪动数组
- 增量为5即+4
- 缩小增量为3,即+2
-
缩小增量为1即相邻为一组
代码:
import com.sun.org.apache.xalan.internal.xsltc.compiler.util.Util;
public class ShellSort {
public static void main(String[] args) {
// TODO Auto-generated method stub
int [] arr = {9,8,4,6,7,5,10,3,2,1};
shellSort(arr);
for (int i = 0; i < arr.length; i++) {
System.out.print(arr[i]+" ");
}
}
public static void shellSort(int []arr) {
//双层循环
//外层循环确定增量,不断的缩小增量
for (int interval = arr.length/2; interval >0; interval = interval/2) {
//增量为1的插入排序
// for (int i = 1; i < arr.length; i++) {
// int target = arr[i];
// int j = i-1;
// while(target<arr[j]) {
// arr[j+1] = arr[j];
// j--;
// }
// arr[j+1] = target;
// }
for (int i = interval; i < arr.length; i++) {
int target = arr[i];
int j = i-interval;
while(j>-1&&target<arr[j]) {
arr[j+interval] = arr[j];
j-=interval;
}
arr[j+interval] = target;
}
}
}
}
小结
找重复:
1.找到一种划分方法
2.找到递推公式或者等价转换
都是父问题转化为求解子问题
找变化的量
变化的量通常作为参数
找出口
根据参数变化的趋势,对边界进行控制,适时的终止递归
如何评估算法性能
O表示法
大O举例
算法复杂度/拥有的时间 | 1s可处理的规模 |
---|---|
n | 100000000(1Ghz) |
n^2 | 10000 |
n^3 | ≈500 |
2^n | 27 |
lgn | 2^100000000 |
算法复杂度/n的规模 | |
---|---|
lgn | 27/10^8 |
n | 1 |
n^2 | 100000000 |
经典算法分析
顺序查找:O(n)
二分查找:O(lgn)
测试算法效率
public static void duration(long x) {
System.out.println(System.currentTimeMillis()-x+"ms");//打印系统当前的时间
}
完成测试代码:
public class BinarySearch {
public static void main(String[] args) {
// TODO Auto-generated method stub
int [] x =new int[10000*10000];
for (int i = 0; i < x.length; i++) {
x[i] = i + 1;//给x[0]~x[9999999]赋值1~100000000
}
int target = 10000*10000;
long now = System.currentTimeMillis();//获取当前系统时间
int index = binarySearch(x,0,x.length-1,target);
duration(now);
System.out.println(target+"所在的位置是"+index);
now = System.currentTimeMillis();
index = search(x,target);
System.out.println(target+"所在的位置是"+index);
duration(now);
}
private static int search(int[] x, int target) {
for (int i = 0; i < x.length; i++) {
if(x[i]==target) {
return i;
}
}
return -1;
}
private static int binarySearch(int[] x, int low, int high, int target) {
//当left>right说明没有找到数据,这是跳出递归的条件
if(low>high) {
return -1;
}
//在这个过程中,mid = (left+right)/2、以及下面递归中的mid+1或mid-1可以看成是递归条件
// 的一个变换,通过这些递归条件的不断变换,当达到left>right时结束递归逐层返回-1,表明
// 目标元素不存在
// int mid = (low+high)/2;
int mid = low + ((high - low)>>1);//(low + high)>>>;防止溢出,移位也更高效
int midVal = x[mid];
if(midVal>target) {
//目标元素小于中间的元素,说明目标元素在中间元素的左边,对左半部分的元素进行递归二分查找
return binarySearch(x, low, mid-1, target);
}else if(midVal<target) {
// /目标元素大于中间元素,说明目标元素在中间元素的右边,对右半部分的元素进行递归二分查找
return binarySearch(x, mid+1, high, target);
}else {
return mid;
}
}
public static void duration(long x) {
System.out.println(System.currentTimeMillis()-x+"ms");//打印系统当前的时间
}
}
冒泡、插入、选择排序:
- 冒泡排序(交换)
-
插入排序(挪动)
- 从第一个元素开始,该元素可以认为已经被排序;
- 取出下一个元素,在已经排序的元素序列中从后向前扫描;
- 如果该元素(已排序)大于新元素,将该元素移到下一位置;
- 重复步骤3,直到找到已排序的元素小于或者等于新元素的位置;
- 将新元素插入到该位置后;
- 重复步骤2~5。
- 选择排序:(求最大最小)
思路
首先在未排序序列中找到最小(大)元素,存放到排序序列的起始位置。
再从剩余未排序元素中继续寻找最小(大)元素,然后放到已排序序列的末尾。
重复第二步,直到所有元素均排序完毕。
感受下性能的差别:Arrays.sort()的执行时间对比
Arrays.sort()用的是快速排序算法。时间复杂度为O(nlgn)
希尔排序的性能分析
-
如果原始数据的大部分元素都已经排好序,那么插入排序的速度很快(因为需要移动的元素很少)
-
快?
- 无序的时候,元素少
- 元素多的时候,已经基本有序,后面的比较次数和移动元素就越来越少
-
最好与最坏
排序算法稳定性
相关题解
题1:小白上楼梯(递归设计)
思路:
走到第n阶的上一次所在位置可能是在n-1/ n-2 /n-3阶,因此分别计算到达n-1/ n-2 /n-3这三个位置的方法数,将其加起来就是能够到达n阶楼梯的方法
代码:
private static int f(int n) {
if(n == 0) {
return 0;
}
if (n == 1) {
return 1;
}
if (n == 2) {
return 2;
}
if (n == 3) {
return 4;
}
// TODO Auto-generated method stub
return f(n - 1) + f(n - 2) + f(n - 3);
}
static int f(int n) {
if(n==0) {
return 1; //理论上n=0的时候,f(n)=0,但是,为了验算正确,对代码做调整,令f(0)=1;
}
if(n==1) {
return 1;
}
if(n==2) {
return 2;
}
return f(n-1)+f(n-2)+f(n-3);
}
题2:旋转数组的最小数字(改造二分法)
思路:
最小值一定是最大值的右侧
位于无序的位置
旋转之后的数组实际上可以划分为两个排序的子数组,而且前面的子数组的元素都是大于或者等于后面子数组的元素。我们还注意到最小的元素刚好是这两个子数组的分界线。在排序的数组中我们可以利用二分查找来实现O(logn)的查找。本题给出的数组在一定程度上是排序的,因此我们可以试着用二分查找的思路来寻找这个最小的元素。
代码:
package _02_01recursion;
public class Case01_旋转数组的最小数字 {
public static void main(String[] args) {
// TODO Auto-generated method stub
int[] arr = { 5, 1, 2, 3, 4 };
int res = min(arr);
System.out.println(res);
int[] arr2 = { 1, 0, 1, 1, 1 };
int res2 = min(arr2);
System.out.println(res2);
}
private static int min(int[] arr) {
if(arr==null) {
return -1;
}
int length = arr.length;
int start = 0;
int end = length - 1;
// 考虑没有旋转的特殊的旋转
if (arr[start] < arr[end])
return arr[start];
// start 和begin 指向相邻元素退出
while (start + 1 < end) {
int mid = start + (end - start) >> 1;
//如果首位中间都相同,为了避免错误采用顺序查找
if (arr[start] == arr[end] && arr[mid] == arr[end]) {
return MinInOrder(arr, start, end);
}
if (arr[mid] >= arr[start]) {// 中间的大于了开头那么左侧有序,从右侧开始找
start = mid;
} else {
end = mid;
}
}
return arr[end];
}
private static int MinInOrder(int[] arr, int start, int end) {
int result = arr[start];
for (int i = start + 1; i < end; i++) {
if (result > arr[i]) {
result = arr[i];
}
}
return result;
}
}
当着两个数相同,并且它们中间的数相同的也相同时,我们把mid赋给了last,也就是认为此时最小的数字位于中间数字的后面。但是是错误的。
需要在二分查找之前添加一个筛选条件如果满足arr[mid] = arr[start]并且arr[mid] =arr[end]那么就采用顺序查找:
if (arr[start] == arr[end] && arr[mid] == arr[end]) {
return MinInOrder(arr, start, end);
}
题3:在有空字符串的有序字符串数组中查找
思路:
代码:
package _02_01recursion;
public class Case02_特殊有序数组中查找 {
public static void main(String[] args) {
// TODO Auto-generated method stub
String[] arr = { "a", "", "ac", "", "ad", "b", "", "ba" };
int res = indexOf(arr, "abc");
System.out.println(res);
}
private static int indexOf(String[] arr, String string) {
int begin = 0;
int end = arr.length - 1;
while (begin <= end) {
int indexOfMid = begin + ((end - begin) >> 1);
//防止indexOfHid下标取得的是空字符串,如果是则++,直到不是空字符串
while (arr[indexOfMid].equals("")) {
indexOfMid++;
}
//防止越界// 此处特别注意,如果是"abc",要考虑这个逻辑
if (indexOfMid > end) {
return -1;
}
if (arr[indexOfMid].compareTo(string) > 0) {
end = indexOfMid - 1;
} else if (arr[indexOfMid].compareTo(string) < 0) {
begin = indexOfMid + 1;
} else {
return indexOfMid;
}
}
return -1;
}
}
注意!!
防止越界 此处特别注意,如果是"abc",要考虑这个逻辑
if (indexOfMid > end) {
return -1;
}
题4:最长连续递增子序列(部分有序)【不会,参考别人的】
public class Case04_最大连续递增子序列 {
public static void main(String[] args) {
// TODO Auto-generated method stub
int nums[] = {1,3,5,4,7};
int res = findMaxCIS(nums);
}
public static int findMaxCIS(int nums[]) {
if(nums.length == 0)
return 0;
int max = 0;
int count = 1;
for(int i=0;i<nums.length-1;i++){
if(nums[i]<nums[i+1]){
count++;
}else{
if(max<count)
max = count;
count = 1;
}
}
return Math.max(count,max);
}
}
题5:设计一个高效求a的n次幂的算法
package _02_01recursion;
public class Case05_a的n次幂 {
public static void main(String[] args) {
int n = 15;
int a = 2;
int res = pow(a,n);
System.out.println(res);
}
//O(n)
private static int pow0(int a, int n) {
int res = 1;
for (int i = 0; i < n; i++) {
res*=a;
}
return res;
}
//
private static int pow(int a, int n) {
//出口
if(n==0)
return 1;
int res = a;
int ex = 1;
//能翻
while(ex<<1<=n) {
// 翻
res = res*res;
// 指数
ex<<=1;
}
// 不能翻
// 差n-ex次方没有乘到里面
return res*pow(a,n-ex);
}
}