【算法日记】排序算法:原理、实现、性能与应用

文章目录

一、排序的概念

1. 稳定性

稳定性是排序算法的重要特性:

  • 稳定排序:若待排序序列中存在多个关键字相同的记录,排序后它们的相对次序保持不变。例如原序列中5a5b之前,排序后5a还在5b之前。
  • 不稳定排序:排序后关键字相同的记录相对次序被打乱。

稳定性的实际意义:当需要按多个关键字排序时(比如先按成绩排序,再按姓名排序),稳定排序能保证后续排序不破坏之前的排序结果。

2. 内部排序与外部排序

  • 内部排序:所有数据元素都能放入内存,排序过程在内存中完成(本文重点讲解的七大算法均为内部排序)。
  • 外部排序:数据量过大,无法全部放入内存,需借助磁盘等外部存储设备,排序过程中需在内外存间移动数据。归并排序常用于外部排序。

二、基于比较的排序算法详解

基于比较的排序算法通过比较元素间的关键字大小来确定次序,包括插入排序、选择排序、交换排序、归并排序四大类,共七种算法。

(一)插入排序

插入排序的核心思想是"将待排序元素逐个插入到已有序的序列中",就像玩扑克牌时整理手牌的过程。

1. 直接插入排序

  • 基本思想

    当插入第ii≥1)个元素时,前面的array[0]~array[i-1]已有序。将array[i]与前面的元素从后往前逐一比较,找到合适的插入位置,将该位置及后续元素后移,再插入array[i]

  • 示例过程

    待排序序列:[3, 44, 38, 5, 47, 15, 36, 26, 27, 2, 46, 4, 19, 50, 48]

    • 插入第1个元素(44):前面只有3,44>3,直接插入,序列变为[3,44,38,...]
    • 插入第2个元素(38):与44比较,38<44,44后移;再与3比较,38>3,插入3和44之间,序列变为[3,38,44,...]
    • 依次类推,直到所有元素插入完毕。
  • 代码实现

java 复制代码
public static void insertSort(int[] array) {
    for (int i = 1; i < array.length; i++) {
        int temp = array[i]; // 待插入元素
        int j = i - 1;
        // 从后往前找插入位置
        while (j >= 0 && array[j] > temp) {
            array[j + 1] = array[j]; // 元素后移
            j--;
        }
        array[j + 1] = temp; // 插入待排序元素
    }
}
  • 特性总结
    • 时间复杂度:O(N²)(最坏和平均情况);最好情况O(N)(序列已有序,只需遍历无需移动)。
    • 空间复杂度:O(1)(原地排序,仅需临时变量)。
    • 稳定性:稳定。
    • 适用场景:元素集合接近有序或数据量较小时(N≤1000),效率较高。

2. 希尔排序(缩小增量排序)

希尔排序是直接插入排序的优化版本,解决了直接插入排序在序列无序时移动元素过多的问题。

  • 基本思想

    1. 选定一个增量gap(初始值通常为数组长度的一半),将数组按gap分成多个组,每组包含距离为gap的元素。
    2. 对每组分别进行直接插入排序,使数组初步有序。
    3. 缩小gap(如gap = gap/2gap = gap/3 + 1),重复分组和排序步骤。
    4. gap=1时,数组已接近有序,此时进行最后一次直接插入排序,完成最终排序。
  • 示例过程

    初始序列:[9,1,2,5,7,4,8,6,3,5]

    • gap=5:分成5组[9,4]、[1,8]、[2,6]、[5,3]、[7,5],每组排序后得到[4,1,2,3,5,9,8,6,5,7]
    • gap=2:分成2组[4,2,5,8,6,7]、[1,3,9,5],每组排序后得到[2,1,4,3,5,5,6,7,8,9]
    • gap=1:进行直接插入排序,最终得到[1,2,3,4,5,5,6,7,8,9]
  • 代码实现(Java,Knuth增量)

java 复制代码
public static void shellSort(int[] array) {
    int gap = array.length;
    // 增量序列:gap = gap/3 + 1
    while (gap > 1) {
        gap = gap / 3 + 1;
        for (int i = gap; i < array.length; i++) {
            int temp = array[i];
            int j = i - gap;
            while (j >= 0 && array[j] > temp) {
                array[j + gap] = array[j];
                j -= gap;
            }
            array[j + gap] = temp;
        }
    }
}
  • 特性总结
    • 时间复杂度:难以精确计算,与增量序列相关。常用的Knuth增量下,时间复杂度约为O(N1.25)~O(1.6*N1.25)。
    • 空间复杂度:O(1)(原地排序)。
    • 稳定性:不稳定(分组排序时可能打乱相同关键字的相对次序)。
    • 适用场景:数据量较大且无序的场景,效率远高于直接插入排序。

(二)选择排序:每次选最值,逐步定位

选择排序的核心思想是"每次从待排序序列中选出最值元素,放到序列的指定位置"。

1. 直接选择排序

  • 基本思想

    1. array[i]~array[n-1]中找到关键字最小(或最大)的元素。
    2. 若该元素不是当前区间的第一个元素,将其与区间第一个元素交换。
    3. 缩小待排序区间(i++),重复上述步骤,直到区间只剩1个元素。
  • 示例过程

    待排序序列:[3, 44, 38, 5, 47, 15, 36, 26, 27, 2, 46, 4, 19, 50, 48]

    • 第一次选择:找到最小值2,与第一个元素3交换,序列变为[2, 44, 38, 5, 47, 15, 36, 26, 27, 3, 46, 4, 19, 50, 48]
    • 第二次选择:从剩余元素中找到最小值3,与第二个元素44交换,序列变为[2, 3, 38, 5, 47, 15, 36, 26, 27, 44, 46, 4, 19, 50, 48]
    • 依次类推,直到排序完成。
  • 代码实现(Java)

java 复制代码
public static void selectSort(int[] array) {
    for (int i = 0; i < array.length - 1; i++) {
        int minIndex = i; // 记录最小值索引
        // 找待排序区间的最小值
        for (int j = i + 1; j < array.length; j++) {
            if (array[j] < array[minIndex]) {
                minIndex = j;
            }
        }
        // 交换最小值与当前区间第一个元素
        if (minIndex != i) {
            int temp = array[i];
            array[i] = array[minIndex];
            array[minIndex] = temp;
        }
    }
}
  • 特性总结
    • 时间复杂度:O(N²)(最坏、平均、最好情况均为O(N²),需遍历所有元素找最值)。
    • 空间复杂度:O(1)(原地排序)。
    • 稳定性:不稳定(如序列[2, 2, 1],第一次选择1与第一个2交换,导致两个2的相对次序改变)。
    • 适用场景:简单易实现,但效率较低,仅适用于数据量极小的场景。

2. 堆排序

堆排序是选择排序的优化版本,利用堆(完全二叉树)的数据结构快速查找和获取最值元素,大幅提升效率。

  • 核心前提

    • 堆的定义:大顶堆(父节点值≥子节点值)、小顶堆(父节点值≤子节点值)。
    • 排序规则:排升序用大顶堆(每次提取堆顶最大值放到序列末尾);排降序用小顶堆(每次提取堆顶最小值放到序列末尾)。
  • 基本思想

    1. 将待排序数组构建成大顶堆。
    2. 交换堆顶元素(最大值)与堆尾元素,将最大值固定在序列末尾。
    3. 缩小堆的范围(排除已固定的最大值),对剩余元素重新调整为大顶堆。
    4. 重复步骤2和3,直到堆的范围缩小为1,排序完成。
  • 代码实现(Java,升序)

java 复制代码
public static void heapSort(int[] array) {
    // 1. 构建大顶堆(从最后一个非叶子节点开始调整)
    for (int i = array.length / 2 - 1; i >= 0; i--) {
        adjustHeap(array, i, array.length);
    }
    // 2. 逐步提取最大值并调整堆
    for (int i = array.length - 1; i > 0; i--) {
        // 交换堆顶与堆尾
        int temp = array[0];
        array[0] = array[i];
        array[i] = temp;
        // 调整剩余元素为大顶堆
        adjustHeap(array, 0, i);
    }
}

// 调整堆结构(大顶堆)
private static void adjustHeap(int[] array, int parent, int heapSize) {
    int temp = array[parent]; // 保存父节点
    int child = 2 * parent + 1; // 左子节点索引
    while (child < heapSize) {
        // 找到左右子节点中的最大值
        if (child + 1 < heapSize && array[child + 1] > array[child]) {
            child++;
        }
        // 若父节点≥子节点,调整结束
        if (temp >= array[child]) {
            break;
        }
        // 子节点值赋给父节点
        array[parent] = array[child];
        // 继续向下调整
        parent = child;
        child = 2 * parent + 1;
    }
    array[parent] = temp;
}
  • 特性总结
    • 时间复杂度:O(N*logN)(构建堆O(N),每次调整堆O(logN),共N-1次调整)。
    • 空间复杂度:O(1)(原地排序,仅需临时变量)。
    • 稳定性:不稳定(调整堆时可能打乱相同关键字的相对次序)。
    • 适用场景:数据量较大且对空间要求严格的场景,效率高于直接选择排序和冒泡排序。

(三)交换排序:通过交换调整次序

交换排序的核心思想是"比较两个元素的关键字,若次序错误则交换它们的位置",逐步将关键字大的元素移到序列尾部。

1. 冒泡排序

冒泡排序是最直观的排序算法,通过相邻元素的反复比较和交换,让"大元素"像气泡一样浮到序列末尾。

  • 基本思想

    1. 从序列头部开始,依次比较相邻的两个元素,若前一个元素>后一个元素,则交换它们。
    2. 一轮遍历后,最大的元素会被移到序列末尾。
    3. 缩小排序范围(排除已固定的最大元素),重复上述步骤,直到没有元素需要交换。
  • 优化点 :添加标志位flag,若某一轮遍历中没有发生交换,说明序列已有序,直接退出循环。

  • 代码实现(Java,优化版)

java 复制代码
public static void bubbleSort(int[] array) {
    boolean flag = false; // 标记是否发生交换
    for (int i = 0; i < array.length - 1; i++) {
        flag = false;
        for (int j = 0; j < array.length - 1 - i; j++) {
            if (array[j] > array[j + 1]) {
                // 交换相邻元素
                int temp = array[j];
                array[j] = array[j + 1];
                array[j + 1] = temp;
                flag = true;
            }
        }
        if (!flag) {
            break; // 无交换,序列已有序
        }
    }
}
  • 特性总结
    • 时间复杂度:O(N²)(最坏、平均情况);最好情况O(N)(序列已有序,一轮遍历无交换)。
    • 空间复杂度:O(1)(原地排序)。
    • 稳定性:稳定(仅相邻元素交换,不改变相同关键字的相对次序)。
    • 适用场景:简单易理解,适用于数据量小或接近有序的场景。

2. 快速排序

快速排序是由Hoare于1962年提出的排序算法,基于分治法思想,是实际应用中综合性能最优的排序算法之一。

  • 基本思想

    1. 选基准值:从待排序序列中任取一个元素作为基准值(pivot)。
    2. 分区:将序列分割为两部分,左部分元素均≤基准值,右部分元素均≥基准值,基准值位于最终排序位置。
    3. 递归排序:对左右两部分分别重复上述步骤,直到所有子序列长度≤1(天然有序)。
  • 核心步骤:分区实现

    快速排序的效率关键在于分区方式,常用的有三种:

    (1)Hoare版(左右指针法)
    • 思路:左指针从左向右找大于基准值的元素,右指针从右向左找小于基准值的元素,交换两元素;重复直到左右指针相遇,最后交换基准值与相遇位置元素。
java 复制代码
  private static int partitionHoare(int[] array, int left, int right) {
      int pivot = array[left]; // 基准值(取左端点)
      int i = left;
      int j = right;
      while (i < j) {
          // 右指针找小于基准值的元素
          while (i < j && array[j] >= pivot) {
              j--;
          }
          // 左指针找大于基准值的元素
          while (i < j && array[i] <= pivot) {
              i++;
          }
          // 交换两元素
          if (i < j) {
              int temp = array[i];
              array[i] = array[j];
              array[j] = temp;
          }
      }
      // 交换基准值与相遇位置元素
      array[left] = array[i];
      array[i] = pivot;
      return i; // 返回基准值最终位置
  }
(2)挖坑法
  • 思路:将基准值存为临时变量,形成"坑位";右指针找小于基准值的元素,填入坑位并形成新坑位;左指针找大于基准值的元素,填入新坑位并形成新坑位;重复直到左右指针相遇,将基准值填入最后一个坑位。
java 复制代码
  private static int partitionPit(int[] array, int left, int right) {
      int pivot = array[left]; // 基准值(坑位初始在left)
      int i = left;
      int j = right;
      while (i < j) {
          // 右指针找小于基准值的元素,填入坑位
          while (i < j && array[j] >= pivot) {
              j--;
          }
          array[i] = array[j]; // 新坑位在j
          // 左指针找大于基准值的元素,填入坑位
          while (i < j && array[i] <= pivot) {
              i++;
          }
          array[j] = array[i]; // 新坑位在i
      }
      array[i] = pivot; // 基准值填入最后一个坑位
      return i;
  }
(3)前后指针法
  • 思路:prev指针指向已处理区间的末尾,cur指针遍历序列;若cur指向的元素小于基准值,prev后移并交换prev与cur指向的元素;遍历结束后,交换基准值与prev指向的元素。
java 复制代码
  private static int partitionPrevCur(int[] array, int left, int right) {
      int pivot = array[left];
      int prev = left;
      int cur = left + 1;
      while (cur <= right) {
          // 找到小于基准值的元素,交换到prev后
          if (array[cur] < pivot && array[++prev] != array[cur]) {
              int temp = array[prev];
              array[prev] = array[cur];
              array[cur] = temp;
          }
          cur++;
      }
      // 交换基准值与prev指向的元素
      array[left] = array[prev];
      array[prev] = pivot;
      return prev;
  }
  • 快速排序主框架(递归版)
java 复制代码
public static void quickSort(int[] array, int left, int right) {
    if (right - left <= 1) {
        return; // 子序列长度≤1,直接返回
    }
    // 分区(三种方式任选其一)
    int pivotIndex = partitionPrevCur(array, left, right);
    // 递归排序左子序列
    quickSort(array, left, pivotIndex - 1);
    // 递归排序右子序列
    quickSort(array, pivotIndex + 1, right);
}
  • 优化策略

    1. 三数取中法选基准值:避免因序列有序或逆序导致基准值选到最值,使时间复杂度退化为O(N²)。(取左、中、右三个位置的元素,选中间值作为基准值)
    2. 小区间用插入排序:当子序列长度小于阈值(如5~10),递归排序的开销大于插入排序,改用插入排序提升效率。
    3. 非递归实现:用栈模拟递归过程,避免递归深度过深导致栈溢出。
  • 非递归实现(Java)

java 复制代码
public static void quickSortNonRecursive(int[] array, int left, int right) {
    Stack<Integer> stack = new Stack<>();
    stack.push(left);
    stack.push(right);
    while (!stack.isEmpty()) {
        int r = stack.pop();
        int l = stack.pop();
        if (r - l <= 1) {
            continue;
        }
        int pivotIndex = partitionPrevCur(array, l, r);
        // 压入右子序列区间
        stack.push(pivotIndex + 1);
        stack.push(r);
        // 压入左子序列区间
        stack.push(l);
        stack.push(pivotIndex - 1);
    }
}
  • 特性总结
    • 时间复杂度:O(NlogN)(平均情况);最坏情况O(N²)(基准值选到最值,如有序序列);最好情况O(NlogN)。
    • 空间复杂度:O(logN)~O(N)(递归栈空间,优化后可控制在O(logN))。
    • 稳定性:不稳定(分区交换时可能打乱相同关键字的相对次序)。
    • 适用场景:数据量较大、对排序效率要求高的场景,是实际开发中的首选排序算法之一。

(四)归并排序:分而治之,合并有序

归并排序是基于分治法的典型应用,核心思想是"先分后合",通过合并两个有序子序列来构建完整的有序序列。

  • 基本思想

    1. 分解:将待排序数组递归拆分为两个长度大致相等的子数组,直到每个子数组长度为1(天然有序)。
    2. 合并:将两个有序子数组合并为一个有序数组,重复合并过程,直到得到完整的有序数组。
  • 示例过程

    待排序序列:[3, 44, 38, 5, 47, 15, 36, 26, 27, 2, 46, 4, 19, 50, 48]

    • 分解:[3,44,38,5,47,15,36,26][27,2,46,4,19,50,48] → 继续分解直到子数组长度为1。
    • 合并:[3,44][5,38][15,47] 等 → 合并为 [3,5,38,44][15,26,36,47] 等 → 最终合并为完整有序序列。
  • 代码实现(Java,递归版)

java 复制代码
public static void mergeSort(int[] array) {
    if (array.length <= 1) {
        return;
    }
    int mid = array.length / 2;
    // 拆分左、右子数组
    int[] left = Arrays.copyOfRange(array, 0, mid);
    int[] right = Arrays.copyOfRange(array, mid, array.length);
    // 递归排序左、右子数组
    mergeSort(left);
    mergeSort(right);
    // 合并两个有序子数组
    merge(array, left, right);
}

// 合并两个有序子数组到原数组
private static void merge(int[] result, int[] left, int[] right) {
    int i = 0, j = 0, k = 0;
    // 比较左、右子数组元素,按序放入结果数组
    while (i < left.length && j < right.length) {
        if (left[i] <= right[j]) {
            result[k++] = left[i++];
        } else {
            result[k++] = right[j++];
        }
    }
    // 放入左子数组剩余元素
    while (i < left.length) {
        result[k++] = left[i++];
    }
    // 放入右子数组剩余元素
    while (j < right.length) {
        result[k++] = right[j++];
    }
}
  • 特性总结
    • 时间复杂度:O(NlogN)(最坏、平均、最好情况均为O(NlogN),分解和合并过程的时间复杂度固定)。
    • 空间复杂度:O(N)(需额外空间存储拆分后的子数组和合并结果)。
    • 稳定性:稳定(合并时按顺序比较,相同关键字的元素保持原有次序)。
    • 适用场景:数据量较大、要求稳定排序的场景,尤其适合外部排序(如海量数据排序)。

三、海量数据排序:归并排序的实际应用

当数据量远超内存(如内存1G,数据100G)时,需使用外部排序,归并排序是外部排序的首选算法,具体步骤如下:

  1. 分割文件:将100G数据按内存大小分割为200个512M的小文件。
  2. 内部排序:对每个512M的小文件进行内部排序(如快速排序、堆排序),得到200个有序小文件。
  3. 多路归并:利用归并排序的合并思想,同时读取200个有序小文件的元素,按序合并到输出文件,最终得到100G的有序数据。

四、七大排序算法性能对比与选择

1. 性能与稳定性汇总表

排序算法 最好时间复杂度 平均时间复杂度 最坏时间复杂度 空间复杂度 稳定性
冒泡排序 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) O(N^1.3) O(N²) O(1) 不稳定
堆排序 O(N*logN) O(N*logN) O(N*logN) O(1) 不稳定
快速排序 O(N*logN) O(N*logN) O(N²) O(logN)~O(N) 不稳定
归并排序 O(N*logN) O(N*logN) O(N*logN) O(N) 稳定

2. 算法选择策略

  • 数据量小(N≤1000):优先选择直接插入排序或冒泡排序(简单易实现,效率足够)。
  • 数据量中大型(N≥1000):优先选择快速排序(综合效率最优)或堆排序(空间开销小)。
  • 要求稳定排序:选择归并排序或冒泡排序、直接插入排序(小数据量)。
  • 序列接近有序:选择直接插入排序或冒泡排序(最好情况O(N))。
  • 海量数据(外部排序):选择归并排序。
  • 对空间敏感:选择堆排序、希尔排序或直接选择排序(空间复杂度O(1))。

五、Java中的常用排序方法

Java提供了内置的排序工具类java.util.Arraysjava.util.Collections,底层实现结合了多种排序算法,性能优异。

1. 数组排序:Arrays.sort()

  • 针对基本类型数组(int[]、long[]等):底层使用双轴快速排序(Dual-Pivot QuickSort),是快速排序的优化版本,效率更高。

  • 针对对象数组(String[]、自定义对象数组等):底层使用归并排序(稳定排序),保证对象的相对次序不被打乱。

  • 使用示例

java 复制代码
// 基本类型数组排序
int[] arr1 = {3, 1, 4, 1, 5, 9};
Arrays.sort(arr1); // 结果:[1,1,3,4,5,9]

// 对象数组排序(需实现Comparable接口或传入Comparator)
String[] arr2 = {"apple", "banana", "cherry", "date"};
Arrays.sort(arr2); // 按字典序排序:[apple, banana, cherry, date]

// 自定义对象排序
Person[] people = {new Person("Alice", 25), new Person("Bob", 20)};
Arrays.sort(people, Comparator.comparingInt(Person::getAge)); // 按年龄排序

2. 集合排序:Collections.sort()

  • 用于排序实现了List接口的集合(如ArrayList、LinkedList)。

  • 底层调用Arrays.sort(),对于对象集合同样使用稳定排序。

  • 使用示例

java 复制代码
List<Integer> list = new ArrayList<>(Arrays.asList(5, 2, 8, 1));
Collections.sort(list); // 结果:[1,2,5,8]

// 自定义排序规则
List<Person> personList = new ArrayList<>();
personList.add(new Person("Alice", 25));
personList.add(new Person("Bob", 20));
Collections.sort(personList, (p1, p2) -> p2.getAge() - p1.getAge()); // 按年龄降序

3. 注意事项

  • Arrays.sort()Collections.sort()均为稳定排序(对象类型)。
  • 对于自定义对象,需通过实现Comparable接口或传入Comparator来指定排序规则。
  • 底层算法会根据数据规模自动优化,无需手动选择排序算法,直接使用即可满足绝大多数场景。
相关推荐
啊阿狸不会拉杆2 小时前
《数字图像处理》第 5 章-图像复原与重建
图像处理·人工智能·算法·matlab·数字图像处理
断剑zou天涯2 小时前
【算法笔记】资源限制类题目的解题套路
笔记·算法·哈希算法
zz0723202 小时前
数据结构 —— 字典树
数据结构
元亓亓亓2 小时前
LeetCode热题100--763. 划分字母区间--中等
算法·leetcode·职场和发展
鹿角片ljp2 小时前
力扣206.反转链表-双指针法(推荐)
算法·leetcode·链表
智航GIS2 小时前
ArcGIS大师之路500技---037普通克里金VS泛克里金
人工智能·算法·arcgis
晨晖22 小时前
循环队列:出队
算法
LYFlied3 小时前
【每日算法】LeetCode 70. 爬楼梯:从递归到动态规划的思维演进
算法·leetcode·面试·职场和发展·动态规划
最晚的py3 小时前
聚类的评估方法
人工智能·算法·机器学习