1. 什么是链表
数据结构的存储⽅式只有两种:数组(顺序存储)和链表(链式存储)(摘自labuladong的算法小抄)。
我们在使用数组的过程中会发现如下问题:
- 数组的大小是固定的,并且数组需要连续的内存空间。
- 从数组的起点或中间插入或移除项的成本很高,因为需要移动元素。
链表解决了数组的上述问题,链表不需要连续的内存,链表中的元素可存储在内存的任何地方,如下图
并且链表添加或移除元素的时候不需要移动其它元素,只需要更改next 的指向即可。
下图是一个简单的链表
用JavaScript代码模拟如下:
const a = { val: 'a' };
const b = { val: 'b' };
const c = { val: 'c' };
const d = { val: 'd' };
a.next = b;
b.next = c;
c.next = d;
// 遍历链表
let p = a;
while (p) {
console.log(p.val);
p = p.next;
}
// 在c和d中间插入e
const e = { val: 'e' };
c.next = e;
e.next = d;
// 删除e
c.next = d;
1.1 链表特点:
- 多个元素组成的列表。
- 元素存储不连续,用next指针连在一起。
1.2 链表相对于数组的优点
- 不需要连续的内存
- 添加或移除元素的时候不需要移动其它元素,只需要更改 next 的指向即可。
1.3 链表缺点
链表需要指针,在数组中,我们可以直接访问任何位置的任何元素,而想要访问链表中间的一个元素,需要从表头开始迭代链表直到找到所需的元素。
1.4 根据数组生成链表
function createLinkList(arr: number[]): ILinkListNode {
const length = arr.length
if (length === 0) throw new Error('arr is empty')
let curNode: ILinkListNode = {
value: arr[length - 1]
}
if (length === 1) return curNode
for (let i = length - 2; i >= 0; i--) {
curNode = {
value: arr[i],
next: curNode
}
}
return curNode
}
const arr = [100, 200, 300, 400, 500]
const list = createLinkList(arr)
console.info('list:', list)
2. 模拟链表操作
javascript本身没有链表,所以我们用JavaScript来模拟一个链表。
一个链表包含如下常用方法
push(element):向链表尾部添加一个新元素。
insert(element, position):向链表的特定位置插入一个新元素。
getElementAt(index):返回链表中特定位置的元素。如果链表中不存在这样的元素,则返回 undefined。
remove(element):从链表中移除一个元素。
indexOf(element):返回元素在链表中的索引。如果链表中没有该元素则返回-1。
removeAt(position):从链表的特定位置移除一个元素。
isEmpty():如果链表中不包含任何元素,返回 true,如果链表长度大于 0则返回 false。
size():返回链表包含的元素个数,与数组的 length 属性类似。
toString():返回表示整个链表的字符串。由于列表项使用了 Node 类,就需要重写继承自 JavaScript 对象默认的 toString 方法,让其只输出元素的值。
class Node {
constructor(element) {
this.element = element
this.next = null
}
}
class LinkedList {
constructor () {
this.count = 0
this.head = null
}
// 向链表尾部添加新元素
push(element) {
const node = new Node(element)
let current
if (this.head === null) { // 链表为空,将元素赋给head
this.head = node
} else { // 遍历直到找到next为空的节点,将该元素next指向新加入的元素
current = this.head
while (current.next !== null) { // 获得最后一项
current = current.next
}
// 将其 next 赋为新元素,建立连接
current.next = node
}
this.count++
}
// 返回链表中特定位置的元素
getElementAt(index) {
if (index >= 0 && index <= this.count) {
let node = this.head
// 遍历找到index位置的元素
for (let i = 0; i < index && node !== null; i++) {
node = node.next
}
return node
}
return null
}
// 从链表中移除元素
removeAt (index) {
if (index >= 0 && index < this.count) {
let current = this.head
if (index === 0) { // 元素位置为0,即表头,直接将表头设置为第二个元素
this.head = current.next
} else { // 遍历找到该元素前一项,将前一项next指向该元素的next
const previous = this.getElementAt(index - 1)
current = previous.next
// 将 previous 与 current 的下一项链接起来:跳过 current,从而移除它
previous.next = current.next
}
this.count--
return current.element
}
return null
}
// 插入元素
insert(element, index) {
if (index >= 0 && index <= this.count) {
const node = new Node(element)
if (index === 0) { // 在0位置插入,将新节点next设置为表头head
const current = this.head
node.next = current
this.head = node
} else { // 找到插入位置前一项,将该元素next设置为前一项的next,前一项next设置为该元素
const previous = this.getElementAt(index - 1)
node.next = previous.next
previous.next = node
}
this.count++
return true
}
return false
}
// 返回某个链表位置
indexOf(element) {
let current = this.head
for (let i = 0; i < this.size() && current !== null; i++) {
if (element === current.element) {
return i
}
current = current.next
}
return -1
}
// 从链表中移除元素
remove(element) {
const index = this.indexOf(element)
return this.removeAt(index)
}
isEmpty() {
return this.size() === 0
}
size() {
return this.count
}
getHead() {
return this.head
}
clear() {
this.head = null
this.count = 0
}
}
const list = new LinkedList()
list.push(15)
list.push(10)
console.log(list.toString())
3. 链表操作的复杂度分析
- prepend: O(1)
- append: O(1)
- 查找(lookup): O(n)
- 插入(insert): O(1)
- 删除(delete): O(1)
4. 双向链表
双向链表和普通链表的区别在于,在链表中,一个节点只有链向下一个节点的链接。而在双向链表中,链接是双向的:一个链向下一个元素,另一个链向前一个元素,如下图所示:
双向链表代码实现:
class DoublyNode extends Node {
constructor(element, next, prev) {
super(element, next);
this.prev = prev;
}
}
class DoublyLinkedList extends LinkedList {
constructor() {
super();
this.tail = undefined;
}
push(element) {
const node = new DoublyNode(element);
if (this.head == null) {
this.head = node;
this.tail = node;
} else {
this.tail.next = node;
node.prev = this.tail;
this.tail = node;
}
this.count++;
}
// 向任意位置插入元素
insert(element, index) {
if (index >= 0 && index <= this.count) {
const node = new DoublyNode(element);
let current = this.head;
if (index === 0) { // 在头部插入
// 如果双向链表为空,head 和 tail 都指向这个新节点
if (this.head == null) {
this.head = node;
this.tail = node;
} else {
node.next = this.head;
current.prev = node;
this.head = node;
}
} else if (index === this.count) { // 尾部插入
current = this.tail;
current.next = node;
node.prev = current;
this.tail = node;
} else { // 中间插入
const previous = this.getElementAt(index - 1);
current = previous.next;
node.next = current;
previous.next = node;
current.prev = node;
node.prev = previous;
}
this.count++;
return true;
}
return false;
}
// 从任意位置移除元素
removeAt(index) {
if (index >= 0 && index < this.count) {
let current = this.head;
if (index === 0) { // 删除头部
this.head = current.next;
// 如果只有一项,更新tail
if (this.count === 1) {
this.tail = undefined;
} else {
this.head.prev = undefined;
}
} else if (index === this.count - 1) { // 删除尾部
current = this.tail;
this.tail = current.prev;
this.tail.next = undefined;
} else { // 删除中间
current = this.getElementAt(index);
const previous = current.prev;
// 将previous与current的下一项链接起来——跳过current
previous.next = current.next;
current.next.prev = previous;
}
this.count--;
return current.element;
}
return undefined;
}
}
起点插入新元素:
尾部插入元素:
中间插入元素:
5. 循环链表
循环链表可以像链表一样只有单向引用,也可以像双向链表一样有双向引用。循环链表和链表之间唯一的区别在于,最后一个元素指向下一个元素的指针(tail.next)不是引用undefined,而是指向第一个元素(head)
双向循环链表有指向 head 元素的 tail.next 和指向 tail 元素的 head.prev
循环链表代码实现
class CircularLinkedList extends LinkedList {
constructor() {
super();
}
push(element) {
const node = new Node(element);
let current;
if (this.head == null) {
this.head = node;
} else {
current = this.getElementAt(this.size() - 1);
current.next = node;
}
// 指向头部
node.next = this.head;
this.count++;
}
insert(element, index) {
if (index >= 0 && index <= this.count) {
const node = new Node(element);
let current = this.head;
if (index === 0) { // 头部插入
if (this.head == null) { // 链表为空
this.head = node;
node.next = this.head;
} else {
node.next = current;
current = this.getElementAt(this.size());
this.head = node;
current.next = this.head;
}
} else { // 其余位置插入
const previous = this.getElementAt(index - 1);
node.next = previous.next;
previous.next = node;
}
this.count++;
return true;
}
return false;
}
removeAt(index) {
if (index >= 0 && index < this.count) {
let current = this.head;
if (index === 0) {
if (this.size() === 1) { // 只有一个元素情况
this.head = undefined;
} else {
const removed = this.head;
current = this.getElementAt(this.size() - 1);
this.head = this.head.next;
current.next = this.head;
current = removed;
}
} else {
const previous = this.getElementAt(index - 1);
current = previous.next;
previous.next = current.next;
}
this.count--;
return current.element;
}
return undefined;
}
}
链表为空时头部插入
链表不为空时头部插入
6. 有序链表
有序链表是指保持元素有序的链表结构。除了使用排序算法之外,我们还可以将元素插入到正确的位置来保证链表的有序性
有序链表代码实现:
const Compare = {
LESS_THAN: -1,
BIGGER_THAN: 1,
EQUALS: 0
}
function defaultCompare(a, b) {
if (a === b) {
return Compare.EQUALS;
}
return a < b ? Compare.LESS_THAN : Compare.BIGGER_THAN;
}
class SortedLinkedList extends LinkedList {
constructor(compareFn = defaultCompare) {
super();
this.compareFn = compareFn;
}
push(element) {
if (this.isEmpty()) {
super.push(element);
} else {
const index = this.getIndexNextSortedElement(element);
super.insert(element, index);
}
}
insert(element, index = 0) {
if (this.isEmpty()) {
return super.insert(element, index === 0 ? index : 0);
}
const pos = this.getIndexNextSortedElement(element);
return super.insert(element, pos);
}
// 获取插入位置
getIndexNextSortedElement(element) {
let current = this.head;
let i = 0;
for (; i < this.size() && current; i++) {
const comp = this.compareFn(element, current.element);
// 找到插入位置
if (comp === Compare.LESS_THAN) {
return i;
}
current = current.next;
}
return i;
}
}
7. 使用双向链表创建栈结构
之所以使用双向链表而不是链表,是因为对栈来说,我们会向链表尾部添加元素,也会从链表尾部移除元素。DoublyLinkedList 类有列表最后一个元素的引用,无须迭代整个链表的元素就能获取它。双向链表可以直接获取头尾的元素,减少过程消耗,它的时间复杂度和原始的 Stack 实现相同,为 O(1)。
class StackLinkedList {
constructor() {
this.items = new DoublyLinkedList();
}
push(element) {
this.items.push(element);
}
pop() {
if (this.isEmpty()) {
return undefined;
}
const result = this.items.removeAt(this.size() - 1);
return result;
}
peek() {
if (this.isEmpty()) {
return undefined;
}
return this.items.getElementAt(this.size() - 1).element;
}
isEmpty() {
return this.items.isEmpty();
}
size() {
return this.items.size();
}
clear() {
this.items.clear();
}
toString() {
return this.items.toString();
}
}
8. 跳表
8.1 什么是跳表
跳表是链表的优化,跳表在工程中主要在redis中进行运用
跳表的优化思想:
- 升维、空间换时间
- 添加更多指针(头、尾、中...指针)
1. 原始链表
查询时间复杂度O(n)
2. 添加索引
3. 增加一级索引
可以看到,一级索引当问速度为2n,二级索引访问速度为4n,以此类推,增加(log2n级)多级索引
8.2 跳表的时间复杂度
n/2、n/4、 n/8、第k级索弓结点的个数就是n/(2^k)
假设索引有h级,最高级的索引有2个结点。n/(2^h)=2,从而求得 h=log2(n)-1
索引的高度: logn, 每层索引遍历的结点个数: 3,在跳表中查询任意数据的时间复杂度就是0(logn)
8.3 现实中跳表的形态
在现实中,由于元素的增加和删除,导致有些所以并不是完全非常工整的,经过多次改动之后,有些地方会多跨,有些地方会少跨,索引的增加和删除时间复杂度为logn
8.4 工程中的应用
LRU Cache - Linked list
Redis - Skip List
为啥 redis 使用跳表(skiplist)而不是使用 red-black?
9. 常见链表问题
1. 原型链
- 原型链的本质是链表。
- 原型链上的节点是各种原型对象,比如Function.prototype、Object.prototype.....
- 原型链通过
__proto__属性连接各种原型对象。
obj -> Object.prototype -> null
func -> Function.prototype -> Object.prototype -> null
arr -> Array.prototype -> Object.prototype -> null
原型链知识点:如果A沿着原型链能找到B.prototype, 那么 A instanceof B 为 true.
const obj = {}
obj instanceof Object // true
如果在A对象上没有找到x属性,那么会沿着原型链找x属性
2. instanceof原理,并用代码实现
- 知识点:如果A沿着原型链能找到B.prototype,那么A instanceof B为true。
- 解法: 遍历A的原型链,如果找到B.prototype,返回true,否则返回false
const instanceOf = (A, B) => {
let p = A;
while(p) {
if(p === B.prototype) {
return true;
}
p = p.__proto__
}
return false;
}
var foo = [],
F = function() {};
Object.prototype.a = 'value a';
Function.prototype.b = 'value b';
console.log(foo.a);
console.log(foo.b);
console.log(F.a);
console.log(F.b);
3. 使用链表指针获取JSON的节点值
const json = {
a: { b: { c: 1 } },
d: { e: 2 },
};
const path = ['a', 'b', 'c'];
let p = json;
path.forEach((k) => {
p=p[k];
});
- 链表里的元素存储不是连续的,之间通过 next 连接
- JavaScript中没有链表,但可以用Object模拟链表。
- 链表常用操作:修改next、遍历链表。
- JS 中的原型链也是一一个链表。
- 使用链表指针可以获取JSON的节点值。
10. leetcode常见题解
10.1 简单
1. 删除链表中的节点
难度:简单
题解:删除链表中的节点
2. 反转链表
难度:简单
3. 删除排序链表中的重复元素
难度:简单
题解:删除排序链表中的重复元素
4. 合并两个有序链表
难度:简单
10.2 中等
1. 两数相加
难度:中等
题解:两数相加
2. 两数相加II
难度:中等
题解:两数相加II(栈实现)
3. 反转链表 II
难度:中等
题解:反转链表II(迭代法)
4. 两两交换链表中的节点
难度:中等
5. 删除排序链表中的重复元素 II
难度:中等
题解:[删除排序链表中的重复元素 II
6. 旋转链表
难度:中等
题解:旋转链表(环解法)
7. 排序链表
难度:中等
10.3 困难
1. K 个一组翻转链表
难度:困难
题解:K 个一组翻转链表
难度:困难
2. 合并K个升序链表
难度:困难
题解:
11. 双指针问题
11.1 快慢指针
1. 链表中倒数第k个节点
难度:简单
2. 删除链表的倒数第 N 个结点
难度:中等
3. 链表的中间结点
难度:简单
4. 环形链表
难度:简单
题解:环形链表(快慢指针)
5. 环形链表II
难度:中等
题解:环形链表II(快慢指针)
11.2 左右指针
1. 回文链表
难度:简单
题解:回文链表(多解法)
2. 反转字符串
题解:反转字符串(左右指针)
11.3 其他双指针
1. 爬楼梯
难度:简单
题解:爬楼梯(双指针)
2. 移动0
难度:简单
题解:移动零(双指针)
3. 盛水最多的容器
难度:中等
题解:盛水最多的容器(双指针)
4. (高频老题)三数之和
难度:中等
题解:三数之和(排序+双指针)
5. 比较版本号
难度:中等
具体题解参考: leetcode刷题之路
持续更新ing...