总言
主要内容:编程题举例,熟悉理解递归类题型,进一步加深理解深搜,引入回溯和剪枝相关概念。
文章目录
1、递归
1)、递归
实际在学习语言中我们对其有一定涉及。这里,若从宏观视角看待递归的过程:我们不要在意递归的细节展开图,将该递归函数当成一个黑盒,并相信这个黑盒一定能完成这个任务。
2)、递归与循环
递归和循环在很多情况下是可以互相转换的。 这是因为递归和循环在本质上是解决重复执行代码的方式,只是实现方式不同。递归是通过函数调用自身来实现重复执行,而循环则是通过循环结构(如for、while等)来重复执行代码块。
关于什么时候使用循环或递归,这主要取决于问题的性质和编程者的偏好。以下是一些一般性的指导原则:
使用循环的情况:
- 当问题具有明确的迭代次数时:例如,计算一个数列的前n项和,或者重复执行某个操作固定次数。循环在这种情况下非常直观且高效。
- 避免栈溢出:递归需要函数调用栈来存储中间结果,如果递归深度过大,可能会导致栈溢出。在这种情况下,使用循环可以避免这个问题。
- 性能考虑:对于某些问题,循环可能比递归具有更好的性能,因为循环避免了函数调用的开销。
使用递归的情况:
- 问题具有自然的递归结构:例如,树的遍历、图的深度优先搜索、分治算法等。这些问题通常可以很容易地通过递归来解决,因为它们本身就是层层嵌套的。
- 简化代码和逻辑:有时递归可以使代码更简洁、更易于理解。通过将问题分解为更小的子问题,递归可以帮助我们更清晰地表达问题的解决方案。
- 数学归纳法:当问题可以通过数学归纳法来证明时,递归通常是实现该证明的自然方式。
2、汉诺塔 (easy)
题源:链接。
2.1、题解
1)、思路分析
1、为什么能用递归来解决?
在将复杂问题都分解为更小、更简单的子问题过程中,发现子问题的解决方案与原始问题的解决方案类似或相同。
2、如何设计递归?
根据我们分解时发现的规律:本题重复的操作是:将X柱子上的一堆盘子,借助Y柱子,挪动到Z柱子上。 因此函数头可以设计如下:F(X,Y,Z,n)
;
X、Y、Z柱子在题中为A、B、C三柱,而具体地谁借助谁挪动到谁,需要看题目要求和挪盘过程(原问题和子问题的解决方案),即函数体设计:
a、将A柱子上的n-1个盘子,借助C柱子,转移到B柱子上
b、将A柱子上的第n个盘子,直接转移到C柱子上
c、将B柱子上的这n-1个盘子,借助A柱子,转移到C柱子上
2)、题解
这里注意看题目给的示例:A = [2, 1, 0], B = [], C = []
,这说明在数组中存放数据时,是将大盘放在数组首,小盘放在数组尾部的。使用代码实现时需要注意。
class Solution {
public:
// 参数说明:将A柱子上的n个盘子,借助B柱子,转移到C柱子上。
void recursion(vector<int>& A, vector<int>& B, vector<int>& C, int n) {
if (n == 1) // 递归结束条件:当A柱子上只有一个盘子时,直接将其转移到C柱子上。
{
C.push_back(A.back());
A.pop_back();
return;
}
// 先将A柱子上的n-1个盘子,借助C柱子,转移到B柱子上
recursion(A, C, B, n-1);
// 再将A柱子上的第n个盘子,直接转移到C柱子上
C.push_back(A.back());
A.pop_back();
// 最后,将B柱子上的这n-1个盘子,借助A柱子,转移到C柱子上
recursion(B, A, C, n-1);
}
void hanota(vector<int>& A, vector<int>& B, vector<int>& C) {
int n = A.size();
recursion(A, B, C, n);
}
};
3、合并两个有序链表(easy)
题源:链接。
3.1、题解
1)、思路分析
此题我们曾用双指针法解决过(相当于归并排序中合并的步骤),相关链接。
这里我们以递归的方式来解决:题目要求将两个有序的链表合并,并返回合并后的头结点。因此,我们可以找到先比较当前两链表,选择两个头结点中较小的结点作为最终合并后的头结点。然后,继续对剩余的两链表进行合并即可。
递归函数设计如下:
函数头:已知两个链表的头结点,返回合并后的头结点Node* Merge( list1, list2)
;
函数体:①比较大小获取头结点head
;②将剩下的链表交给递归函数去处理head->next = Merge()
;
递归结束条件:当某⼀个链表为空的时候,返回另外⼀个链表。
2)、题解
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* ListNode *next;
* ListNode() : val(0), next(nullptr) {}
* ListNode(int x) : val(x), next(nullptr) {}
* ListNode(int x, ListNode *next) : val(x), next(next) {}
* };
*/
class Solution {
public:
ListNode* mergeTwoLists(ListNode* list1, ListNode* list2) {
return Merge(list1,list2);
}
//递归:对当前层中的两个链表进行合并,并返回合并后的头结点
ListNode* Merge(ListNode* list1, ListNode* list2)
{
//递归结束条件
if(list1 == nullptr) return list2;
if(list2 == nullptr) return list1;
//比较当前层中,两链表的头结点大小(排升序,以数值小的结点,作为合并后的链表头结点)
if(list1->val < list2->val)
{ //选出头结点后,后将剩下的链表交给递归函数去处理(下层递归会返回合并后的升序链表)。
list1->next = Merge(list1->next, list2);
return list1;//返回当前层中,合并后的升序链表
}
else //list2->val <= list1->val
{
list2->next = Merge(list2->next, list1);
return list2;
}
}
};
4、反转链表(easy)
题源:链接。
4.1、题解
1)、思路分析
此题我们曾用头插法/三指针法解决过,相关链接。
这里主要讲解递归的写法:
2)、题解
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* ListNode *next;
* ListNode() : val(0), next(nullptr) {}
* ListNode(int x) : val(x), next(nullptr) {}
* ListNode(int x, ListNode *next) : val(x), next(next) {}
* };
*/
class Solution {
public:
ListNode* reverseList(ListNode* head) {
if (head == nullptr || head->next == nullptr)
return head;
// 先让当前结点的后续结点均完成逆置
ListNode* newhead = reverseList(head->next);
// 再来逆置当前结点
head->next->next = head;
head->next = nullptr;
return newhead;// 注意将逆置后的链表头结点返回
}
};
5、两两交换链表中的节点(medium)
题源:链接。
5.1、题解
1)、思路分析
之前使用循环解决故此题,相关链接。这里我们使用递归来解决:
注意递归结束条件:当前结点为空或者当前只有⼀个结点的时候,不⽤交换,直接返回。
2)、题解
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* ListNode *next;
* ListNode() : val(0), next(nullptr) {}
* ListNode(int x) : val(x), next(nullptr) {}
* ListNode(int x, ListNode *next) : val(x), next(next) {}
* };
*/
class Solution {
public:
ListNode* swapPairs(ListNode* head) {
//递归结束条件:注意考虑单数、双数两情况
if(head == nullptr || head->next == nullptr)
return head;
ListNode* n1 = head, *n2 = head->next;
//先将后续结点逆置:n1 n2 n3 ……
ListNode* n3 = swapPairs(n2->next);
//再逆置当前两结点
n1->next = n3;
n2->next = n1;
return n2;//返回当前头结点
}
};
6、Pow(x, n)(medium):快速幂
题源:链接。
6.1、题解
1)、思路分析
解法一:暴力循环。 要求
n
n
n 次幂,则循环
n
n
n 次求解。显然,这种解法在
n
n
n 很大时容易超时,题目中
−
2
31
<
=
n
<
=
2
31
−
1
-2^{31} <= n <= 2^{31-1}
−231<=n<=231−1。
解法二:快速幂。 快速幂是一种高效的计算幂次结果的算法,其时间复杂度为
O
(
l
o
g
2
N
)
O(log₂N)
O(log2N)。它的核心思想是将幂的计算过程分解为多个小步骤,通过逐步计算来降低时间复杂度。主要运用了幂的乘法法则,
a
m
+
n
=
a
m
∗
a
n
a^{m+n} = a^m * a^n
am+n=am∗an。当我们要计算
a
a
a 的某个大幂次时,如果可以将这个大幂次分解为两个较小的幂次之和,那么就可以分别计算这两个较小幂次的结果,然后将它们相乘,从而得到最终的结果。
实现方式有两种,递归或循环。递归实现相对简单,核心思想是不断地将幂次指数减半,并递归地计算子问题。
此外就是题目细节:
1、这里幂可以是负数,因此,
x
n
x^n
xn,当n为负数时,应计算
(
1
x
)
n
(\frac{1}{x} )^n
(x1)n。
2、计算结果存储的类型应该使用long long
:int
的最大整型为
2
31
−
1
2^{31}-1
231−1,而题目给定的
n
n
n可以为-31
,即
x
31
x^{31}
x31。
2)、题解
class Solution {
public:
double myPow(double x, int n) {
if(n >= 0) return _mypow(x, n);//n 次幂是正数
else return 1.0/_mypow(x,-(long long)n);//n 次幂是负数数
}
double _mypow(double x, long long n)
{
if(n == 0) return 1.0;
double tmp = _mypow(x, n/2);
return n % 2 == 0 ? tmp*tmp : tmp*tmp*x;//处理奇偶
}
};
7、二叉树的深搜
1)、基本介绍
深度优先遍历(DFS,全称为 Depth First Traversal),是我们树或者图这样的数据结构中常用的⼀种遍历算法。这个算法会尽可能深的搜索树或者图的分⽀,直到⼀条路径上的所有节点都被遍历完毕,然后再回溯到上⼀层,继续找⼀条路遍历。
在二叉树中,常见的深度优先遍历为:前序遍历、中序遍历以及后序遍历。 因为树的定义本身就是递归定义,因此采用递归的方法去实现树的三种遍历不仅容易理解而且代码很简洁。并且前中后序三种遍历的唯⼀区别就是访问根节点的时机不同,在做题的时候,选择⼀个适当的遍历顺序,对于算法的理解是有帮助的。
8、计算布尔二叉树的值(easy)
题源:链接。
8.1、题解
1)、思路分析
主要运用后序遍历:①对非叶节点,先分别访问左右子树,获取左右孩子的布尔运算值,再根据当前节点的逻辑符号进行运算(要判断 2 还是 3,找与其对应的逻辑运算符)。②对叶节点,直接返回其值( 由于这里0 表示 False ,1 表示 True,可直接参与逻辑运算 )
2)、题解
class Solution {
public:
bool evaluateTree(TreeNode* root) {
// 递归结束条件(&&、||都可以,单独左右孩子节点也可以,因为题目给了完全二叉树的定义,左子树为空右子树必定为空)
if(root->left == nullptr && nullptr == root->right)
return root->val;
// 先求左子树和右子树的bool值(根据完整二叉树的定义,这里不必求出某一子树后做返回值优化判断)
bool left = evaluateTree(root->left);
bool right = evaluateTree(root->right);
// 求当前节点的bool值
if(root->val == 2) return left | right;
else return left && right;
}
};
9、求根节点到叶节点数字之和(medium)
题源:链接。
9.1、题解
1)、思路分析
整体思路大同,但代码实现细节看个人风格(总归都是DFS,只是在遍历时存在各种形式变化)。
2)、题解
下述方法中,直接一个变量result统计所有路径值。
class Solution {
public:
int sumNumbers(TreeNode* root) {
int result = 0;
return _sum(root, result);
}
int _sum(TreeNode* root, int result) {
// 先计算当前节点的值:若其左右子树均为空(叶子节点),直接返回该值
result += root->val;
if(root->left == nullptr && root->right == nullptr)
return result;
// 否则,需要再获取左右子树的值(注意单独一边子树存在的情况)
result *= 10;
if (root->left == nullptr && root->right != nullptr)
return _sum(root->right, result);// 右子树存在,则获取右子树的值
else if (root->left != nullptr && root->right == nullptr)
return _sum(root->left, result);// 左子树存在,则获取左子树的值
else
{
return _sum(root->left, result) + _sum(root->right, result);
}
}
};
另一种写法:result用来记录遍历到当前路径的节点值,然后将其传递给左右子树,回溯时统计当前路径处,左右子树的返回值 。
class Solution {
public:
int sumNumbers(TreeNode* root) { return _sum(root, 0); }
int _sum(TreeNode* root, int result) {
// 先计算当前节点的值:若其左右子树均为空(叶子节点),直接返回该值
result = result*10 + root->val;
if (root->left == nullptr && root->right == nullptr)
return result;
// 否则,需要再获取左右子树的值(注意单独一边子树存在的情况)
int ret = 0;
if (root->left) // 左子树存在,则获取左子树的值
ret += _sum(root->left, result);
if (root->right)// 右子树存在,则获取右子树的值
ret += _sum(root->right, result);
return ret;
}
};
10、二叉树剪枝(medium)
题源:链接。
10.1、题解
1)、思路分析
2)、题解
关于这里的delete root;
,若是笔试题可以不写,若是面试题没说明要求的情况下,最好问一下面试官。
class Solution {
public:
TreeNode* pruneTree(TreeNode* root) {
if(root == nullptr)
return nullptr;
//先判断左右子树
root->left = pruneTree(root->left);
root->right = pruneTree(root->right);
//再解决当前节点
if(root->left == nullptr && root->right == nullptr && root->val == 0)
{ //左子树为空,右子树为空,当前节点值为0,需要删除
delete root; // 可加,也可不加,这里主要是用于防⽌内泄漏
return nullptr;
}
else return root;
}
};
11、验证二叉搜索树(medium)
题源:链接。
11.1、题解
1)、思路分析
要解决此题,首先要对二叉搜索树有一定了解:如果一棵树是二叉搜索树,那么它的中序遍历的结果一定是一个严格递增的序列。
①在知道上述的信息条件后,一个容易想到的方法是:遍历一遍二叉搜索树,获取得中序遍历的结果,存储在一个数组中;再遍历一次该数组,判断是否呈严格递增的次序。但此方法消耗的空间复杂度相对较大,我们可以对此做一定优化:
②使用一个类成员变量(全区变量),用于记录每次遍历到的节点的前一节点值(前驱节点值)。 最初时,初始化为无穷小,在中序遍历过程中,先判断当前节点值是否和前驱结点构成递增序列,若不满足条件则说明该二叉树非搜索二叉树,若满足则修改前驱结点为当前结点,传入下一层的递归中。
下面展示了两种写法(回溯和剪枝的初步了解)。
2)、题解
回溯的写法:
class Solution {
long currentmax =
LONG_MIN; // 多个测试用例,定义成全局变量则每个测试用例都需要初始化一次
// 若在函数中初始化则递归调用时会复位,因此这里定义成成员变量。那么多个测试用例每个类调用时,都能获取独立的值。
public:
bool isValidBST(TreeNode* root) {
if (root == nullptr)
return true;
// 中序遍历
// 先遍历左子树
bool left = isValidBST(root->left);
// 判断当前节点是否满足搜索二叉树
bool cur = false;
if (root->val > currentmax) {
currentmax = root->val;
cur = true;
}
// 最后判断右子树
bool right = isValidBST(root->right);
// 返回最终结果
return left && cur && right;
}
};
剪枝的写法:实则就是在每次获取到不满足条件时,直接返回(不必继续遍历)
class Solution {
long currentmax = LONG_MIN;// 多个测试用例,定义成全局变量则每个测试用例都需要初始化一次
// 若在函数中初始化则递归调用时会复位,因此这里定义成成员变量。那么多个测试用例每个类调用时,都能获取独立的值。
public:
bool isValidBST(TreeNode* root) {
if(root == nullptr) return true;
// 中序遍历
// 先遍历左子树,若左子树不满足搜索二叉树,直接返回结果。
bool ret = isValidBST(root->left);
if(!ret) return false;
// 若左子树满足,判断当前节点是否满足搜索二叉树
if(root->val > currentmax)
{
currentmax = root->val;
}
else return false;//若当前节点不满足条件,则直接返回结果
// 最后判断右子树,返回右子树的判断结果
return isValidBST(root->right);
}
};
12、二叉搜索树中第 k 小的元素(medium)
题源:链接。
12.1、题解
1)、思路分析
解法多种,这里主要学习递归和DFS。
有了之前几题的铺垫,这里理解起来也不难,实现一个中序遍历,遍历到第k个节点时返回结果即可。具体:
①可定义⼀个成员变量 count
,在主函数中初始化为 k
的值(若不用此法,可以设置成函数形参传入递归过程中);每遍历⼀个节点就将 count--
。直到某次递归的时候count==0
,说明此结点就是我们要找的结果(这里是count==0
,还是count==1
,取决于count判断和修改的顺序。)
②返回结果:可以设置为返回值,也可以再使用一个成员变量来存储。
③回溯or剪枝:加不加剪枝看自己需求。
2)、题解
回溯的写法:
class Solution {
int result = 0;
int count = 0;
public:
int kthSmallest(TreeNode* root, int k) {
count = k;
_Small(root);
return result;
}
void _Small(TreeNode* root)
{
if(count == 0 || root == nullptr) return;
// 中序遍历,求第K个元素
// 左子树
_Small(root->left);
// 当前节点
--count;
if(count == 0) result = root->val;
// 右子树
_Small(root->right);
}
};
剪枝的写法:
class Solution {
int result = 0;
int count = 0;
void _Small(TreeNode* root) {
if (count == 0 || root == nullptr)
return;
// 中序遍历,求第K个元素
// 左子树
_Small(root->left);
if (count == 0)
return;
// 当前节点
--count;
if (count == 0) {
result = root->val;
return;
}
// 右子树
_Small(root->right);
}
public:
int kthSmallest(TreeNode* root, int k) {
count = k;
_Small(root);
return result;
}
};
13、二叉树的所有路径(easy)
题源:链接。
13.1、题解
1)、思路分析
使⽤深度优先遍历(DFS)求解:对单条路径,以字符串形式存储(string path
),从根节点开始遍历,每次遍历时将当前节点的值加⼊到路径(string path
)中。若遍历到叶子节点,则说明一条路径遍历完成,将其存储到最终返回的结果中(vector<string> ret
);否则,将 “->
” 加⼊到路径中,继续递归遍历该节点的左右子树。
2)、题解
class Solution {
vector<string> ret;
public:
vector<string> binaryTreePaths(TreeNode* root) {
if(root == nullptr) return ret;//可不加(题目给定至少有一个节点)
string path;
TreePaths(root, path);
return ret;
}
void TreePaths(TreeNode* root, string path)
{
if(root == nullptr) return;// 可加可不加(遍历左右子树时进行了判断)
// 前序遍历
// 先遍历当前节点
path += to_string(root->val);// 整型数值转字符串类型
if(!root->left && !root->right)//若是叶子节点,则完成一条路径
{
ret.push_back(path);
return;
}
path +="->";//题目输出打印
// 若左子树存在,遍历左子树
if(root->left) TreePaths(root->left, path);
// 若右子树存在,遍历右子树
if(root->right) TreePaths(root->right,path);
}
};
3)、借助此题说明一些错误写法,以及理解全局变量在回溯、剪枝中的作用
1)、回溯过程中的“恢复现场”:注意因果关系,实际是有回溯的趋势,才想到要“恢复现场”。
2)、是否加引用&
,包括前面几题,在递归时,引用需要谨慎使用。
void TreePaths(TreeNode* root, string path);
void TreePaths(TreeNode* root, string& path);