虽说工作中基本很难用到,但面试它是真问啊。之前总是随便刷几十道 LeetCode 简单/中等就混过去了,这回时间比较充裕,开始系统的学一下。
对于算法,不要被吓到,需要你掌握的无非就:
- 八种数据结构:基本实现,简单用法。
- 五个基础排序:冒泡、快排、插入、归并、选择
- 两个搜索算法:顺序搜索、二分搜索
- 四个算法思想:分而治之、动态规划、贪心算法、回溯算法
视频资源:bilibili 随便找个「JS数据结构与算法网课」
GitHub 代码:点击跳转,感兴趣一起学的小伙伴可以点个 Star ✨,我可以把我的相关学习资源发你。
一、Javascript 常用方法
01|数组
02|字符串
二、算法复杂度
常见复杂度大小比较: O(n^2) > O(nlogn) > O(n) > O(logn) > O(1)
常见时间复杂度对应关系:
- O(n^2) :2层循环(嵌套循环)
- O(nlogn) :快速排序(循环 + 二分)
- O(n) :1层循环
- O(logn) :二分
三、八大数据结构的 JS 实现
01|栈
栈是一种「后进先出 」的数据结构,JS 中没有栈,但可以用 Array
实现栈的所有功能
js
class Stack {
#items = []; // ES2020 private class fields,私有属性
// 出栈
pop() {
return this.#items.pop();
}
// 入栈
push(item) {
this.#items.push(item);
}
// 查看栈顶元素
peek() {
return this.#items[this.#items.length - 1];
}
// 判断栈是否为空
isEmpty() {
return this.#items.length === 0;
}
// 清空栈
clear() {
this.#items = [];
}
// 获取栈的大小
size() {
return this.#items.length;
}
}
常见面试题,栈实现进制转换:
js
/**
* 用栈实现进制转换
* @param {number} decNumber - 基数
* @param {number} base - 进制
* @returns {string} - 转换后的字符串
*/
function convert(decNumber, base) {
let remstack = new Stack();
let number = decNumber;
let string = "";
let baseString = "0123456789ABCDEF";
while (number > 0) {
remstack.push(number % base);
number = Math.floor(number / base);
}
while (!remstack.isEmpty()) {
string += baseString[remstack.pop()];
}
return string;
}
console.log(convert(100345, 16));
LeetCode题目
02|队列
队列是一种「先进先出」的数据结构。
虽然 JavaScript 本身并未提供队列数据结构,但我们可以借助 Array
来实现队列的所有功能。特别是,我们可以使用 Array.shift()
方法来实现出队操作。
然而,当队列中的元素数量增多时,这种方法的性能会变得很差,因为每次出队操作都会导致数组中的每一项都向前移动一位。这里有改进版:Queue_improved.js
js
class QueueSimple {
#items = [];
// 出队
delQueue() {
return this.#items.shift(); // 性能很差,每次出队都要把数组每一项前移
}
// 入队
addQueue(item) {
this.#items.push(item);
}
// 查看队首元素
peek() {
return this.#items.at(0); // ES2021 Array.prototype.at(),返回索引处的元素
// return this.#items[0]; // 作用同上
}
// 判断队列是否为空
isEmpty() {
return this.#items.length === 0;
}
// 清空队列
clear() {
this.#items = [];
}
// 获取队列的大小
size() {
return this.#items.length;
}
}
LeetCode题目
- 简单: 933. 最近的请求次数
- 中等: 695. 岛屿的最大面积
03|链表
链表是一种「线性数据结构,其中的元素不必在内存中连续存储,而是通过指针连接在一起 」。每个元素包含两个部分:数据和指向下一个节点的指针,可以用Object
模拟链表。
链表的优点是插入和删除元素的时间复杂度为 O(1),不需要移动其他元素。但是查找一个元素的时间复杂度为 O(n),因为可能需要遍历整个链表。并且存一个数据增加了指针的内存空间,空间开销大。
链表有多种类型,包括:
- 单向链表:每个节点只有一个指向下一个节点的链接
- 双向链表:每个节点有两个指针,一个指向前一个节点,一个指向下一个节点
- 循环链表:最后一个节点指向第一个节点
js
class Node {
constructor(value) {
this.value = value;
this.next = null;
}
}
export class LinkedList {
constructor(value) {
// head 是链表的第一个节点
this.head = {
value: value,
next: null,
};
}
// 尾部添加节点
addNode(data) {
const newNode = new Node(data);
if (!this.head) {
// 如果链表为空,说明是第一个节点,将新节点赋值给 head 即可
this.head = newNode;
} else {
let current = this.head;
// 如果不为空,则需要遍历链表,找到最后一个节点
while (current.next !== null) {
current = current.next;
}
// 将最后一个节点的 next 指向新节点
current.next = newNode;
}
}
// 基于节点值删除节点
delNode(data) {
let current = this.head; // 从头节点开始查找
let previous = null; // 用于记录当前节点的前一个节点
while (current !== null) {
if (current.value === data) {
if (previous === null) {
// 如果删除的是头节点,将头节点指向下一个节点
this.head = current.next;
} else {
previous.next = current.next; // 将前一个节点的 next 设置为下一个节点
}
return current.value;
}
previous = current;
current = current.next;
}
return null;
}
// 任意位置插入节点
// 有点像拆链子,拿掉要换的那节,然后把前后两节连起来
insertNode(data, index) {
let newNode = new Node(data);
let current = this.head;
let previous = null;
let i = 0;
if (index === 0) {
newNode.next = current; // 将新节点的 next 指向当前节点
this.head = newNode; // 更新头节点指向新节点
} else {
while (i++ < index) {
// 遍历链表,找到要插入位置的节点
previous = current;
current = current.next;
}
previous.next = newNode;
newNode.next = current;
}
}
// 打印链表
printList() {
let current = this.head;
let str = "";
while (current !== null) {
str += current.value + " -> ";
current = current.next;
}
str += "null";
console.log(str);
}
// 反转链表
reverseList() {
let current = this.head;
let previous = null;
let next = null;
while (current !== null) {
next = current.next; // 保存当前节点的下一个节点
current.next = previous; // 将当前节点的 next 指向前一个节点
previous = current; // previous 指向当前节点
current = next; // current 指向下一个节点
}
this.head = previous; // 更新头节点
}
}
- 简单: 83. 删除排序链表中的重复元素
- 简单: 206. 反转链表
- 中等: 237. 删除链表中的节点
- 中等: 2. 两数相加
04|集合
集合是一种「包含不重复元素」的数据结构。
它的主要特点是元素无序且唯一 ,ES6 中有集合:Set
。集合常用操作:数组去重、求交集、判断某元素是否在集合中。
js
export class Set {
items = {};
// 将item的key和value都设置为item
add(item) {
if (!this.has(item)) {
this.items[item] = item;
return true;
}
return false;
}
delete(item) {
if (this.has(item)) {
delete this.items[item];
return true;
}
return false;
}
has(item) {
return this.items.hasOwnProperty(item); // 判断是否有这个属性
}
clear() {
this.items = {};
}
size() {
return Object.keys(this.items).length;
}
values() {
return Object.values(this.items);
}
}
js
// 去重
const arr = [1, 1, 2, 2]
const arr2 = [...new Set(arr)] // [1, 2]
// 判断元素是否在集合中
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))) // Set(1) {2}
// 求差集, 交集的取反
const set4 = new Set([...set].filter(item => !set2.has(item))) // Set(1) {1}
// 求并集
const set5 = new Set([...arr1, ...arr2] )// [1, 2]
LeetCode题目
- 简单: 349. 两个数组的交集
05|字典
字典(在 JavaScript 中通常被称为 Map
)是一种存储键值对的数据结构。它的主要特点是通过键来查找值,这种结构提供了快速查找、删除和更新键值的能力。
js
class Dictionary {
items = {};
// 对象做key值会导致隐式转换,所以需要将key转换为字符串
toStrFn(key) {
if (key === null) {
return "NULL";
} else if (key === undefined) {
return "UNDEFINED";
} else if (typeof key === "string" || key instanceof String) {
return `${key}`;
} else {
return JSON.stringify(key);
}
}
hasKey(key) {
return (
this.items[this.toStrFn(key)] !== null ||
this.items[this.toStrFn(key)] !== undefined
);
}
set(key, value) {
if (key != null && value != null) {
this.items[this.toStrFn(key)] = new ValuePair(key, value);
}
}
get(key) {
const valuePair = this.items[this.toStrFn(key)];
return valuePair == null ? undefined : valuePair.value;
}
remove(key) {
if (this.hasKey(key)) {
delete this.items[this.toStrFn(key)];
return true;
}
return false;
}
size() {
return Object.keys(this.items).length;
}
clear() {
this.items = {};
}
// 额外的一些辅助方法:返回key、返回value、返回键值对
keys() {
return this.keyValues().map((valuePair) => valuePair.key);
}
values() {
return this.keyValues().map((valuePair) => valuePair.value);
}
keyValues() {
return Object.values(this.items);
}
}
class ValuePair {
constructor(key, value) {
this.key = key;
this.value = value;
}
}
ES6中,Map 对象提供了一些方法来执行常见的字典操作
set(key, value)
:添加或更新字典中的一个键值对。delete(key)
:移除字典中的一个键值对。get(key)
:获取字典中的一个值。has(key)
:返回一个布尔值,表示某个键是否在字典中。clear()
:清空字典中的所有键值对。
c
// 字典
const map = new Map()
// 增
map.set('key1', 'value1')
var newObj = {a:1} // 需明确具体的引用地址
map.set(newObj, 'value2') // 正确,可以通过map.get(newObj)获取到value2的值
map.set({a:1}, 'value2') // 错误,无法获取到具体值,因为key的引用地址变了
// 删
map.delete('key1')
map.clear()
// 改
map.set('key1', 'value222')
// 查
map.get('key1')
LeetCode题目
- 简单: 1. 两数之和
- 简单: 3. 无重复字符的最长子串
- 简单: 20.有效括号
06|树
树是一种「以分层的方式存储,且非线性」的数据结构。
它是由节点组成的,其中最上层是根节点,其余的节点可以分为多个不交叉的子树。在前端开发中,树形结构的应用非常广泛,例如 DOM 就是一种树形结构。另外,像级联选择、树形控件等UI组件,也是基于树形结构实现的。
如下图,是一个二叉树搜素树:
- 二叉树的一种,每个节点最多只有两个子节点,一个左侧,一个右侧;
- 但要求左侧子节点的值比父节点小,右侧子节点比父节点大;
- 最小值在最左侧;
- 最大值在最右侧;
在 JavaScript 中,虽然没有内置的树数据结构,但我们可以使用 Object
和 Array
来构建树。
js
class Node {
constructor(value) {
this.value = value;
this.left = null;
this.right = null;
}
}
// 二叉搜索树
export class BST {
constructor() {
this.root = null;
}
insert(value) {
const newNode = new Node(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);
}
}
}
// 先序遍历: 根节点 -> 左子树 -> 右子树
preOrderTraverse(callback) {
this.#preOrderTraverseNode(this.root, callback);
}
#preOrderTraverseNode(node, callback) {
if (node !== null) {
callback(node.value);
this.#preOrderTraverseNode(node.left, callback);
this.#preOrderTraverseNode(node.right, callback);
}
}
// 中序遍历: 左子树 -> 根节点 -> 右子树
inOrderTraverse(callback) {
this.#inOrderTraverseNode(this.root, callback);
}
#inOrderTraverseNode(node, callback) {
if (node !== null) {
this.#inOrderTraverseNode(node.left, callback);
callback(node.value);
this.#inOrderTraverseNode(node.right, callback);
}
}
// 后序遍历: 左子树 -> 右子树 -> 根节点
postOrderTraverse(callback) {
this.#postOrderTraverseNode(this.root, callback);
}
#postOrderTraverseNode(node, callback) {
if (node !== null) {
this.#postOrderTraverseNode(node.left, callback);
this.#postOrderTraverseNode(node.right, callback);
callback(node.value);
}
}
min() {
return this.#minNode(this.root);
}
#minNode(node) {
let current = node;
while (current !== null && current.left !== null) {
current = current.left;
}
return current.value;
}
max() {
return this.#maxNode(this.root);
}
#maxNode(node) {
let current = node;
while (current !== null && current.right !== null) {
current = current.right;
}
return current.value;
}
// 搜索树中是否存在某个值
search(value) {
return this.#searchNode(this.root, value);
}
#searchNode(node, value) {
if (node === null) {
return false;
}
if (value < node.value) {
return this.#searchNode(node.left, value);
} else if (value > node.value) {
return this.#searchNode(node.right, value);
} else {
return true;
}
}
remove(value) {
this.root = this.#removeNode(this.root, value);
}
#removeNode(node, value) {
if (node === null) {
return null;
}
if (value < node.value) {
node.left = this.#removeNode(node.left, value);
return node;
} else if (value > node.value) {
node.right = this.#removeNode(node.right, value);
return node;
} else {
// 没有子节点,直接移除
if (node.left === null && node.right === null) {
node = null;
return node;
}
// 只有一个子节点的节点,将其子节点提升为当前节点的位置
if (node.left === null) {
node = node.right;
return node;
} else if (node.right === null) {
node = node.left;
return node;
}
// 移除有两个子节点的节点,找到右子树中的最小节点,将其替换为当前节点
const aux = this.#findMinNode(node.right);
node.value = aux.value;
node.right = this.#removeNode(node.right, aux.value);
return node;
}
}
#findMinNode(node) {
let current = node;
while (current !== null && current.left !== null) {
current = current.left;
}
return current;
}
}
遍历: 中序遍历是从小到大展示节点值
rust
8
/ \
3 10
/ \ \
1 6 14
/ \ /
4 7 13
先序遍历(根节点 -> 左子树 -> 右子树):8, 3, 1, 6, 4, 7, 10, 14, 13
中序遍历(左子树 -> 根节点 -> 右子树):1, 3, 4, 6, 7, 8, 10, 13, 14
后续遍历(左子树 -> 右子树 -> 根节点):1, 4, 7, 6, 3, 13, 14, 10, 8
删除:
- 无子节点:直接删除
- 有一个子节点:将其子节点提升为当前节点的位置
- 有两个子节点:找到右子树中的最小节点,将其替换为当前节点
LeetCode题目
- 简单: 94. 二叉树的中序遍历
- 简单: 104. 二叉树的最大深度
- 简单: 111. 二叉树的最小深度
- 简单: 112. 路径总和
07|堆
堆是一种特殊的二叉树
- 是一棵完全二叉树,表示树每一层都有左侧和右侧子节点(除最后一层叶节点外)
- 二叉堆不是最小堆就是最大堆
-
- 最小堆所有节点都小于等于他的子节点,最小的元素位于根节点;
- 最大堆所有节点都大于等于他的子节点,最大的元素位于根节点;
由于堆是一种完全二叉树,我们可以使用数组来表示堆。数组的索引可以用来表示堆中的父节点和子节点之间的关系。例如,对于一个索引为 i 的节点(索引从 0 开始):
- 它的父节点的索引是 (i - 1) / 2(向下取整)
- 它的左子节点的索引是 2 * i + 1
- 它的右子节点的索引是 2 * i + 2
js
// 封装一个最小堆
class MinHeap {
heap = []; // 存储堆数据
insert(value) {
this.heap.push(value);
this.#shiftUp();
}
// 与父节点逐级对比,若小于则交换位置
#shiftUp() {
let index = this.heap.length - 1; // 当前节点索引,数组长度减一
let parentIndex = Math.floor((index - 1) / 2); // 父节点索引,向下取整
// 当前节点值小于父节点值时,交换位置,直到根节点
while (this.heap[parentIndex] > this.heap[index]) {
[this.heap[parentIndex], this.heap[index]] = [
this.heap[index],
this.heap[parentIndex],
];
index = parentIndex;
parentIndex = Math.floor((index - 1) / 2);
}
}
size() {
return this.heap.length;
}
isEmpty() {
return this.heap.length === 0;
}
remove() {
if (this.isEmpty()) {
return null;
}
if (this.size() === 1) {
return this.heap.shift();
}
const removedValue = this.heap[0];
this.heap[0] = this.heap.pop();
this.#shiftDown();
return removedValue;
}
#shiftDown() {
let index = 0;
let left = 2 * index + 1;
while (left < this.heap.length) {
let right = 2 * index + 2;
let smallerIndex =
right < this.heap.length && this.heap[right] < this.heap[left]
? right
: left;
if (this.heap[index] < this.heap[smallerIndex]) {
break;
}
[this.heap[index], this.heap[smallerIndex]] = [
this.heap[smallerIndex],
this.heap[index],
];
index = smallerIndex;
left = 2 * index + 1;
}
}
// 查找某个值的索引
findTaeget(value) {
return this.#findTaegetNode(this.heap, value);
}
#findTaegetNode(heap, value) {
for (let i = 0; i < heap.length; i++) {
if (heap[i] === value) {
return i;
}
}
return -1;
}
}
LeetCode题目
- 困难: 23. 合并K个升序链表
- 中等: 215. 数组中的第K个最大元素
- 中等: 347. 前 K 个高频元素
08|图
图是一种非线性的数据结构,由节点和边组成。每条边连接两个节点,表示它们之间的关系。图可以表示任何二元关系,例如网络、道路、航班等。
- 无/有向图:图的每条边规定一个方向,那么得到的图称为有向图;相反,边没有方向的图称为无向图
- 带权图:在带权图中,每条边都有一个权重(weight)[非负实数]
- 图中某两个顶点之间的边数多于一条,又允许顶点通过同一条边和自己关联,则称为多重图
在 JavaScript 中,虽然没有内置的图数据结构,我们封装一个最简单的图:
js
class Graph {
constructor() {
this.vertices = []; // 用于存储图中的顶点
this.adjList = new Map(); // 用于存储图中的边
}
// 添加顶点
addVertex(v) {
this.vertices.push(v);
this.adjList.set(v, []); // 初始化邻接表
}
// 添加边
addEdge(v, w) {
this.adjList.get(v).push(w); // 添加边
this.adjList.get(w).push(v); // 对于无向图,需要添加两条边
}
// 输出图
toString() {
let s = "";
for (let i = 0; i < this.vertices.length; i++) {
s += this.vertices[i] + " -> ";
let neighbors = this.adjList.get(this.vertices[i]);
for (let j = 0; j < neighbors.length; j++) {
s += neighbors[j] + " ";
}
s += "\n";
}
return s;
}
}
LeetCode题目
- 中等: 133. 克隆图
- 中等: 417. 太平洋大西洋水流问题
- 简单: 997. 找到小镇的法官