栈和队列的实现一般都要依赖于数组,大家完全可以把栈和队列都看作是“特别的数组”。
两者的区别在于,它们各自对数组的增删操作有着不一样的限制。
要想学会栈和队列就必须要了解数组的几种增删方法
数组中增加元素的三种方法
unshift
添加元素到数组的头部push
添加元素到数组的尾部splice
添加元素到数组的任何位置
数组中删除元素的三种方法
shift
删除数组头部的元素pop
删除数组尾部的元素splice
删除数组任意位置的元素
栈(Stack)
栈是 只用pop和 push 完成增删的“数组”
栈是一种后进先出(LIFO,Last In First Out)的数据结构。
就拿小卖部老板放冰糕和卖冰糕举例:
他有两个特征:
- 只允许从尾部添加元素
- 只允许从尾部取出元素
对应到数组的方法,刚好就是 push 和 pop。因此,我们可以认为在 JavaScript 中,栈就是限制只能用 push 来添加元素,同时只能用 pop 来移除元素的一种特殊的数组。
栈顶元素:所谓栈顶元素,从图上我们不难看出来,实际上它指的就是数组尾部的元素。
// 初始状态,栈空 const stack = [] // 入栈过程 stack.push('东北大板') stack.push('可爱多') stack.push('巧乐兹') // 栈顶元素 就是最后一个元素 stack[stack.length-1] // 出栈过程,栈不为空时才执行 while(stack.length) { // 单纯访问栈顶元素(不出栈) const top = stack[stack.length-1] console.log('现在取出的冰淇淋是', top) // 将栈顶元素出栈 stack.pop() } // 栈空 stack // []
队列(Queue)
队列是(Queue)只用 push 和 shift 完成增删的“数组”
队列是一种先进先出(FIFO,First In First Out)的数据结构。
就像派对做核酸
队列的特征就是:
- 只允许从尾部添加元素
- 只允许从头部移除元素
整个过程只涉及了数组的 push
和 shift
方法。
在栈元素出栈时,我们关心的是栈顶元素(数组的最后一个元素);队列元素出队时,我们关心的则是队头元素(数组的第一个元素)。
链表
链表和数组相似,它们都是有序的列表、都是线性结构(有且仅有一个前驱、有且仅有一个后继)。不同点在于,链表中,数据单位的名称叫做“结点”,而结点和结点的分布,在内存中可以是离散的。
数组的元素分布示意图:
数组在内存中最为关键的一个特征,就是它一般是对应一段位于自己上界和下界之间的、一段连续的内存空间。元素与元素之间,紧紧相连
而链表中的结点,则允许散落在内存空间的各个角落里。一个内容为1->2->3->4->5的链表,在内存中的形态可以是散乱如下的:
正是由于数组中的元素是连续的,每个元素的内存地址可以根据其索引距离数组头部的距离来计算出来。因此对数组来说,每一个元素都可以通过数组的索引下标直接定位。对链表来说,元素和元素之间似乎毫无内存上的瓜葛可言
在链表中,每一个结点的结构都包括了两部分的内容:数据域和指针域。JS 中的链表,是以嵌套的对象的形式来实现的:
{ // 数据域 val: 1, // 指针域,指向下一个结点 next: { val:2, next: ... } }
数据域存储的是当前结点所存储的数据值,而指针域则代表下一个结点(后继结点)的引用。 有了 next 指针来记录后继结点的引用,每一个结点至少都能知道自己后面的节点在哪,原本相互独立的结点之间就有了如下的联系:
关系进行一下简化:
要想访问链表中的任何一个元素,我们都得从起点结点开始,逐个访问 next,一直访问到目标结点为止。为了确保起点结点是可抵达的,我们有时还会设定一个 head 指针来专门指向链表的开始位置:
创建链表节点
function ListNode(val) { this.val = val; this.next = null; } // 创造一个节点,并且节点的值为1 const node = new ListNode(1)
在使用构造函数创建结点时,传入 val (数据域对应的值内容)、指定 next (下一个链表结点)即可:
const node1 = new ListNode(1) node1.next = new ListNode(2)
经过上一步操作就创建出了一个数据域值为1,next 结点数据域值为2的链表结点:
链表的结点间关系是通过 next 指针来维系的。因此,链表元素的添加和删除操作,本质上都是在围绕 next 指针做文章。
添加节点
直接在尾部添加结点相对比较简单,我们改变一个 next 指针就行。
这里记值为2的 node 结点为 node2(假设 node2 是现在的尾部结点),值为3的 node 结点为 node3。
假如我要把 node3 添加到 node2 所在链表的尾部,直接把 node2 的 next 指针指向 node3 即可:
插入节点
如何在两个结点间插入一个结点?
由于链表有时会有头结点,这时即便你是往链表头部增加结点,其本质也是“在头结点和第一个结点之间插入一个新结点”。
大学时的计算机网络基础课,任意两结点间插入一个新结点这种类型的增加操作算是一道必考题了
我们需要变更的是前驱结点和目标结点的 next 指针指向,过程如下图
代码这样写
// 如果目标结点本来不存在,那么记得手动创建 const node3 = new ListNode(3) // 把node3的 next 指针指向 node2 node3.next = node1.next // 把node1的 next 指针指向 node3 node1.next = node3
删除节点
如何把刚才添加的node3节点删除?
可以直接让它的前驱结点 node1 的 next 指针跳过它、指向 node3 的后继即可:
如此一来,node3 就成为了一个完全不可抵达的结点了,它会被 JS 的垃圾回收器自动回收掉。
这个过程用代码表述如下:
node1.next = node3.next
涉及链表删除操作的题目中,重点不是定位目标结点,而是定位目标结点的前驱结点。