二叉树与链表中的递归思想
对很多同学而言,递归可能是在计算机编程入门过程中的一道屏障,我们可以很快的理解for/while 迭代,看到递归却感到头大,这是一个正常的现象,因为在迭代中,解决问题的顺序是很明了的,但是在递归中,这个顺序却变得模糊起来.
就拿最简单的1~n求和来举例
// 迭代版本
public int getSum(int n){
int sum = 0;
for(int i=1; i<=n; i++) sum += i;
return sum;
}
// 递归版本
public int getSum(int n){
if(n == 0) return 0;
return n + getSum(n-1);
}
我们发现,用循环的思想解决问题就是理解初始情况和终止情况,然后明确每种情况间是如何变化的。
而递归其实很类似,就是理解不同规模问题是否存在共性,并明确规模最小时的情况,以及较大规模情况时如何从较小规模情况演变过来的
在理解了递归的思想后,不如回到我们的主题,链表,二叉树和递归有什么关系呢?
链表和二叉树的某一部分一直都会是该结构的子结构,而这与之前的递归思想不谋而合
- 反转链表 Leetcode 206
// 迭代版本 头插法
/*
newHead -> null
newHead -> 1 -> null
newHead -> 2 -> 1 -> null
...
newHead -> 5 -> 4 -> 3 -> 2 -> 1 -> null
*/
class Solution {
public ListNode reverseList(ListNode head) {
ListNode newHead = new ListNode(-1);
while (head != null) {
ListNode next = head.next;
head.next = newHead.next;
newHead.next = head;
head = next;
}
return newHead.next;
}
}
// 递归版本
/*
递归是从简单的情况开始解决,假设我们当前已经解决了3个节点,
然后我们要解决4个节点的问题
当前情况为 2 -> 5 -> 4 -> 3, 转化为 5 -> 4 -> 3 -> 2
*/
class Solution {
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;
}
}
/*作者:LeetCode
链接:https://leetcode-cn.com/problems/reverse-linked-list/solution/fan-zhuan-lian-biao-by-leetcode/
来源:力扣(LeetCode)
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。*/
递归和迭代并非完全对立,他们只是解决一个问题的两种不同的模拟方式,在某些场景下,迭代和递归也要进行适当的结合
- 从链表中删去总和值为0的连续节点 Leetcode 1171
如果我们分析这道题要做的事情,既不是从头部删除和为0的部分,也不是从链表中删除一些连续的节点,而是从链表中删除所有和为0的连续节点,这个连续的部分可能从链表的任意部分开始,也可能有任意数量这样的连续节点群。
在这种情况下,如果只使用递归,那么一定需要一个辅助递归函数
private ListNode removeTargetValue(ListNode head, int target);
但是这样会令我们的递归过程变得复杂且不够直观,下面是递归方法
class Solution {
private boolean found = false; // 记录是否找到和为0连续节点
public ListNode removeZeroSumSublists(ListNode head) {
if(head == null) return head;
ListNode headPrev = new ListNode(-1);
headPrev.next = head;
while(true){
head = removeTargetValue(head,0);
found = false;
if(headPrev.next == head) break;
else headPrev.next = head;
}
/* 当前节点开始已经无和为0的连续部分,从下一个节点开始继续递归重复整个过程 */
if(head != null)
head.next = removeZeroSumSublists(head.next);
return headPrev.next;
}
private ListNode removeTargetValue(ListNode head, int target){
if(head == null) return null;
if(head.val == target){
found = true;
return head.next;
}
ListNode ans = removeTargetValue(head.next, target-head.val);
/* 如果找到和为0连续节点,返回该连续部分的下一个节点,反之返回原节点 */
return found ? ans : head;
}
}
如果我们在和是否为0这个过程中使用迭代,而在选择连续部分开始节点的过程中使用递归,则并不需要辅助函数,代码也相对简洁直观,下面是递归 + 迭代
class Solution {
public ListNode removeZeroSumSublists(ListNode head) {
if(head == null) return head;
ListNode headPrev = new ListNode(-1);
ListNode headCopy = head; /* 记录当前头节点,因为后续迭代部分会更新头节点 */
headPrev.next = headCopy;
int sum = 0;
while(head != null){
sum += head.val;
head = head.next;
if(sum == 0){
/* 如果有以头节点为开始节点的连续为0部分,直接将头节点更新为该连续部分的下一个节点
并继续递归*/
headPrev.next = removeZeroSumSublists(head);
}
}
/* 若迭代过程中未找到以头节点为开始节点的连续为0部分,
则从下一个节点开始递归重复这一过程 */
if(headPrev.next == headCopy){
head = headCopy;
headCopy.next = removeZeroSumSublists(head.next);
}
return headPrev.next;
}
}
看完了链表,让我们再处理一下二叉树,同学们会不会感觉更加得心应手了呢?
- 路径总和 Leetcode 437
注意题目中的几个关键点,思考一下这些在代码中应该如何体现呢 - 无须从根节点开始
- 无须在叶节点结束
- 路径是连续向下的节点
class Solution {
public int pathSum(TreeNode root, int sum) {
if(root == null){
return 0;
}
/* dfs(TreeNode,int) 搜索了以当前节点为开始节点的所有路径,
而pathSum(TreeNode,int)的递归则确保把所有节点作为开始节点 */
return dfs(root, sum) + pathSum(root.left, sum) + pathSum(root.right, sum);
}
public int dfs(TreeNode root, int sum){
int res = 0;
if(root == null){
return res;
}
/* 当找到一条路径时并不return,而是继续寻找直至叶节点,
这样确保了一条线路上的所有可能性全部发掘
e.g. sum = 3
1 -> 2 -> 3 -> -3 这条线路上总共有2条路径而非1条 */
if(root.val == sum){
res++;
}
res += dfs(root.left, sum - root.val);
res += dfs(root.right, sum - root.val);
return res;
}
}
总结
其实递归并非想象的那么难,它只是一种模拟过程的方式,在解决实际问题的过程中,只有我们真正理解问题是如何处理的,才能选择出适当的方法,在问题中有如下特性时,可以考虑选择递归的方式
- 问题采取的数据结构可以实践递归思想
- 问题拥有“规模”这一概念
- 不同规模间有一定的联系
理解了递归的思想,后续的 DFS算法 和 动态规划思想 也会变得有迹可循。
希望这篇文章对大家有所帮助!