- 链表理论基础
- 203.移除链表元素、707.设计链表、206.反转链表
建议: 本题最关键是要理解 虚拟头结点的使用技巧,这个对链表题目很重要。
链表基本知识
什么是链表?
- 链表是一种通过指针串联在一起的线性结构,每一个节点由两部分组成,一个是数据域一个是指针域(存放指向下一个节点的指针),最后一个节点的指针域指向null(空指针的意思)。
- 链接的入口节点称为链表的头结点也就是head。
链表特点?
- NodeList是一中类数组对象,用于保存一组有序的节点
- 可以通过方括号来访问NodeList的值,他有item()方法与length属性。
- 他并不是Array的实列,没有数组对象的方法。
为什么会用到链表?
数组不总是最佳的数据结构,因为,在很多编程语言中,数组的长度都是固定的,如果数组已被数据填满,再要加入新的元素是非常困难的。而且,对于数组的删除和添加操作,通常需要将数组中的其他元素向前或者向后平移,这些操作也是十分繁琐的。
然而,JS中数组却不存在上述问题,主要是因为他们被实现了成了对象,但是与其他语言相比(比如C或Java),那么它的效率会低很多。
203.移除链表元素
思路:
- 首先要判断头结点是不是空的,如果是空的,编译就会报错。同时头结点指向的下一个节点也不能为空。
while(head != null)
- ⭐️想要删除target,那么就要对它的上一个节点(current)做一些操作
- 原链表中,current的next➡️target,但是现在要删除target,那么只需要将current.next ➡️target.next 。 就相当于:current.next➡️current.next.next
如果想要删除的节点刚好是头结点,头结点找不到其上一个节点进行操作,应该怎么办?
需要单独写一段逻辑来处理移除头结点的情况。这里就涉及如下链表操作的两种方式:
原链表操作
直接使用原来的链表来进行删除操作。
- 使用原来的链表来进行移除。
- 其实只要将头结点向后移动一位就可以,这样就从链表中移除了一个头结点。
- 要记得将原来的头结点从内存中删除。
虚拟头结点
设置一个虚拟头结点在进行删除操作。
JavaScript中可以使用:const ret = new ListNode(0, head);
设置一个虚拟头结点,这样原链表的所有节点就都可以按照统一的方式进行移除了。
最后呢在题目中,return 头结点的时候,别忘了 return dummyNode->next;
, 这才是新的头结点
// 设置一个虚拟头结点来解决
var removeElements = function(head, val) {
const ret = new ListNode(0, head); // 虚拟头结点
let current = ret;
while(current.next) { // 当下一个节点存在的时候
if(current.next.val == val) {
current.next = current.next.next;
continue;
}
current = current.next;
}
return ret.next;
// return current.next
};
问:当链表为[7, 7, 7, 7],val=7时,该怎么考虑?
答:(当程序运行到continue; 语句时,会终止当前的这一次循环,进入到下一次循环中。)注意代码中第八行中的
continue;
就是以防cur.next.next.val的值仍然为val的情况,所以还需要检查一遍。
有个问题昂,为什么这里一定要返回 ret.next 呢?
答:ret是创建出来的虚拟头结点,直接返回 ret.next 就能打印整条链表。但是现在需要对链表进行操作,所以必须设置一个current,用current = ret,从头开始对一个个节点进行检查和操作。
创建虚拟头结点的目的是为了方便对头结点进行操作 不用虚拟头结点也可以 但是就需要对头结点进行特殊处理了
707.设计链表
操作链表注意点:
在遍历链表的时候,要定义一个指针(定义临时指针 cur = dummyHead)来遍历,而不是直接操作。因为操作完链表之后,要返回头结点。如果上来就操作头结点,那么头结点的值都改变了。
看到题目时,首先让我懵的就是这一块,属实是基础不够扎实了。
使用函数表达式创建了函数,类名建议首字母大写,结合后面的var obj = new MyLinkedList()
,知道了MyLinkedList是一个类,obj就是这个类的实例。
补充知识点:
- 首先JS连class关键字都没有,怎么办呢?用函数代替,JS中最不缺的就是函数,函数不仅能够执行普通功能,还能当class使用。
- 当做类用的函数本身也是一个函数,而且他就是默认的构造函数。
- constructor() 方法是类的构造函数(默认方法),用于传递参数,返回实例对象,通过 new 命令生成对象实例时,自动调用该方法。如果没有显示定义, 类内部会自动给我们创建一个constructor()
但是这个类没有构造函数,而且看题目,本身也没有用作构造函数。
做题思路
我们设计链表包含两个类,一个是 LinkNode 类用来表示节点,另一个事 MyLinkedList 类提供插入节点、删除节点等一些操作。
在链表类中实现这些功能:
- get(index):获取链表中第 index 个节点的值。如果索引无效,则返回-1。
- addAtHead(val):在链表的第一个元素之前添加一个值为 val 的节点。插入后,新节点将成为链表的第一个节点。
- addAtTail(val):将值为 val 的节点追加到链表的最后一个元素。
- addAtIndex(index,val):在链表中的第 index 个节点之前添加值为 val 的节点。如果 index 等于链表的长度,则该节点将附加到链表的末尾。如果 index 大于链表长度,则不会插入节点。如果index小于0,则在头部插入节点。
- deleteAtIndex(index):如果索引 index 有效,则删除链表中的第 index 个节点
控制台中的输入输出是什么意思
输入["MyLinkedList","addAtHead","addAtTail","addAtIndex","get","deleteAtIndex","get"] [[],[1],[3],[1,2],[1],[1],[1]]
输出
[null,null,null,null,2,null,3]
由于get函数才会返回值,所以输出中,这个数组中的2和3是返回的链表节点的值。其他的null都是依次对应了"MyLinkedList","addAtHead","addAtTail","addAtIndex","deleteAtIndex"这些方法的操作,且没有值返回。
Carl老师的解法解读:
原文链接:代码随想录 (programmercarl.com)
创建实例对象linkedList时,需要知道链表长度,有没有首尾。所以,MyLinkedList 类中要有这三个属性。
getNode
方法中,首先考虑索引的有效性。之后,需要创建虚拟头结点。
- 注意:
index >= this._size
- 注意:
index-- >= 0
class LinkNode {
constructor(val, next) {
this.val = val;
this.next = next;
}
}
// 单链表 储存头尾节点 和 节点数量
var MyLinkedList = function() {
this._size = 0;
this._tail = null;
this._head = null;
};
MyLinkedList.prototype.getNode = function(index) {
if(index < 0 || index >= this._size) return null;
// 创建虚拟头节点
let cur = new LinkNode(0, this._head);
// 0 -> head
while(index-- >= 0) {
cur = cur.next;
}
return cur;
};
get
用到了上面定义的getNode方法,这个方法可以获取到索引为index的节点。
- 注意:返回的是
this.getNode(index).val
,而不是getNode(index).val
MyLinkedList.prototype.get = function(index) {
if(index < 0 || index >= this._size) return -1;
// 获取当前节点
return this.getNode(index).val;
};
addAtHead和 addAtTail
在插入头结点时,就显示了使用虚拟头结点带来的便利了。因为在头部有一个虚拟节点,只需要在这两个节点之前插入一个节点,就能完成 addAtHead 的操作。
在定义新的node时,默认node下一个指向是null
注意点: 首先需要将新的节点指向头结点,然后再让虚拟节点指向新的节点
MyLinkedList.prototype.addAtHead = function(val) {
// 创建新节点,值为val,指针指向了头部
const node = new LinkNode(val, this._head);
// this._head指针始终要指向头结点 所以头结点变更后它也需要变更指向的位置
this._head = node;
this._size++; // 链表长度发生变化
if(!this._tail) {
this._tail = node;
}
};
MyLinkedList.prototype.addAtTail = function(val) {
const node = new LinkNode(val, null);
this._size++;
if(this._tail) {
this._tail.next = node;
this._tail = node;
return;
}
this._tail = node;
this._head = node;
};
尾部插入时,当前遍历节点current一定要指向尾部节点(也就是说,current.next != null时就要一直遍历下去),然后next指向new Node
addAtIndex
一定要保证第n个节点是current.next 而不是current
MyLinkedList.prototype.addAtIndex = function(index, val) {
if(index > this._size) return;
if(index <= 0) {
this.addAtHead(val);
return;
}
if(index === this._size) {
this.addAtTail(val);
return;
}
// 获取目标节点的上一个的节点
const node = this.getNode(index - 1);
node.next = new LinkNode(val, node.next);
this._size++;
};
deleteAtIndex
MyLinkedList.prototype.deleteAtIndex = function(index) {
if(index < 0 || index >= this._size) return;
if(index === 0) {
this._head = this._head.next;
// 如果删除的这个节点同时是尾节点,要处理尾节点
if(index === this._size - 1){
this._tail = this._head
}
this._size--;
return;
}
// 获取目标节点的上一个的节点
const node = this.getNode(index - 1);
node.next = node.next.next;
// 处理尾节点
if(index === this._size - 1) {
this._tail = node;
}
this._size--;
};
力扣中别人的解法
// MyLinkedList类,提供了对链表进行操作的方法
var MyLinkedList = function() {
this.size = 0;
this.head = new LinkNode(0); // 虚拟头结点
};
// 再定义一个LinkNode类,包含两个属性: val 用来保存节点上的数据,next 用来保存指向下一个节点的链接
function LinkNode(val, next) {
this.val = (val === undefined ? 0 : val)
this.next = (next === undefined ? null : next)
}
get
首先要考虑 index 是否存在不合理的情况:index < 0 或者 index > 链表长度
MyLinkedList.prototype.get = function(index) {
// 判断索引是否有效
if (index < 0 || index >= this.size) {
return -1;
}
let cur = this.head; // 定义临时指针
for (let i = 0; i <= index; i++) {
cur = cur.next;
}
return cur.val;
};
addAtIndex
实现 addAtHead(val) 和 addAtTail(val) 时,可以借助 addAtIndex(index, val) 来实现。所以先优先写出addAtIndex(index, val)。
MyLinkedList.prototype.addAtIndex = function(index, val) {
if (index > this.size) {
return;
}
index = Math.max(0, index);
this.size++;
let pred = this.head;
for (let i = 0; i < index; i++) {
pred = pred.next;
}
let toAdd = new LinkNode(val);
toAdd.next = pred.next;
pred.next = toAdd;
};
addAtHead和 addAtTail
MyLinkedList.prototype.addAtHead = function(val) {
this.addAtIndex(0, val);
};
MyLinkedList.prototype.addAtTail = function(val) {
this.addAtIndex(this.size, val);
};
deleteAtIndex
MyLinkedList.prototype.deleteAtIndex = function(index) {
if (index < 0 || index >= this.size) {
return;
}
this.size--;
let pred = this.head;
for (let i = 0; i < index; i++) {
pred = pred.next;
}
pred.next = pred.next.next;
};
206.反转链表
可以用双指针解法、递归解法
双指针写法
定义两个指针,一个cur,一个pre。让cur指向head。
当cur指向null的时候,遍历就结束了
- while(cur) {
- temp = cur.next // 首先要把 cur.next 节点用tmp指针保存一下,也就是保存一下这个节点
- cur.next = pre // 要改变 cur.next 的指向了,将cur.next 指向pre
- pre = cur // pre 向前移动一格
- cur = temp }
- return pre 这个就是新链表的头结点了
var reverseList = function(head) {
if(!head || !head.next) return head;
let temp = null, pre = null, cur = head;
while(cur) {
temp = cur.next; // // 先保存当前节点的指针next
cur.next = pre;
pre = cur;
cur = temp;
}
// temp = cur = null;
return pre;
};
总结:代码中怎么链表是否为空,该怎么写?是 if(head.next = null) return null; ?
不不不,应该是
if(!head || !head.next) return head;
。如果链表为空,那么head就是null,那么!head
就是True了,就能对空指针进行后续操作了
递归写法
递归的终止条件为链表没元素或者只有一个元素。
var reverse = function(pre, head) {
if(!head) return pre;
const temp = head.next;
head.next = pre;
pre = head
return reverse(pre, temp);
}
var reverseList = function(head) {
return reverse(null, head);
};
// 递归2
var reverse = function(head) {
if(!head || !head.next) return head;
// 从后往前翻
const pre = reverse(head.next);
head.next = pre.next;
pre.next = head;
return head;
}
var reverseList = function(head) {
let cur = head;
while(cur && cur.next) {
cur = cur.next;
}
reverse(head);
return cur;
};