js八大数据结构
- 八大数据结构的JS实现
1. 栈**(Stack)**
认识
栈是一个线性数据结构 ,遵循后进先出(LIFO,Last In First Out)的原则
JavaScript中没有栈,但是可以用Array实现栈的所有功能。
JS实现
plain
// 数组实现栈数据结构
const stack = []
// 入栈
stack.push(0)
stack.push(1)
stack.push(2)
// 出栈
const popVal = stack.pop() // popVal 为 2
使用场景
- 场景一:十进制转二进制
- 场景二:有效括号
- 场景三:函数调用堆栈
LeetCode题目
javascript
● 20 有效括号
● 144 二叉树的前序遍历
特点
- 后进先出:栈的操作遵循"后进先出"的规则。最新加入栈的元素会最先被移除。
- 只能在一端操作:栈只有一个端口进行操作,通常是栈顶(Top),也叫栈顶操作。
- 两种基本操作 :
- push:将元素压入栈中。
- pop:从栈顶弹出元素。
栈通常用于需要"撤回"或"回溯"的操作场景,例如:浏览器历史记录、撤销操作、递归的实现等。
基本操作
- push:将元素放入栈中。
- pop:将栈顶元素移除。
- peek/top:查看栈顶元素,但不移除。
- isEmpty:检查栈是否为空。
- size:获取栈的大小。
栈的实现
在 JavaScript 中,我们可以通过数组来实现栈。数组本身提供了 push() 和 pop() 方法,可以直接用来模拟栈的行为。
javascript
class Stack {
constructor() {
this.items = []; // 存储栈元素
}
// 压入元素
push(element) {
this.items.push(element);
}
// 弹出栈顶元素
pop() {
if (this.isEmpty()) {
return "栈为空,无法弹出";
}
return this.items.pop();
}
// 查看栈顶元素
peek() {
if (this.isEmpty()) {
return "栈为空";
}
return this.items[this.items.length - 1];
}
// 判断栈是否为空
isEmpty() {
return this.items.length === 0;
}
// 获取栈的大小
size() {
return this.items.length;
}
}
// 使用栈
let stack = new Stack();
stack.push(10); // 压入元素 10
stack.push(20); // 压入元素 20
console.log(stack.peek()); // 输出栈顶元素: 20
console.log(stack.pop()); // 弹出栈顶元素: 20
console.log(stack.size()); // 输出栈的大小: 1
console.log(stack.isEmpty()); // 输出栈是否为空: false
使用场景
栈的应用非常广泛,特别是在需要反向操作的场合。以下是一些常见的使用场景:
- 递归调用:递归在计算机内部通常是通过栈来实现的,栈保存了每一层函数的执行状态,调用栈的最后一个元素是当前正在执行的函数。
- 表达式求值:栈常用于表达式的求值,如中缀表达式转后缀表达式(逆波兰表达式)时,或者是求解括号匹配的问题。
- 浏览器历史记录:浏览器的"前进"和"后退"按钮实现的就是基于栈的。每当你访问一个新页面时,浏览器会将该页面推入栈中,当你点击"后退"时,栈顶的页面会被弹出。
- 撤销操作:例如,在文本编辑器中,每当用户执行操作时,都会把这些操作压入栈中。如果用户点击撤销按钮,栈顶的操作就会被弹出,从而恢复之前的状态。
- 括号匹配:在编程语言的语法分析中,检查括号是否匹配也是栈的一种应用。如果遇到左括号就压栈,遇到右括号就弹栈,最终栈是否为空来判断括号是否匹配。
栈的优缺点
优点:
- 操作简单:栈的操作非常简单,只涉及到压栈、弹栈和查看栈顶元素等基本操作。
- 空间高效:栈是一个线性数据结构,使用数组或链表存储数据,不需要额外的空间开销。
缺点:
- 访问限制:栈只能通过栈顶进行访问,无法直接访问中间的元素,效率较低。
- 溢出问题 :如果栈的容量有限,当栈满时,再执行
push()操作会导致栈溢出。
2. 队列
认识
队列(Queue) 是一种 线性数据结构 ,遵循先进先出(FIFO, First-In-First-Out) 的原则。
也就是说,队列中的元素按照它们被加入的顺序排列,先加入队列的元素先被移除,后加入队列的元素后被移除。
JavaScript中没有队列,但是可以用Array实现队列的所有功能。
JS实现
plain
// 数组实现队列数据结构
const queue = []
// 入队
stack.push(0)
stack.push(1)
stack.push(2)
// 出队
const shiftVal = stack.shift() // shiftVal 为 0
使用场景
- 场景一:日常测核酸排队
- 场景二:JS异步中的任务队列
- 场景三:计算最近请求次数
LeetCode题目
javascript
933 最近的请求次数
基本操作
- 入队(Enqueue):向队列的尾部添加一个元素。
- 出队(Dequeue):从队列的头部移除一个元素。
- 查看队首元素(Peek/Front):返回队列头部的元素,但不移除它。
- 判断队列是否为空(IsEmpty):检查队列中是否有元素。
- 队列的大小(Size):返回队列中元素的数量。
应用场景
队列广泛应用于需要按顺序处理任务的场景,以下是一些典型的应用:
- 任务调度:操作系统中的任务调度常常使用队列来管理进程或线程,按照先到先处理的原则执行任务。
- 消息队列:在分布式系统或异步通信中,消息队列用于存储和传递消息,保证消息按顺序被处理。
- 打印任务:打印机的打印任务通常是排队的,先发送的任务先打印。
- 广度优先搜索(BFS):在图的广度优先遍历中,队列用来管理访问节点的顺序。
实现
队列可以用数组、链表或者其他数据结构来实现。常见的两种实现方式是:
- 数组实现 :队列可以用一个数组来存储元素,使用两个指针(头指针和尾指针)来表示队列的两端。
- 优点:实现简单。
- 缺点:在数组中间删除元素可能会导致大量元素移动,效率较低。
- 链表实现 :队列也可以使用链表来实现,通过链表的头部进行出队操作,通过尾部进行入队操作。
- 优点:无固定大小限制,可以高效地进行插入和删除。
- 缺点:需要额外的内存来存储指针。
操作
javascript
class Queue {
constructor() {
this.items = [];
}
// 入队
enqueue(element) {
this.items.push(element);
}
// 出队
dequeue() {
if (this.isEmpty()) {
return 'Queue is empty';
}
return this.items.shift(); // 从头部移除元素
}
// 查看队首元素
front() {
if (this.isEmpty()) {
return 'Queue is empty';
}
return this.items[0];
}
// 判断队列是否为空
isEmpty() {
return this.items.length === 0;
}
// 返回队列大小
size() {
return this.items.length;
}
}
// 测试队列操作
let queue = new Queue();
queue.enqueue(1); // 入队 1
queue.enqueue(2); // 入队 2
queue.enqueue(3); // 入队 3
console.log(queue.dequeue()); // 出队 1
console.log(queue.front()); // 队首元素 2
console.log(queue.size()); // 队列大小 2
console.log(queue.isEmpty()); // 是否为空 false
队列的变种
- 双端队列(Deque):双端队列允许从队列的两端进行插入和删除操作,因此它既可以作为队列使用,也可以作为栈使用。
- 优先级队列(Priority Queue):优先级队列中的元素有优先级,出队时不是按顺序(FIFO)执行,而是根据元素的优先级进行处理,优先级高的元素先出队。
3. 链表
认识
链表(Linked List) 是一种 线性数据结构 ,与数组类似,也用来存储一系列的元素。与数组不同的是,链表中的元素不是按连续的内存地址存储的,而是通过 节点 (Node)之间的 指针(或引用)连接起来。
链表是多个元素组成的列表,元素存储不连续,用next指针连在一起。JavaScript中没有链表,但是可以用Object模拟链表。
基本概念
一个链表由多个 节点 (Node)组成,每个节点包含两个部分(数据+指针)
- 数据部分:存储节点的数据。
- 指针部分 :指向下一个节点的引用。对于链表中的最后一个节点,指针部分为
null或undefined,表示链表的结束。
类型
- 单向链表(Singly Linked List):每个节点指向下一个节点,链表是单向的。
- 双向链表(Doubly Linked List):每个节点有两个指针,一个指向下一个节点,一个指向前一个节点。
- 循环链表(Circular Linked List):链表的最后一个节点指向链表的头节点,形成循环。
链表与数组的比较
| 特性 | 链表 | 数组 |
|---|---|---|
| 内存分配 | 非连续的内存分配 | 连续的内存分配 |
| 插入/删除效率 | 高效(O(1)) | 低效(需要移动元素) |
| 随机访问 | 低效(O(n)) | 高效(O(1)) |
| 空间效率 | 节省空间(不需要固定大小) | 固定大小(需要预先分配) |
链表的常见操作
- 插入(Insertion):可以在链表的头部、尾部或任意位置插入节点。
- 删除(Deletion):可以删除链表的头部、尾部或指定位置的节点。
- 遍历(Traversal):从头节点开始逐一访问链表中的每个节点,直到链表的结束。
- 查找(Search):查找链表中是否有某个特定的数据。
在 JavaScript 中如何理解链表
在 JavaScript 中,链表通常通过 对象 来实现,因为 JavaScript 对象本身就能存储 键值对,而每个节点的指针可以用对象的属性来表示。
TS实现
plain
/**
* 定义一个 node 节点
*/
interface ILinkListNode {
value: number
next?: ILinkListNode
}
/**
* 根据数组创建单向链表
* @param arr
* @returns
*/
function createLinkList(arr: number[]): ILinkListNode {
const len = arr.length
if (len === 0) throw new Error('arr is empty')
let curNode: ILinkListNode = {
value: arr[len - 1]
}
if (len === 1) return curNode
for (let i = len - 2; i >= 0; i--) {
curNode = {
value: arr[i],
next: curNode
}
}
return curNode
}
使用场景
- 场景一:JS中的原型链
- 场景二:使用链表指针获取 JSON 的节点值
LeetCode题目
javascript
● 237. 删除链表中的节点
● 206. 反转链表
● 2. 两数相加
● 83. 删除排序链表中的重复元素
● 141. 环形链表
单向链表的实现
javascript
// 节点类
class Node {
constructor(data) {
this.data = data; // 节点数据
this.next = null; // 指向下一个节点的指针
}
}
// 链表类
class LinkedList {
constructor() {
this.head = null; // 链表的头部
}
// 添加节点到链表尾部
append(data) {
const newNode = new Node(data);
// 如果链表为空,将新节点设置为头节点
if (!this.head) {
this.head = newNode;
return;
}
// 否则找到链表的最后一个节点,并将其 next 指向新节点
let current = this.head;
while (current.next) {
current = current.next;
}
current.next = newNode;
}
// 打印链表的所有节点
print() {
let current = this.head;
let output = '';
while (current) {
output += current.data + ' -> ';
current = current.next;
}
console.log(output + 'null');
}
// 查找某个节点
find(data) {
let current = this.head;
while (current) {
if (current.data === data) {
return current;
}
current = current.next;
}
return null;
}
// 删除某个节点
remove(data) {
if (!this.head) return; // 链表为空
// 如果要删除的是头节点
if (this.head.data === data) {
this.head = this.head.next;
return;
}
let current = this.head;
while (current.next) {
if (current.next.data === data) {
current.next = current.next.next;
return;
}
current = current.next;
}
}
}
// 测试链表操作
let list = new LinkedList();
list.append(10); // 添加节点 10
list.append(20); // 添加节点 20
list.append(30); // 添加节点 30
list.print(); // 打印链表 10 -> 20 -> 30 -> null
console.log(list.find(20)); // 查找值为 20 的节点
list.remove(20); // 删除值为 20 的节点
list.print(); // 打印链表 10 -> 30 -> null
实际应用
- 动态内存管理:链表在内存中分配空间时,不需要连续的内存空间,可以有效地利用碎片化的内存。
- 实现队列和栈:链表可以用来实现队列(FIFO)和栈(LIFO)。链表的插入和删除操作是 O(1) 的,非常适合用来实现这两种数据结构。
- 处理大量数据:对于大小动态变化的数据集,链表非常适合,因为它能够灵活地扩展而不需要移动已有的数据。
4. 集合
集合是一个无序且唯一的数据结构。ES6中有集合:Set,集合常用操作:去重、判断某元素是否在集合中、求交集。
JS实现
plain
// 去重
const arr = [1, 1, 2, 2]
const arr2 = [...new Set(arr)]
// 判断元素是否在集合中
const set = new Set(arr)
const has = set.has(3) // false
// 求交集
const set2 = new Set([2, 3])
const set3 = new Set([...set].filter(item => set2.has(item)))
使用场景
- 场景一:求交集、差集
LeetCode题目
javascript
349 两个数组的交集
5. 字典(哈希)
字典也是一种存储唯一值的数据结构,但它以键值对的形式存储。ES6中的字典名为Map
字典(或称哈希表)通常通过 对象(Object) 或 **Map** 类型来实现,它们用于存储键值对(key-value pairs)。字典的核心特性是 通过键(key)快速访问值(value) ,通常使用 哈希算法 来实现键到值的映射。
JS实现
Map 中的键和值可以是任何类型(包括对象、函数等)
plain
// 字典
const map = new Map()
// 增
map.set('key1', 'value1')
map.set('key2', 'value2')
map.set('key3', 'value3')
// 删
map.delete('key3')
// map.clear()
// 改
map.set('key2', 'value222')
// 查
map.get('key2')
使用场景
- 场景:leetcode刷题
LeetCode题目
javascript
● 349 两个数组的交集
● 20 有效括号
● 1 两数之和
● 3 无重复字符的最长子串
● 76 最小覆盖子串
对象 vs Map:区别
尽管对象和 Map 都可以用来实现字典,二者在使用上有一些重要的区别:
| 特性 | 对象 (Object) | Map |
|---|---|---|
| 键的类型 | 只能是字符串或符号(Symbol) | 可以是任何类型(包括对象、函数等) |
| 键值对的顺序 | 没有保证(ES6 后的对象会保持插入顺序) | 保持插入顺序 |
| 性能 | 键值对较少时性能较好,随着属性增加可能变慢 | 在添加、删除大量键值对时性能更好 |
| 默认原型 | 继承自 Object.prototype ,可能包含其他属性 |
没有继承自 Object ,没有额外的属性 |
Object.keys()<br/>Object.values()` 等 |
set() get() has() delete() 等 |
|
| 迭代器 | 不支持直接迭代(需要手动转换为数组) | 直接支持迭代(forEach() 和 for...of ) |
6. 树
认识
树(Tree)是一种常用的数据结构,它由节点(node)和连接节点的边(edge)组成,具有层次性和递归结构。树的特点是每个节点有一个父节点,除了根节点没有父节点,每个节点可以有多个子节点。
树是一种分层的数据模型。前端常见的树包括:DOM、树、级联选择、树形控件......。JavaScript中没有树,但是可以通过Object和Array构建树。树的常用操作:深度/广度优先遍历、先中后序遍历。
TS实现
plain
/**
* 前序遍历:root -> left -> right
* 中序遍历:left -> root -> right
* 后序遍历:left -> right -> root
* 问1:为什么二叉树很重要,而不是三叉树、四叉树?
* 答:
* (1)数组、链表各有缺点
* (2)特定的二叉树(BBST,平衡二叉树)可以结合数组 & 链表的优点,让整体查找效果最优(可用二分法)
* (3)各种高级二叉树(红黑数、B树),继续优化,满足不同场景
* 问2:堆特点?和二叉树的关系?
* 答:
* (1)逻辑结构是一棵二叉树
* (2)物理结构是一个数组
* (3)数组:连续内存 + 节省空间
* (4)查询比 BST 慢
* (5)增删比 BST 快,维持平衡更快
* (6)整体时间复杂度都在 O(logn) 级别,与树一致
* @description 二叉搜索树
* @author hovinghuang
*/
interface ITreeNode {
value: number
left: ITreeNode | null
right: ITreeNode | null
}
const treeArr: number[] = []
/**
* 前序遍历
* @param node
* @returns
*/
function preOrderTraverse(node: ITreeNode | null): void {
if (node == null) return
console.info(node.value)
treeArr.push(node.value)
preOrderTraverse(node.left)
preOrderTraverse(node.right)
}
/**
* 中序遍历
* @param node
* @returns
*/
function inOrderTraverse(node: ITreeNode | null): void {
if (node == null) return
inOrderTraverse(node.left)
console.info(node.value)
treeArr.push(node.value)
inOrderTraverse(node.right)
}
/**
* 后序遍历
* @param node
* @returns
*/
function postOrderTraverse(node: ITreeNode | null): void {
if (node == null) return
postOrderTraverse(node.left)
postOrderTraverse(node.right)
console.info(node.value)
treeArr.push(node.value)
}
function getKthValue(node: ITreeNode, k: number): number | null {
inOrderTraverse(bst)
return treeArr[k - 1] || null
}
const bst: ITreeNode = {
value: 5,
left: {
value: 3,
left: {
value: 2,
left: null,
right: null
},
right: {
value: 4,
left: null,
right: null,
}
},
right: {
value: 7,
left: {
value: 6,
left: null,
right: null
},
right: {
value: 8,
left: null,
right: null
}
}
}
// 功能测试
// preOrderTraverse(bst)
// inOrderTraverse(bst)
// postOrderTraverse(bst)
// console.info('第3小值', getKthValue(bst, 3))
使用场景
- 场景一:DOM树
- 场景二:级联选择器
LeetCode题目
javascript
● 104. 二叉树的最大深度
● 111. 二叉树的最小深度
● 102. 二叉树的层序遍历
● 94. 二叉树的中序遍历
● 112. 路径总和
树的基本概念
- 节点(Node):树中的基本元素,包含值或数据。每个节点由数据部分和指向子节点的指针(或者引用)组成。
- 根节点(Root):树的顶端节点,没有父节点。
- 父节点(Parent):某个节点的直接上级节点。
- 子节点(Child):某个节点的直接下级节点。
- 叶子节点(Leaf):没有子节点的节点。
- 子树(Subtree):树的某个节点及其所有后代节点。
- 深度(Depth):从根节点到该节点的路径长度。
- 高度(Height):从该节点到最远叶子节点的路径长度。
- 度(Degree):节点的子节点个数。
分类
根据不同的结构和用途,树可以有许多不同的类型,常见的包括:
- 二叉树(Binary Tree) :每个节点最多有两个子节点,通常称为左子节点和右子节点。
- 满二叉树(Full Binary Tree):每个节点要么是叶子节点,要么有两个子节点。
- 完全二叉树(Complete Binary Tree):除了最后一层,其他层的节点都达到最大,且最后一层的节点从左到右依次排列。
- 二叉搜索树(Binary Search Tree,BST):对于每个节点,左子树的值小于节点的值,右子树的值大于节点的值。
- 平衡树(Balanced Tree) :为了保证高效的查询、插入和删除操作,树的高度尽量保持平衡。
- AVL树:一种高度平衡的二叉搜索树,任何一个节点的两个子树的高度差的绝对值不超过1。
- 红黑树:一种自平衡的二叉搜索树,每个节点都有额外的颜色属性(红色或黑色),通过颜色规则保持平衡。
- B树和B+树:用于数据库和文件系统,支持大规模数据的高效插入、删除、查找等操作。
树的常见操作
3.1 插入节点
- 在树中插入一个节点通常依赖于树的类型。例如,在二叉搜索树中,新的节点会插入到合适的位置,遵循左小右大的规则。
3.2 删除节点
- 删除树中的节点时需要考虑该节点的子节点。如果节点是叶子节点,直接删除;如果有一个子节点,删除节点并将子节点提升;如果有两个子节点,通常选择右子树中的最小节点或左子树中的最大节点来替代删除节点。
3.3 查找节点
- 查找操作在二叉搜索树中非常高效,因为它利用树的排序特性,可以在O(log n)的时间复杂度内完成查找。
3.4 遍历树
树的遍历是树结构的基本操作,常见的遍历方式有:
- 前序遍历(Preorder Traversal):先访问根节点,然后访问左子树,再访问右子树。
plain
textCopy Code根 → 左 → 右
- 中序遍历(Inorder Traversal):先访问左子树,然后访问根节点,再访问右子树。
plain
textCopy Code左 → 根 → 右
- 后序遍历(Postorder Traversal):先访问左子树,然后访问右子树,最后访问根节点。
plain
textCopy Code左 → 右 → 根
- 层次遍历(Level Order Traversal):按层级顺序访问树的节点,通常使用队列实现。
实现
在实际编程中,树通常通过类(class)或结构体(struct)来实现。以下是一个简单的二叉树实现示例:
4.1 二叉树节点的定义
javascript
class TreeNode {
constructor(value) {
this.value = value;
this.left = null;
this.right = null;
}
}
4.2 二叉搜索树的插入操作
javascript
class BinarySearchTree {
constructor() {
this.root = null;
}
insert(value) {
const newNode = new TreeNode(value);
if (this.root === null) {
this.root = newNode;
} else {
this._insertNode(this.root, newNode);
}
}
_insertNode(node, newNode) {
if (newNode.value < node.value) {
if (node.left === null) {
node.left = newNode;
} else {
this._insertNode(node.left, newNode);
}
} else {
if (node.right === null) {
node.right = newNode;
} else {
this._insertNode(node.right, newNode);
}
}
}
}
4.3 二叉树的前序遍历
plain
class BinaryTree {
constructor() {
this.root = null;
}
preorderTraversal(node) {
if (node !== null) {
console.log(node.value); // 访问节点
this.preorderTraversal(node.left); // 访问左子树
this.preorderTraversal(node.right); // 访问右子树
}
}
}
应用场景
- 数据库索引:B树和B+树广泛用于数据库索引中,支持高效的数据查找、插入、删除操作。
- 文件系统:目录结构通常用树来表示,文件夹是父节点,文件是子节点。
- 编译器:语法树用于表示程序的语法结构。
- 搜索引擎:倒排索引结构常使用树来表示。
- 人工智能:决策树用于机器学习中的分类任务。
7. 图
图(Graph)是一种非线性的数据结构,用于表示物体之间的关系。图由一组节点(Vertex)和一组边(Edge)组成,其中边连接着图中的两个节点。图在现实生活中有很多应用,比如社交网络、交通系统、互联网拓扑结构等。
图是网络结构的抽象模型,是一组由边连接的节点。图可以表示任何二元关系,比如道路、航班。JS中没有图,但是可以用Object和Array构建图。图的表示法:邻接矩阵、邻接表、关联矩阵。
基本概念
- 节点(Vertex):图中的基本元素,表示物体或者状态。
- 边(Edge) :连接图中两个节点的线,表示节点之间的关系或联系。边有两种类型:
- 有向边(Directed Edge):边有方向,从一个节点指向另一个节点。
- 无向边(Undirected Edge):边没有方向,连接的两个节点是对等的。
- 邻接(Adjacency):两个节点之间如果有边连接,称这两个节点是邻接的。
- 度(Degree) :一个节点的度是与它相连接的边的数目。
- 入度(Indegree):指向该节点的边的数量(仅适用于有向图)。
- 出度(Outdegree):从该节点指向其他节点的边的数量(仅适用于有向图)。
表示
plain
// 邻接表表示图结构
const graph = {
0: [1, 2],
1: [2],
2: [0, 3],
3: [3]
}
// 深度优先遍历
const visited = new Set()
function dfs(n, visited) { // n 表示开始访问的根节点
console.log(n)
visited.add(n)
graph[n].forEach((item) => {
if (!visited.has(item)) dfs(item, visited)
})
}
dfs(2, visited) // 2 0 1 3
console.log(visited) // {2, 0, 1, 3}
// 广度优先遍历
function bfs(n) { // n 表示开始访问的根节点
const visited = new Set()
visited.add(n)
const queue = [n]
while (queue.length) {
const shiftVal = queue.shift()
graph[shiftVal].forEach((item) => {
if (!visited.has(item)) {
queue.push(item)
visited.add(item)
}
})
}
console.log(visited) // {2, 0, 3, 1}
}
bfs(2)
使用场景
- 场景一:道路
- 场景二:航班
LeetCode题目
javascript
● 65 有效数字
● 417 太平洋大西洋水流问题
● 133 克隆图
分类
图可以根据不同的特征进行分类:
2.1 根据边的方向
- 有向图(Directed Graph, Digraph):每条边都有一个方向,边从一个节点指向另一个节点。比如社交网络中"关注"关系可以用有向图表示。
- 无向图(Undirected Graph):边没有方向,两个节点之间的边没有特定的起始点或终点。比如道路网络、社交网络中的"朋友"关系可以用无向图表示。
2.2 根据边的数量
- 简单图(Simple Graph):没有自环(一个节点不能通过一条边指向自己)和重复的边。
- 多重图(Multigraph):允许多条边连接同一对节点。
- 带权图(Weighted Graph):每条边都有一个权重,表示节点之间的距离或成本。常见于网络流、地图导航等应用中。
2.3 根据图的连通性
- 连通图(Connected Graph) :图中的任意两个节点都有路径相连。
- 对于无向图,连通图指的是图中的任意两点之间都存在路径。
- 对于有向图,强连通图指的是任意两个节点之间都可以互相到达。
- 非连通图(Disconnected Graph):图中存在至少一对节点之间没有路径连接。
2.4 根据图的结构
- 树(Tree):一种特殊的图,没有环(循环),是一个连通无环的有向图或无向图。
- 有向无环图(Directed Acyclic Graph, DAG):有向图且没有环,常用于表示依赖关系,比如任务调度、版本控制等。
图的表示方法
图可以通过以下几种方式进行表示:
3.1 邻接矩阵(Adjacency Matrix)
邻接矩阵是一个二维数组,其中矩阵的行和列都表示图中的节点。如果节点 i_i_ 和节点 j_j_ 之间有边,则矩阵元素 A[i][j]=1_A_[i ][j ]=1(对于无权图)或 A[i][j]=边的权重_A_[i ][j]=边的权重(对于带权图);如果没有边,则为 0。
- 优点:方便进行图的操作(如查找某两节点之间是否有边),但空间复杂度较高。
- 缺点 :存储空间复杂度为 O(V2)O (V_2),其中 V_V 为节点数。对于稀疏图,效率较低。
plain
pythonCopy Code# 邻接矩阵表示图
graph = [
[0, 1, 0, 0],
[1, 0, 1, 1],
[0, 1, 0, 0],
[0, 1, 0, 0]
]
3.2 邻接表(Adjacency List)
邻接表是一种更节省空间的表示方法。它使用一个数组或链表来存储每个节点的所有邻接节点。每个节点都有一个链表,链表中的元素表示该节点与其他节点的边。
- 优点:空间复杂度较低,适用于稀疏图。
- 缺点:查找两个节点之间是否有边较慢。
plain
pythonCopy Code# 邻接表表示图
graph = {
0: [1],
1: [0, 2, 3],
2: [1],
3: [1]
}
3.3 边列表(Edge List)
边列表是通过一个边的集合来表示图,每一条边表示为一个二元组或三元组(带权边)。这种表示方式常用于存储图的数据结构,特别适合于边的遍历。
- 优点:表示方式简洁,适合边的处理。
- 缺点:对于图的其他操作(如查找节点的邻居)较为不便。
plain
pythonCopy Code# 边列表表示图
graph = [(0, 1), (1, 2), (1, 3)]
常见操作
4.1 遍历图
- 深度优先搜索(DFS):从一个节点开始,尽可能深地遍历图,直到无法继续,再回溯到上一个节点。适合用栈实现。
plain
pythonCopy Codedef dfs(graph, start, visited=None):
if visited is None:
visited = set()
visited.add(start)
for neighbor in graph[start]:
if neighbor not in visited:
dfs(graph, neighbor, visited)
return visited
- 广度优先搜索(BFS):从一个节点开始,先访问所有邻居节点,再逐层访问更远的节点,适合用队列实现。
plain
pythonCopy Codefrom collections import deque
def bfs(graph, start):
visited = set()
queue = deque([start])
while queue:
vertex = queue.popleft()
if vertex not in visited:
visited.add(vertex)
queue.extend(graph[vertex] - visited)
return visited
4.2 查找最短路径
- Dijkstra算法:用于找出从起始节点到其他所有节点的最短路径,适用于带权图。
- Bellman-Ford算法:可以处理带负权边的图,能够检测负权环。
4.3 拓扑排序
拓扑排序是有向无环图(DAG)中的一种排序方式,按照边的依赖关系将图的节点进行排序。常用于任务调度、编译顺序等。
应用场景
- 社交网络:图用于表示人与人之间的关系,节点是人,边是社交联系。
- 互联网:图表示网页与网页之间的链接关系,节点是网页,边是超链接。
- 地图与导航:图可以表示交通网络,节点是交叉口或地点,边是道路或路线。
- 任务调度与依赖关系:图用于表示任务间的依赖关系,拓扑排序可以帮助确定任务的执行顺序。
- 计算机网络:图用于表示计算机网络中的路由和数据流。
8. 堆(Heap)
堆是一种特殊的完全二叉树数据结构,满足一定的顺序性质。
所有的节点都大于等于(最大堆)或小于等于(最小堆)它的子节点。由于堆的特殊结构,我们可以用数组表示堆。
堆广泛应用于实现优先队列、堆排序等算法。
基本概念
堆是一棵完全二叉树,满足堆的性质。根据堆的性质,堆分为两种类型:
1.1 最大堆(Max-Heap)
在最大堆中,任何一个节点的值都不小于其子节点的值。也就是说,堆顶元素是整个堆中最大的元素。
- 堆的性质:父节点的值大于或等于子节点的值。
- 堆的形状:完全二叉树,每层节点从左到右依次排列,且最后一层的节点填充到最左边。
1.2 最小堆(Min-Heap)
在最小堆中,任何一个节点的值都不大于其子节点的值。也就是说,堆顶元素是整个堆中最小的元素。
- 堆的性质:父节点的值小于或等于子节点的值。
- 堆的形状:同样是完全二叉树。
表示
plain
1
/ \
2 3
/ \ /\
4 5 6
// 数组表示堆结构
const heap = [1, 2, 3, 4, 5, 6]
// 实现一个最小堆类
class MinHeap {
constructor() {
this.heap = [];
}
swap(i1, i2) {
const temp = this.heap[i1];
this.heap[i1] = this.heap[i2];
this.heap[i2] = temp;
}
getParentIndex(i) {
return (i - 1) >> 1;
}
getLeftIndex(i) {
return i * 2 + 1;
}
getRightIndex(i) {
return i * 2 + 2;
}
shiftUp(index) {
if (index == 0) { return; }
const parentIndex = this.getParentIndex(index);
if (this.heap[parentIndex] > this.heap[index]) {
this.swap(parentIndex, index);
this.shiftUp(parentIndex);
}
}
shiftDown(index) {
const leftIndex = this.getLeftIndex(index);
const rightIndex = this.getRightIndex(index);
if (this.heap[leftIndex] < this.heap[index]) {
this.swap(leftIndex, index);
this.shiftDown(leftIndex);
}
if (this.heap[rightIndex] < this.heap[index]) {
this.swap(rightIndex, index);
this.shiftDown(rightIndex);
}
}
insert(value) {
this.heap.push(value);
this.shiftUp(this.heap.length - 1);
}
pop() {
this.heap[0] = this.heap.pop();
this.shiftDown(0);
}
peek() {
return this.heap[0];
}
size() {
return this.heap.length;
}
}
const h = new MinHeap();
h.insert(3);
h.insert(2);
h.insert(1);
h.pop();
使用场景
- 场景:leetcode刷题
LeetCode题目
javascript
● 215 数组中的第K个最大元素
● 347 前 K 个高频元素
● 23 合并K个升序链表
表示
堆通常用一个数组来表示。数组的下标和堆的结构有着密切的关系:
- 父节点 :对于一个数组下标为
i的节点,其父节点下标为floor((i-1) / 2)。 - 左子节点 :对于一个数组下标为
i的节点,其左子节点下标为2 * i + 1。 - 右子节点 :对于一个数组下标为
i的节点,其右子节点下标为2 * i + 2
操作
堆支持几种基本操作,通常包括:
3.1 堆化(Heapify)
堆化是一个将数组调整成堆的过程。通过递归或迭代地调整数组中的元素,使得父节点满足堆的性质。
- 单向堆化:将某个子树调整为堆。
- 全堆化:将整个数组调整为堆。
3.2 插入(Insert)
插入操作用于向堆中添加一个新元素。新元素通常被添加到堆的最后一个位置,然后通过"上浮"(bubble-up)操作将其调整到正确的位置,确保堆的性质不被破坏。
- 步骤 :
- 将新元素添加到堆的末尾。
- 将新元素与其父节点比较,若新元素较大(在最大堆中)或较小(在最小堆中),则交换位置,直到堆的性质得到恢复。
3.3 删除堆顶(Extract Max/Min)
删除堆顶元素是堆的一个重要操作。在最大堆中,堆顶是最大元素,删除后要保持堆的性质。删除堆顶元素的步骤如下:
- 步骤 :
- 将堆顶元素与堆的最后一个元素交换。
- 删除堆的最后一个元素(已交换到堆顶)。
- 从堆顶开始,通过"下沉"(sink-down)操作将新的堆顶元素恢复为堆。
3.4 堆排序(Heap Sort)
堆排序是基于堆的排序算法,通过反复提取堆顶元素来排序数组。堆排序的步骤如下:
- 步骤 :
- 将输入数组构建成最大堆。
- 反复删除堆顶元素(最大元素),将其与当前堆的最后一个元素交换。
- 调整堆结构,恢复堆的性质。
- 重复此过程,直到堆中只剩一个元素。
堆排序的时间复杂度为 O(nlogn)O (n_log_n),是一个不稳定的排序算法。
3.5 获取堆顶元素(Peek)
获取堆顶元素即访问堆中最大(或最小)元素而不删除它。这个操作时间复杂度为 O(1)O(1),因为堆顶元素就是堆中最大或最小的元素。
应用
堆具有许多应用,尤其是在处理优先级队列和排序问题时。
4.1 优先队列(Priority Queue)
优先队列是一种抽象数据类型,其中每个元素都关联一个优先级。堆是实现优先队列的一种高效数据结构。
- 最大堆:在优先队列中,具有最高优先级的元素总是位于堆顶。
- 最小堆:在优先队列中,具有最低优先级的元素总是位于堆顶。
4.2 堆排序(Heap Sort)
堆排序是一种基于比较的排序算法,时间复杂度为 O(nlogn)O (n_log_n),不需要额外的内存空间,因此它是一种原地排序。
4.3 动态中位数计算
可以使用两个堆来动态地计算中位数。通常会使用一个最大堆和一个最小堆来分别存储数据的两部分,从而快速获取中位数。
4.4 图的算法(例如Dijkstra算法)
在图的算法中,堆(通常是最小堆)用于高效地选择当前最短路径的节点。例如,Dijkstra算法通过使用优先队列来优化最短路径的查找。
堆的实现
(Python示例)
在Python中,heapq模块提供了堆的功能,默认实现的是最小堆。
plain
import heapq
# 创建一个空堆
heap = []
# 向堆中添加元素(堆化操作)
heapq.heappush(heap, 20)
heapq.heappush(heap, 10)
heapq.heappush(heap, 30)
# 获取堆顶元素(最小堆,最小元素)
print(heap[0]) # 输出 10
# 删除并返回堆顶元素
min_element = heapq.heappop(heap)
print(min_element) # 输出 10
# 获取当前堆的堆顶元素
print(heap[0]) # 输出 20
如果需要实现最大堆,可以通过插入负值来模拟:
plain
import heapq
# 创建一个空堆
max_heap = []
# 向堆中添加元素(模拟最大堆)
heapq.heappush(max_heap, -20)
heapq.heappush(max_heap, -10)
heapq.heappush(max_heap, -30)
# 获取堆顶元素(最大堆,最大元素)
print(-max_heap[0]) # 输出 30
# 删除并返回堆顶元素
max_element = -heapq.heappop(max_heap)
print(max_element) # 输出 30
堆(Heap)可以使用 JavaScript 来实现
最大堆的实现
javascript
class MaxHeap {
constructor() {
this.heap = [];
}
// 获取父节点索引
parent(index) {
return Math.floor((index - 1) / 2);
}
// 获取左子节点索引
leftChild(index) {
return index * 2 + 1;
}
// 获取右子节点索引
rightChild(index) {
return index * 2 + 2;
}
// 判断节点是否是叶子节点
isLeaf(index) {
return index >= Math.floor(this.heap.length / 2) && index < this.heap.length;
}
// 堆化操作:将堆的某个部分调整为堆
heapify(index) {
let largest = index;
const left = this.leftChild(index);
const right = this.rightChild(index);
// 左子节点比父节点大
if (left < this.heap.length && this.heap[left] > this.heap[largest]) {
largest = left;
}
// 右子节点比当前最大的还要大
if (right < this.heap.length && this.heap[right] > this.heap[largest]) {
largest = right;
}
// 如果最大的节点不是父节点,交换并继续堆化
if (largest !== index) {
[this.heap[index], this.heap[largest]] = [this.heap[largest], this.heap[index]];
this.heapify(largest);
}
}
// 插入一个元素
insert(value) {
this.heap.push(value); // 将元素插入到堆的末尾
let current = this.heap.length - 1;
// 向上调整:如果当前元素大于父节点,交换它们
while (current > 0 && this.heap[this.parent(current)] < this.heap[current]) {
[this.heap[current], this.heap[this.parent(current)]] = [this.heap[this.parent(current)], this.heap[current]];
current = this.parent(current);
}
}
// 删除堆顶元素(最大元素)
extractMax() {
if (this.heap.length === 0) return null;
const max = this.heap[0];
// 将堆顶元素与最后一个元素交换
this.heap[0] = this.heap[this.heap.length - 1];
this.heap.pop(); // 删除堆顶元素
// 堆化堆顶元素
this.heapify(0);
return max;
}
// 获取堆顶元素(最大元素)
peek() {
if (this.heap.length === 0) return null;
return this.heap[0];
}
// 获取堆的大小
size() {
return this.heap.length;
}
}
// 示例
const maxHeap = new MaxHeap();
maxHeap.insert(10);
maxHeap.insert(20);
maxHeap.insert(5);
maxHeap.insert(30);
maxHeap.insert(15);
console.log(maxHeap.peek()); // 输出 30
console.log(maxHeap.extractMax()); // 输出 30
console.log(maxHeap.peek()); // 输出 20
要实现最小堆,只需调整条件,使得父节点总是小于或等于其子节点。
最小堆的实现
javascript
class MinHeap {
constructor() {
this.heap = [];
}
parent(index) {
return Math.floor((index - 1) / 2);
}
leftChild(index) {
return index * 2 + 1;
}
rightChild(index) {
return index * 2 + 2;
}
isLeaf(index) {
return index >= Math.floor(this.heap.length / 2) && index < this.heap.length;
}
heapify(index) {
let smallest = index;
const left = this.leftChild(index);
const right = this.rightChild(index);
if (left < this.heap.length && this.heap[left] < this.heap[smallest]) {
smallest = left;
}
if (right < this.heap.length && this.heap[right] < this.heap[smallest]) {
smallest = right;
}
if (smallest !== index) {
[this.heap[index], this.heap[smallest]] = [this.heap[smallest], this.heap[index]];
this.heapify(smallest);
}
}
insert(value) {
this.heap.push(value);
let current = this.heap.length - 1;
while (current > 0 && this.heap[this.parent(current)] > this.heap[current]) {
[this.heap[current], this.heap[this.parent(current)]] = [this.heap[this.parent(current)], this.heap[current]];
current = this.parent(current);
}
}
extractMin() {
if (this.heap.length === 0) return null;
const min = this.heap[0];
this.heap[0] = this.heap[this.heap.length - 1];
this.heap.pop();
this.heapify(0);
return min;
}
peek() {
if (this.heap.length === 0) return null;
return this.heap[0];
}
size() {
return this.heap.length;
}
}
// 示例
const minHeap = new MinHeap();
minHeap.insert(10);
minHeap.insert(20);
minHeap.insert(5);
minHeap.insert(30);
minHeap.insert(15);
console.log(minHeap.peek()); // 输出 5
console.log(minHeap.extractMin()); // 输出 5
console.log(minHeap.peek()); // 输出 10