排序算法在计算机科学中占据着非常基础且重要的地位。无论是在日常开发,还是在各种算法面试中,排序问题都频繁出现,其中快速排序、归并排序和堆排序更是面试中的重难点和高频考点。掌握这些排序算法不仅是算法学习的基石,更是应对复杂数据结构与问题的有力工具。
要想在算法面试中展现无敌的实力,光靠死记硬背显然不够。系统性地学习和理解这些排序算法,深入了解它们在不同场景中的优势与劣势,才能为自己建立一套坚固的“算法武装”。本文将详细介绍七大常见的排序算法(选择排序,插入排序,快速排序,归并排序,冒泡排序,希尔排序,以及堆排序),并通过对比它们的优劣势,帮助大家培养在不同应用场景下算法/技术选型的意识和能力。这种系统性的学习方法,将让大家在未来的算法面试中更加从容不迫,稳步胜出。
介绍:
选择排序(英语:Selection Sort)是一种原地比较排序算法。它的时间复杂度为O(n^2),这使得它在处理大型列表时效率较低,并且通常比类似的插入排序表现更差。选择排序以其简单性著称,在某些情况下(尤其是辅助内存受限时)相比于更复杂的算法具有性能优势。
选择排序的工作原理如下:首先在未排序序列中找到最小(大)元素,存放到排序序列的起始位置,然后,再从剩余未排序元素中继续寻找最小(大)元素,然后放到已排序序列的末尾。以此类推,直到所有元素均排序完毕。
流程图

代码实现:
Java:
public int[] selectionSort(int[] nums) {
if(nums == null || nums.length == 0) {
return nums;
}
for (int i = 0; i < nums.length - 1; i++) {
int minIndex = i;
for (int j = i + 1; j < nums.length; j++) {
if (nums[j] < nums[minIndex]) {
minIndex = j;
}
}
if (minIndex != i) {
swap(nums, i, minIndex);
}
}
return nums;
}
private void swap(int[] nums, int i, int j) {
int temp = nums[i];
nums[i] = nums[j];
nums[j] = temp;
}性能分析:
时间复杂度
对于一个包含 n 个元素的数组,选择排序需要进行 n - 1 轮比较和交换操作。在每一轮中,需要比较未排序部分的所有元素,即大约 n - i 次比较(其中 i 是当前轮数)。所以总的比较次数为:(n - 1) + (n - 2) +... + 2 + 1 = n (n - 1)/2。在每一轮中,最多进行一次交换操作。总共进行 n - 1 轮,所以交换次数为 n - 1。由于比较次数为n (n - 1)/2,与交换次数 n - 1 相比,比较次数在数量级上占主导地位。n (n - 1)/2而可以近似看作n^2/2,忽略常数系数,时间复杂度为O(n^2)。
空间复杂度
选择排序是一种原地排序算法,即在排序过程中不需要额外的数组或数据结构来存储数据。它只需要几个额外的变量来记录最小元素的索引和进行交换操作时的临时变量。这些额外变量的数量与输入规模 n 无关,不随输入规模的增长而增长。所以选择排序的空间复杂度为O(1)。
稳定性
选择排序是不稳定的排序算法。
稳定性的定义:
在排序算法中,如果两个相等的元素在排序前后的相对顺序保持不变,则称该排序算法是稳定的;否则,称该排序算法是不稳定的。
选择排序不稳定的原因:
在选择排序的过程中,每次从待排序序列中选择最小(或最大)的元素,并将其与当前位置的元素交换。如果待排序序列中有两个相等的元素,且较小的那个元素在后面,当选择到较小的那个元素并与当前位置交换时,就可能会改变这两个相等元素的相对顺序。
例如,有数组[4a, 4b, 1],第一次选择最小元素1与第一个元素4a交换,变为[1, 4b, 4a];这里原本在后面的4b在排序后跑到了前面的4a前面,破坏了相等元素的相对顺序,所以选择排序是不稳定的。
插入排序的工作原理是通过构建有序序列,对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入。
2.流程图

public int[] insertionSort(int[] nums) {
if (nums == null || nums.length == 0) {
return nums;
}
for (int i = 1; i < nums.length; i++) {
int current = nums[i];
int j = i - 1;
while (j >= 0 && current < nums[j]) {
nums[j + 1] = nums[j];
j--;
}
nums[j + 1] = current;
}
return nums;
}1.最坏情况:当输入数组是逆序排列时,每次插入一个元素都需要与已排序部分的所有元素进行比较和移动,所以比较和移动的次数最多为 1 + 2 + 3 + ... + (n - 1),即n(n - 1)/2,时间复杂度为O(n^2)。
2. 最好情况:当输入数组已经是有序的时,每个元素只需要与已排序部分的最后一个元素比较一次,不需要进行移动操作,时间复杂度为O(n)。
空间复杂度
插入排序的空间复杂度为O(1),因为它只需要常数级别的额外空间。
在排序过程中,只需要一个额外的变量来存储当前要插入的元素,不需要像一些其他排序算法那样需要额外的数组或数据结构来存储中间结果。
稳定性
插入排序是稳定的排序算法。
稳定性的定义
在排序算法中,如果两个相等的元素在排序前后的相对顺序保持不变,则称该排序算法是稳定的;否则,称该排序算法是不稳定的。
插入排序稳定的原因
插入排序在比较和移动元素的过程中,当遇到相等的元素时,不会交换它们的位置,而是保持不动。这样就保证了相等元素在排序前后的相对顺序不会改变。
例如,有数组[3a, 2, 3b, 1],在插入排序过程中,首先2和3a比较,将2插入到合适位置得到[2, 3a, 3b, 1];接着处理3b,由于3b和3a相等,发现已经在合适位置,所以保持不动,得到[2, 3a, 3b, 1];最后处理1,将其插入到合适位置得到[1, 2, 3a, 3b]。这里原本在前面的3a和后面的3b在排序后相对顺序没有改变,所以插入排序是稳定的。
介绍:
快速排序(英语: Quick Sort)是一种高效的通用排序算法。它由英国计算机科学家Tony Hoare于1959年开发,并于1961年发表。至今,快速排序仍然是一种常用的排序算法。总体上,对于随机化数据,特别是较大的数据集,快速排序比归并排序和堆排序稍快一些。
快速排序是一种分治算法。它的工作原理如下:
1.选择基准值:首先从待排序的数组中选择一个元素作为基准值(pivot)。通常可以选择第一个元素、最后一个元素或者随机选择一个元素作为基准值。
2.分区操作:对数组进行分区(partition),使得比基准值小的元素都在基准值的左边,比基准值大的元素都在基准值的右边。经过分区操作后,基准值将数组分为了两部分,左边部分的元素都小于等于基准值,右边部分的元素都大于等于基准值。
3.递归排序:对分区后的左右两部分子数组分别重复上述步骤,即选择基准值、进行分区操作、递归排序,直到子数组的长度为 1 或 0,此时子数组已经有序,则整个数组有序,排序完成。
流程图

代码实现:
Java中:
public int[] quickSort(int[] array) {
if (array == null || array.length == 0) {
return array;
}
quickSortRecursive(array, 0, array.length - 1);
return array;
}
private void quickSortRecursive(int[] array, int left, int right) {
if (left >= right) {
return;
}
int pivotIndex = partition(array, left, right);
quickSortRecursive(array, left, pivotIndex - 1);
quickSortRecursive(array, pivotIndex + 1, right);
}
private int partition(int[] array, int left, int right) {
int pivot = array[right];
int i = left;
int j = right - 1;
while (i <= j) {
if (array[i] <= pivot) {
i++;
} else {
swap(array, i, j);
j--;
}
}
swap(array, i, right);
return i;
}
private void swap(int[] nums, int i, int j) {
int temp = nums[i];
nums[i] = nums[j];
nums[j] = temp;
}空间复杂度
稳定性
快速排序是不稳定的排序算法。因为在分区过程中,如果有两个相等的元素,它们的相对顺序可能会因为分区而改变。
介绍:
归并排序(英语:Merge Sort)采用分治(Divide and Conquer)策略。首先将待排序的数组不断地分成两半,直到每个子数组只包含一个元素或者为空。这个过程被称为 “分”。然后,将两个已排序的子数组合并成一个更大的有序数组。这个过程被称为 “治” 或 “归并”。
流程图

代码实现:
Java中:
public int[] mergeSort(int[] array) {
if (array == null || array.length == 0) {
return array;
}
int[] helper = new int[array.length];
mergeSort(array, helper, 0, array.length - 1);
return array;
}
private void mergeSort(int[] array, int[] helper, int left, int right) {
if (left == right) {
return;
}
int mid = left + (right - left) / 2;
mergeSort(array, helper, left, mid);
mergeSort(array, helper, mid + 1, right);
merge(array, helper, left, mid, right);
}
private void merge(int[] array, int[] helper, int left, int mid, int right) {
for (int i = left; i <= right; i++) {
helper[i] = array[i];
}
int i = left;
int j = mid + 1;
int index = left;
while (i <= mid && j <= right) {
if (helper[i] <= helper[j]) {
array[index] = helper[i];
i++;
} else {
array[index] = helper[j];
j++;
}
index++;
}
while (i <= mid) {
array[index] = helper[i];
i++;
index++;
}
while (j <= right) {
array[index] = helper[j];
j++;
index++;
}
}空间复杂度
归并排序的空间复杂度为O(n)。
稳定性
归并排序是稳定的排序算法。在归并过程中,当比较两个相等的元素时,会先将左边子数组中的元素放入临时数组,这样就保证了相等元素的相对顺序不会改变。
介绍:
冒泡排序(英文:Bubble Sort)是一种简单的排序算法,它通过反复遍历输入列表,逐个比较当前元素与其后面的元素,如果需要则交换它们的值。这种遍历会一直重复,直到在一次遍历中不再需要进行任何交换,这意味着列表已经完全排序。该算法是一种比较排序,之所以得名,是因为较大的元素在排序过程中会像气泡一样“冒”到列表的顶端。
流程图

代码实现:
Java中:
public int[] bubbleSort(int[] array) {
if (array == null || array.length == 0) {
return array;
}
int n = array.length;
for (int i = 0; i < n; i++) {
for (int j = 0; j < n - i - 1; j++) {
if (array[j] > array[j + 1]) {
swap(array, j, j + 1);
}
}
}
return array;
}
private void swap(int[] nums, int i, int j) {
int temp = nums[i];
nums[i] = nums[j];
nums[j] = temp;
}优化版本:
优化策略:冒泡排序通过每轮比较未排序部分,确定一个元素的最终排序位置。其基本原理是通过比较相邻元素,将较大的元素逐步交换至右侧。如果在某一轮排序中未发生任何交换,则说明相邻元素的比较过程中,较大的元素已位于右侧,无需进一步调整,表明数组已然有序,此时排序过程可以终止。
public int[] bubbleSort(int[] array) {
if (array == null || array.length == 0) {
return array;
}
int n = array.length;
for (int i = 0; i < n; i++) {
boolean swapped = false;
for (int j = 0; j < n - i - 1; j++) {
if (array[j] > array[j + 1]) {
swap(array, j, j + 1);
swapped = true;
}
}
if (!swapped) {
break;
}
}
return array;
}
private void swap(int[] nums, int i, int j) {
int temp = nums[i];
nums[i] = nums[j];
nums[j] = temp;
}空间复杂度
稳定性
介绍:
希尔排序(英语:Shellsort),是一种原地比较排序算法。它可以看作是交换排序(如冒泡排序)或插入排序的一种推广。该方法通过首先排序相隔较远的元素对,然后逐渐减少比较元素之间的间隔。通过从相隔较远的元素开始,希尔排序能够比简单的相邻交换更快地将一些错位的元素移动到正确的位置。Donald Shell 于1959年首次发表了这种排序算法。希尔排序的运行时间高度依赖于所使用的间隔序列,对于许多实际变体而言,确定其时间复杂度仍然是一个未解的问题。
其基本原理如下:
一、分组与间隔
1.希尔排序首先将待排序的数组分割成若干个子序列,每个子序列的元素间隔为一个特定的 “增量”(increment)。
2.初始时,增量通常取数组长度的一半,然后逐渐减小增量,直到增量为 1。
二、子序列排序
1.对每个子序列分别进行插入排序。
流程图

代码实现:
Java中:
public int[] shellSort(int[] array) {
if (array == null || array.length == 0) {
return array;
}
int n = array.length;
for(int gap = n / 2; gap > 0; gap /= 2){
for(int i = gap; i < n; i++){
int j = i;
int temp = array[i];
while (j - gap >= 0 && array[j - gap] > temp){
array[j] = array[j - gap];
j -= gap;
}
array[j] = temp;
}
}
return array;
}流程图

代码
Java代码:
public int[] heapSort(int[] array) {
if (array == null || array.length == 0) {
return array;
}
int n = array.length;
// 建一个大顶堆
for (int i = n / 2 - 1; i >= 0; i--) {
heapify(array, i, n);
}
// 将未排序部分的最大数交换至已排序部分,再进行一次堆化以保持堆的结构
for (int i = 0; i < n - 1; i++) {
swap(array, 0, n - 1 - i);
heapify(array, 0, n - 1 - i);
}
return array;
}
private void heapify(int[] array, int i, int length) {
int left = 2 * i + 1;
int right = 2 * i + 2;
int largest = i;
if (left < length && array[left] > array[largest]) {
largest = left;
}
if (right < length && array[right] > array[largest]) {
largest = right;
}
if (largest != i) {
swap(array, i, largest);
heapify(array, largest, length);
}
}
private void swap(int[] nums, int i, int j) {
int temp = nums[i];
nums[i] = nums[j];
nums[j] = temp;
}