数据结构 二

数据结构 二

排序算法是数据结构的核心基础,既是算法竞赛的入门必学内容,也是校招面试、考研数据结构的高频考点。排序的本质是将一组无序数据,按照指定规则调整为有序序列,根据核心思想可分为交换类、插入类、选择类、分治类等多个方向。

本文为排序算法上篇,系统讲解冒泡、选择、插入、希尔、归并、快速六种经典排序算法,覆盖核心原理、执行步骤、性能指标、优化方案与可运行Java实现,兼顾理论理解、代码落地与面试考点。

前置知识:排序算法的评判维度

衡量一个排序算法的优劣,通常从四个核心维度评估:

  1. 时间复杂度:描述数据量增长时操作次数的增长趋势,分为最好、最坏、平均三种场景。平均复杂度代表算法的常规表现,最坏复杂度代表极端场景下的性能下限。
  2. 空间复杂度 :排序过程中需要额外申请的内存空间大小。仅占用常数级额外空间的称为原地排序,无需申请大规模额外内存。
  3. 稳定性:若两个元素值相等,排序后二者的相对位置保持不变,则为稳定排序;反之为不稳定排序。稳定性在业务场景中有实际价值,例如电商场景中先按销量排序、再按价格排序,稳定排序可以保证价格相同的商品依然保留销量排序的相对顺序。
  4. 算法常数因子:时间复杂度只描述增长趋势,实际运行速度还受常数因子影响,例如同样是O(n²)复杂度,插入排序的常数因子远小于冒泡排序,实际运行速度更快。

六大排序算法分类总览

算法分类 包含算法 核心思想
交换类 冒泡排序、快速排序 通过元素交换调整顺序,快速排序为分治+交换
插入类 插入排序、希尔排序 将元素插入到已排序区间的正确位置,希尔排序为分组插入优化
选择类 选择排序 每轮选出极值放到已排序区
分治类 归并排序 拆分后合并,化整为零逐步排序

一、冒泡排序(交换类)

1. 核心思想

通过相邻元素两两比较与交换,让较大的元素逐步向后移动,如同水中气泡向上浮起,最终每一轮都会将当前未排序区间的最大值"冒泡"到区间末尾的正确位置。

2. 执行步骤

  1. 将数组分为已排序区(末尾)和未排序区(开头),初始已排序区长度为0
  2. 遍历未排序区,相邻元素两两比较,若前一个大于后一个则交换位置
  3. 一轮遍历结束后,未排序区的最大值会移动到未排序区的最后一位,该位置加入已排序区
  4. 重复上述过程,直到未排序区长度为0

3. 性能指标

  • 时间复杂度:最好O(n)(数据已有序),最坏/平均O(n²)
  • 空间复杂度:O(1),纯原地排序
  • 稳定性:稳定排序(相等元素不交换,相对位置不变)

4. Java代码实现

java 复制代码
public class BubbleSort {
    // 基础版冒泡排序
    public static void bubbleSort(int[] arr) {
        if (arr == null || arr.length <= 1) {
            return;
        }
        int n = arr.length;
        // 外层循环控制排序轮数,共n-1轮
        for (int i = 0; i < n - 1; i++) {
            // 内层循环遍历未排序区,两两比较
            for (int j = 0; j < n - 1 - i; j++) {
                if (arr[j] > arr[j + 1]) {
                    // 交换相邻元素
                    int temp = arr[j];
                    arr[j] = arr[j + 1];
                    arr[j + 1] = temp;
                }
            }
        }
    }

    // 优化版1:增加标记位,某轮无交换则提前结束
    public static void bubbleSortOptimized(int[] arr) {
        if (arr == null || arr.length <= 1) {
            return;
        }
        int n = arr.length;
        for (int i = 0; i < n - 1; i++) {
            boolean swapped = false; // 标记本轮是否发生交换
            for (int j = 0; j < n - 1 - i; j++) {
                if (arr[j] > arr[j + 1]) {
                    int temp = arr[j];
                    arr[j] = arr[j + 1];
                    arr[j + 1] = temp;
                    swapped = true;
                }
            }
            // 本轮没有交换,说明数组已经完全有序
            if (!swapped) {
                break;
            }
        }
    }
}

5. 进阶优化:鸡尾酒排序(双向冒泡)

基础冒泡排序只能单向冒泡,当数组中大部分元素有序、仅少量小元素位于末尾时,需要多轮才能将小元素移到前面。鸡尾酒排序对此做了优化:

  • 交替进行从左到右、从右到左的双向遍历
  • 正向遍历将最大值移到末尾,反向遍历将最小值移到开头
  • 适合"大部分有序、小元素集中在末尾、大元素集中在开头"的场景,能有效减少排序轮数

6. 特点与适用场景

实现逻辑最简单,适合作为排序入门案例;仅适合小规模或接近有序的数据,大规模数据下效率极低,工业界几乎不直接使用。


二、选择排序(选择类)

1. 核心思想

每一轮从未排序区间中遍历找到最小值,直接交换到未排序区间的起始位置,逐步扩大已排序区间。

2. 执行步骤

  1. 数组分为已排序区(开头)和未排序区(末尾),初始已排序区为空
  2. 遍历整个未排序区,记录最小值的下标
  3. 将最小值与未排序区的第一个元素交换,最小值加入已排序区
  4. 重复上述过程,直到未排序区为空

3. 性能指标

  • 时间复杂度:最好/最坏/平均均为O(n²),无论数据是否有序都必须完成全部比较
  • 空间复杂度:O(1),原地排序
  • 稳定性:不稳定排序(跨位置交换可能打乱相等元素的相对顺序)

4. Java代码实现

java 复制代码
public class SelectionSort {
    public static void selectionSort(int[] arr) {
        if (arr == null || arr.length <= 1) {
            return;
        }
        int n = arr.length;
        // 外层循环:已排序区的末尾位置
        for (int i = 0; i < n - 1; i++) {
            int minIndex = i; // 记录最小值的下标
            // 内层循环:遍历未排序区找最小值
            for (int j = i + 1; j < n; j++) {
                if (arr[j] < arr[minIndex]) {
                    minIndex = j;
                }
            }
            // 将最小值交换到已排序区末尾
            if (minIndex != i) {
                int temp = arr[i];
                arr[i] = arr[minIndex];
                arr[minIndex] = temp;
            }
        }
    }
}

5. 进阶优化:二元选择排序

基础选择排序每轮只找一个最小值,二元选择排序每轮同时找出最小值和最大值,分别放到未排序区的首尾,可将排序轮数减少一半,比较次数略有下降,但整体时间复杂度仍为O(n²)。

6. 特点与适用场景

思路直观,每轮最多只执行一次交换,交换次数远少于冒泡排序;但整体性能无明显优势,且不稳定,仅适合极小规模数据,实际应用较少。


三、插入排序(插入类)

1. 核心思想

将未排序元素逐个取出,向前插入到已排序区间的正确位置,逻辑和手动整理扑克牌完全一致。

2. 执行步骤

  1. 初始已排序区只有数组第一个元素
  2. 取出未排序区的第一个元素,作为待插入值
  3. 从已排序区的末尾向前遍历,若元素大于待插入值,则向后挪一位
  4. 直到找到小于等于待插入值的位置,将待插入值放入该空位
  5. 重复上述过程,直到所有元素插入完成

3. 性能指标

  • 时间复杂度:最好O(n)(数据已有序,只需遍历一次),最坏/平均O(n²)
  • 空间复杂度:O(1),原地排序
  • 稳定性:稳定排序

4. Java代码实现

java 复制代码
public class InsertionSort {
    public static void insertionSort(int[] arr) {
        if (arr == null || arr.length <= 1) {
            return;
        }
        int n = arr.length;
        // 从第二个元素开始逐个插入
        for (int i = 1; i < n; i++) {
            int insertValue = arr[i]; // 待插入的元素
            int j = i - 1; // 已排序区的末尾下标
            // 向前遍历,大于待插入值的元素后移
            while (j >= 0 && arr[j] > insertValue) {
                arr[j + 1] = arr[j];
                j--;
            }
            // 插入到正确位置
            arr[j + 1] = insertValue;
        }
    }
}

5. 进阶优化:二分插入排序

插入过程中,查找插入位置的步骤可以用二分查找优化,将比较次数从O(n)降到O(logn),但元素移动的次数不变,整体时间复杂度仍为O(n²)。适合数据量中等、比较操作开销远大于移动操作的场景。

6. 特点与适用场景

常数因子极小,对接近有序的小规模数据效率极高;是希尔排序的基础,也是很多高级排序算法在小数组场景下的兜底实现。Java原生的Arrays.sort()在数组长度小于47时,会直接使用插入排序,因为小数据量下O(n²)的插入排序,实际速度反而快于O(nlogn)的快速排序。


四、希尔排序(插入类·缩小增量排序)

1. 核心思想

插入排序的优化版本,核心是分组插入排序:通过设定增量将数组拆分为多个独立分组,每个分组内先做插入排序;逐步缩小增量,数组整体逐渐接近有序,最终增量为1时,对整体做一次插入排序。

希尔排序的本质是利用"插入排序在数据接近有序时效率极高"的特点,通过前期分组预排序,让数组提前达到基本有序状态,最终全局插入排序的成本大幅降低。

2. 执行步骤

  1. 设定初始增量,通常取数组长度的1/2
  2. 按增量对数组分组,增量为gap则分为gap组,每组内下标相差gap
  3. 每个分组内分别执行插入排序
  4. 增量逐步减半(gap = gap / 2),重复分组排序
  5. 当gap=1时,整个数组为一组,执行最后一次插入排序

3. 增量序列的选择

希尔排序的性能与增量序列的选择直接相关,常见增量序列:

  • 希尔增量:初始为n/2,每次减半,实现最简单,但最坏复杂度仍为O(n²)
  • Hibbard增量:增量为 2^k - 1,最坏时间复杂度约为O(n^1.5)
  • Knuth增量:增量为 (3^k - 1)/2,是工程中常用的增量序列,性能表现更稳定

4. 性能指标

  • 时间复杂度:平均约O(n^1.3),最坏场景O(n²),具体性能与增量序列选择有关
  • 空间复杂度:O(1),原地排序
  • 稳定性:不稳定排序(分组插入会打乱相等元素的全局相对位置)

5. Java代码实现

java 复制代码
public class ShellSort {
    public static void shellSort(int[] arr) {
        if (arr == null || arr.length <= 1) {
            return;
        }
        int n = arr.length;
        // 希尔增量:初始为长度的一半,逐步减半
        for (int gap = n / 2; gap > 0; gap /= 2) {
            // 对每个分组执行插入排序
            for (int i = gap; i < n; i++) {
                int insertValue = arr[i];
                int j = i - gap;
                // 分组内向前遍历,元素后移
                while (j >= 0 && arr[j] > insertValue) {
                    arr[j + gap] = arr[j];
                    j -= gap;
                }
                arr[j + gap] = insertValue;
            }
        }
    }
}

6. 特点与适用场景

第一个突破平方级时间复杂度的排序算法,实现简单,适合中等规模数据;性能优于直接插入排序,但略逊于归并、快速排序。由于性能不如快速排序等O(nlogn)算法,工业界直接使用较少,主要作为教学理解排序优化思路的案例。


五、归并排序(分治类)

1. 核心思想

典型的分治思想实现:将数组不断二分拆分为子序列,直到每个子序列只有1个元素(天然有序);再将两个有序子序列两两合并,最终得到整体有序的数组。

2. 执行步骤

拆分阶段(递归)
  1. 将数组从中间位置拆分为左、右两个子数组
  2. 对左、右子数组分别递归执行拆分
  3. 直到子数组长度为1,拆分结束
合并阶段
  1. 申请临时数组,大小等于两个有序子数组的长度之和
  2. 双指针分别指向两个子数组的起始位置
  3. 比较两个指针指向的元素,将较小的放入临时数组,对应指针后移
  4. 直到其中一个子数组遍历完毕,将另一个数组的剩余元素直接拷贝到临时数组
  5. 将临时数组的结果拷贝回原数组的对应位置

3. 复杂度推导

  • 时间复杂度:递归树深度为logn,每一层合并的总操作量为n,因此总时间复杂度为O(nlogn)
  • 空间复杂度:需要长度为n的临时数组存储合并结果,同时递归调用栈深度为logn,整体空间复杂度O(n)

4. 性能指标

  • 时间复杂度:最好/最坏/平均均为O(nlogn),性能不受数据有序性影响,极其稳定
  • 空间复杂度:O(n),需要额外临时数组存储合并结果,非原地排序
  • 稳定性:稳定排序

5. Java代码实现(递归版)

java 复制代码
public class MergeSort {
    public static void mergeSort(int[] arr) {
        if (arr == null || arr.length <= 1) {
            return;
        }
        // 临时数组,全程复用,避免反复申请内存
        int[] temp = new int[arr.length];
        mergeSort(arr, 0, arr.length - 1, temp);
    }

    // 递归拆分与合并
    private static void mergeSort(int[] arr, int left, int right, int[] temp) {
        if (left >= right) {
            return;
        }
        int mid = left + (right - left) / 2; // 中间位置,避免整数溢出
        // 递归拆分左半部分
        mergeSort(arr, left, mid, temp);
        // 递归拆分右半部分
        mergeSort(arr, mid + 1, right, temp);
        // 合并两个有序子数组
        merge(arr, left, mid, right, temp);
    }

    // 合并两个有序区间 [left, mid] 和 [mid+1, right]
    private static void merge(int[] arr, int left, int mid, int right, int[] temp) {
        int i = left;       // 左区间指针
        int j = mid + 1;    // 右区间指针
        int k = 0;          // 临时数组指针

        // 双指针比较,按顺序放入临时数组
        while (i <= mid && j <= right) {
            if (arr[i] <= arr[j]) {
                temp[k++] = arr[i++];
            } else {
                temp[k++] = arr[j++];
            }
        }
        // 处理左区间剩余元素
        while (i <= mid) {
            temp[k++] = arr[i++];
        }
        // 处理右区间剩余元素
        while (j <= right) {
            temp[k++] = arr[j++];
        }
        // 将临时数组的结果拷贝回原数组
        k = 0;
        while (left <= right) {
            arr[left++] = temp[k++];
        }
    }
}

6. 扩展:迭代版归并排序

递归版依赖调用栈,数据量极大时存在栈溢出风险,迭代版从底向上逐层合并,避免了递归开销:

java 复制代码
public static void mergeSortIterative(int[] arr) {
    if (arr == null || arr.length <= 1) return;
    int n = arr.length;
    int[] temp = new int[n];
    
    // 从长度为1的子数组开始,逐步翻倍合并
    for (int gap = 1; gap < n; gap *= 2) {
        for (int left = 0; left < n; left += 2 * gap) {
            int mid = Math.min(left + gap - 1, n - 1);
            int right = Math.min(left + 2 * gap - 1, n - 1);
            merge(arr, left, mid, right, temp);
        }
    }
}

7. 特点与适用场景

性能稳定,无最坏情况退化,适合大规模数据排序;缺点是需要额外内存空间,存在数据拷贝开销,内存敏感场景需谨慎使用。

归并排序是外部排序的核心算法,当数据量远超内存容量、无法全部加载到内存时,可以通过归并排序的思想分批次排序后合并,是海量数据排序的标准方案。


六、快速排序(分治·交换类)

1. 核心思想

分治思想的经典实现,选取一个基准值,通过一轮分区操作将数组拆分为两部分:小于基准的元素全部移到左侧,大于基准的元素全部移到右侧;基准值落到自身的最终正确位置,再对左右两个子区间递归执行排序。

2. 执行步骤

  1. 从数组中选取一个基准元素(pivot),常见选择:首元素、尾元素、随机元素
  2. 分区操作(Partition):
    • 左右双指针分别从数组两端向中间靠拢
    • 左指针找大于基准的元素,右指针找小于基准的元素
    • 交换两个元素,直到左右指针相遇
    • 将基准值放到指针相遇的位置,此时基准左侧全小于它,右侧全大于它
  3. 递归对基准左侧、右侧的子数组执行上述操作
  4. 子数组长度为0或1时,递归终止

3. 性能指标

  • 时间复杂度:平均O(nlogn),最坏O(n²)(数据完全有序且选首尾为基准时退化);采用随机基准后,最坏情况出现概率可忽略
  • 空间复杂度:O(logn),主要为递归调用栈的开销
  • 稳定性:不稳定排序

4. Java代码实现(随机基准优化版)

java 复制代码
import java.util.Random;

public class QuickSort {
    private static final Random random = new Random();

    public static void quickSort(int[] arr) {
        if (arr == null || arr.length <= 1) {
            return;
        }
        quickSort(arr, 0, arr.length - 1);
    }

    private static void quickSort(int[] arr, int left, int right) {
        if (left >= right) {
            return;
        }
        // 分区,获取基准值的最终位置
        int pivotIndex = partition(arr, left, right);
        // 递归排序左半部分
        quickSort(arr, left, pivotIndex - 1);
        // 递归排序右半部分
        quickSort(arr, pivotIndex + 1, right);
    }

    // 分区函数:返回基准元素的最终下标
    private static int partition(int[] arr, int left, int right) {
        // 随机选择基准,避免有序数据下的性能退化
        int pivotIndex = left + random.nextInt(right - left + 1);
        // 将基准元素交换到最左侧
        swap(arr, left, pivotIndex);
        int pivot = arr[left];

        int i = left;
        int j = right;
        while (i < j) {
            // 右指针向左找小于基准的元素
            while (i < j && arr[j] >= pivot) {
                j--;
            }
            // 左指针向右找大于基准的元素
            while (i < j && arr[i] <= pivot) {
                i++;
            }
            // 交换两个元素
            swap(arr, i, j);
        }
        // 基准元素放到最终正确位置
        swap(arr, left, i);
        return i;
    }

    private static void swap(int[] arr, int a, int b) {
        int temp = arr[a];
        arr[a] = arr[b];
        arr[b] = temp;
    }
}

5. 进阶优化方案

工业级快速排序通常会做多层优化,核心优化点包括:

  1. 三数取中法选基准:取区间首、中、尾三个元素的中位数作为基准,避免极端有序数据的退化,比随机基准更稳定
  2. 三路划分:将数组分为小于、等于、大于基准三部分,当数组中存在大量重复元素时,能大幅减少递归区间,性能提升显著
  3. 小数组切换插入排序:当子数组长度小于阈值(通常为10~20)时,改用插入排序,减少递归开销与常数成本
  4. 尾递归优化:对较短的子区间进行递归,较长的子区间用循环处理,降低调用栈深度,避免栈溢出

6. 特点与适用场景

综合性能极强,常数因子极小,平均场景下效率优于同复杂度的归并排序。快速排序是原地排序,对缓存更友好,是工业界最主流的通用排序算法。Java原生的Arrays.sort()底层在基本类型排序时,核心就采用了双轴快速排序的优化版本。


六大排序算法核心指标对比表

排序算法 平均时间复杂度 最坏时间复杂度 最好时间复杂度 空间复杂度 稳定性 原地排序
冒泡排序 O(n²) O(n²) O(n) O(1) 稳定
选择排序 O(n²) O(n²) O(n²) O(1) 不稳定
插入排序 O(n²) O(n²) O(n) O(1) 稳定
希尔排序 O(n^1.3) O(n²) O(n) O(1) 不稳定
归并排序 O(nlogn) O(nlogn) O(nlogn) O(n) 稳定
快速排序 O(nlogn) O(n²) O(nlogn) O(logn) 不稳定

第一期总结与高频面试考点

核心总结

六种排序算法覆盖了从入门到进阶的核心排序思想:

  • 平方级复杂度的冒泡、选择、插入是排序基础,适合入门理解与小规模数据
  • 希尔排序是插入排序的升级,突破了平方级复杂度,是理解排序优化的典型案例
  • 归并与快速排序是分治思想的两大经典实现,是大规模数据排序的主流选择

高频面试问题

  1. 哪些排序是稳定的?哪些不稳定?

    稳定:冒泡、插入、归并;不稳定:选择、希尔、快速。本质上,不发生跨位置交换的排序通常稳定,涉及远距离交换、分组的排序通常不稳定。

  2. 快速排序为什么比归并排序快?

    快速排序是原地排序,无需额外内存拷贝;分区操作的缓存命中率更高,常数因子更小。虽然平均复杂度相同,但实际运行速度快2~3倍。

  3. 快速排序的最坏情况如何避免?

    最坏情况出现在数据完全有序且基准选在首尾时,通过随机选基准、三数取中都可以有效避免;工业级实现还会通过三路划分、切换插入排序等进一步优化。

  4. 排序稳定性有什么实际意义?

    多维度排序场景下,稳定排序可以保留上一次排序的相对顺序,例如先按销量排序、再按价格排序,价格相同的商品依然能保持销量高低的顺序。