递推关系得到问题解 – 递归详解

递归通常用于问题递推得解,因其 基于用户栈实现,也常用来控制执行顺序。由于虚拟地址空间的内存限制,过多的调用栈帧可能触发栈溢出/OOM,通常将递归改为迭代减小调用开销避免。本文着重解析递归调用思想、顺序控制和调用流程追踪

递归

问题的解若可表示为递推关系,形如 \scriptsize F(n) = G(F(n-1)),问题的解便可由开始状态 `$$\scriptsize F(s) = S 经由关系式递推得到

步骤与构成

1)找到问题的递推关系式 \scriptsize F(n) = G(F(n-1))
2)明确进入子问题前和完成子问题后需要完成的计算
3)明确当前问题传给子问题的参数、获得子问题的结果、回传父问题的结果
4)确定递归边界 \scriptsize F(s) = S

逻辑后置与倒序处理

递归体中,递归调用过程先于逻辑执行,从而使得开始计算本层逻辑时,后序问题均已计算完毕;一般用于 倒序处理

public Result preRecursion(Param param) {
    // 递归边界
    bound();
 
    // 后续问题计算先于本层执行逻辑
    Result res = preRecursion(new Param());
 
    // 每层执行逻辑时均基于后续已计算完成的结果
    Result doneResult = process(res);
 
    // 返回父问题当前层处理结果
    return doneResult;
}

逻辑前置与正序处理

递归体中,逻辑执行先于递归调用过程,从而使得开始计算本层逻辑时,前序问题均已计算完毕;一般用于 正序处理;全局问题解决完毕时在每层问题末尾逐层向前返回同一个结果,又称为尾递归

public Result postRecursion(Param param) {
    // 递归边界
    bound();
 
    // 本层运算逻辑先于后续运算
    process();
 
    // 后序运算均基于已计算结果,返回时逐层向前传递问题解
    return postRecursion(new Param());
}

递归一般形式

综合首递归和尾递归,递归调用前置逻辑用来 基于前序运算正序处理元素,调用后置逻辑用来 基于后序运算倒序处理元素

public Result recursion(Result lastResult) {
    // 递归边界
    bound();
 
    // 父问题进入子问题前,基于前序运算已完成的操作
    preOperation(lastResult);
 
    // 父问题向子问题传入预定参数,子问题运算完毕后返回后序运算结果
    Result subResult = recursion(new Param());
 
    // 子问题处理完毕后回到父问题,基于后序运算计算
    doneResult = postOperation(subResult);
 
    // 向父问题返回当前问题处理完毕的结果
    return doneResult;
}

案例详析

汉诺塔问题

- 递推关系分析:
    + 若要移动最下层的第N个元素,必须先移走上面更小的N-1个元素,问题转化为移动第N-1个元素的子问题:由于子问题元素均小于父问题元素,所以只要先解决子问题,元素在柱间的移动就不会违反大小关系
    + 若要使起始柱的最大元素置于目标柱底,目标柱必空或必已放置当前最大元素N,即限制了更小的N-1个元素必须全部位于中转柱
    + 综上父问题定义为:将N个元素经由中转柱搬至目标柱;父问题分解为:上N-1个元素从起始柱 -> 中转柱、N从起始柱 -> 目标柱、上N-1个元素从中转柱 -> 目标柱
- 明确递归边界:
    + 当前元素是顶端最小元素时(此时N=1),将其从调用时的源柱顶端移至目标柱
- 逻辑放置顺序:
    + 中转N-1元素子问题 前置于 移动本层最大元素逻辑:保证先处理更小元素,以及倒序回到本层逻辑时,N-1个元素均搬运完毕
    + 回传N-1元素子问题 后置于 移动本层最大元素逻辑:保证正序处理子问题时,目标柱当前顶层已经是当前最大元素,且在移动N-1个元素时,仍然保证先处理更小元素
\tiny
汉诺塔问题的调用次数推导如下:\\
 递归调用同满二叉树的中序遍历,调用次数同其节点数量,满足递推关系\\
a_n = 2a_{n-1} + 1, a_1 = 1\\
则a_n通项可作如下推导\\
a_n = 2a_{n-1} + 1  \\
= 2(2a_{n-2} + 1) + 1\\
= 2^{n-1}a_1 + (1+2+2^2+\cdots+2^{n-2})\\
= 2^{n-1} + 2^{n-1} - 1\\
= 2^n - 1

三阶汉诺塔问题的递归调用追踪如下,读者可自行画出4阶调用轨迹方便理解
hanoi

class Solution {
    public void hanota(List<Integer> A, List<Integer> B, List<Integer> C) {
        int n = A.size();
        move(n, A, B, C);
    }

    /**
     * 把最大元素上面的n-1个元素从src经由tmp移至tar
     */
    public void move(int n, List<Integer> src, List<Integer> tmp, List<Integer> tar) {

        // 递归边界,将本层起始柱顶端元素移至目标柱顶端
        if (n == 1) {
            tar.add(src.remove(src.size() - 1));
            return;
        }

        // 将本层最大元素上面的n-1个元素从src移到tmp
        move(n - 1, src, tar, tmp);

        // 将本层最大元素从src移到tar,本层最大是src[src.size() - 1],非全局最大src[0]
        tar.add(src.remove(src.size() - 1));

        // 把最大元素上面的n-1个元素从tmp移到tar
        move(n - 1, tmp, src, tar);
    }
}

两两交换链表结点

    分析
        > 父子问题递推
            父问题 -> head -> next -> 子问题
            父问题 -> next -> head -> 子问题
            每一层返给父问题头结点,拿到子问题头结点
        > 每层核心逻辑
            交换两个结点,返回当前头结点给父问题,指向子结点返回的头结点
        > 递归边界
            空结点或只剩一个结点无法交换
    public class ListNode {
        int val;
        ListNode next;
        ListNode(int x) { val = x; }
    }

    class Solution {
        public ListNode swapPairs(ListNode head) {
            if(head == null || head.next == null){
                return head;
            }
            ListNode next = head.next;
            head.next = swapPairs(next.next);
            next.next = head;
            return next;
        }
    }

数字回文序列

    public void mystery(int x) {
        System.out.print(x % 10);
        if ((x / 10) != 0) {
            mystery(x / 10);
        }
        System.out.print(x % 10);
    }
> 1234
43211234

杨辉三角

    > 递推关系
        if(j == 0 || j == i) father.add(1);
        else father.add(sub.get(j-1) + sub.get(j));

① [改进] 从首行开始每次保存上行结果供当前行计算,避免递减递归造成的重复计算

    public static List<Integer> getRow(int rowIndex) {
        List<Integer> pre = new ArrayList<Integer>();
        for (int i = 1; i <= rowIndex; i++) {
            List<Integer> now = new ArrayList<Integer>();
            for (int j = 0; j < i; j++) {
                if (j == 0 || j == i - 1) now.add(1);
                else now.add(pre.get(j - 1) + pre.get(j));
            }
            pre = now;
        }
        return pre;
    }

② [改进] 复用同一个list,减少新建list开销

    public static List<Integer> getRow(int n) {
        List<Integer> res = new ArrayList<>(n);
        res.add(1);
        int pre = res.get(0);
        for (int i = 1; i <= n; i++) {
            for (int j = 1; j < i; j++) {
                int tmp = res.get(j);
                res.set(j, tmp + pre);
                pre = tmp;
            }
            res.add(1);
        }
        return res;
    }

③ [改进] 倒序去除覆盖,减少中间变量开销

    public static List<Integer> getRow(int n) {
        List<Integer> res = new ArrayList<>(n);
        res.add(1);
        int pre = res.get(0);
        for (int i = 1; i < n; i++) {
            for (int j = i - 1; j > 0; j--) {
                cur.set(j, cur.get(j - 1) + cur.get(j));
            }
            cur.add(1);
        }
        return res;
    }

④ [计算] 组合数公式

    public List<Integer> getRow(int rowIndex) {
        List<Integer> ans = new ArrayList<>();
        int n = rowIndex;
        for (int k = 0; k <= n; k++)
            ans.add(C(n, k));
        return ans;
    }

    private int C(int n, int k) {
        long res = 1;
        for (int i = 1; i <= k; i++)
            res = res * (n - k + i) / i;
        return (int) res;
    }

⑤ [计算] 组合数公式简化

    public List<Integer> getRow(int rowIndex) {
        List<Integer> res = new ArrayList<>();
        int n = rowIndex;
        long pre = 1;
        ans.add(1);
        for (int k = 1; k <= N; k++) {
            long now = pre * (n - k + 1) / k;
            ans.add((int) now);
            pre = now;
        }
        return res;
    }

上述若采用pre*=(n-k+1)/k,右侧结果先计算为0,导致末位为0
可利用对称性进行进一步优化

反转链表

分析
    > 父子问题递推
        子问题返回后一结点 -> 当前结点 -> 返回父问题头/尾结点
    > 递归边界
        空结点返回空或只剩一个结点返回该结点

① [递归] 返回父问题头节点,每次插入需要遍历到尾节点

    public ListNode reverseList(ListNode head) {
        if (head == null || head.next == null) return head;
        ListNode node = reverseList(head.next);
        head.next = null;
        ListNode temp = node;
        while (temp.next != null) temp = temp.next;
        temp.next = head;
        return node;
    }

② [递归] 返回父问题尾节点,一个附加结点保存头结点,避免遍历

    private static ListNode headNode = null;
    public static ListNode reverseList(ListNode head) {
        if (head == null || head.next == null) return headNode = head;
        ListNode node = reverseList(head.next);
        head.next = null;
        node.next = head;
        return head;
    }

③ [递归] 每一步指向之前结点,每一步都返给父问题结果的头结点

    public ListNode reverseList(ListNode head) {
        if (head == null || head.next == null) return head;
        ListNode p = reverseList(head.next);
        head.next.next = head;
        head.next = null;
        return p;
    }

④ [尾递归] 返回父问题头结点,保存上一结点供指向和next指针供向后遍历

    public ListNode reverseList(ListNode head) {
        return reverse(null,head);
    }

    private static ListNode reverse(ListNode pre,ListNode cur){
        if(cur == null) return pre;
        ListNode next = cur.next;
        cur.next = pre;
        return reverse(cur,next);
    }

全排列问题

给定n个字符,求其字典序/升序全排列

字典序[暴力求解]

1.问题分解:
    枚举当前字符 枚举下一个字符
2.问题模式:
    全排列 = 当前枚举字符 + 不含当前所有字符的枚举
3.递归边界:
    枚举到达给定字符集末尾
4.核心操作
    按序遍历字符集,设定未出现字符为下一个字符
- 代码流程
    -1
        -1 ×
        -2 √
            -1 ×
            -2 ×
            -3 √
        -3 √
            -1 ×
            -2 √
            -3 ×
    -2
        -1 √
            -1 ×
            -2 ×
            -3 √
        -2 ×
        -3 √
            -1 √
            -2 ×
            -3 ×
    -3
        -1 √
            -1 ×
            -2 √
            -3 ×
        -2 √
            -1 √
            -2 ×
            -3 ×
        -3 ×
    private static char[] arr = new char[]{'c', 'b', 'a'};

    public static void main(String[] args) {
        char[] des = new char[arr.length];
        Arrays.sort(arr);
        permutation(des, 0);
    }

    public static void permutation(char[] des, int cur) {
        if (cur == des.length) {
            System.out.println(Arrays.toString(des));
        }
        for (char item : arr) {
            int i = 0;
            for (; i < cur; i++) {
                if (des[i] == item) {
                    break;
                }
            }
            if (i == cur) {
                des[cur] = item;
                permutation(des, cur + 1);
            }
        }
    }
[a, b, c]
[a, c, b]
[b, a, c]
[b, c, a]
[c, a, b]
[c, b, a]

非字典序[递归交换]

分析
    > 父子问题递推
        父问题 = 选定某字符开头 + 子问题除该字符已排列结果 并还原选定前状态
    > 每层核心逻辑
        在子问题已排好基础前枚举每个字符,将当前层排列好的基础传给父问题
- 代码流程
    -1与1交换首位
        -2与3交换首位
        -2与3交换恢复原位
    -1交换恢复原位
    -2与1交换首位
        -1与3交换首位
        -1与3交换恢复原位
    -2交换恢复原位
    -3与1交换首位
        -2与3交换首位
        -2与3交换恢复原位
    -3交换恢复原位
    public static void main(String[] args) {
        char[] arr = new char[]{'1', '2', '3', '4'};
        int end = arr.length - 1;
        permutation(arr, 0, end);
    }

    public static void permutation(char[] arr, int cur, int end) {
        if (cur == end) {
            System.out.println(Arrays.toString(arr));
            return;
        }
        for (int i = cur; i <= end; i++) {
            swap(arr, i, cur);
            permutation(arr, cur + 1, end);
            swap(arr, i, cur);
        }
    }

    public static void swap(char[] arr, int cur, int end) {
        char temp = arr[cur];
        arr[cur] = arr[end];
        arr[end] = temp;
    }
- 实际流程
    -1
        -2
            -3
                4
            -4
                3
        -3
            -2
                -4
            -4
                -2
        -4
            -3
                -2
            -2
                -3
    -2
        -1
            -3
                -4
            -4
                -3
        -3
            -1
                -4
            -4
                -1
        -4
            -3
                -2
            -1
                -3
    -3
        -2
            -1
                -4
            -4
                -1
        -1
            -2
                -4
            -4
                -2
        -4
            -1
                -2
            -2
                -1
    -4
        -2
            -3
                -1
            -1
                -3
        -3
            -2
                -1
            -1
                -2
        -1
            -3
                -2
            -2
                -3

记忆化

将第一次的计算结果存储在哈希表中,通过查表法避免递归中的重复运算

斐波那契数列

① 递归哈希表缓存减少计算成本

    public static HashMap<Integer, Integer> cache = new HashMap<>();
    public static int fib(int N) {
        if (cache.containsKey(N)) return cache.get(N);
        int result;
        if (N < 2) result = N;
        else result = fib(N - 1) + fib(N - 2);
        cache.put(N, result);
        return result;
    }

② 斐波那契数原始计算方法

    public int climbStairs(int n) {
        if(n < 3) return n;
        int result0 = 0;
        int result1 = 1;
        int result = 0;
        for(int i = 2; i <= n; i++){
            result = result1 + result0;
            result0 = result1;
            result1 = result;
        }
        return result;
    }

③ 斐波那契数列计算公式

    public int climbStairs(int n) {
        double sqrt5 = Math.sqrt(5);
        double fib = Math.pow((1 + sqrt5) / 2, n + 1) - Math.pow((1 - sqrt5) / 2, n + 1);
        return (int)(fib / sqrt5);
    }

爬楼梯

LeetCode70

分析
    当前位置种数 = 退一步数位置种数 + 退两步数位置种数
    递归边界 走一步1=1,走两步2=1+1或2=2
    实际数字规律为斐波那契数列,可用上例所有方法

① 缓存递归

    Map<Integer, Integer> cache = new HashMap<Integer, Integer>();
    public int climbStairs(int n) {
        if(cache.containsKey(n)) return cache.get(n);
        int result;
        if(n < 3) result = n;
        else result = climbStairs(n - 1) + climbStairs(n - 2);
        cache.put(n, result);
        return result;
    }

② 动态规划

    public static int climbStairs(int n) {
        if(n < 3) return n;
        int[] dp = new int[n];
        dp[0] = 1;
        dp[1] = 2;
        for (int i = 2; i < n; i++)
            dp[i] = dp[i - 1] + dp[i - 2];
        return dp[n - 1];
    }

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注

©2018-2025 Howell版权所有 备案号:冀ICP备19000576号