五子棋
简易地实现了Dom 和 Canvas 两个版本的五子棋游戏
这是一本 5 万字符(中文约 2w)的小书,可能需要几个小时阅读,需要几天或更多时间去消化。部分代码还不能正确地跑起来,有错别字,有不准确的概念...,但这不妨碍它作为你一个野生前端学习数据结构与算法的启蒙文章,期待你的一针见血、刀刀致命 😐
对任何专业技术人员来说,理解数据结构都非常重要。作为软件开发者,我们要能够用编程语言和数据结构来解决问题。编程语言和数据结构是这些问题解决方案中不可或缺的一部分。如果选择了不恰当的数据结构,可能会影响所写程序的性能。因此,了解不同数据结构和它们的适用范围十分重要。
一句话:算法即原力,即正义
本文主要讲述 JavaScript 中实现栈、队列、链表、集合、字典、散列表、树、图等数据结构,以及各种排序和搜索算法,包括冒泡排序、选择排序、插入排序、归并排序、快速排序、顺序搜索、二分搜索,最后还介绍了动态规划和贪心算法等常用的高级算法及相关知识。
在阅读之前假设你已了解并可以熟练使用 JavaScript 编写应用程序。
Object
。排序算法
搜索算法
贪心算法:在对问题求解时,不考虑全局,总是做出局部最优解的方法。
动态规划:在对问题求解时,由以求出的局部最优解来推导全局最优解。
复杂度概念:一个方法在执行的整个生命周期,所需要占用的资源,主要包括:时间资源、空间资源。
栈是一种遵从先进后出 (LIFO) 原则的有序集合;新添加的或待删除的元素都保存在栈的末尾,称作栈顶,另一端为栈底。在栈里,新元素都靠近栈顶,旧元素都接近栈底。
通俗来讲,一摞叠起来的书或盘子都可以看做一个栈,我们想要拿出最底下的书或盘子,一定要现将上面的移走才可以。
栈也被用在编程语言的编译器和内存中保存变变量、方法调用。
在 Javascript 中我们可以使用数组的原生方法实现一个栈/队列的功能,鉴于学习目的,我们使用类来实现一个栈。
class Stack {
constructor() {
this.items = [];
}
// 入栈
push(element) {
this.items.push(element);
}
// 出栈
pop() {
return this.items.pop();
}
// 末位
get peek() {
return this.items[this.items.length - 1];
}
// 是否为空栈
get isEmpty() {
return !this.items.length;
}
// 尺寸
get size() {
return this.items.length;
}
// 清空栈
clear() {
this.items = [];
}
// 打印栈数据
print() {
console.log(this.items.toString());
}
}
使用栈类:
// 实例化一个栈
const stack = new Stack();
console.log(stack.isEmpty); // true
// 添加元素
stack.push(5);
stack.push(8);
// 读取属性再添加
console.log(stack.peek); // 8
stack.push(11);
console.log(stack.size); // 3
console.log(stack.isEmpty); // false
与栈相反,队列是一种遵循先进先出 (FIFO / First In First Out) 原则的一组有序的项;队列在尾部添加新元素,并从头部移除元素。最新添加的元素必须排在队列的末尾。
在现实中,最常见的例子就是排队,吃饭排队、银行业务排队、公车的前门上后门下机制...,前面的人优先完成自己的事务,完成之后,下一个人才能继续。
在计算机科学中,一个常见的例子就是打印队列。比如说我们需要打印五份文档。我们会打开每个文档,然后点击打印按钮。每个文档都会被发送至打印队列。第一个发送到打印队列的文档会首先被打印,以此类推,直到打印完所有文档。
同样的,我们在 Javascript 中实现一个队列类。
class Queue {
constructor(items) {
this.items = items || [];
}
enqueue(element) {
this.items.push(element);
}
dequeue() {
return this.items.shift();
}
front() {
return this.items[0];
}
clear() {
this.items = [];
}
get size() {
return this.items.length;
}
get isEmpty() {
return !this.items.length;
}
print() {
console.log(this.items.toString());
}
}
使用队列类:
const queue = new Queue();
console.log(queue.isEmpty); // true
queue.enqueue("John");
queue.enqueue("Jack");
queue.enqueue("Camila");
console.log(queue.size); // 3
console.log(queue.isEmpty); // false
queue.dequeue();
queue.dequeue();
queue.print(); // 'Camila'
队列大量应用在计算机科学以及我们的生活中,我们在之前话题中实现的默认队列也有一些修改版本。
其中一个修改版就是优先队列。元素的添加和移除是基于优先级的。一个现实的例子就是机场登机的顺序。头等舱和商务舱乘客的优先级要高于经济舱乘客。在有些国家,老年人和孕妇(或 带小孩的妇女)登机时也享有高于其他乘客的优先级。
另一个现实中的例子是医院的(急诊科)候诊室。医生会优先处理病情比较严重的患者。通常,护士会鉴别分类,根据患者病情的严重程度放号。
实现一个优先队列,有两种选项:设置优先级,然后在正确的位置添加元素;或者用入列操作添加元素,然后按照优先级移除它们。在下面示例中,我们将会在正确的位置添加元素,因此可以对它们使用默认的出列操作:
class PriorityQueue {
constructor() {
this.items = [];
}
enqueue(element, priority) {
const queueElement = { element, priority };
if (this.isEmpty) {
this.items.push(queueElement);
} else {
const preIndex = this.items.findIndex(
(item) => queueElement.priority < item.priority
);
if (preIndex > -1) {
this.items.splice(preIndex, 0, queueElement);
} else {
this.items.push(queueElement);
}
}
}
dequeue() {
return this.items.shift();
}
front() {
return this.items[0];
}
clear() {
this.items = [];
}
get size() {
return this.items.length;
}
get isEmpty() {
return !this.items.length;
}
print() {
console.log(this.items);
}
}
优先队列的使用:
const priorityQueue = new PriorityQueue();
priorityQueue.enqueue("John", 2);
priorityQueue.enqueue("Jack", 1);
priorityQueue.enqueue("Camila", 1);
priorityQueue.enqueue("Surmon", 3);
priorityQueue.enqueue("skyRover", 2);
priorityQueue.enqueue("司马萌", 1);
priorityQueue.print();
console.log(priorityQueue.isEmpty, priorityQueue.size); // false 6
为充分利用向量空间,克服"假溢出"现象的方法是:将向量空间想象为一个首尾相接的圆环,并称这种向量为循环向量。存储在其中的队列称为循环队列(Circular Queue)。这种循环队列可以以单链表、队列的方式来在实际编程应用中来实现。
下面我们基于首次实现的队列类,简单实现一个循环引用的示例:
class LoopQueue extends Queue {
constructor(items) {
super(items);
}
getIndex(index) {
const length = this.items.length;
return index > length ? index % length : index;
}
find(index) {
return !this.isEmpty ? this.items[this.getIndex(index)] : null;
}
}
访问一个循环队列:
const loopQueue = new LoopQueue(["Surmon"]);
loopQueue.enqueue("SkyRover");
loopQueue.enqueue("Even");
loopQueue.enqueue("Alice");
console.log(loopQueue.size, loopQueue.isEmpty); // 4 false
console.log(loopQueue.find(26)); // 'Evan'
console.log(loopQueue.find(87651)); // 'Alice'
要存储多个元素,数组(或列表)可能是最常用的数据结构。
每种语言都实现了数组。这种数据结构非常方便,提供了一个便利的 []
语法来访问它的元素。
然而,这种数据结构有一个缺点:在大多数语言中,数组的大小是固定的,从数组的起点或中间插入或移除项的成本很高,因为需要移动元素;
尽管 JavaScript 中的 Array
类方法可以帮我们做这些事,但背后的处理机制同样如此。
链表存储有序的元素集合,但不同于数组,链表中的元素在内存中并不是连续放置的。每个 元素由一个存储元素本身的节点和一个指向下一个元素的引用(也称指针或链接)组成。下图展示了链表的结构:
相对于传统的数组,链表的一个好处在于,添加或移除元素的时候不需要移动其他元素。然而,链表需要使用指针,因此实现链表时需要额外注意。
数组的另一个细节是可以直接访问任何位置的任何元素,而要想访问链表中间的一个元素,需要从起点(表头)开始迭代列表直到找到所需的元素。
现实中有许多链表的例子:一列火车是由一系列车厢/车皮组成的,每节车厢/车皮都相互连接,你很容易分离一节车皮,改变它的位置,添加或移除它。下图演示了一列火车,每节车皮都是列表的元素,车皮间的连接就是指针:
下面我们使用 Javascript 创建一个链表类:
// 链表节点
class Node {
constructor(element) {
this.element = element;
this.next = null;
}
}
// 链表
class LinkedList {
constructor() {
this.head = null;
this.length = 0;
}
// 追加元素
append(element) {
const node = new Node(element);
let current = null;
if (this.head === null) {
this.head = node;
} else {
current = this.head;
while (current.next) {
current = current.next;
}
current.next = node;
}
this.length++;
}
// 任意位置插入元素
insert(position, element) {
if (position >= 0 && position <= this.length) {
const node = new Node(element);
let current = this.head;
let previous = null;
let index = 0;
if (position === 0) {
this.head = node;
} else {
while (index++ < position) {
previous = current;
current = current.next;
}
node.next = current;
previous.next = node;
}
this.length++;
return true;
}
return false;
}
// 移除指定位置元素
removeAt(position) {
// 检查越界值
if (position > -1 && position < length) {
let current = this.head;
let previous = null;
let index = 0;
if (position === 0) {
this.head = current.next;
} else {
while (index++ < position) {
previous = current;
current = current.next;
}
previous.next = current.next;
}
this.length--;
return current.element;
}
return null;
}
// 寻找元素下标
findIndex(element) {
let current = this.head;
let index = -1;
while (current) {
if (element === current.element) {
return index + 1;
}
index++;
current = current.next;
}
return -1;
}
// 删除指定文档
remove(element) {
const index = this.indexOf(element);
return this.removeAt(index);
}
isEmpty() {
return !this.length;
}
size() {
return this.length;
}
// 转为字符串
toString() {
let current = this.head;
let string = "";
while (current) {
string += ` ${current.element}`;
current = current.next;
}
return string;
}
}
链表类的使用:
const linkedList = new LinkedList();
console.log(linkedList);
linkedList.append(2);
linkedList.append(6);
linkedList.append(24);
linkedList.append(152);
linkedList.insert(3, 18);
console.log(linkedList);
console.log(linkedList.findIndex(24));
双向链表
链表有多种不同的类型,这一节介绍双向链表。双向链表和普通链表的区别在于,在链表中, 一个节点只有链向下一个节点的链接,而在双向链表中,链接是双向的:一个链向下一个元素, 另一个链向前一个元素,如下图所示:
双向链表提供了两种迭代列表的方法:从头到尾,或者反过来。我们也可以访问一个特定节 点的下一个或前一个元素。在单向链表中,如果迭代列表时错过了要找的元素,就需要回到列表 起点,重新开始迭代。这是双向链表的一个优点。
我们继续来实现一个双向链表类:
// 链表节点
class Node {
constructor(element) {
this.element = element;
this.prev = null;
this.next = null;
}
}
// 双向链表
class DoublyLinkedList {
constructor() {
this.head = null;
this.tail = null;
this.length = 0;
}
// 任意位置插入元素
insert(position, element) {
if (position >= 0 && position <= this.length) {
const node = new Node(element);
let current = this.head;
let previous = null;
let index = 0;
// 首位
if (position === 0) {
if (!head) {
this.head = node;
this.tail = node;
} else {
node.next = current;
this.head = node;
current.prev = node;
}
// 末位
} else if (position === this.length) {
current = this.tail;
current.next = node;
node.prev = current;
this.tail = node;
// 中位
} else {
while (index++ < position) {
previous = current;
current = current.next;
}
node.next = current;
previous.next = node;
current.prev = node;
node.prev = previous;
}
this.length++;
return true;
}
return false;
}
// 移除指定位置元素
removeAt(position) {
if (position > -1 && position < this.length) {
let current = this.head;
let previous = null;
let index = 0;
// 首位
if (position === 0) {
this.head = this.head.next;
this.head.prev = null;
if (this.length === 1) {
this.tail = null;
}
// 末位
} else if (position === this.length - 1) {
this.tail = this.tail.prev;
this.tail.next = null;
// 中位
} else {
while (index++ < position) {
previous = current;
current = current.next;
}
previous.next = current.next;
current.next.prev = previous;
}
this.length--;
return current.element;
} else {
return null;
}
}
}
循环链表
循环链表可以像链表一样只有单向引用,也可以像双向链表一样有双向引用。循环链表和链表之间唯一的区别在于,最后一个元素指向下一个元素的指针(tail.next)不是引用 null, 而是指向第一个元素(head),如下图所示。
双向循环链表有指向 head 元素的 tail.next,和指向 tail 元素的 head.prev。
链表相比数组最重要的优点,那就是无需移动链表中的元素,就能轻松地添加和移除元素。因此,当你需要添加和移除很多元素 时,最好的选择就是链表,而非数组。
集合是由一组无序且唯一(不能重复)的项组成的。这个数据结构使用了与有限集合相同的数学概念,但应用在计算机科学的数据结构中。
在数学中,集合是一组不同的对象(的集)。
比如说:一个由大于或等于 0 的证书组成的自然数集合:N = { 0, 1, 2, 3, 4, 5, 6, ... }
,集合中的对象列表用 {}
包围。
集合是由一组一(即不能重的项组成的。这个数据结构使用了与有..合相同的数学..,但应用在.算..学的数据结构中。
目前 ES6 中已内置了 Set 类型的实现,出于学习目的,下面我们依旧使用 Javascript 创建一个集合类:
class Set {
constructor() {
this.items = {};
}
has(value) {
return this.items.hasOwnProperty(value);
}
add(value) {
if (!this.has(value)) {
this.items[value] = value;
return true;
}
return false;
}
remove(value) {
if (this.has(value)) {
delete this.items[value];
return true;
}
return false;
}
get size() {
return Object.keys(this.items).length;
}
get values() {
return Object.keys(this.items);
}
}
使用集合类:
const set = new Set();
set.add(1);
console.log(set.values); // ["1"]
console.log(set.has(1)); // true
console.log(set.size); // 1
set.add(2);
console.log(set.values); // ["1", "2"]
console.log(set.has(2)); // true
console.log(set.size); // 2
set.remove(1);
console.log(set.values); // ["2"]
set.remove(2);
console.log(set.values); // []
对集合可以进行如下操作:
并集
并集的数学概念:集合 A 和 B 的并集,表示为 A∪B
,定义如下:A∪B = { x | x∈A ∨ x∈B }
,意思是 x(元素)存在于 A 中,或 x 存在于 B 中。如图:
我们基于刚才的 Set 类实现一个并集方法:
union(otherSet) {
const unionSet = new Set()
this.values.forEach((v, i) => unionSet.add(this.values[i]))
otherSet.values.forEach((v, i) => unionSet.add(otherSet.values[i]))
return unionSet
}
交集
并集的数学概念:集合 A 和 B 的交集,表示为 A∩B
,定义如下:A∩B = { x | x∈A ∧ x∈B }
,意思是 x(元素)存在于 A 中,且 x 存在于 B 中。如图:
我们基于刚才的 Set 类实现一个交集方法:
intersection(otherSet) {
const intersectionSet = new Set()
this.values.forEach((v, i) => {
if (otherSet.has(v)) {
intersectionSet.add(v)
}
})
return intersectionSet
}
差集
差集的数学概念:集合 A 和 B 的差集,表示为 A-B
,定义如下:A-B = { x | x∈A ∧ x∉B }
,意思是 x(元素)存在于 A 中,且不 x 存在于 B 中。如图:
我们基于刚才的 Set 类实现一个差集方法:
difference(otherSet) {
const differenceSet = new Set()
this.values.forEach((v, i) => {
if (!otherSet.has(v)) {
differenceSet.add(v)
}
})
return differenceSet
}
子集
子集的数学概念:集合 A 是 B 的子集,或者说集合 B 包含了集合 A,如图:
我们基于刚才的 Set 类实现一个子集方法:
subset(otherSet) {
if (this.size > otherSet.size) {
return false
} else {
return !this.values.some(v => !otherSet.has(v))
}
}
集合、字典、散列表都可以存储不重复的数据。字典和我们上面实现的集合很像,上面的集合中我们以 { value: value }
的形式存储数据,而字典是以 { key: value }
的形式存储数据,字典也称作映射。
简单说:Object
对象便是字典在 Javascript 中的实现。
还是简单实现一个字典类:
class Dictionary {
constructor() {
this.items = {};
}
set(key, value) {
this.items[key] = value;
}
get(key) {
return this.items[key];
}
remove(key) {
delete this.items[key];
}
get keys() {
return Object.keys(this.items);
}
get values() {
/*
也可以使用ES7中的values方法
return Object.values(this.items)
*/
// 在这里我们通过循环生成一个数组并输出
return Object.keys(this.items).reduce((r, c, i) => {
r.push(this.items[c]);
return r;
}, []);
}
}
使用字典类:
const dictionary = new Dictionary();
dictionary.set("Gandalf", "gandalf@email.com");
dictionary.set("John", "johnsnow@email.com");
dictionary.set("Tyrion", "tyrion@email.com");
console.log(dictionary);
console.log(dictionary.keys);
console.log(dictionary.values);
console.log(dictionary.items);
JS内置的各个对象的访问复杂度如何?myobj.key = 55和myArr[4] = 3的复杂度都是O(1)吗?
牛逼
amazing!
谢谢作者,这篇文章让我这个非计算机专业的收获很多
回复:
这本书有原作者,但没有电子版,谢谢他
这页面真卡
普通链表 代码95行 this.indexOf 应该是 this.findIndex 吧
回复:
findIndex
参数是函数好吗Set 的写法有点问题,如果value是引用类型就会出错了
回复:
可以对非基本类型做字符化处理会更完备
看完了,收货良多呀! 非常感谢 👍
能不能把背景的canvas去掉,好卡
回复:
左下角有开关啊
好
可以可以
创建图类的时候不需要加 ()吧
class Graph {}
我想请教一下哈,为什么数组前面加个;就不会导致报错呢 😃
回复:
看这里: JavaScript 语句后应该加分号么? - 尤雨溪的回答 - 知乎 https://www.zhihu.com/question/20298345/answer/49551142
博主好强 👍👍👍 向你学习
请问博主,本网站web端,可以加一个放宽文章页面的按钮吗,好窄
🙏🙏🙏🙏🙏🙏🙏🙏🙏🙏🙏🙏🙏💪
回复:
好主意,很快加上
回复:
哈哈哈, 匿名聊天真开心。请问用户的头像能设置么?
回复:
gravatar
回复:
加上了
回复:
感谢博主加上全屏浏览的功能, 看文章更方便了
回复:
🌹
回复:
全屏浏览的按钮在哪,没找到哇 😂
回复:
早就去掉了,老兄
😍
哥,看你的文章一下午,听了一下午歌,发现了 京东热,是不是罪过啊
回复:
东京到底热不热
回复:
很躁😂,哈哈
你手机端的说说广播📢会导致文章列表重绘,一直抖。可以点点上下箭头shi shi
回复:
🌻🌻🌻谢谢
回复:
看错了,是轮播图导致的。评论的表单,iPhone 的输入法有自动补全提示,自动填入之后,你的验证不能通过。你去看看 input 的 compositionstart 和 compositionend 事件。不知道什么原因,我Safari访问,如果不打开vpn,提示无法连接到服务器😂
有个地方写错了:后序遍历:左侧子节点 => 右侧子节点 => 节点本身
回复:
谢谢,实际上这篇文章还没彻底整理好...🌹🌹🌹
代理评论测试💊
回复:
测你大爷
回复:
😜
BUG测试💊
😃😃😃