本文篇幅较长,建议仔细耐心看完,相信会有极大的收获。

一、最具启发性的汉诺塔问题

1、汉诺塔问题描述

有三根杆子A,B,C。A杆上有 N 个 (N>1) 穿孔圆盘,盘的尺寸由下到上依次变小。要求按下列规则将所有圆盘移至 C 杆:每次只能移动一个圆盘;大盘不能叠在小盘上面。

问:最少要移动多少次?并打印每次移动的情况。

2、从最初开始

我们的目标是将1、2、3圆盘从左杆移到右杆
image.png

可以拆解为以下三步

1)(大步)将1、2圆盘从左移到中杆

2)将3圆盘从左移到右杆

3)(大步)将1、2圆盘从中移到右杆
image.png

代码如下:

/**
 * @author Java和算法学习:周一
 */
public static void leftToRight(int n) {
    if (n == 1) { // base case
        System.out.println("Move 1 from left to right");
        return;
    }
    // 1、1到n-1个圆盘从左到中
    leftToMid(n - 1);
    // 2、从左到右
    System.out.println("Move " + n + " from left to right");
    // 3、1到n-1个圆盘从中到右
    midToRight(n - 1);
}

整个左到右方法,你会发现第一大步依赖左到中子方法,第三大步依赖中到右子方法,然后你去补起左到中方法、中到右方法;此时又会发现左到中依赖左到右、右到中方法,中到右依赖中到左、左到右方法……

/**
 * @author Java和算法学习:周一
 */
private static void leftToMid(int n) {
    if (n == 1) {
        System.out.println("Move 1 from left to mid");
        return;
    }
    leftToRight(n - 1);
    System.out.println("Move " + n + " from left to mid");
    rightToMid(n - 1);
}

private static void midToRight(int n) {
    if (n == 1) {
        System.out.println("Move 1 from mid to right");
        return;
    }
    midToLeft(n - 1);
    System.out.println("Move " + n + " from mid to right");
    leftToRight(n - 1);
}

private static void rightToMid(int n) {
    if (n == 1) {
        System.out.println("Move 1 from right to mid");
        return;
    }
    rightToLeft(n - 1);
    System.out.println("Move " + n + " from right to mid");
    leftToMid(n - 1);
}

private static void midToLeft(int n) {
    if (n == 1) {
        System.out.println("Move 1 from mid to left");
        return;
    }
    midToRight(n - 1);
    System.out.println("Move " + n + " from mid to left");
    rightToLeft(n - 1);
}

private static void rightToLeft(int n) {
    if (n == 1) {
        System.out.println("Move 1 from right to left");
        return;
    }
    rightToMid(n - 1);
    System.out.println("Move " + n + " from right to left");
    midToLeft(n - 1);
}

最后你会发现左到中、左到右、中到左、中到右、右到左、右到中这6个方法相互依赖完成了汉诺塔问题。

是不是有点神奇,递归有时候是有点玄学,但是只要把子过程想明白,base case写对,就跑出来了(同时,需要有一点宏观思维)。

3、优化

这6个过程,是不是有点麻烦,同时细心的伙伴可能也发现了,这6个方法是及其相似的,那么我们是不是可以定义from、to、other三个变量,他们都可以表示左、中、右。当我左到右时,from=左、to=右、other=中;当左到中时,from=左、to=中、other=右……是不是就能六合一召唤神龙了。

同样拆解为以下三步

1)(大步)将1、2圆盘(即上面n-1个圆盘)从左移到中杆

2)将3圆盘(即最大的圆盘)从左移到右杆

3)(大步)将1、2圆盘(即上面n-1个圆盘)从中移到右杆

/**
 * 第一次调用function时:from=左、to=右、other=中
 * 表示借助other=中,将圆盘从from=左移动到to=右杆上
 *
 * @author Java和算法学习:周一
 *
 * @param n     总共的圆盘数量
 * @param from  起始位置
 * @param to    目标位置
 * @param other 剩余杆子
 */
public static void function(int n, String from, String to, String other) {
    if (n == 1) {
        System.out.println("Move 1 from " + from + " to " + to);
        return;
    }
    // 1.将上面n-1个圆盘从左移到中杆上,
    // 即起始位置为左,当前from=左;目标位置中,当前other=中
    function(n - 1, from, other, to);
    // 2.将最大的圆盘从左移到右杆上
    System.out.println("Move " + n + " from " + from + " to " + to);
    // 3.将上面n-1个圆盘从中移到右杆上
    // 即起始位置为中,当前other=中;目标位置右,当前to=右
    function(n - 1, other, to, from);
}

这时候,我们就学会了一个技巧,一个递归函数可以通过增加参数的方式表达更多的可能性,听着跟废话一样,但是现在你再品品。

但是,若没有前面启发性的过程,直接看这个是不是有点难懂;有了启发性的过程,再看是不是豁然开朗

是不是发现上课时听老师讲汉诺塔时一头雾水,要是老师当时能这么讲绝对听的明明白白。此处应有掌声(和点赞)

一气呵成,再来看看几个递归过程。

二、打印一个字符串的全部子序列

1、子序列定义

对于字符串"12345",任意取其中0个、1个、2个、3个、4个、5个都是它的子序列,同时相对顺序不能改变。

对于字符串“123”,我们可以很容易分析出以下递归过程
image.png

2、代码

/**
 * @author Java和算法学习:周一
 */
public static List<String> getAllSubSequences(String s) {
    char[] str = s.toCharArray();
    List<String> answer = new ArrayList<>();
    String path = "";
    process1(str, 0, answer, path);
    return answer;
}

/**
 * 当前来到了str[index]字符
 * str[0..index-1]已经走过了,之前的选择都在path上,之前的选择已经不能改变了,就是path。
 * 但是str[index....]还能自由选择,把str[index....]所有生成的子序列,放入到answer里
 *
 * @param str    指定的字符串(固定)
 * @param index  当前所处的位置
 * @param answer 之前决策依据产生的答案
 * @param path   之前已经做的选择
 */
public static void process1(char[] str, int index, List<String> answer, String path) {
    // 当前来到了字符串的最后位置,已经不能再做决策了,answer只能放入之前的决策
    if (index == str.length) {
        answer.add(path);
        return;
    }
    // 当前没有要index位置的字符
    process1(str, index + 1, answer, path);
    // 当前要index位置的字符
    process1(str, index + 1, answer, path + str[index]);
}

process1方法就是上面分析出来的递归过程。

三、打印一个字符串的全部子序列,没有重复值

打印一个字符串的全部子序列,要求没有重复字面值的子序列,直接将上面的List换成Set即可去重。

/**
 * 打印一个字符串的全部子序列,要求没有重复字面值的子序列
 *
 * @author Java和算法学习:周一
 */
public static Set<String> getAllSubSequencesNoRepeat(String s) {
    char[] str = s.toCharArray();
    Set<String> answer = new HashSet<>();
    String path = "";
    process2(str, 0, answer, path);
    return answer;
}

public static void process2(char[] str, int index, Set<String> answer, String path) {
    if (index == str.length) {
        answer.add(path);
        return;
    }
    process2(str, index + 1, answer, path);
    process2(str, index + 1, answer, path + str[index]);
}

四、打印一个字符串的全部全排列

1、全排列定义

所有字符都要,只是顺序不同。

2、采用舍弃添加的方式

(1)递归过程如下

image.png

在第一大列选择a时,形成了一些结果,当我进行第二大列递归时,得把a添加回去,保持最开始的abc,在第二大列选择b时进行递归得到的结果才是正确的,对于每一列的每一位选择都是如此,递归结束后要恢复现场

(2)代码

/**
 * 1.添加删除的方式
 *
 * @author Java和算法学习:周一
 */
public static List<String> permutation1(String s) {
    List<String> answer = new ArrayList<>();
    if (s == null || s.length() == 0) {
        return answer;
    }

    ArrayList<Character> strList = new ArrayList<>();
    for (char c : s.toCharArray()) {
        strList.add(c);
    }
    String path = "";
    process1(strList, path, answer);

    return answer;
}

/**
 * 递归获取全排列
 *
 * @param strList 当前参与选择的所有字符
 * @param path 之前所做的选择
 * @param answer 最终结果
 */
private static void process1(ArrayList<Character> strList, String path, List<String> answer) {
    // 当前没有可以选择的字符了,answer只能放入之前的选择
    if (strList.isEmpty()) {
        answer.add(path);
        return;
    }
    for (int i = 0; i < strList.size(); i++) {
        // 当前选择的字符
        char cur = strList.get(i);
        // 舍弃已经选择的字符
        strList.remove(i);
        // 剩余字符再进行选择
        process1(strList, path + cur, answer);
        // 恢复现场
        strList.add(i, cur);
    }
}

3、一直在原始字符串上以交换的方式进行递归

在第一大列形成结果acb时,如果不恢复现场,当我进行第二大列递归时,是从acb开始进行0、1位交换形成cab,和后面的重复了,所以每一步交换递归结束后要恢复现场

(1)递归过程如下

image.png

(2)代码

/**
 * 2.交换的方式
 *
 * @author Java和算法学习:周一
 */
public static List<String> permutation2(String s) {
    List<String> answer = new ArrayList<>();
    if (s == null || s.length() == 0) {
        return answer;
    }
    char[] str = s.toCharArray();
    process2(str, 0, answer);
    return answer;
}

/**
 * 递归获取全排列
 *
 * @param str 当前经历过交换后的字符
 * @param index 当前交换到哪个位置了
 * @param answer 结果
 */
private static void process2(char[] str, int index, List<String> answer) {
    // 当前来到了字符串的最后位置,已经不能再交换了,answer只能放入之前交换后的字符
    if (index == str.length) {
        answer.add(String.valueOf(str));
        return;
    }

    // index之前的已经交换过不能再变了,所以从index往后还可以再交换
    for (int i = index; i < str.length; i++) {
        // index、i位置交换
        swap(str, index, i);
        // index后面的继续交换
        process2(str, index + 1, answer);
        // index、i位置 恢复现场
        swap(str, index, i);
    }
}

五、打印一个字符串的全部全排列,没有重复值

1、代码

/**
 * 交换的方式,去重
 *
 * @author Java和算法学习:周一
 */
public static List<String> permutation3(String s) {
    List<String> answer = new ArrayList<>();
    if (s == null || s.length() == 0) {
        return answer;
    }
    char[] str = s.toCharArray();
    process3(str, 0, answer);
    return answer;
}

/**
 * 递归获取全排列,没有重复的字符串
 *
 * @param str 当前经历过交换后的字符
 * @param index 当前交换到哪个位置了
 * @param answer 结果
 */
private static void process3(char[] str, int index, List<String> answer) {
    // 当前来到了字符串的最后位置,已经不能再交换了,answer只能放入之前交换后的字符
    if (index == str.length) {
        answer.add(String.valueOf(str));
        return;
    }

    boolean[] visited = new boolean[256];
    // index之前的已经交换过不能再变了,所以从index往后还可以再交换
    for (int i = index; i < str.length; i++) {
        // str[i]位置对应字符没有出现过才递归交换,否则忽略
        if (!visited[str[i]]) {
            visited[str[i]] = true;
            // index、i位置交换
            swap(str, index, i);
            // index后面的继续交换
            process3(str, index + 1, answer);
            // index、i位置 恢复现场
            swap(str, index, i);
        }
    }
}

2、为啥我们不采用Set的方式来去重?

仔细看以上代码,发现没有再用Set的方法来去重,为啥?

因为采用Set去重,程序会重复的做很多相同字符的递归操作,将产生的相同字符串放到Set中由Set去重;而采用以上方式,对于相同的字符就不会再重复的递归了,有效减少了重复分支的递归操作,俗称剪枝

相当于Set是从结果中过滤去重,而以上方式是在中途的过程就已经去重了

六、栈的逆序

给你一个栈,请你逆序这个栈,不能申请额外的数据结构,只能使用递归函数。如何实现?

1、得到栈底元素

(1)代码

/**
 * 得到栈底元素,剩余元素直接下沉
 *
 * 例如,从栈顶到栈底元素为1、2、3、4、5
 * 此方法返回5,剩余从栈顶到栈底元素为1、2、3、4
 *
 * @author Java和算法学习:周一
 */
private static int getDown(Stack<Integer> stack) {
    int result = stack.pop();
    if (stack.isEmpty()) {
        return result;
    } else {
        int last = getDown(stack);
        stack.push(result);
        return last;
    }
}

(2)过程

image.png

2、递归逆序

(1)代码

/**
 * @author Java和算法学习:周一
 */
public static void reverse(Stack<Integer> stack) {
    if (stack.isEmpty()) {
        return;
    }
    int down = getDown(stack);
    reverse(stack);
    stack.push(down);
}

(2)过程

image.png

本文所有代码

Github地址:https://github.com/monday-pro/algorithm-study/tree/master/src/basic/dynamicprogramming/recursion

Gitee地址:https://gitee.com/monday-pro/algorithm-study/tree/master/src/basic/dynamicprogramming/recursion

怎么样,是不是如沐春风般醍醐灌顶。


周一pro
6 声望2 粉丝

你好,周一


« 上一篇
图的关键算法
下一篇 »
动态规划