算法 & 数据结构
大部分算法教学的问题在于教学者/作者并不是真正的程序员, 或者至少是对软件工程不够熟悉的程序员, 因此他们不懂得如何写出更好的程序代码.
这意味着他们给出的示例代码会充满糟糕的味道:
- 破坏代码可读性的短变量名.
- 省略花括号的编码风格("token越少, 性能越好"的典型错误认识).
- 一个函数负责完成多件事(把可读性和重构当空气).
- 几乎每个教程对同一种算法的实现都有所不同, 缺乏权威性,
也很少有负责任的benchmark作为算法性能的证明(忽视生成的底层代码的运行效率).
如果作者只给出伪代码, 情况往往会变得更加糟糕.
因为伪代码的正确性无法经过机器验证, 错误难以被发现,
没经历过的人恐怕很难想象遇到这种事情时会有多愤怒.
因为伪代码的正确性无法经过机器验证, 错误难以被发现,
没经历过的人恐怕很难想象遇到这种事情时会有多愤怒.
算法复杂度表示规模增长时算法耗时(步骤)的增速, 大O表示法表示算法的最糟情况.
大O表示法会省略一些常数值.
大O表示法会省略一些常数值.
O(n) 表示线性时间增长, 当n是一个很大的值时, 算法耗时也会同步增长, 例如n=64时, 增速为64.O(logn) 表示对数时间增长, 当n是一个很大的值时, 算法耗时只会根据幂次进行增长, 例如n=64时, 增速为6.log10(100) 即"求10的几次幂为100", 答案是2: 10^2 = 100在大O表示法里讨论算法的复杂度时, log指log2.
拿二分查找举例, 其算法复杂度为logn, 被查找的有序数组包含8个元素, 最多只需检查3个元素即可找到目标(
因此, 二分查找(
拿二分查找举例, 其算法复杂度为logn, 被查找的有序数组包含8个元素, 最多只需检查3个元素即可找到目标(
log2(8) = 3).因此, 二分查找(
O(logn))是一种比简单查找(遍历所有元素, O(n))在需要处理的元素数量增长时耗时增长更低的算法.O(1)常数的.O(log(n))对数的.O(log(n))c)对数多项式的.O(n)线性的.O(n^2)二次的.
这种复杂度在未经优化的算法中很常见, 意味着算法难以扩展到较大的n.O(nlog(n))快速排序.O(n^c)多项式的.O(c^n)指数的.O(n!)阶乘的.
在n的值极小以外的情况下, 都会非常慢, 穷举所有排列组合是这种算法的代表.O(log*n)迭代对数(iterated logarithm)的.
指的是对n反复取几次对数后, 结果才会小于等于1.
这种复杂度被认为接近常数, 因为即使是非常大的n也经不起几次取对数.
另有lg*n指以2为底数的迭代对数,lg*(2^65535)的结果是5.
| 数据结构 | 一般情况 | 最差情况 | |||||
|---|---|---|---|---|---|---|---|
| 随机访问 | 插入 | 删除 | 搜索 | 插入 | 删除 | 搜索 | |
| 数组/栈/队列 | 支持 | O(1) | O(1) | O(n) | O(n) | O(n) | O(n) |
| 链表/双向链表 | 不支持 | O(1) | O(1) | O(n) | O(1) | O(1) | O(n) |
| 哈希表 | O(1) | O(1) | O(1) | O(n) | O(n) | O(n) | |
| 二分搜索树 | O(log(n)) | O(log(n)) | O(log(n)) | O(n) | O(n) | O(n) | |
| AVL树 | O(log(n)) | O(log(n)) | O(log(n)) | O(log(n)) | O(log(n)) | O(log(n)) |
| 节点/边的管理方式 | 存储空间 | 增加顶点 | 增加边 | 删除顶点 | 删除边 | 轮询 |
|---|---|---|---|---|---|---|
| 邻接表 | O(abs(V)+abs(E)) | O(1) | O(1) | O(abs(V)+abs(E)) | O(abs(E)) | O(abs(V)) |
| 邻接矩阵 | O(abs(V)^2) | O(abs(V)^2) | O(1) | O(abs(V)^2) | O(1) | O(1) |
| 算法(用于数组) | 时间复杂度 | ||
|---|---|---|---|
| 最好情况 | 一般情况 | 最差情况 | |
| 冒泡排序 | O(n) | O(n^2) | O(n^2) |
| 选择排序 | O(n^2) | O(n^2) | O(n^2) |
| 插入排序 | O(n) | O(n^2) | O(n^2) |
| 归并排序 | O(nlog(n)) | O(nlog(n)) | O(nlog(n)) |
| 快速排序 | O(nlog(n)) | O(nlog(n)) | O(n^2) |
| 堆排序 | O(nlog(n)) | O(nlog(n)) | O(nlog(n)) |
| 桶排序 | O(n+k) | O(n+k) | O(n^2) |
| 基数排序 | O(nk) | O(nk) | O(nk) |
| 算法 | 数据结构 | 最差情况 |
|---|---|---|
| 顺序搜索 | 数组 | O(n) |
| 二分搜索 | 已排序的数组 | O(log(n)) |
| 深度优先搜索(DPS) | 顶点(节点)数为abs(V), 边数为abs(E)的图 | O(abs(V)+abs(E)) |
| 广度优先搜索(BFS) | 顶点(节点)数为abs(V), 边数为abs(E)的图 | O(abs(V)+abs(E)) |
- 分而治之 Divide and Conquer(D&C): 将问题分解为更简单的子问题, 自上而下解决问题的方法.
- 贪心策略: 每一步都采取局部最优解(尽管最后得到的可能不是全局最优解).
- 动态规划 Dynamic Programming: 从解决小问题着手, 逐步解决大问题.
动态规划之所以成立, 是因为子问题是独立和离散的, 并且子问题的答案可以被用来解决更大的问题.
排序的稳定性指的是重复元素在排序完成后能否维持其之前的相对位置关系.
比如元素A和元素B的某个属性都是10, 元素A位于元素B之前,
在按此属性排序这两个元素后, 如果元素A总是能保证位于元素B之前, 那么这个排序算法就是稳定的.
比如元素A和元素B的某个属性都是10, 元素A位于元素B之前,
在按此属性排序这两个元素后, 如果元素A总是能保证位于元素B之前, 那么这个排序算法就是稳定的.
计数排序只需要一个一维数组就可以完成的非比较排序, 是在不可避免遍历每一个元素的情况下最快的排序算法.
计数排序的原理是"统计元素的出现次数", 然后"按统计还原出新的数组".
当桶排序(Bucket sort)的存储桶大小为1的时候, 就会退化为计数排序.
计数排序的缺点在于由于它基于统计, 所以只能排序整数数字.
遍历并交换左右两值
在内循环中遍历找到最小值, 与外循环索引位置的元素进行交换, 然后进入外循环的下一个元素.
特征:
- 耗时与输入无关(即使集合已经排序过, 仍然会消耗同样的时间, 因为总是要遍历剩余的所有元素)
- 原地排序(最左侧是已经排序的集合)
在内循环中倒序遍历之前的元素, 将每一项与外循环索引位置之前的元素进行比较, 如果更小, 就交换两个元素.
特征:
特征:
- 在小数组情况下优于冒泡和选择排序
- 原地排序(最左侧是已经排序的集合)
插入排序的改进版本.
原理很复杂: 使数组中任意间隔为h的元素都有序.
特征:
- 即使是排序大型数组, 性能也可接受(尽管还是慢于能在生产中使用的排序算法)
// 来自: 维基百科public static void shellSort(int[] arr) { int length = arr.length; int temp; for (int step = length / 2; step >= 1; step /= 2) { for (int i = step; i < length; i++) { temp = arr[i]; int j = i - step; while (j >= 0 && arr[j] > temp) { arr[j + step] = arr[j]; j -= step; } arr[j + step] = temp; } }}O(nlogn)归并排序会从数组中间将数组分成两半分别排序, 最后将两个有序的数组合并成一个更大的有序数组.
归并排序是一种分而治之的排序, 将大问题拆分为局部的问题:
如果它能排序两个子数组, 那么也通过进一步拆分子数组进行排序.
如果它能排序两个子数组, 那么也通过进一步拆分子数组进行排序.
归并排序分为两个函数, 一个函数完成归并(merge, 将两个子数组合并成完成排序的数组, 该函数在归并排序中起排序作用),
一个函数完成拆分(split, 把数组拆成小数组, 然后用merge函数归并回来, 该函数在归并排序中起分而治之的作用).
一个函数完成拆分(split, 把数组拆成小数组, 然后用merge函数归并回来, 该函数在归并排序中起分而治之的作用).
非原地归并(merge):
创建一个额外数组, 遍历这个额外数组以确定每个索引应该插入哪一个子数组索引里的元素
(分别以变量i和j表示子数组的索引, 插入成功则自增1).
原地归并实现起来比较复杂.
创建一个额外数组, 遍历这个额外数组以确定每个索引应该插入哪一个子数组索引里的元素
(分别以变量i和j表示子数组的索引, 插入成功则自增1).
原地归并实现起来比较复杂.
- 自顶向下的归并排序 TopDownMergeSort (递归): 将大数组分割成小数组, 递归这一过程, 最终完成归并.
- 自底向上的归并排序 BottomUpMergeSort (循序渐进):
从元素数量为2的小数组开始排序,
完成一遍之后再从头开始排序元素数量为4的数组(上一轮的两个小数组), 以此类推, 最后完成最大数组的排序.
此方法比较适合链表结构.
特点:
- 分而治之
应用最广泛的排序算法.
平均
最糟(在数组已经有序的情况下)
平均
O(nlogn)最糟(在数组已经有序的情况下)
O(n^2)与归并排序对比:
- 归并排序的数组拆分先于数组内容, 总是从中间拆分.
快速排序的数组拆分后于数组内容, 拆分的位置取决于数组的内容. - 虽然归并排序的算法复杂度稳定为O(nlogn), 但归并排序的速度慢于快速排序.
若将算法复杂度误认为算法的速度, 就会被误导.
特点:
- 分而治之
- 可原地排序
- 高性能
看代码比解释起来更方便, 此版本只需要生成数组并递归:
# 来自算法图解def quicksort(array): if len(array) < 2: return array else: pivot = array[0] less = [i for i in array[1:] if i <= pivort] greater = [i for i in array[1:] if i > pivot] return quicksort(less) + [pivot] + quicksort(greater)挑选一个基准元素(默认使用的基准元素是第一个元素或最后一个元素), 执行以下步骤:
- 1.从距离基准元素最远的元素开始向基准元素遍历.
- 2.当发现比基准元素小的元素A时停止.
- 3.从基准元素向元素A遍历.
- 4.当发现比基准元素大的元素B时停止.
- 5.交换元素A和元素B.
- 6.以元素A和元素B的位置为范围, 继续执行1~5的步骤, 直到没有可遍历的元素(没有符合条件的元素A或没有符合条件的元素B).
按基准元素的索引分割出两个子数组, 在这两个子数组上递归先前的步骤.
// 来自geeksforgeeks.orgvoid quicksort(int arr[]) { sort(arr, 0, arr.length - 1)}void sort(int arr[], int low, int high) { if (low < high) { int pi = partition(arr, low, high); sort(arr, low, pi - 1); sort(arr, pi + 1, high); }}// 实际起排序作用的部分int partition(int arr[], int low, int high) { int pivot = arr[high]; int i = low - 1; for (int j = low; j < high; j++) { if (arr[j] < pivot) { i++; int temp = arr[i]; arr[i] = arr[j]; arr[j] = temp; } } int temp = arr[i + 1]; arr[i + 1] = arr[high]; arr[high] = temp; return i + 1;}# 来自geeksforgeeks.orgdef partition(arr, low, high): i = low - 1 pivot = arr[high] for j in range(low, high): if arr[j] < pivot: i = i + 1 arr[i], arr[j] = arr[j], arr[i] arr[i + 1], arr[high] = arr[high], arr[i + 1] return i + 1def quickSort(arr, low, high): if low < high: pi = partition(arr, low, high) quickSort(arr, low, pi - 1) quickSort(arr, pi + 1, high)- 二分搜索 Binary Search (数组, 链表)
- 回溯法 Backtracking
- 双指针 Two Pointers
- 扫描线 Scan-line algorithm
- 拓扑排序 (DAG): 基于深度优先搜索
O(V+E)基于队列, 逐渐向外延伸的搜索算法.
该算法是一种贪心算法.
可用于解决以下问题:
- 一个节点到另一个节点的路径是否存在
- 求一个节点到另一个节点的最短路径
在实现时需要使用队列来存储待搜索的节点.
基本实现:
- 1.创建一个队列, 存储待搜索的节点(当前节点的所有邻近节点)
- 2.从队列中弹出一个节点
- 3.检查弹出的节点是否是搜索的目标.
- 是搜索的目标, 搜索完成, 返回
- 不是搜索的目标, 将这个节点的所有邻近节点加入队列, 回到步骤2
在此处可能会因为图中的环状结构形成循环,
因此要再创建一个散列表/数组保存已经检查过的节点或为节点增加一个属性用以表示已被访问, 以跳过这些节点.
- 5.队列为空, 即没有找到搜索目标
求最短路径:
- 1.在基本实现的"将待搜索节点加入队列"时:
用一个散列表记录前溯点, 表示该待搜索节点B由当前节点A搜索得到. - 2.在BFS搜索完成后返回当前节点, 根据前溯点可得出最短路径(因为广度优先搜索是贪心算法, 所以搜索返回时走的那条路径一定是最短路径).
基于Stack(递归), 不撞南墙不回头的搜索算法.
在实现时需要使用栈(Stack), 用于存储搜索时下探的路径, 如果使用递归, 则栈由编程语言自身实现.
根据节点访问顺序的不同, 可分为三种模式:
- 前序/先序(preorder)
- 中序(inorder, 在二叉树中出现),
- 后序(postorder)
造成不同访问顺序的原因在于递归过程中调用回调函数(执行对树的操作)的时机不同:
- 前序/先序: 在递归后代之前调用回调
- 中序(二叉树): 递归left, 调用回调, 递归right
- 后序: 在递归后代之后调用回调
一种用于有环图的DFS.
时间复杂度与BFS相同.
具有线性空间复杂度, 空间复杂度与DFS相同.
时间复杂度与BFS相同.
具有线性空间复杂度, 空间复杂度与DFS相同.
基本思想:
- 1.限制深度优先搜索的深度, 初始值为1, 开始DFS, 但在DFS达到限制深度时就返回.
- 2.如果找到目标节点, 则结束算法.
如果未能找到目标节点, 则将深度限制加1, 再次执行步骤1.
深度限制使得该算法不会陷入无限循环.
每次深度增加, IDS算法都会从起点出发重新访问一遍在之前的深度访问过的节点, 这显然造成浪费.
然而, 对算法的分析认为, 由于深度的节点数量通常呈指数级增长, 搜索的大部分开销将集中在最后一个深度(目标节点能被找到的深度),
前期的搜索步骤带来的开销基本可以忽略不计.
然而, 对算法的分析认为, 由于深度的节点数量通常呈指数级增长, 搜索的大部分开销将集中在最后一个深度(目标节点能被找到的深度),
前期的搜索步骤带来的开销基本可以忽略不计.
缓存上一级深度因深度限制而返回时, 每个分支访问的最后一个节点, 可以加速下一级深度的搜索.
这么做将破坏算法的线性空间复杂度, 因为每一级深度的节点数量会呈指数级增长.
这么做将破坏算法的线性空间复杂度, 因为每一级深度的节点数量会呈指数级增长.
- https://stackoverflow.com/questions/12599143/what-is-the-distinction-between-sparse-and-dense-graphs
- https://en.wikipedia.org/wiki/Dense_graph
带有权重(weight)的图, 即地图上记录了每条路径所需要付出的开销(权重).
权重可以为负值, 带有负权重值的边被称为负权边.
Dijkstra算法不支持负权边.
贝尔曼-福德算法(Bellman-Ford algorithm)支持负权边.
贝尔曼-福德算法(Bellman-Ford algorithm)支持负权边.
该算法是一种用于加权图的最短路径贪心算法.
与广度优先搜索一样, 为了在遍历过程中避开已经走过的节点, 需要记录下已经走过的节点.
与广度优先搜索一样, 为了在遍历过程中避开已经走过的节点, 需要记录下已经走过的节点.
步骤:
- 1.将起点节点记为start, 创建一个开销表, 开销表记录start到每个节点的开销.
在算法开始前, 将开销表中所有项目初始化为无穷大. - 2.遍历起点节点start周围的邻近节点, 更新开销表.
- 3.将当前邻近节点记为current, 将current的上一级节点记为previous, 遍历current的邻近节点, 将这些邻近节点记为next.
遍历current到所有next, 更新开销表.
如果发现一个next可以直接通向previous, 则比较"previous直接到next"和"previous到current再到next"的开销,
如果开销较低, 则执行松弛操作, 降低开销表中start到next的开销. - 4.重复步骤3, 直到遍历完所有节点.
- 5.开销表中现在已经记录了start到所有节点的最低开销, 开销最低的就是最短路径, 最短路径可能有好几条.
输出最短路径: 只需要从终点节点开始, 沿着最低开销的邻近节点行走, 就能回到起点节点.
一张用来记录从起点节点到特定节点最少开销值的表, 表中项目的初始值为无穷大.
注意, 开销表只能记录下开销, 而使用该算法的时一般还想要记录移动的路径.
松弛是Dijkstra算法中的一种基本操作.
指"开销表中的开销"由于发现"开销更低的新路径"而更新为这个更低值的操作.
指"开销表中的开销"由于发现"开销更低的新路径"而更新为这个更低值的操作.
包含启发式评估函数的搜索算法.
最常见于游戏的寻路算法, 在大多数情况下被视作游戏寻路的最佳方案.
在选择合适启发式算法的情况下, 性能表现较好, 并且通常能找到 一条 最短路径.
A*可以被视作结合了启发式算法的Dijkstra, 有权图版本的贪心最佳搜索.
在选择合适启发式算法的情况下, 性能表现较好, 并且通常能找到 一条 最短路径.
A*可以被视作结合了启发式算法的Dijkstra, 有权图版本的贪心最佳搜索.
A*对开销的计算可以表示为公式
f(n) = g(n) + h(n)g(n) 从起点到节点n的开销(即Dijkstra算法里的开销).h(n) 从节点n到终点, 由启发式算法得出的开销.f(n) 即 g(n) 和 h(n) 的和, 即A*算法实际使用的开销.极端情况下:
h(n)返回0, 算法此时退化为Dijkstra算法.h(n) >>> g(n), 即h(n)远大于g(n), 算法此时退化为启发式算法.
步骤:
- 1.将起点节点记为start, 创建两个集合, open集合(开集合)和close集合(闭集合).
在算法开始前, 将起点节点插入open集合. - 2.得到open集合中的节点开销, 取出open集合中开销最小的节点current, 将它加入close集合.
遍历current的邻近节点next: - 过滤出所有已经存在于open集合的next.
重新计算这些next的开销, 如果新开销小于旧开销, 则更新parent属性为current. - 过滤掉所有已经存在于close集合的next, 只是忽略它们.
- 将剩余的不存在于open集合, 也不存在于close集合的next们,
计算完开销, 将parent属性设置为current后, 插入到open集合.
- 4.重复步骤2, 直到open集合为空.
- 5.如果终点节点的parent属性不为空, 则说明寻路成功.
一路回溯parent属性直到起点节点, 即可得出路径.
实现: https://github.com/qiao/PathFinding.js
包含解释步骤的DEMO: http://qiao.github.io/PathFinding.js/visual/
包含解释步骤的DEMO: http://qiao.github.io/PathFinding.js/visual/
参考:
- https://www.gamedev.net/reference/articles/article2003.asp
- https://www.redblobgames.com/pathfinding/a-star/introduction.html
A*的迭代深化版本.
具有线性空间复杂度, 内存用量明显少于A*.
然而, 每次迭代导致的重复和启发式函数的计算成本很容易使得IDA*的时间复杂度高于A*, 这使得IDA*基本只在内存受限情况下有用.
具有线性空间复杂度, 内存用量明显少于A*.
然而, 每次迭代导致的重复和启发式函数的计算成本很容易使得IDA*的时间复杂度高于A*, 这使得IDA*基本只在内存受限情况下有用.
参考: http://theory.stanford.edu/~amitp/GameProgramming/Heuristics.html
适用于仅能往上, 下, 左, 右四个方向移动的寻路.
def heuristic(node): distance_x = abs(node.x - goal.x) distance_y = abs(node.y - goal.y) # D是一个用来调节的常数 return D * (distance_x + distance_y)适用于仅能往上, 下, 左, 右, 左上, 左下, 右上, 右下八个方向移动的寻路.
def heuristic(node): distance_x = abs(node.x - goal.x) distance_y = abs(node.y - goal.y) # D是一个用来调节的常数 return D * (distance_x + distance_y) + (D2 - 2 * D) * min(distance_x, distance_y)适用于可以以任意角度移动的寻路.
def heuristic(node): distance_x = abs(node.x - goal.x) distance_y = abs(node.y - goal.y) # D是一个用来调节的常数 return D * sqrt(distance_x ** 2 + distance_y ** 2)Hash的本质是将大范围值单向映射到小范围值上.
h(k) = k mod m值域为
[0, m-1]可以通过取余运算将大整数值映射到特定的小范围整数上.
在取m值时, 一般会选取一个质数, 并且此值应该远离2的幂.
该方法的主要缺点是计算机执行除法运算的性能不佳, 但仍然是一种较常见的方案.
需要注意的是, 该方法并不是一种好的散列函数.
尽管连续的值会被均匀映射到不同的整数上, 但由于现实世界中的输入值往往隐含某种规律, 这会导致取余法的散列结果也形成有规律, 导致数据聚集在一起.
在大部分情况下, 取余法的应用场景是被用来作为第二级散列函数, 用来将其他散列函数的结果映射到小范围整数上, 而不是单独使用它.
尽管连续的值会被均匀映射到不同的整数上, 但由于现实世界中的输入值往往隐含某种规律, 这会导致取余法的散列结果也形成有规律, 导致数据聚集在一起.
在大部分情况下, 取余法的应用场景是被用来作为第二级散列函数, 用来将其他散列函数的结果映射到小范围整数上, 而不是单独使用它.
h(k) = floor(m(kA mod 1))常数A需要满足
0<A<1值域为
[0, m-1]其中,
m一般取2的幂.
kA mod 1 是取出 kA 的小数部分.m一般取2的幂.
该算法可以通过位运算来加速, 因此是速度最快的散列函数之一.
可以通过前一项的哈希值和当前项快速算出当前项哈希值的哈希算法.
常见于rsync, Dropbox等增量内容传输工具.
模糊哈希对于相似但不同的输入, 返回相同的哈希值.
一种生成图片指纹的哈希算法,
通过汉明距离等算法比较指纹差异可以得出图片之间的相似性.
通过汉明距离等算法比较指纹差异可以得出图片之间的相似性.
该算法被广泛用于第一代图片搜索系统(以图搜图).
被基于embedding的搜索技术取代, 因为embedding支持多模态并且具有更强的鲁棒性.
被基于embedding的搜索技术取代, 因为embedding支持多模态并且具有更强的鲁棒性.
n 被搜索字符串的长度
m 模式的长度
m 模式的长度
遍历字符串, 从每个字符开始移动指针进行匹配, 当匹配失败(失配)时指针回溯到开始匹配的字符的下一个字符.
一般情况的时间复杂度为O(n+m), 最差情况的时间复杂度为O(nm).
KMP的核心思想是在朴素搜索时通过已知信息来跳过一些字符, 从而减少最坏情况下浪费掉的匹配时间.
在字符串匹配失败时(也称为"失配"), KMP基于以下公式计算指针回溯后的移动位数, 而不是像朴素搜索那样固定移动1位:
KMP匹配失败时的移动位数 = 最后一个成功匹配的字符的部分匹配值 +1
KMP匹配失败时的移动位数 = 最后一个成功匹配的字符的部分匹配值 +1
时间复杂度稳定为O(n+k), 其中O(k)为构建PMT的时间复杂度.
在搜索开始前, 需要先从搜索词里创建一张部分匹配表(<<PMT>>, partial match table).
例如:
例如:
| 搜索词 | ABCDABD |
| 部分匹配值 | 0000120 |
一个字符的部分匹配值是"从字符串的首个字符其到此字符的字符串"的"前缀集合"和"后缀集合"的最长共有元素的长度.
部分匹配值实际代表的是 字符串起始部分开始与当前字符相关的重复模式(overlap)的长度.
部分匹配值实际代表的是 字符串起始部分开始与当前字符相关的重复模式(overlap)的长度.
以字符串"ABCDABD"为例:
| 索引 | 字符 | 字符串 | 前缀集合 | 后缀集合 | 共有元素 | 最长共有元素的长度 |
| 0 | A | A | [] | [] | [] | 0 |
| 1 | B | AB | [A] | [B] | [] | 0 |
| 2 | C | ABC | [A, AB] | [BC, B] | [] | 0 |
| 3 | D | ABCD | [A, AB, ABC] | [BCD, CD, D] | [] | 0 |
| 4 | A | ABCDA | [A, AB, ABC, ABCD] | [BCDA, CDA, DA, A] | [A] | 1 |
| 5 | B | ABCDAB | [A, AB, ABC, ABCD, ABCDA] | [BCDAB, CDAB, DAB, AB, B] | [AB] | 2 |
| 6 | D | ABCDABD | [A, AB, ABC, ABCD, ABCDAB] | [BCDABD, CDABD, DABD, ABD, BD, D] | [] | 0 |
在实现KMP算法时, 预先计算好的指针跳跃位置的索引值被存储为next数组.
function computeNext(pattern: string): number[] { const next: number[] = new Array(pattern.length).fill(0) if (pattern.length === 1) return next for (let i = 2; i < str.length; i++) { if (pattern[i - 1] === pattern[next[i - 1]]) { next[i] = next[i - 1] + 1 } } return next}void ComputeNext(char *pattern, int patternLength, int next[]) { next[0] = 0; if (patternLength <= 1) return; next[1] = 0; int jump = 2; while (jump < patternLength) { if (pattern[jump - 1] == pattern[next[jump - 1]]) { next[jump] = next[jump - 1] + 1; } else { next[jump] = 0; } jump++; }}Boyer-Moore是高效率字符串搜索算法, 被用于各种单模式搜索.
该算法从模式的尾部开始匹配.
该算法从模式的尾部开始匹配.
详见 https://www.ruanyifeng.com/blog/2013/05/boyer-moore_string_search_algorithm.html
在搜索前需要构造出DFA.
非常快, 时间复杂度为O(n).
基于模式构造出以TF表(二维数组)表示的DFA, 根据每个字符决定下一个状态.
当下一个字符与模式匹配时, 会在当前状态码上自增1, 从而得到下一个状态.
当下一个字符与模式不匹配时, 会"跳转到首个状态"或"利用先前的重复模式从而跳转到某个匹配的中间状态".
当下一个字符与模式匹配时, 会在当前状态码上自增1, 从而得到下一个状态.
当下一个字符与模式不匹配时, 会"跳转到首个状态"或"利用先前的重复模式从而跳转到某个匹配的中间状态".
其原理非常类似于双数组字典树(DAT).
例如以下为模式ACACACGA的TF表:
| 状态(码) | 字符 | |||
|---|---|---|---|---|
| A | C | G | T | |
| 0 | 1 | 0 | 0 | 0 |
| 1 | 1 | 2 | 0 | 0 |
| 2 | 3 | 0 | 0 | 0 |
| 3 | 1 | 4 | 0 | 0 |
| 4 | 5 | 0 | 0 | 0 |
| 5 | 1 | 4 | 6 | 0 |
| 6 | 7 | 0 | 0 | 0 |
| 7 | 1 | 2 | 0 | 0 |
多模式搜索当然也可以用于单模式搜索, 但很可能是用牛刀杀鸡.
通过滚动哈希进行匹配.
一般情况的时间复杂度为O(n+m), 最差情况的时间复杂度为O(nm).
被用于单模式搜索时效率不如KMP.
为敏感词字典创建Trie, 然后进行搜索.
在中文互联网中流传的一种基于HashMap的有限自动机, 而该方法实际上是用HashMap实现的字典树方法.
字典可以动态增减.
随着字典的增长, Trie数据结构可能占用较大的内存空间.
一种多模式搜索算法.
在搜索前需要按字典编译出一个自动机.
在搜索前需要按字典编译出一个自动机.
一般情况的时间复杂度为O(n+m), 最差情况的时间复杂度为O(n^2).
- 1.先根据要匹配的元素构建字典树(Trie)
- 2.将字典树里加上fail指针.
fail指针: 当前字符匹配失败时, 应该跳转到新的节点, 这个跳转的指针就叫做fail指针.
fail指针的存在是为了加速不匹配情况下的查询速度.
举例来说, 有以下两条Trie子树A和B:
A: s->h->e->i
B: h->i->s
->e->r->s
A: s->h->e->i
B: h->i->s
->e->r->s
两条子树都具有h->e段, 当A树的s->h->e匹配成功, 但在i节点上失败时,
根据fail指针, 可以将e节点跳转到B树h->e段的e节点上, 从而节省下了再次匹配共有前缀h->e的时间.
根据fail指针, 可以将e节点跳转到B树h->e段的e节点上, 从而节省下了再次匹配共有前缀h->e的时间.
加上fail指针后, 即可得到AC自动机.
执行一般的Trie匹配操作, 但在失败时根据fail指针进行移动.
在自动机中, 如果fail指针最终指回根节点, 说明当前字符没有可匹配的项, 放弃当前字符, 匹配下一个字符.
在自动机中, 如果fail指针最终指回根节点, 说明当前字符没有可匹配的项, 放弃当前字符, 匹配下一个字符.
时间复杂度近似于线性.
每次修改字典都需要重新编译自动机.
hyperscan是一个高性能多模式正则表达式库, 目前是世界上最快的正则表达式库.
hyperscan在内部使用自动机.
hyperscan在内部使用自动机.
指数退避算法被用于乐观并发控制的重试机制.
客户端会根据因子(factor)计算下次重试的时间, 此时间是随着重试次数而指数增长的.
客户端会根据因子(factor)计算下次重试的时间, 此时间是随着重试次数而指数增长的.
Math.min(factor ** retries * minTimeout, maxTimeout)一般情况下, factor的值取2.
第一次重试(retries = 0): minTimeout
第二次重试(retries = 1): minTimeout * 2
第三次重试(retries = 2): minTimeout * 4
第一次重试(retries = 0): minTimeout
第二次重试(retries = 1): minTimeout * 2
第三次重试(retries = 2): minTimeout * 4
无抖动的指数退避被认为对于解决惊群问题(thundering herd problem)的作用十分有限, 因此出现了带抖动的指数退避.
抖动指的是在算法中引入随机值, 从而大幅降低客户端发生竞争的可能性.
抖动指的是在算法中引入随机值, 从而大幅降低客户端发生竞争的可能性.
增量抖动指的是在指数退避值的基础上, 增加一个随机值.
这种抖动的缺点在于进一步延长了退避的时间.
这种抖动的缺点在于进一步延长了退避的时间.
randomTimeout是一个取值范围为[0, 1000]的随机毫秒值.
return Math.min(factor ** retries * minTimeout + randomIntInclusive(0, 1000), maxTimeout)randomTimeout取值上限是以当前指数退避的结果乘以一个百分数(例如20%)得来的,
然后再根据[0, upperBound]得到随机毫秒值.
然后再根据[0, upperBound]得到随机毫秒值.
这种方法在重试次数较小时有较小的随机范围, 在重试次数较大时有较大的随机范围.
const temp = factor ** retries * minTimeoutreturn Math.min(temp + randomIntInclusive(0, temp * 0.2), maxTimeout)非增量抖动指的是以指数退避值为上限的随机抖动.
对半抖动是在指取指数退避值的一半进行抖动, 即[0, timeout / 2].
const temp = Math.min(factor ** retries * minTimeout)return Math.floor(temp / 2 + randomInclusive(0, temp / 2))完全抖动是指完全将指数退避的计算结果作为随机数上限, 将0作为下限的抖动, 即[0, timeout].
在AWS被认为是最佳的抖动方式:
https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/
https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/
return randomIntInclusive(0, Math.min(factor ** retries * minTimeout, maxTimeout))设定一个计数器, 周期起始时间点和固定时间窗口内请求的阈值, 达到阈值即拒绝请求.
在超过指定的时间后, 重置计数器和更新周期起始时间点.
在超过指定的时间后, 重置计数器和更新周期起始时间点.
符合直觉, 同时也符合各类API服务常见的限流描述: 在X时间内允许Y个请求.
流量可能在时间段的开始处就被消耗完, 其余的时间处于空载状态.
这迫使时间窗口被设定为较小的值.
这迫使时间窗口被设定为较小的值.
这是对固定时间窗口的改进.
滑动时间窗口记录了当前设定的时间窗口里每个请求到达时的时间点,
因此在检查阈值时, 可以根据当前时间点向前滑动时间窗口动态确定周期起始时间点,
而不必使用固定的周期起始时间点.
滑动时间窗口记录了当前设定的时间窗口里每个请求到达时的时间点,
因此在检查阈值时, 可以根据当前时间点向前滑动时间窗口动态确定周期起始时间点,
而不必使用固定的周期起始时间点.
突发的大流量(例如大量请求)可能导致计数器快速到达阈值, 从而使其余的时间处于空载状态.
令牌桶是一个存放令牌的桶, 其容量是恒定的(记为M, 可当作系统允许的瞬时最大流量, 这种最大流量不可持续).
令牌桶会以恒定速度产生令牌(意为T秒内允许总计消耗N个令牌的操作), 并把令牌放进令牌桶中.
令牌桶会以恒定速度产生令牌(意为T秒内允许总计消耗N个令牌的操作), 并把令牌放进令牌桶中.
当令牌桶的容量满了, 多余的令牌就会被放弃.
当处理请求时, 申请从令牌桶里取出相应数量的令牌, 如果令牌桶中的令牌数量不足, 则拒绝该请求.
当处理请求时, 申请从令牌桶里取出相应数量的令牌, 如果令牌桶中的令牌数量不足, 则拒绝该请求.
令牌桶的优点是可以用令牌的数量来类比实际流量的大小:
一个请求可能需要大量的令牌, 而令牌不足时, 则该请求会直接无法发出, 而那些只需要少量令牌的请求则能够被放行.
一个请求可能需要大量的令牌, 而令牌不足时, 则该请求会直接无法发出, 而那些只需要少量令牌的请求则能够被放行.
令牌桶是漏桶meter变体的反向实现, 想象水龙头在往空水桶里灌水(令牌), 然后再把水(令牌)从桶中舀出来.
在漏桶中, 将请求看作是水流.
漏桶是一个有进水口和出水口的桶, 桶的容量是恒定的.
出水口的流速是恒定的(意为T秒内最多消耗N个单位的水), 进水口流速由生产者决定.
漏桶是一个有进水口和出水口的桶, 桶的容量是恒定的.
出水口的流速是恒定的(意为T秒内最多消耗N个单位的水), 进水口流速由生产者决定.
当进水口的速率大于出水口时, 桶最终会溢出, 此时会拒绝生产者的请求.
当出水口的速率大于进水口时, 桶最终会变空, 此时会拒绝消费者的请求.
当出水口的速率大于进水口时, 桶最终会变空, 此时会拒绝消费者的请求.
漏桶的仪表形式是一种计数器, 计数器会由于流量增加而上升, 并定期减少.
这其实是令牌桶的一种实现, 只不过在描述上方向相反.
这其实是令牌桶的一种实现, 只不过在描述上方向相反.
漏桶的队列形式是一种FIFO队列, 与一般认为的消息队列具有相似的工作模式:
定期从队列中取出一定数量的流量, 然后转发.
定期从队列中取出一定数量的流量, 然后转发.
- 由于令牌桶的特性, 令牌桶的容量往往需要设定为一个较高的值,
否则由于令牌的补充速率较低, 可能会导致流量无法被充分利用.
因此, 令牌桶的容量无法用于最大负载值的参考, 对于后端来说, 系统的最大负载难以预测. - 由于漏桶的特性, 漏桶出水口的最大速率是恒定不变的.
因此对被限流的后端来说, 系统的最大负载是一个确定的值.
缓存具有容量限制, 当缓存已满, 首先丢弃使用频率最低的项目.
最简单的实现方式是为每个项目分配一个计数器, 每次使用项目时, 增加其值.
当缓存已满, 丢弃计数值最小的项目.
当缓存已满, 丢弃计数值最小的项目.
- 使用次数最多的项目, 即使长时间不使用, 也不会被删除.
- 刚刚进入缓存的项目, 很容易成为使用计数值最小的项目.
缓存具有容量限制, 当缓存已满, 首先丢弃最近最少使用的项目.
实现方式为设置一个自增计数器, 为每个项目分配一个序号值.
每次访问缓存时, 将对应项目的序号设置为计数器的值, 然后将计数器自增1.
当缓存已满, 丢弃序号最小的项目.
每次访问缓存时, 将对应项目的序号设置为计数器的值, 然后将计数器自增1.
当缓存已满, 丢弃序号最小的项目.
对于有序结构而言, 有一种很简单的实现方法:
每次访问时, 从序列里删除最早的项目, 然后将其插入到序列末尾.
每次访问时, 从序列里删除最早的项目, 然后将其插入到序列末尾.
带有容量限制和过期删除机制的缓存.
结合了LFU和LRU.
当缓存满时, 随机选择一个项目替换掉.
编辑距离是两个字符串之间不同的一种度量,
指将一个字符串变换为另一个字符串时所需的最少"编辑"操作次数.
指将一个字符串变换为另一个字符串时所需的最少"编辑"操作次数.
不同的距离支持的"编辑"操作不同.
汉明距离只支持"替换"这一项编辑操作,
因此仅可用于计算等长字符串之间的编辑距离.
因此仅可用于计算等长字符串之间的编辑距离.
最有名的字符串编辑距离算法, 经常与编辑距离一词互换使用.
莱文斯坦距离为汉明距离的推广, 支持"插入", "删除", "替换"三项操作,
因此支持计算不等长字符串之间的编辑距离.
因此支持计算不等长字符串之间的编辑距离.
解决LCS问题的算法被广泛用于diff等程序.
LCS可以视作只支持"插入", "删除"两项编辑操作的编辑距离变种.
给定序列的子序列指的是删除或不删除序列中的元素, 保持元素之间的相对位置得到的序列.
一个长度为n的序列有2^n个子序列.
一个长度为n的序列有2^n个子序列.
例如序列ABCD中的子序列有:
- 空序列.
- A, B, C, D.
- AB, AC, AD, BC, BD, CD.
- ABC, ABD, ACD, BCD.
- ABCD.
固定使用D65光源.
标准化的sRGB是取值范围为
最常见的sRGB实现是8位色深的sRGB, 也就是取值范围为
注意, 在专业色彩处理领域, 提到sRGB一般指标准化的sRGB, 而不是8位色深的sRGB.
[0, 1] 的浮点数三元组.最常见的sRGB实现是8位色深的sRGB, 也就是取值范围为
[0, 255] 的无符号整数三元组.注意, 在专业色彩处理领域, 提到sRGB一般指标准化的sRGB, 而不是8位色深的sRGB.
sRGB是经过伽马压缩的非线性色彩空间, 这意味着将它的R, G, B通道数值提升相同的值并不会线性地改变颜色的亮度.
由于这一点, 几乎所有直接对sRGB进行计算的操作都将是错的.
由于这一点, 几乎所有直接对sRGB进行计算的操作都将是错的.
由于会在较小数值处产生误差, 不应在8位sRGB和8位线性sRGB之间互相转换.
参考: https://blog.demofox.org/2018/03/10/dont-convert-srgb-u8-to-linear-u8/
尽管编写方便且经常被使用, 但该算法不符合人眼的感知.
通过加权对该算法的改进仍然远远不够用.
这是CompuPhase于Colour metric提出的低成本颜色距离算法.
这个算法的实际表现并不如加权的RGB欧几里得距离.
HSV/HSL和RGB一样, 并非感知均匀色彩空间.
即使只是HSL的亮度通道, 在人类感知方面也不准确, 以至于无法用来生成正确的灰度.
Björn Ottosson发明的色彩空间, 优于CIELAB.
Oklab固定使用D65光源, 简化了使用.
Oklab固定使用D65光源, 简化了使用.
https://bottosson.github.io/posts/oklab/
Oklab色彩空间下的欧几里得距离.
根据Björn Ottosson在W3C相关标准化问题中留下的记录,
在用欧几里得距离计算色差时, a和b的比例因子略大于2时会产生最佳结果.
在用欧几里得距离计算色差时, a和b的比例因子略大于2时会产生最佳结果.
参考实现: https://github.com/color-js/color.js/blob/3e97aa382b7a861409c8f1bdaf04fa7a42b1f5ee/src/deltaE/deltaEOK2.js
实际测试表明, 该算法优于Delta E OK.
Chris Lilley于笔记中提出的Delta E, 类似dE94, 需要先转换为Lch.
实际测试中, 该算法与Delta E OK的区别极小(此方法制造的"噪点"较多), 令人怀疑多出的计算量是否值得.
一种指标, 衡量人眼感知色差, 范围为[0, 100].
数值越小, 意味着差异越难感知, 小于等于1的Delta E被视作人眼无法察觉的差异.
数值越小, 意味着差异越难感知, 小于等于1的Delta E被视作人眼无法察觉的差异.
参考:
- https://zschuessler.github.io/DeltaE/learn/
- http://www.easyrgb.com/en/math.php
- https://en.wikipedia.org/wiki/Color_difference
由国际照明委员会(CIE)于1976年创建的色彩空间.
- CIE L*: 表示亮度, 0%为黑色, 50%为灰色, 100%为白色.
- CIE a*: 表示颜色的红灰度, 有符号, 正值为红色, 负值为绿色, 0为中性色.
- CIE b*: 表示颜色的黄蓝度, 有符号, 正值为黄色, 负值为蓝色, 0为中性色.
Delata E具有多种计算公式, 每种计算公式又具有多种别名, 使得交流变得很混乱.
由国际照明委员会(CIE)于1976年制定的第一个Delta E公式.
公式非常简单, 只是LAB色彩空间下的欧几里得距离.
只在亮度相同的情况下, 具备良好的感知均匀性.
只在亮度相同的情况下, 具备良好的感知均匀性.
实测证明, 这种色差不比RGB的欧几里得距离好多少.
优点是算法简单, 快速.
优点是算法简单, 快速.
CIE于1994年发布的对dE76公式的改进, 引入了将Lab转换为CIE L*C*h (Lch)的计算.
公式变得相当复杂.
公式变得相当复杂.
CIE于2001年发布的对1994年公式的改进, 在2013年标准化.
仅适用于标准动态范围(SDR), 在相当长的时间里是效果最佳, 应用最广泛的色差公式.
仅适用于标准动态范围(SDR), 在相当长的时间里是效果最佳, 应用最广泛的色差公式.
2019年, 为了宽色域(WCG)和高动态范围(HDR)引入的新标准.
大多数三维聚类算法都可用于色彩量化.
尽管大部分色彩量化算法在设计时都选择了sRGB这样的非人类感知均匀色彩空间,
但在人类感知均匀色彩空间上有时能获得更好的效果.
但在人类感知均匀色彩空间上有时能获得更好的效果.
参考: https://spin.atomicobject.com/pixels-and-palettes-extracting-color-palettes-from-images/
最简单的色彩量化算法, 计算效率很高.
算法描述:
- 1.将图片中的每个像素按照RGB颜色放进三维空间.
- 2.将三维空间平均切分为27个桶(即3x3x3切割).
- 3.将落在同一个桶中的像素的颜色值平均, 得到该桶的颜色值.
- 4.将桶按其中的像素数量进行降序排序, 即可得到色板.
中位切割是最流行的色彩量化算法, 似乎也是常见算法中量化效果最好的.
算法描述:
- 1.设定色板的颜色数量.
- 2.将图片中的所有像素放进一个桶内.
- 3.统计桶中所有像素的颜色值, 找到RGB三个通道中数值范围最大的通道, 将该通道的数值范围视作这个桶的颜色范围.
- 4.找到颜色范围最大的桶, 根据其数值范围最大的通道的值对像素排序.
- 5.执行"中位切割": 将桶等分为"中位前"和"中位后"两个.
- 6.对得到的新桶重复执行步骤3~5, 直到桶的数量与要求的色板颜色数量相同.
- 7.对每个桶中的像素的颜色值执行平均, 得到调色盘.
颜色提取最有名的实现是lokesh/color-thief, 它使用了来自Leptonica的MMCQ算法.
MMCQ算法被认为与Leptonica的八叉树算法能达到相同级别的量化效果.
该算法提取调色盘的能力优于单纯的中位切割.
常用于色彩量化的聚类算法之一, 运行时间取决于退出算法的条件.
复杂度很高, 计算效率相当低.
复杂度很高, 计算效率相当低.
实际效果相对中位切割进步有限, 考虑计算时间的话相当不划算.
实测仅在配合Oklab等人类感知均匀色彩空间时可以获得明显优于中位切割的结果.
实测仅在配合Oklab等人类感知均匀色彩空间时可以获得明显优于中位切割的结果.
标准实现有随机性, 每次执行的结果可能有微小差异.
在nQuantCpp上发现的算法, 速度较慢, 但似乎是在生成256及更少颜色数量的调色盘时效果最好的算法.
演示: https://mcychan.github.io/PnnQuant.js/demo/
https://en.wikipedia.org/wiki/Comparison_gallery_of_image_scaling_algorithms
缩放的图像不会变得模糊, 而是以锯齿呈现, 主要用于将像素图放大.
require! 'prelude-ls': {round, map, zip-with}nearest-neighbor-interpolation = (zoom, src-image) -> color = (image, x, y) -> i = (y * image.width + x) * 4 map (|| 0), image.data[i to i + 3] canvas = document.create-element \canvas canvas.width = src-image.width * zoom canvas.height = src-image.height * zoom ctx = canvas.get-context \2d dst-image= ctx.create-image-data canvas.width, canvas.height for dst-x from 0 til canvas.width for dst-y from 0 til canvas.height src-color = color src-image, (round dst-x / zoom), (round dst-y / zoom) dst-i = (dst-y * canvas.width + dst-x) * 4 dst-color = map (/ 2), zip-with (+), src-color, color dst-image, dst-x, dst-y for i from 0 to 3 dst-image.data[dst-i + i] = dst-color[i] ctx.put-image-data dst-image, 0, 0 canvasrequire! 'prelude-ls': {map, round, floor, ceiling, sqrt, abs, sum}bilinear-interpolation-put = (zoom, src-image) -> color = (image, offset, point) --> if point.x >= image.width or point.y >= image.height if offset is 3 255 else 0 else image.data[(image.width * point.y + point.x) * 4 + offset] [R, G, B, A] = map (color src-image), [0 to 3] canvas = document.create-element \canvas canvas.width = src-image.width * zoom canvas.height = src-image.height * zoom ctx = canvas.get-context \2d dst-image = ctx.create-image-data canvas.width, canvas.height for dst-x from 0 til canvas.width for dst-y from 0 til canvas.height src = x: dst-x / zoom, y: dst-y / zoom lt = x: floor(src.x), y: floor src.y rt = x: ceiling(src.x), y: floor src.y lb = x: floor(src.x), y: ceiling src.y rb = x: ceiling(src.x), y: ceiling src.y weight-mean = (src, points) -> weights = [] sf = [] for p, i in points when p.x < src-image.width and p.y < src-image.height w = sqrt(abs(p.x - src.x) * abs(p.y - src.y)) w = 1 / w if w isnt 0 weights.push w sf.push ((w, p)-> (f) -> w * f p )(w, p) (f) -> s = sum map ((s) -> s(f)), sf if sum(weights) is 0 then 0 else s / sum weights get-weight-mean = weight-mean src, [lt, rt, lb, rb] dst-i = (dst-y * canvas.width + dst-x) * 4 dst-image.data[dst-i + 0] = round get-weight-mean R dst-image.data[dst-i + 1] = round get-weight-mean G dst-image.data[dst-i + 2] = round get-weight-mean B dst-image.data[dst-i + 3] = round get-weight-mean A ctx.put-image-data dst-image, 0, 0 canvasrequire! 'prelude-ls': {map, floor}bicubic-interpolation-put = (zoom, src-image)-> color = (image, x, y) -> i = (y * image.width + x) * 4 map (|| 0), image.data[i to i + 3] triangle-interpolation = f -> f = f / 2 if f < 0 f + 1 else 1 - f clamp = value -> return 255 if value > 255 return 0 if value < 0 value canvas = document.create-element \canvas canvas.width = src-image.width * zoom canvas.height = src-image.height * zoom ctx = canvas.get-context \2d dst-image = ctx.create-image-data canvas.width, canvas.height for dst-y from 0 til dst-image.height src-y = dst-y * zoom j = floor src-y t = src-y - j for dst-x from 0 til dst-image.width src-x = dst-x * zoom k = floor src-x u = src-x - k rgba = [0, 0, 0, 0] s = 0 for m from -1 til 3 for n from -1 til 3 rgb = color src-image, j + m, k + n f1 = triangle-interpolation m - t f2 = triangle-interpolation -(n - u) s += f2 * f1 rgba[0] += rgb[0] * f2 * f1 rgba[1] += rgb[1] * f2 * f1 rgba[2] += rgb[2] * f2 * f1 rgba[3] += rgb[3] * f2 * f1 dst-i = (dst-y * canvas.width + dst-x) * 4 dst-image.data[dst-i + 0] = clamp parseInt rgba[0] / s dst-image.data[dst-i + 1] = clamp parseInt rgba[1] / s dst-image.data[dst-i + 2] = clamp parseInt rgba[2] / s dst-image.data[dst-i + 3] = clamp parseInt rgba[3] / s ctx.put-image-data dst-image, 0, 0 canvas泛光效果本质上是将模糊图像与原始图像混合的结果.
在Photoshop里将图像高斯模糊然后叠加在原图上, 就相当于一种泛光效果.
模糊图像是实现泛光(发光部分影响周围)的关键, 泛光的不同实现通常都是在模糊算法上做文章.
在Photoshop里将图像高斯模糊然后叠加在原图上, 就相当于一种泛光效果.
模糊图像是实现泛光(发光部分影响周围)的关键, 泛光的不同实现通常都是在模糊算法上做文章.
在3D里, 泛光经常被用来实现低成本的灯光效果.
具体实现方法为, 通过为材质设置自发光, 使材质的颜色在亮度上超出设定的阈值.
提取图像中超出阈值的像素, 将未超出阈值的部分设置为黑色, 仅对这张基于阈值提取出的图像执行模糊步骤.
混合则是将模糊图像与原始图像相加.
具体实现方法为, 通过为材质设置自发光, 使材质的颜色在亮度上超出设定的阈值.
提取图像中超出阈值的像素, 将未超出阈值的部分设置为黑色, 仅对这张基于阈值提取出的图像执行模糊步骤.
混合则是将模糊图像与原始图像相加.
实践中, 最常见的阈值为sRGB色彩空间里的1, 也就是低动态范围(LDR)颜色.
当颜色超出1, 达到高动态范围(HDR)颜色时执行泛光.
当颜色超出1, 达到高动态范围(HDR)颜色时执行泛光.
参考: https://www.froyok.fr/blog/2021-12-ue4-custom-bloom/
与形态侵蚀互逆.
需要两个输入:
- 需要膨胀的源图像.
- 一个滑动窗口, 通常为3x3大小.
步骤:
在输入图像上滑动窗口, 窗口覆盖到的部分包含至少一个1值时,
将输出图像上, 窗口中心位置(也称为锚点)的值设置为1.
在输入图像上滑动窗口, 窗口覆盖到的部分包含至少一个1值时,
将输出图像上, 窗口中心位置(也称为锚点)的值设置为1.
与形态膨胀互逆.
需要两个输入:
- 需要侵蚀的源图像.
- 一个滑动窗口, 通常为3x3大小.
步骤:
在输入图像上滑动窗口, 当窗口覆盖到的部分包含至少一个0值时,
将输出图像上, 窗口中心位置(也称为锚点)的值设置为0.
在输入图像上滑动窗口, 当窗口覆盖到的部分包含至少一个0值时,
将输出图像上, 窗口中心位置(也称为锚点)的值设置为0.
计算机图形学常用的伪随机生成方式.
以二维向量为输入的随机数生成器:
float rand(vec2 uv) { return fract( // 将值限制在[0, 1]范围内 sin( // 将值限制在[-1, 1]范围内 dot( // 通过点积生成随机数 uv.xy , vec2(12.9898, 78.233) // 带有小数的, 与输入维度相同的任意向量 ) ) * 43758.5453123 // 带有小数的任意巨大值 );}教程: https://www.ronja-tutorials.com/noise.html
由Ken Perlin设计的噪声算法, 广泛用于计算机图形学和游戏开发等领域.
结果范围为
[0, 1].柏林噪声算法作者设计的柏林噪声改进算法, 受到专利保护.
Simplex噪声的计算开销比柏林噪声低, 结果与柏林噪声相当.
Simplex噪声的计算开销比柏林噪声低, 结果与柏林噪声相当.
取值范围为
[-1, 1].由Kurt Spencer实现的绕过原有Simplex Noise专利的噪声算法.
平铺噪声是可以在噪声的边界无缝衔接的噪声.
以2D噪声图为例, 这样的噪声图在平铺时, 即使改变噪声图的偏移值, 最终得到的图案仍然是连续的.
以2D噪声图为例, 这样的噪声图在平铺时, 即使改变噪声图的偏移值, 最终得到的图案仍然是连续的.
制造平铺噪声的一种方法是将低维坐标映射到高维空间中一条具有连续性的首尾相连的路径上,
典型做法是映射到高维空间的圆环上.
典型做法是映射到高维空间的圆环上.
该方案的缺陷是, 高维空间的圆/圆环自身的曲率会导致采样结果的扭曲.
1D平铺噪声是1D空间上首尾相连的噪声, 需要将1D坐标映射到2D的圆环(ring)上.
创建1D平铺噪声的过程本质上是极坐标系到笛卡尔坐标系的变换.
将1D的坐标映射为极坐标系中的角坐标, 然后在2D噪声函数里创建一个圆.
将1D的坐标映射为极坐标系中的角坐标, 然后在2D噪声函数里创建一个圆.
半径坐标r固定为1.
角坐标a由1D坐标范围映射到角度/弧度范围得来.
变换公式为
由于半径坐标r固定为1, 变换公式可简化为
角坐标a由1D坐标范围映射到角度/弧度范围得来.
变换公式为
x = r * cos(a), y = r * sin(a).由于半径坐标r固定为1, 变换公式可简化为
x = cos(a), y = sin(a).参考: https://en.wikipedia.org/wiki/Polar_coordinate_system
2D平铺噪声是2D空间上, 能够首尾相连的噪声.
需要1个轴实现首尾相连的情况, 需要将2D坐标映射到3D的首尾贴合的纸带/没有顶面和底面的圆柱(cylinder)上.
需要2个轴实现首尾相连的情况, 需要将2D坐标映射到3D的圆环(torus)上.
需要2个轴实现首尾相连的情况, 需要将2D坐标映射到3D的圆环(torus)上.
一种在4D上进行采样的方法, 据称可获得优于在3D上采样与混合法的结果: Seamless Noise
- https://www.ronja-tutorials.com/post/029-tiling-noise/
- https://godotshaders.com/snippet/seamless-perlin-noise/
具有时间连续性的噪声可以在具有空间连续性的噪声上新增一个维度来获得.
以1D噪声为例, 通过将其坐标映射为2D噪声的x, 将时间映射为2D噪声的y来获得具有时间连续性的1D噪声.
以1D噪声为例, 通过将其坐标映射为2D噪声的x, 将时间映射为2D噪声的y来获得具有时间连续性的1D噪声.
具有时间连续性的1D噪声本质上是2D噪声;
具有时间连续性的2D噪声本质上是3D噪声;
以此类推.
具有时间连续性的2D噪声本质上是3D噪声;
以此类推.
时间循环噪声是在时间轴上构成循环的噪声, 即最后一个时间点的噪声值和第一个时间点的噪声值能够衔接上.
由于具有时间连续性的1D噪声本质上是2D噪声, 因此1D时间循环噪声本质上是2D平铺噪声;
由于具有时间连续性的2D噪声本质上是3D噪声, 因此2D时间循环噪声本质上是3D平铺噪声;
以此类推.
由于具有时间连续性的2D噪声本质上是3D噪声, 因此2D时间循环噪声本质上是3D平铺噪声;
以此类推.
线性映射是将一个向量空间映射到另一个向量空间的过程.
将随机数映射到整数域的算法就是一种线性映射.
将随机数映射到整数域的算法就是一种线性映射.
Flood fill是从图中的一个节点出发, 找到周边符合条件的连续节点, 对这些节点执行某种操作的算法.
该算法最典型的应用场景是二维数组(网格)里的填色(图像编辑工具的油漆桶).
该算法最典型的应用场景是二维数组(网格)里的填色(图像编辑工具的油漆桶).
在很大程度上, Flood fill是专用于多维数组(主要是二维数组)的图遍历算法, 其本质与通用的图搜索算法没有区别.
稍加利用, 就可将其变为一种性能较差的连通分量标记算法:
遍历多维数组, 在未标记的节点上调用该算法直到没有节点未标记.
遍历多维数组, 在未标记的节点上调用该算法直到没有节点未标记.
参考: https://codeheir.com/2022/08/21/comparing-flood-fill-algorithms-in-javascript/
从一个节点出发, 递归检查它周边的节点, 直到没有符合条件的节点.
该算法本质上是深度优先搜索.
该算法本质上是深度优先搜索.
朴素实现的缺点很明显:
- 对已经走过的节点重复访问.
- 递归过深可能导致栈溢出.
对递归实现的朴素改进, 通过添加一个队列来替代递归产生的调用栈, 从而避免栈溢出.
缺点是仍然会对已经走过的节点重复访问, 明显不如直接改为广度优先搜索.
Flood fill的一种优化, 执行横向扫描而不是四向扫描.
以下算法描述仅适用于二维数组和四向洪水:
- 1.向队列添加起始节点.
- 2.从队列中取出一个节点, 如果没有节点, 算法结束.
向左扫描直到不能继续, 将最左侧节点的x坐标记为lx;
向右扫描直到不能继续, 将最右侧节点的x坐标记为rx. - 3.向下移动一行, 在[lx, rx]之间寻找新的满足条件的节点, 将该节点作为起始节点加入队列;
向上移动一行, 在[lx, rx]之间寻找新的满足条件的节点, 将该节点作为起始节点加入队列;
跳转至步骤1.
优点:
- 减少了重复访问.
- 因为顺序访问的关系, 内存性能优异.
缺点:
- 尽管重复访问相比朴素实现少得多, 但仍然有重复访问.
一种为二值图像的连通区域分配标记的算法.
本质是图遍历, 在原理上也适用于更高的维度和非二值图.
本质是图遍历, 在原理上也适用于更高的维度和非二值图.
连通分量(connected comonent / conntected graph)是图论中的概念.
指的是一个无向图, 图中的节点可以抵达该图的任意一个其他节点.
简单来说, 就是连通形成的区域.
指的是一个无向图, 图中的节点可以抵达该图的任意一个其他节点.
简单来说, 就是连通形成的区域.
一遍算法是基于图搜索算法的, 每次解决一个连通分量, 实现简单.
- 1.扫描图像, 找到一个未分配标记的像素, 为其分配一个新的标记.
如果没有找到, 算法结束. - 2.执行图搜索算法, 找到所有的连通节点, 对连通节点分配相同的标记.
- 3.跳转到步骤1.
通过按行遍历二值图像两遍来完成标记.
创建一个表, 用于记录标记与标记之间的等价关系.
该表使用的数据结构直接决定了该算法的性能, 通常为一种并查集.
该表使用的数据结构直接决定了该算法的性能, 通常为一种并查集.
第一次遍历时执行以下操作:
检查当前像素与上方像素和左方像素的连通性.
检查当前像素与上方像素和左方像素的连通性.
- 如果当前像素只与一方连通: 将当前像素设置为连通方的标记.
- 如果当前像素与双方都连通:
将当前像素设置为其中一方的标记.
如果双方的标记不同, 则记录下双方标记的等价关系, 供之后使用. - 如果当前像素与双方都不连通: 为当前像素分配一个新的标记.
第二次遍历时执行以下操作:
检查每个像素.
根据第一次遍历时记录的表, 查找与当前标记具有等价关系的标记里, 数值最小的标记(如果使用并查集, 则为并查集根节点的值), 将当前像素的标记设置为该标记.
检查每个像素.
根据第一次遍历时记录的表, 查找与当前标记具有等价关系的标记里, 数值最小的标记(如果使用并查集, 则为并查集根节点的值), 将当前像素的标记设置为该标记.
EA是一种基于迭代的优化算法.
EA通过适应度函数(fitness function)计算解的质量, 适应度函数本质上是优化算法里的损失函数.
EA通过适应度函数(fitness function)计算解的质量, 适应度函数本质上是优化算法里的损失函数.
EA具有普适性, 可以解决相当多的优化问题, 但通常来说会有比EA更合适的算法.
这导致EA往往不是具体问题的解决方案.
这导致EA往往不是具体问题的解决方案.
进化算法的一项有趣的应用是Genetic Programming: Evolution of Mona Lisa(逐像素比较颜色的欧几里得距离, 以距离的总和作为适应度).
这启发了很多用简单几何图形还原照片的项目, 例如primitive和geometrize.
然而, 最具影响力的primitive项目使用爬山算法替代了GP(因为该案例有局部最优解就足够, 并且并行性能更好).
更晚出现的geometrize大量参考了primitive的实现, 两者的算法基本等同, 因此也使用了爬山算法.
这启发了很多用简单几何图形还原照片的项目, 例如primitive和geometrize.
然而, 最具影响力的primitive项目使用爬山算法替代了GP(因为该案例有局部最优解就足够, 并且并行性能更好).
更晚出现的geometrize大量参考了primitive的实现, 两者的算法基本等同, 因此也使用了爬山算法.
最流行的EA算法.
算法原理:
- 1.随机生成初始种群.
- 2.评估种群中个体的适应度, 淘汰掉适应度低的个体(模拟自然选择).
- 3.对留存下来的个体进行某种操作(交叉crossover, 突变mutation等)来产生不同基因的子代.
- 4.回到步骤2.
以计算机程序为输出的进化算法, 迭代过程牵涉到语法树的突变.
找到解之后, 在解的周围寻找一个新的解.
如果新的解比之前的解更好, 则用新的解替代旧的解.
如果新的解比之前的解更好, 则用新的解替代旧的解.
该算法的过程决定了它很容易陷入局部最优解.
一种能够逼近全局最优解的优化算法.
样本误差的绝对值加总后除以样本的数量.
样本误差的平方加总后除以样本的数量.
平方会放大误差, 因此与MAE相对.
MSE的开平方.
RMSE的优点是它的单位与样本一致.
凸包算法在简单多边形情况下的特例.
简单多边形的凸包算法里最优的算法, 线性时间复杂度.
算法可视化: https://maxgoldste.in/melkman/
公式:
(y - y0) / (x - x0) = (y1 - y0) / (x1 - x0)如果数据不是坐标, 而是值, 公式如下:
x = a + (b - a) * t (t是插值位置, 取值范围为 [0, 1], 当t为0时等于a, 当t为1时等于b).可以推导出同一条公式的其他表示方式:
x = a + (b * t - a * t)x = b * t - a * t + ax = b * t - (a * t - a)x = b * t - a * (t - 1)x = -a * (t - 1) + b * tx = a * (1 - t) + b * t 此为GLSL中的mix函数使用的公式/** ,* @param a a <= b ,* @param b b >= a ,* @param alpha [0, 1] ,*/function mix(a: number, b: number, alpha: number): number { return a * (1 - alpha) + b * alpha}这种插值需要3个参数, 边缘1, 边缘2, 以及位于两个边缘之间的值.
smoothstep的功能是让值在进入和退出特定值范围(边缘1与边缘2形成的范围)时具有平滑的过渡.
function smoothstep(edge1: number, edge2: number, x: number): number { // t表示x在两个边缘形成的范围中的进度 // 当x < edge1时, t = 0 // 当x > edge2时, t = 1 const t = clamp( (x - edge1) / (edge2 - edge1) , [0, 1] ) return t * t * (3 - 2 * t)}参考: https://en.wikipedia.org/wiki/Smoothstep
内存分配器的功能是从操作系统处申请以页为单位的虚拟内存空间, 然后将这些空间分配给应用程序.
内存分配器的目标是保证内存得到高效率的利用.
视操作系统的支持程序, 应用程序可能被允许使用自己的内存分配器, 详见C语言动态内存分配的实现.
内存分配器的目标是保证内存得到高效率的利用.
视操作系统的支持程序, 应用程序可能被允许使用自己的内存分配器, 详见C语言动态内存分配的实现.
由于现代操作系统对于物理地址的翻译是基于分页机制的, 因此可分配的最小空间是一个内存页面.
被分配的内存页面里没有被用户申请的"已分配"空间被称作内部碎片.
被分配的内存页面里没有被用户申请的"已分配"空间被称作内部碎片.
内存空间遭到内存分配器的划分, 从而产生的间隙(不可用的内存空间)被称作外部碎片.
来自FreeBSD的内存分配器.
可能是应用最广泛的主流分配器, 被Facebook大量使用, 在Firefox和Rust里被使用.
可能是应用最广泛的主流分配器, 被Facebook大量使用, 在Firefox和Rust里被使用.
Google出品的内存分配器, 相比jemalloc表现更好.
在Chrome浏览器里使用.
Go语言自己的内存分配器借鉴了tcmalloc的设计.
在Chrome浏览器里使用.
Go语言自己的内存分配器借鉴了tcmalloc的设计.
微软出品的内存分配器, 在基准测试中胜过jemalloc和tcmalloc这样的主流分配器.
在微软的云服务, 虚幻引擎中使用.
在微软的云服务, 虚幻引擎中使用.
最简单的内存分配器实现.
线性分配器将内存视作一段连续的空间,
每当用户申请一段内存, 则将一个表示已分配内存的指针后向挪动, 直到所有内存都被分配完毕.
线性分配器将内存视作一段连续的空间,
每当用户申请一段内存, 则将一个表示已分配内存的指针后向挪动, 直到所有内存都被分配完毕.
线性分配器的典型问题是无法回收前面已释放的内存(碎片):
由于它只用一个指针记录用量, 最多只能回收已分配的尾部内存而已.
由于它只用一个指针记录用量, 最多只能回收已分配的尾部内存而已.
为了让线性分配器能够回收内存, 需要垃圾回收算法对碎片进行整理, 然后再由线性分配器回收整理到尾部的内存.
由于垃圾回收算法有可能导致数据的内存地址改变, 这种方案对于直接暴露指针的语言不可用.
由于垃圾回收算法有可能导致数据的内存地址改变, 这种方案对于直接暴露指针的语言不可用.
空闲链表分配器将内存中的 空闲区域 以地址顺序递增划分为相连的链表.
每个空闲链表只需要表示: 空闲区域的字节长度, 后一个链表的指针.
一个空闲链表可以被拆分为多个连续的空闲链表, 多个连续的空闲链表也可以被合并为一个空闲链表.
每个空闲链表只需要表示: 空闲区域的字节长度, 后一个链表的指针.
一个空闲链表可以被拆分为多个连续的空闲链表, 多个连续的空闲链表也可以被合并为一个空闲链表.
为了让free函数能够知道被释放的内存空间长度, 分配器会在被分配的块的头部额外添加此块的元数据.
将内存划分为固定大小的块的分配器.
常用于嵌入式系统和视频游戏, 会产生大量碎片, 但对于内存池的应用场景来说够用了.
动态分配的内存块大小和数量是可变的, 从而完美适配用户申请的内存大小.
然而, 这种看似完美的解决方案的外部碎片会随着时间推移越来越多, 这导致动态分配被更先进的内存分配策略替代.
然而, 这种看似完美的解决方案的外部碎片会随着时间推移越来越多, 这导致动态分配被更先进的内存分配策略替代.
遍历链表, 将找到的第一个可以被分配的空闲内存块分配(剩余的空闲内存块会成为一个新的链表).
首次适配策略的变种, 不再从链表头开始查找空闲内存块,
而是从上一个查找到空闲内存块的位置继续查找.
而是从上一个查找到空闲内存块的位置继续查找.
实验表明其性能差于First Fit.
将未完全分配的内存块按空闲大小升序排序, 查找可以用于分配的最小内存块.
该策略的空间利用率很高, 但会因为需要事先排序内存块而产生性能损耗.
该策略的空间利用率很高, 但会因为需要事先排序内存块而产生性能损耗.
实验表明其性能差于First Fit和Next Fit, Best可谓名不副实.
最佳匹配法的反面, 每次都分配最大内存块.
实验表明其性能表现很不理想.
一种类似于伙伴分配, 将内存按照常见大小划分的空闲链表方案.
按照2的幂作为页面数量来划分出多个内存池, 每个池里的内存块大小相同, 用双向链表连接起来.
当用户申请内存分配时根据申请的内存量找到对应的内存池, 将空闲的内存块分配给用户.
如果该内存池没有空闲的内存块, 则在下一级内存池里寻找空闲的内存块.
如果找到空闲的内存块, 就将其分割为两个符合上一级内存池成员大小的内存块, 移动到上一级内存池里.
反之亦然, 如果有两个连续的位于边缘的空闲块, 就可以将它们合并后放到下一级内存池里.
当用户申请内存分配时根据申请的内存量找到对应的内存池, 将空闲的内存块分配给用户.
如果该内存池没有空闲的内存块, 则在下一级内存池里寻找空闲的内存块.
如果找到空闲的内存块, 就将其分割为两个符合上一级内存池成员大小的内存块, 移动到上一级内存池里.
反之亦然, 如果有两个连续的位于边缘的空闲块, 就可以将它们合并后放到下一级内存池里.
由于伙伴内存分配总是以2的幂来分配内存块, 这种策略容易导致内部碎片问题.
举例来说, 申请一个257字节的内存块, 实际上会得到512字节的内存块:
因为257字节刚好超出了
举例来说, 申请一个257字节的内存块, 实际上会得到512字节的内存块:
因为257字节刚好超出了
2^8 = 256 的大小, 这导致接近一半的内存块空间被浪费.一种通常用于实现ECS的简单数据结构, 用于高效率存储无符号整数集, 其本质是两个一维整型数组:
- 紧凑数组dense/packed: 储存值及其插入顺序.
- 稀疏数组sparse: 存储值到dense数组的索引, 用于快速检查值是否已经在此集中存在.
标准实现的sparse数组是脏的, 这导致需要多个指令才能检查值是否存在.
获取sparse数组对应的下标的值, 记为index.
如果index为null, 则值不存在.
如果index为非null, 检查dense数组对应位置的值是否与输入值相等.
如果index为null, 则值不存在.
如果index为非null, 检查dense数组对应位置的值是否与输入值相等.
检查值是否存在, 如果不存在, 则向dense数组追加一个值, 在sparse数组添加索引.
检查值是否存在, 如果存在, 则弹出dense数组的最后一个值.
- 如果弹出的值是要删除的值, 什么也不做.
- 如果弹出的值不是要删除的值,
用弹出的值替换掉dense数组和sparse数组里要删除的值.
只需迭代dense数组.
稀疏集不对迭代顺序做保证.
稀疏集不对迭代顺序做保证.
与标准实现不同, 干净稀疏集的sparse数组得到管理.
该实现要求sparse数组中能够表示
该实现要求sparse数组中能够表示
null 或 -1 等特殊值.通过略微降低"删除值"的速度, 显著提高"检查值存在"的速度, 也因此间接提高了"添加值"的速度.
判断sparse数组对应的下标是否非null.
检查值是否存在, 如果存在, 则弹出dense数组的最后一个值.
- 如果弹出的值是要删除的值,
将sparse数组对应位置的值设置为null. - 如果弹出的值不是要删除的值,
将sparse数组对应位置的值设置为null,
然后用弹出的值替换掉dense数组和sparse数组里要删除的值.
Array 的特定规则子集.
DAG 的特定规则子集.
Tree 的特定规则子集.
有序的二叉树.
红黑树的一种变体, 是一种平衡树.
带有顺序的集, 在插入和删除项目时会对内部数组重新排序.
实现方式:
- 平衡二叉搜索树.
- 红黑树(性能最好).
- 跳跃列表(skip list), 这是Redis的实现方式.
- 数组 + 二分搜索.
举例来说, 插入操作是这样的:
用二分搜索找到目标位置, 将之后的项目全部向后挪动一位.
该实现方式的显著问题是性能低下, 造成性能低下的原因是挪动项目非常花时间.
平衡树的替代品, 具有相当的性能, 且更容易实现.
由于不需要加锁, 相比平衡树更适合并发场景.
由于不需要加锁, 相比平衡树更适合并发场景.
跳跃列表在最坏情况下比平衡树性能差, 会达到相当于遍历数组的速度.
遇到最坏情况的可能性被认为非常低, 几乎不可能遇到, 故被认为可以忽略.
遇到最坏情况的可能性被认为非常低, 几乎不可能遇到, 故被认为可以忽略.
一种数据结构类型, 用于处理不交集的合并和查询问题.
并查集需要实现的操作之一.
参数为两个元素, 执行此操作后, 作为参数的两个元素将视作处于同一集合.
参数为两个元素, 执行此操作后, 作为参数的两个元素将视作处于同一集合.
并查集需要实现的操作之一.
参数为一个元素, 返回一个值, 这个值代表着元素从属的集合.
参数为一个元素, 返回一个值, 这个值代表着元素从属的集合.
如果两个元素处于同一集合, 则
如果两个元素处于不同集合, 则
Find(A) 将等于 Find(B).如果两个元素处于不同集合, 则
Find(A) 将不等于 Find(B).最常见的并查集实现.
数组实现:
class DisjointSet { // parent是以数组表示的N颗树(N是集合的数量). // 树的节点具有两个属性: 节点的值, 父节点的引用. // 树的根节点具有"父节点等于自身"的性质, 在用数组表示时, 等同于"值等于索引". private parent: number[] = [] // 可选的, 仅在使用unionBySize方法时有意义. private size: number[] = [] // 可选的, 仅在使用unionByRank方法时有意义. private rank: number[] = [] // 创建一个新集合, 即创建一颗新的树, 即创建一个指向自身的节点. makeSet(value: number): void { this.parent[value] = value this.size[value] = 1 this.rank[value] = 0 } // 查找a和b的根节点, 使其中一个根节点成为另一方的子节点. union(a: number, b: number): void { const aRoot = this.find(a) const bRoot = this.find(b) if (aRoot === bRoot) return // 将aRoot变为bRoot的子节点 this.parent[aRoot] = bRoot } // 对union的优化, 需要配合具有路径压缩的find使用. // 根据树的大小(size)来决定哪颗树在union时成为根节点. // 树的大小较大的一方, 将成为根节点, 这使得路径压缩需要执行的次数更少. unionBySize(a: number, b: number): void { const aRoot = this.findWithPathCompression(a) const bRoot = this.findWithPathCompression(b) if (aRoot === bRoot) return const aRootSize = this.size[aRoot] const bRootSize = this.size[bRoot] if (aRootSize < bRootSize) { // 将aRoot变为bRoot的子节点 this.parent[aRoot] = bRoot this.size[bRoot] = bRootSize + aRootSize } else { // 将bRoot变为aRoot的子节点 this.parent[bRoot] = aRoot this.size[aRoot] = aRootSize + bRootSize } } // 对union的优化, 需要配合具有路径压缩的find使用. // 根据树的秩(rank)来决定哪颗树在union时成为根节点. // 树的秩较大的一方, 将成为根节点, 这使得路径压缩需要执行的次数更少. // 秩的定义: // - 只有根节点的树, 秩为0. // - 两颗秩不同的树合并后, 新树的秩为其中较大的一方. // - 两颗秩相同的树合并后, 新树的秩为原树的秩加上1. unionByRank(a: number, b: number): void { const aRoot = this.findWithPathCompression(a) const bRoot = this.findWithPathCompression(b) if (aRoot === bRoot) return const aRootRank = this.rank[aRoot] const bRootRank = this.rank[bRoot] if (aRootRank === bRootRank) { // 将aRoot变为bRoot的子节点 this.parent[aRoot] = bRoot this.rank[bRoot]++ } else if (aRootRank < bRootRank) { // 将aRoot变为bRoot的子节点 this.parent[aRoot] = bRoot } else { // 将bRoot变为aRoot的子节点 this.parent[bRoot] = aRoot } } // 向上查找值的根节点, 获得根节点的索引/值作为集合的标志. find(value: number): number { if (this.parent[value] === value) return value return this.find(this.parent[value]) } // 对find的优化. // 具有路径压缩的find, 路径压缩指的是让被查节点的父节点直接指向根节点来压缩树的深度. findWithPathCompression(value: number): number { if (this.parent[value] === value) return value const root = this.find(this.parent[value]) this.parent[value] = root return root }}一种DFA, 用于实现字符串匹配, 通过空间换时间获得高性能.
被实际运用在搜索关键字的自动完成, 敏感词匹配等功能上.
被实际运用在搜索关键字的自动完成, 敏感词匹配等功能上.
标准字典树的一种内存优化版本, 它的末端节点从字符变成了字符串.
- 增减字典项目时, 需要重新构建同级的节点.
- 牺牲一定的性能换取空间高效.
后缀树为字典的每一个后缀建立节点, 例如单词banana会建立以下字符串的字典(\0用于标记字符串结尾):
banana\0anana\0nana\0ana\0na\0a\0\0需要较大的内存空间, 常用于模式匹配.
堆是一种完全二叉树数据结构, 除最底层之外, 其他层的节点都被元素填满, 且最底层尽可能从左到右插入.
其特点在于堆的所有节点要么总是小于它的后代, 要么总是大于它的后代.
根据这种特点, 可将堆分为两种类型:
根据这种特点, 可将堆分为两种类型:
- 最大堆(根节点最大)
- 最小堆(根节点最小)
Python内建的heapq模块提供了一种基于list的堆实现, 它用list表示二叉树, 将list转化为堆的操作被称为heapify.
具有优先级的队列, 可插入带优先级的元素, 取出优先级最高的元素, 查看可优先级最高的元素.
优先队列有多种不同的实现, 有的实现会维护一个有序数组, 有的则使用堆队列.
优先队列有多种不同的实现, 有的实现会维护一个有序数组, 有的则使用堆队列.
一种只支持"入队/入列(enqueue)"和"出队/出列(dequeue)"的数据结构.
特性:
- 先进先出(First In First Out, FIFO)
链表诞生的原因是数组在内存中大小固定, 在数组中间插入/删除元素效率很低(需要移动之后的所有元素),
对数组进行扩容很麻烦(复制所有的数组元素到新的位置), 未使用的数组部分还会浪费内存空间.
对数组进行扩容很麻烦(复制所有的数组元素到新的位置), 未使用的数组部分还会浪费内存空间.
链表用一个结构体解决了数组的痛点, 代价是比单个数组成员多消耗一点内存空间(用于存储前后元素的指针),
以及无法根据索引找到元素(只能通过遍历).
以及无法根据索引找到元素(只能通过遍历).
HashMap = Hash + Array + Linked List负载因子描述一个哈希表的负载程度.
alpha = 表中的元素数量 / 表中的桶数量.理想环境中, alpha应该为1, 此时表中的条目和桶达到一对一的完美散列关系.
实际使用时, 常见的负载因子是0.75, 当发现alpha高于此值时, 需要根据增长因子来调整哈希表的大小.
实际使用时, 常见的负载因子是0.75, 当发现alpha高于此值时, 需要根据增长因子来调整哈希表的大小.
一种天真的想法是直接用
在实践中, 这往往会导致哈希表不停地扩容, 导致严重的性能问题.
表中的元素数量 / alpha 替代增长因子.在实践中, 这往往会导致哈希表不停地扩容, 导致严重的性能问题.
类似于动态数组, 哈希表也有类似的增长因子.
与动态数组不同, 哈希表由于其特性, 无法仅仅根据增长因子来判断哈希表是否需要扩容, 它必须与负载因子搭配使用.
与动态数组不同, 哈希表由于其特性, 无法仅仅根据增长因子来判断哈希表是否需要扩容, 它必须与负载因子搭配使用.
由于哈希表的扩容成本偏高, 增长因子通常被设置为2, 以便减少扩容次数.
当前使用最普遍的非加密哈希算法可能是xxHash.
Rust在哈希算法的挑选上有自己的实现aHash.
Rust在哈希算法的挑选上有自己的实现aHash.
大部分哈希算法的结果是32位无符号整数, 直接将散列算法的结果当作数组的索引会太过巨大,
需要通过取模运算将其映射到表的大小.
需要通过取模运算将其映射到表的大小.
由于HashTable使用的哈希函数的映射结果数量普遍很有限, HashTable在存储时可能会将多个元素存储在一个位置.
为散列表的每一个冲突位置创建一个链表, 将冲突的元素用链表连接起来.
在搜索存在冲突的位置时, 逐个遍历链表上的元素, 返回匹配的元素.
线性探查法将冲突的元素保存在对应索引位置(已经被使用)的下一个索引上(若下一个索引也被使用, 以此类推).
删除元素时有多种方案:
- 将之后的冲突元素全部前移一位.
- 将最后一位交换到被删除元素的位置.
- 惰性删除, 即通过添加标记来实现软删除.
该方法的缺陷也很明显, 如果在数组中的一些位置出现数据堆积, 则相关区域的数据会越堆越长, 严重影响性能.
对于 线性探测法 或 低质量的散列函数, 有一种技巧是通过将数组长度设置为素数来减少冲突.
一种由"节点(Node)"和"边(Edge)"组成的数据结构.
类型:
- 有向图(directed graph): 节点之间的连接包含方向
- 无向图(undirected graph): 节点之间的连接不包含方向
- 有向无环图(DAG): 在有向图的基础上不包含环状结构(多个节点之间在方向上构成循环)
表示方法:
- 邻接矩阵
- 邻接表
使用散列表表示节点之间的关系
graph = {}graph['you'] = ['alice', 'bob', 'claire']graph['bob'] = ['anuj', 'peggy']graph['alice'] = ['peggy']graph['claire'] = ['thom', 'jonny']graph['anuj'] = []graph['peggy'] = []graph['thom'] = []graph['jonny'] = []- 关联矩阵
Graph 的特定规则子集.
布隆过滤器是一种概率数据结构, 用于替代HashMap实现超大集合(元素规模上亿)的元素是否存在的判定.
用布隆过滤器能够得到"该元素一定不存在"或"可能存在"两种结果, 后一种结果不是确定的, 而是概率性的.
Redis有布隆过滤器的模块RedisBloom.
用布隆过滤器能够得到"该元素一定不存在"或"可能存在"两种结果, 后一种结果不是确定的, 而是概率性的.
Redis有布隆过滤器的模块RedisBloom.
布隆过滤器的原理很简单, 想象有一个超大的数组(数组长度计为m), 所有数组下标的值都为0.
我们用k种不同的hash算法对需要存入布隆过滤器的元素分别求hash值,
然后将数组对应hash值的下标设置为1.
在布隆过滤器的公式里, n为插入的元素个数.
当我们需要查询一个元素是否存在时, 采取相同的k种hash操作, 然后查看对应的数组下标,
如果有一个数组下标为0, 则说明这个值肯定不存在于布隆过滤器, 反之则可能存在.
我们用k种不同的hash算法对需要存入布隆过滤器的元素分别求hash值,
然后将数组对应hash值的下标设置为1.
在布隆过滤器的公式里, n为插入的元素个数.
当我们需要查询一个元素是否存在时, 采取相同的k种hash操作, 然后查看对应的数组下标,
如果有一个数组下标为0, 则说明这个值肯定不存在于布隆过滤器, 反之则可能存在.
将每个元素都这样存入布隆过滤器时, 有概率出现某个下标值已经被设置为1的现象,
这是布隆过滤器无法准确判定"元素存在"的主要原因.
这是布隆过滤器无法准确判定"元素存在"的主要原因.
布隆过滤器不支持删除.
下推自动机与状态机的区别在于它有栈, 因此它不仅可以到达下一个状态, 还可以退回上一个状态.
有限状态机是一种数学计算模型, 是一个在任何给定时间只能处于有限数量状态中的一种状态的抽象机器.
状态机具有一个状态转换函数(transation), 它会响应输入的"事件"改变自身的状态.
状态机具有一个状态转换函数(transation), 它会响应输入的"事件"改变自身的状态.
一些状态机是无尽的, 状态变更具有循环结构, 一些状态机则有一个最终状态.
由于状态本质上只是一个储存值的引用, 状态变更的约束可以通过多种方式定义, 状态机本质是简单的, 有很多不同的实现方式.
状态爆炸是指状态机需要表示的状态数量因为组合激增而变成笛卡尔积.
举例来说, 一个游戏角色有"站立"和"跳起"两种状态, 又有"持枪"和"不持枪"两种状态.
如果要在一个状态机里表示该游戏角色的全部状态,
就会出现"持枪站立", "不持枪站立", "持枪跳起", "不持枪跳起"这四种状态.
如果要在一个状态机里表示该游戏角色的全部状态,
就会出现"持枪站立", "不持枪站立", "持枪跳起", "不持枪跳起"这四种状态.
在这类情况下, 需要将状态机拆分成几个独立的状态机.
状态图的复合状态特性也可解决状态爆炸问题, 并且它仍然只使用一个状态图.
状态图的复合状态特性也可解决状态爆炸问题, 并且它仍然只使用一个状态图.
隐式状态机指的是具有状态机的行为, 但并不被专门建模成状态机的系统.
传统编程中的各种基于if条件的状态判断和基于布尔值的状态开关就是隐式状态机.
传统编程中的各种基于if条件的状态判断和基于布尔值的状态开关就是隐式状态机.
大部分隐式状态机本质上是意大利面条代码, 很容易随着状态数量增加而陷入维护困境.
状态机的增强版, 通过让状态机可以嵌套, 克服了状态机的局限性.
状态图能够实现单个状态机和多个状态机都无法实现的功能,
比如状态图可以中途从一个分层子状态机里跳转到另一个分层子状态机,
同时还保留着前一个分层子状态机的状态.
比如状态图可以中途从一个分层子状态机里跳转到另一个分层子状态机,
同时还保留着前一个分层子状态机的状态.
由于状态图的复杂性, 尽管理论上功能强大, 但却并不流行.
对于状态图的应用场景, 往往有比状态图更高级的替代解决方案.
对于状态图的应用场景, 往往有比状态图更高级的替代解决方案.
参考: https://statecharts.dev
状态图本身和状态机一样, 只有描述状态变化的能力.
可执行状态图在状态图的基础上加上了副作用,
使得状态图本身变成一种根据状态变更执行特定行为的机器.
可执行状态图在状态图的基础上加上了副作用,
使得状态图本身变成一种根据状态变更执行特定行为的机器.
拿一个任务列表状态图举例,
该任务列表只允许当"未完成"状态下的所有状态机转移到"完成"状态时, 才允许转移到顶层的"完成"状态:
该任务列表只允许当"未完成"状态下的所有状态机转移到"完成"状态时, 才允许转移到顶层的"完成"状态:
- 在可执行状态图的设计里, 每个子状态机"完成"时都会自己尝试转移到顶层的"完成"状态,
是否跳转成功取决于顶层的"完成"状态所具有的guard. - 在非可执行状态图的设计里, 子状态机"完成"时都需要由使用者手动尝试跳转到"完成",
非可执行状态图会导致状态机的定义与使用者的代码耦合(耦合越多, 状态图越难推理),
并且实现起来相当不自然(获取状态图的对象, 然后逐个检查其子状态机的状态).
与复合状态相对的概念, 即没有子状态的状态.
具有一个或多个子状态的状态被称为复合状态, 子状态的变更通过复合状态的子状态机来描述.
复合状态的一种.
分层状态是嵌套了子状态机的状态, 当状态机进入分层状态时, 就会从头启动它的子状态机.
分层状态是嵌套了子状态机的状态, 当状态机进入分层状态时, 就会从头启动它的子状态机.
可以将其理解为只有一个状态机的并行状态.
复合状态的一种.
并行状态是一个状态里的多个互不相关的子状态机.
当状态机进入并行状态时, 每个子状态机从头启动
(子状态机一旦退出, 其状态就会销毁, 每次进入都是重新开始),
当状态机退出并行状态时, 每个子状态机都退出.
并行状态是一个状态里的多个互不相关的子状态机.
当状态机进入并行状态时, 每个子状态机从头启动
(子状态机一旦退出, 其状态就会销毁, 每次进入都是重新开始),
当状态机退出并行状态时, 每个子状态机都退出.
并行状态常用于组合一个实体的多个离散行为, 以解决状态爆炸.
一种特殊的状态, 允许在退出此状态时回到进来时的那个状态.
有浅历史状态和深历史状态两种:
- 浅历史状态指的是一个复合状态能够记住它是从哪个状态转换而来的,
- 深历史状态指的是一个复合状态能够记住它是从哪个状态转换而来的, 以及该状态所有后代状态机的状态.
Guard是"事件"所具有的条件检查断言.
只有在断言通过时, 才会将事件输入给状态转换函数.
只有在断言通过时, 才会将事件输入给状态转换函数.
状态机里的一个状态可以响应事件名相同, 但Guard不同的多个事件.
只有第一个断言通过的事件会真的发生.
只有第一个断言通过的事件会真的发生.
条件检查为状态转换引入了复杂性, 使得推理状态转换变得困难.
一种与特定状态的"进入状态"绑定的行为, 进入该状态后会立即触发又一次转换.
通常与Guard配合使用, 仅在满足条件时才发生让自动转换成功.
通常与Guard配合使用, 仅在满足条件时才发生让自动转换成功.
使用场景:
- 根据条件决定状态机的初始化状态.
通过向文件尾部写入数据库记录的变化来存储数据.
出于优化原因, 写入一定量的数据后, 就会切换到新的文件.
旧的文件中同一条记录的变化将被合并, 只保留其最新结果.
旧的文件中同一条记录的变化将被合并, 只保留其最新结果.
通过只允许一个写入者来防止并发写入.
最普遍的索引实现.