JavaScript 基础数据结构
计算机相关专业的同学,在大学里一定会有《数据结构》这门课程,其中有几种比较常见的数据结构:链表、栈、队列、集合、树。本文将对它们逐一进行讲解。
链表
链表是一种链式数据结构,链上的每个节点包含两种信息:节点本身的数据和指向下一个节点的指针。当要移动或删除元素时,只需要修改相应元素上的指针就可以了。对链表元素的操作要比对数组元素的操作效率更高。下面是链表数据结构的示意图:
链表和传统的数组都是线性的数据结构,存储的都是一个序列的数据,但也有很多区别,如下表:
比较维度 | 数组 | 链表 |
---|---|---|
内存分配 | 静态内存分配,编译时分配且连续 | 动态内存分配,运行时分配且不连续 |
元素获取 | 通过Index获取,速度较快 | 通过遍历顺序访问,速度较慢 |
添加删除元素 | 因为内存位置连续且固定,速度较慢 | 因为内存分配灵活,只有一个开销步骤,速度更快 |
空间结构 | 可以是一维或者多维数组 | 可以是单向、双向或者循环链表 |
要实现链表数据结构,关键在于保存head元素(即链表的头元素)以及每一个元素的next指针,有这两部分我们就可以很方便地遍历链表从而操作所有的元素。可以把链表想象成一条锁链,锁链中的每一个节点都是相互连接的,我们只要找到锁链的头,整条锁链就都可以找到了。
首先,链表中存放的是一个个的节点,节点必须包含两个属性:当前节点的值和指向下一节点的指针。那么,这里就需要一个节点类:
// 方式一
const Node = function(element) {
this.element = element;
this.next = null;
};
// 方式二
class Node {
constructor(element) {
this.element = element;
this.next = null;
}
}
一般情况下,链表会有以下几种方法:
// 在链表尾部添加节点
append(element) {}
// 在链表的指定位置插入节点
insert(position, element) {}
// 删除链表中指定位置的元素,并返回这个元素的值
removeAt(position) {}
// 删除链表中对应的元素
remove(element) {}
// 在链表中查找给定元素的索引
indexOf(element) {}
// 返回链表中索引所对应的元素
getElementAt(position) {}
// 判断链表是否为空
isEmpty() {}
// 返回链表的长度
size() {}
// 返回链表的头元素
getHead() {}
// 清空链表
clear() {}
获取指定位置的元素——getElementAt
这里先实现该方法,因为我们后面会内部多次调用。
getElementAt(position) {
// 非法位置
if (position < 0 || position >= this.length) return null;
// 从头节点开始向后遍历
let currentNode = this.head;
for (let index = 0; index < position; index++) {
currentNode = currentNode.next;
}
return currentNode;
}
和所有的有序数据集合一样,链表的默认索引是从0
开始,而0
位在链表中对应的又是头节点。因此,只要知道了头节点,就能找到该链表中任意索引位置的节点信息。
有了getElementAt
方法后,我们就能很方便地对链表进行增删操作
向尾部添加新元素——append
append(element) {
const newNode = new Node(element);
// 当前链表为空, 将链表头部指向新节点
if (this.head === null) this.head = newNode;
else {
// 找到尾节点并将其next指向待添加节点
this.getElementAt(this.length - 1).next = newNode;
}
// 更新链表长度
this.length++;
}
在指定位置添加新元素——insert
insert(position, element) {
// 非法位置, 插入失败
if (position < 0 || position > this.length) return false;
else {
const newNode = new Node(element);
if (position === 0) {
// 头部位置
newNode.next = this.head;
this.head = newNode;
} else {
// 非头部位置
const prevNode = this.getElementAt(position - 1);
newNode.next = prevNode.next;
prevNode.next = newNode;
}
// 更新链表长度
this.length++;
return true;
}
}
和获取指定位置节点类似,这里也对插入位置做了校验,非法位置直接返回false
,表示插入失败。
合法位置,又分为两种情况:
-
在头部位置插入,需要先将新节点的next指针指向旧链表的头节点,再将链表的头指针指向该新节点;
-
在其他位置插入,则需要先找到待插入位置的前一个节点,将其next属性赋给新节点的next属性,然后再更新前一个节点的next属性为新节点,示意图如下:
思考:下面这两行代码顺序能否颠倒?请说明原因。newNode.next = prevNode.next; prevNode.next = newNode;
删除指定位置的元素——removeAt
removeAt(position) {
// 非法位置, 删除失败
if (position < 0 || position >= this.length) return null;
else {
let delNode;
if (position === 0) {
// 头部位置
delNode = this.head;
this.head = delNode.next;
} else {
// 非头部位置
const prevNode = this.getElementAt(position - 1);
delNode = prevNode.next;
prevNode.next = delNode.next;
}
this.length--;
return delNode.element;
}
}
删除节点和添加节点类似,非法位置不做任何操作。
合法位置也分为两种情况:
- 删除链表头节点,直接将链表的头指针指向第二个节点即可
- 删除其他位置节点,需要先获取该节点的前节点,然后将前节点的next指向该节点的后节点
查找给定元素的索引——indexOf
indexOf(element) {
let curNode = this.head;
for (let i = 0; i < this.length; i++) {
if (curNode.element === element) return i;
curNode = curNode.next;
}
return -1;
}
该方法比较简单,从头节点开始,遍历每一个节点,当节点element属性值和目标值相等时,返回索引;如果找不到满足条件的节点,则返回-1
。
删除给定元素——remove
remove(element) {
const delIndex = this.indexOf(element);
return this.removeAt(delIndex);
}
判断链表是否为空——isEmpty
isEmpty() {
return this.length === 0;
}
返回链表的长度——size
size() {
return this.length;
}
返回链表的头节点——getHead
getHead() {
return this.head;
}
清空链表——clear
clear() {
this.head = null;
this.length = 0;
}
上面介绍的是最基础的单向链表,除此之外,还有双向链表
、循环链表
,原理类似,感兴趣的同学可以尝试实现一下。
栈
栈也是一种非常常见的数据结构,它遵从先进后出
原则,添加或删除元素都是在栈顶操作。在栈中,新元素总是靠近栈顶,旧元素总是靠近栈底。栈数据结构的示意图:
观察栈的示意图,很容易想到用JavaScript的数组
来模拟。和链表相比,栈不需要维护相邻元素之间的关系,所以栈的实现简单许多:
class Stack {
constructor() {
this.items = [];
}
/**
* 向栈顶添加元素
* @param {*} element
*/
push(element) {
this.items.push(element);
}
/**
* 弹出栈顶元素
*/
pop() {
return this.items.pop();
}
/**
* 获取栈顶元素
*/
peek() {
return this.items[this.items.length - 1];
}
/**
* 栈是否为空
*/
isEmpty() {
return this.items.length === 0;
}
/**
* 栈长度
*/
size() {
return this.items.length;
}
/**
* 清空栈
*/
clear() {
this.items = [];
}
}
栈的应用——成对出现的括号
给定一个只包含"("、")"的字符串,校验该字符串中的括号是否为成对出现。
例:"()()"、"(())“是一个合法括号对,”)()("、"((()"不是一个合法括号对
const isBracketBalanced = str => {
const leftBracket = "(";
// 通过正则限定参数字符串中只包含()
const regex = /^(\(|\))*$/;
if (regex.test(str)) {
throw new Error("Invalid parameter!");
}
// 创建一个栈, 存储字符串中的左括号
const stack = new Stack();
for (let index = 0; index < str.length; index++) {
if (str[index] === leftBracket) {
// 遇到左括号, 入栈
stack.push(leftBracket);
} else {
// 遇到右括号, 从栈中弹出配对左括号
if (!stack.pop()) return false;
}
}
// 如果括号是成对的, 循环结束, 栈中应无元素
return stack.isEmpty();
};
我们以((()
来理解上述方法的执行过程:
index | 当前元素 | 操作 | 栈 |
---|---|---|---|
0 | ( | push() | ["("] |
1 | ( | push() | ["(", “(”] |
2 | ( | push() | ["(", “(”, “(”] |
3 | ) | pop() | ["(", “(”] |
我们可以看到,循环结束后,栈中还有两个左括号配对失败。
队列
队列遵循先进先出
原则,这一点和栈不同。它的数据结构示意图如下:
队列的实现也比较简单,和栈一样,需要借助数组
来模拟:
class Queue {
constructor() {
this.items = [];
}
/**
* 向队列添加元素(一个或多个)
* @param {*} element
*/
enqueue(element) {
if (element instanceof Array) this.items = this.items.concat(element);
else this.items.push(element);
}
// 从队列移除元素
dequeue() {
return this.items.shift();
}
// 返回队列中的第一个元素
front() {
return this.items[0];
}
// 判断队列是否为空
isEmpty() {
return this.items.length === 0;
}
// 返回队列的长度
size() {
return this.items.length;
}
// 清空队列
clear() {
this.items = [];
}
}
队列的应用——求一个字符串的无重复字符最长子串的长度
给定一个字符串,请你找出其中不含有重复字符的最长子串的长度。
示例 1:
输入: "abcabcbb"
输出: 3
解释: 因为无重复字符的最长子串是 "abc",所以其长度为 3。
示例 2:
输入: "bbbbb"
输出: 1
解释: 因为无重复字符的最长子串是 "b",所以其长度为 1。
示例 3:
输入: "pwwkew"
输出: 3
解释: 因为无重复字符的最长子串是 "wke",所以其长度为 3。
请注意,你的答案必须是 子串 的长度,"pwke" 是一个子序列,不是子串。
这里我们用队列的思想来求解:
const lengthOfLongestSubstring = str => {
// 最长子串长度
let maxLength = 0;
// 当前索引
let index = 0;
// 子串队列
const queue = [];
// 遍历字符串
while (index < str.length) {
if (queue.indexOf(str[index]) === -1) {
// 子串队列中不存在当前字符, 入队
queue.push(str[index]);
} else {
// 子串队列中存在当前字符
// 1. 出队
// 2. 借助continue关键字, 重复执行步骤1, 直到子串队列不包含当前字符
queue.shift();
continue;
}
maxLength = Math.max(maxLength, queue.length);
index++;
}
return maxLength;
};
这里我们以示例 1来理解执行过程:
由上图可知,最长非重复子串长度为3,分别为abc
、bca
、cab
。
集合
集合是数学中的一个基本概念,它是指具有某种特定性质的具体的或抽象的对象汇总而成的集体。
在ES6中,集合(Set)已经实现,这里就不再实现,具体用法参照MDN即可。
树
在计算机科学中,树是一种十分重要的数据结构。它被描述为一种分层数据抽象模型,常用来描述数据间的层级关系和组织结构。树也是一种非顺序的数据结构,示意图如下:
如上图所示,一棵完整的树包含一个位于树顶部的节点,称之为根节点(11),它没有父节点。树中的每一个元素都叫做一个节点,节点分为内部节点(图中显示为黄色的节点)和外部节点(图中显示为灰色的节点),至少有一个子节点的节点称为内部节点,没有子元素的节点称为外部节点或叶子节点。一个节点可以有祖先(根节点除外)和后代。子树由节点本身和它的后代组成,如上图中三角虚框中的部分就是一棵子树。节点拥有的子树的个数称之为节点的度,如上图中除叶子节点的度为0外,其余节点的度都为2。从根节点开始,根为第1层,第一级子节点为第2层,第二级子节点为第3层,以此类推。树的高度(深度)由树中节点的最大层级决定(上图中树的高度为4)。
在一棵树中,具有相同父节点的一组节点称为兄弟节点,如上图中的3和6、5和9等都是兄弟节点。
二叉树
二叉树中的节点最多只能有两个子节点:左子节点和右子节点,因此,二叉树中不存在度大于2的节点。
二叉搜索树(BST——Binary Search Tree)是二叉树的一种,它规定在左子节点上存储(比父节点)小的值,在右子节点上存储(比父节点)大(或等于)的值。上图就是一个二叉搜索树。
下面我们重点来看一下二叉搜索树的实现,先看一下二叉搜索树的数据结构示意图:
观察示意图可以发现,每一个节点结构都是相同的,分别包含三个属性:指向左子节点的指针、当前节点的值、指向右子节点的指针。
通常情况下,搜索二叉树支持以下方法:
// 向树中插入一个节点
insert(element) {}
// 树中某个节点是否存在
isExist(element) {}
// 查找树中的某个节点
find(element) {}
// 返回树中的最小节点
min() {}
// 返回树中的最大节点
max() {}
// 从树中移除一个节点
remove(element) {}
为了方便描述节点间关系,这里需要一个辅助节点类Node:
class Node {
constructor(element) {
this.element = element;
this.left = null;
this.right = null;
}
}
插入节点——insert
insert(element) {
const treeNode = new Node(element);
if (this.root === null) {
this.root = treeNode;
} else {
_insertNode(this.root, treeNode);
}
}
当树的root为null时,表示树为空,新增加的节点即为根节点。否则,需要借助私有函数_insertNode()来完成节点的添加(根据新添加节点的数值大小来递归查找树的左侧子节点或者右侧子节点)。
下面是_insertNode()函数的实现代码:
/**
* 递归插入
* @param {Node} node 挂载节点
* @param {Node} newNode 新节点
*/
const _insertNode = (node, newNode) => {
if (newNode.element < node.element) {
if (node.left === null) {
node.left = newNode;
} else {
_insertNode(node.left, newNode);
}
} else {
if (node.right === null) {
node.right = newNode;
} else {
_insertNode(node.right, newNode);
}
}
};
所有新节点只能作为叶子节点被添加到树中。我们以一开始的树结构示意图为例,现在需要添加新节点2,对应的操作步骤如下图:
我们传入树的根节点,依次进行递归
,找到对应的叶子节点,然后修改节点的left
或right
指针,使其指向新添加的节点。
小练习:
描述添加节点17、节点11的过程。
树中是否存在某个节点——isExist
/**
* 树中是否存在某个节点
* @param {*} element
*/
isExist(element) {
let current = this.root;
while (current) {
if (element === current.element) return true;
if (element < current.element) current = current.left;
else current = current.right;
}
return false;
}
查找某个节点是否存在比较简单:
从根节点开始,如果目标值与根节点值相等,则返回true;如果目标值小于根节点值,则在左子树上继续比较;如果目标值大于根节点值,则在右子树上继续比较。持续这一过程,直到找到满足条件的节点或循环结束(没有满足条件的节点)。
查找树中的某个节点——find
/**
* 查找树中的某个节点
* @param {*} element
*/
find(element) {
let current = this.root;
while (current) {
if (element === current.element) return current;
if (element < current.element) current = current.left;
else current = current.right;
}
return null;
}
查找节点的过程和isExist
完全相同,不同的只是找到节点后的行为:一个是返回节点本身,另一个则是返回标志位。
返回树中的最小节点——min
// 返回树中的最小节点
min() {
let current = this.root;
while (current.left) {
current = current.left;
}
return current.element;
}
返回树中的最大节点——max
// 返回树中的最大节点
max() {
let current = this.root;
while (current.right) {
current = current.right;
}
return current.element;
}
根据二叉搜索树的定义,左子树上的节点值总是小于根节点,右子树上的节点值总是大于根节点,那么最小节点就是最左侧的叶子结点,最大节点就是最右侧的叶子结点。
从树中移除一个节点——remove
/**
* 从树中移除一个节点
* @param {*} element
*/
remove(element) {
const _removeNode = function (node, element) {
if (node == null) return null;
if (element == node.element) {
// node没有子节点
if (node.left == null && node.right == null) return null;
// node没有左侧子节点
if (node.left == null) return node.right;
// node没有右侧子节点
if (node.right == null) return node.left;
// node有两个子节点,重构右子树(将node替换为node右子树的最小节点)
let current = node.right;
while (current.left) {
current = current.left;
}
node.element = current.element;
// 剔除用于替换node节点的最小节点
node.right = _removeNode(node.right, current.element);
return node;
} else if (element < node.element) {
node.left = _removeNode(node.left, element);
return node;
} else {
node.right = _removeNode(node.right, element);
return node;
}
};
this.root = _removeNode(this.root, element);
}
下面用一张图来重点说明一下待删除节点存在左右子节点的情况:
- 首先向
_removeNode
方法传入根节点11和待删除节点15,由于15 > 11,递归执行_removeNode(15, 15)
- 15 = 15,意味着找到了待删除节点,直接移除节点15会导致它的子树无法得到处理,所以需要重新找到一个合适的节点,放置在节点15的位置:找到节点15的右子树上的最小节点18,将它的
element
属性赋给节点15的element
属性,再递归删掉节点18
参考文章
https://segmentfault.com/a/1190000020011987
https://www.cnblogs.com/jaxu/p/11309385.html