若是你和小鹿同样,刚开始对链表的操做代码实现很懵的话,不妨按照小鹿通过一个月的时间对链表相关操做以及题型的整理总结,由浅入深进行适当的练习,我相信,当你真正的练习完这些题目,不但会让你放下对链表心理上的困惑,并且对你学习其余数据结构有很大的信心和帮助!javascript
小鹿不建议你一口气去看完这篇全部的题目和练习,给本身制定一个小计划,我当初整理该题目的时候,天天都计划认真整理一到题目,把每道题分析透,这样才能达到最好的吸取效果。java
本篇分为三个阶段,基础练习阶段、进阶练习阶段、增强练习阶段。node
1)基础练习阶段git
首先进行第一个阶段以前,你已经对链表的基础知识可以熟练掌握,可是对于没有动手写过链表代码,那么你从第一阶段最基础的开始进行。确保每个基础点要亲自动手用本身熟悉的语言写出来,虽然本篇中基本都是 javascript 代码实现的,可是算法思路是一成不变的,若是遇到困难能够自行百度或谷歌,也能够下方给我进行留言。github
2)进阶练习阶段面试
若是你对上述的链表基本代码已经彻底熟练掌握了,那么恭喜你能够进行下一个阶段,进阶阶段,这一阶段增长的难度就是链表的操做是对于实际问题来解决的,因此很是锻炼你对问题的分析能力和解决能力,也考验你对代码的全面性、鲁棒性。这一阶段很是的重要,下面的每道题我都作出了详细的分析。算法
3)增强练习阶段编程
若是上述的进阶练习阶段的题型你都了如指掌了,那么不妨咱们实战一下,LeetCode 汇聚了不少面试的题型,因此我在上边整理了几个经典的题目,你能够尝试着解答它们,相关题目的代码以及解题思路我都整理好了。这一阶段的题目小鹿会在后期不断的更新,这些题目你可以彻底掌握,链表对你来讲小菜一碟了。缓存
本身首相尝试着一个个攻破下方的链表中最基础的操做,相关代码我也整理好了(先本身尝试着去解决哦)。数据结构
题目:输入一个链表的头结点,从尾到头反过来打印出每一个节点的值。
1)看到题目第一想到的就是反转链表在打印输出,一种反转链表的方法,可是这种方法改变了原有的链表结构。
缺点:使得链表的结构发生改变了。若是不改变链表结构应该怎么解决?
2)从问题中能够得出,咱们想要从尾到头打印链表,正常状况下是从头至尾打印的,咱们就会想到最后的数据先打印,开始的数据最后打印,有种“先进后出”的特色,咱们就能想到用“栈”这种结构,用栈来实现。
缺点:代码不够简洁。
优势:鲁棒性好(在不肯定的状况下,程序仍然能够正确的执行)。
3)提到栈这种数据结构,咱们就会想到“递归”的实现就是用栈这种数据结构实现的。既然栈能实现,那么递归也能实现。
缺点:若是链表很长,递归深度很深,致使堆栈溢出。
优势:代码简洁、明了。
得出如下几种实现方式:
1)反转链表实现:
从尾到头输出链表的内容,通常的思路就是将链表反转过来,而后从头至尾输出数据。
2)栈实现
从头至尾遍历单链表,将数据存储按照顺序存储到栈中。而后遍历整个栈,打印输出数据。
2)递归实现:
能够经过递归的方式来实现单链表从尾到头依次输出,递归过程涉及到“递”和“归”,反转链表输出数据,正式利用了循环“递”的过程,因此数据先从头部输出,那么递归采用的是“归”的过程来输出内容,输出当前结点先要输出当前节点的下一节点。
在写代码以前,要想好测试用例才能写出健全、鲁棒性的代码,也是为了考虑到边界状况,每每也是整个程序最致命的地方,若是考虑不全面,就会出现 bug,致使程序崩溃。
测试用例:
1)输入空链表;
2)输入的链表只有一个结点;
3)输入的链表有多个结点。
//定义结点
class Node{
constructor(data){
this.data = data;
this.next = null;
}
}
//定义链表
class LinkedList{
constructor(){
this.head = new Node('head');
}
// 功能:单链表反转
// 步骤:
// 一、定义三个指针(pre=null/next/current)
// 二、判断链表是否可反转(头节点是否为空、是否有第二个结点)
// 三、尾指针指向第一个结点的 next
// 四、尾指针向前移动
// 五、当前指针(current)向后移动
// 六、将 head 指向单转好的结点
reverseList = () =>{
//声明三个指针
let current = this.head; //当前指针指向头节点
let pre = null;//尾指针
let next;//指向当前指针的下一个指针
//判断单链表是否符合反转的条件(一个结点以上)?
if(this.head == null || this.head.next == null) return -1;
//开始反转
while(current !== null){
next = current.next;
current.next = pre;
pre = current;
current = next;
}
this.head = pre;
}
//输出结点
print = () =>{
let currentNode = this.head
//若是结点不为空
while(currentNode !== null){
console.log(currentNode.data)
currentNode = currentNode.next;
}
}
}
复制代码
//方法三:栈实现
const tailToHeadOutput = (currentNode)=>{
let stack = [];
//遍历链表,将数据入栈
while(currentNode !== null){
stack.push(currentNode.data);
currentNode = currentNode.next;
}
//遍历栈,数据出栈
while(stack.length !== 0){
console.log(stack.pop());
}
}
复制代码
// 步骤:
// 一、判断是否为空链表
// 二、终止条件(下一结点为空)
// 三、递归打印下一结点信息
const tailToHeadOutput = (head)=>{
// 判断是否空链表
if(head !== null){
// 判断下一结点是否为空
if(head.next !== null){
// 下一结点不为空,先输出下一结点
tailToHeadOutput(head.next)
}
console.log(head.data);
}else{
console.log("空链表");
}
}
复制代码
反转链表实现:
循环栈实现:
递归实现:
1)对单链表的基本操做。
2)代码的鲁棒性。
3)循环、递归、栈的灵活运用。
适用条件:若是须要进行屡次计算相同的问题,将采用循环或递归的方式。
递归的优势:代码简洁。
递归的缺点:
1)堆栈溢出:函数调用自身,函数的临时变量是压栈的操做,当函数执行完,栈才清空,若是递归的规模过大,在函数内部一直执行函数的自身调用,临时变量一直压栈,系统栈或虚拟机栈内存小,致使堆栈溢出。
2)重复计算:递归会出现不少的重复计算问题,重复计算对程序的性能有很大影响,致使消耗时间成指数增加,可是能够经过散列表的方式解决。
3)高空间复杂度:递归的每次函数调用都要涉及到在内存开辟空间,压栈、出栈等操做,即耗时又耗费空间,致使递归的效率并不如循环的效率。
扩展:
1)递归—栈:递归的本质是栈,一般用栈循环解决的问题适合于递归。
2)递归-动态规划:动态规划解决问题常常用递归的思路分析问题。关于递归重复计算问题,咱们一般使用自下而上的解决思路(动态规划)来解决递归重复计算的问题。
1)涉及到循环解决的问题,能够想想能不能使用递归来解决。
2)用递归解决必定要铭记递归的缺点带来的性能问题。
3)递归解决的问题,能不能用动态规划来解决,使得性能更高。
4)用到栈这种数据结构,想想递归是否能够实现呢。
题目:在 O(1)的时间复杂度内删除链表节点。
给定单向链表的头指针和一个节点指针,定义一个函数在 O(1)时间内删除该节点。
1)想必看到单链表删除节点的题,第一想到的就是删除链表结点须要以 O(n)时间复杂度遍历链表找到该结点的前结点,而后以 O(1)时间复杂度进行删除,时间复杂度为O(n)。而题目中的确实总体要求时间复杂度为 O(1)。
2)怎么才能达到 O(1)的时间复杂度删除链表?若是不遍历不就能够了?若是直接删除的时间复杂度为 O(1),前提是咱们须要知道前结点才能作到。咱们就会想怎么作到不用遍历数据才能获取到前结点呢?并且必须保证时间复杂度为 O(1)。
3)可是必须让本身多想一步就是若是删除的结点是尾结点怎么操做,若是删除的链表结点只有一个结点,便是尾结点又是头结点怎么办?
得出如下几种实现方式:
1)这一有种技巧很难想到,就是我把当前结点的数据与下一结点的数据进行交换,删除下一结点不就能够达到时间复杂度为O(1)了吗。并且咱们知道当前结点就是下一结点的前节点,perfect。
2)针对以上两种特殊状况,若是是尾结点,没有下一结点,咱们就从头遍历链表删除节点;若是便是尾结点又是头结点,那么删除头结点,并置于 null。
2)在多个结点链表中删除中间结点;
3)在多个链表中删除头结点;
4)在多个链表总删除尾结点;
5)在只有一个结点链表中删除惟一结点;
// 定义结点
class Node{
constructor(data){
this.data = data;
this.next = null;
}
}
// 定义链表
class LinkedList{
constructor(){
this.head = new Node('head');
}
//根据 value 查找结点
findByValue = (value) =>{
let currentNode = this.head;
while(currentNode !== null && currentNode.data !== value){
currentNode = currentNode.next;
}
//判断该结点是否找到
console.log(currentNode)
return currentNode === null ? -1 : currentNode;
}
//插入元素(指定元素向后插入)
insert = (value,element) =>{
//先查找该元素
let currentNode = this.findByValue(element);
//若是没有找到
if(currentNode == -1){
console.log("未找到插入位置!")
return;
}
let newNode = new Node(value);
newNode.next = currentNode.next;
currentNode.next = newNode;
}
//遍历全部结点
print = () =>{
let currentNode = this.head
//若是结点不为空
while(currentNode !== null){
console.log(currentNode.data)
currentNode = currentNode.next;
}
}
// 删除节点(核心代码)
deleteNode = node =>{
// 判断当前查找的结点是否为 null
if(node == null) return -1;
// 一、查找删除的结点
let d_node = this.findByValue(parseInt(node.data))
// 二、判断该结点是否为尾结点
if(d_node.next == null){
// 从新遍历链表
let p = null;
let current = this.head;
while(current.next !== null){
p = current;
current = current.next;
}
// 尾结点置为 null
p.next = null;
}else{
// 三、将删除结点的值与下一结点交换
d_node.data = d_node.next.data;
// 四、删除下一结点
d_node.next = d_node.next.next;
}
}
}
// 测试
sortedList1 = new LinkedList()
sortedList1.insert(1, 'head')
sortedList1.insert(2, 1)
sortedList1.insert(3, 2)
sortedList1.insert(4, 3)
sortedList1.print();
console.log('------------------------------删除指定结点----------------------------')
let dnode = new Node('1')
sortedList1.deleteNode(dnode)
sortedList1.print();
复制代码
时间复杂度:O(1)。通过上述的方法,删除一个链表的结点,除了删除一个链表的尾结点以外,其余删除节点的时间复杂度为 O(1),获取删除的结点的前一结点,时间复杂度为 O(1),删除节点的时间复杂度为 O(1)。只有删除尾结点才须要遍历整个链表,但大部分删除节点是 O(1)的。使用分析时间复杂度的一个方法摊还分析,将删除节点的时间复杂度平均分到其余大部分状况下,因此平均时间复杂度为 O(1)。
空间复杂度:O(1)。不须要额外的内存空间。
1)对单链表的删除基本操做。
2)对问题的有创新思惟的解决能力:能不能将复杂问题的根源用另外一种思惟去优化。
3)问题考虑的全面性:考虑到问题出现的各类特殊状况,以及边界问题。
题目:输入一个链表,输出该链表中倒数第 K 个节点。为符合大多数人的习惯,从 1 开始计数,即链表的尾结点是倒数第一个节点。
1)看到这个题的第一想法就是从链表头遍历到链表尾部,而后尾部倒数 k 个数,由于是单链表,因此倒数并不能实现,想法行不通。
2)那咱们只能将思路转移到头结点开始,怎么才能从头结点开始遍历到倒数第 k 个结点呢?大致咱们能够得出至少须要遍历两次链表。
3)上述能不能再优化呢?遍历一次链表就能够完成查找?
得出如下几种实现方式:
前提条件:
1)不要忘记判断单链表是否为环型结构
两次遍历法:
1)有一个规律就是链表的长度 n 减去 k 加 1 就是倒数第 k 个数据。因此须要遍历链表获得链表的长度,而后再遍历两次找到链表的倒数第 k 个数据。整个过程须要遍历两遍链表。
一次遍历法:
1)那咱们就用到双指针,第一个指针指向第一个结点,第二个指针指向 k - 1 个结点,同时向前移动,直到第二个节点指向尾结点位置,第一个节点就指向了倒数第 k 结点。遍历一遍链表就完成查找。
1)k 的取值范围(0 < k < n);输入不在范围内的数据。
2)输入空链表。
3)查找倒数第 k 结点为头结点/尾结点。
// 定义结点
class Node{
constructor(data){
this.data = data;
this.next = null;
}
}
// 定义链表
class LinkedList{
constructor(){
this.head = new Node('head');
}
//根据 value 查找结点
findByValue = (value) =>{
let currentNode = this.head;
while(currentNode !== null && currentNode.data !== value){
currentNode = currentNode.next;
}
//判断该结点是否找到
console.log(currentNode)
return currentNode === null ? -1 : currentNode;
}
//插入元素(指定元素向后插入)
insert = (value,element) =>{
//先查找该元素
let currentNode = this.findByValue(element);
//若是没有找到
if(currentNode == -1){
console.log("未找到插入位置!")
return;
}
let newNode = new Node(value);
newNode.next = currentNode.next;
currentNode.next = newNode;
}
//遍历全部结点
print = () =>{
let currentNode = this.head
//若是结点不为空
while(currentNode !== null){
console.log(currentNode.data)
currentNode = currentNode.next;
}
}
// 检测单链表是否为环
checkCircle = ()=>{
// 判断是否为空链表
if(this.head == null) return fast;
// 定义快慢指针
let fast = this.head.next;
let low = this.head;
//进行循环判断(当前 fast 结点/fast 移动两步后的结点是否为 null)
while(fast !== null && fast.next !== null){
// fast 指针向前移动两步
fast = fast.next.next;
// low 指针向前移动一步
low = low.next;
// 若是为环,总有一天会相遇
if(fast === low) return true;
}
return false;
}
// 查找倒数第 k 结点
findByIndexFromEnd = k =>{
//判断 k 是否大于0
if(k < 1) return 'k 的大小不在搜索范围内';
// 检测是否为环
if(this.checkCircle()) return false;
// 定义两个指针进行遍历
let current = this.head;
let fast = current;
let low = current;
let pos = 0;
for(let i = 1;i <= k - 1;i++){
if(fast.next !== null){
fast = fast.next;
}else{
// k 的大小超出链表大小的范围
return 'k 的大小超出链表的范围';
}
}
// low 和 fast 指针同时移动
while(fast.next !== null){
fast = fast.next;
low = low.next;
}
// 返回倒数第 k 结点
return low;
}
}
// 测试
const list = new LinkedList();
list.insert('1','head');
// list.insert('2','1');
// list.insert('3','2');
// list.insert('4','3');
// list.insert('5','4');
// list.insert('6','5');
list.print();
console.log('-------------------查找倒数第 k 结点----------------')
console.log(list.findByIndexFromEnd(8));
复制代码
两次遍历法:
一次遍历法:
1)对单链表的基本操做。
2)代码的全面性、鲁棒性。
1)当咱们用一个指针不能解决时,想想两个指针可否解决?
1)求中间结点
2)求倒数第 k 个结点
3)检测环的存在
题目:定义一个函数,输入一个链表的头结点,反转该链表并输出反转链表的头结点。
反转链表的咱们第一可以想到的方法就是最经常使用的方法,声明三个指针,把头结点变为尾结点,而后下一结点拼接到尾结点的头部,一次类推。说白了就是就是直接将链表指针反转就能够实现反转链表。
1)定义三个指针,分别为 Pnext、pre、current,current 存储当前结点, pre 指向反转好的结点的头结点,Pnext 存储下一结点信息。
2)判断当前结点是否能够反转(是否为空链表或链表大于 1 个结点)?
步骤:
1)Pnext 指针存储下一结点 。
2)当前结点的 next 结点是否为 null (为 null 的话当前结点就是最后的一个结点),若是为 null,将当前节点赋值为 head 头指针(断裂处)。
3)将 pre 指针指向的结点赋值当前节点 current 的下一结点 next。
4)而后让 pre 指针指向当前节点 current。
5)current 继续遍历, 当前节点指向 current 指向 Pnext。
递归法(重点分析):
1)先肯定终止条件:当下一结点为 null 时,返回当前节点;
2)判断当前的链表是否为 null;
3)递归找到尾结点,将其存储为头结点。
4)此时递归的层次是第二层递归,因此要设置为头结点的下一结点就是当前第二层结点,而且将第二节点的下一结点设置为 bull。
1)链表是空链表。
2)当前链表的长度小于等于 1。
3)输入长度大于 1 的链表。
var reverseList = function(head) {
// 判断当前链表是否为空链表
if(head == null) return null;
// 定义三个指针
let [current,prev,next] = [head,null,null];
while(current !== null){
//一、存储下一结点
next = current.next;
if(next == null){
head = current;
}
current.next = prev;
prev = current;
current = next;
}
return head;
};
复制代码
const reverseList = (head)=>{
//若是链表为空或者链表中只有一个元素
if(head == null || head.next == null){
return head;
}else{
//先反转后面的链表,走到链表的末端结点
let newhead = reverseList(head.next);
//再将当前节点设置为后面节点的后续节点
head.next.next = head;
head.next = null;
return newhead;
}
}
复制代码
- 时间复杂度:O(n)。只需遍历整个链表就能够完成反转,时间复杂度为 O(n)。
- 空间复杂度:O(1)。只须要常量级的空间,空间复杂度为 O(1)。
1)对单链表的基本操做。
2)对指针操做顺序的逻辑性考察。
3)考察思惟的全面性以及代码的鲁棒性。
1)边界条件。
2)写代码以前想好测试用例,写完代码一一验证测试用例的正确性。
题目:输入两个递增排序的链表,合并这两个链表并使新链表中的节点仍然是递增排序的。
1)合并两个链表,常常犯的错误就是没有弄清除指针的指向,致使链表合并的时候断裂以及代码全面性考虑的不全,也就是代码的鲁棒性存在问题。
2)递归。每次都要比较两个结点大小,是否可使用递归来解决呢?
通常解决法:
1)合并两个链表,首先须要两个指针,分别指向两个链表。
2)比较两个指针指向结点元素的大小,小的结点添加到新链表,而后指针向后移动继续比较。
3)直到其中一个链表没有结点了,另外一个链表存在结点,将剩余的结点加入到新链表的尾部,完成合并。
递归法:(知足递归的三个条件)
比较当前结点大小先比较下一结点的大小。
1)结点之间的比较能够分的子问题为每一个节点的比较。
2)终止条件:其中一个链表结点为 null。
3)子问题和总问题具备相同的解决思路。
1)输入两个空链表。
2)其中一个链表为空链表。
3)输入两个完整的链表。
// 功能:两个有序链表的合并
// 步骤:
// 一、判断两个链表是否为 null,并将链表赋予临时变量
// 二、声明合并链表,经过 currentNode 指向当前结点
// 三、两个链表比较大小,数值小的添加到合并链表中,合并链表进行指针移动
// 四、将链表剩余数据添加到合并链表后边
const mergeSortList = (listA,listB) =>{
//判断链表是否为空
if(listA === null) return false;
if(listB === null) return false;
let a = listA;
let b = listB;
//声明合并链表,经过 currentNode 指向当前结点
let resultList = undefined
//两个链表比较大小,数值小的添加到合并链表中,合并链表进行指针移动
if (a.data < b.data) {
resultList = a
a = a.next
} else {
resultList = b
b = b.next
}
let currentNode = resultList;
while (a !== null && b !== null) {
if (a.data < b.data) {
currentNode.next = a
a = a.next
} else {
currentNode.next = b
b = b.next
}
currentNode = currentNode.next
}
// 将链表剩余数据添加到合并链表后边
if(a !== null){
currentNode.next = a;
}else{
currentNode.next = b;
}
//返回合并链表
return resultList;
}
复制代码
var mergeTwoLists = function(l1, l2) {
let result = null;
//终止条件
if(l1 == null) return l2;
if(l2 == null) return l1;
//判断数值大小递归
if(l1.val < l2.val){
result = l1;
result.next = mergeTwoLists(l1.next,l2);
}else{
result = l2;
result.next = mergeTwoLists(l2.next,l1);
}
//返回结果
return result;
};
复制代码
//定义结点
class Node{
constructor(data){
this.data = data;
this.next = null;
}
}
//定义链表
class LinkedList{
constructor(){
this.head = new Node('head');
}
//根据 value 查找结点
findByValue = (value) =>{
let currentNode = this.head;
while(currentNode !== null && currentNode.data !== value){
currentNode = currentNode.next;
}
//判断该结点是否找到
console.log(currentNode)
return currentNode === null ? -1 : currentNode;
}
//插入元素(指定元素向后插入)
insert = (value,element) =>{
//先查找该元素
let currentNode = this.findByValue(element);
//若是没有找到
if(currentNode == -1){
console.log("未找到插入位置!")
return;
}
let newNode = new Node(value);
newNode.next = currentNode.next;
currentNode.next = newNode;
}
//遍历全部结点
print = () =>{
let currentNode = this.head
//若是结点不为空
while(currentNode !== null){
console.log(currentNode.data)
currentNode = currentNode.next;
}
}
}
// 合并两个链表
var mergeSortList = function(l1, l2) {
let result = null;
//终止条件
if(l1 == null) return l2;
if(l2 == null) return l1;
//判断数值大小递归
if(l1.val < l2.val){
result = l1;
result.next = mergeSortList(l1.next,l2);
}else{
result = l2;
result.next = mergeSortList(l2.next,l1);
}
//返回结果
return result;
};
// 测试
sortedList1 = new LinkedList()
sortedList1.insert(9, 'head')
sortedList1.insert(8, 'head')
sortedList1.insert(7, 'head')
sortedList1.insert(6, 'head')
sortedList1.print();
sortedList2 = new LinkedList()
sortedList2.insert(21, 'head')
sortedList2.insert(20, 'head')
sortedList2.insert(19, 'head')
sortedList2.insert(18, 'head')
sortedList2.print();
console.log('----------------合并两个有序的链表----------------')
let resultList = mergeSortList(sortedList1.head.next,sortedList2.head.next)
while (resultList !== null) {
console.log(resultList.date);
resultList = resultList.next;
}
复制代码
1)对链表的基本操做。
2)写代码考虑问题的全面性和鲁棒性。
1)递归实现,注意递归解决问题的三个缺点。
若是你对基本的链表操做已经掌握,想进一步提升对链表熟练度的操做,能够练习一下 LeetCode 题目。每道题我都作了详细的解析,如:问题分析、算法思路、代码实现、考查内容等,有关链表的相关题目会不断更新......
作了大量有关链表的题型以后,对链表的操做作一个总结和复盘,对链表有一个总体的把握和从新的认识。
1)存储链表的内存空间是不连续的,全部须要使用指针将这些零碎内存空间链接起来,致使须要经过指针来进行操做,这也是为何链表中大多数都是关于指针的操做的缘由。
2)链表在结构上有两个特殊的地方就是链表头和链表尾,不少操做都要对链表头和链表尾进行特殊处理,因此咱们能够借助哨兵思想(在链表头添加一个哨兵),这样带头的链表能够简化问题的解决。
1)递归:链表中的不少操做都是能够用递归来进行解决的,由于链表的每一个结点都有着相同的结构,再加上解决的问题能够分解为子问题进行解决。因此在链表中递归编程技巧仍是很是经常使用的。如:从尾到头打印链表、合并两个有序链表、反转链表等。
2)双指针:链表中大部分都是进行指针操做,链表属于线性表结构(形如一条线的结构),不少问题可使用双指针来解决,也是很是经常使用到的。如:查找倒数第K 结点、求链表的中间结点等。
1)链表正是由于存储空间不连续,对 CPU 缓存不友好,随时访问只能从头遍历链表,时间复杂度为 O(n),可是链表的这种结构也有个好处就是。能够动态的申请内存空间,不须要提早申请。
2)指针的存储是须要额外的内存空间的,若是存储的数据远大于存储指针的内存空间,能够进行忽略。
做者:小鹿 座右铭:追求平淡不平凡,一辈子追求作一个不甘平凡的码农! 本文首发于 Github ,转载请说明出处。 我的公众号:一个不甘平凡的码农。