数组
说起链表结构的话,不得不提数组结构
先看一下数组的本质是什么
- 通过一段
连续分配
的内存空间 元素在内存中呈线性连续排列
- 每个元素都可以存储
字节相同
的数据(或地址) - 对象的话 存的是
内存地址
此时地址指向的数据被称为卫星数据
数组这样规定有它的好处:
1 - 访问索引比较方便
内存地址 | 0xA000 | 0xA008 | 0xA010 | 0xA018 | 0xA020 | 0xA028 | 0xA030 | 0xA038 |
---|---|---|---|---|---|---|---|---|
序数 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | ... |
其他数据 | 数据 1 | 数据 2 | 数据 3 | 数据 4 | 数据 5 | 数据 6 | ...预留区域 | 其他数据 |
- arr = 0xA000
- a[3] = 0xA000 + 3 * 8(字节) = 0xA018
可以看到 因为数组是连续排序的元素,所以可以很方便的通过下标/索引去访问到目标元素,时间复杂度仅为O(1)
2 - 追加元素也比较方便
因为数组在申请内存空间时, 会多预留一部分空闲区域,以便之后的各种操作。 所以在预留区域追加元素十分简单 时间复杂度也是O(1)
有个问题就是, 如果预留区域小于追加元素的个数怎么办? 其实也不难,根据简单的分配算法可以大致理解为
数组的元素为n
, 预留区域是N
, 其中 N = 2n
。 如果要追加n+1
个元素,对于前n
个元素,每个追加的也是常数时间,仅在预留区域内正常追加即可。那么最后一个超过预留区域的元素的追加怎么办?为了避免溢出内存,会将整个2n
个元素拷贝到新的内存空间,再对最后一个元素在新的内存预留空间进行追加,那么时间复杂度也就是:2n + (n+1) / n
也就是 3n+1 / n
大致等于 3
所以它也是常数级的操作 比较简单
但对于数组的其他操作就较复杂
因为其他操作 都会影响到数组中的所有元素
JS数组操作 | 时间复杂度 |
---|---|
push | O(1) |
pop(删除最后一个元素并返回删除的元素) | O(1) |
shift(删除第一个元素并返回删除的元素) | O(n) |
unshift (在数组的首位添加一个元素) | O(n) |
slice(截取) | O(n) |
splice(从数组中添加/删除项目,然后返回被删除的项目) | O(n) |
concat | O(n) |
find | O(n) |
filter | O(n) |
every | O(n) |
所以在这个时候 就体现了链表的优势。
链表
- 通过一段
离散
的内存空间 元素在内存中通过指针
的形式进行连接 也呈线性排列
- 因为有
指针
在内存中可以离散的分配
-
头指针
指向链表的第一个节点
链表和数组的一个最大不同就是链表在内存空间中的表现是离散的(当然也可以连续) 所以 链表的某些操作就不会影响到链表中的其他元素 从而可以减少操作的复杂度
单向链表
除了上述的链表特点外 单向链表还有自身的特点:
- 因为是单向 所有只有
一个方向
-
链表节点包括
key
和next 指针
key
可以是数据或卫星数据的地址next
指针指向下一个链表节点 -
尾指针
指向null
实现单向链表:
// 链表节点
class ListNode {
constructor(key) {
this.key = key;
this.next = null;
}
}
// 链表
class LinkedList {
constructor() {
this.head = null;
this._length = 0;
}
static createNode(key) {
return new ListNode(key);
}
// 首部插入 O(n)
insert(node) {
if (this.head) {
node.next = this.head;
}
this.head = node;
this._length++;
}
// 查找 O(n)
find(key) {
let p = this.head; // 指针
while (p && p.key !== key) {
// 终止条件是 p===null || p.key=key
// 没找到 || 找到了
p = p.next;
}
return p;
}
// 删除 del O(n)
delete(node) {
// a. 删除根节点
if (node === this.head) {
this.head = node.next;
this._length--;
return;
}
// 取node的上个节点
let prevNode = this.head;
while (prevNode && prevNode.next !== node) {
prevNode = prevNode.next;
}
// b. 删除尾节点
if (!node.next) {
prevNode.next = null;
}
// c. 删除中间节点
if (node.next) {
prevNode.next = node.next;
}
this._length--;
}
}
let node = LinkedList.createNode(1);
let m = new LinkedList();
m.insert(LinkedList.createNode(1));
m.insert(LinkedList.createNode(2));
m.insert(LinkedList.createNode(3));
m.insert(LinkedList.createNode(4));
m.insert(LinkedList.createNode(5));
m.insert(LinkedList.createNode(6));
// console.log(m);
// console.log(m.find(2))
代码多读读就懂了 很简单
知道了单向链表之后 双向链表也就自然懂了
双向链表
由上面单向链表的代码可以发现,单向链表只有一个next
指针,因而在获取上一个链表元素
的时候略麻烦一些 双向链表因为是双向
的 所以没有此问题
除了上述的链表特点外 双向链表还有自身的特点:
- 因为是双向 所以有
两个方向
-
链表节点包括
key
,prev 指针
和next 指针
key
可以是数据或卫星数据的地址prev
指针指向上一个链表节点next
指针指向下一个链表节点 -
头指针
的prev指向null -
尾指针
的next指向null
代码和单向链表挺相似:
class ListNode {
constructor() {
this.key = key;
this.prev = null;
this.next = null;
}
}
class LinkedNode {
constructor() {
this.head = null;
this._length = 0;
}
static createNode(key) {
return new ListNode(key);
}
insert(node) {
if (this.head) {
this.head.prev = node;
node.next = this.head;
}
this.head = node;
this._length++;
}
find(key) {
// 查找和单向链表相同
let p = this.head;
while (p && p.key !== key) {
p = p.next;
}
return p;
}
delete(node) {
// 根节点
if (node === this.head) {
node.next.prev = null;
this.head = node.next;
}
// 非第一个
if (node.prev) {
node.prev.next = node.next;
}
// 非最后一个
if (node.next) {
node.next.prev = node.prev;
}
}
}
到此为止, 分别简单实现了单向和双向链表。 所以问题来了,链表这么香吗? 这么香为什么不用链表而非要用数组呢? 其实 链表在时间复杂度上也有损耗, 有的甚至比数组还复杂:
链表操作 | 时间复杂度 |
---|---|
追加(append/push) | O(1) <数组是 o(1),无变化> |
索引(arr[idx]) | O(n) <数组是 o(1),链表的索引变慢了> |
插入(insert) | O(1) <数组是 o(n),链表的插入变快了> |
删除(delete/remove) | O(1) <数组是 o(n),链表的删除变快了> |
合并(concat/merge) | O(1) <数组是 o(m+n),链表的合并变快了> |
可以看到 链表的 索引访问
反而变慢了, 因为链表去访问元素的时, 需要从head
开始,不停的循环, 直到找到目标元素。而数组则只需在内存中根据索引
和内存地址
很容易的找到目标元素。 但是链表的其他操作,比如插入
删除
合并
等 确实比数组快多了, 因为在数组中这些操作都影响到整体数组,复杂度是O(n)
。 而链表有指针这个东西,所以只需关注目标元素即可,所以复杂度是 O(1)
。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。