简介
排序算法有三个重要的指标:
-
时间/空间复杂度
在前面的文章中,虽然经常强调空间换时间能解决大多数问题。但如果时间与空间都比较小,自然是更好的选择。 -
排序稳定性
相同的元素,如果排序之后相对位置没有发生改变
,那可以被称为稳定排序,否则就是不稳定排序。
比如说两个相同的数,排序前它们的顺序是 a 在前,b 在后,排序后如果 a 仍然在 b 前面,那么这个排序算法就是稳定的Date OrderId
2025-03-01 1
2025-03-01 2
2025-03-02 1
2025-03-02 1
2025-03-03 1
2025-03-03 2
//根据OrderId降序排序后,依旧根据Data有序排序,这被称为稳定排序。
2025-03-01 2
2025-03-01 1
2025-03-02 2
2025-03-02 1
2025-03-03 2
2025-03-03 1
... -
是否原地排序
是否需要额外的空间来辅助排序的过程,比如temp等。对于大数据量的排序来说,原地排序肯定是更优解。
万物的起源:选择排序(SelectionSort)
选择排序是最简单的算法,属于最笨的办法
。且不是稳定排序,所以其它排序算法都是基于选择排序的优化
。
public class SelectionSort
{
public static void Run()
{
var arr = new int[] { 5, 7, 1, 8, 6,2,-1,100,15 ,-2};
new SelectionSort().Sort(arr);
foreach (var item in arr)
{
Console.WriteLine(item);
}
}
/// <summary>
/// 时间复杂度O(n^2)
/// 空间复杂度O(1)
/// </summary>
/// <param name="arr"></param>
public void Sort(int[] arr)
{
//从一个位置开始遍历,找到最小的,然后再找第二小,第三小。
for (int i = 0; i < arr.Length; i++)
{
for (int j = i+1; j< arr.Length; j++)
{
if (arr[i] > arr[j])
{
//swap
var temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
}
}
}
}
进一步优化:冒泡排序(BubbleSort)
上面的选择排序有很多问题:
- 排序不稳定
每次都是拿最小元素与当前元素对比,这样可能会改变元素位置 - 时间复杂度高
由于嵌套两层for循环,无法突破O(n^2)的限制 - 有序初素不敏感
如果初始元素本身就是有序,复杂度依旧是O(n^2),并没有提前终止。
优化第一步,让排序变稳定
var arr = new int[] {2,2,2,1,3};
[2, (另一个2), (再另外一个2), 1, 3]
//这里可以看到,如果在选择排序中,会发生什么情况呢?
[1, (另一个2), (再另外一个2), 2, 3]
当交换后,2的位置发生了变化。相对之前的位置,它从 (另一个2)之前移动到了(再另外一个2)后。这就破坏了相对位置。
根据定义,同样的元素不应该产生移动,所以我们的代码优化一下,变成这个样子
public void Sort2(int[] arr)
{
for (int i = 0; i < arr.Length; i++)
{
for (int j = i + 1; j < arr.Length ; j++)
{
if (arr[i] > arr[j])
{
var temp = arr[i];
arr[i] = arr[j];
//主要在这一步,不换位置。而是将元素整体往后移
//arr[j] = temp;
for (int k = j; k >i+1 ; k--)
{
arr[k] = arr[k - 1];
}
arr[i + 1] = temp;
}
}
}
}
这样的话,相同元素的相对位置是没有变化的。保证了相对稳定性!
但又出现了一个新的问题,引入的一个新的for循环,复杂度又退化到了O(n^3)。
优化第二步,复杂度重回O(n^2)
观察上述代码,其核心主要在做两件事。
- 找到当前循环最小的那个值
- 填补最小值的空缺,保持排序相对稳定
思考一个问题:有没有一种可能将两步合二为一?找到最小的值的时候交换位置的同时又不影响排序稳定
。
public void Sort3(int[] arr)
{
for (int i = 0; i < arr.Length; i++)
{
for (int j = 0 ; j < arr.Length-1; j++)
{
//当前元素与右边对比大小,并顺带移动位置,这样并不会破坏同元素的相对位置
//同元素,在左边的始终在左边,不可能移动到右边。
if (arr[j] > arr[j+1])
{
var temp = arr[j + 1];
arr[j + 1] = arr[j];
arr[j] = temp;
}
}
}
}
经过这样一优化,复杂度又升级为O(N^2),同时又具备了排序稳定性。
这就是冒泡排序法,只与相邻元素对比,这样就不会破坏排序稳定性。
优化第三步,有序提前终止
上面的排序算法中,有可能循环几轮后,元素就已经完成了排序,但程序并不知道,还是傻乎乎的完成整个循环。
因此,我们需要当排序完成后,提前终止循环。
public void Sort4(int[] arr)
{
for(int i = 0;i < arr.Length; i++)
{
int notSwapNum = 1;
for (int j = 0; j < arr.Length-1; j++)
{
if (arr[j] > arr[j + 1])
{
(arr[j], arr[j + 1]) = (arr[j + 1], arr[j]);
}
else
{
notSwapNum++;
}
}
//说明元素已经是全部有序的,提前终止循环
if (notSwapNum == arr.Length)
{
break;
}
}
}
这就是完整版的冒泡排序,针对选择排序,我们优化了排序稳定性
以及在数据有序时提前退出
。唯一遗憾的是时间复杂度并没有降低。
倒序对比:插入排序(InsertionSort)
在冒泡排序中,虽然我们能在数组有序时提前提出。但面对如下场景
var arr = new int[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, -1 };
冒泡排序还是会完成整个循环,这明显还有很大的优化空间。接下来我们再思考,如何在大部分都有序的情况下完成快速排序。
public void Sort(int[] arr)
{
for (int i = 0; i < arr.Length; i++)
{
for (int j = i ; j > 0; j--)
{
//与左边已排好序的元素进行对比,因为有序,所以插入很快速。
//有点类似打扑克,把抓到的牌按序排列
if (arr[j] < arr[j - 1])
{
//swap
(arr[j], arr[j - 1]) = (arr[j - 1], arr[j]);
}
else
{
break;
}
}
}
}
与冒泡排序相比,插入排序在面对初始数据有序程度高
时,时间复杂度会降低不少,直逼O(n),但面对完全无序的情况,时间复杂度依旧是O(n^2)。
但总体来说,插入排序的综合性能要高于
冒泡排序。
突破O(N^2):希尔排序(ShellSort)
在上面的插入排序中,对数组初始有序度要求很高。所以我们优化的重点就是降低时间复杂度,我们可以通过预处理数据的局部数据,从而突破O(N^2).
希尔排序(Shell Sort),也称为缩小增量排序,是插入排序的一种改进版本。它通过将原始数据分成多个子序列来改善插入排序的性能,使得元素能够更快地移动到它们大致所在的位置,最终完成整个排序过程。
简单来说就是分而治之思想
public void Sort(int[] arr)
{
var h = 1;
//根据数组长度确定h高度,也就是分多少组
while (h < arr.Length / 3)
{
h = h * 3 + 1;
}
while (h >= 1)
{
//初始化为h,从h开始对比
for (var i = h; i < arr.Length; i++)
{
for (var j = i; j >= h; j = j - h)
{
//因为不再是对比相邻元素,所以排序不稳定
if (arr[j] < arr[j - h])
{
(arr[j], arr[j - h]) = (arr[j - h], arr[j]);
}
else
{
break;
}
}
}
//完成有序数组分组后,h高度降低
h = h / 3;
}
}
不过由于h>1,所以不是对比相邻元素,因此排序失去了稳定性。终究有所取舍
如何证明复杂度突破了O(N^2)?
从代码伤看,循环也一个都没少啊。而且还多了一个算高度的过程,这真的降低了时间复杂度吗?
我也不好解释,使用LeetCode作为佐证吧,前面几个算法都会超时,而希尔排序能成功。
https://leetcode.cn/problems/sort-an-array/submissions/609504025/
插入排序 | 希尔排序 |
---|---|
![]() |
![]() |