数据结构与算法分析:(八)如何写好递归代码?

一、前言

相信大家在真实项目中,很少用递归,宁愿把这个递归代码改写成迭代循环的非递归写法。笼统的讲,所有的递归代码都可以改写为迭代循环的非递归写法。如何做?抽象出递推公式、初始值和边界条件,然后用迭代循环实现。为啥我们宁愿改成迭代循环的非递归写法呢?因为在实际项目中用递归,一不小心就容易搞成堆栈溢出抛异常。我们这么怕写递归代码是因为我们可能还没有掌握透递归的精髓。其实也没有那么可怕,不信和我一起开启递归之旅。

递归是一种应用非常广泛的算法(或者编程技巧)。之后我们要讲的很多数据结构和算法的编码实现都要用到递归,比如 DFS 深度优先搜索、前中后序二叉树遍历等等。

我们把去的过程叫“递”,回来的过程叫“归”。基本上,所有的递归问题都可以用递推公式来表示。下面就来讲讲怎么写好递归公式。

二、递归需要满足的三个条件

1、一个问题的解可以分解为几个子问题的解

2、这个问题与分解之后的子问题,除了数据规模不同,求解思路完全一样

3、存在递归终止条件

三、如何写好递归代码?

写递归代码最关键的是写出递推公式找到终止条件,剩下将递推公式转化为代码就很简单了。

我们来举个例子:

假如这里有 n 个台阶,每次你可以跨 1 个台阶或者 2 个台阶,请问走这 n 个台阶有多少种走法?如果有 7 个台阶,你可以 2,2,2,1 这样子上去,也可以 1,2,1,1,2 这样子上去,总之走法有很多,那如何用编程求得总共有多少种走法呢?

我们仔细想下,实际上,可以根据第一步的走法把所有走法分为两类,第一类是第一步走了 1 个台阶,另一类是第一步走了 2 个台阶。所以 n 个台阶的走法就等于先走 1 阶后,n-1 个台阶的走法 加上先走 2 阶后,n-2 个台阶的走法。用公式表示就是:

f(n) = f(n-1) + f(n-2)

有了递推公式,递归代码基本上就完成了一半。我们再来看下终止条件。当有一个台阶时,我们不需要再继续递归,就只有一种走法。所以 f(1) = 1。这个递归终止条件足够吗?我们可以用 n=2,n=3 这样比较小的数试验一下。

n = 2 时,f(2) = f(1) + f(0)。如果递归终止条件只有一个 f(1) = 1,那 f(2) 就无法求解了。所以除了 f(1) = 1 这一个递归终止条件外,还要有 f(0) = 1,表示走 0 个台阶有一种走法,不过这样子看起来就不符合正常的逻辑思维了。所以,我们可以把 f(2) = 2 作为一种终止条件,表示走 2 个台阶,有两种走法,一步走完或者分两步来走。

所以,递归终止条件就是 f(1) = 1,f(2) = 2。这个时候,你可以再拿 n = 3,n = 4 来验证一下,这个终止条件是否足够并且正确。

我们把递归终止条件和刚刚得到的递推公式放到一起就是这样的:

f(1) = 1
f(2) = 2
f(n) = f(n-1) + f(n-2)

有了这个公式,我们转化成递归代码就简单多了。最终的递归代码是这样的:

int f(int n) {
  if (n == 1) return 1;
  if (n == 2) return 2;
  return f(n-1) + f(n-2);
}

编写递归代码的关键是,只要遇到递归,我们就把它抽象成一个递推公式,不用想一层层的调用关系,不要试图用人脑去分解递归的每个步骤。

总结一下:写递归代码的关键就是找到如何将大问题分解为小问题的规律,并且基于此写出递推公式,然后再推敲终止条件,最后将递推公式和终止条件翻译成代码。

四、警惕堆栈溢出

在实际的软件开发中,编写递归代码时,我们会遇到很多问题,比如堆栈溢出。而堆栈溢出会造成系统性崩溃,后果会非常严重。为什么递归代码容易造成堆栈溢出呢?我们又该如何预防堆栈溢出呢?

我在“栈”那一节讲过,函数调用会使用栈来保存临时变量。每调用一个函数,都会将临时变量封装为栈帧压入内存栈,等函数执行完成返回时,才出栈。系统栈或者虚拟机栈空间一般都不大。如果递归求解的数据规模很大,调用层次很深,一直压入栈,就会有堆栈溢出的风险。

那么,如何避免出现堆栈溢出呢?

我们可以通过在代码中限制递归调用的最大深度的方式来解决这个问题。递归调用超过一定深度(比如 1000)之后,我们就不继续往下再递归了,直接返回报错。还是以前面那个跨台阶为例:

// 全局变量,表示递归的深度。
int depth = 0;

int f(int n) {
  ++depth;
  if (depth > 1000) throw Exception;
  
  if (n == 1) return 1;
  if (n == 2) return 2;
  return f(n-1) + f(n-2);
}

但这种做法并不能完全解决问题,因为最大允许的递归深度跟当前线程剩余的栈空间大小有关,事先无法计算。如果实时计算,代码过于复杂,就会影响代码的可读性。所以,如果最大深度比较小,比如 10、50,就可以用这种方法,否则这种方法并不是很实用。

五、警惕重复计算

除此之外,使用递归时还会出现重复计算的问题。还是看上面那个跨台阶的例子,如果我们把整个递归过程分解一下的话,那就是这样的:

在这里插入图片描述

从图中,我们可以直观地看到,想要计算 f(5),需要先计算 f(4) 和 f(3),而计算 f(4) 还需要计算 f(3),因此,f(3) 就被计算了很多次,这就是重复计算问题。

为了避免重复计算,我们可以通过一个数据结构(比如散列表)来保存已经求解过的 f(k)。当递归调用到 f(k) 时,先看下是否已经求解过了。如果是,则直接从散列表中取值返回,不需要重复计算,这样就能避免刚讲的问题了。

public int f(int n) {
  if (n == 1) return 1;
  if (n == 2) return 2;
  HashMap<Integer, Integer> map = new HashMap<>();
  // key是n,value是f(n)
  if (map.containsKey(n)) {
    return map.get(n);
  }
  
  int res = f(n-1) + f(n-2);
  map.put(n, res);
  return res;
}

除了堆栈溢出、重复计算这两个常见的问题。递归代码还有很多别的问题。

六、怎么将递归代码改写为非递归代码?

递归有利有弊,利是递归代码的表达力很强,写起来非常简洁;而弊就是空间复杂度高、有堆栈溢出的风险、存在重复计算、过多的函数调用会耗时较多等问题。所以,在开发过程中,我们要根据实际情况来选择是否需要用递归的方式来实现。

那我们是否可以把递归代码改写为非递归代码呢?还是那个跨台阶的例子:

pre 表示 f(n-1) prepre 表示 f(n-2)

int f(int n) {
  if (n == 1) return 1;
  if (n == 2) return 2;
  
  int res = 0;
  int pre = 2;
  int prepre = 1;
  for (int i = 3; i <= n; ++i) {
    res = pre + prepre;
    prepre = pre;
    pre = res;
  }
  return res;
}

笼统地讲,所有的递归代码都可以改为这种迭代循环的非递归写法。因为递归本身就是借助栈来实现的,只不过我们使用的栈是系统或者虚拟机本身提供的,我们没有感知罢了。如果我们自己在内存堆上实现栈,手动模拟入栈、出栈过程,这样任何递归代码都可以改写成看上去不是递归代码的样子。数据量规模比较大的话,可以采用这种循环迭代,这样就不会因为深度太深而导致堆栈溢出。

七、典型例子

1、斐波那契数列

public class FibonacciSequence {
    /**
     * 用递归实现斐波那契数列,适用于求解比较小的位置数值
     * 0 1 1 2 3 5 8 13 21 ...
     * @param n
     * @return
     */
    public int getFibonacciSequence(int n) {
        if (n < 2) return 1;
        return getFibonacciSequence(n - 1) + getFibonacciSequence(n - 2);
    }
}

2、求阶乘

public class Factorial {
    /**
     * 求阶乘
     * n!=n*(n-1)*(n-2)*...*1
     * @param n
     * @return
     */
    public static int getFactorial(int n) {
        if (n == 1) {
            System.out.print(1 + "=");
            return 1;
        } else {
            System.out.print(n + "*");
            return getFactorial(n - 1) * n;
        }
    }
}

3、列出某个目录下所有子目录和文件

public class DirectoryFile {
    /**
     * 列出某个目录下所有子目录和文件
     * @param path
     * @throws Exception
     */
    public static void getDir(String path) throws Exception {
        File file = new File(path);
        if (file.isDirectory()) {
            System.out.println("Dir" + file.getPath());
            File[] fileArr = file.listFiles();
            for (File f : fileArr) {
                getDir(f.getPath());
            }
        } else if (file.isFile()) {
            System.out.println("File" + file.getPath());
        } else {
            throw new Exception(file.getPath() + "非Dir非File!");
        }
    }
}

4、汉诺塔问题

public class HanoiTower {
    private final static String from = "柱子A";
    private final static String mid = "柱子B";
    private final static String to = "柱子C";
    /**
     * 汉诺塔
     *
     * func:
     * if n!=0 then          ;预定值
     * func(n-1, a, c, b)    ;将n-1个盘子由a移动到b,以c为辅助柱子(注意参数顺序)
     * move a[n] to c        ;将a上的最后一个盘子移动到c
     * func(n-1, b, a, c)    ;将n-1个盘子由b移动到c,以a为辅助柱子
     * endif                 ;完成
     *
     * @param n
     * @param from
     * @param mid
     * @param to
     */
    public static void move(int n, String from, String mid, String to) {
        if (n == 1) {
            System.out.println("移动盘子 " + n + " 从 " + from + " 到 " + to);
        } else {
            move(n - 1, from, to, mid);
            System.out.println("移动盘子 " + n + " 从 " + from + " 到 " + to);
            move(n - 1, mid, from, to);
        }
    }
}

5、二分法查找

public class BinarySearch {
    /**
     * @param array      有序数组,但不限于数组
     * @param start       开始查找的数组下标
     * @param end         结束查找的数组下标
     * @param searchValue 要搜索的值
     * @return
     */
    public static int search(int[] array, int start, int end, int searchValue) {
        if (array != null && array.length > 0) {
            int middle = (start + end) / 2;
            int middleValue = array[middle];
            if (searchValue == middleValue) {
                return middle;
            } else if (searchValue < middleValue) {
                // 查询值小于中值,在中值前面再次搜索,缩小范围
                return search(array, start, middle - 1, searchValue);
            } else {
                // 查询值大于中值,在中值后面再次搜索,缩小范围
                return search(array, middle + 1, end, searchValue);
            }
        } else {
            return -1;
        }
    }
}

你要是能把这5道递归经典例子写好,我相信递归的代码的精髓你掌握的差不多了。

发布了386 篇原创文章 · 获赞 313 · 访问量 21万+

猜你喜欢

转载自blog.csdn.net/riemann_/article/details/104719333