目录
前言:
本文开始介绍递归相关的算法,仍然是通过题目讲解,本次带来的题目有三道题目,分别是:
面试题 08.06. 汉诺塔问题 - 力扣(LeetCode)
相信大家对于递归有了一定的了解,所以在这里,什么是递归,递归的概念定义什么的,咱也就不介绍了,不过在本文会着重介绍递归算法的核心思想,废话不多说,咱们直接进入主题。
汉诺塔问题
题目解析
在经典汉诺塔问题中,有 3 根柱子及 N 个不同大小的穿孔圆盘,盘子可以滑入任意一根柱子。一开始,所有盘子自上而下按升序依次套在第一根柱子上(即每一个盘子只能放在更大的盘子上面)。移动圆盘时受到以下限制:
(1) 每次只能移动一个盘子;
(2) 盘子只能从柱子顶端滑出移到下一根柱子;
(3) 盘子只能叠在比它大的盘子上。
请编写程序,用栈将所有盘子从第一根柱子移到最后一根柱子。
你需要原地修改栈。
以上是题目要求,以下是测试用例:

相信大家对于汉诺塔的游戏规则也是有一定理解的,简单概述就是有三个柱子,最开始所有的圆盘按照从小到大的规则放在A柱上,我们需要借助B柱子将所有圆盘按照规则从A柱子移动到C柱子,其中的规则是不管圆盘怎么放,大的不能在小的上面就行。
这是汉诺塔的游戏规则,有了该游戏的理解,我们直接进入算法原理部分。
算法原理

我们拿该图作为算法原理的讲解,在正式讲解该题目之前,我们简单介绍一下递归算法的核心。
对于递归算法来说,我们最需要关心的一件事不是递归算法应该如何实现,而是我们应该相信我们自己写的函数一定能实现相关的问题,也就是当我们发现了最小子问题之后,我们直接调用自己的递归函数,至于能不能成功我不关心,我认为一定能成功。
当然以上多少包含了一点PUA,但是要理解的是我们一定要相信自己的递归函数,编写的时候直接就是:不管了,先调用再说。那么上文我们简单提及到了一个词,叫做最小子问题。其实最小子问题才是递归算法中最最重要的东西,因为递归做的事就是做具有相同性质的事儿,比如阶乘,就是一个数*一个数,我们才能通过递归将每个数做乘法乘起来。
那么在汉诺塔问题中,也是一样的,我们在相信自己的递归函数一定能完成的同时,也要找到最小子问题是什么,找到了最小子问题,这道题也就迎刃而解了。
假设方块的数目只有1个,那么我们直接将该方块从A移动到C即可。
假设方块的数目有两个,我们需要先将小的移动到B,再将大的移动到C,然后再把B上小的方块移动到C。仔细观察我们发现分为了三步,将小的移动到辅助柱子,然后将大的移动到目标柱子,最最后把小的移动到目标柱子。
假设方块的数目有三个,我们需要......你以为我们还会一个一个移动看吗?当然不会了,因为我们完成可以简化这个问题,如果有三个方块,我们可以将最大的方块看成一个,将上面的两个小的看成一个整体,那么问题就回归到了n = 2的情况,我们需要将小的移动到辅助柱子,再把大的移动,再移动小的。那上面看成整体的两个方块,本质上就是n = 2的情况,其实到这里初学者还是蒙蔽的,为什么我们不把最上面的小的单独拿出来,而是把大的单独拿出来,因为我们要**借助规则,**如果大的单独拿出来,它的存在几乎为0,因为规则是小的在大的上面,大的我们单独拿出来,我们不管怎么移动上面的小的都没有问题,这就是为什么要把大的看成一个整体,其他的小的看成是一个整体。
以上是汉诺塔的基本算法思想。
为什么这道题能用递归?明显就是因为我们移动方块的过程发现移动方块我们可以拆分为更小的子问题,比如n = 4,我们就可以拆分为n = 3 和 n = 1的情况,其他同理。
那么我们接下来需要关心,也是之后写算法题目需要关心的点:函数头的参数我们应该如何做?函数体我们应该如何实现?
对于函数头来说,我们明显发现整体汉诺塔的问题是和方块的个数有关的,所以总数n是少不了的,然后是因为移动到三个柱子上,我们也就需要vector的参数,综合看来,我们需要三个柱子的参数和总数n的参数:
sql
void Hanota(vector<int>& A, vector<int>& B, vector<int>& C, int n);
对于函数体来说,我们只需要关心最后柱子的头插尾插即可,你会发现n = 1的时候问题就变成了柱子尾插和尾删的魅力时刻了。因为太简单了这里也就不演示了,咱们只需要记得对于辅助柱子和目标柱子来说,并不是固定的,老实说在这里我并没有介绍的十分详细,只是让大家有了一个大概的印象,接下来直接看算法编写吧!
算法编写
cpp
class Solution
{
public:
void Hanota(vector<int>& A, vector<int>& B, vector<int>& C, int n)
{
if (n == 1)
{
C.push_back(A[A.size() - 1]);
A.pop_back();
return;
}
// x -> y
Hanota(A, C, B, n - 1);
// back -> z
C.push_back(A.back());
A.pop_back();
// y -> z
Hanota(B, A, C, n - 1);
}
void hanota(vector<int>& A, vector<int>& B, vector<int>& C)
{
Hanota(A, B, C, A.size());
}
};
代码大概率是超出了你想象的简单的。
合并两个有序链表
题目解析
将两个升序链表合并为一个新的 升序 链表并返回。新链表是通过拼接给定的两个链表的所有节点组成的。

题目的要求很简单,是将两个有序链表合并为一个新的有序链表即可,需要注意的点是原来的两个链表已经是升序的了。
其实这道题相信我们在最开始学习链表的时候已经做过了,但是那个时候肯定用的是循环的方式,并且方法也非常简单粗暴,即双指针遍历比较即可。
在这里我们将尝试一种全新的思路。
算法原理
首先,我们相信我们的递归函数一定能完成我们的需求,其次,我们来找一下最小子问题是什么?
因为两个链表本身就是有序的,那么我们连接的时候,是否可以将链表的看作只有一个节点,直接假设后面的节点不在了,反正都是有序的,我们管它干什么呢?也就是我们可以以这样的视角看待问题:

黑->红->蓝->绿。比较我们只需要比较第一个或者是第二个链表的下一个节点就可以了。如果走到了空,那么我们直接返回即可。整体的算法思想就是谁连接了,谁奉献出自己的下一个节点即可。
对于函数头的设计,因为我们涉及到了两个链表,所以链表的头肯定是需要的,对于函数体的设计,无非就是比较节点的值,因为这道题的教学意义不是很大,相对比较简单,所以大家直接上手即可。
算法编写
cpp
class Solution {
public:
ListNode* mergeTwoLists(ListNode* list1, ListNode* list2)
{
// 结束条件
if (list1 == nullptr)
return list2;
if (list2 == nullptr)
return list1;
// 判断大小合并
if (list1->val <= list2->val)
{
list1->next = mergeTwoLists(list1->next, list2);
return list1;
}
else
{
list2->next = mergeTwoLists(list1, list2->next);
return list2;
}
}
};
反转链表
题目解析
给你单链表的头节点
head
,请你反转链表,并返回反转后的链表。

相信大家在学习链表的时候也是做过这道题的,而且那个时候的一般做法是用三个指针,一个一个的改变指向。
但是当我们使用递归的思想来看这道题,我们就会发现,反转链表?不就是把每个节点,重复式的改变指向吗?像这种循环能做的事儿,咱递归也能做,所以这道题顺理成章的,可以使用递归算法。
算法原理

对于这道题来说,咱们在反转上上面需要做的事儿只有两个,即改变两个指针的朝向,并且因为我们反转之后需要返回新的头节点,所以我们得把这个5节点带出来,这是在本题唯一需要注意的。
对于函数头的设计,没啥好设计的,就一个链表的头指针就可以了,对于函数体的设计,出口肯定是下一个为空,或头节点就是空就返回。
那么还需要注意的一个点是我们如何将1的next节点置为空,我们每改变一个指向后,就应该把head的next置为空,这样我们就能以一种类似于传递的方式,使最后的1的next指向空,这道题传递了两个值,一个是5这个节点,一个是空指针,传递的还是有点意思的。
算法编写
cpp
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;
}
};
感谢阅读!