39.剖析无处不在的数据结构
数据结构是计算机中组织和存储数据的特定方式,它的目的是方便且高效地对数据进行访问和修改。数据结构表述了数据之间的关系,以及操作数据的一系列方法。数据又是程序的基本单元,因此无论是哪种语言、哪种领域,都离不开数据结构;另一方面,数据结构是算法的基础,其本身也包含了算法的部分内容。也就是说,想要掌握算法,先有一个巩固的数据结构基础是必要条件。
前端领域也到处体现着数据结构的应用,尤其是随着需求的复杂度上升,前端工程师越来越离不开数据结构。React、Vue 这些设计精巧的框架,在线文档编辑系统、大型管理系统,甚至一个简单的检索需求,都离不开数据结构的支持。是否能够掌握这个难点内容,将是进阶的重要考量。我们应该如何学习数据结构呢?
下图是本讲内容的提纲。

数据结构和学习方法概览
我通常将数据结构分为八大类:
- 数组:Array
- 堆栈:Stack
- 队列:Queue
- 链表:Linked Lists
- 树:Trees
- 图:Graphs
- 字典树:Trie
- 散列表(哈希表):Hash Tables
这么多的类型,这节课该如何介绍呢?我认为,按部就班地只是实现各种数据结构的意义不大,这些内容读者都可以从算法书籍中找到。更重要地是应用,也只有在应用中,才能真正地记住并掌握特定的数据结构,才能在下次有类似场景时,能够想起来相关的数据结构实现。因此,这节课程我将从前端出发,从前端类库或者典型场景入手,结合数据结构来剖析其实现和应用。这需要读者首先对每种数据结构有一个大概认知,我们可以先来细化感知一下:
栈和队列是类似数组的结构,非常多的初级题目要求用数组实现栈和队列,他们在插入和删除的方式上和数组有所差异,但是实现还是非常简单的;
链表、树和图这种数据结构的特点是,其节点需要引用到其他节点,因此在增删时,需要注意对相关前驱和后继节点的影响;
可以从堆栈和队列出发,构建出链表;
树和图最为复杂,因为他们本质上是扩展了链表的概念;
散列表的关键是理解散列函数,明白依赖散列函数实现保存和定位数据的过程;
直观上认为,链表适合记录和存储数据;哈希表和字典树在检索数据以及搜索方面有更大的应用场景。
以上这些「直观感性」的认知并不是「恒等式」, 我们将在下面的学习中去印证这些「认知」,你将会看到熟悉的 React、Vue 框架的部分实现,将会看到典型的算法场景,也请读者做好基础知识的储备。
堆栈和队列
栈和队列是一种操作受限的线性结构,它们非常简单,虽然 JavaScript 并没有原生内置这样的数据结构,但是我们可以轻松地模拟出来。
栈的实现,后进先出 LIFO(Last in、First out):
class Stack {constructor(...args) {this.stack = [...args];}// Modifierspush(...items) {return this.stack.push(...items);}pop() {return this.stack.pop();}// Element accesspeek() {return this.isEmpty() ? undefined : this.stack[this.size() - 1];}// CapacityisEmpty() {return this.size() == 0;}size() {return this.stack.length;}
}
队列的实现,先进先出 FIFO(First in、First out):
class Queue {constructor(...args) {this.queue = [...args];}// Modifiersenqueue(...items) {return this.queue.push(...items);}dequeue() {return this.queue.shift();}// Element accessfront() {return this.isEmpty() ? undefined : this.queue[0];}back() {return this.isEmpty() ? undefined : this.queue[this.size() - 1];}// CapacityisEmpty() {return this.size() == 0;}size() {return this.queue.length;}
}
关于栈和队列的实际应用比比皆是:
- 浏览器的历史记录,因为回退总是回退「上一个」最近的页面,它需要遵循栈的原则;
- 类似浏览器的历史记录,任何 undo/redo 都是一个栈的实现;
- 在代码中,广泛应用的递归产生的调用栈,同样也是栈思想的体现,想想我们常说的「栈溢出」就是这个道理;
- 同上,浏览器在抛出异常时,常规都会抛出调用栈信息;
- 在计算机科学领域应用广泛,如进制转换、括号匹配、栈混洗、表达式求值等;
- 队列的应用更为直观,我们常说的宏任务 / 微任务都是队列,不管是什么类型的任务,都是先进先执行;
- 后端也应用广泛,如消息队列、RabbitMQ、ActiveMQ 等,能起到延迟缓冲的功效。
我们看到不管是栈还是队列,都是用数组来模拟的。数组是最基本的数据结构,但是它的价值是惊人的,这里稍微提一下 React hooks 的本质就是数组。给大家推荐文章:React hooks: not magic, just arrays
另外,与性能后话相关,HTTP 1.1 有一个队头阻塞问题,这个原因就在于队列这样的数据结构:我们先看 HTTP 1.0,对于同一个 tcp 连接,HTTP 1.0 是将所有请求都放入队列当中,这么一来,在客户端,「先进先出」,只有前一个请求得到了响应,下一个请求才会发出。在 HTTP 1.1 中,这样的情况得到了改观,每一个链接都默认是长链接,因此对于同一个 tcp 链接,不必等到前一个响应回来。但是这只是解决了客户端的队头阻塞问题,事实上,HTTP 1.1 规定:服务端的响应返回顺序需要遵循其接收到相应的顺序,这样的问题是:如果第一个请求处理需要较长时间,响应较慢,也都会「拖累」其他后续请求的响应,这仍然是一种队头阻塞。
HTTP 2 采用了二进制分帧和多路复用等方法, 同域名下的通信都是在同一个连接上完成,并且这种链接是双向的,在这个链接上可以并行请求和响应而互不干扰。
这里延伸的有点多了,主要是读者需要明白队列和栈这种数据结构的应用,以及利弊
链表(单向链表和双向链表)
堆栈和队列都可以用数组实现,链表同样和数组一样,都实现了按照一定的顺序存储元素,不同的地方在于链表不能像数组一样通过下标访问,而是每一个元素指向下一个元素。我们不再过多介绍链表方面的基础知识,对于链表仍不理解的读者可以先自行学习。
直观上我们就可以得出结论:链表不需要一段连续的存储空间,「指向下一个元素」的方式能够更大限度地利用内存。
根据上面结论可以继续总结,链表的优点在于:
- 链表的插入和删除操作的时间复杂度是常数级的,我们只需要改变相关节点的指针指向即可;
- 链表可以像数组一样顺序访问,查找元素的时间复杂度是线性的。
我们来看看链表的应用场景:
- React 的核心算法 Fiber 的实现就是链表
关于此我们可以稍作展开。React 最早开始使用大名鼎鼎的 Stack reconciler 调度算法,关于此在之前的课程中已经有所涉及。Stack reconciler 调度算法最大的问题在于:它是像函数调用栈一样,递归地、自顶向下进行 diff 和 render 相关操作的,在 Stack reconciler 执行的过程当中,该调度算法始终会占据浏览器主线程。也就是说在此期间,用户的交互所触发的布局行为、动画执行任务都不会被立即响应,从而影响用户体验。
因此 React Fiber 将渲染和更新过程进行了拆解,简单来说,就是每次检查虚拟 DOM 的一小部分,在检查间隙会检查「是否还有时间继续执行下一个虚拟 DOM 树上某个分支任务」,同时观察是否有更优先的任务需要响应,如果「没有时间执行下一个虚拟 DOM 树上某个分支任务」,且有更高优先级,React 就会让出主线程,直到主线程「不忙」的时候继续执行任务。
React Fiber 因此也很简单,它是将 Stack reconciler 过程分成块,一次执行一块,执行完一块需要将结果保存起来,根据是否还有空闲的响应时间(requestIdleCallback)来决定下一步策略。当所有的块都已经执行完,就进入提交阶段,这个阶段需要更新 DOM,它是一口气完成的。
以上是比较主观地介绍,我们来看更具体的实现。
为了达到「随意中断调用栈并手动操作调用栈」,React Fiber 就是专门用于 React 组件堆栈调用的重新实现,也就是说一个 Fiber 就是一个虚拟堆栈帧,一个 Fiber 的结构类似:
function FiberNode(tag: WorkTag,pendingProps: mixed,key: null | string,mode: TypeOfMode
) {// Instance// ...this.tag = tag;// Fiberthis.return = null;this.child = null;this.sibling = null;this.index = 0;this.ref = null;this.pendingProps = pendingProps;this.memoizedProps = null;this.updateQueue = null;this.memoizedState = null;this.dependencies = null;// Effects// ...this.alternate = null;
}
这么看 Fiber 就是一个对象,通过 parent、children、sibling 维护一个树形关系,同时 parent、children、sibling 也都是一个 Fiber 结构,FiberNode.alternate 这个属性来存储上一次渲染过的结果,事实上整个 Fiber 模式就是一个链表。React 也借此,从依赖于内置堆栈的同步递归模型,变为具有链表和指针的异步模型了。
具体的渲染过程:
function renderNode(node) {// 判断是否需要渲染该节点,如果 props 发生变化,则调用 renderif (node.memoizedProps !== node.pendingProps) {render(node)}// 是否有子节点,进行子节点渲染if (node.child !== null) {return node.child// 是否有兄弟节点,进行兄弟点渲染} else if (node.sibling !== null){return node.sibling// 没有子节点和兄弟节点} else if (node.return !== null){return node.return} else {return null}
}function workloop(root) {nextNode = rootwhile (nextNode !== null && (no other high priority task)) {nextNode = renderNode(nextNode)}
}
注意在 workloop 当中,while 条件 nextNode !== null && (no other high priority task),这是描述 Fiber 工作原理的关键伪代码。
当然这里是为了说明链表的数据结构,伪代码较为简略,也没有深入:
- requestAnimationFrame(callback)
- requestIdleCallback(callback)
的实现和应用。React Fiber 的介绍我们先到此为止,重点是体会链表数据结构的思想。
链表实现
实现链表,我们需要先对链表进行分类,常见的有:
- 单链表:单链表是维护一系列节点的数据结构,其特点是:每个节点包含了数据,同时包含指向链表中下一个节点的指针。
- 双向链表:不同于单链表,双向链表特点:每个节点分除了包含其数据以外,还包含了分别指向其前驱和后继节点的指针。
由于篇幅有原因,我们挑选更加复杂的双向链表进行实现,实现思路如下。
首先,根据双向链表的特点,我们实现一个节点构造函数(节点类):
class Node {constructor(data) {// data 为当前节点所储存的数据this.data = data;// next 指向下一个节点this.next = null;// prev 指向前一个节点this.prev = null;}
}
有了节点类,我们来初步实现双向链表类:
class DoublyLinkedList {constructor() {// 双向链表开头this.head = null;// 双向链表结尾this.tail = null;}// ...
}
接下来,需要实现双向链表原型上的一些方法,这些方法包括
- add:在链表尾部添加一个新的节点
- addAt:在链表指定位置添加一个新的节点
- remove:删除链表指定数据项节点
- removeAt:删除链表指定位置节点
- reverse:翻转链表
- swap:交换两个节点数据
- isEmpty:查询链表是否为空
- length:查询链表长度
- traverse:遍历链表
- find:查找某个节点的索引
add 方法:
add(item) {// 实例化一个节点let node = new Node(item);// 如果当前链表还没有头if (!this.head) {this.head = node;this.tail = node;}// 如果当前链表已经有了头,只需要在尾部加上该节点else {node.prev = this.tail;this.tail.next = node;this.tail = node;}
}
addAt 方法:
addAt(index, item) {let current = this.head// 维护查找时当前节点的索引let counter = 1let node = new Node(item)// 如果在头部插入if (index === 0) {this.head.prev = nodenode.next = this.headthis.head = node}// 非头部插入,需要从头开始,找寻插入位置else {while(current) {current = current.nextif( counter === index) {node.prev = current.prevcurrent.prev.next = nodenode.next = currentcurrent.prev = node}counter++}}
}
remove 方法:
remove(item) {let current = this.headwhile (current) {// 找到了目标节点if (current.data === item ) {// 目标链表只有当前目标项,即目标节点即是链表头又是链表尾if (current == this.head && current == this.tail) {this.head = nullthis.tail = null}// 目标节点为链表头else if (current == this.head ) {this.head = this.head.nextthis.head.prev = null}// 目标节点为链表尾部else if (current == this.tail ) {this.tail = this.tail.prev;this.tail.next = null;}// 目标节点在链表收尾之间,中部else {current.prev.next = current.next;current.next.prev = current.prev;}}current = current.next}
}
removeAt 方法:
removeAt(index) {// 都是从「头」开始遍历let current = this.headlet counter = 1// 删除链表头部if (index === 0 ) {this.head = this.head.nextthis.head.prev = null}else {while(current) {current = current.next// 如果目标节点在链表尾if (current == this.tail) {this.tail = this.tail.prevthis.tail.next = null}else if (counter === index) {current.prev.next = current.nextcurrent.next.prev = current.prevbreak}counter++}}
}
reverse 方法:
reverse() {let current = this.headlet prev = nullwhile (current) {let next = current.next// 前后倒置current.next = prevcurrent.prev = nextprev = currentcurrent = next}this.tail = this.headthis.head = prev
}
swap 方法,交换两个节点数据值:
swap(index1, index2) {// 使 index1 始终小于 index2,方便后面查找交换if (index1 > index2) {return this.swap(index2, index1)}let current = this.headlet counter = 0let firstNodewhile(current !== null) {// 找到第一个节点,先存起来if (counter === index1 ){firstNode = current}// 找到第二个节点,进行数据交换else if (counter === index2) {// ES 提供了更简洁交换数据的方法,这里我们用传统方式实现,更为直观let temp = current.datacurrent.data = firstNode.datafirstNode.data = temp}current = current.nextcounter++}return true
}
isEmpty 方法:
isEmpty() {return this.length() < 1
}
这里通过 DoublyLinkedList 类 length 的方法实现。马上看一下 length 方法:
length() {let current = this.headlet counter = 0while(current !== null) {counter++current = current.next}return counter
}
length 方法通过遍历链表,返回链表的长度。
traverse 方法:
traverse(fn) {let current = this.headwhile(current !== null) {fn(current)current = current.next}return true
}
有了上面 length 方法的遍历实现,traverse 也就不难理解了,它接受一个遍历执行函数,在 while 循环中进行调用。
最后一个 search 方法:
search(item) {let current = this.headlet counter = 0while( current ) {if( current.data == item ) {return counter}current = current.nextcounter++}return false
}
到此,我们就实现了所有 DoublyLinkedList 类双向链表的方法。仔细分析整个实现过程,可以发现:双向链表的实现并不复杂,在手写过程当中,需要开发者做到心中有表,考虑到当前节点的 next 和 prev 取值,逻辑上还是很简单的。
掌握了这些内容,在回想一下链表的应用,回想 React Fiber 的设计和实现,也许一切都变的不再神秘。
树
前端开发者应该对树这个数据结构丝毫不陌生,不同于之前介绍的所有数据结构,树是非线性的。因为树决定了其存储的数据直接有明确的层级关系,因此对于维护具有层级特性的数据,树是一个天然良好的选择。
在前面总领中,我们看到树有很多种分类,但是他们都具有以下特性:
- 除了根节点以外,所有的节点都有一个父节点
- 每一个节点都可以有若干子节点,如果没有子节点,那么称此节点为叶子节点
- 一个节点所拥有的叶子节点的个数,称之为该节点的度,因此叶子节点的度为 0
- 所有节点中,最大的度为整棵树的度
- 树的最大层次称为树的深度
从应用上来看,我们前端开发离不开的 DOM 就是一个树状结构;同理,不管是 React 还是 Vue 的虚拟 DOM 也都是树。
我们从最基本的二叉树入手,来慢慢深入。
二叉搜索树的实现和遍历
说二叉树最为基本,因为他的结构最简单,每个节点至多包含两个子节点。二叉树又非常有用:因为根据二叉树,我们可以延伸出二叉搜索树(BST)、平衡二叉搜索树(AVL)、红黑树(R/B Tree)等。
二叉搜索树有以下特性:
- 左子树上所有结点的值均小于或等于它的根结点的值
- 右子树上所有结点的值均大于或等于它的根结点的值
- 左、右子树也分别为二叉搜索树
根据其特性,我们实现二叉搜索树还是应该先构造一个节点类:
class Node {constructor(data) {this.left = nullthis.right = nullthis.value = data}
}
接着按照惯例,我们实现二叉搜索树的以下方法:
- insertNode:根据一个父节点,插入一个子节点
- insert:插入一个新节点
- removeNode:根据一个父节点,移除一个子节点
- remove:移除一个节点
- findMinNode:获取子节点的最小值
- searchNode:根据一个父节点,查找子节点
- search:查找节点
- preOrder:前序遍历
- InOrder:中序遍历
- PostOrder:后续遍历
insertNode(root, newNode) {if (newNode.value < root.value) {(!root.left) ? root.left = newNode : this.insertNode(root.left, newNode)} else {(!root.right) ? root.right = newNode : this.insertNode(root.right, newNode)}
}insert(value) {let newNode = new Node(value)if (!this.root) {this.root = newNode} else {this.insertNode(this.root, newNode)}
}
理解这两个方法是理解二叉搜索树的关键,下面的其他方法也就「不在话下」。我们看,insertNode 方法先判断目标父节点和插入节点的值,如果插入节点的值更小,则考虑放到父节点的左边,接着递归调用 his.insertNode(root.left, newNode);如果插入节点的值更大,以此类推即可。
insert 方法只是多了一步构造 Node 节点实例,接下来区分有无父节点的情况,调用 this.insertNode 方法即可。
removeNode(root, value) {if (!root) {return null}if (value < root.value) {root.left = this.removeNode(root.left, value)return root} else if (value > root.value) {root.right = tis.removeNode(root.right, value)return root} else {// 找到了需要删除的节点// 如果当前 root 节点无左右子节点if (!root.left && !root.right) {root = nullreturn root}// 只有左节点if (root.left && !root.right) {root = root.leftreturn root}// 只有右节点else if (root.right) {root = root.rightreturn root}// 有左右两个子节点let minRight = this.findMinNode(root.right)root.value = minRight.valueroot.right = this.removeNode(root.right, minRight.value)return root}}remove(value) {if (this.root) {this.removeNode(this.root, value)}
}
上述代码不难理解,可能最需要读者思考的就是:
// 有左右两个子节点
let minRight = this.findMinNode(root.right)
root.value = minRight.value
root.right = this.removeNode(root.right, minRight.value)
return root
我来特殊说明一下:当需要删除的节点含有左右两个子节点时,因为我们要把当前节点删除,就需要找到合适的「补位」节点,这个「补位」节点一定在该目标节点的右侧树当中,因为这样才能保证「补位」节点的值一定大于该目标节点的左侧树所有节点,而该目标节点的左侧树不需要调整;同时为了保证「补位」节点的值一定要小于该目标节点的右侧树值,因此要找的「补位」节点其实就是该目标节点的右侧树当中最小的那个节点。
这个过程我们借助 this.findMinNode 方法实现:
findMinNode(root) {if (!root.left) {return root} else {return this.findMinNode(root.left)}
}
该方法不断递归,直到找到最左叶子节点即可。
查找方法:
searchNode(root, value) {if (!root) {return null}if (value < root.value) {return this.searchNode(root.left, value)} else if (value > root.value) {return this.searchNode(root.right, value)}return root
}search(value) {if (!this.root) {return false}return Boolean(this.searchNode(this.root, value))
}
这也比较简单,其实就是对递归的运用。最能体现递归简便优势的其实是对于树的遍历:
前序遍历:
preOrder(root) {if (root) {console.log(root.value)this.preOrder(root.left)this.preOrder(root.right)}
}
中序遍历:
inOrder(root) {if (root) {this.inOrder(root.left)console.log(root.value)this.inOrder(root.right)}
}
后序遍历:
postOrder(root) {if (root) {this.postOrder(root.left)this.postOrder(root.right)console.log(root.value)}
}
前后中序遍历其实就在于 console.log(root.value) 方法执行的位置。
字典树
字典树(Trie)是针对特定类型的搜索而优化的树数据结构。典型的例子是 autoComplete,也就是说它适合实现:通过部分值得到完整值的场景。字典树因此也是一种搜索树,我们有时候也叫做前缀树,因为任意一个节点的后代都存在共同的前缀。更多基础概念请读者先做了解。我们总结一下它的特点:
- 字典树能做到高效查询和插入,时间复杂度为 O(k),k 为字符串长度
- 但是如果大量字符串没有共同前缀,那就很耗内存,读者可以想象一下最极端的情况,所有单词都没有共同前缀时,这颗字典树是什么样子
- 字典树的核心就是减少没必要的字符比较,使查询高效率,也就是说用空间换时间,再利用共同前缀来提高查询效率
除了我们刚刚提到的 autoComplete 自动填充的情况,字典树还有很多其他应用场景:
- 搜索
- 输入法选项
- 分类
- IP 地址检索
- 电话号码检索
字典树的实现和遍历
字典树的实现也不复杂,我们慢慢一步步来,首先实现一个字典树上的节点:
class PrefixTreeNode {constructor(value) {// 存储子节点this.children = {}this.isEnd = nullthis.value = value}
}
一个字典树继承 PrefixTreeNode 类:
class PrefixTree extends PrefixTreeNode {constructor() {super(null)}
}
我们实现方法:
- addWord:创建一个字典树节点
- predictWord:给定一个字符串,返回字典树中以该字符串开头的所有单词
addWord 实现:
addWord(str) {const addWordHelper = (node, str) => {// 当前 node 不含当前 str 开头的目标if (!node.children[str[0]]) {// 以当前 str 开头的第一个字母,创建一个 PrefixTreeNode 实例node.children[str[0]] = new PrefixTreeNode(str[0])if (str.length === 1) {node.children[str[0]].isEnd = true}else if (str.length > 1) {addWordHelper(node.children[str[0]], str.slice(1))}}}addWordHelper(this, str)
}
predictWord 实现:
predictWord(str) {let getRemainingTree = function(str, tree) {let node = treewhile (str) {node = node.children[str[0]]str = str.substr(1)}return node}// 该数组维护所有以 str 开头的单词let allWords = []let allWordsHelper = function(stringSoFar, tree) {for (let k in tree.children) {const child = tree.children[k]let newString = stringSoFar + child.valueif (child.endWord) {allWords.push(newString)}allWordsHelper(newString, child)}}let remainingTree = getRemainingTree(str, this)if (remainingTree) {allWordsHelper(str, remainingTree)}return allWords
}
图
图是由具有边的节点集合组成的数据结构,图可以是定向的或不定向的。因此图可以分为好多种类,这里不一一讲解,主要看图的应用场景:
- LBS 地图服务以及 GPS 系统
- 社交媒体网站的用户关系图
- 前端工程化中的开发依赖图
- 搜索算法使用图,保证搜索结果的相关性
- 寻找降低运输和交付货物和服务成本的最佳途径
图也是应用最广泛的数据结构之一,真实场景中处处有图。更多概念还是需要读者先进行了解,尤其是图的几种基本元素:
- 节点 Node
- 边 Edge
- |V| 图中顶点(节点)的总数
- |E| 图中的连接总数(边)
图的实现和遍历
这里我们主要实现一个有向图,Graph 类:
class Graph {constructor() {this.AdjList = new Map()}
}
使用 Map 数据结构表述图中顶点关系。
实现方法:
- 添加顶点:addVertex
- 添加边:addEdge
- 打印图:print
- 广度优先算法遍历
- 深度优先算法
addVertex 方法:
addVertex(vertex) {if (!this.AdjList.has(vertex)) {this.AdjList.set(vertex, [])} else {throw 'vertex already exist!'}
}
创建顶点:
let graph = new Graph();
graph.addVertex('A')
graph.addVertex('B')
graph.addVertex('C')
graph.addVertex('D')
其中 A、B、C、D 顶点都对应一个数组:
'A' => [],'B' => [],'C' => [],'D' => []
该数组将用来存储边。我们设计图预计得到如下关系:
Map {'A' => ['B', 'C', 'D'],'B' => [],'C' => ['B'],'D' => ['C']
}
根据此描述,其实已经可以把图画出来了。addEdge 因此需要两个参数:一个是顶点,一个是连接对象 Node:
addEdge(vertex, node) {if (this.AdjList.has(vertex)) {if (this.AdjList.has(node)){let arr = this.AdjList.get(vertex)if(!arr.includes(node)){arr.push(node)}}else {throw `Can't add non-existing vertex ->'${node}'`}} else {throw `You should add '${vertex}' first`}
}
理清楚数据关系,我们就可以打印图了,其实就是一个很简单的 for...of 循环:
print() {for (let [key, value] of this.AdjList) {console.log(key, value)}
}
剩下的内容就是遍历图了。
广度优先算法(BFS),是一种利用队列实现的搜索算法。对于图,其搜索过程和 「湖面丢进一块石头激起层层涟漪」 类似。换成算法语言,就是从起点出发,对于每次出队列的点,都要遍历其四周的点。
因此 BFS 的实现步骤:
- 起始节点作为起始,并初始化一个空对象:visited
- 初始化一个空数组,该数组将模拟一个队列
- 将起始节点标记为已访问
- 将起始节点放入队列中
- 循环直到队列为空
实现:
createVisitedObject() {let map = {}for(let key of this.AdjList.keys()) {arr[key] = false}return map
}bfs(initialNode) {// 创建一个已访问节点的 maplet visited = this.createVisitedObject()// 模拟一个队列let queue = []// 第一个节点已访问visited[initialNode] = true// 第一个节点入队列queue.push(initialNode)while(queue.length) {let current = queue.shift()console.log(current)// 获得该节点的其他节点关系let arr = this.AdjList.get(current)for (let elem of arr) {// 如果当前节点没有访问过if (!visited[elem]) {visited[elem] = trueq.push(elem)}}}
}
那么对于深度优先搜索算法(DFS),我把它总结为:「不撞南墙不回头」,从起点出发,先把一个方向的点都遍历完才会改变方向。换成程序语言就是:「DFS 是利用递归实现的搜索算法」。
因此 DFS 过程:
- 起始节点作为起始,创建访问对象
- 调用辅助函数递归起始节点
实现代码:
createVisitedObject() {let map = {}for (let key of this.AdjList.keys()) {arr[key] = false}return map
}dfs(initialNode) {let visited = this.createVisitedObject()this.dfsHelper(initialNode, visited)}dfsHelper(node, visited) {visited[node] = trueconsole.log(node)let arr = this.AdjList.get(node)for (let elem of arr) {if (!visited[elem]) {this.dfsHelper(elem, visited)}}}
}
BFS 的重点在于队列,而 DFS 的重点在于递归,这是它们的本质区别。
图在前端中的应用
图其实在前端中应用不算特别多,但绝对还是不容忽视的一部分。这里我举一个我现实中应用的例子——循环图。
在前端工程化发展的今天,理清项目中的依赖关系:比如查找项目中的循环依赖,可视化依赖都是图的应用,有助于开发者在宏观上把控工程化项目。在我们的项目中,我借助 mermaidj 画图工具,实现了项目依赖的完全可视化。并借助 npm script 来生成图片结果,相关 script 脚本:
yarn graph
脚本:
import glob from 'glob'
import readJSON from 'XXX/utils/readJSON'const pkgs = glob.sync('packages/*/package.json').map(readJSON)const deps = {}for (const pkg of pkgs) {deps[pkg.name] = Object.keys(pkg.dependencies || []).filter(dep =>// ...)
}const graph = { code: '', mermaid: { theme: 'default' } }graph.code += 'graph TD;'
for (const name in deps) {for (const dep of deps[name]) {graph.code += `${name}-->${dep};`}
}const base64 = Buffer.from(JSON.stringify(graph)).toString('base64')/* eslint-disable-next-line */
console.log(`Open in browser: https://mermaidjs.github.io/mermaid-live-editor/#/edit/${base64}`
)
上述代码,我首先获取到 packages/*/package.json 中声明的所有依赖,然后对依赖进行必要性过滤之后,维护到 deps 对象当中,按照 mermaid 需求,将 monorepo 项目中的每一个子项目名和依赖按照 → 的间隔维护为 graph.code,最后通过生成 base64 交给 mermaid 进行绘图,绘图过程会根据约定(→ 的标记),成生可视化的依赖图。
最终效果:

那么 mermaid 是如何对图进行绘制的呢?了解了课程前面实现图的代码,我们再看 mermaid 绘制图的部分源码实现:export const addVertices = function (vert, g, svgId) {const svg = d3.select(`[id="${svgId}"]`)const keys = Object.keys(vert)const styleFromStyleArr = function (styleStr, arr) {// Create a compound style definition from the style definitions found for the node in the graph definitionfor (let i = 0; i < arr.length; i++) {if (typeof arr[i] !== 'undefined') {styleStr = styleStr + arr[i] + ';'}}return styleStr}// Iterate through each item in the vertex object (containing all the vertices found) in the graph definitionkeys.forEach(function (id) {const vertex = vert[id]/*** Variable for storing the classes for the vertex* @type {string}*/let classStr = ''if (vertex.classes.length > 0) {classStr = vertex.classes.join(' ')}/*** Variable for storing the extracted style for the vertex* @type {string}*/let style = ''// Create a compound style definition from the style definitions found for the node in the graph definitionstyle = styleFromStyleArr(style, vertex.styles)// Use vertex id as text in the box if no text is provided by the graph definitionlet vertexText = vertex.text !== undefined ? vertex.text : vertex.id// We create a SVG label, either by delegating to addHtmlLabel or manuallylet vertexNodeif (conf.htmlLabels) {// TODO: addHtmlLabel accepts a labelStyle. Do we possibly have that?const node = { label: vertexText.replace(/fa[lrsb]?:fa-[\w-]+/g, s => ``) }vertexNode = addHtmlLabel(svg, node).node()vertexNode.parentNode.removeChild(vertexNode)} else {const svgLabel = document.createElementNS('http://www.w3.org/2000/svg', 'text')const rows = vertexText.split(//)for (let j = 0; j < rows.length; j++) {const tspan = document.createElementNS('http://www.w3.org/2000/svg', 'tspan')tspan.setAttributeNS('http://www.w3.org/XML/1998/namespace', 'xml:space', 'preserve')tspan.setAttribute('dy', '1em')tspan.setAttribute('x', '1')tspan.textContent = rows[j]svgLabel.appendChild(tspan)}vertexNode = svgLabel}// If the node has a link, we wrap it in a SVG linkif (vertex.link) {const link = document.createElementNS('http://www.w3.org/2000/svg', 'a')link.setAttributeNS('http://www.w3.org/2000/svg', 'href', vertex.link)link.setAttributeNS('http://www.w3.org/2000/svg', 'rel', 'noopener')link.appendChild(vertexNode)vertexNode = link}let radious = 0let _shape = ''// Set the shape based parametersswitch (vertex.type) {case 'round':radious = 5_shape = 'rect'breakcase 'square':_shape = 'rect'breakcase 'diamond':_shape = 'question'breakcase 'odd':_shape = 'rect_left_inv_arrow'breakcase 'lean_right':_shape = 'lean_right'breakcase 'lean_left':_shape = 'lean_left'breakcase 'trapezoid':_shape = 'trapezoid'breakcase 'inv_trapezoid':_shape = 'inv_trapezoid'breakcase 'odd_right':_shape = 'rect_left_inv_arrow'breakcase 'circle':_shape = 'circle'breakcase 'ellipse':_shape = 'ellipse'breakcase 'group':_shape = 'rect'breakdefault:_shape = 'rect'}// Add the nodeg.setNode(vertex.id, { labelType: 'svg', shape: _shape, label: vertexNode, rx: radious, ry: radious, 'class': classStr, style: style, id: vertex.id })})
}/**
* Add edges to graph based on parsed graph defninition
* @param {Object} edges The edges to add to the graph
* @param {Object} g The graph object
*/
export const addEdges = function (edges, g) {let cnt = 0let defaultStyleif (typeof edges.defaultStyle !== 'undefined') {defaultStyle = edges.defaultStyle.toString().replace(/,/g, ';')}edges.forEach(function (edge) {cnt++const edgeData = {}// Set link type for renderingif (edge.type === 'arrow_open') {edgeData.arrowhead = 'none'} else {edgeData.arrowhead = 'normal'}let style = ''if (typeof edge.style !== 'undefined') {edge.style.forEach(function (s) {style = style + s + ';'})} else {switch (edge.stroke) {case 'normal':style = 'fill:none'if (typeof defaultStyle !== 'undefined') {style = defaultStyle}breakcase 'dotted':style = 'stroke: #333; fill:none;stroke-width:2px;stroke-dasharray:3;'breakcase 'thick':style = 'stroke: #333; stroke-width: 3.5px;fill:none'break}}edgeData.style = styleif (typeof edge.interpolate !== 'undefined') {edgeData.curve = interpolateToCurve(edge.interpolate, d3.curveLinear)} else if (typeof edges.defaultInterpolate !== 'undefined') {edgeData.curve = interpolateToCurve(edges.defaultInterpolate, d3.curveLinear)} else {edgeData.curve = interpolateToCurve(conf.curve, d3.curveLinear)}if (typeof edge.text === 'undefined') {if (typeof edge.style !== 'undefined') {edgeData.arrowheadStyle = 'fill: #333'}} else {edgeData.arrowheadStyle = 'fill: #333'if (typeof edge.style === 'undefined') {edgeData.labelpos = 'c'if (conf.htmlLabels) {edgeData.labelType = 'html'edgeData.label = '' + edge.text + ''} else {edgeData.labelType = 'text'edgeData.style = edgeData.style || 'stroke: #333; stroke-width: 1.5px;fill:none'edgeData.label = edge.text.replace(/
/g, '\n')}} else {edgeData.label = edge.text.replace(/
/g, '\n')}}// Add the edge to the graphg.setEdge(edge.start, edge.end, edgeData, cnt)})
}
那么根据我的脚本,用 → 表现的依赖关系,除了可视化以外,还有其他用处吗? 其实肯定是有的,除了「花架子」,这个依赖图对于项目的部署构建也有非常重要的作用。比如在对 monorepo 项目进行构建时,因为子项目过多,导致构建时间过长。为此,我给出的方案是增量构建,如果这次改动只设计项目 A、项目 B,以及公共依赖 C,那么项目 C,项目 D 等其他项目在构建时只需要读取缓存构建结果即可。思路是很简单,但是一个直接问题是,如果检测说真正需要构建的项目呢?
举个例子,项目 A 依赖公共依赖 C,那么及时通过 git hook 拿到的 diff 表明项目 A 并没有代码变动,但是可能因为 C 变了,我们还需要重新构建项目 A(因为 A 依赖 C)。按照正常的思路,需要遍历整个项目,这样带来的问题是增加了回溯构建的可能:构建时先遍历到 A,读取缓存,再遍历到 C 时,不得不回退到 A,重新构建。解决思路就是使用一个拓扑图,根据拓扑图,按照一定的顺序进行遍历和编译构建即可。
这是我近期一个使用到拓扑图数据结构的经典场景。具体实施过程因为机密性,我不在贴代码了,对于读者来说,更重要地是体会思想,相信自己动手实现也不会困难。
散列表(哈希表)
散列表是一种以 key-value 形式存储数据的数据结构,可以把散列表理解为一种高级的数组,这种数组的下标可以是很大的整数,浮点数,字符串甚至结构体。这种数据结构非常有用,js 里的 Map/Set/WeakMap/WeakSet 在 v8 里都是通过散列表来实现的,再比如 LRU Cache、数据库索引等非常多的场景也都能看到散列表的身影。
散列并不仅仅是一种技术,从某种意义上讲,它甚至是一种思想。接下来让我们一起揭开散列表神秘的面纱。
假如,我们要存储 key 为 6、2019、2333333 的三组数据,如果用数组来存,至少需要一个长度为 2333333 的数组来做这件事情,显然这种做法存在大量的空间浪费。
我们也可以像下图一样,准备一个长度为 10 的数组(bucket array),将每一个 key 通过一个散列函数(hash function),映射到桶数组中的一位,将 key 相应的值直接存入即可。可以看到这种方式只需使用一个长度为 10 的数组,同时查找和插入的时间复杂度都是 O(1)。这就是散列表的核心思想。

散列表中的几个概念:
- 桶(bucket),用来直接存放或间接指向一个数据
- 桶数组(bucket array)由桶组成的数组
- 散列函数(hash function)将 key 转换为桶数组下标的函数
上面的例子比较简单,如果我们继续在之前的基础上再存储一个 key 为 9 的数据,通过 9 % 10 计算得出的也是落在下标为 9 的 bucket 上,此时有两个不同的 key 落在了同一个 bucket 上,这一现象被称为散列冲突。
散列冲突理论上是不可避免的,我们能做的优化主要从以下两个方面入手:
- 精心设计桶数组长度及散列函数,尽可能降低冲突的概率
- 发生冲突时,能对冲突进行排解
假设不用散列表直接用数组来存储需要的数组长度为 R,用散列表存储需要的桶数组长度为 M,需要存储的元素个数为 N,则一定存在以下关系 N < M << R,只有这样散列表才能既保持操作的高效同时起到节省空间的效果。
其中,N / M 称为散列表的装载因子,当装载因子超过一定的阈值时,需要对桶数组扩容并 rehash。
理想的散列函数遵循以下的设计原则:
- 确定:同一 key 总是被映射至同一地址
- 高效:插入/查找/删除 excepted-O(1) 时间复杂度
- 满射:尽可能充分地覆盖整个桶数组空间
- 均匀:key 映射到桶数组各位置的概率尽量接近
常用的散列函数如下。
除余法
hash(key) = key % M,直接对 key 按桶数组的长度取余,这种方法非常简单,但存在以下缺陷。
- 存在不动点:无论桶数组长度 M 取何值,总有 hash(0) = 0,这与任何元素都有均等的概率被映射到任何位置的原则相违背。
- 零阶均匀:[0, R) 的关键码,平均分配至 M 个桶;但相邻关键码的散列地址也必相邻。
MAD 法 multiply-add-divide
hash(key) = (a x key + b) % M,跟除余法相比,引入的变量 b 可以视作偏移量,可有效的消除不动点,另一个变量 a 扮演着步长的角色,也就是说原本相邻的关键码在经过散列后步长为 a,从而不再继续相邻。
平方取中 mid-square
取 key^2 的中间若干位,构成地址:
hash(123) = 512 // 保留 key^2 = 123^2 = 15219 的中间 3 位
hash(1234567) = 556 // 1234567^2 = 1524155677489
我们可以将一个数的平方运算,分解为一系列的左移操作以及若干次加法,从下图中不难看出,每一个数位都是由原关键码中的若干数位经求和得到的,因此两侧的数位由更少的原数位求和而得,越是居中的数位,则是由更多的原数位积累而得,因此截取居中的若干位,可使得原关键码的各数位都能对最终结果产生影响,从而实现更好的均匀性

多项式法
在实际应用中,我们的 key 不一定都是整数形式,因此往往需要一个预处理将其转换为散列码(hashcode),然后才可以对其进一步处理为桶数组的下标地址。整个过程可以描述为 key → hashcode → bucket addr,多项式法就是一种有效的将字符串 key 转换为 hashcode 的方法 对于一个长度为 n 的字符串,其计算过程如下:
hash(x0 x1 ... xn-1) = x0 * a^(n-1) + x1 * a^(n-2) ... + xn-2 * a + xn-1
// 如果上面的不是很理解,它其实等价于下面这样
(...((x0 * a + x1) * a + x2) * a + ... xn-2) * a + xn-1)
这个多项式可以在 O(n) 而不是 O(n2) 的时间复杂度内计算出结果,具体证明的过程这里就不详细展开了。
在实际的工程中会采用如下这种近似多项式,但更快捷的做法:
function hash(key) {let h = 0for (let n = key.length, i = 0; i != n; i++) {h = (h << 5 | h >> 27)h += key[i].charCodeAt()}return h >>> 0
}
通过一个循环依次处理字符串的每一个字符,对于每一个字符将它转换为整数后累加,在累加之前对原有的累积值,都按照 h << 5 | h >> 27 这样的规则做一个数位变换

冲突解决方法
主要的处理散列表冲突的方法有开链法和探测法这两类。
- 开链法(linked-list chaining / seperate chaining)
每个桶存放一个指针,将冲突的 key 以链表的形式组织起来,这种处理方式最大的优点是能解决任意次数的冲突,但缺点也很明显,最极端的情况所有的 key 数据都落在一个桶上时,散列表将退化为一个链表,查找插入删除的复杂度都将变成 O(n)。

- 探测法(open addressing / closed hashing)
探测法所有的冲突都在这块连续的空间中加以排解,而不用像开链法那样申请额外的空间。当存入一个 key 时,所有的桶都按照某种优先级关系排成一个序列,从本该属于该 key 的桶出发,顺次查看每一个桶直到找到可用的桶。每个 key 对应的这样的一个序列,称为试探序列或者查找链,在查找 key 时,沿查找链查找有两种结果,在桶中找到了查询的 key 也就是查找成功,还有的一种可能是找到一个空桶,则说明查找失败,没有这个 key。
最简单的试探序列的生成方法叫做线性试探(Linear probing),具体做法是一旦发生冲突,则试探后一个紧邻的桶单元,直到成功或失败。这种做法的优点是无需附加的(指针、链表等)空间,缺点也很明显,以往的冲突会导致后续的冲突。
[hash(key) + 1] % M
[hash(key) + 2] % M
[hash(key) + 3] % M
...
线性试探的问题根源在于大部分的试探位置都集中在某一个相对较小的局部,因此优化线性试探的方式就是适当的拉开各次探测的间距,平方试探(Quadratic Probing)就是基于这一优化思路的具体实现方式,所谓平方试探顾名思义就是以平方数为距离,确定下一试探桶单元。
[hash(key) + 1^2] % M
[hash(key) + 2^2] % M
[hash(key) + 3^2] % M
...
相对于线性试探,平方探测的确可以在很大程度上缓解数据聚集的现象,查找链上,各桶间距线性递增,一旦冲突,可从没地跳是非之地。
散列表的实现
最后用 JavaScript 来模拟实现一下 hashtable,这里我们采用开链法来解决散列的冲突。
// 单向链表节点
class ForwardListNode {constructor(key, value) {this.key = keythis.value = valuethis.next = null}
}class Hashtable {constructor(bucketSize = 97) {this._bucketSize = bucketSizethis._size = 0this._buckets = new Array(this._bucketSize)}hash(key) {let h = 0for (let n = key.length, i = 0; i != n; i++) {h = (h << 5 | h >> 27)h += key[i].charCodeAt()}return (h >>> 0) % this._bucketSize}// Modifiersput(key, value){let index = this.hash(key);let node = new ForwardListNode(key, value)if (!this._buckets[index]) {// 如果桶是空的,则直接把新节点放入桶中即可this._buckets[index] = node} else {// 如果桶不为空,则在链表头插入新节点node.next = this._buckets[index]this._buckets[index] = node}this._size++return index} delete(key) {let index = this.hash(key)if (!this._buckets[index]) {return false}// 添加一个虚拟头节点,方便后面的删除操作let dummy = new ForwardListNode(null, null)dummy.next = this._buckets[index]let cur = dummy.next, pre = dummywhile (cur) {if (cur.key === key) {// 从链表删除该节点pre.next = cur.nextcur = pre.nextthis._size--} else {pre = curcur = cur.next}}this._buckets[index] = dummy.nextreturn true}// Lookupfind(key){let index = this.hash(key);// 如果对应的 bucket 为空,说明不存在此 keyif (!this._buckets[index]) {return null}// 遍历对应桶的链表let p = this._buckets[index]while (p) {// 找到 keyif (p.key === key) {return p.value}p = p.next}return null}// Capacitysize() {return this._size}isEmpty() {return this._size == 0}
}
总结
这一节课我们介绍了和前端最为贴合的几种数据结构,虽然篇幅较长,但是内容算不上太难。一些基本概念并没有深入讲解, 因为数据结构更重要的是应用,我希望读者能够做到的是:在需要的场景,能够想到最为适合的数据结构处理问题。请读者务必掌握好这些内容,接下来的算法章节需要对数据结构有一个较为熟练地掌握和了解。

喜欢的朋友记得点赞、收藏、关注哦!!!
相关文章:
39.剖析无处不在的数据结构
数据结构是计算机中组织和存储数据的特定方式,它的目的是方便且高效地对数据进行访问和修改。数据结构表述了数据之间的关系,以及操作数据的一系列方法。数据又是程序的基本单元,因此无论是哪种语言、哪种领域,都离不开数据结构&a…...
基于 Vue 的Tiptap 富文本编辑器使用指南
目录 🧰 技术栈 📦 所需依赖 📁 文件结构 🧱 编辑器组件实现(components/Editor.vue) ✨ 常用操作指令 🧠 小贴士 🧩 Tiptap 扩展功能使用说明(含快捷键与命令&am…...
【音视频】AAC-ADTS分析
AAC-ADTS 格式分析 AAC⾳频格式:Advanced Audio Coding(⾼级⾳频解码),是⼀种由MPEG-4标准定义的有损⾳频压缩格式,由Fraunhofer发展,Dolby, Sony和AT&T是主 要的贡献者。 ADIF:Audio Data Interchange Format ⾳…...
vue中将elementUI和echarts转成pdf文件
若要将包含 ElementUI 组件数据和多个 ECharts 图表的数据转换为 PDF 文档,可结合 html2canvas、jspdf 以及 dom-to-image 来实现。其中,html2canvas 和 dom-to-image 可将 ECharts 图表转换为图片,jspdf 则用于生成 PDF 文档。对于 ElementU…...
基于 Electron、Vue3 和 TypeScript 的辅助创作工具全链路开发方案:涵盖画布系统到数据持久化的完整实现
基于 Electron、Vue3 和 TypeScript 的辅助创作工具全链路开发方案:涵盖画布系统到数据持久化的完整实现 引言 在数字内容创作领域,高效的辅助工具是连接创意与实现的关键桥梁。创作者需要一款集可视化画布、节点关系管理、数据持久化于一体的专业工具&…...
本地部署DeepSeek-R1模型接入PyCharm
以下是DeepSeek-R1本地部署及接入PyCharm的详细步骤指南,整合了视频内容及官方文档核心要点: 一、本地部署DeepSeek-R1模型 1. 安装Ollama框架 下载安装包 访问Ollama官网(https://ollama.com/download)或通过视频提供的百度云盘链接下载对应系统的安装包。Windows用户…...
基于LightGBM-TPE算法对交通事故严重程度的分析与可视化
基于LightGBM-TPE算法对交通事故严重程度的分析与可视化 原文: Analysis and visualization of accidents severity based on LightGBM-TPE 1. 引言部分 文章开篇强调了道路交通事故作为意外死亡的主要原因,引起了多学科领域的关注。分析事故严重性特…...
音视频小白系统入门课-3
本系列笔记为博主学习李超老师课程的课堂笔记,仅供参阅 往期课程笔记传送门: 音视频小白系统入门笔记-0音视频小白系统入门笔记-1音视频小白系统入门笔记-2 视频: 由一组图像组成:像素、分辨率、RGB 8888(24位) 、RGBA(32位)为…...
考研系列-计算机网络-第五章、传输层
一、传输层提供的服务 1.重点知识...
将Ubuntu系统中已有的Python环境迁移到Anaconda的虚拟环境中
需求:关于如何将Ubuntu系统中已有的Python环境迁移到Anaconda的虚拟环境test2里,而且他们提到用requirements.txt 安装一直报错,所以想尝试直接拷贝的方法。 可以尝试通过直接拷贝移植的方式迁移Python环境到Anaconda虚拟环境,但…...
AI 数字短视频数字人源码开发:多维赋能短视频生态革新
在短视频行业深度发展的进程中,AI 数字短视频数字人源码开发凭借其独特的技术优势,从多个维度为行业生态带来了革命性的变化,重塑短视频创作、传播与应用的格局。 数据驱动,实现内容精准化创作 AI 数字短视频数字人源码开发能够深…...
ffmpeg 硬解码相关知识
一:FFMPEG 支持的硬解方式:如下都是了解知识 DXVA2 - windows DXVA2 硬件加速技术解析 一、核心特性与适用场景 技术定义:DXVA2(DirectX Video Acceleration 2)是微软推出的基于 DirectX 的硬件加速标准…...
Ubuntu数据连接访问崩溃问题
目录 一、分析问题 1、崩溃问题本地调试gdb调试: 二、解决问题 1. 停止 MySQL 服务 2. 卸载 MySQL 相关包 3. 删除 MySQL 数据目录 4. 清理依赖和缓存 5.重新安装mysql数据库 6.创建程序需要的数据库 三、验证 1、动态库更新了 2、头文件更新了 3、重新…...
边缘计算全透视:架构、应用与未来图景
边缘计算全透视:架构、应用与未来图景 一、产生背景二、本质三、特点(一)位置靠近数据源(二)分布式架构(三)实时性要求高 四、关键技术(一)硬件技术(二&#…...
迅为iTOP-RK3576开发板/核心板6TOPS超强算力NPU适用于ARM PC、边缘计算、个人移动互联网设备及其他多媒体产品
迅为iTOP-3576开发板采用瑞芯微RK3576高性能、低功耗的应用处理芯片,集成了4个Cortex-A72和4个Cortex-A53核心,以及独立的NEON协处理器。它适用于ARM PC、边缘计算、个人移动互联网设备及其他多媒体产品。 支持INT4/INT8/INT16/FP16/BF16/TF32混合运算&a…...
前沿分享|技术雷达202504月刊精华
本期雷达 ###技术部分 7. GraphRAG 试验 在上次关于 检索增强生成(RAG)的更新中,我们已经介绍了GraphRAG。它最初在微软的文章中被描述为一个两步的流程: (1)对文档进行分块,并使用基于大语言…...
[创业之路-380]:企业法务 - 企业经营中,企业为什么会虚开増值税发票?哪些是虚开増值税发票的行为?示例?风险?
一、动机与风险 1、企业虚开增值税发票的动机 利益驱动 骗抵税款:通过虚开发票虚增进项税额,减少应纳税额,降低税负。公司套取国家的利益。非法套现:虚构交易开具发票,将资金从公司账户转移至个人账户,用…...
嵌入式:ARM公司发展史与核心技术演进
一、发展历程:从Acorn到全球算力基石 1. 起源(1978-1990) 1978年:奥地利物理学家Hermann Hauser与工程师Chris Curry创立剑桥处理器公司(CPU Ltd.),后更名为**艾康电脑(Acor…...
ubuntu的各种工具配置
1.nfs:虚拟机桥接模式下,开发板和虚拟机保持在同一网段下,开发板不要直连电脑 挂载命令:mount -v -t nfs 192.168.110.154:/home/lhj /mnt -o nolock (1) 安装 NFS 服务器 sudo apt update sudo apt install nfs-kernel-server -y…...
Go 剥离 HTML 标签的三把「瑞士军刀」——从正则到 Bluemonday
1 为什么要「剥皮」? 安全:去掉潜在的 <script onload…> 等恶意标签,防止存储型 XSS。可读性:日志、消息队列、搜索索引里往往只需要纯文本。一致性:不同富文本编辑器生成的 HTML 五花八门,统一成「…...
【Java面试笔记:基础】6.动态代理是基于什么原理?
1. 反射机制 定义:反射是 Java 语言提供的一种基础功能,允许程序在运行时自省(introspect),直接操作类或对象。功能: 获取类定义、属性和方法。调用方法或构造对象。运行时修改类定义。 应用场景ÿ…...
docker容器中uv的使用
文章目录 TL;DRuv简介uv管理项目依赖step 1step 2WindowsLinux/Mac step 3依赖包恢复 在Docker容器中使用uv TL;DR 本文记录uv在docker容器中使用注意点, uv简介 uv是用rust编写的一个python包管理器,特点是速度快,且功能强大,目标是替代p…...
分部积分选取u、v的核心是什么?
分部积分选取u、v的核心是什么?是反对幂指三吗? 不全是,其实核心是:v要比u更容易积分,也就是更容易求得原函数,来看一道例题:...
Android Studio调试中的坑二
下载新的Android studio Meerkat后,打开发现始终无法更新对应的SDK,连Android 15的SDK也无法在SDK Manger中显示出来,但是Meerkat必须要使用新版本SDK。 Android studio下载地址 命令行工具 | Android Studio | Android Developers 解决…...
【Redis】缓存三剑客问题实践(上)
本篇对缓存三剑客问题进行介绍和解决方案说明,下篇将进行实践,有需要的同学可以跳转下篇查看实践篇:(待发布) 缓存三剑客是什么? 缓存三剑客指的是在分布式系统下使用缓存技术最常见的三类典型问题。它们分…...
2025年4月22日(平滑)
在学术和工程语境中,表达“平滑”需根据具体含义选择术语。以下是专业场景下的精准翻译及用法解析: 1. 数学/信号处理中的「平滑」(消除噪声) Smooth (verb/noun/adjective) “Apply a Gaussian filter to smooth the noisy signa…...
给vue-admin-template菜单栏 sidebar-item 添加消息提示
<el-badge :value"200" :max"99" class"item"><el-button size"small">评论</el-button> </el-badge> <!-- 在 SidebarItem.vue 中 --> <template><div v-if"!item.hidden" class&q…...
C++(初阶)(十二)——stack和queue
十二,stack和queue 十二,stack和queueStackQueuepriority_queue 简单使用模拟实现deque Stack 函数说明stack()构造空栈empty()判断栈是否为空size()返回栈的有效元素个数top()返会栈顶元素的引用push()将所给元素val压入栈中pop()将栈的尾部元素弹出 …...
数据采集:AI 发展的基石与驱动力
人工智能(AI)无疑是最具变革性的技术力量之一,正以惊人的速度重塑着各行各业的格局。从智能语音助手到自动驾驶汽车,从精准的医疗诊断到个性化的推荐系统,AI 的广泛应用已深刻融入人们的日常生活与工作的各个层面。而在…...
Kubernetes Docker 部署达梦8数据库
Kubernetes & Docker 部署达梦8数据库 一、达梦镜像获取 目前达梦官方暂未在公共镜像仓库提供Docker镜像,需通过达梦官网联系获取官方镜像包。 二、Kubernetes部署方案 部署配置文件示例 apiVersion: apps/v1 kind: Deployment metadata:labels:app: dm8na…...
宏碁笔记本电脑怎样开启/关闭触摸板
使用快捷键:大多数宏碁笔记本可以使用 “FnF7” 或 “FnF8” 组合键来开启或关闭触摸板,部分型号可能是 “FnF2”“FnF9” 等。如果不确定,可以查看键盘上的功能键图标,一般有触摸板图案的按键就是触摸板的快捷键。通过设备管理器…...
计算机组成与体系结构:缓存(Cache)
目录 为什么需要 Cache? 🧱 Cache 的分层设计 🔹 Level 1 Cache(L1 Cache)一级缓存 🔹 Level 2 Cache(L2 Cache)二级缓存 🔹 Level 3 Cache(L3 Cache&am…...
【VS Code】打开远程服务器Docker项目或文件夹
1、配置SSH连接 在VS Code中,按CtrlShiftP打开命令面板。 输入并选择Remote-SSH: Connect to Host...。 输入远程服务器的SSH地址(例如userhostname或userip_address)。 如果这是您第一次连接到该主机,VS Code可能会要求您配置…...
docker 常见命令
指定服务名查看日志 docker-compose logs -f doc-cleaning docker inspect id 启动所有服务 在docker-compose目录下 docker-compose up -d docker-compose down会删除容器和网络 docker compose stop redis rabbitmq docker compose stop可以快速停止服务,方…...
C#抽象类和虚方法的作用是什么?
抽象类 (abstract class): 不能直接实例化,只能被继承。 用来定义一套基础框架和规范,强制子类必须实现某些方法(抽象方法)。 可用来封装一些共通的逻辑,减少代码重复。 虚方法 (virtual): …...
redis数据类型-基数统计HyperLogLog
redis数据类型-基数统计HyperLogLog 文档 redis单机安装redis常用的五种数据类型redis数据类型-位图bitmap 说明 官网操作命令指南页面:https://redis.io/docs/latest/commands/?nameget&groupstringHyperLogLog介绍页面:https://redis.io/docs…...
音视频学习 - MP3格式
环境 JDK 13 IDEA Build #IC-243.26053.27, built on March 16, 2025 Demo MP3Parser MP3 MP3全称为MPEG Audio Layer 3,它是一种高效的计算机音频编码方案,它以较大的压缩比将音频文件转换成较小的扩展名为.mp3的文件,基本保持源文件的音…...
Oracle--PL/SQL编程
前言:本博客仅作记录学习使用,部分图片出自网络,如有侵犯您的权益,请联系删除 PL/SQL(Procedural Language/SQL)是Oracle数据库中的一种过程化编程语言,构建于SQL之上,允许编写包含S…...
【愚公系列】《Python网络爬虫从入门到精通》063-项目实战电商数据侦探(主窗体的数据展示)
🌟【技术大咖愚公搬代码:全栈专家的成长之路,你关注的宝藏博主在这里!】🌟 📣开发者圈持续输出高质量干货的"愚公精神"践行者——全网百万开发者都在追更的顶级技术博主! …...
DAPP(去中心化应用程序)开发全解析:构建去中心化应用的流程
去中心化应用(DApp)凭借其透明性、抗审查性和用户数据主权,正重塑金融、游戏、社交等领域。本文基于2025年最新开发实践,系统梳理DApp从需求规划到部署运维的全流程,并融入经济模型设计、安全加固等核心要点࿰…...
Spark与Hadoop之间有什么样的对比和联系
一、什么是Spark Spark 是一个快速、通用且可扩展的大数据处理框架,最初由加州大学伯克利分校的AMPLab于2009年开发,并于2010年开源。它在2013年成为Apache软件基金会的顶级项目,是大数据领域的重要工具之一。 Spark 的优势在于其速度和灵活…...
spark和Hadoop之间的对比和联系
Spark 诞生主要是为了解决 Hadoop MapReduce 在迭代计算以及交互式数据处理时面临的性能瓶颈问题。 一,spark的框架 Hadoop MR 框架 从数据源获取数据,经过分析计算后,将结果输出到指定位置,核心是一次计算,不适合迭…...
LeetCode 第 262 题全解析:从 SQL 到 Swift 的数据分析实战
文章目录 摘要描述题解答案(SQL)Swift 题解代码分析代码示例(可运行 Demo)示例测试及结果时间复杂度分析空间复杂度分析总结未来展望 摘要 在实际业务中,打车平台要监控行程的取消率,及时识别服务质量的问…...
“融合Python与机器学习的多光谱遥感技术:数据处理、智能分类及跨领域应用”
随着遥感技术的快速发展,多光谱数据凭借其多波段信息获取能力,成为地质、农业及环境监测等领域的重要工具。相较于高光谱数据,Landsat、哨兵-2号等免费中分辨率卫星数据具有长时间序列、广覆盖的优势,而无人机平台的兴起进一步补充…...
JavaScript的JSON处理Map的弊端
直接使用 Map 会遇到的问题及解决方案 直接使用 Map 会导致数据丢失,因为 JSON.stringify 无法序列化 Map。以下是详细分析及解决方法: 问题复现 // 示例代码 const myMap new Map(); myMap.set(user1, { name: Alice }); myMap.set(user2, { name: B…...
python的深拷贝浅拷贝(copy /deepcopy )
先说结论: 浅拷贝: 浅拷贝对在第一层的操作都是新建,不改变原对象。 浅拷贝对于原拷贝对象中的嵌套的可变对象是引用,对原拷贝对象中的嵌套的不可变对象是新建。 对新建的对象操作不会影响原被拷贝对象。 对引用对象操作会影…...
新能源汽车充电桩:多元化运营模式助力低碳出行
摘 要:以新能源汽车民用充电桩为研究对象,在分析充电桩建设运营的政府推动模式、电网企业推动模式、汽车厂商推动模式等三种模式利弊的基础上,结合我国的实际情况,提出我国现阶段应实行汽车厂商与电网企业联盟建设充电桩的模式。建立一个考虑…...
Python 设计模式:享元模式
1. 什么是享元模式? 享元模式是一种结构型设计模式,旨在通过共享对象来减少内存使用和提高性能。它特别适用于需要大量相似对象的场景,通过共享相同的对象来避免重复创建,从而节省内存和提高效率。 享元模式的核心思想是将对象的…...
文献×汽车 | 基于 ANSYS 的多级抛物线板簧系统分析
板簧系统是用于减弱或吸收动态系统中发生的应力、应变、偏转和变形等破坏性因素的机械结构。板簧系统可能对外力产生不同的响应,具体取决于其几何结构和材料特性。板簧系统的计算机辅助分析对于高精度确定系统的变形特性和结构特性至关重要。 在这项工作中ÿ…...
Element UI、Element Plus 里的表单验证的required必填的属性不能动态响应?
一 问题背景 想要实现: 新增/修改对话框中(同一个),修改时“备注”字段非必填,新增时"备注"字段必填 结果发现直接写不生效-初始化一次性 edit: [{ required: true, message: "请输入备注", trigger: "blur" }…...