前言
既然这个系列的文章叫数据结构与算法之XXX,除了数据结构的总结与介绍之外,当然还有算法的一些总结介绍了,当然限于篇幅与自身能力,太过高深的算法就不介绍了,毕竟路西菲尔也是一个在不断学习的小白,哈哈:)
什么是算法呢?
一个常见的回答是,完成一个任务的一系列步骤。
说到链表相关的算法,我相信大名鼎鼎的反转链表一定没有人未曾听过!这句话有点绕哈,简单的讲就是说,链表反转常常会考到嘛,其实这就是考察你对链表间结点关系的理解,当然了经典与高难度的算法还很多,这篇文章,只是介绍这一个算法,我觉得算法这东西,贵精不贵多,许多算法本质思想都是相同的,一通百通便是如此。
这段时间的博客撰写,还是让我成长了很多,当然,不是技术方面的,更多的是对技术的一些思考和习惯的养成,同时,希望自己能坚持下去,不积跬步,无以至千里;不积小流,无以成江海,我觉得坚持还是很重要和很有意义的!
链表反转
题目:定义一个函数,输入一个链表的头结点,反转该链表并输出反转后链表的头结点。
对于一个算法问题来说,其实理解这个问题是其二,设计出正确的,有效率的算法是其二!现实是,对于很多问题我们可能能够设计出正确的算法,但是那个算法的效率高低就难以保证了,所以,学习还是有必要的:)
这个题目出自剑指Offer,但是实际的链表反转算法出自哪里,我确实没有查到。
要完成这个算法的解答,我们应当先要明确这个所谓的“任务”是什么?
将题目用图片描述出来:
从上图,可以看到,此处所描述的链表,是不具备虚拟头结点的,这道题所需要做的任务是将一个链表从头部到尾部,所有的指针反向!
输入:1->2->3->NULL
输出:3->2->1->NULL
这道题的解法很多,有比较简单的外空间法,也有效率更高的迭代法,这个方法常常被人称作双指针或三指针解法,当然,还有利用了递归天然性质的递归解法,对于这三种算法来讲,需要考虑的东西也不尽相同。
外空间法
外空间法就是借助容器存放链表的所有元素,再通过遍历容器或者使用其本身带有的api来实现链表的反转,我个人认为这是一种取巧的做法,并非是理解了链表的性质而实现的解法,这种方法本身不太可取,但是如果在面试中,不太其他的解法,这不妨也是一个答案,代码如下:
//引入外部空间
public Node reverseListByTemp(Node head) {
//空链表与只有一个元素的链表直接返回
if (head == null || head.next == null) {
return head;
}
Stack<Node> stack = new Stack<>();
//遍历链表,将全部元素压入栈中
while (head != null) {
stack.push(head);
head = head.next;
}
//出栈
head = stack.pop();
while (!stack.isEmpty()){
head.next = stack.pop();
}
return head;
}
因为是进行反转,所以我采用了天生具有“反转”性质的栈,元素全部压栈后再出栈,便实现了反转。
迭代法
相对于前文的简单的外空间法,迭代法属于原地反转,有着更高的效率,也有更高的难度但是所谓的迭代是什么?
迭代是重复反馈过程的活动,其目的通常是为了逼近所需目标或结果。每一次对过程的重复称为一次“迭代”,而每一次迭代得到的结果会作为下一次迭代的初始值。
该方法,有着两个关键的指针,分别指向当前结点和前驱结点,过程中最重要的操作是将前驱结点替换为当前结点的后继结点,实现单个结点的反转,迭代法便是不断地重复该过程。
这里我们将指向当前结点地指针称为cur,指向其前驱结点的指针称为pre,迭代法分解到对每一个结点的操作实质上是对每一个结点指向其前驱,即cur的前驱和后继被交换,迭代就是对每一个结点进行这样的操作,此处我们从head开始迭代,下图中绿色箭头表示pre指针,紫色箭头表示cur指针,黑色箭头表示后继:
head的前驱为空,所以我们将head指向其前驱:
不断迭代,将node2(从头至尾的第二个结点)进行指向前驱操作,相应的此时cur指向node2,而pre指向head:
同理,对node3进行指向前驱操作:
从上图我们可以看到,其实迭代过程已经结束了,可能有同学会问,node3所指向的Null为什么不进行迭代,实际上,这里也可以进行迭代,但是是没有意义的,从逻辑上讲,,Null是空, 它并没有指向node3的能力,我们不能也不需要一个空head(与虚拟结点不同,虚拟节点只是值为空);从代码来讲,每一个结点只有一个next的指针,它已经指向了前驱,所以此处的后继便断开了,聪明的小伙伴已经想到了这个问题,既然只有一个指针,我们指向了前驱,那我们怎么来寻找后继结点呢,答案很简单,我们引入第三个指针提前保存后继结点,代码如下:
//迭代法反转链表
public Node reverseListIteration(Node head) {
//指针:前驱结点
Node pre = null;
//指针:当前结点
Node cur = head;
//指针:后继结点
Node succ = null;
while (cur != null) {
//获得后继结点
succ = cur.next;
//反转当前结点,使当前结点next指向前驱结点
cur.next = pre;
//当前被操作结点向后移动
cur = succ;
//相应的,前驱结点也要向后移动一位,保持其定义不变
pre = cur;
}
return pre;
}
代码中的每一步都进行了注释,结合上图食用更佳,其实反转单链表的迭代法使很容易理解的,当然这里仍然有一些引申的问题:
迭代法必须从头部开始迭代吗?对于单链表来说是一定的,因为单链表的方向一定,你只能通过从头到尾的方向得到其后继,而无法通过从尾到头的方向得到其前驱,但是对于双向链表如果你能拿到最后一个结点,利用相似的思想,同样使能够反转链表的!
为什么java中也要称为指针呢?我个人是觉得c语言中指针这个概念更容易理解,在java中我们更多地称为引用,但是学习数据结构和算法,这种指向我觉得使用指针这个词不容易混淆。
递归法
既然链表有着递归性质,那么相关算法,使用递归实现,有助于我们对递归的理解!
先谈谈,递归在算法中是非常重要的一种思想,常常听到的说法是:递归就是自我调用,
比如
public void me(){
me();
}
这一段代码便是递归,当时,这一定会堆栈溢出,为什么?因为递归是有深度的,这涉及到内存模式的知识,这里先不作深究,当递归深度过大,便会抛出异常:StackOverflow,多么经典的一个词汇:)
对于递归可以理解为分而治之!
从微观的角度来看,也就是将一个问题做了拆分,将一个复杂地问题拆分为简单的问题,这便是分而治之中的“分”,那这个问题到底该拆为多小呢?这便需要递归的终止条件。
从宏观的角度来看,计算机的递归是期待完成了某一个任务,这便是分而治之中的“治”。
我们将一个复杂问题拆为有限个简单的问题并解决最后得到结果的过程,便可以称为递归。
当然,这是我个人对递归的理解,每个人都会有自己的理解,我觉得只要最后得到问题的答案是好的,便无所谓工具与方法,究其根本,计算机的出现,编程语言的出现都只是为了解决问题。
回到正题,我们用递归的思想来实现链表反转,我们可以想想如何来分治,链表是一个个结点“串联”起来的线性表,它们的连接是通过指针来连接的,所以,我们只要分到对每一个结点的操作,再进行反转,最后将结果一层层返回,便能得到想要的结果。
public Node reverseList(Node node) {
}
这个函数的作用便是反转链表,这是其宏观语意,接下来我们要做的便是分而治之,先将链表分解为单个结点:
返回第一个操作后的结果:
以此类推:
那么要如何做到这一点,首先,我们要考虑的是,递归终止条件,与返回值。
返回值是什么,可以认为是当前被操作的node,因为是反转链表,所以最后一层,也就是第一个返回的一定是反转后链表的head,如上图的node3,这也是所谓的终止条件,而递归中对结点的处理,此处我们可以对node或者node.next或者node.next.next等等进行处理,很明显的如果采用对node处理:
public Node reverseList(Node node) {
if (node==null){
return node;
}
return reverseList(node);
}
这是一个不会终止的递归方法,所以我们应当对node.next进行反转处理:
public Node reverseList(Node node) {
if (node.next==null){
return node;
}
return reverseList(node.next);
}
事实上,以上的过程可以看作分,我们还未对拿到的结果进行治理:
public Node reverseList(Node node) {
if (node.next==null){
return node;
}
Node h = reverseList(node.next);
//TODO 此处是治理的过程
return h;
}
我们要对结果进行治理,可以先分析一下过程,很明显的,当递归终止条件触发时,返回的结点应当是链表的尾结点,用上图来说就是node3,此时我们应当让node3的后继指向node3的前驱node2,这便是治理的过程,用代码实现是这样吗?
//TODO 此处是治理的过程
h.next = node;
这样不对!我们真正治理的是当前入参–node!对于单链表中的node,反转node的方法为
public Node reverseList(Node node) {
if (node.next==null){
return node;
}
Node h = reverseList(node.next);
//TODO 此处是治理的过程
node.next.next = node;
return h;
}
这一步最难以理解,举例说明,
1->2->3->4->5
治理结点4,因为递归调用的入参是node.next,
即
Node h = reverseList(3.next);
//TODO 此处是治理的过程
3.next.next = 3; 等价于 4.next = 3
return h;
此时便成功治理了结点4,需要注意的是递归调用传参!此处需要细细品味。
函数的返回值是新的头结点,而每一次治理,都会有一个结点反转其指向。
但是上面的代码还有缺陷,4.next = 3,但是3.next = 4是没有变化的,,所以,令node.next = null,令链表的结点仍然为单向:
public Node reverseList(Node node) {
if (node.next==null){
return node;
}
Node h = reverseList(node.next);
//TODO 此处是治理的过程
node.next.next = node;
node.next = null;
return h;
}
最后,空链表或者单结点链表直接返回:
public Node reverseList(Node node) {
if (node == null || node.next==null){
return node;
}
Node h = reverseList(node.next);
//TODO 此处是治理的过程
node.next.next = node;
node.next = null;
return h;
}
最后,将该例的整个过程写下来加深体会:
1->2->3->4->5
reverseList(4)
因为5.next = null
return 5
治理:5<->4 去掉5<-4
return:5->4
reverseList(3)
治理:4<->3 去掉4<-3
retrun 5->4->3
reverseList(2)
治理:3<->2 去掉3<-2
retrun 5->4->3->2
reverseList(1)
治理:2<->1 去掉2<-1
retrun 5->4->3->2->1
最后的最后,在网上找了一张递归法相关动图,感谢“王尼玛”大佬的动图^_^
总结
这篇博客写下来,太不容易了,自己的理解与将其记录下来的难度不可相提并论。
其次,看到这里的小伙伴,不知道你们对上述几个算法有所理解有所感悟吗?
最后,还是希望在2020年有更多的收获,好好学习,天天向上!
如有不正,敬请指出,我是路西菲尔,期待与你一同成长!
----来自路西菲尔的博客https://blog.csdn.net/csdn_1364491554/article/details/103893814,转载请注明出处!