算法篇——递归与分治

146 阅读5分钟

一、递归与分治

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.分治算法

归并排序,典型的分治算法;分治,典型的递归结构。

分治算法可以分三步走:分解 -> 解决 -> 合并

  1. 分解原问题为结构相同的子问题。
  2. 分解到某个容易求解的边界之后,进行第归求解。
  3. 将子问题的解合并成原问题的解。

归并排序像不像是二叉树的后续遍历?因为我们分治算法的套路是 分解 -> 解决(触底) -> 合并(回溯) 啊,先左右分解,再处理合并,回溯就是在退栈,就相当于后序遍历了。

附上我之前敲的归并排序的代码:

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);
    }


}