2021/8/4打卡算法递归&分治
学习资料转自
简介
递归(英语:Recursion),在数学和计算机科学中是指在函数的定义中使用函数自身的方法,在计算机科学中还额外指一种通过重复将问题分解为同类的子问题而解决问题的方法。
分治(英语:Divide and Conquer),字面上的解释是“分而治之”,就是把一个复杂的问题分成两个或更多的相同或相似的子问题,直到最后子问题可以简单的直接求解,原问题的解即子问题的解的合并。
递归的缺点
在程序执行中,递归是利用堆栈来实现的。每当进入一个函数调用,栈就会增加一层栈帧,每次函数返回,栈就会减少一层栈帧。而栈不是无限大的,当递归层数过多时,就会造成 栈溢出 的后果。
显然有时候递归处理是高效的,比如归并排序;有时候是低效的,比如数孙悟空身上的毛,因为堆栈会消耗额外空间,而简单的递推不会消耗空间。比如这个例子,给一个链表头,计算它的长度:
// 典型的递推遍历框架
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;
}
分治算法
分治算法的核心思想就是“分而治之”。
大概的流程可以分为三步:分解 -> 解决 -> 合并。
- 分解原问题为结构相同的子问题。
- 分解到某个容易求解的边界之后,进行递归求解。
- 将子问题的解合并成原问题的解。
分治法能解决的问题一般有如下特征: - 该问题的规模缩小到一定的程度就可以容易地解决。
- 该问题可以分解为若干个规模较小的相同问题,即该问题具有最优子结构性质,利用该问题分解出的子问题的解可以合并为该问题的解。
- 该问题所分解出的各个子问题是相互独立的,即子问题之间不包含公共的子问题。
以归并排序为例。假设实现归并排序的函数名为 merge_sort。明确该函数的职责,即 对传入的一个数组排序。这个问题显然可以分解。给一个数组排序等于给该数组的左右两半分别排序,然后合并成一个数组。
void merge_sort(一个数组) {
if (可以很容易处理) return;
merge_sort(左半个数组);
merge_sort(右半个数组);
merge(左半个数组, 右半个数组);
}
传给它半个数组,那么处理完后这半个数组就已经被排好了。注意到,merge_sort 与二叉树的后序遍历模板极其相似。因为分治算法的套路是 分解 -> 解决(触底)-> 合并(回溯),先左右分解,再处理合并,回溯就是在退栈,即相当于后序遍历。
merge 函数的实现方式与两个有序链表的合并一致。
要点
写递归的要点
明白一个函数的作用并相信它能完成这个任务,千万不要跳进这个函数里面企图探究更多细节, 否则就会陷入无穷的细节无法自拔,人脑能压几个栈啊。
以遍历二叉树为例。
void traverse(TreeNode* root) {
if (root == nullptr) return;
traverse(root->left);
traverse(root->right);
}
这几行代码就足以遍历任何一棵二叉树了。对于递归函数 traverse(root),只要相信给它一个根节点 root,它就能遍历这棵树。所以只需要把这个节点的左右节点再传给这个函数就行了。
同样扩展到遍历一棵 N 叉树。与二叉树的写法一模一样。不过,对于 N 叉树,显然没有中序遍历。
void traverse(TreeNode* root) {
if (root == nullptr) return;
for (auto child : root->children) traverse(child);
}
例题
原题
给定一个二叉树,它的每个结点都存放着一个整数值。
找出路径和等于给定数值的路径总数。
路径不需要从根节点开始,也不需要在叶子节点结束,但是路径方向必须是向下的(只能从父节点到子节点)。
二叉树不超过 1000 个节点,且节点数值范围是[-1000000,1000000]的整数。
示例:
root = [10,5,-3,3,2,null,11,3,-2,null,1], sum = 8
10
/ \
5 -3
/ \ \
3 2 11
/ \ \
3 -2 1
返回 3。和等于 8 的路径有:
> 1. 5 -> 3
> 2. 5 -> 2 -> 1
> 3. -3 -> 11
/**
* 二叉树结点的定义
* struct TreeNode {
* int val;
* TreeNode *left;
* TreeNode *right;
* TreeNode(int x) : val(x), left(NULL), right(NULL) {}
* };
*/
参考代码
题目看起来很复杂,不过代码却极其简洁。
首先明确,递归求解树的问题必然是要遍历整棵树的,所以二叉树的遍历框架(分别对左右子树递归调用函数本身)必然要出现在主函数 pathSum 中。那么对于每个节点,它们应该干什么呢?它们应该看看,自己和它们的子树包含多少条符合条件的路径。好了,这道题就结束了。
按照前面说的技巧,根据刚才的分析来定义清楚每个递归函数应该做的事:
PathSum 函数:给定一个节点和一个目标值,返回以这个节点为根的树中,和为目标值的路径总数。
count 函数:给定一个节点和一个目标值,返回以这个节点为根的树中,能凑出几个以该节点为路径开头,和为目标值的路径总数。
int pathSum(TreeNode *root, int sum) {
if (root == nullptr) return 0;
int pathImLeading = count(root, sum); // 自己为开头的路径数
int leftPathSum = pathSum(root->left, sum); // 左边路径总数(相信它能算出来)
int rightPathSum =
pathSum(root->right, sum); // 右边路径总数(相信它能算出来)
return leftPathSum + rightPathSum + pathImLeading;
}
int count(TreeNode *node, int sum) {
if (node == nullptr) return 0;
// 能不能作为一条单独的路径呢?
int isMe = (node->val == sum) ? 1 : 0;
// 左边的,你那边能凑几个 sum - node.val ?
int leftNode = count(node->left, sum - node->val);
// 右边的,你那边能凑几个 sum - node.val ?
int rightNode = count(node->right, sum - node->val);
return isMe + leftNode + rightNode; // 我这能凑这么多个
}
还是那句话,明白每个函数能做的事,并相信它们能够完成。
总结下,PathSum 函数提供了二叉树遍历框架,在遍历中对每个节点调用 count 函数(这里用的是先序遍历,不过中序遍历和后序遍历也可以)。count 函数也是一个二叉树遍历,用于寻找以该节点开头的目标值路径。