一、简介
- 数据结构:计算机存储、组织数据的方式,就像锅碗瓢盆
- 算法:一系列解决问题的清晰指令,就像食谱
- 程序 = 数据结构 + 算法
数据结构**
- 栈、队列、链表
- 集合、字典
- 树、堆、图
算法
- 链表:遍历链表、删除链表节点
- 树、图:深度/广度优先遍历
- 数组:冒泡/选择/插入/归并/快速排序、顺序/二分搜索
算法设计思想
- 分而治之
- 动态规划
- 贪心算法
- 回溯算法
算法解题四步分析法
- 模拟:模拟题目的运行
- 规律:尝试总结出题目的一般规律和特点
- 匹配:找到符合这些特点的数据结构与算法
- 边界:考虑特殊情况
二、时间复杂度与空间复杂度
1、时间复杂度
- 一个函数,用大O表示,比如O(1)、O(n)、O(logN)
- 定性描述该算法的运行时间(执行次数)
2、空间复杂度
- 一个函数,用大O表示,比如O(1)、O(n)、O(n ^ 2)
- 算法在运行过程中临时占用存储空间大小的量度(占用的内存单元)
三、栈
一个后进先出的数据结构
1、使用场景例子
- 十进制转二进制
- 有效的括号(LeetCode-20)
- js函数调用堆栈
2、常用操作方式
- push、pop、stack[stack.length - 1]
四、队列
一个先进先出的数据结构
1、使用场景例子
- 食堂排队打饭
- JS异步中的任务队列
- 计算最近请求次数(LeetCode-933)
2、常用操作方式
- push、shift、queue[0]
五、链表
- 多个元素组成的列表
- 元素存储不连续,用 next 指针连在一起
- 在 js 中使用 Object 实现链表
1、链表和数组的区别
- 数组:增删非首尾元素时往往需要移动元素
- 链表:增删非首尾元素,不需要移动元素,只需要更改 next 的指向即可
2、链表创建方法及操作
const a = { val: 'a' };
const b = { val: 'b' };
const c = { val: 'c' };
const d = { val: 'd' };
a.next = b;
b.next = c;
c.next = d;
// 遍历链表
let p = a;
while (p) {
console.log(p.val);
p = p.next;
}
// 插入
const e = { val: 'e' };
c.next = e;
e.next = d;
// 删除
c.next = d;
3、算法场景
- 删除链表中的节点(LeetCode-237)
- 反转链表(LeetCode-206)
- 两数相加(LeetCode-2)
- 删除排序链表中的重复元素(LeetCode-83)
- 环形链表(LeetCode-141)
4、JS 中的链表 — 原型链
- 原型链的本质是链表
- 原型链上的节点是各种原型对象,比如 Function.prototype、Object.prototype
- 原型链通过
__proto__
属性链接各种原型对象
4.1 常见原型链
- obj -> Object.prototype -> null
- func -> Function.prototype -> Object.prototype -> null
- arr -> Array.prototype -> Object.prototype -> null
4.2 原型链知识点
- 如果 A 沿着原型链能找到 B.prototype,那么 A instanceof B 为 true
// 手动实现 instanceof
const instanceof = (A, B) => {
let p = A
while (p) {
if (p === B.prototype) {
return true
}
p = p.__proto__
}
return false
}
- 如果在 A 对象上没有找到 x 属性,那么会沿着原型链找 x 属性(如 toString)
5、JS 中的链表 — 使用链表指针获取 JSON 的节点值
const json = {
a: { b: { c: 1 } },
d: { e: 2 },
}
const path = ['a', 'b', 'c']
let p = json
path.forEach(k => {
p = p[k]
})
六、集合
- 一种无序且唯一的数据结构
- ES6 中有集合,名为 Set
- 集合的常用操作:去重、判断某元素是否在集合中、求交集
// 去重
const arr = [1, 1, 2, 2]
const arr2 = [...new Set(arr)]
// 判断元素是否在集合中
const set = new Set(arr)
const has = set.has(1)
// 求交集
const set2 = new Set([2, 3])
const set3 = new Set([...set].filter(v => set2.has(v)))
1、算法场景
- 两个数组的交集(LeetCode-349)
2、ES6 的 Set
- 使用 Set 对象:new、add、delete、has、size
let mySet = new Set();
mySet.add(1);
mySet.add(5);
mySet.add(5);
mySet.add('some text');
let o = { a: 1, b: 2 };
mySet.add(o);
mySet.add({ a: 1, b: 2 });
const has = mySet.has(o);
mySet.delete(5);
console.log(mySet.size)
- 迭代 Set:多种迭代方法、Set 与 Array 互转、求交集/差集
// 三种迭代方法,其中第一、第二种等价
for(let item of mySet.keys()) console.log(item)
for(let item of mySet.values()) console.log(item)
for(let [key, value] of mySet.entries()) console.log(key, value);
mySet.forEach((value, key, current) => {})
// Set 与 Array 互转
const myArr = Array.from(mySet);
const myArr2 = [...mySet]
const mySet2 = new Set([1,2,3,4]);
// 求交集/差集
const intersection = new Set([...mySet].filter(x => mySet2.has(x)));
const difference = new Set([...mySet].filter(x => !mySet2.has(x)));
七、字典
- 与集合类似,也是存储唯一值的数据结构,但它是以键值对的形式来存储
- ES6 中有字典,名为 Map
- 常用操作:键值对的增删改查
const m = new Map();
// 增
m.set('a', 'aa');
m.set('b', 'bb');
// 删
m.delete('b');
// m.clear();
// 改
m.set('a', 'aaa');
// 查
m.get('a')
// 遍历和与数组互转,方法同集合(set)
1、算法场景
- 两个数组的交集(LeetCode-349)
- 有效的括号(LeetCode-20)
- 两数之和(LeetCode-1)
- 无重复字符的最长子串(LeetCode-3)
- 最小覆盖子串(LeetCode-76)
八、树
- 一种分层数据的抽象模型
- 前端工作中常见的树包括:DOM树、级联选择、树形控件
- JS中没有树,但是可以用 Object 和 Array 构建树
- 常用操作:深度/广度优先遍历、先中后序遍历
1、深度/广度优先遍历
- 深度优先遍历:尽可能深的搜索树的分支
- 广度优先遍历:先访问离根节点最近的节点
1.1 深度优先遍历口诀
- 访问根节点
- 对根节点的 children 挨个进行深度优先遍历
// dfs
const tree = {
val: 'a',
children: [
{
val: 'b',
children: [
{
val: 'd',
children: [],
},
{
val: 'e',
children: [],
}
],
},
{
val: 'c',
children: [
{
val: 'f',
children: [],
},
{
val: 'g',
children: [],
}
],
}
],
};
const dfs = (root) => {
console.log(root.val);
root.children.forEach(dfs);
};
dfs(tree);
1.2 广度优先遍历口诀
- 新建一个队列,把根节点入队
- 把队头出队并访问
- 把队头的 children 挨个入队
- 重复第二、三步,直到队列为空
// bfs
const tree = {
val: 'a',
children: [
{
val: 'b',
children: [
{
val: 'd',
children: [],
},
{
val: 'e',
children: [],
}
],
},
{
val: 'c',
children: [
{
val: 'f',
children: [],
},
{
val: 'g',
children: [],
}
],
}
],
};
const bfs = (root) => {
const q = [root];
while (q.length > 0) {
const n = q.shift();
console.log(n.val);
n.children.forEach(child => {
q.push(child);
});
}
};
bfs(tree);
2、二叉树先中后序遍历
- 树中每个节点最多只能有两个子节点
- 在 JS 中通常用 Object 来模拟二叉树
const binaryTree = {
val: 1,
left: {
val: 2,
left: null,
right: null
},
right: {
val: 3,
left: null,
right: null
}
}
2.1 先序遍历算法口诀
- 访问根节点
- 对根节点的左子树进行先序遍历
- 对根节点的右子树进行先序遍历
// preorder
const bt = {
val: 1,
left: {
val: 2,
left: {
val: 4,
left: null,
right: null,
},
right: {
val: 5,
left: null,
right: null,
},
},
right: {
val: 3,
left: {
val: 6,
left: null,
right: null,
},
right: {
val: 7,
left: null,
right: null,
},
},
};
// 递归版
const preorder = (root) => {
if (!root) { return; }
console.log(root.val);
preorder(root.left);
preorder(root.right);
};
// 非递归版
const preorder = (root) => {
if (!root) { return; }
const stack = [root]
while (stack.length) {
const n = stack.pop()
console.log(n.val)
if (n.right) stack.push(n.right)
if (n.left) stack.push(n.left)
}
};
preorder(bt);
2.2 中序遍历算法口诀
- 对根节点的左子树进行中序遍历
- 访问根节点
- 对根节点的右子树进行中序遍历
// inorder
// 递归版
const inorder = (root) => {
if (!root) { return; }
inorder(root.left);
console.log(root.val);
inorder(root.right);
};
// 非递归版
const inorder = (root) => {
if (!root) { return; }
const stack = []
let p = root
while (stack.length || p) {
while (p) {
stack.push(p)
p = p.left
}
const n = stack.pop()
console.log(n.val)
p = n.right
}
};
inorder(bt);
2.3 后序遍历算法口诀
- 对根节点的左子树进行中序遍历
- 对根节点的右子树进行中序遍历
- 访问根节点
// postorder
// 递归版
const inorder = (root) => {
if (!root) { return; }
inorder(root.left);
inorder(root.right);
console.log(root.val);
};
// 非递归版
// 逆序思考,类似先序遍历(根右左)
const postorder = (root) => {
if (!root) { return; }
const stack = [root]
const outputStack = []
while (stack.length) {
const n = stack.pop()
outputStack.push(n)
if (n.left) stack.push(n.left)
if (n.right) stack.push(n.right)
}
while (outputStack.length) {
const n = outputStack.pop()
console.log(n.val)
}
};
postorder(bt);
3、算法场景
- 二叉树的最大深度(LeetCode-104)
- 二叉树的最小深度(LeetCode-111)
- 二叉树的层序遍历(LeetCode-102)
- 二叉树的中序遍历(LeetCode-94)
- 路径总和(LeetCode-112)
4、前端与树
- 遍历 JSON 的所有节点值
const json = {
a: { b: { c: 1 } },
d: [1, 2],
};
const dfs = (n, path) => {
console.log(n, path);
Object.keys(n).forEach(k => {
dfs(n[k], path.concat(k));
});
};
dfs(json, []);
- 渲染 Antd 的树组件
九、图
- 图是网络结构的抽象,是一组由边连接的节点
- 图可以表示任何二元关系,比如道路、航班等
- JS 中没有图,但是可以用 Object 和 Array 构建图
- 图的表示法:邻接矩阵、邻接表、关联矩阵等
- 常用操作:深度/广度优先遍历
1、图的深度/广度优先遍历
- 深度优先遍历:尽可能深的搜索图的分支
- 广度优先遍历:先访问离根节点最近的节点
1.1 深度优先遍历口诀
- 访问根节点
- 对根节点的没访问过的相邻节点挨个进行深度优先遍历
const graph = {
0: [1, 2],
1: [2],
2: [0, 3],
3: [3]
};
const visited = new Set()
const dfs = (n) => {
console.log(n)
visited.add(n)
graph[n].forEach(c => {
if (!visited.has(c)) {
dfs(c)
}
})
}
1.2 广度优先遍历口诀
- 新增一个队列,把根节点入队
- 把队头出队并访问
- 把队头的没访问过的相邻节点入队
- 重复第二、三步直到队列为空
const graph = {
0: [1, 2],
1: [2],
2: [0, 3],
3: [3]
};
const bfs = (root) => {
const visited = new Set()
visited.add(root)
const q = [root]
while(q.length) {
const n = q.shift()
console.log(n)
graph[n].forEach(c => {
if (!visited.has(c)) {
q.push(c)
visited.add(c)
}
})
}
}
2、算法场景
- 有效数字(LeetCode-65)
- 太平洋大西洋水流问题(LeetCode-417)
- 克隆图(LeetCode-133)
十、堆
- 堆是一种特殊的完全二叉树(节点完全填满,只能剩余右侧的节点)
- 所有的节点都大于等于(最大堆)或小于等于(最小堆)它的子节点
1、JS 中的堆
- JS 中通常用数组来表示堆
- 左侧子节点的位置是2 * index + 1
- 右侧子节点的位置是2 * index + 2
- 父节点的位置是 (index - 1) / 2
2、堆的应用
- 堆能高效、快速地找出最大/最小值,时间复杂度是 O(1)
- 找出第 k 个最大(小)元素
2.1 找出第 k 个最大(小)元素
2.2 最小堆类实现
- 在类中,声明一个数组,用来装元素
- 主要方法:插入、删除堆顶、获取堆顶、获取堆大小
插入
- 将值插入堆的底部,即数组的尾部
- 然后上移:将这个值和它的父节点进行交换,直到父节点小于等于这个插入的值
删除堆顶
- 用数组尾部元素替换堆顶(直接删除堆顶会破坏堆结构)
- 然后下移:将新堆顶和它的子节点进行交换,直到子节点大于等于这个新堆顶
获取堆顶和堆的大小
- 获取堆顶:返回数组的头部
获取堆的大小:返回数组的长度 ```javascript 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 // return Math.floor((i - 1) / 2) }
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) }
// 获取堆顶 top() { return this.heap[0] }
// 获取堆大小 size() { return this.heap.length } }
// 示例 const h = new MinHeap() h.insert(3) h.insert(2) h.insert(1) h.pop()
<a name="GlNGh"></a>
### 3、算法场景
- 数组中的第 K 个最大元素(LeetCode-215)
- 前 K 个高频元素(LeetCode-347)
- 合并 K 个排序链表(LeetCode-23)
<a name="uVEvR"></a>
## 十一、搜索排序
<a name="WWed6"></a>
### 1、排序
把某个乱序的数组变成升序或者降序的数组,JS 中的排序如 sort 方法,常见排序算法如下:
<a name="swz6I"></a>
#### 1.1 冒泡排序
**思路:**
1. 比较所有相邻元素,如果第一个比第二个大,则交换它们
2. 一轮下来可以保证最后一个数是最大的
3. 执行 n - 1 轮,就可以完成排序
**<br />**代码实现:**
```javascript
// 时间复杂度 O(n^2)
// 升序
Array.prototype.bubbleSort = function () {
for (let i = 0; i < this.length - 1; i += 1) {
for (let j = 0; j < this.length - 1 - i; j += 1) {
if (this[j] > this[j + 1]) {
const temp = this[j]
this[j] = this[j + 1]
this[j + 1] = temp
}
}
}
}
const arr = [5, 4, 3, 2, 1]
arr.bubbleSort()
console.log(arr)
1.2 选择排序
思路:
- 找到数组中的最小值,选中它并将其放置在第一位
- 接着找到第二小的值,选中它并将其放置在第二位
- 以此类推,执行 n - 1 轮
代码实现:**
// 时间复杂度 O(n^2)
// 升序
Array.prototype.selectionSort = function () {
for (let i = 0; i < this.length - 1; i += 1) {
let minIndex = i
for (let j = i + 1; j < this.length; j += 1) {
if (this[minIndex] > this[j]) {
minIndex = j
}
}
if (i !== minIndex) {
const temp = this[i]
this[i] = this[minIndex]
this[minIndex] = temp
}
}
}
const arr = [5, 4, 3, 2, 1]
arr.selectionSort()
console.log(arr)
1.3 插入排序
思路:
- 从第二个数开始,将其“提取”往前比
- 比它大就往后移一格,否则将其插入
- 以此类推进行到最后一个数
代码实现:**
// 时间复杂度 O(n^2)
// 升序
Array.prototype.insertSort = function () {
for (let i = 1; i < this.length; i += 1) {
const temp = this[i]
for (let j = i - 1; j >= 0; j -= 1) {
if (this[j] > temp) {
this[j + 1] = this[j]
this[j] = temp
} else {
break
}
}
}
}
const arr = [5, 4, 3, 2, 1]
arr.insertSort()
console.log(arr)
1.4 归并排序
思路:
- 分:把数组劈成两半,再递归地对子数组进行“分”操作,直到分成一个个单独的数
- 合:把两个数合并为有序数组,再对有序数组进行合并,直到全部子数组合并为一个完整数组
合并两个有序数组:
- 新建一个空数组 res,用于存放最终排序后的数组
- 比较两个有序数组的头部,较小者出队并推入 res 中
- 如果两个数组还有值,就重复第二步
代码实现:**
// 时间复杂度 O(nlogn)
// 升序
Array.prototype.mergeSort = function () {
const rec = (arr) => {
if (arr.length === 1) return arr
const mid = Math.floor(arr.length / 2)
const left = arr.slice(0, mid)
const right = arr.slice(mid, arr.length)
const orderLeft = rec(left)
const orderRight = rec(right)
const res = []
while (orderLeft.length || orderRight.length) {
if (orderLeft.length && orderRight.length) {
res.push(orderLeft[0] < orderRight[0] ? orderLeft.shift() : orderRight.shift())
} else if (orderLeft.length) {
res.push(orderLeft.shift())
} else if (orderRight.length) {
res.push(orderRight.shift())
}
}
return res
}
const res = rec(this)
res.forEach((v, i) => {
this[i] = v
})
}
const arr = [5, 4, 3, 2, 1]
arr.mergeSort()
console.log(arr)
1.5 快速排序
思路:
- 分区:从数组中任意选择一个“基准”,所有比基准小的元素放在基准前面,比基准大的元素放在基准的后面
- 递归:递归地对基准前后的子数组进行分区
代码实现:**
// 时间复杂度 O(nlogn)
// 升序
Array.prototype.quickSort = function () {
const rec = (arr) => {
if (arr.length <= 1) return arr
const left = []
const right = []
const mid = arr[0]
for (let i = 1; i < arr.length; i += 1) {
if (arr[i] < mid) {
left.push(arr[i])
} else {
right.push(arr[i])
}
}
return [...rec(left), mid, ...rec(right)]
}
const res = rec(this)
res.forEach((v, i) => {
this[i] = v
})
}
const arr = [5, 4, 3, 2, 1]
arr.quickSort()
console.log(arr)
2、搜索
找出数组中某个元素的下标,JS 中的搜索如 indexOf、findIndex等方法,常见搜索算法如下:
2.1 顺序搜索
思路:
- 遍历数组
- 找到跟目标值相等的元素,就返回它的下标
- 遍历结束后,如果没找到,就返回 -1
代码实现:**
// 时间复杂度 O(n)
Array.prototype.sequentialSearch = function (target) {
for(let i = 0; i < this.length; i += 1) {
if (this[i] === target) return i
}
return -1
}
const arr = [5, 4, 3, 2, 1]
const index = arr.sequentialSearch(2)
console.log(index)
2.2 二分搜索
必须有序
思路:
- 从数组的中间元素开始,如果中间元素正好是目标值,则搜索结束
- 如果目标值大于或小于中间元素,则在大于或小于中间元素的那一半数组中搜索
代码实现:**
// 时间复杂度 O(logn)
Array.prototype.binarySearch = function (target) {
let begin = 0
let end = this.length - 1
while (begin <= end) {
const mid = Math.floor((end + begin) / 2)
if (this[mid] === target) {
return mid
} else if (this[mid] < target) {
begin = mid + 1
} else if (this[mid] > target) {
end = mid - 1
}
}
return -1
}
const arr = [1, 2, 3, 4, 5]
const index = arr.binarySearch(2)
console.log(index)
3、算法场景
- 合并两个有序链表(LeetCode-21)
- 猜数字大小(LeetCode-374)
十二、分而治之
- 分而治之是算法设计中的一种方法,是一种思想
- 它将一个问题分成多个和原问题相似的小问题,递归解决小问题,再将结果合并以解决原来的问题
1、场景一:归并排序
- 分:把数组从中间一分为二
- 解:递归地对两个子数组进行归并排序
- 合:合并有序子数组
2、场景二:快速排序
- 分:选基准,按基准把数组分成两个子数组
- 解:递归地对两个子数组进行快速排序
- 合:对两个子数组进行合并
3、算法场景
- 猜数字大小(LeetCode-374)
- 翻转二叉树(LeetCode-226)
- 相同的树(LeetCode-100)
- 对称二叉树(LeetCode-101)
十三、动态规划
- 动态规划是算法设计中的一种方法,是一种思想
- 它将一个问题分解为相互重叠的子问题,通过反复求解子问题,来解决原来的问题
1、动态规划 vs 分而治之
- 相同点:动态规划和分而治之都是将一个问题分解为子问题
- 不同点:动态规划是相互重叠的子问题,子问题之间有关联关系;分而治之的子问题彼此相互独立
2、算法场景
- 爬楼梯(LeetCode-70)
- 打家劫舍(LeetCode-198)
十四、贪心算法
- 贪心算法是算法设计中的一种方法,是一种思想
- 期盼通过每个阶段的局部最优选择,从而达到全局的最优
- 结果并不一定是最优
1、算法场景
- 分饼干(LeetCode-455)
- 买卖股票的最佳时机(LeetCode-122)
十五、回溯算法
- 回溯算法是算法设计中的一种方法,是一种思想
- 回溯算法是一种渐进式寻找并构建问题解决方式的策略
- 回溯算法会先从一个可能的动作开始解决问题,如果不行,就回溯并选择另一个动作,直到将问题解决
1、适用场景
- 有很多路
- 这些路里,有死路,也有出路
- 通常需要递归来模拟所有的路
2、算法场景
- 全排列(LeetCode-46)
- 子集(LeetCode-78)