来源商业新知网,原标题:题型篇 | 数据结构与算法之链表系列
写在前边
如果你和小鹿一样,刚开始对链表的操作代码实现很懵的话,不妨按照小鹿经过一个月的时间对链表相关操作以及题型的整理总结,由浅入深进行适当的练习,我相信,当你真正的练习完这些题目,不但会让你放下对链表心理上的困惑,而且对你学习其他数据结构有很大的信心和帮助!
由于文章篇幅共计 8000 字,公众号为缩减版本,完整版请查看文章底部链接。
学习建议
小鹿不建议你一口气去看完这篇所有的题目和练习,给自己制定一个小计划,我当初整理该题目的时候,每天都计划认真整理一到题目,把每道题分析透,这样才能达到最好的吸收效果。
学习路径
本篇分为三个阶段,基础练习阶段、进阶练习阶段、加强练习阶段。
1、基础练习阶段
首先进行第一个阶段之前,你已经对链表的基础知识能够熟练掌握,但是对于没有动手写过链表代码,那么你从第一阶段最基础的开始进行。确保每一个基础点要亲自动手用自己熟悉的语言写出来,虽然本篇中基本都是 javascript 代码实现的,但是算法思路是一成不变的,如果遇到困难可以自行百度或谷歌,也可以下方给我进行留言。
2、进阶练习阶段
如果你对上述的链表基本代码已经完全熟练掌握了,那么恭喜你可以进行下一个阶段,进阶阶段,这一阶段增加的难度就是链表的操作是对于实际问题来解决的,所以非常锻炼你对问题的分析能力和解决能力,也考验你对代码的全面性、鲁棒性。这一阶段非常的重要,下面的每道题我都做出了详细的分析。
3、加强练习阶段
如果上述的进阶练习阶段的题型你都了如指掌了,那么不妨我们实战一下,LeetCode 汇聚了很多面试的题型,所以我在上边整理了几个经典的题目,你可以尝试着解答它们,相关题目的代码以及解题思路我都整理好了。这一阶段的题目小鹿会在后期不断的更新,这些题目你能够完全掌握,链表对你来说小菜一碟了。
阶段一:链表 基础练习
自己首先尝试着一个个攻破下方的链表中最基础的操作,相关代码我也整理好了(先自己尝试着去解决哦)
1、单链表的插入、删除、查找操作(☛ 题目解析 )
2、循环链表的插入、删除、查找操作(☛ 题目解析 )
3、双向链表的插入、删除、查找操作(☛ 题目解析 )
阶段二:链表进阶练习
1、单链表从尾到头打印
题目:输入一个链表的头结点,从尾到头反过来打印出每个节点的值。
1.1 问题分析与解决
▉ 问题分析
1、看到题目第一想到的就是反转链表在打印输出,一种反转链表的方法,但是这种方法改变了原有的链表结构。
※缺点:使得链表的结构发生改变了。如果不改变链表结构应该怎么解决?
2、从问题中可以得出,我们想要从尾到头打印链表,正常情况下是从头到尾打印的,我们就会想到最后的数据先打印,开始的数据最后打印,有种“先进后出”的特点,我们就能想到用“栈”这种结构,用栈来实现。
※缺点:代码不够简洁。
※优点:鲁棒性好(在不确定的情况下,程序仍然可以正确的执行)。
3、提到栈这种数据结构,我们就会想到“递归”的实现就是用栈这种数据结构实现的。既然栈能实现,那么递归也能实现。
※缺点:如果链表很长,递归深度很深,导致堆栈溢出。
※优点:代码简洁、明了。
▉ 算法思路
通过上边的问题分析,得出以下几种解决方法:
● 反转链表法
● 栈实现
● 递归实现
1、反转链表实现
从尾到头输出链表的内容,一般的思路就是将链表反转过来,然后从头到尾输出数据。
2、栈实现
从头到尾遍历单链表,将数据存储按照顺序存储到栈中。然后遍历整个栈,打印输出数据。
3、递归实现
可以通过递归的方式来实现单链表从尾到头依次输出,递归过程涉及到“递”和“归”,反转链表输出数据,正式利用了循环“递”的过程,所以数据先从头部输出,那么递归采用的是“归”的过程来输出内容,输出当前结点先要输出当前节点的下一节点。
▉ 测试用例
在写代码之前,要想好测试用例才能写出健全、鲁棒性的代码,也是为了考虑到边界情况,往往也是整个程序最致命的地方,如果考虑不全面,就会出现 bug,导致程序崩溃。
1、输入空链表;
2、输入的链表只有一个结点;
3、输入的链表有多个结点。
▉ 代码实现
1、代码实现: 反转链表法
1//定义结点
2class Node{ 3 constructor(data){ 4 this.data = data; 5 this.next = null; 6 } 7} 8//定义链表 9class LinkedList{10 constructor(){11 this.head = new Node('head');12 }1314 // 功能:单链表反转15 // 步骤:16 // 1、定义三个指针(pre=null/next/current)17 // 2、判断链表是否可反转(头节点是否为空、是否有第二个结点)18 // 3、尾指针指向第一个结点的 next19 // 4、尾指针向前移动20 // 5、当前指针(current)向后移动21 // 6、将 head 指向单转好的结点22 reverseList = () =>{23 //声明三个指针24 let current = this.head; //当前指针指向头节点25 let pre = null;//尾指针26 let next;//指向当前指针的下一个指针2728 //判断单链表是否符合反转的条件(一个结点以上)?29 if(this.head == null || this.head.next == null) return -1;3031 //开始反转32 while(current !== null){33 next = current.next;34 current.next = pre;35 pre = current;36 current = next;37 }38 this.head = pre;39 }4041 //输出结点42 print = () =>{43 let currentNode = this.head44 //如果结点不为空45 while(currentNode !== null){46 console.log(currentNode.data)47 currentNode = currentNode.next;48 }49 }50}
2、代码实现:循环栈
1//方法三:栈实现
2const tailToHeadOutput = (currentNode)=>{ 3 let stack = []; 4 //遍历链表,将数据入栈 5 while(currentNode !== null){ 6 stack.push(currentNode.data); 7 currentNode = currentNode.next; 8 } 9 //遍历栈,数据出栈10 while(stack.length !== 0){11 console.log(stack.pop());12 }13}
3、 代码实现:递归
1// 步骤:
2// 1、判断是否为空链表 3// 2、终止条件(下一结点为空) 4// 3、递归打印下一结点信息 5const tailToHeadOutput = (head)=>{ 6 // 判断是否空链表 7 if(head !== null){ 8 // 判断下一结点是否为空 9 if(head.next !== null){10 // 下一结点不为空,先输出下一结点11 tailToHeadOutput(head.next)12 }13 console.log(head.data);14 }else{15 console.log("空链表");16 }17}
▉ 性能分析
1、反转链表实现
● 时间复杂度:O(n)。需要遍历整个链表,时间复杂度为 O(n)。
● 空间复杂度:O(1)。不需要额外的栈存储空间,空间复杂度为 O(1)。
2、循环栈实现
● 时间复杂度:O(n)。需要遍历整个链表,时间复杂度为 O(n)。
● 空间复杂度:O(n)。需要额外的栈存储空间,空间复杂度为 O(n)。
3、递归实现
● 时间复杂度:O(n)。需要遍历整个链表,时间复杂度为 O(n)。
● 空间复杂度:O(n)。需要额外的栈存储空间,空间复杂度为 O(n)。
2.2 小结
▉ 考察内容
1、对单链表的基本操作。
2、代码的鲁棒性。
3、循环、递归、栈的灵活运用。
▉ 扩展思考: 循环和递归
※ 适用条件: 如果需要进行多次计算相同的问题,将采用循环或递归的方式。
※ 递归的优点: 代码简洁。
※ 递归的缺点:
1、堆栈溢出:函数调用自身,函数的临时变量是压栈的操作,当函数执行完,栈才清空,如果递归的规模过大,在函数内部一直执行函数的自身调用,临时变量一直压栈,系统栈或虚拟机栈内存小,导致堆栈溢出。
2、重复计算:递归会出现很多的重复计算问题,重复计算对程序的性能有很大影响,导致消耗时间成指数增长,但是可以通过散列表的方式解决。
3、高空间复杂度:递归的每次函数调用都要涉及到在内存开辟空间,压栈、出栈等操作,即耗时又耗费空间,导致递归的效率并不如循环的效率。
扩展:
1、递归—栈:递归的本质是栈,通常用栈循环解决的问题适合于递归。
2、递归-动态规划:动态规划解决问题经常用递归的思路分析问题。关于递归重复计算问题,我们通常使用自下而上的解决思路(动态规划)来解决递归重复计算的问题。
▉ 注意事项
1、涉及到循环解决的问题,可以想一想能不能使用递归来解决。
2、用递归解决一定要铭记递归的缺点带来的性能问题。
3、递归解决的问题,能不能用动态规划来解决,使得性能更高。
4、用到栈这种数据结构,想一想递归是否可以实现呢。
除此题外,还有几个有关链表的经典例题,在阅读原文中查看,也对每一题进行了以上详细的解析。
注意: 由于文章篇幅过大,剩余的几个重点题目分析都在 Github ,点击“阅读原文”查看剩余题目详细解析。
阶段三: LeetCode 加强练习阶段
如果你对基本的链表操作已经掌握,想进一步提高对链表熟练度的操作,可以练习一下 LeetCode 题目。每道题我都做了详细的解析,如:问题分析、算法思路、代码实现、考查内容等,有关链表的相关题目会不断更新......
1、环形链表 I (☛ 题目解析 )
2、环形链表 II (☛ 题目解析 )
3、合并K个排序链表 (☛ 题目解析 )
链表总结
做了大量有关链表的题型之后,对链表的操作做一个总结和复盘,对链表有一个整体的把握和重新的认识。
1、结构上
存储链表的内存空间是不连续的,所有需要使用指针将这些零碎内存空间连接起来,导致需要通过指针来进行操作,这也是为什么链表中大多数都是关于指针的操作的原因。
链表在结构上有两个特殊的地方就是链表头和链表尾,很多操作都要对链表头和链表尾进行特殊处理,所以我们可以借助哨兵思想(在链表头添加一个哨兵),这样带头的链表可以简化问题的解决。
2、操作上
递归:链表中的很多操作都是可以用递归来进行解决的,因为链表的每个结点都有着相同的结构,再加上解决的问题可以分解为子问题进行解决。所以在链表中递归编程技巧还是非常常用的。如:从尾到头打印链表、合并两个有序链表、反转链表等。
双指针:链表中大部分都是进行指针操作,链表属于线性表结构(形如一条线的结构),很多问题可以使用双指针来解决,也是非常常用到的。如:查找倒数第K 结点、求链表的中间结点等。
3、性能上
链表正是因为存储空间不连续,对 CPU 缓存不友好,随时访问只能从头遍历链表,时间复杂度为 O(n),但是链表的这种结构也有个好处就是。可以动态的申请内存空间,不需要提前申请。
指针的存储是需要额外的内存空间的,如果存储的数据远大于存储指针的内存空间,可以进行忽略。