数据结构七种排序算法讲解及其Java实现
数据结构的排序算法共有 十种,本文仅给出其中 七种算法,有空的话会把剩下的三种(基数排序、计数排序、桶排序)补全。
本文的算法实现参考: 超详细十大经典排序算法总结(java代码)c或者cpp的也可以明白. 这篇文章补充了一些基本知识,对于排序的基本描述也不错,而且有动图,但是算法没有注释,而且所提供的代码存在一些错误,建议结合本文及上述文章的非算法代码部分食用
本文代码使用的测试样例:
int[] arr = {1,2,3,4,5};
int[] arr1 = {5,4,3,2,1};
int[] arr2 = {1,5,3,4,2};
本文对于需要排序的数组为a,类型为整形数组,假设其长度为n,所以数组下标范围为[0, n-1]
冒泡排序
冒泡排序可以说是我们最基础的排序方式了,可以说是排序题的暴力解法
算法描述
核心思想:
每轮都把最大的元素移到最右边,再开始下一轮,从剩下的元素中继续挑选最大值继续移动到右边,这样的操作重复n轮,即可完成排序(其实只要n-1
次就够了,最后一次只剩下一个元素)
具体过程:
首先将数组从a[0]
到a[n-1]
,通过一次次比较,把最大的元素移到最右边的a[n-1]
,此时a[0]
到a[n-1]
的最大的元素已经放到a[n-1]
第二次将数组从a[0]
到a[n-2]
,通过一次次比较,把最大的元素移到最右边的a[n-2]
,此时a[0]
到a[n-1]
的第二大的元素已经放到a[n-1]
第三次将数组从a[0
]到a[n-3]
,…
Java代码
public int[] bubbleSort(int[] array) {
for (int i = 0; i < array.length; i++) {
boolean isSwapped = false;
for (int j = 0; j < array.length - 1 - i; j++) {
if (array[j] > array[j+1]) {
int tmp = array[j];
array[j] = array[j+1];
array[j+1] = tmp;
isSwapped = true;
}
}
if (!isSwapped) {
break;
}
}
return array;
}
时间复杂度
最好情况O(n)
, 最坏情况O(n²)
, 平均情况O(n²)
需要一提的是,网上给出的许多实现代码(包括参考文章的代码)并没有进行优化,无论情况好坏都是O(n²)的时间复杂度,此处给出的是优化后的代码,可以达到O(n)。
最好情况便是如[1,2,3,4,5]
这样的情况,外层循环只需要执行一次,通过内层循环判定内部有序,直接跳出外层循环,因此时间复杂度为O(n)
最坏情况便是如[5,4,3,2,1]
这样的情况,每一次都需要把头部元素移到尾部,因此时间复杂度为O(n²)
选择排序
选择排序也是比较简单的一种排序方式,并且比冒泡排序更容易理解,笔者刚入门时特别偏好这种排序方式
算法描述
核心思想:
每轮都把找出最大的元素的下标,再把该元素和最右边的元素交换,再开始下一轮,从剩下的元素中继续挑选最大值继续交换到右边,这样的操作重复n
轮,即可完成排序(选择排序也只要n-1
次就够了,最后一次只剩下一个元素)
具体过程:
首先将数组从a[0]
到a[n-1]
扫描一遍,找到其中最大的元素并记录其下标x,交换a[x]
和a[n-1]
的元素值,此时a[0]
到a[n-1]
的最大的元素已经放到a[n-1]
第二次将数组从a[0]
到a[n-2]
扫描一遍,找到其中最大的元素并记录其下标x,交换a[x]
和a[n-2]
的元素值,此时a[0]
到a[n-2]
的最大的元素已经放到a[n-2]
第三次将数组从a[0
]到a[n-3]
,…
Java代码
public int[] selectionSort(int[] array) {
for (int i = 0; i < array.length; i++) {
int selectedIndex = 0;
int lastIndex = array.length - 1 - i;
for (int j = 1; j <= lastIndex; j++) {
if (array[selectedIndex] < array[j]) {
selectedIndex = j;
}
}
int tmp = array[lastIndex];
array[lastIndex] = array[selectedIndex];
array[selectedIndex] = tmp;
}
return array;
}
时间复杂度
最好情况O(n²)
, 最坏情况O(n²)
, 平均情况O(n²)
无论好坏,每一轮都需要把数组元素扫描一遍,求得最大值并进行交换,共重复n轮,所以时间复杂度为O(n²)
插入排序
算法描述
核心思想:
在保证a[0]
到a[i]
是有序的情况下,寻找一个合适的位置,把a[i+1]
的元素插入其中,使得a[0]
到a[i+1]
也有序
样例:
有序数列[1,2,5,6,4]
,已知[1,2,5,6]
有序,需要把4
插入其中,首先找到合适的位置,即2
的后面、5
的前面,然后把4
插进去,数列变成[1,2,4,5,6]
,此时排序成功
具体过程:
首先a[0]
只有一个元素,不需要排序
然后a[0]
到a[1]
,已经保证a[0]
是有序的,只需要把a[1]
找一个合适的地方插入即可
然后a[0]
到a[2]
,已经保证a[0]
到a[1]
是有序的,只需要把a[2]
找一个合适的地方插入即可
…
然后a[0]
到a[i+1]
,已经保证a[0]
到a[i]
是有序的,把a[i+1]
插入其中即可,令insertionNum = a[i+1]
,insertionNum 插入数组后的位置为x
,此时需要把数组中a[x]
到a[i]
的元素都后移一格,腾出a[x]
的空间,并让a[x]
= insertionNum ,完成插入,使得array[0]
到array[i+1]
有序
Java代码
public int[] insertionSort(int[] array) {
for (int i = 0; i < array.length - 1; i++) {
int index = i;
int insertionNum = array[i+1];
// 从后往前扫描,一边寻找位置一边后移元素
// 如果位置不合适那么就把元素后移一格,如果位置合适就退出循环,向该位置放入需要插入的insertionNum
while (index >=0 && array[index] > insertionNum ) {
array[index+1] = array[index];
index--;
}
array[index+1] = insertionNum;
}
return array;
}
时间复杂度
最好情况O(n),
最坏情况O(n²)
,平均情况O(n²)
最好情况便是无需插入,数组自身有序,for循环扫描一遍即可
最坏情况则是每次都需要把a[i+1]
这个元素插入到数组头,导致a[0]
到a[i]
之间的元素都需要移动一格,腾出a[0]
用来放置需要插入的元素,for循环跑满,内层的while循环也跑满,所以是O(n²)
希尔排序
希尔排序可以说是插入排序的升级版,里面直接使用到插入排序
算法描述
核心思想:
按一定量进行分组,分组后对组内元素进行插入排序
具体过程:
假设数组为[1,2,3,4,5,6,7,8]
首先每两个元素一组,分组数量groupNum = n/2,共分为[1,5]、[2,6]、[3,7]、[4,8]
,组内元素的下标相隔groupNum ,从a[0]
到a[n-1]
扫描一次数组,扫描的过程中对同组元素之间进行插入排序
然后每四个元素一组,分组数量groupNum = n/4,共分为[1,3,5,7]、[2,4,6,8]
,组内元素的下标相隔groupNum ,从a[0]
到a[n-1]
扫描一次数组,扫描的过程中对同组元素之间进行插入排序
然后每八个元素一组,分组数量groupNum = n/8,只有一组,[1,2,3,4,5,6,7,8]
,组内元素的下标相隔groupNum ,从a[0]
到a[n-1]
扫描一次数组,扫描的过程中对同组元素之间进行插入排序
按此过程进行排序,共需进行logn
轮分组。
需要注意的是,分组数量并不唯一,可以一开始就选用3个元素一组,然后9个元素一组,然后27个元素一组,这样也是可以的
Java代码
public int[] shellSort(int[] array) {
// 最初是2个元素一组
int groupNum = array.length/2;
// 分组,共需进行logn次分组
while(groupNum > 0) {
// 对组内元素进行插入排序, groupNum为每组的第二个元素
// 组内元素分别为:
// 0 groupNum 2*groupNum 3*groupNum
// 1 1+groupNum 1+2*groupNum 1+3*groupNum
for (int i = groupNum; i < array.length; i++) {
int insertionNum = array[i];
int index = i - groupNum;
while (index >= 0 && array[index] > insertionNum ) {
array[index + groupNum] = array[index];
index -= groupNum;
}
array[index + groupNum] = insertionNum;
}
groupNum /= 2;
}
return array;
}
时间复杂度
最好情况O(nlogn)
, 最坏情况O(nlogn)
, 平均情况O(nlogn)
关于希尔排序的时间复杂度的问题比较复杂,想要详细了解的同学可以自行百度
归并排序
归并排序使用了分治法,将一个父问题拆分成多个子问题,先自顶向下进行分割,然后再自底向上进行合并
算法描述
核心思想:
将序列分割为左右两半,然后递归对左右序列进行排序,再将有序的左右序列使用O(n)的合并算法合并为一个有序的合并序列。
具体过程:
分割的过程是自顶向下的:
0 [5,4,3,2,1]
/ \
1 [5,4] [3,2,1]
/ \ / \
2 [5] [4] [3] [2,1]
/ \
3 [2] [1]
分治的过程如上图,一开始没有分割,然后分割为[5,4][3,2,1]
两部分,再对这两部分递归,随着逐步分割递归,形成了类似树一样的形式
合并的过程则是自底向上结束完成递归的:
首先看标号为3的那层,两个数组都长度为1,不需要排序,直接返回上一层调用
0 [5,4,3,2,1]
/ \
1 [5,4] [3,2,1]
/ \ / \
2 [5] [4] [3] [2,1]
/ \
3 [2] [1]
然后看标号为2的那层,前面三个数组的长度都为1,对于最后一个数组,左半边排序完为[2]
,右半边排序完为[1]
,有序合并后为[1,2]
0 [5,4,3,2,1]
/ \
1 [5,4] [3,2,1]
/ \ / \
2 [5] [4] [3] [1,2]
/ \
3 [2] [1]
然后看标号为1的那层,对于[5,4]
,左半边排序完为[5]
,右半边排序完为[4]
,有序合并后为[4,5]
,对于[3,2,1]
,左半边排序完为[3]
,右半边排序完为[1,2]
,有序合并后为[1,2,3]
0 [5,4,3,2,1]
/ \
1 [4,5] [1,2,3]
/ \ / \
2 [5] [4] [3] [1,2]
/ \
3 [2] [1]
然后看标号为0的那层,这是一开始的那层,对于[5,4,3,2,1]
,左半边排序完为[4,5]
,右半边排序完为[1,2,3]
,有序合并后为[1,2,3,4,5]
,至此,自底向上的合并完成,排序结束
0 [1,2,3,4,5]
/ \
1 [4,5] [1,2,3]
/ \ / \
2 [5] [4] [3] [1,2]
/ \
3 [2] [1]
Java代码
public int[] mergeSort(int[] array) {
int mid = array.length / 2;
if (mid == 0) {
return array;
}
// 运用分治思想,把一个大的问题划分的多个小的问题
// 把大的序列划分为两个子序列,并对子序列进行排序
int[] left = mergeSort(Arrays.copyOfRange(array, 0, mid));
int[] right = mergeSort(Arrays.copyOfRange(array, mid, array.length));
// 把两个有序的子序列合并起来
// 所需时间为left.length + right.length,即array.length,时间复杂度为O(n)
int index = 0, i = 0, j = 0;
// 两个字符列中任何一个使用完毕都会退出循环
while (i < left.length && j < right.length) {
array[index++] = left[i] < right[j] ? left[i++] : right[j++];
}
// 如果是右子序列使用完毕,左子序列没有使用完,继续合并
while (i < left.length) {
array[index++] = left[i++];
}
// 如果是左子序列使用完毕,右子序列没有使用完,继续合并
while (j < right.length) {
array[index++] = right[j++];
}
return array;
}
时间复杂度
最好情况O(n)
, 最坏情况O(nlogn),
平均情况O(nlogn)
注意,这里给出的算法无论好坏都是O(nlogn),也就是把上面画出来的递归树整颗遍历一次,最好情况想要达到O(n)需要进行改进
改进思路:自底向上合并left和right的时候,left和right都已经是有序的,如果left[left.length-1] < right[0],那么默认有序,不需要合并
快速排序
快速排序简称快排,面试的时候手写快排是很正常的,快排也是使用了分治的思想
算法描述
核心思想:选择数组中的任意一个元素作为基准元素x,然后根据x进行分区(也就是把小于x的元素放到x的左边,把大于x的元素放到x的右边),然后对x两边的元素各自调用快排进行排序
基准元素的选择:
选择基准元素可以是随机的也可以是固定的
如果选择随机产生基准元素,则随机产生下标后,基准元素和最后一个元素交换,排序过程中使用最后一个元素作为基准元素
如果选择固定的基准元素,也是同理,需要和最后一个元素交换。
当然,不一定要放到数组的尾部,放到数组头部也可以
我们这里为了简单起见,选择固定的基准元素,并且使用数组的最后一个元素作为基准元素,万变不离其宗,只要你理解快排的思想,基准元素不是问题
具体过程:
首先,选择1作为基准元素,原数组[5,4,3,2,1]
经过分区后,数组变成[1,4,3,2,5]
,基准元素左边没有元素,于是对右边的[4,3,2,5]
进行快排
0 [1,4,3,2,5]
\
1 [4,3,2,5]
这次基准元素为5,进行分区后得到[4,3,2,5]
,基准元素右边没有元素,于是对左边的[4,3,2]
进行快排
0 [1,4,3,2,5]
\
1 [4,3,2,5]
\
2 [4,3,2]
这次基准元素为2,进行分区后得到[2,3,4]
,基准元素左边没有元素,于是对右边的[3,4
进行快排,需要注意的是,由于每一层递归使用同一个数组,下层的分区也会对总数组产生影响,所以第0层和第1层对应[4,3,2]
的部分都变成了[2,3,4]
0 [1,2,3,4,5]
\
1 [2,3,4,5]
\
2 [2,3,4]
\
3 [3,4]
这次基准元素为4,进行分区后得到[3,4]
,基准元素右边没有元素,于是对右边的[3]
进行快排,此时只有1个元素,已经可以结束快排了,无论左右都没有元素了,递归结束
0 [1,2,3,4,5]
\
1 [2,3,4,5]
\
2 [2,3,4]
\
3 [3,4]
分区思想:其实快排最核心的是这个分区方法,我给出的分区算法参考自本文开头所说的参考文献,即 超详细十大经典排序算法总结(java代码)c或者cpp的也可以明白. 参考文献中有算法的执行动图,可以配合理解。实现方式网上也有多种版本,可以挑选自己喜欢的版本。
在本文所给出的分区算法中,leftIndex
用于记录小于或等于基准元素x
的元素个数,用下标i
从左往右扫描数组,如果遇到大于x
的元素,继续往右扫描,如果遇到小于或等于x
的元素,leftIndex++
,此时若i
和leftIndex不相等
,证明在i
和leftIndex
之间存在一个或多个大于x的元素,而且a[i] <= x,a[leftIndex] > x
,那么将a[i]
与a[leftIndex]
的元素进行交换,把大于x的元素向后移,小于或等于x的元素向前移,等整个数组扫描完毕以后,leftIndex
就是基准元素所在的下标
Java代码
public int[] quickSort(int[] array){
return quickSort(array, 0, array.length-1);
}
public int[] quickSort(int[] array, int begin, int end) {
if (array.length <= 0 || begin < 0 || end >= array.length || begin > end) {
return null;
}
// 进行分区,并获取基准元素的新位置
int leftPartitionIndex = partition(array, begin, end);
quickSort(array, begin, leftPartitionIndex-1);
quickSort(array, leftPartitionIndex+1, end);
return array;
}
// 这个分区方法才是快排的核心关键
public int partition (int[] array, int begin, int end) {
// 使用最后一个元素作为基准
int leftPartitionIndex = begin - 1;
for (int i = begin; i <= end; i++) {
if (array[i] <= array[end]) {
leftPartitionIndex++;
if (i > leftPartitionIndex) {
int tmp = array[i];
array[i] = array[leftPartitionIndex];
array[leftPartitionIndex] = tmp;
}
}
}
return leftPartitionIndex;
}
时间复杂度
最好情况O(nlogn)
, 最坏情况O(n²),
平均情况O(nlogn)
最好情况便是每次刚好把序列分为长度相等的两个子序列,形成的分治树共logn层,每层扫描一遍需要时间为n,因为时间复杂度为O(nlogn)
最坏情况可以参考算法描述中的具体过程,每次只能对左边或者右边的子序列分治,导致形成的分治树共n层,每层扫描一遍需要时间为n,因为时间复杂度为O(n²)
堆排序
面试的时候手写堆排也是常见的,堆排利用了大顶堆/小顶堆来进行排序,忘记堆相关知识的同学可以自行百度,大顶堆的形式是树的一种,简单来说大顶堆具备两个性质:
- 对于非叶子结点,父结点大于或等于左右孩子的结点,即父节点元素不能比孩子结点的元素小
- 是完全二叉树,即除最下面的那层结点外,剩余结点是满二叉树,而且最下面的那层结点必须从左往右开始排
算法描述
核心思想:
先用数组建立一个大顶堆,堆顶元素arr[0]
就是这个堆里最大的元素,然后把堆顶元素arr[0]
跟堆的最后一个元素arr[n-1]
互换,成功把大的元素放到末尾,调整剩下的arr[0]
到arr[n-2]
,变回大顶堆
然后把arr[0]跟arr[n-2]互换,如此重复操作,每一次都把最大的元素放到末尾,重复n次即可完成排序
关于建立大顶堆:
找到最后一个叶子结点的父元素,即arr[n-1]的父元素
根据左叶子=2父亲+1,右叶子=2父亲+2,所以父亲 = (叶子-1)/2 = (n-1-1)/2 = n/2 -1
从a[n/2-1]
开始,a[n/2-2]
, a[n/2-3],
...
, a[0]
逐步将a[i]
调整为大顶堆,逐步自下而上建立大顶堆
关于调整大顶堆:
a[i]
的左右孩子为a[2i+1]
, a[2i+2]
若a[i]
是三者中最大,仍是大顶堆,无需调整,否则选出左右孩子中较大者,与a[i]
交换,对该孩子递归调整,自上而下将堆恢复成大顶堆
堆排序的算法并不难,但需要理解其核心思想,理解大顶堆和性质其思想后,手写算法的难度不高。
Java代码
// len用于记录大顶堆的大小
int len;
public int[] heapSort (int[] array) {
if (array.length == 0) {
return array;
}
len = array.length;
// 建立大顶堆
buildMaxHeap(array);
// 将堆顶元素放到后面,并减小堆的大小,把新的堆重新调整回大顶堆,重复n次就可以完成排序
while (len > 0) {
swap(array, 0, len-1);
len--;
adjustHeap(array, 0);
}
return array;
}
// 从最后一个叶子结点的父节点开始自底向上调整最大堆,把大的元素一步一步往上移
// 嫌麻烦的同学也可以直接从len-1开始,从a[len/2]到a[len-1]这些叶子结点由于没有左右孩子,实际上不会有变化的
public void buildMaxHeap (int[] array) {
for (int i = len/2 -1; i>=0; i--) {
adjustHeap(array, i);
}
}
// 自上而下调整大顶堆,把小的那一个元素放到子树
public void adjustHeap(int[] array, int index) {
int maxIndex = index;
int left = 2 * index + 1;
int right = 2 * index + 2;
if (left < len && array[left] > array[maxIndex]) {
maxIndex = left;
}
if (right < len && array[right] > array[maxIndex]) {
maxIndex = right;
}
if (maxIndex != index) {
swap(array, index, maxIndex);
adjustHeap(array, maxIndex);
}
}
public void swap (int[] array, int i1, int i2) {
int tmp = array[i1];
array[i1] = array[i2];
array[i2] = tmp;
}
时间复杂度
最好情况O(nlogn)
, 最坏情况O(nlogn),
平均情况O(nlogn)
关于堆排的时间复杂度比较复杂,讲解需要很多篇幅,此处不便讲解,有需要的同学可以自行百度。
总结
排序算法其实不算特别复杂,毕竟是数据结构里面的算法,写这篇文章的初衷也是因为我在复习过程中看到 超详细十大经典排序算法总结(java代码)c或者cpp的也可以明白. 这篇文章,觉得写得挺好的,但是有些地方写得不是很清楚,而且文章存在一些错误,所以针对它里面一些一笔带过的地方详细地介绍一遍同时改正一些错误的地方。
洋洋洒洒几个小时写下来也一万多字了,如果发现有什么错误的地方烦请您指出,以便我及时纠正。