算法学习笔记:20.分治法——从原理到实战,涵盖 LeetCode 与考研 408 例题

发布于:2025-07-17 ⋅ 阅读:(18) ⋅ 点赞:(0)

分治法(Divide and Conquer)是计算机科学中最经典的算法设计思想之一,其核心思想是将复杂问题分解为若干个规模较小的子问题,通过解决子问题并合并结果来求解原问题。这种思想不仅在排序、搜索等基础算法中广泛应用,也是考研计算机专业基础综合(408)的核心考点。

分治法核心思路

算法定义与步骤

分治法的字面含义是 “分而治之”,其基本流程可分为三个步骤:

  1. 分解(Divide):将原问题分解为若干个与原问题结构相似但规模更小的子问题。
  2. 解决(Conquer):若子问题规模足够小,则直接求解;否则递归求解子问题。
  3. 合并(Combine):将子问题的解合并为原问题的解。

适用条件

并非所有问题都适合用分治法解决,其适用条件包括:

  • 可分解性:原问题可分解为规模较小的相似子问题。
  • 独立子问题:子问题之间相互独立,不存在重叠依赖(否则更适合动态规划)。
  • 可合并性:子问题的解可有效合并为原问题的解。
  • 基础情况:存在最小子问题的直接解法。

典型应用场景

分治法在计算机科学中应用广泛,典型场景包括:

  • 排序算法:归并排序、快速排序。
  • 搜索问题:二分查找、寻找第 k 大元素。
  • 矩阵运算:Strassen 矩阵乘法。
  • 组合问题:全排列、子集和。
  • 图论问题:最近点对问题、连通分量求解。

分治法在 LeetCode 中的实战

例题 1:912. 排序数组(中等)

题目描述:给你一个整数数组 nums,请你将该数组升序排列。

示例

输入:nums = [5,2,3,1]

输出:[1,2,3,5]

解题思路(归并排序)

归并排序是分治法的经典应用,其步骤为:

  1. 分解:将数组从中间分为左右两个子数组,递归分解直至子数组长度为 1(可直接求解)。
  1. 解决:当子数组长度为 1 时,数组天然有序。
  1. 合并:将两个有序子数组合并为一个更大的有序数组(核心步骤)。
代码实现
class Solution {

    public int[] sortArray(int[] nums) {

        if (nums == null || nums.length <= 1) {

            return nums;

        }

        int n = nums.length;

        int[] temp = new int[n]; // 临时数组,用于合并

        mergeSort(nums, 0, n - 1, temp);

        return nums;

    }

// 分解:将数组[left..right]排序

    private void mergeSort(int[] nums, int left, int right, int[] temp) {

        if (left < right) {

            int mid = left + (right - left) / 2; // 避免溢出

            mergeSort(nums, left, mid, temp); // 左子数组排序

            mergeSort(nums, mid + 1, right, temp); // 右子数组排序

            merge(nums, left, mid, right, temp); // 合并两个有序子数组

        }

    }

// 合并:将[left..mid]和[mid+1..right]合并为有序数组

    private void merge(int[] nums, int left, int mid, int right, int[] temp) {

        int i = left; // 左子数组指针

        int j = mid + 1; // 右子数组指针

        int k = left; // 临时数组指针

// 比较并合并元素

        while (i <= mid && j <= right) {

            if (nums[i] <= nums[j]) {

                temp[k++] = nums[i++];

            } else {

                temp[k++] = nums[j++];

            }

        }

// 处理左子数组剩余元素

        while (i <= mid) {

            temp[k++] = nums[i++];

        }

// 处理右子数组剩余元素

        while (j <= right) {

            temp[k++] = nums[j++];

        }

// 复制临时数组到原数组

        for (k = left; k <= right; k++) {

            nums[k] = temp[k];

        }

    }

}
复杂度分析
  • 时间复杂度:O (nlogn)。分解过程产生 logn 层递归,每层合并操作总时间为 O (n)。
  • 空间复杂度:O (n)。需要临时数组存储合并结果。
  • 分治体现:通过递归分解数组为子数组,合并子数组的解(有序子数组)得到原数组的解(有序数组)。

例题 2:215. 数组中的第 K 个最大元素(中等)

题目描述:给定整数数组 nums 和整数 k,请返回数组中第 k 个最大的元素。

示例

输入: [3,2,1,5,6,4] 和 k = 2

输出: 5

解题思路(快速选择算法)

快速选择算法基于分治法,是快速排序的变种:

  1. 分解:随机选择基准元素(pivot),将数组分为三部分:小于 pivot、等于 pivot、大于 pivot。
  2. 解决:通过基准元素的位置判断是否为目标元素:
    • 若基准位置等于 n - k(n 为数组长度),则基准元素即为第 k 大元素。
    • 若基准位置小于 n - k,则递归处理右子数组。
    • 若基准位置大于 n - k,则递归处理左子数组。
    • 合并:无需合并,直接返回找到的目标元素。
代码实现
class Solution {

    private Random random = new Random();

    public int findKthLargest(int[] nums, int k) {

        int n = nums.length;

        int targetIndex = n - k; // 第k大元素在有序数组中的索引

        return quickSelect(nums, 0, n - 1, targetIndex);

    }

// 分治:在nums[left..right]中寻找索引为target的元素

    private int quickSelect(int[] nums, int left, int right, int target) {

        if (left == right) {

            return nums[left]; // 子数组长度为1,直接返回

        }

// 分解:分区并返回基准元素位置

        int pivotIndex = randomPartition(nums, left, right);

// 解决:判断基准位置是否为目标

        if (pivotIndex == target) {

            return nums[pivotIndex];

        } else if (pivotIndex < target) {

            return quickSelect(nums, pivotIndex + 1, right, target); // 递归右子数组

        } else {

            return quickSelect(nums, left, pivotIndex - 1, target); // 递归左子数组

        }

    }

// 随机选择基准并分区

    private int randomPartition(int[] nums, int left, int right) {

        int pivotPos = left + random.nextInt(right - left + 1); // 随机选择基准位置

        swap(nums, pivotPos, right); // 将基准元素移至末尾

        return partition(nums, left, right);

    }

// 分区:将小于基准的元素放左边,大于基准的放右边

    private int partition(int[] nums, int left, int right) {

        int pivot = nums[right];

        int i = left - 1; // 小于基准区域的边界

        for (int j = left; j < right; j++) {

            if (nums[j] <= pivot) {

                i++;

                swap(nums, i, j); // 扩展小于基准的区域

            }

        }

        swap(nums, i + 1, right); // 将基准元素放至最终位置

        return i + 1;

    }

    private void swap(int[] nums, int i, int j) {

        int temp = nums[i];

        nums[i] = nums[j];

        nums[j] = temp;

    }

}
复杂度分析
  • 时间复杂度:平均 O (n),最坏 O (n²)(随机化后最坏情况概率极低)。
  • 空间复杂度:O (logn),来自递归栈(平均情况)。
  • 分治体现:通过分区将原问题分解为规模更小的子问题(寻找子数组中的目标元素),递归求解后直接返回结果。

考研 408 例题解析

例题 1:概念辨析题(选择题)

题目:下列关于分治法的叙述中,正确的是( )。

A. 分治法的时间复杂度总是优于蛮力法

B. 分治法要求子问题必须完全独立,无任何关联

C. 分治法的合并步骤对算法效率无影响

D. 快速排序和归并排序的分治策略完全相同

答案:B

解析

  • A 错误:分治法的时间复杂度取决于子问题规模和合并成本,例如某些问题的分治法时间复杂度可能与蛮力法相同(如寻找最大值)。
  • B 正确:分治法要求子问题独立,若存在重叠子问题,动态规划更合适。
  • C 错误:合并步骤的效率直接影响算法总复杂度(如归并排序的合并步骤为 O (n),是总复杂度的关键)。
  • D 错误:快速排序的分解基于基准元素分区,合并步骤可省略;归并排序的分解是等分,合并步骤为核心。

例题 2:算法设计题(408 高频考点)

题目:设计一个分治算法,求二叉树的深度。二叉树的深度是指从根节点到最远叶子节点的最长路径上的节点数。

解题思路

  1. 分解:将二叉树分解为左子树和右子树两个子问题。
  2. 解决:递归求解左子树深度和右子树深度。
  3. 合并:二叉树的深度为左、右子树深度的最大值加 1(根节点)。

基础情况:空树的深度为 0。

代码实现

class TreeNode {

    int val;

    TreeNode left;

    TreeNode right;

    TreeNode(int x) {
        val = x;
    }

}

public class TreeDepth {

    public int maxDepth(TreeNode root) {

// 基础情况:空树深度为0

        if (root == null) {

            return 0;

        }

// 分解:求左、右子树深度

        int leftDepth = maxDepth(root.left);

        int rightDepth = maxDepth(root.right);

// 合并:取最大值加1(当前节点)

        return Math.max(leftDepth, rightDepth) + 1;

    }

}

复杂度分析

  • 时间复杂度:O (n),每个节点被访问一次。
  • 空间复杂度:O (h),h 为树的深度(递归栈空间)。
  • 分治体现:通过递归分解为左、右子树的深度问题,合并时取最大值加 1 得到原树深度。

例题 3:综合应用题

题目:使用分治法求 n 个元素的最大子数组和(子数组是连续的)。例如,数组 [-2,1,-3,4,-1,2,1,-5,4] 的最大子数组和为 6(对应子数组 [4,-1,2,1])。

解题思路

  1. 分解:将数组从中间分为左半部分、右半部分、跨越中点的子数组三部分。
  2. 解决
    • 递归求左半部分的最大子数组和。
    • 递归求右半部分的最大子数组和。
    • 直接求跨越中点的最大子数组和(从中间向左扩展求最大左和,向右扩展求最大右和,总和为两者之和)。
    • 合并:原数组的最大子数组和为三部分的最大值。

代码实现

public class MaximumSubarray {

    public int maxSubArray(int[] nums) {

        if (nums == null || nums.length == 0) {

            return 0;

        }

        return maxSubArray(nums, 0, nums.length - 1);

    }

    private int maxSubArray(int[] nums, int left, int right) {

        if (left == right) {

            return nums[left]; // 基础情况:单元素子数组

        }

        int mid = left + (right - left) / 2;

// 分解:求左、右、跨越中点的最大子数组和

        int leftMax = maxSubArray(nums, left, mid);

        int rightMax = maxSubArray(nums, mid + 1, right);

        int crossMax = maxCrossingSum(nums, left, mid, right);

// 合并:取三者最大值

        return Math.max(Math.max(leftMax, rightMax), crossMax);

    }

// 求跨越中点的最大子数组和

    private int maxCrossingSum(int[] nums, int left, int mid, int right) {

// 向左扩展求最大和

        int leftSum = Integer.MIN_VALUE;

        int sum = 0;

        for (int i = mid; i >= left; i--) {

            sum += nums[i];

            leftSum = Math.max(leftSum, sum);

        }

// 向右扩展求最大和

        int rightSum = Integer.MIN_VALUE;

        sum = 0;

        for (int i = mid + 1; i <= right; i++) {

            sum += nums[i];

            rightSum = Math.max(rightSum, sum);

        }

        return leftSum + rightSum;

    }

}

复杂度分析

  • 时间复杂度:O (nlogn)。分解为两个规模为 n/2 的子问题,合并步骤(求跨越中点的最大和)为 O (n),递归方程为 T (n) = 2T (n/2) + O (n)。
  • 空间复杂度:O (logn),来自递归栈。

分治法的扩展与考研 408 考点总结

分治法与其他算法的对比

算法思想

核心策略

适用场景

典型算法

分治法

分解为独立子问题

子问题无重叠,可合并

归并排序、快速选择

动态规划

分解为重叠子问题

子问题有重叠,需记录中间结果

斐波那契数列、最短路径

贪心算法

局部最优决策

问题具有贪心选择性质

哈夫曼编码、活动选择

考研 408 中的分治法考点

  • 算法设计:能根据问题设计分治策略(如二叉树深度、最大子数组和)。
  • 复杂度分析:掌握递归方程求解(主方法、递归树法),如 T (n) = aT (n/b) + f (n) 的分析。
  • 应用场景:理解分治法在排序、搜索、树等数据结构中的应用。
  • 与其他算法的区别:重点区分分治法与动态规划(子问题独立性)。

总结

分治法作为一种经典的算法设计思想,通过 “分解 - 解决 - 合并” 的流程,将复杂问题转化为可处理的子问题,在计算机科学中有着不可替代的地位。

掌握分治法不仅需要理解其核心步骤,更要能根据问题特性判断是否适用,并设计合理的分解与合并策略。在考研备考中,分治法的复杂度分析和算法设计是重点,需结合具体问题深入练习。

希望本文能够帮助读者更深入地理解分治法,并在实际项目中发挥其优势。谢谢阅读!


希望这份博客能够帮助到你。如果有其他需要修改或添加的地方,请随时告诉我。


网站公告

今日签到

点亮在社区的每一天
去签到