算法随笔-基础知识
本文总结一些算法相关的基础知识:时间、空间复杂度、排序、数据结构和一些前端常用的算法介绍。供自己以后查漏补缺,也欢迎同道朋友交流学习。
引言
我目前在学习算法的过程中,在大多数人的印象里算法是非常难的,但我觉得万事开头难,只要肯登攀。而且要深入理解前端的某些领域,例如 vue
和react
的 Diff
算法、图片压缩算法、懒加载、3D可视化和游戏开发等,了解算法是很有帮助的。后续我也会持续的学习算法,并输出一些知识体系跟大家一起学习。
时间复杂度
时间复杂度是衡量算法性能的一个重要指标,它描述了算法执行所需的时间单位。以下是对时间复杂度的详细介绍:
时间复杂度大O表示法
-
O(1):常数时间复杂度,无论输入大小如何,执行时间都是固定的。
let num = 0 num++; // O(1)
-
O(log n):对数时间复杂度,执行时间随输入规模的增长而缓慢增加,常用于
二分搜索
。function binarySearch(arr, target) { let start = 0; let end = arr.length - 1; while (start <= end) { const middle = Math.floor((start + end) / 2); const midValue = arr[middle]; if (midValue === target) { return middle; // 找到目标值,返回索引 } else if (midValue < target) { start = middle + 1; // 在右侧查找 } else { end = middle - 1; // 在左侧查找 } } return -1; // 未找到目标值,返回-1 }
-
O(n):线性时间复杂度,执行时间与输入规模成正比。
let num = 0 for (let i = 0; i < 10; i++) { num += i }
-
O(n log n):线性对数时间复杂度,常用于高效的排序算法,如
快速排序
和归并排序
。// 快速排序 function quickSort(arr) { // 基线条件:如果数组只有一个元素或没有元素,它已经是有序的 if (arr.length <= 1) { return arr; } // 选择一个基准值,这里我们选择数组的最后一个元素 const pivot = arr[arr.length - 1]; // 存储小于、等于和大于基准值的元素 let less = []; let equal = []; let greater = []; // 使用for循环遍历数组,根据与基准值的比较,分配元素到不同的数组 for (let i = 0; i < arr.length - 1; i++) { if (arr[i] < pivot) { less.push(arr[i]); } else if (arr[i] > pivot) { greater.push(arr[i]); } else { equal.push(arr[i]); } } // 递归地对小于和大于基准值的数组进行快速排序,然后合并结果 return [...quickSort(less), ...equal, ...quickSort(greater)]; }
-
O(n^2):平方时间复杂度,执行时间随输入规模的平方增长,如简单选择排序。
let num = 0 for (let i = 0; i < 10; i++) { for (let j = 0; j < 10; j++) { num += i + j } }
-
O(2^n):指数时间复杂度,执行时间随输入规模呈指数增长,通常效率较低。
// 斐波那契数列 function fibonacci(n) { // 基本情况 if (n === 0) { return 0; } else if (n === 1) { return 1; } // 递归情况 return fibonacci(n - 1) + fibonacci(n - 2); }
空间复杂度
空间复杂度是衡量算法在运行过程中所需的存储空间的指标。它描述了算法在执行过程中临时占用的存储空间量,通常与输入数据的大小有关。以下是空间复杂度的一些基本概念和示例:
空间复杂度大O表示法
-
常数空间复杂度 (O(1)):算法使用的额外空间不随输入规模的变化而变化。
function identity(x) { return x; // 无论输入大小,只占用常数空间 }
-
线性空间复杂度 (O(n)):算法所需的额外空间与输入数据的大小成正比。
function copyArray(arr) { const copy = new Array(arr.length); // 创建一个与输入数组等长的数组 for (let i = 0; i < arr.length; i++) { copy[i] = arr[i]; } return copy; // 额外空间与输入数组大小成正比 }
-
多项式空间复杂度:算法所需的额外空间与输入数据大小的多项式成正比,如 O(n^2)、O(n^3) 等。
let result = [] for (let i = 0; i < 10; i++) { for (let j = 0; j < 10; j++) { result[i][j] = i * j; } }
-
指数空间复杂度 (O(2^n)):算法所需的额外空间随输入规模呈指数增长。
function subsets(arr, index = 0, subset = [], allSubsets = []) { if (index === arr.length) { allSubsets.push(subset); return; } // 不包含当前元素的子集 subsets(arr, index + 1, subset, allSubsets); // 包含当前元素的子集 subset.push(arr[index]); subsets(arr, index + 1, subset, allSubsets); } // 这个递归函数生成所有可能的子集,空间复杂度为 O(2^n),因为需要存储所有子集
影响因素
- 数据结构:使用的数组、链表、栈、队列、哈希表等数据结构会影响空间复杂度。
- 递归调用:每次递归调用都会占用栈空间,递归深度影响空间复杂度。
- 辅助存储:算法中使用的额外数组或数据结构会增加空间复杂度。
排序算法
排序算法是计算机科学中最基本的算法之一,用于将元素序列重新排列成特定顺序(通常是升序或降序)。以下是一些常见的排序算法:
冒泡排序
冒泡排序(Bubble Sort
)是一种简单的排序算法,它通过重复遍历要排序的数列,比较每对相邻元素的大小,并在必要时交换它们的位置。这个过程会重复进行,直到没有需要交换的元素为止,这意味着数列已经排序完成。
冒泡排序的步骤如下:
- 开始排序:从数列的第一个元素开始,比较相邻的两个元素。
- 交换元素:如果左边的元素大于右边的元素,则交换它们的位置。
- 移动到下一个元素:移动到下一个元素,重复步骤2。
- 完成一轮遍历:当到达数列的末尾时,最大的元素会被“冒泡”到它应该在的位置。
- 重复遍历:对剩余的未排序元素重复步骤1到4,每轮遍历后,都会将下一个最大元素放到它应该在的位置。
- 结束条件:当整个数列都不需要交换时,排序完成。
冒泡排序的名字来源于较小的元素会像气泡一样逐渐“浮”到数列的顶端。
冒泡排序的时间复杂度通常是O(n^2),其中n是数列中元素的数量。这使得它在处理小规模数据集时效率尚可,但对于大规模数据集,效率较低,因此实际应用中较少使用。尽管如此,冒泡排序因其简单性,常被用于教学和理解排序算法的基本概念。
JS代码实现:
function bubbleSort(arr) {
let len = arr.length;
for (let i = 0; i < len - 1; i++) { // 外层循环次数为数组长度减1
for (let j = 0; j < len - i - 1; j++) { // 内层循环次数逐渐减少,因为每次都会将最大值放到最后
if (arr[j] > arr[j + 1]) {
// 交换元素
let temp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = temp;
}
}
}
return arr;
}
// 使用示例
let array = [64, 34, 25, 12, 22, 11, 90];
console.log("原始数组:", array);
console.log("冒泡排序后的数组:", bubbleSort(array));
选择排序
选择排序(Selection Sort
)是一种简单的排序算法,其工作原理是分多次遍历待排序的数列,每次遍历都找到最小(或最大)的元素,并将其放到数列的起始位置。这个过程会重复进行,直到整个数列都被排序。
选择排序的步骤如下:
- 找到最小元素:在未排序的数列中找到最小(或最大)的元素。
- 交换位置:将找到的最小元素与数列的第一个元素交换位置。
- 忽略已排序部分:将该最小元素从未排序部分移除,剩下的部分继续进行排序。
- 重复过程:对剩余的未排序部分重复步骤1到3。
- 完成排序:当整个数列都被遍历并排序后,排序完成。
选择排序算法的特点是:
- 稳定性:相等的元素在排序后保持原来的相对顺序。
- 时间复杂度:无论数列是否已经排序,选择排序的时间复杂度都是O(n^2),其中n是数列中元素的数量。
- 空间复杂度:O(1),因为它只需要一个额外的存储空间来记录最小元素的索引。
选择排序通常不适用于大型数据集的排序,因为它的时间效率较低。然而,由于其实现简单,对于小型数据集或基本教学目的,选择排序仍然是一个有用的算法。
JS代码实现:
function selectionSort(arr) {
let len = arr.length;
for (let i = 0; i < len - 1; i++) {
// 找到最小元素的索引
let minIndex = i;
for (let j = i + 1; j < len; j++) {
if (arr[j] < arr[minIndex]) {
minIndex = j;
}
}
// 交换最小元素到当前位置
if (minIndex !== i) {
let temp = arr[i];
arr[i] = arr[minIndex];
arr[minIndex] = temp;
}
}
return arr;
}
// 使用示例
let array = [64, 34, 25, 12, 22, 11, 90];
console.log("原始数组:", array);
console.log("选择排序后的数组:", selectionSort(array));
这段代码定义了一个selectionSort函数,它接受一个数组作为参数,并返回排序后的数组。函数内部使用了一个嵌套循环:外层循环控制排序的轮数,内层循环用于找到每一轮中的最小元素,并将其与当前轮次的起始位置的元素交换。
插入排序
插入排序(Insertion Sort
)是一种简单直观的排序算法,它的工作原理是通过构建有序序列,对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入。插入排序在实现上,通常采用 in-place
(即在原数组上进行操作)排序,不需要额外的存储空间。
插入排序的基本步骤如下:
- 构建有序序列:从第一个元素开始,该元素可以认为已经被排序。
- 选取未排序元素:取出下一个未排序的元素。
- 从后向前扫描:在已经排序的序列中,从后向前扫描(即从当前元素的前一个元素开始)。
- 找到插入位置:在扫描过程中,如果已排序元素大于待插入元素,则将已排序元素向后移动一个位置。
- 插入元素:将待插入元素插入到找到的位置。
- 重复过程:对剩余的未排序元素重复步骤2到5。
- 完成排序:当所有元素都被插入到有序序列中时,排序完成。
插入排序的时间复杂度通常是O(n^2),其中n是数列中元素的数量。尽管这个时间复杂度相对较高,但插入排序在某些情况下非常有效,特别是当输入数据已经部分排序或数据量较小时。
插入排序的一个优点是它是一个稳定的排序算法,即相等的元素在排序后会保持它们原始的相对顺序。
JS代码实现:
function insertionSort(arr) {
let len = arr.length;
for (let i = 1; i < len; i++) {
let key = arr[i];
let j = i - 1;
// 将元素移动到正确的位置
while (j >= 0 && arr[j] > key) {
arr[j + 1] = arr[j];
j = j - 1;
}
arr[j + 1] = key;
}
return arr;
}
// 使用示例
let array = [12, 11, 13, 5, 6];
console.log("原始数组:", array);
console.log("插入排序后的数组:", insertionSort(array));
这段代码定义了一个insertionSort
函数,它接受一个数组作为参数,并返回排序后的数组。函数内部使用了一个循环,每次循环取出一个未排序的元素,并将其插入到已排序序列中的适当位置。
快速排序
快速排序(Quick Sort
)是一种高效的排序算法。它采用分治法(Divide and Conquer
)的策略来把一个序列分为较小的两个子序列,然后递归地排序两个子序列。
快速排序的基本步骤如下:
- 选择基准值(Pivot):从数列中挑出一个元素,称为“基准”(pivot)。选择基准的方法可以有多种,常见的有选取第一个元素、最后一个元素、中间元素或随机元素。
- 分区操作(Partitioning):重新排列数列,所有比基准值小的元素摆放在基准前面,所有比基准值大的元素摆在基准的后面(相同的数可以到任一边)。在这个分区退出之后,该基准就处于数列的中间位置。这个称为分区(partition)操作。
- 递归排序:递归地(Recursive)把小于基准值的子数列和大于基准值的子数列排序。
- 完成排序:递归到基准值的子数列为空时,排序完成。
快速排序的性能通常比其他O(n log n)算法更好,因为它的内部循环(inner loop)可以在大多数的架构上很有效率地被实现出来。快速排序的平均时间复杂度为O(n log n),但在最坏的情况下会退化到O(n^2),尤其是当输入数组已经接近有序时。为了避免这种情况,通常会使用一些策略来选择基准值,比如随机选择或三数取中法。
JS代码实现:
function quickSort(arr) {
// 基础情形:如果数组长度小于等于1,直接返回数组
if (arr.length <= 1) {
return arr;
}
// 选择基准值,这里选择数组的第一个元素
const pivot = arr[0];
// 用于存放小于基准值的元素
const left = [];
// 用于存放大于基准值的元素
const right = [];
// 遍历数组,根据与基准值的比较结果分配到left或right
for (let i = 1; i < arr.length; i++) {
if (arr[i] < pivot) {
left.push(arr[i]);
} else {
right.push(arr[i]);
}
}
// 递归地对left和right进行快速排序,然后合并结果
return quickSort(left).concat(pivot, quickSort(right));
}
// 使用示例
let array = [3, 6, 8, 10, 1, 2, 1];
console.log("原始数组:", array);
console.log("快速排序后的数组:", quickSort(array));
这个示例中,quickSort
函数首先检查数组的长度,如果数组只有一个或没有元素,就直接返回这个数组,因为一个空数组或只有一个元素的数组是已经排序的。然后,选择数组的第一个元素作为基准值,遍历数组,将小于基准值的元素放入 left
数组,将大于或等于基准值的元素放入 right
数组。最后,递归地对 left 和 right 数组进行快速排序,并将结果与基准值合并。
归并排序
归并排序(Merge Sort)是一种使用分治法(Divide and Conquer)策略的排序算法。它将原始数据分成更小的数组,递归地将这些小数组排序,然后将排序好的小数组合并成一个有序的大数组。
归并排序的基本步骤如下:
- 分解:将数组分成两半,如果数组只有一个元素或为空,则不需要排序。
- 递归排序:递归地对数组的两半进行归并排序。
- 合并:将排序好的两半合并成一个有序数组。
归并排序的关键在于合并过程,这个过程需要一个辅助数组来存放合并后的有序序列。
归并排序的时间复杂度是O(n log n),其中n是数组中的元素数量。它是一种稳定的排序算法,意味着相等的元素在排序后会保持它们原始的相对顺序。
以下是一个使用JavaScript实现归并排序的示例代码:
function mergeSort(arr) {
// 基础情形:如果数组长度小于等于1,直接返回数组
if (arr.length <= 1) {
return arr;
}
// 找到中间索引,将数组分成两半
const middle = Math.floor(arr.length / 2);
// 分别对左右两半进行归并排序
const left = mergeSort(arr.slice(0, middle));
const right = mergeSort(arr.slice(middle));
// 合并两个有序数组
return merge(left, right);
}
function merge(left, right) {
// 创建一个空数组作为合并结果
let sortedArr = [];
// 索引用于跟踪左右数组的当前位置
let leftIndex = 0;
let rightIndex = 0;
// 当左右数组都还有元素时,进行合并
while (leftIndex < left.length && rightIndex < right.length) {
if (left[leftIndex] < right[rightIndex]) {
sortedArr.push(left[leftIndex]);
leftIndex++;
} else {
sortedArr.push(right[rightIndex]);
rightIndex++;
}
}
// 如果左数组还有剩余,将其添加到结果数组
while (leftIndex < left.length) {
sortedArr.push(left[leftIndex]);
leftIndex++;
}
// 如果右数组还有剩余,将其添加到结果数组
while (rightIndex < right.length) {
sortedArr.push(right[rightIndex]);
rightIndex++;
}
return sortedArr;
}
// 使用示例
let array = [3, 6, 8, 10, 1, 2, 1];
console.log("原始数组:", array);
console.log("归并排序后的数组:", mergeSort(array));
在这个示例中,mergeSort
函数首先检查数组的长度,如果数组只有一个或没有元素,就直接返回。然后,找到数组的中间索引,将数组分成两半,并递归地对这两半进行归并排序。merge
函数负责合并两个有序数组,它使用两个索引来跟踪左右数组的当前位置,并按照顺序将元素添加到结果数组中。最后,如果左右数组中还有剩余的元素,将它们全部添加到结果数组。
排序算法的效率比较
排序算法 | 时间复杂度 | 空间复杂度 | 稳定性 | 应用场景 |
---|---|---|---|---|
冒泡排序 (Bubble Sort) | O(n^2) | O(1) | 稳定 | 小规模数据或基本教学 |
选择排序 (Selection Sort) | O(n^2) | O(1) | 稳定 | 小规模数据,空间受限 |
插入排序 (Insertion Sort) | 平均 O(n^2),最佳情况 O(n)(如果数据已经有序) | O(1) | 稳定 | 小规模数据,部分有序数据 |
快速排序 (Quick Sort) | 平均 O(n log n),最坏情况 O(n^2) | O(log n)(递归调用栈) | 不稳定 | 大规模数据,平均性能高 |
归并排序 (Merge Sort) | O(n log n) | O(n)(需要额外空间存储合并结果) | 稳定 | 大规模数据,需要稳定性 |
数据结构
数据结构是计算机科学中的一个基本概念,它定义了数据的组织、管理和存储方式,以及在这些数据上可以执行的操作。数据结构对于设计高效的算法至关重要,因为它们直接影响到程序的性能和内存使用。
以下是一些常见的数据结构:
- 数组 (Array): 元素在内存中连续存储,支持快速随机访问。
- 栈 (Stack): 后进先出 (LIFO) 结构,只能在一端(栈顶)进行添加和删除操作。
- 队列 (Queue): 先进先出 (FIFO) 结构,在一端添加元素,在另一端删除元素。
- 双端队列 (Deque): 双端队列允许在两端进行添加和删除操作。
- 链表 (Linked List): 元素在内存中可以分散存储,每个元素包含指向下一个元素的引用。不支持快速随机访问,但插入和删除操作更灵活。
- 哈希表 (Hash Table): 通过键值对存储数据,提供快速的数据访问。通过哈希函数将键映射到表中的位置。
- 树 (Tree): 由节点组成的层次结构,每个节点有零个或多个子节点,但只有一个父节点(除了根节点)。二叉树是一种特殊类型的树,每个节点最多有两个子节点。
- 二叉搜索树 (Binary Search Tree, BST):特殊的二叉树,左子树上所有节点的键值小于它的节点,右子树上所有节点的键值大于它的节点。
- 堆 (Heap):一种特殊的树结构,满足父节点的键值总是大于(或小于)其子节点的键值。
- 图 (Graph): 由顶点(节点)和边组成,可以表示复杂的关系和网络。
- 字典 (Dictionary) / 映射 (Map): 存储键值对,可以通过键快速访问值。
- 集合 (Set): 存储唯一的元素,通常不支持重复。
前端常用算法介绍
前端开发中,虽然不经常涉及到复杂的算法实现,但了解一些常用的算法对于优化代码、提高性能和解决特定问题仍然非常重要。以下是一些前端开发中可能会用到的算法和数据结构:
-
排序算法
- 冒泡排序、选择排序、插入排序:适用于小规模数据的简单排序。
- 快速排序、归并排序:适用于大规模数据的高效排序。
-
搜索算法
- 线性搜索:简单的搜索方法,适用于未排序的数据。
- 二分搜索:高效的搜索方法,需要数据已经排序。
-
哈希表
- 使用对象或
Map
来存储键值对,快速访问数据。
- 使用对象或
-
数组和字符串操作
- 反转数组或字符串、查找子字符串、数组去重等。
-
树和图算法
- 尽管在前端中不常见,但理解树结构(如DOM树)和图(如网页间的链接关系)的概念对于某些问题(如DOM操作、页面渲染优化)是有帮助的。
-
动态规划
- 解决具有重叠子问题和最优子结构特性的复杂问题,如计算最小编辑距离(Levenshtein距离)。
-
贪心算法
- 用于在每一步选择中都采取在当前状态下最好或最优的选择,从而希望导致结果是全局最好或最优的算法。
-
分治算法
- 将复杂的问题分解成多个小问题,递归解决小问题,然后合并结果。
-
缓存机制
- 使用记忆化(memoization)或缓存来存储昂贵函数调用的结果,避免重复计算。
-
递归和回溯算法
- 递归用于解决可以分解为相似子问题的问题,如遍历树结构。
- 回溯算法通常用于搜索所有可能的解决方案,例如在生成所有可能的排列或组合时。
-
图算法
- 如深度优先搜索(DFS)和广度优先搜索(BFS),在处理DOM树或执行页面爬取时可能会用到。
-
最短路径算法
- 如迪杰斯特拉(Dijkstra)算法,虽然在前端不常见,但理解其原理有助于解决一些布局和路径优化问题。
-
事件冒泡和捕获
- 理解事件冒泡和捕获机制对于处理事件处理程序和实现自定义事件逻辑至关重要。
-
布局算法
- 如CSS Flexbox和Grid布局,它们背后有自己的算法来计算元素的大小和位置。
-
响应式设计
- 媒体查询和断点逻辑可以看作是一种简单的条件算法,用于根据不同的屏幕尺寸调整布局。
前端开发者通常更多地关注于实现用户界面和交互,但了解这些算法可以帮助开发者写出更高效、更可维护的代码。此外,一些算法思维,如递归思维、分治思想、贪心选择等,对于解决前端开发中的问题也非常有用。