算法模型
算法模型的分类往往基于一定的数据结构,面向一定的算法场景。而数据结构的类型可以分为:
- 线性结构:数组、链表、栈、队列、堆(优先队列)等;
- 分支结构:各种树、跳表;
- 网络结构:各种图、矩阵; 对应不同的数据结构,有特定的算法模型进行解决。
遍历和查找
查找是最为基础和常见的算法模型,其核心在于遍历,并且基于一定的数据结构进行遍历。为此,需要先理解几个重要的问题,即:
- 遍历的范围,或者说搜索集合;
我们需要明确题目的入参和出参,从而确定遍历的范围,或者说搜索集合。入参集合指的是题目所给的数据结构和数据,出参集合指的是由所有可能是答案的候选数据所构成的集合。一些,入参集合和出参集合相同,我们直接以此作为搜索集合,进行遍历即可;如果入参集合和出参集合不同,那么可以先遍历入参集合,构建出出参集合,并以出参集合作为搜索集合,再遍历。亦或者,也可以不使用入参或者出参集合作为搜索集合,基于间接计算算出搜索目标进行验证。总之,出参集合往往作为搜索集合,但不是唯一的选择。
- 目标的特征,或者说搜索目标;
- 遍历的方式,即遍历策略;
枚举 枚举法即遍历所有可能的解,一一校验,从而查找答案。枚举法实现简单,时间复杂度一般较高。具体地,枚举法在出参集合中枚举所有可能的备选解,从备选解中找到满足条件的解,或者从入参集合中出发,重复进行计算并寻找所有满足条件的解。枚举是所有查找算法问题的思想基础,后续的算法可以基于枚举法进行优化。枚举的关键就是确定枚举的范围或者搜索集合。
从枚举方式出发,枚举法可以基于循环实现,也可以基于递归实现。循环的一般基于线性结构,入参为循环长度 n,递归一般基于树状结构,入参为递归深度 depth 和 分支数 branch。
循环
循环枚举是任何算法的基础思路,使用循环可以对参数的所有可能值的使用同样的逻辑进行讨论。一般的算法题目中,会有 1~2 个参数,当场上存在两个参数时,穷尽两个参数的不同情况时,需要考虑二者的组合,因此我们会启用两个嵌套的循环,以此来穷尽二者的组合,以此类推,当出现三个参数的时候,我们会采用三个嵌套循环来穷举。
题目中关键词:
查找;js// 一维迭代遍历 for (let i = 0; i < n; i++) { // 遍历出参集合 } // 二维迭代遍历 for (let i = 0; i < n; i++) { for (let j = 0; j < m; j++) { // 遍历出参集合 } } // 递归遍历 function dfs(depth, arg) { // 递归终止条件 if (depth === target) { return; // 找到答案 } for (let i = 0; i < m; i++) { const ret = dfs(depth + 1, arg + i); // 处理结果 ret } return; // 答案 }双指针 双指针遍历,基于同时管理两个指针的遍历方式,可以减少遍历次数,从而降低时间复杂度。双指针遍历可以基于数组、链表、字符串等线性结构,也可以基于哈希表等非线性结构。双指针具体在操作的时候可以有很多不同的方式,例如左右指针、滑动窗口、快慢指针等。其主要特征是题目中具有两个参数。双指针遍历的使用场景往往需要我们找到两个参数之间的关系,从而确定遍历策略。
题目中关键词:
子数组、两个数组、区间等等;jslet left = 0, right = 0; while (left < right) { // 遍历出参集合 if (condition) { // 处理边界情况 left++; } else { // 处理边界情况 right--; } } for (let left = 0; left < n; left++) { let right = left + width; // 处理定长窗口 } for (let left = 0, right = 0; left < n && right < m; left++, right++) { // 处理双序列 }二分法 二分法是一种在有序集合中查找目标值的方法,其时间复杂度为 O(logn)。二分法的基本思想是将集合分成两部分,然后根据目标值的大小,选择其中一部分继续查找,直到找到目标值或者查找范围为空。二分法可以用于实现快速查找,例如查找有序数组中的目标值、查找有序数组中的第 k 小的元素等等。二分法优化的前提思路是,你可以先基于线性遍历暴力查找,然后得知这个线性序列其实是有序的,那么在移动指针的时候便可以考虑使用二分法的移动逻辑,快速跳过不必要的验证项,而不是傻傻地一个个去排除。
题目中关键词:
有序、查找、第 k 小的元素、最大化最小值、最小化最大值、单调等等;js// 开区间二分遍历 let left = -1, right = n; while (left < right) { let mid = Math.floor((left + right) / 2); if (condition) { // 处理边界情况 left = mid; } else { // 处理边界情况 right = mid; } } // 闭区间二分遍历 let left = 0, right = n - 1; while (left <= right) { let mid = Math.floor((left + right) / 2); if (condition) { // 处理边界情况 left = mid + 1; } else { // 处理边界情况 right = mid - 1; } }贪心法 贪心法是一种遍历策略,在遍历时,有导向地选择在当前状态下最好或最优的选择进行优先遍历,忽略某些路径,跳过不必要的遍历,并且依然能够找到目标或最优结果的算法。贪心法需要我们提前知道:局部选择是否能导致全局最优。这往往要求题目具有一定的数学性质,例如最优子结构、无后效性、单调性等。
题目中关键词:
最优、单调性等等;jsfor(let i = 0; i < n; i++) { if (condition) { // 跳过特殊情况 continue } for(let j = 0; j < m; j++) { // 遍历入参集合 } } function dfs(depth, arg) { // 递归终止条件 if (depth === target) { return // 找到答案 } if (condition) { // 剪枝,跳过特殊情况 continue } for (let i = 0; i < m; i++) { if (condition) { // 剪枝,跳过特殊情况 continue } dfs(depth + 1, arg + i); } }多趟遍历 多趟遍历是一种遍历策略,在遍历时,将问题分解为多个子流程(每个子流程之间在算法结构上不具有相似性,这里区分于分治法中的减少问题的规模),然后依次解决这些子流程,从而得到最终的结果。多趟遍历可以用于解决一些复杂的问题,如果一个问题一次性解决需要较大的时间复杂度,那么就分成多次时间复杂度较低的遍历进行解决;或者一次遍历不能够得到最终结果,那么就分成多次遍历,每次遍历都得到一部分结果,然后导出最终结果。
题目中关键词:
多趟、多次遍历、多次计算等等;jsconst tmp = []; for (let i = 0; i < n; i++) { // 遍历入参集合 // 计算中间结果 tmp } const result = []; for (let i = 0; i < n; i++) { // 遍历出参集合 }
数据结构辅助
在算法设计中,合理使用数据结构可以显著提高效率。这体现了"空间换时间"的思想:通过使用额外的空间来存储中间结果,避免重复计算,从而降低时间复杂度。更多数据结构参看数据结构章节和数据结构辅助。
数学技巧辅助
我们可以使用数学技巧来优化算法,例如使用数学公式、数学定理、数学性质等等。这些技巧可以帮助我们找到更优的算法解决方案,从而降低时间复杂度和空间复杂度。
前缀和与差分
前缀和是指一个数组的前 n 个元素的和。前缀和可以用于快速计算一个数组的子数组和,时间复杂度为 O(1)。前缀和可以用于解决一些需要频繁计算子数组和的问题,例如最大子数组和、子数组和等于 k 的子数组等等。其实,也不一定是和,也可以是最大值、最小值、个数等等基于一个子数组的统计信息,均可以使用前缀和来优化。前缀和将引入一个额外的数组来保存中间结果,从而降低时间复杂度。
对于数组 a,定义前缀和数组 s,其中 s[i] 表示 a[0] 到 a[i-1] 的和,即:
s[i] = a[0] + a[1] + a[2] + ... + a[i-1]
sum(a, b) = s[b+1] - s[a]关键词:子数组、和、最大值、最小值、个数、区间等等;
差分是指一个数组的每两个数之间的差值所产生的序列,可以理解为前缀和的逆运算。差分数组可以快速地对一个区间内的数值进行区间计算,时间复杂度为 O(1)。差分数组可以用于解决一些需要频繁对区间进行加减操作的问题,例如区间求和、区间修改等等。其实,也不一定是和,也可以是最大值、最小值、个数等等基于一个子数组的统计信息,均可以使用差分数组来优化。差分数组将引入一个额外的数组来保存中间结果,从而降低时间复杂度。
关键词:区间、和、最大值、最小值、个数、加减、修改等等;
对于数组 a,定义差分数组 d,其中 d[i] 表示 a[i] 和 a[i-1] 的差值,即:
d[0] = a[0]
d[i] = a[i] - a[i-1] (i > 0)
对 [a, b] 区间内的数值进行加减操作,只需要对差分数组进行修改:
d[a] += val
d[b+1] -= valdp 动态规划
动态规划是一种通过将问题分解为子问题,并将子问题的解存储起来,从而避免重复计算的方法,是典型空间换时间的思想,同时,动态规划刻意地在构造一个数列问题,将题目转化为寻找数列的递推公式的问题,或者说是寻找状态转移方程的问题。动态规划通常用于解决一些具有重叠子问题和最优子结构的问题,例如背包问题、最长公共子序列问题、最长递增子序列问题等等。
关键词:最优等;(含有重叠子问题),具体内容参看 动态规划。
位运算
使用位运算可以模拟集合操作,例如集合的并集、交集、差集等等。位运算的时间复杂度为 O(1),可以用于解决一些需要频繁进行集合操作的问题,例如集合的并集、交集、差集等等,同时降低算法的空间复杂度。但是,位运算需要我们提前知道集合中的元素,并且元素的范围不能太大,否则会超出计算机的表示范围。具体参考位运算
数学公式
使用数学公式可以简化算法,一些问题具有鲜明的数学性质,通过归纳数学公式来直接计算答案。数学公式的时间复杂度为 O(1),可以用于解决一些需要频繁进行计算的问题,例如阶乘、斐波那契数列等等,同时降低算法的空间复杂度。一旦一个问题可以通过推导数学公式来解决,那么该问题的难点就变成了数学推导,而对算法实现的难度大大降低。
排序
排序算法是计算机科学中最基础且重要的算法之一。根据不同的应用场景和需求,我们可以选择不同的排序算法。
排序算法的选择
选择排序算法时,需要考虑以下因素:
- 数据规模:小规模数据可以使用简单排序,大规模数据需要使用高效排序
- 数据分布:如果数据分布有特点,可以使用特定排序算法
- 稳定性:如果需要保持相等元素的相对顺序,选择稳定排序
- 空间限制:如果空间有限,选择原地排序
- 数据特点:如果数据基本有序,插入排序可能更高效
排序算法的应用
- 数据预处理:排序是许多算法的基础步骤
- 查找优化:排序后的数据可以使用二分查找
- 去重:排序后可以方便地去除重复元素
- 统计:排序后可以方便地进行各种统计
- 数据展示:排序后的数据更易于展示和理解
字符串
字符串算法是计算机科学中最常见和实用的算法之一,涉及对文本数据的处理和分析。字符串处理在日常开发中有着广泛的应用,从简单的文本搜索到复杂的自然语言处理。
字符串算法的主要类别
- 搜索与匹配:在文本中查找模式串
- 暴力匹配(Brute Force)
- KMP算法(Knuth-Morris-Pratt)
- Boyer-Moore算法
- Rabin-Karp算法
- 后缀树/后缀数组
- 编辑与比较:计算字符串间的相似度或距离
- 最长公共子序列(LCS)
- 最长公共子串
- 编辑距离(Levenshtein距离)
- 字符串对齐
- 压缩与编码:减少字符串存储空间
- Huffman编码
- 游程编码(Run-length encoding)
- LZ77/LZ78压缩
- 文本分析:从文本中提取信息
- 正则表达式
- 词频统计
- 文本分类
常见的字符串数据结构
- Trie树(前缀树):用于高效存储和查找字符串集合
- 后缀树/后缀数组:用于复杂的字符串匹配问题
- Bloom过滤器:用于快速判断一个元素是否在集合中
字符串算法的详细实现和应用可以参考字符串算法专题。
加密与解密
加密和解密是信息安全领域的基础技术,用于保护数据的机密性、完整性和可用性。根据加密方式的不同,可以分为以下几类:
- 朴素加密:基于简单的替换或移位操作
- 凯撒密码:字母表移位
- Base64:二进制数据编码
- 其他古典密码
- 对称加密:使用相同的密钥进行加密和解密
- AES(高级加密标准):最常用的对称加密算法
- DES(数据加密标准):较老的对称加密算法
- 3DES:DES的三重加密版本
- 非对称加密:使用不同的密钥进行加密和解密
- RSA:最广泛使用的非对称加密算法
- ECC(椭圆曲线加密):更高效的现代非对称加密
- DSA(数字签名算法):主要用于数字签名
- 哈希加密:单向加密,不可逆
- MD5:较老的哈希算法,已不推荐用于安全场景
- SHA系列:包括SHA-1、SHA-256、SHA-512等
- HMAC:基于哈希的消息认证码
加密算法的选择需要考虑以下因素:
- 安全性:算法的抗攻击能力
- 性能:加密/解密的速度和资源消耗
- 密钥管理:密钥的生成、存储和分发
- 应用场景:不同的场景需要不同的加密方案
详细内容请参考加密与解密专题。