云计算百科
云计算领域专业知识百科平台

零基础数据结构与算法——第六章:算法设计范式与高级主题-设计技巧(下)

6.1 算法设计技巧

设计技巧(上)

6.1.3 预处理与索引

基本概念

预处理是指在主要算法执行之前,对输入数据进行一些预先处理,以便简化后续的计算。索引是一种数据结构,用于加速数据查找操作。这就像在做一道复杂菜肴前,先把所有食材洗好、切好、分类好,这样在烹饪过程中就能更加高效。

预处理和索引的核心思想是:花费一次性的前期工作,换取后续多次操作的效率提升。

生活中的例子

图书馆的图书分类系统:

  • 图书馆不会随机摆放书籍,而是按照分类系统(如杜威十进制分类法)组织图书
  • 这种预处理需要初始投入(对所有书籍进行分类和编号)
  • 但大大加快了后续查找特定书籍的速度
  • 还有目录索引系统,可以通过作者、书名或主题快速找到书籍位置

厨房食材的准备工作:

  • 专业厨师在烹饪前会做"mise en place"(法语,意为"放置到位")
  • 提前将所有食材洗净、切好、分类放好
  • 虽然前期需要时间,但在实际烹饪过程中可以更加高效流畅
常见的预处理与索引技术
  • 排序预处理:

    • 预先对数据进行排序,以便后续使用二分查找
    • 例如:电话簿按姓氏字母顺序排序,便于快速查找联系人
  • 哈希表索引:

    • 构建哈希表以实现O(1)时间复杂度的查找
    • 例如:数据库中的主键索引,可以快速定位记录
  • 前缀和/前缀积:

    • 预计算数组的前缀和或前缀积,以便快速计算区间和或积
    • 例如:在线考试系统中预计算每道题的累计分数
  • 空间分区索引:

    • 将空间数据划分为区域,加速范围查询
    • 例如:地图应用中的四叉树或R树索引
  • 算法示例:前缀和与区间查询

    假设我们有一个整数数组,需要频繁地计算数组中某个区间内所有元素的和。

    方法1:直接计算(无预处理)

    public static int rangeSum(int[] arr, int left, int right) {
    int sum = 0;
    for (int i = left; i <= right; i++) {
    sum += arr[i];
    }
    return sum;
    }

    这种方法每次查询的时间复杂度为O(n),其中n是区间长度。

    方法2:使用前缀和(预处理)

    // 构建前缀和数组
    public static int[] buildPrefixSum(int[] arr) {
    int n = arr.length;
    int[] prefixSum = new int[n + 1]; // 注意大小为n+1

    // prefixSum[i]表示arr[0]到arr[i-1]的和
    for (int i = 0; i < n; i++) {
    prefixSum[i + 1] = prefixSum[i] + arr[i];
    }

    return prefixSum;
    }

    // 使用前缀和计算区间和
    public static int rangeSum(int[] prefixSum, int left, int right) {
    // 区间[left, right]的和 = 前缀和[0, right] – 前缀和[0, left-1]
    return prefixSum[right + 1] prefixSum[left];
    }

    这种方法预处理时间复杂度为O(n),但每次查询的时间复杂度降为O(1)。

    图解前缀和

    假设有数组:[3, 1, 4, 1, 5, 9, 2, 6]

    前缀和数组:[0, 3, 4, 8, 9, 14, 23, 25, 31]

    • prefixSum[0] = 0(空前缀)
    • prefixSum[1] = 3(前1个元素和)
    • prefixSum[2] = 4(前2个元素和)
    • prefixSum[3] = 8(前3个元素和)
    • prefixSum[8] = 31(全部元素和)

    计算区间和示例:

    • 区间[2, 5]的和 = prefixSum[6] – prefixSum[2] = 23 – 4 = 19
    • 区间[0, 7]的和 = prefixSum[8] – prefixSum[0] = 31 – 0 = 31
    • 区间[4, 4]的和 = prefixSum[5] – prefixSum[4] = 14 – 9 = 5
    二维前缀和

    对于二维数组,也可以使用类似的技术:

    // 构建二维前缀和
    public static int[][] build2DPrefixSum(int[][] matrix) {
    if (matrix.length == 0 || matrix[0].length == 0) return new int[0][0];

    int m = matrix.length;
    int n = matrix[0].length;
    int[][] prefixSum = new int[m + 1][n + 1];

    for (int i = 0; i < m; i++) {
    for (int j = 0; j < n; j++) {
    prefixSum[i + 1][j + 1] = prefixSum[i + 1][j] + prefixSum[i][j + 1]
    prefixSum[i][j] + matrix[i][j];
    }
    }

    return prefixSum;
    }

    // 计算子矩阵和
    public static int subMatrixSum(int[][] prefixSum, int row1, int col1, int row2, int col2) {
    return prefixSum[row2 + 1][col2 + 1] prefixSum[row2 + 1][col1]
    prefixSum[row1][col2 + 1] + prefixSum[row1][col1];
    }

    应用场景
  • 数据库系统:使用索引加速查询操作
  • 图像处理:使用积分图像(二维前缀和)快速计算区域特征
  • 地理信息系统:使用空间索引加速地理位置查询
  • 搜索引擎:使用倒排索引加速全文搜索
  • 游戏开发:预计算光照贴图,加速渲染
  • 6.1.4 增量更新

    基本概念

    增量更新是一种只更新必要部分而不是重新计算整个结果的技术。这种方法在处理动态变化的数据时特别有用,可以大大提高算法的效率。就像修改文档时,我们只需要修改变动的部分,而不是重新打字整个文档。

    增量更新的核心思想是:利用前一个状态的结果和新的变化,快速计算出新状态的结果。

    生活中的例子

    银行账户余额:

    • 银行不会每次交易后重新计算你的所有历史交易
    • 而是基于当前余额加上或减去新的交易金额
    • 例如:余额1000元,消费200元,新余额 = 1000 – 200 = 800元

    电子表格计算:

    • 当你修改电子表格中的一个单元格时
    • 只有依赖于该单元格的公式会被重新计算
    • 其他无关的计算保持不变

    社交媒体信息流:

    • 当你打开社交媒体应用时,它不会重新加载所有内容
    • 而是只加载自上次查看后的新内容
    • 通常会显示"加载新内容"的选项
    常见的增量更新技术
  • 滑动窗口:

    • 在窗口移动时只更新窗口边界的元素
    • 例如:计算数组的移动平均值
  • 差分更新:

    • 只记录和处理变化的部分
    • 例如:版本控制系统只存储文件的变化
  • 增量维护数据结构:

    • 动态维护树、堆、图等数据结构
    • 例如:动态维护最小生成树
  • 流处理:

    • 处理连续到达的数据流
    • 例如:实时数据分析系统
  • 算法示例:滑动窗口最大值

    假设我们有一个整数数组和一个大小为k的滑动窗口,窗口从数组的最左边滑动到最右边,我们需要找出窗口中的最大值。

    方法1:暴力法(无增量更新)

    public static int[] maxSlidingWindow(int[] nums, int k) {
    int n = nums.length;
    if (n == 0 || k == 0) return new int[0];

    int[] result = new int[n k + 1];

    for (int i = 0; i <= n k; i++) {
    int max = Integer.MIN_VALUE;
    for (int j = i; j < i + k; j++) {
    max = Math.max(max, nums[j]);
    }
    result[i] = max;
    }

    return result;
    }

    这种方法的时间复杂度为O(n*k),因为对于每个窗口位置,我们都需要遍历k个元素找出最大值。

    方法2:使用双端队列(增量更新)

    public static int[] maxSlidingWindow(int[] nums, int k) {
    int n = nums.length;
    if (n == 0 || k == 0) return new int[0];

    int[] result = new int[n k + 1];
    // 使用双端队列存储可能成为窗口最大值的元素的索引
    Deque<Integer> deque = new LinkedList<>();

    for (int i = 0; i < n; i++) {
    // 移除队列中所有小于当前元素的值(它们不可能是后续窗口的最大值)
    while (!deque.isEmpty() && nums[deque.peekLast()] < nums[i]) {
    deque.pollLast();
    }

    // 添加当前元素索引到队列
    deque.offerLast(i);

    // 移除队列中所有不在当前窗口范围内的元素
    while (!deque.isEmpty() && deque.peekFirst() < i k + 1) {
    deque.pollFirst();
    }

    // 当窗口至少有k个元素时,记录当前窗口的最大值
    if (i >= k 1) {
    result[i k + 1] = nums[deque.peekFirst()];
    }
    }

    return result;
    }

    这种方法的时间复杂度为O(n),因为每个元素最多被处理两次(入队和出队)。

    另一个例子:最大子数组和(Kadane算法)

    最大子数组和问题是寻找一个数组中具有最大和的连续子数组。

    public static int maxSubArraySum(int[] arr) {
    // 如果数组为空,返回0
    if (arr.length == 0) return 0;

    int maxSoFar = arr[0]; // 记录全局最大子数组和
    int maxEndingHere = arr[0]; // 记录以当前元素结尾的最大子数组和

    for (int i = 1; i < arr.length; i++) {
    // 增量更新:当前元素要么自己开始一个新的子数组,要么加入到前面的子数组
    maxEndingHere = Math.max(arr[i], maxEndingHere + arr[i]);

    // 更新全局最大值
    maxSoFar = Math.max(maxSoFar, maxEndingHere);
    }

    return maxSoFar;
    }

    图解Kadane算法

    假设数组为:[-2, 1, -3, 4, -1, 2, 1, -5, 4]

    索引数组值maxEndingHere计算maxEndingHeremaxSoFar
    0 -2 初始值 -2 -2
    1 1 max(1, -2+1) 1 1
    2 -3 max(-3, 1+(-3)) -2 1
    3 4 max(4, -2+4) 4 4
    4 -1 max(-1, 4+(-1)) 3 4
    5 2 max(2, 3+2) 5 5
    6 1 max(1, 5+1) 6 6
    7 -5 max(-5, 6+(-5)) 1 6
    8 4 max(4, 1+4) 5 6

    最终结果:最大子数组和为6,对应的子数组是[4, -1, 2, 1]。

    应用场景
  • 实时数据分析:处理连续到达的数据流
  • 动态规划优化:减少重复计算
  • 图形渲染:只更新屏幕上变化的部分
  • 数据库更新:增量备份和同步
  • 编译器优化:增量编译只重新编译修改的部分
  • 赞(0)
    未经允许不得转载:网硕互联帮助中心 » 零基础数据结构与算法——第六章:算法设计范式与高级主题-设计技巧(下)
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!