算法笔记 (递归 & 分治)

· · 算法·理论

递归 & 分治

本页面将介绍递归与分治算法的区别与结合运用。

递归

定义

递归(英语:Recursion),在数学和计算机科学中是指在函数的定义中使用函数自身的方法,在计算机科学中还额外指一种通过重复将问题分解为同类的子问题而解决问题的方法。

引入

递归的基本思想是某个函数直接或者间接地调用自身,这样原问题的求解就转换为了许多性质相同但是规模更小的子问题。求解时只需要关注如何把原问题划分成符合条件的子问题,而不需要过分关注这个子问题是如何被解决的。

以下是一些有助于理解递归的例子:

  1. 结构清晰,可读性强。例如,分别用不同的方法实现归并排序:
    
    // 不使用递归的归并排序算法
    template <typename T>
    void merge_sort(vector<T> a) {
    int n = a.size();
    for (int seg = 1; seg < n; seg = seg + seg)
    for (int start = 0; start < n - seg; start += seg + seg)
      merge(a, start, start + seg - 1, std::min(start + seg + seg - 1, n - 1));
    }

// 使用递归的归并排序算法 template <typename T> void merge_sort(vector<T> a, int front, int end) { if (front >= end) return; int mid = front + (end - front) / 2; merge_sort(a, front, mid); merge_sort(a, mid + 1, end); merge(a, front, mid, end); }

显然,递归版本比非递归版本更易理解。递归版本的做法一目了然:把左半边排序,把右半边排序,最后合并两边。而非递归版本看起来不知所云,充斥着各种难以理解的边界计算细节,特别容易出 bug,且难以调试。

2.练习分析问题的结构。当发现问题可以被分解成相同结构的小问题时,递归写多了就能敏锐发现这个特点,进而高效解决问题。
### 递归的缺点
在程序执行中,递归是利用堆栈来实现的。每当进入一个函数调用,栈就会增加一层栈帧,每次函数返回,栈就会减少一层栈帧。而栈不是无限大的,当递归层数过多时,就会造成**栈溢出**的后果。

显然有时候递归处理是高效的,比如归并排序;**有时候是低效的**,比如数孙悟空身上的毛,因为堆栈会消耗额外空间,而简单的递推不会消耗空间。比如这个例子,给一个链表头,计算它的长度:
```c
// 典型的递推遍历框架
int size(Node *head) {
  int size = 0;
  for (Node *p = head; p != nullptr; p = p->next) size++;
  return size;
}

// 我就是要写递归,递归天下第一
int size_recursion(Node *head) {
  if (head == nullptr) return 0;
  return size_recursion(head->next) + 1;
}

递归的优化

主页面:搜索优化 和 记忆化搜索

比较初级的递归实现可能递归次数太多,容易超时。这时需要对递归进行优化。

分治

定义

分治(英语:Divide and Conquer),字面上的解释是「分而治之」,就是把一个复杂的问题分成两个或更多的相同或相似的子问题,直到最后子问题可以简单的直接求解,原问题的解即子问题的解的合并。

过程

分治算法的核心思想就是「分而治之」。

大概的流程可以分为三步:分解 -> 解决 -> 合并。

1.分解原问题为结构相同的子问题。

2.分解到某个容易求解的边界之后,进行递归求解。

3.将子问题的解合并成原问题的解。

分治法能解决的问题一般有如下特征:

以归并排序为例。假设实现归并排序的函数名为merge_sort。明确该函数的职责,即对传入的一个数组排序。这个问题显然可以分解。给一个数组排序等于给该数组的左右两半分别排序,然后合并成一个数组。

void merge_sort(一个数组) {
  if (可以很容易处理) return;
  merge_sort(左半个数组);
  merge_sort(右半个数组);
  merge(左半个数组, 右半个数组);
}

传给它半个数组,那么处理完后这半个数组就已经被排好了。注意到merge_sort 与二叉树的后序遍历模板极其相似。因为分治算法的套路是分解 -> 解决(触底)-> 合并(回溯),先左右分解,再处理合并,回溯就是在退栈,即相当于后序遍历。

## 要点 ### 写递归的要点 **明白一个函数的作用并相信它能完成这个任务,千万不要跳进这个函数里面企图探究更多细节**, 否则就会陷入无穷的细节无法自拔,人脑能压几个栈啊. 以遍历二叉树为例。 ```c void traverse(TreeNode* root) { if (root == nullptr) return; traverse(root->left); traverse(root->right); } ``` 这几行代码就足以遍历任何一棵二叉树了。对于递归函数$traverse(root)$,只要相信给它一个根节点$root$,它就能遍历这棵树。所以只需要把这个节点的左右节点再传给这个函数就行了。 同样扩展到遍历一棵$N$叉树。与二叉树的写法一模一样。不过,对于$N$叉树,显然没有中序遍历。 ```c void traverse(TreeNode* root) { if (root == nullptr) return; for (auto child : root->children) traverse(child); } ``` ## 区别 ### 递归与枚举的区别 递归和枚举的区别在于:枚举是横向地把问题划分,然后依次求解子问题;而递归是把问题逐级分解,是纵向的拆分。 ### 递归与分治的区别 递归是一种编程技巧,一种解决问题的思维方式;分治算法很大程度上是基于递归的,解决更具体问题的算法思想。