递归算法的学习和应用 I

1,307 阅读4分钟

递归是解决问题的有效方式,函数将自身作为子例程调用。

递归的诀窍就是每次调用自身,将自身问题简化成子问题,不断简化问题直到不需要进一步递归就能解决问题。

为避免递归陷入无限循环中,它必须有一下两种特性:

  • 最简单的基本情况 —— 不使用递归生产答案的终止案例,在程序中即终止条件。
  • 递推关系 —— 问题结果与其子问题结果的关系。

递归函数的执行就是根据递推关系调用函数本身,直到其抵达基本情况。

注意:函数可以能在多个地方调用自身。

如何执行一个递归方案

一个函数 F(X), X为函数输入,函数中我们将:

  1. 将问题分解成更小的范围, 例如:x0 ∈ X, x1 ∈ X, ...,xn ∈ X;
  2. 递归调用 F(x0), F(x2), ..., F(xn), 解决X的子问题。
  3. 最后处理递归调用返回的结果解决X相应的问题。

案例: 给定一个链表,交换相邻的链并且返回链表

实现链表的两个类:

class ListNode {
  constructor(val) {
    this.val = val;
    this.next = null;
  }
}

class LinkedList {
  constructor() {
    this.head = null;
  }

  add(val) {
    let node = new ListNode(val);
    if (this.head) {
      this.addNode(this.head, node);
    } else {
      this.head = node
    }
  }

  addNode(head, node) {
    if (head.next) {
      this.addNode(head.next, node);
    } else {
      head.next = node;
    }
  }

  recursionList(node, arr) {
    if (node === null) return;
    arr.push(node.val);
    this.recursionList(node.next, arr);
  }

  print() {
    let result = [];
    this.recursionList(this.head, result);
    console.log(result);
  }
}

定义函数swap(head), 传入根节点head, 递归关系就是 node 和 node.next 交换,所以创建一个helper函数,这样我可以很明确表现 递归关系基本案例

  • 递推关系: swap(head) = helper(head, head.next);
  • 基本案例:swap(head) = head where head = null or head.next = null;

helper函数分析:

  1. 分解: 每次都是 node 和 node.next 交换,两个为一组,helper(node, node.next);
  2. 执行 helper(node, node.next) -> helper(node.next.next, node.next.next.next) -> ... -> helper(null, null);
  3. 将交换后的头部返回,和前一次交换后的链表尾部连接。
var swapPairs = function(head) {
  if (head === null) return null;

  function helper(node, next = null) {
    if (next === null) return node;
    let nextAndNext = next.next;
    next.next = node;
    node.next = helper(nextAndNext, nextAndNext ? nextAndNext.next : null);
    return next;
  }

  return helper(head, head.next);
};

let linkedList = new LinkedList();
let arr = [1, 2, 3, 4];
for (let value of arr) {
  linkedList.add(value);
}

linkedList.print(); // [1, 2, 3, 4]

linkedList.head = swapPairs(linkedList.head);

linkedList.print(); // [ 2, 1, 4, 3 ]

递归记忆

递归是很直观和有效的算法,但有时会有一些我们不期望的副作用-重复计算,比如在下面的斐波那契数算法中:

求第n个斐波那契数 分析:

  • 基础案例:fn(1) = 1, fn(0) = 0;
  • 递推关系:fn(n) = fn(n - 1) + f(n - 2)
function fibonacci(n) {
  if (n < 2) return n;
  return fibonacci(n - 1) + fibonacci(n - 2);
}

fibonacci(4);

fibonacci(4) = fibonacci(3) + fibonacci(2) = (fibonacci(2) + fibonacci(1)) + fibonacci(2); 这个例子中重复多次计算。

记忆化

为了消除重复计算,加速程序执行的速度,我们可以将已经计算过的结果保存起来,再次遇到 相同输入时,调用缓存数据,避免重复计算。这时上面的例子我们可以这样写。

function fibonacci(n) {
  let map = new Map();
  function helper(n) {
    if (map.has(n)) return map.get(n);
    if (n < 2) return n;
    let result = fibonacci(n - 1) + fibonacci(n - 2);
    map.set(n, result);
    return result;
  }
  return helper(n);
}

fibonacci(10);

我们创建一个Map,保存以n为键的每个fibonacci(n)的结果的映射。

尾递归

尾递归是在递归函数中,递归调用是函数的最后指令,并且在函数中应该只有一次递归调用。

function tailRecursion(n, sum = 1) {
  if (n <= 1) return sum;
  return tailRecursion(n - 1, sum + n);
}

这就是一个典型的尾递归。

尾递归的作用是在避免递归调用中积累堆栈消耗,消除堆栈溢出的情况。递归调用呈现在堆栈中的情况是:

f(x1) -> f(x2) -> f(x3)

|f(x3) |
|f(x2) | 
|f(x1) | 
|——————| 
 stack

在这里内存的释放是从f(x3)开始的,也就是说在递归调用还未结束时,会一直向堆栈加入子任务。递归对内存消耗很大,但是在尾递归中,我们是直接返回递归调用,这时其实我们完全不需要函数中的任何信息,程序完全可以在第一次分配的空间中重复执行,不必积累保存无用信息。于是在有些语言中javascript, c等语言中,解析器直接优化了递归调用的调用栈。这代表尾递归永远不会出现堆栈溢出现象。

注意1:在js中,尾递归优化只在严格模式下有效,因为正常模式下,函数内部会存在两个变量被调用栈记录。

  • arguments:返回调用时函数的参数。
  • func.caller:返回调用当前函数的那个函数。

注意2:在研究尾递归的时候,我在chrome上想看看堆栈调用情况,但并没有任何优化,查了一下,浏览器都不支持优化,V8支持,但必须有启用优化的命令。

总结

  1. 遇到疑惑时,写下递推关系;
  2. 尽可能的应用记忆化;
  3. 如果栈溢出,试着使用尾递归。

来源LeetCode-递归学习I