【数据结构与算法】暴力递归实战入门

1,013 阅读9分钟

1. 暴力递归

通俗来说,使用一种彻彻底底穷举的方法把一个答案试出来,就叫暴力递归(Force Recursive)。

暴力递归就是尝试,把问题转化为规模缩小了的同类问题的子问题,子问题通过决策过程概括大问题,同时子问题还要拆成更小的子问题,当子问题拆分到不需要再尝试能就出来答案的时候为止(base case)。

暴力尝试不要记录每一个子问题的解,只有尝试最重要,如何优化,自然有方法。

2. 汉诺塔问题

题目:打印n层汉诺塔从最左边杆移动到最右边杆的全部过程。汉诺塔移动过程中,小圆盘可以压大圆盘,大圆盘不能压小圆盘。

20211003214811.png

分析

讲一种比较好的尝试。

首先,我们不考虑杆的绝对位置,抽象问题,只设置from杆,to杆,和other杆。from,to或者other杆可复用,具体指的是最左杆,最右杆,中间杆都有可能。

确定当前整体任务:将from杆上第 1~ i 的圆盘全部移动到to杆上去。

自上而下划分子任务:

第一步:将from杆上第 1 ~ (i-1) 的圆盘全部移动到other杆上去。

第二步:将from杆上第 i 个圆盘移动到to杆上去。

第三步:将other杆上第 1 ~ (i-1) 的圆盘从other杆移动到to杆上去。

注意

1 ~ (i-1) 的盘子移动的过程如何保证大盘子在小盘子之下?

我只要保证在第 i 个盘子移动的过程中大盘在小盘之下,那么第 i-1个盘子移动的子过程也会保持相同规则。同样 i-2 个盘子移动也会满足相同规则。

在暴力递归中,很多人会想弄清楚全局的操作流程,然后怎么想都想不清楚。

在尝试的时候,完全不必要这么想。尝试就是你给所有的过程定义一个统一的标准,只要你父问题能不违反标准,子问题就一定也不违反标准。你在尝试的过程中,你只要思考,在这个局部下,你如何拆问题。只要保证在这个局部下拆的决策是对的,那么整体一定是对的。

代码

public static void hanoi(int n) {
    process(n, "左", "右", "中");
}

/**
 * 尝试方法
 * @param i 1~i 的圆盘
 * @param from 移动起始杆
 * @param to 移动目的杆
 * @param other 另外一个杆
*/
public static void process(int i, String from, String to, String other) {
    // base case 当只有一个圆盘时,该圆盘放哪个杆都可以
    if (i == 1) {
        System.out.println("Move 1 from " + from + " to " + to);
        return ;
    }
    process(i - 1, from, other, to);
    System.out.println("Move " + i + " from " + from + " to " + to);
    process(i - 1, other, to, from);
}

3. 字符串子序列

题目:打印一个字符串的全部子序列,包括空字符串。

分析

经典尝试方法,从左往右,每个位置元素要或不要做决策。

代码

public static void printAllSubStr(String str) {
    char[] charSequence = str.toCharArray();
    process(charSequence, 0, new ArrayList<Character>());
}

/**
 * 尝试方法
 * @param charSequence 字符串拆解的字符序列
 * @param i 当前字符在字符序列中的下标
 * @param chars 当前通路已经选择的字符
 */
public static void process(char[] charSequence, int i, List<Character> chars) {
    // 当尝试到通路最后时,直接输出通路所有字符序列组成的字符串
    if (i == charSequence.length) {
        printList(chars);
        return ;
    }
    // 添加当前字符往下走
    List<Character> addCurChars = copyList(chars);
    addCurChars.add(charSequence[i]);
    process(charSequence, i + 1, addCurChars);
    // 不添加当前字符往下走
    List<Character> notAddCurChars = copyList(chars);
    process(charSequence, i + 1, notAddCurChars);
}

// 打印List
public static void printList(List<Character> chars) {
    for (Character character : chars) {
        System.out.print(character);
    }
    System.out.println();
}

// 拷贝List
public static List<Character> copyList(List<Character> chars) {
    return new ArrayList<>(chars);
}

省空间的写法,思路是复用原先字符序列,通过修改当前位置的字符,从而实现要还是不要当前字符。

两种写法的时间复杂度指标是一样的。

public static void printAllSubStr(String str) {
    char[] charSequence = str.toCharArray();
    process(charSequence, 0);
}

public static void process(char[] charSequence, int i) {
    if (i == charSequence.length) {
        // 当尝试到通路最后时,直接输出通路所有字符序列组成的字符串
        System.out.println(String.valueOf(charSequence));
        return ;
    }
    // 添加当前字符往下走
    process(charSequence, i + 1);
    // 在原字符序列上将当前字符抹去
    char tmp = charSequence[i];
    charSequence[i] = 0;
    // 不添加当前字符往下走
    process(charSequence, i + 1);
    // 还原原字符序列
    charSequence[i] = tmp;
}

4. 字符串排列

题目:打印一个字符串的全部排列,要求不要出现重复的排列。

分析

尝试方法:从左到右,后续每一个字符和当前字符做交换,做决策。

同样也是在原字符串的基础上做修改,利用递归保留代码片段的特性,复用了原字符序列,从而节省空间。

代码

public static void printAllArrange(String str) {
    char[] charSequence = str.toCharArray();
    ArrayList<String> list = new ArrayList<>();
    process(charSequence, 0, list);
    // 打印
    for (String string : list) {
        System.out.println(string);
    }
}

public static void process(char[] charSequence, int i, ArrayList<String> list) {
    // 如果当前遍历到字符序列末尾,存储当前字符序列,并打印
    if (i == charSequence.length) {
        list.add(new String(charSequence));
        return ;
    }
    // 遍历当前字符后的每一个字符
    for (int j = i; j < charSequence.length; ++ j) {
        // 字符和当前字符交换
        swap(charSequence, i, j);
        // 递归到下一个字符
        process(charSequence, i + 1, list);
        // 还原字符串
        swap(charSequence, j, i);
    }
}

public static void swap(char[] charSequence, int i, int j) {
    char temp = charSequence[i];
    charSequence[i] = charSequence[j];
    charSequence[j] = temp;
}

上述代码中,如果str中没有重复字符,那么结果不会出问题,如果有重复字符,那么结果就会出现重复的排列。因为如果str没有重复字符,其他字符替换某个字符时只会到该字符位置一次;如果有重复字符,其他字符替换某个字符时重复的字符就会到该字符位置不止一次。

如果要求在字符串的全排列中,不能有重复排列,那么有两种思路。

  1. 使用上述代码,在得到所有结果后洗数据,将重复数据洗掉。
  2. 使用下面代码,在尝试换字符时,判断目标替换字符有没有在当前位置出现过,如果出现过,就不能交进行交换。

第一种方案很慢,因为需要走所有路径,得到所有结果,最后还要洗数据。

第二种方案更好,因为在走分支时,就杀死了不可能的分支,会得到更快的方法(分支限界)。

两种方案时间复杂度的指标一样,因为在最差情况下,第二种情况的剪枝策略可能都不成立,这个时候和第一种方案就没有区别了。但是第二种方案在不同数据状况下常数项上有优化。

public static void process(char[] charSequence, int i, ArrayList<String> list) {
    if (i == charSequence.length) {
        list.add(new String(charSequence));
        return ;
    }
    // 记录每一个字符(a-z)是否尝试过,默认全是false
    boolean[] flag = new boolean[26];
    // 遍历当前字符后的每一个字符
    for (int j = i; j < charSequence.length; ++ j) {
        int index = charSequence[j] - 'a';
        // 判断是否在该位置尝试过该字符串
        if (!flag[index]) {
            swap(charSequence, i, j);
            process(charSequence, i + 1, list);
            swap(charSequence, j, i);
            // 记录已经尝试
            flag[index] = true;
        }
    }
}

5. 最长递增子序列

题目:从一个数组中,找出最长递增子序列的长度。例如:[ 1, 5, 2, 4, 3 ],最长递增子序列有两个,分别是 [ 1, 2, 4 ],[ 1, 2, 3 ],返回3。

分析

尝试方法:从每一个元素开始,向后遍历所有元素,决策是否加入递增序列。

代码

public static int longestIncreasingSubSequence(int[] arr, int begin) {
    if (begin == arr.length - 1) {
        return 1;
    }
    int longestLength = 1;
    for (int i = begin; i < arr.length; i ++) {
        if (arr[i] > arr[begin]) {
            longestLength = Math.max(longestIncreasingSubSequence(arr, i) + 1, longestLength);
        }
    }
    return longestLength;
}

6. 拿牌问题

题目:给定一个整型数组arr,代表数值不同的纸牌排成一条线。玩家A和玩家B依次拿走每张纸牌,规定玩家A先拿,玩家B后拿,但是每个玩家每次只能拿走最左或最右的纸牌,玩家A和玩家B都绝顶聪明。请返回最后获胜者的分数。

例如:

arr = [1, 2, 100, 4]

开始时,玩家A只能拿走1或4。如果开始时玩家A拿走1,则排列变为[2, 100, 4],接下来玩家B可以拿走2或4,然后继续轮到玩家A...

如果开始时玩家A拿走4,则排列变为[1, 2, 100],接下来玩家B可以拿走1或100,然后继续轮到玩家A...

玩家A作为绝顶聪明的人不会先拿4,因为拿4之后,玩家B将拿走100。所以玩家A会先拿1,让排列变为[2, 100, 4],接下来玩家B不管怎么选,100都会被玩家A拿走。玩家A会获胜,分数为101。所以返回101。

分析

这道题如何做决策已经给出,每个玩家每次只能拿走最左或最右的纸牌。

尝试方法:在每一轮摸牌中,要不摸最左边的牌,要不摸最右边的牌。

代码

public static int win(int[] arr) {
    // base case
    if (arr == null || arr.length == 1) {
        return 0;
    }
    // 先手的是玩家A,后手的玩家B,谁大谁赢
    return Math.max(offensive(arr, 0, arr.length - 1), defensive(arr, 0, arr.length - 1));
}

// 先手函数
public static int offensive(int[] arr, int left, int right) {
    // 在先手的情况下只有一张牌,直接拿
    if (left == right) {
        return arr[left];
    }
    // 尝试拿左边牌和拿右边牌,然后后手
    return Math.max(arr[left] + defensive(arr, left + 1, right), arr[right] + defensive(arr, left, right - 1));
}

// 后手函数
public static int defensive(int[] arr, int left, int right) {
    // 在后手的情况下只有一张牌,无牌可拿
    if (left == right) {
        return 0;
    }
    // 分别在两种情况中先手,选择小的那个是因为对手肯定把差的牌给我
    return Math.min(offensive(arr, left + 1, right), offensive(arr, left, right - 1));
}

7. 逆序栈

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

分析

本题是一个递归技法题,在暴力递归中,对于递归技法的要求比较高,因此可以尝试做一下该题。

首先需要实现一个函数,该函数的功能是:将栈底元素移除栈且返回,但是栈中其他元素位置保持不变。

代码

public static void reverseStack(Stack<Integer> stack) {
    // base case
    if (stack.isEmpty()) {
        return ;
    }

    // 保存当前处理的栈底元素
    int item = process(stack);
    // 进行下一次处理
    reverseStack(stack);

    // 将保存的栈底元素压回栈中
    stack.push(item);
}

// 该函数完成了取出栈底元素,并保持栈中其他元素位置不变的作用
public static int process(Stack<Integer> stack) {
    // 弹栈,并保存栈顶元素
    int item = stack.pop();
    // 当栈空的时候,最后一个栈顶元素不压栈,直接返回
    if (stack.isEmpty()) {
        return item;
    }
    // 获取最后一个栈顶元素
    int lastItem = process(stack);
    // 将其他栈顶元素再压回栈中
    stack.push(item);
    // 返回最后一个栈顶元素
    return lastItem;
}

8. 数字字符串转化

题目

规定1和A对应,2和B对应,3和C对应 ... 26和Z对应。那么一个数字字符串如 "111" 就可以转化为 "AAA","AK","KA" 三种结果。

给定一个只有数字字符组成的字符串str,求有多少种转化结果。

分析

本题的试法也是经典的从左到右开始尝试。

比方说来到了 i 位置,假设 0 ~ (i-1) 的决策已经确定了,我们只关注从第 i 位和第 i 位往后的所有变种,和前面的决策相结合,有多少种有效的整体决策,具体细节由Coding来处理。

代码

public static int transformStrCount(String str) {
    return process(str.toCharArray(), 0);
}

// i表示从第i位开始转化
public static int process(char[] chars, int i) {
    // str的最后一位也被转化完成,是一种可行的转化方案
    if (i == chars.length) {
        return 1;
    }

    // 转化到第i位的字符是0,没有对应字母与之对应,str无法被转换,该转化方案不可行
    if (chars[i] == '0') {
        return 0;
    }

    // 转化到第i位的字符是1
    if (chars[i] == '1') {
        // 单独转化第i位,构成一种方案
        int count = process(chars, i + 1);

        // 判断第i位后面一个字符是否存在
        if (i + 1 < chars.length) {
            // 如果存在,可以将第i位和后面一个字符合并转化,构成一种方案
            count += process(chars, i + 2);
        }

        return count;
    }

    // 转化到第i位的字符是2
    if (chars[i] == '2') {
        // 单独转化第i位,构成一种方案
        int count = process(chars, i + 1);

        // 判断第i位后面的字符是否存在,,如果存在是否小与等于6
        if (i + 1 < chars.length && chars[i + 1] >= '0' && chars[i + 1] <= '6') {
            // 如果存在且小于6,可以将第i位后后面一个在字符合并转化,构成一种方案
            count += process(chars, i + 2);
        }

        return count;
    }

    // 转化到第i位的字符是3-9,只能单独转化第i位
    return process(chars, i + 1);
}

9. 背包问题

题目

给定两个长度都为 N 的数组 weights 和 values,weights[ i ] 和 values[ i ]分别代表 i 号物品的重量和价值。给定一个正数bag,表示一个载重为 bag 的袋子,你装的物品不能超过这个重量。

你能装下物品的总价值最大是多少?

分析

总左往右尝试,0号货要或不要,1号货要或不要 ...

代码

本题目有两种常规写法,第一种写法是第二种的简化版本。

第一种写法好,因为第一种写法只有两个可变参数,i 和 alreadyWeight;而第二种写法有三个可变参数 i 、alreadyWeight 和 alreadyValue。

我们在构建尝试方法时,有一个原则 "尽量构建可变参数形式最简单,可变参数数量最少的的方法"。可变参数形式最简单表示可以使用一个值就可以表达,如果使用链表,哈希表等作为可变参数的形式,就会非常复杂。这个是后续改DP的基础,可变参数形式越简单,数量越少,DP越好改。

第一种:

public static int knapsackProblem(int[] weights, int[] values, int bag) {
    return process(weights, values, bag, 0, 0);
}

/**
 * 对当前第i号物品做决策
 * @param weights 所有物品重量
 * @param values 所有物品价值
 * @param bag 袋子最大重量
 * @param alreadyWeight 之前所做的决策袋子的重量
 * @param i 当前第i号物品
 * @return 最大价值
 */
public static int process(int[] weights, int[] values, int bag, int alreadyWeight, int i) {
    // 如果所有物品尝试完
    if (i == weights.length) {
        return 0;
    }

    // 如果当前袋子超重
    if (alreadyWeight > bag) {
        return 0;
    }

    // 放入第i号物品和不放入产生的价值大的返回
    return Math.max(
        // 将第i号物品放入袋子中
        values[i] + process(weights, values, bag, alreadyWeight + weights[i], i + 1),
        // 不将第i号物品放入袋子中
        process(weights, values, bag, alreadyWeight, i + 1)
    );

}

第二种:

public static int knapsackProblem(int[] weights, int[] values, int bag) {
    return process(weights, values, bag, 0, 0, 0);
}

public static int process(int[] weights, int[] values, int bag, int alreadyWeight, int alreadyValue, int i) {
    // 所有物品尝试完
    if (i == weights.length) {
        return alreadyValue;
    }

    // 如果当前袋子超重
    if (alreadyWeight > bag) {
        return 0;
    }

    return Math.max(
        // 将第i号物品放入袋子中
        process(weights, values, bag, alreadyWeight - weights[i], alreadyValue + values[i], i + 1),
        // 不将第i号物品放入袋子中
        process(weights, values, bag, alreadyWeight, alreadyValue, i + 1)
    );
}

10. N皇后问题

题目

N皇后问题是指在N * N的棋盘上要摆N个皇后,要求任何两个皇后不同行、不同列,也不在同一条斜线上。

给定一个整数n,返回n皇后的摆法有多少种。

分析:每一行只能放一个Queen,在每一行中,从左往右一列一列的尝试。

代码

public static int nQueen(int n) {
    if (n < 1) {
        return 0;
    }

    int[] record = new int[n];

    return process(n, record, 0);
}

/**
 * @param n n行n列
 * @param record record[i] = j 表示一个Queen在第i行第j列
 * @param i 当前做决策的行
 * @return 摆放方法数
 */
public static int process(int n, int[] record, int i) {
    // 所有行都已经做完决策,是一种合法方案
    if (i == n) {
        return 1;
    }

    int result = 0;

    // 遍历第i行所有列,判断能否放Queen
    for (int j = 0; j < n; j ++) {
        if (isValid(record, i, j)) {
            // 放皇后
            record[i] = j;
            // 去下一行做决策
            result += process(n, record, i + 1);
        }
    }

    return result;
}

// 判断第i行第j列放一个Queen是否合法
public static boolean isValid(int[] record, int i, int j) {
    // 0 ~ i-1 行放的所有Queen不共列,不共斜线
    for (int k = 0; k < record.length; k ++) {
        if (record[k] == j || Math.abs(k - i) == Math.abs(record[k] - j)) {
            return false;
        }
    }
    return true;
}