数据结构和算法小结
算法
双指针
双指针法基本用于处理字符串、数组和链表相关问题。
两个指针起始位置可以处在不同的位置,移动的规则也不同。
-
一个指向开头一个指向结尾。可以解决反转字符串类似的问题。
-
都指向开头,但是移动的速度不同,一个每次前进一步,另一个每次前进两步(快慢指针)。
示例
let slow = 0; let fast = 0; while (fast < len) { ... fast += 2; slow++; }
-
都指向开头,一个每次前进一步,另一个按照判断逻辑是否移动指针(快慢指针)。
示例
let slow = 0; let fast = 0; while (i < len) { if (xxx) { ... slow++; } fast++; }
-
一个指向开头,一个指向后面的节点,根据判断逻辑移动指针(滑动窗口)。
示例
let left = 0; let right = 1; while (right < length) { // 增大窗口 right++; while (xx) { // 缩小窗口 left++; } }
递归
递归一般处理重复性的问题。
将处理的问题的逻辑封装好,然后进行重复的调用。
示例
function recursion() {
// 重复性逻辑
...
// 避免死循环
if (xxx) {
recursion();
}
}
回溯
回溯是递归的过程中增加一步回撤的逻辑。
示例
const paths = []
function recursion() {
// 重复性逻辑
...
// 避免死循环
if (xxx) {
recursion();
}
// 回撤逻辑
...
}
贪心
以局部最优推算全局最优。难点在于找出什么是局部最优。
同时要考虑后面的局部决策,不会被前面的影响。
可以用于处理类似分发饼干等问题。
动态规划
动态规划每一步的计算逻辑,依赖于上一步的计算逻辑,即最终的问题,是由 N 多个重叠的子问题构成。
动态规划问题的解题步骤
- 先建立一个 dp (Dynamic Programming)数组。一般为二维数组,有些问题可以优化空间至一维数组。
- 建立推导公。即如何根据上一步的状态,计算下一步的状态的逻辑。
- 初始化 dp 数组。这样才能开始进行初始的推导。
- 确定循环顺序。 4.1 有些问题可以优化二维数组为一维数组。就需要从后往前遍历,因为从后往前可以拿到上一次的运算结果,如果从前往后就会覆盖之前的运算结果。 4.2 如果循环是选择物品,从后往前遍历,可以保证物品被使用一次。如果从前往后,则前面的计算结果可能是已经使用过该商品,后续的计算如果用到了这个计算结果,则该物品就会被多次使用。
KMP
最长公共前后缀。前缀不包含最后一个字符,后缀不包含第一个字符。
例如 aabaa
字符 | 最长公共前后缀 | 最长公共前后缀长度 |
---|---|---|
aa | a | 1 |
aab | '' | 0 |
aaba | a | 1 |
aabaa | aa | 2 |
可以用于处理类似 模式串匹配 等问题。
求字符串最长公共前后缀
function getPrefixSuffixMaxResults(str) {
const results = [0];
const len = str.length;
let i = 1;
let j = 0;
while (i < len) {
if (&& j > 0 && str[i] !== str[j]) {
// results 表示最长公共前后缀个数
// 如果 str[i] !== str[j], 则找 第 j - 1 个字符的最长公共前后缀个数 results[j - 1]
// 因为 results 表示最长公共前后缀个数,所以直接取 results[j - 1] 就是需要再次比较的下标
// 可得逻辑 j = results[j - 1];
j = results[j - 1];
continue;
}
if (str[i] === str[j]) {
j++;
}
i++;
results.push(j);
}
return results;
}
小顶堆
小顶堆的定义
- 一个完全二叉树
- 二叉树上的任意节点值都必须小于等于其左右子节点值(大顶堆是大于等于其左右子节点值)
因为是完全二叉树,使用数组表示小顶堆,这样处理起来更方便。
graph TB
1((1))
2((2))
3((3))
8((8))
7((7))
16((16))
4((4))
1--left-->2
1--right-->3
2--left-->8
2--right-->7
3--left-->16
3--right-->4
即 [1, 2, 3, 8, 7, 16, 4]
可以用于处理类似 前 K 个高频元素 等问题。
小顶堆实现
class Heap {
// 完全二叉树在数组中满足以下特点
// 某节点下标 i, left 节点下标为 i * 2,right 节点下标为 i * 2 + 1
heap = [null]; // 初始为 [null] 是为了方便计算
push(v) {
const heap = this.heap;
heap.push(v);
// 上浮元素
this.up();
}
pop() {
const heap = this.heap;
const top = heap[1];
// 下沉元素
this.down();
return top;
}
size() {
return this.heap.length - 1;
}
up() {
const heap = this.heap;
const len = this.heap.length;
let idx = len - 1;
let parentIdx = Math.floor(idx / 2);
while (parentIdx > 0 && heap[parentIdx] > heap[idx]) {
this.swap(idx, parentIdx);
idx = parentIdx;
parentIdx = Math.floor(idx / 2);
}
}
down() {
const heap = this.heap;
heap[1] = heap.pop();
const len = this.heap.length;
let parentIdx = 1;
let leftIdx = parentIdx * 2;
let rightIdx = leftIdx + 1;
let swapIdx = this.getDownSwapIdx(leftIdx, rightIdx);
while (swapIdx < len && heap[swapIdx] && heap[parentIdx] > heap[swapIdx]) {
this.swap(parentIdx, swapIdx);
parentIdx = swapIdx;
leftIdx = swapIdx * 2;
rightIdx = leftIdx + 1;
swapIdx = this.getDownSwapIdx(leftIdx, rightIdx);
}
}
getDownSwapIdx(leftIdx, rightIdx) {
const heap = this.heap;
// right 节点不存在则尝试取 left 节点
if (heap[rightIdx] === undefined) return leftIdx;
return heap[leftIdx] > heap[rightIdx] ? rightIdx : leftIdx; // 让较小的上浮
}
swap(a, b) {
const heap = this.heap;
[heap[a], heap[b]] = [heap[b], heap[a]];
}
}
单调栈
单调栈用于查找数组元素中下一个比它大或小的元素的场景。
以查找数组元素中下一个比它大的元素为例:
栈维护的是未找到比它大的元素的下标,维护下标是为了方便查找元素。
栈的元素顺序是从大到小的顺序,因为在遍历过程中,如果找到了比栈尾大的元素,则让栈中的元素依次出栈。
可以用于处理类似 每日温度 等问题。
求数组元素中下一个比它大的元素
function monotonicStack(array) {
const len = array.length;
const results = Array(len).fill(0);
const stack = [0]; // 单调栈
let i = 1;
while (i < len) {
// 遍历过程中的元素如果大于栈尾的元素,则让栈中的元素依次出栈
while (stack.length !== 0 && array[stack[stack.length - 1]] < array[i]) {
const v = stack.pop();
results[v] = i - v;
}
// 将当前元素推入到栈中
stack.push(i);
i++;
}
return results;
}
数据结构
字符串
示例
const string = "abcd";
[String]((developer.mozilla.org/zh-CN/docs/…) 对象用于表示和操作字符序列。
字符串是一串连续的字符,和数组类似。
可以通过Array.prototype.join() 和 String.prototype.split() 互相转换。
解题技巧
leetcode 题目
1002. 查找共用字符 - 可以借助枚举字符解决
拓展
枚举字母数组
// 这里储存的是字符, 下标和具体字符是可以互相转换的。
xdcArray.from(
{
length: 26,
},
(_, idx) => String.fromCharCode(idx + 97)
);
数组
示例
const array = ["a", "b", "c", "d"];
Array 对象支持在单个变量名下存储多个元素,并具有执行常见数组操作的成员。
在进行数组操作时,应尽量避免使用Array.prototype.shift()和Array.prototype.unshift()。
因为数组是一段连续的空间,所以操作数组前面的元素,整体的元素都会向前或向后移动。
另外就是尽量全填充数组。否则访问未初始化的元素,会访问原型链,带来额外的访问消耗。或者形成慢数组。(参考)
解题技巧
leetcode 题目
拓展
数组初始化
// 无法遍历
const a1 = Array(10);
// 可以遍历
const a2 = Array(10).fill();
const a3 = Array.from({ length: 10 });
// [] 为同一个数组引用,应避免使用
const a4 = Array(10).fill([]);
// 推荐使用
const a5 = Array.from({ length: 10 }, () => []);
链表
示例
const a = {
val: "a",
next: null,
};
const b = {
val: "a",
next: null,
};
const c = {
val: "a",
next: null,
};
a.next = b;
b.next = c;
const links = a;
graph LR
a((a))
b((b))
c((c))
d((d))
a--next-->b
b--next-->c
c--next-->d
链表 是一种线性表,但是并不会按线性的顺序存储数据,而是在每一个节点里存到下一个节点的指针(Pointer)。
链表不同于数组的是,数组使用的是连续的空间。链表使用 next 指针指向下一个节点。
因此链表在对节点的增删的性能,优于数组。更适用于做频繁删除插入的场景。
解题技巧
- 一般问题,可以先声明三个变量辅助解决问题。
pre
、current
、next
,然后通过循环,不断地更新这三个节点。 - 两个链表比较 A 、 B,可以对他们做拼接(A-> B->A),辅助解题。
- 判断链表是否有环可以使用快慢指针。
leetcode 题目
拓展
链表有环推导过程
// 两个指针去遍历链表,一个一次走一步(a = node?.next),一个一次走两步(b = node?.next?.next)
let x; // 链表头到环的起点节点距离
let y; // 环的起点到相遇的节点距离
let z; // 相遇节点到环的起点的距离(向后的距离)
// 快指针是慢指针速度的两倍,则走的距离同样是两倍。可得下面的等式
2(x + y) = x + y + z + y // 下面一步一步简化
2x + 2y = x + 2y + z
2x = x + z
x = z // 所以 链表头到环的起点节点距离 === 相遇节点到环的起点的距离(向后的距离)
散列表
示例
const hashTable = {
a: "a",
b: "b",
c: "c",
};
散列表(Hash table,也叫哈希表),是根据键(Key)而直接访问在内存储存位置的数据结构。也就是说,它通过计算出一个键值的函数,将所需查询的数据映射到表中一个位置来让人访问,这加快了查找速度。这个映射函数称做散列函数,存放记录的数组称做散列表。
散列表相对于数组、链表、树等数据结构,它对于节点的插入和删除做到了O1的程度。
散列表储存数据是以 key、value的形式。底层依赖还是数组,具体的储存位置,会有一个散列函数,将 key 转化为数组中具体的储存位置。
散列表的储存空间被填满时或遇到散列函数计算的值冲突时,性能就会下降很多。
解题技巧
一般问题给出的数据结构不是散列表,这种数据结构可以进行辅助解题。例如在遍历过程中,记录每一个节点的信息。
leetcode 题目
二叉树
示例
const tree = {
val: "a",
left: {
val: "b",
left: {
val: "c",
left: null,
right: null,
},
right: null,
},
right: {
val: "c",
left: null,
right: null,
},
};
graph TB
a((a))
b((b))
c((c))
a--left-->b
a--right-->c
二叉树 是每个节点最多只有两个分支(即不存在分支度大于 2 的节点)的树结构。通常分支被称作“左子树”或“右子树”。
数组和链表数据结构,都有各自的优点。数组便于搜索,链表便于插入和删除。二叉树是为了即便于搜索,又便于插入、删除,而建立的一种数据结构。
但并不是所有二叉树都满足上述的优点,需要二叉树的节点满足某种规则才能具备。例如平衡状态(左右两个子树的高度差的绝对值不超过 1)下的二叉树搜索树(二叉树的一种形式,其左侧节点均小于根节点,右侧节点均大于根节点)。
解题技巧
- 遍历二叉树的方式有很多种,前中后序遍历、层级有限、深度遍历,根据需要选择合适的方式。
- 后序遍历,可以根据前序遍历稍作变化(
中左右
改为中右左
)反转得到(中右左
反转为左右中
)。 - 二叉树路径所有路径可以使用回溯。
- 根据二叉树的后序和中序遍历结果,构造二叉树。可以根据后序最后一个节点(root 节点)将中序数组切割成左右两个数组,再根据中序数组将后序数组切割成左右两个数组。反复上述过程。
- 根据二叉树的前序和中序遍历结果,构造二叉树。可以根据前序第一个节点(root 节点)将中序数组切割成左右两个数组,再根据中序数组将前序数组切割成左右两个数组。反复上述过程。
leetcode 题目
拓展
遍历二叉树模板
let pre = null;
function recursionTree(root) {
if (!root) return root;
const left = recursionTree(root.left);
if (pre !== null) {
// 在前序遍历过程中
// 这里的逻辑可以拿到 上一个节点pre 和 当前节点root 执行具体逻辑
xxx;
}
pre = root;
const right = recursionTree(root.right);
// 这里可以拿到左右两侧遍历结果, 根据逻辑需要返回需要的节点
return xxxxx ? left : right;
}
左叶子节点
左叶子节点指的是符合 node.left && node.left.left === null && node.left.right === null
的节点。