零基础数据结构与算法——第四章:基础算法-排序(中)

排序上(冒泡/选择/插入)

4.1.4 归并排序(Merge Sort)

基本概念

归并排序是一种高效的排序算法,建立在归并操作上。该算法采用分治法(Divide and Conquer)的思想,将问题分解为更小的子问题,解决子问题后再将结果合并。归并排序的核心思想是:先递归地将数组分成两半分别排序,然后将排好序的两半合并成一个有序数组。

生活中的例子

想象你和朋友在整理一大堆扑克牌:

  1. 你们将牌堆平均分成两半
  2. 每人负责整理一半
  3. 如果牌太多,你们可以继续将自己的那一半再分给其他朋友
  4. 当每个人手中只有少量牌时,各自将牌排好序
  5. 然后两两合并已排序的牌堆,直到所有牌都合并成一个有序的牌堆

这就像是公司的组织结构,大任务分解给部门,部门再分解给小组,最后汇总结果。

算法步骤
  1. 分解:将待排序数组分成两个子数组,分别包含前半部分和后半部分。
  2. 递归排序:递归地对两个子数组进行归并排序。
  3. 合并:将两个已排序的子数组合并成一个有序数组。
图解过程

假设我们有数组:[38, 27, 43, 3, 9, 82, 10]

分解过程

复制代码
[38, 27, 43, 3, 9, 82, 10]
            /\            
           /  \           
 [38, 27, 43, 3]    [9, 82, 10]
       /\              /\      
      /  \            /  \     
 [38, 27]  [43, 3]  [9]  [82, 10]
   /\      /\           /\    
  /  \    /  \         /  \   
[38] [27] [43] [3]    [9] [82] [10]

合并过程

复制代码
[38] [27] [43] [3]    [9] [82] [10]
  \  /     \  /        \  /    /  
 [27, 38]  [3, 43]     [9, 82] [10]
     \       /           \     /   
   [3, 27, 38, 43]     [9, 10, 82] 
          \               /        
        [3, 9, 10, 27, 38, 43, 82] 
代码实现
java 复制代码
public static void mergeSort(int[] arr, int left, int right) {
    // 基本情况:如果左索引小于右索引(至少有两个元素)
    if (left < right) {
        // 找出中间点
        int mid = left + (right - left) / 2;
        
        // 递归排序左半部分
        mergeSort(arr, left, mid);
        // 递归排序右半部分
        mergeSort(arr, mid + 1, right);
        
        // 合并已排序的两半
        merge(arr, left, mid, right);
    }
}

private static void merge(int[] arr, int left, int mid, int right) {
    // 计算两个子数组的大小
    int n1 = mid - left + 1;
    int n2 = right - mid;
    
    // 创建临时数组
    int[] L = new int[n1];
    int[] R = new int[n2];
    
    // 将数据复制到临时数组
    for (int i = 0; i < n1; i++) {
        L[i] = arr[left + i];
    }
    for (int j = 0; j < n2; j++) {
        R[j] = arr[mid + 1 + j];
    }
    
    // 合并临时数组
    int i = 0, j = 0; // 初始化两个子数组的索引
    int k = left;     // 初始化合并后数组的索引
    
    // 比较两个子数组的元素并合并
    while (i < n1 && j < n2) {
        if (L[i] <= R[j]) {
            arr[k] = L[i];
            i++;
        } else {
            arr[k] = R[j];
            j++;
        }
        k++;
    }
    
    // 复制L[]的剩余元素(如果有)
    while (i < n1) {
        arr[k] = L[i];
        i++;
        k++;
    }
    
    // 复制R[]的剩余元素(如果有)
    while (j < n2) {
        arr[k] = R[j];
        j++;
        k++;
    }
}
性能分析
  • 时间复杂度

    • 最坏情况:O(n log n)
    • 最好情况:O(n log n)
    • 平均情况:O(n log n)
  • 空间复杂度:O(n) - 需要额外的空间来存储临时数组

  • 稳定性:稳定 - 相等元素的相对位置不会改变

适用场景
  • 需要稳定排序的场景
  • 对时间复杂度要求为O(n log n)的场景
  • 数据量较大的排序(相比O(n²)的算法)
  • 外部排序(当数据太大无法全部加载到内存中时)
归并排序的优缺点

优点

  • 时间复杂度稳定,始终是O(n log n)
  • 稳定排序
  • 适合处理大数据量
  • 可以轻松实现外部排序

缺点

  • 需要额外的O(n)空间
  • 对于小数据集,可能不如插入排序等简单算法
  • 递归调用会带来额外的函数调用开销

4.1.5 快速排序(Quick Sort)

基本概念

快速排序是一种高效的排序算法,也是实际应用中最常用的排序算法之一。它采用分治法的思想,通过选择一个"基准"元素,将数组分为两个子数组,一个子数组的所有元素都小于基准,另一个子数组的所有元素都大于基准,然后递归地对这两个子数组进行排序。

生活中的例子

想象你是一位老师,需要按照学生的身高排队:

  1. 你选择一名学生作为参考(基准)
  2. 让所有比这名学生矮的站到左边,比这名学生高的站到右边
  3. 现在这名参考学生已经站在了正确的位置
  4. 然后你分别对左边和右边的学生重复这个过程
  5. 最终所有学生都会按照身高顺序排好队

这就像是在图书馆整理书籍时,先按照书的大类分类,再在每个大类中进一步细分。

算法步骤
  1. 选择基准:从数组中选择一个元素作为"基准"(pivot)。
  2. 分区操作:重新排列数组,使所有比基准值小的元素都在基准的左边,所有比基准值大的元素都在基准的右边。
  3. 递归排序:递归地对基准左边和右边的子数组应用前两步。
图解过程

假设我们有数组:[10, 80, 30, 90, 40, 50, 70]

第一次分区

  • 选择70作为基准(最后一个元素)
  • 分区后:[10, 30, 40, 50, 70, 90, 80]
  • 70现在在正确的位置上

递归排序左子数组:[10, 30, 40, 50]

  • 选择50作为基准
  • 分区后:[10, 30, 40, 50]
  • 50现在在正确的位置上

递归排序左子数组的左子数组:[10, 30, 40]

  • 选择40作为基准
  • 分区后:[10, 30, 40]
  • 40现在在正确的位置上

递归排序左子数组的左子数组的左子数组:[10, 30]

  • 选择30作为基准
  • 分区后:[10, 30]
  • 30现在在正确的位置上

递归排序右子数组:[90, 80]

  • 选择80作为基准
  • 分区后:[80, 90]
  • 80现在在正确的位置上

最终排序结果:[10, 30, 40, 50, 70, 80, 90]

代码实现
java 复制代码
public static void quickSort(int[] arr, int low, int high) {
    // 基本情况:如果low >= high,说明数组已经排序完成
    if (low < high) {
        // 找到基准位置,使得基准左边的元素都小于基准,右边的元素都大于基准
        int pivotIndex = partition(arr, low, high);
        
        // 递归排序基准左边的子数组
        quickSort(arr, low, pivotIndex - 1);
        // 递归排序基准右边的子数组
        quickSort(arr, pivotIndex + 1, high);
    }
}

private static int partition(int[] arr, int low, int high) {
    // 选择最右边的元素作为基准
    int pivot = arr[high];
    // i是小于基准的元素的最后一个索引
    int i = low - 1;
    
    // 遍历从low到high-1的元素
    for (int j = low; j < high; j++) {
        // 如果当前元素小于等于基准
        if (arr[j] <= pivot) {
            // 将i向右移动一位
            i++;
            // 交换arr[i]和arr[j]
            int temp = arr[i];
            arr[i] = arr[j];
            arr[j] = temp;
        }
    }
    
    // 将基准放到正确的位置上(i+1)
    int temp = arr[i + 1];
    arr[i + 1] = arr[high];
    arr[high] = temp;
    
    // 返回基准的索引
    return i + 1;
}
性能分析
  • 时间复杂度

    • 最坏情况:O(n²) - 当数组已经排序或逆序时(可以通过随机选择基准来改善)
    • 最好情况:O(n log n) - 当每次分区都将数组平均分成两半时
    • 平均情况:O(n log n)
  • 空间复杂度:O(log n) - 递归调用栈的深度

  • 稳定性:不稳定 - 相等元素的相对位置可能会改变

适用场景
  • 大多数实际应用场景(平均性能优秀)
  • 内部排序(数据完全加载到内存中)
  • 对时间复杂度要求为O(n log n)的场景
  • 不要求排序稳定性的场景
快速排序的优化
  1. 随机选择基准:避免最坏情况

    java 复制代码
    private static int randomPartition(int[] arr, int low, int high) {
        int randomIndex = low + new Random().nextInt(high - low + 1);
        swap(arr, randomIndex, high);
        return partition(arr, low, high);
    }
  2. 三数取中法:选择左端、右端和中间位置三个元素的中间值作为基准

  3. 当子数组规模较小时使用插入排序:对于小规模数组,插入排序可能更高效

  4. 尾递归优化:减少递归调用栈的深度

快速排序与归并排序的比较
  • 快速排序通常比归并排序快,因为它的常数因子更小
  • 快速排序是原地排序(不需要额外的数组空间),而归并排序需要O(n)的额外空间
  • 快速排序不稳定,归并排序稳定
  • 快速排序在最坏情况下性能较差,归并排序始终保持O(n log n)的时间复杂度

4.1.6 堆排序(Heap Sort)

基本概念

堆排序是一种基于比较的排序算法,它利用堆这种特殊的数据结构进行排序。堆是一个近似完全二叉树的结构,并同时满足堆的性质:每个节点的值都大于或等于(或小于或等于)其子节点的值。

  • 大顶堆:每个节点的值都大于或等于其子节点的值,堆顶元素是最大值
  • 小顶堆:每个节点的值都小于或等于其子节点的值,堆顶元素是最小值

堆排序通常使用大顶堆来实现升序排序。

生活中的例子

想象一个公司的组织结构:

  1. CEO在最顶层,是公司权力最大的人
  2. 中层管理者的权力小于CEO,但大于普通员工
  3. 普通员工在最底层,权力最小

如果我们按照权力大小排序,可以:

  1. 先将所有人按照权力大小组织成一个金字塔结构(大顶堆)
  2. 然后每次取出权力最大的人(堆顶),放到排序结果的最后
  3. 重新调整剩余人员的结构,继续取出权力最大的人
  4. 重复这个过程,直到所有人都按权力从小到大排好序
算法步骤
  1. 构建初始堆:将无序数组构建成一个大顶堆。
  2. 交换并调整
    • 将堆顶元素(最大值)与末尾元素交换
    • 将剩余n-1个元素重新构建成大顶堆
  3. 重复步骤2,直到整个数组排序完成。
图解过程

假设我们有数组:[4, 10, 3, 5, 1]

步骤1:构建初始大顶堆

首先,我们将数组看作一个完全二叉树:

复制代码
        4
       / \
     10   3
    / \
   5   1

从最后一个非叶子节点开始,自下而上进行堆化:

  • 调整节点5(已经是叶子节点,不需要调整)
  • 调整节点10(比子节点5和1都大,不需要调整)
  • 调整节点3(已经是叶子节点,不需要调整)
  • 调整节点4(比子节点10小,交换4和10)

交换后:

复制代码
       10
       / \
     4    3
    / \
   5   1

再次调整节点4:

复制代码
       10
       / \
     5    3
    / \
   4   1

步骤2:交换并调整

  • 交换堆顶元素10和末尾元素1:[5, 4, 3, 1, 10]

  • 对剩余元素[5, 4, 3, 1]重新堆化:

    复制代码
         5
        / \
      4    3
     /
    1
  • 交换堆顶元素5和末尾元素1:[4, 1, 3, 5, 10]

  • 对剩余元素[4, 1, 3]重新堆化:

    复制代码
         4
        / \
      1    3
  • 交换堆顶元素4和末尾元素3:[3, 1, 4, 5, 10]

  • 对剩余元素[3, 1]重新堆化:

    复制代码
         3
        /
      1
  • 交换堆顶元素3和末尾元素1:[1, 3, 4, 5, 10]

排序完成!

代码实现
java 复制代码
public static void heapSort(int[] arr) {
    int n = arr.length;
    
    // 步骤1:构建初始大顶堆
    // 从最后一个非叶子节点开始,自下而上进行堆化
    for (int i = n / 2 - 1; i >= 0; i--) {
        heapify(arr, n, i);
    }
    
    // 步骤2:一个个从堆顶取出元素
    for (int i = n - 1; i > 0; i--) {
        // 将堆顶元素(当前最大值)与末尾元素交换
        int temp = arr[0];
        arr[0] = arr[i];
        arr[i] = temp;
        
        // 对剩余元素重新调整堆结构
        heapify(arr, i, 0);
    }
}

// 堆化过程,调整以i为根的子树
private static void heapify(int[] arr, int n, int i) {
    int largest = i;      // 初始化最大元素为根节点
    int left = 2 * i + 1; // 左子节点索引
    int right = 2 * i + 2; // 右子节点索引
    
    // 如果左子节点存在且大于当前最大值
    if (left < n && arr[left] > arr[largest]) {
        largest = left;
    }
    
    // 如果右子节点存在且大于当前最大值
    if (right < n && arr[right] > arr[largest]) {
        largest = right;
    }
    
    // 如果最大值不是根节点,则交换并继续堆化
    if (largest != i) {
        // 交换arr[i]和arr[largest]
        int swap = arr[i];
        arr[i] = arr[largest];
        arr[largest] = swap;
        
        // 递归地堆化受影响的子树
        heapify(arr, n, largest);
    }
}
性能分析
  • 时间复杂度

    • 建堆操作:O(n)
    • 调整操作:O(n log n)
    • 总体时间复杂度:O(n log n)
  • 空间复杂度:O(1) - 原地排序,不需要额外空间

  • 稳定性:不稳定 - 相等元素的相对位置可能会改变

适用场景
  • 需要找出数组中最大或最小的几个元素时
  • 需要一个稳定的O(n log n)时间复杂度的排序算法
  • 内存空间有限,需要原地排序的场景
  • 实现优先队列时
堆排序的优缺点

优点

  • 时间复杂度稳定,始终是O(n log n)
  • 空间复杂度低,是原地排序
  • 适合处理大数据量

缺点

  • 不稳定排序
  • 在实际应用中,常数因子较大,性能可能不如快速排序
  • 对于近乎有序的数据,表现不如插入排序
相关推荐
我爱Jack4 分钟前
时间与空间复杂度详解:算法效率的度量衡
java·开发语言·算法
☆璇26 分钟前
【数据结构】栈和队列
c语言·数据结构
DoraBigHead2 小时前
小哆啦解题记——映射的背叛
算法
Heartoxx2 小时前
c语言-指针与一维数组
c语言·开发语言·算法
孤狼warrior2 小时前
灰色预测模型
人工智能·python·算法·数学建模
京东云开发者2 小时前
京东零售基于国产芯片的AI引擎技术
算法
chao_7893 小时前
回溯题解——子集【LeetCode】二进制枚举法
开发语言·数据结构·python·算法·leetcode
十盒半价4 小时前
从递归到动态规划:手把手教你玩转算法三剑客
javascript·算法·trae
GEEK零零七4 小时前
Leetcode 1070. 产品销售分析 III
sql·算法·leetcode
凌肖战4 小时前
力扣网编程274题:H指数之普通解法(中等)
算法·leetcode