Skip to content

算法模型

算法模型的分类往往基于一定的数据结构,面向一定的算法场景。而数据结构的类型可以分为:

  • 线性结构:数组、链表、栈、队列、堆(优先队列)等;
  • 分支结构:各种树、跳表;
  • 网络结构:各种图、矩阵; 对应不同的数据结构,有特定的算法模型进行解决。

遍历和查找

查找是最为基础和常见的算法模型,其核心在于遍历,并且基于一定的数据结构进行遍历。为此,需要先理解几个重要的问题,即:

  1. 遍历的范围,或者说搜索集合;

    我们需要明确题目的入参和出参,从而确定遍历的范围,或者说搜索集合入参集合指的是题目所给的数据结构和数据,出参集合指的是由所有可能是答案的候选数据所构成的集合。一些,入参集合和出参集合相同,我们直接以此作为搜索集合,进行遍历即可;如果入参集合和出参集合不同,那么可以先遍历入参集合,构建出出参集合,并以出参集合作为搜索集合,再遍历。亦或者,也可以不使用入参或者出参集合作为搜索集合,基于间接计算算出搜索目标进行验证。总之,出参集合往往作为搜索集合,但不是唯一的选择。

  2. 目标的特征,或者说搜索目标;
  3. 遍历的方式,即遍历策略;
  • 枚举 枚举法即遍历所有可能的解,一一校验,从而查找答案。枚举法实现简单,时间复杂度一般较高。具体地,枚举法在出参集合中枚举所有可能的备选解,从备选解中找到满足条件的解,或者从入参集合中出发,重复进行计算并寻找所有满足条件的解。枚举是所有查找算法问题的思想基础,后续的算法可以基于枚举法进行优化。枚举的关键就是确定枚举的范围或者搜索集合

    从枚举方式出发,枚举法可以基于循环实现,也可以基于递归实现。循环的一般基于线性结构,入参为循环长度 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; // 答案
    }
  • 双指针 双指针遍历,基于同时管理两个指针的遍历方式,可以减少遍历次数,从而降低时间复杂度。双指针遍历可以基于数组、链表、字符串等线性结构,也可以基于哈希表等非线性结构。双指针具体在操作的时候可以有很多不同的方式,例如左右指针、滑动窗口、快慢指针等。其主要特征是题目中具有两个参数。双指针遍历的使用场景往往需要我们找到两个参数之间的关系,从而确定遍历策略。

    题目中关键词:子数组、两个数组、区间等等;

    js
    let 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;
      }
    }
  • 贪心法 贪心法是一种遍历策略,在遍历时,有导向地选择在当前状态下最好或最优的选择进行优先遍历,忽略某些路径,跳过不必要的遍历,并且依然能够找到目标或最优结果的算法。贪心法需要我们提前知道:局部选择是否能导致全局最优。这往往要求题目具有一定的数学性质,例如最优子结构、无后效性、单调性等。

    题目中关键词:最优、单调性等等;

    js
    for(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);
      }
    }
  • 多趟遍历 多趟遍历是一种遍历策略,在遍历时,将问题分解为多个子流程(每个子流程之间在算法结构上不具有相似性,这里区分于分治法中的减少问题的规模),然后依次解决这些子流程,从而得到最终的结果。多趟遍历可以用于解决一些复杂的问题,如果一个问题一次性解决需要较大的时间复杂度,那么就分成多次时间复杂度较低的遍历进行解决;或者一次遍历不能够得到最终结果,那么就分成多次遍历,每次遍历都得到一部分结果,然后导出最终结果。

    题目中关键词:多趟、多次遍历、多次计算等等;

    js
    const 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] -= val

dp 动态规划

动态规划是一种通过将问题分解为子问题,并将子问题的解存储起来,从而避免重复计算的方法,是典型空间换时间的思想,同时,动态规划刻意地在构造一个数列问题,将题目转化为寻找数列的递推公式的问题,或者说是寻找状态转移方程的问题。动态规划通常用于解决一些具有重叠子问题和最优子结构的问题,例如背包问题、最长公共子序列问题、最长递增子序列问题等等。

关键词:最优等;(含有重叠子问题),具体内容参看 动态规划

位运算

使用位运算可以模拟集合操作,例如集合的并集、交集、差集等等。位运算的时间复杂度为 O(1),可以用于解决一些需要频繁进行集合操作的问题,例如集合的并集、交集、差集等等,同时降低算法的空间复杂度。但是,位运算需要我们提前知道集合中的元素,并且元素的范围不能太大,否则会超出计算机的表示范围。具体参考位运算

数学公式

使用数学公式可以简化算法,一些问题具有鲜明的数学性质,通过归纳数学公式来直接计算答案。数学公式的时间复杂度为 O(1),可以用于解决一些需要频繁进行计算的问题,例如阶乘、斐波那契数列等等,同时降低算法的空间复杂度。一旦一个问题可以通过推导数学公式来解决,那么该问题的难点就变成了数学推导,而对算法实现的难度大大降低。

排序

排序算法是计算机科学中最基础且重要的算法之一。根据不同的应用场景和需求,我们可以选择不同的排序算法

排序算法的选择

选择排序算法时,需要考虑以下因素:

  1. 数据规模:小规模数据可以使用简单排序,大规模数据需要使用高效排序
  2. 数据分布:如果数据分布有特点,可以使用特定排序算法
  3. 稳定性:如果需要保持相等元素的相对顺序,选择稳定排序
  4. 空间限制:如果空间有限,选择原地排序
  5. 数据特点:如果数据基本有序,插入排序可能更高效

排序算法的应用

  1. 数据预处理:排序是许多算法的基础步骤
  2. 查找优化:排序后的数据可以使用二分查找
  3. 去重:排序后可以方便地去除重复元素
  4. 统计:排序后可以方便地进行各种统计
  5. 数据展示:排序后的数据更易于展示和理解

字符串

字符串算法是计算机科学中最常见和实用的算法之一,涉及对文本数据的处理和分析。字符串处理在日常开发中有着广泛的应用,从简单的文本搜索到复杂的自然语言处理。

字符串算法的主要类别

  1. 搜索与匹配:在文本中查找模式串
    • 暴力匹配(Brute Force)
    • KMP算法(Knuth-Morris-Pratt)
    • Boyer-Moore算法
    • Rabin-Karp算法
    • 后缀树/后缀数组
  2. 编辑与比较:计算字符串间的相似度或距离
    • 最长公共子序列(LCS)
    • 最长公共子串
    • 编辑距离(Levenshtein距离)
    • 字符串对齐
  3. 压缩与编码:减少字符串存储空间
    • Huffman编码
    • 游程编码(Run-length encoding)
    • LZ77/LZ78压缩
  4. 文本分析:从文本中提取信息
    • 正则表达式
    • 词频统计
    • 文本分类

常见的字符串数据结构

  • Trie树(前缀树):用于高效存储和查找字符串集合
  • 后缀树/后缀数组:用于复杂的字符串匹配问题
  • Bloom过滤器:用于快速判断一个元素是否在集合中

字符串算法的详细实现和应用可以参考字符串算法专题

加密与解密

加密和解密是信息安全领域的基础技术,用于保护数据的机密性、完整性和可用性。根据加密方式的不同,可以分为以下几类:

  1. 朴素加密:基于简单的替换或移位操作
    • 凯撒密码:字母表移位
    • Base64:二进制数据编码
    • 其他古典密码
  2. 对称加密:使用相同的密钥进行加密和解密
    • AES(高级加密标准):最常用的对称加密算法
    • DES(数据加密标准):较老的对称加密算法
    • 3DES:DES的三重加密版本
  3. 非对称加密:使用不同的密钥进行加密和解密
    • RSA:最广泛使用的非对称加密算法
    • ECC(椭圆曲线加密):更高效的现代非对称加密
    • DSA(数字签名算法):主要用于数字签名
  4. 哈希加密:单向加密,不可逆
    • MD5:较老的哈希算法,已不推荐用于安全场景
    • SHA系列:包括SHA-1、SHA-256、SHA-512等
    • HMAC:基于哈希的消息认证码

加密算法的选择需要考虑以下因素:

  1. 安全性:算法的抗攻击能力
  2. 性能:加密/解密的速度和资源消耗
  3. 密钥管理:密钥的生成、存储和分发
  4. 应用场景:不同的场景需要不同的加密方案

详细内容请参考加密与解密专题