一、递归与分治
1. 什么是递归
某个函数直接或间接地调用自身,将原问题地求解转换为许多性质相同,但规模更小的子问题。
求解时只需要关注如何把原问题划分为符合条件的子问题,而不需要过分关注这个子问题时如何被解决的。
递归代码最重要的两个特征:结束条件和自我调用。
自我调用是在解决子问题;结束条件定义了最简子问题的答案。
int fun(传入数值){
if(终止条件)
return 最小子问题的解;
return func 缩小规模;
}
2. 递归的缺点:
递归时利用堆栈来实现的。每当进入一个函数调用,栈就会增加一层栈帧,每次函数返回,栈就会减少一层栈帧。而栈不是无限大的,当递归层数过多,就会造成栈溢出的后果。
3.实例
给一课二叉树,和一个目标值,节点上的值有正有负,返回树中和等于目标值的路径条数,让你编写 pathSum 函数:
/* 来源于 LeetCode PathSum III: https://leetcode.com/problems/path-sum-iii/ */
root = [10,5,-3,3,2,null,11,3,-2,null,1],
sum = 8
10
/ \
5 -3
/ \ \
3 2 11
/ \ \
3 -2 1
Return 3. The paths that sum to 8 are:
1. 5 -> 3
2. 5 -> 2 -> 1
3. -3 -> 11
编码时的注意事项:在java中,int型数组不能赋值为空,int数组在定义时就已经被默认初始化为0;要想达到类似效果,可以将int数组转为Integer数组。
sorry。在此之前,让我们先来复习一下二叉树
package com.seu.recursion;
/**
* @author SJ
* @date 2020/9/25
*/
public class Node {
public int getData() {
return data;
}
public void setData(int data) {
this.data = data;
}
private int data;
public Node getLeftChild() {
return leftChild;
}
public void setLeftChild(Node leftChild) {
this.leftChild = leftChild;
}
private Node leftChild;
public Node getRightChild() {
return rightChild;
}
public void setRightChild(Node rightChild) {
this.rightChild = rightChild;
}
private Node rightChild;
public Node(int data) {
this.data = data;
this.leftChild = null;
this.rightChild = null;
}
}
package com.seu.recursion;
/**
* @author SJ
* @date 2020/9/25
*/
public class Tree {
public Node getRoot() {
return root;
}
public void setRoot(Node root) {
this.root = root;
}
private Node root;
public Tree(int[] nums, int index) {
this.root = createTree(nums, index);
}
public Node createTree(int[] nums, int index) {
Node node = null;
if (index < nums.length) {
node = new Node(nums[index]);//每次进来先建节点
node.setLeftChild(createTree(nums, index * 2 + 1));
node.setRightChild(createTree(nums, index * 2 + 2));
}
return node;
}
public void preOrderTraverse(Node root) {
if (root != null) {
System.out.println(root.getData());
preOrderTraverse(root.getLeftChild());
preOrderTraverse(root.getRightChild());
}
}
}
package com.seu.recursion;
/**
* @author SJ
* @date 2020/9/25
*/
public class Test {
public static void main(String[] args) {
int[] nums = {10, 5, -3, 1};
Tree tree = new Tree(nums, 0);
tree.preOrderTraverse(tree.getRoot());
}
}
运行结果:
"C:\Program Files\Java\jdk1.8.0_131\bin\java.exe" ...
10
5
1
-3
Process finished with exit code 0
二叉树的创建和遍历也是很好的关于递归的例子。
写递归时,**明白一个函数的作用并且相信他能完成这个任务,千万不要试图跳进细节,**否则就会陷入无穷的细节无法自拔,人脑压不了几个栈!!别想太多!
好,我们接着来解决上面的问题。
package com.seu.solution;
/**
* @author SJ
* @date 2020/9/25
*/
public class TreeNode {
public Integer data;
public TreeNode left;
public TreeNode right;
public TreeNode(Integer data) {
this.data = data;
this.left = null;
this.right = null;
}
}
package com.seu.solution;
/**
* @author SJ
* @date 2020/9/25
*/
public class Tree {
public TreeNode root;
public Tree(Integer[] nums) {
this.root = createTree(nums, 0);
}
public TreeNode createTree(Integer[] nums, int index) {
TreeNode root = null;
if (index < nums.length && nums[index] != null) {
//这里要新建节点,老忘记
root = new TreeNode(nums[index]);
root.left = createTree(nums, 2 * index + 1);
root.right = createTree(nums, 2 * index + 2);
}
return root;
}
}
package com.seu.solution;
/**
* @author SJ
* @date 2020/9/25
*/
public class PathSum {
public static void main(String[] args) {
Integer[] sums = {10, 5, -3, 3, 2, null, 11, 3, -2, null, 1};
Tree tree = new Tree(sums);
int num = pathSum(tree.root, 8);
System.out.println(num);
}
//以不同顶点开头的和为sum的总数
public static int pathSum(TreeNode root, int sum) {
int isMe = 0;
if (root == null)
return 0;
return count(root.right, sum) + count(root.left, sum) + count(root, sum);
}
//以某一顶点开头的和为sum的总数
public static int count(TreeNode root, int sum) {
if (root == null)
return 0;
int isMe = (root.data == sum) ? 1 : 0;
return count(root.left, sum - root.data) + count(root.right, sum - root.data) + isMe;
}
}
结果
"C:\Program Files\Java\jdk1.8.0_131\bin\java.exe" ...
3
Process finished with exit code 0
例子就到这里。
4.分治算法
归并排序,典型的分治算法;分治,典型的递归结构。
分治算法可以分三步走:分解 -> 解决 -> 合并
- 分解原问题为结构相同的子问题。
- 分解到某个容易求解的边界之后,进行第归求解。
- 将子问题的解合并成原问题的解。
归并排序像不像是二叉树的后续遍历?因为我们分治算法的套路是 分解 -> 解决(触底) -> 合并(回溯) 啊,先左右分解,再处理合并,回溯就是在退栈,就相当于后序遍历了。
附上我之前敲的归并排序的代码:
import java.util.Arrays;
public class MergeSort {
/**
* 二路归并排序
* 包含1.两个有序数组合并成一个有序数组
* 2.递归
*/
public static void main(String[] args) {
int[] nums={1,4,2,3};
System.out.println("原数组为" +
Arrays.toString(nums));
mergeSort(nums,0,nums.length-1);
System.out.println("排序后为:"+Arrays.toString(nums));
}
public static void merge(int[] nums, int start, int middle, int end){
//首先从start~middle、从middle+1~end是了两个分别有序的序列
//需要一个和nums等大的数组来辅助
int[] temp = new int[nums.length];
//p1和p2用来在原数组上挪动比较大小
//k用来在新数组上挪动存放数据
int p1=start, p2=middle+1, k=start;
//两组都还有剩余的情况
while (p1<=middle && p2<=end){
if (nums[p1]<=nums[p2])
temp[k++]=nums[p1++];
else
temp[k++]=nums[p2++];
}
//第二组的数已经全部插入新数组,第一组还有剩余
while (p1<=middle)
temp[k++]=nums[p1++];
//第一组数已经全部插入新数组,第二组还有剩余
while (p2<=end)
temp[k++]=nums[p2++];
//注意只能将已经排好序的复制回原数组,而不是从0-结尾开始复制
for (int i = start; i <= end; i++) {
nums[i]=temp[i];
}
//return nums;
}
public static void mergeSort(int[] nums, int start, int end){
if (start<end)
{
int middle = (start+end)/2;
mergeSort(nums,start,middle);
mergeSort(nums,middle+1,end);
merge(nums,start,middle,end);
}
}
}
然后是算法书上的规范代码:有很多值得学习的地方,不该犯的错误我都犯了
public class Merge {
// 不要在 merge 函数里构造新数组了,因为 merge 函数会被多次调用,影响性能
// 直接一次性构造一个足够大的数组,简洁,高效
private static Comparable[] aux;
public static void sort(Comparable[] a) {
aux = new Comparable[a.length];
sort(a, 0, a.length - 1);
}
private static void sort(Comparable[] a, int lo, int hi) {
if (lo >= hi) return;
int mid = lo + (hi - lo) / 2;
sort(a, lo, mid);
sort(a, mid + 1, hi);
merge(a, lo, mid, hi);
}
private static void merge(Comparable[] a, int lo, int mid, int hi) {
int i = lo, j = mid + 1;
for (int k = lo; k <= hi; k++)
aux[k] = a[k];
for (int k = lo; k <= hi; k++) {
if (i > mid) { a[k] = aux[j++]; }
else if (j > hi) { a[k] = aux[i++]; }
else if (less(aux[j], aux[i])) { a[k] = aux[j++]; }
else { a[k] = aux[i++]; }
}
}
private static boolean less(Comparable v, Comparable w) {
return v.compareTo(w) < 0;
}
}
5.对于常见的几种算法的理解
简单阐述一下递归,分治算法,动态规划,贪心算法这几个东西的区别和联系
递归是一种编程技巧,一种解决问题的思维方式;分治算法和动态规划很大程度上是递归思想基础上的(虽然动态规划的最终版本大都不是递归了,但解题思想还是离不开递归),解决更具体问题的两类算法思想;贪心算法是动态规划算法的一个子集,可以更高效解决一部分更特殊的问题。
分治算法,以最经典的归并排序为例,它把待排序数组不断二分为规模更小的子问题处理,这就是 “分而治之” 这个词的由来。显然,排序问题分解出的子问题是不重复的,如果有的问题分解后的子问题有重复的(重叠子问题性质),那么就交给动态规划算法去解决!
6.练习
6.1本题来源leetcode-cn.com/problems/re…
package com.seu.solution;
import java.util.Scanner;
/**
* @author SJ
* @date 2020/9/25
*/
//递归乘法。 写一个递归函数,不使用 * 运算符, 实现两个正整数的相乘。可以使用加号、减号、位移,但要吝啬一些。
public class Multiple {
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
int a=scanner.nextInt();
int b=scanner.nextInt();
System.out.println(multiply(a,b));
}
public static int multiply(int A, int B) {
int temp1=(A>B)?A:B;
int temp2=(A<B)?A:B;
if (A==0||B==0)
return 0;
else if (A==1)
return B;
else if (B==1)
return A;
else
return temp1+multiply(temp1,temp2-1);
}
}
提交通过
6.2本题来源leetcode-cn.com/problems/ch…
这道题要用到深度优先搜索,好,我们先来学习一下搜索。
搜索
DFS(深度优先搜索)
DFS 为图论中的概念,在 搜索算法 中,该词常常指利用递归函数方便地实现暴力枚举的算法,与图论中的 DFS 算法有一定相似之处,但并不完全相同。
深搜模板:
int ans = 最坏情况, now; // now为当前答案
void dfs(传入数值) {
if (到达目的地) ans = 从当前解与已有解中选最优;
for (遍历所有可能性)
if (可行) {
进行操作;
dfs(缩小规模);
撤回操作;
}
}
考虑这个例子:
//把正整数n分解为3个不同的正整数,如6=1+2+3,排在后面的数必须大于等于前面的数,输出所有方案
对于这个问题,如果不知道搜索,应该怎么办呢?
当然是三重循环,参考代码如下:
for (int i = 1; i <= n; ++i)
for (int j = i; j <= n; ++j)
for (int k = j; k <= n; ++k)
if (i + j + k == n) printf("%d=%d+%d+%d\n", n, i, j, k);
分解的层数一旦变多,多重循环就做不了了。那分解成小于等于m个整数呢?
此刻我们需要考虑递归搜索。
该类搜索算法的特点在于,将要搜索的目标分成若干“层”,每层基于前几层的状态进行决策,直到达到目标状态。
换题:将正整数n分解成小于等于m个正整数之和,且排在后面的数必须大于等于前面的数,并输出所有方案。
package com.seu.solution;
/**
* @author SJ
* @date 2020/9/25
*/
public class Test {
static int m=3;
static int[] arr=new int[103]; // arr 用于记录方案
public static void dfs(int n, int i, int a) {
if (n == 0) {
for (int j = 1; j <= i - 1; ++j)
System.out.print(arr[j]+" ");
System.out.println();
}
if (i <= m) {
for (int j = a; j <= n; ++j) {
arr[i] = j;
dfs(n - j, i + 1, j); // 请仔细思考该行含义。
}
}
}
public static void main(String[] args) {
dfs(10, 1, 1);
}
}