那么我们应该选择哪种方式呢?
其实仔细思考一下,我们很容易会想到选择第2种方式,原因也很简单:在寻找第一个比x小的元素k时,我们可以边把比 x 大的元素往后移动。
因此,我们对一个数组排序时,可以从第2个元素开始,看作是把元素插入一个已经有序的数组中。
具体代码如下:
public static void insertSort1(int[] array) {
for(int i = 1; i < array.length; i++) {
// 记录 i 下标的元素,防止比 i下标 大的元素向后移动将其覆盖
int tmp = array[i];
// 从下标为 i-1的元素开始遍历, 比 array[i]大的元素都向后移动
int j = i -1;
for(; j >= 0; j--) {
if(array[j] > tmp) {
array[j+1] = array[j];
} else {
// 此时array[j] < array[i],且 j + 1下标 为 array[i] 在已排序元素中的位置
break;
}
}
array[j+1] = tmp;
}
}
时间复杂度:O(n²)。最好情况是数组已经有序,只需比较n-1次,时间复杂度为O(n);最坏情况是数组为待排序顺序的逆序,需要(n-1)(n-1)…1次,时间复杂度为O(n²)。
空间复杂度:O(1)。记录每次待排序的元素的额外空间。
稳定性:稳定。
适用情况:数组基本有序的情况下。
算法基本思想:(从大到小排序)
折半插入排序的本质也是在一个已经有序的数组的插入一个元素,与直接插入排序的唯一区别是:折半插入排序是利用二分查找找到那个合适的插入位置。
二分查找的基本步骤:
将要插入元素跟区间的中间元素对比,将待查找的区间缩小为之前的一半,直到找到要查找的元素,或者区间被缩小为0(即 left>right ,数组中不存在指定元素)。
注意事项:
(1)若插入元素在数组不存在(如上图所示情况),则插入元素的下标应为 right+1,因此需要将 right+1 下标 开始的所有元素全部向后移动。
(2)若插入元素与 mid 下标的元素相等,为了操作的统一性,我们可以使 right=mid-1,这样一来,待插入元素的下标也为 right+1 ,接下来同样将 right+1 下标 开始的所有元素全部向后移动。
具体代码实现如下:
public static void binaryInsertSort(int[] array) {
for (int i = 1; i < array.length; i++) {
int left = 0, right = i - 1;
int mid = (left + right) / 2;
int target = array[i]; // 待插入的元素
// 1. 找到合适的插入位置
while(left <= right) {
mid = (left + right) / 2;
if(array[mid] < target) left = mid + 1;
// 出现 array[mid] == target,同样进行此操作,可保证 元素插入下标的一致性
else right = mid -1;
}
// 2. 在已排序好的数组区间中,将mid开始的元素,从后向前依次向后移动
for (int j = i - 1; j >= right + 1; j--) {
array[j+1] = array[j];
}
// 3. 将 array[i] 插入到 mid 位置
array[right + 1] = target;
}
}
时间复杂度:O(n²)。在平均情况下,折半插入排序 的元素移动次数与 直接插入排序 相同, 仅减少了元素间的比较次数,因此时间复杂度仍为O(n²)。
空间复杂度:O(1)。记录每次待排序的元素的额外空间。
稳定性:稳定。
适用情况:数组基本有序且数组元素较多,可一定程度减少元素间的比较次数。
关于分组?
对于前面的分组,并不是如下图的“逐段分割”式分组方式。(×)
但事实上,通过上述例子我们可以看出,经过几次“预排序”,似乎并没有达到预期的基本有序的效果,因为 1 和 2 这样较小的数字依然在后面, 12 这个比较大的数子也仍然处于中间的位置,没有加快直接插入排序的速度。
这样的“逐段式”分组方式显然是不够合理的,因此这里正确的分组的方式是:将每间隔距离为“增量”大小 gap 的元素分为一组(如下图)。(√)
这样"跳跃式"的分组的好处是:若较大的元素在前面,通过"跳跃式"分组后,较大的元素有机会通过插入排序直接移动到后面;若较小的元素在后面,通过"跳跃式"分组后,较小的元素有机会通过插入排序直接移动到前面.
关于分组后插入排序的方式?
分组后,并不是依次对每组元素进行插入排序,而是从第一组的第 2 个元素 即下标为 gap 的元素开始,之后的每个元素进行间距为“增量”大小的插入排序。(如下图, gap = 3)
关于 gap 的取值?
gap 的取值有很多种方式,不同取值方式所得到排序时间复杂度有所不同,目前并没有一种确定的最佳增量序列,但唯一确定的是最后一个增量应为 1 。在本文的代码实现中,当待排序排序的元素个数为 n, gap 的初始值为 n/2,随后每次 gap = gap/2, 直到 gap 为1,完成排序。
希尔排序的具体代码实现如下:
public static void shellSort(int[] array){
// 当前数组只有一个元素,不需要进行排序
if(array.length < 2) return;
// 保证第一次的 gap = n/2,最后一次排序 gap = 1
int gap = array.length;
while(gap > 1) {
gap = gap / 2;
shell(array, gap);
}
}
private static void shell(int[] array, int gap) {
for(int i = gap; i < array.length; i++) {
int tmp = array[i];
// 若array[j] > tmp,则该元素向前移动
// j 继续向前移动 gap 个位置,重复上述过程,直到 array[j] <= tmp 或 j < 0
int j = i - gap;
for(; j >= 0; j -= gap) {
if(array[j] > tmp) {
array[j+gap] = array[j];
}else {
break;
}
}
array[j+gap] = tmp;
}
}
时间复杂度:O(n ^ 1.25) - O(1.6 * n ^ 1.25)。
空间复杂度:O(1)。
稳定性:不稳定。
适用情况:适合初始记录无序,元素个数 n 较大时的情况。
public static void selectSort(int[] array) {
for (int i = 0; i < array.length - 1; i++) {
// 需要找 n - 1 次, 每次从第一个元素开始向后遍历
int maxIndex = 0;
for (int j = 1; j < array.length - i; j++) {
if(array[j] > array[maxIndex]) maxIndex = j;
}
swap(array, array.length - 1 - i, maxIndex);
}
}
private static void swap(int[] array, int x, int y) {
int tmp = array[x];
array[x] = array[y];
array[y] = tmp;
}
时间复杂度:O(n²)。在任何情况下,第 i 趟寻找最大的元素时,都需要比较 n - i 次,因此n - 1趟排序的时间复杂度为O(n²)。
空间复杂度:O(1)。需要一个额外空间进行元素交换。
稳定性:不稳定(可通过代码改善变得稳定)。
适用情况:无论数组的有序性如何,简单选择排序的时间复杂度都为O(n²),只适合数据量小的排序,一般不推荐使用。
堆的说明:将数组按下标从小到大抽象成为一颗二叉树,再通过一定的调整手段,使该二叉树具有以下特点:每颗树根结点的值 > 其左右子树根结点的值(大根堆) 或者 每颗树根结点的值 < 其左右子树根结点的值(小根堆) 。(如下图所示)
建立大根堆的具体代码如下:
// array为原始数组
private static void createBigHeap(int[] array) {
// 最后一个非终端结点下标为(array.length-2) / 2
// 对下标为(array.length-2) / 2 到 下标为0的结点依次进行“向上调整 ”
for(int parent = (array.length-2) / 2; parent >= 0; parent-- ) {
shiftDown(array,parent, array.length);
}
}
// root表示当前调整的数的根结点下标,len表示待调整的整个数组的长度
private static void shiftDown(int[] array, int root, int len) {
int parent = root;
int child = root * 2 + 1;
// 防止出现需要多次调整的情况
while(child < len) {
// child + 1 判断该结点是否有右孩子,有则通过比较选出左右孩子的较大值
if(child + 1 < len && array[child] < array[child+1]) {
child = child + 1;
}
// 判断是否需要交换
if(array[child] > array[parent]) {
swap(array,child,parent);
parent = child;
child = parent * 2 + 1;
}else {
break;
}
}
}
经过上述调整的大根堆,就可以正式开始堆排序:
堆排序具体代码如下:
public static void heapSort2(int[] array) {
// 建立大根堆
createBigHeap(array);
int end = array.length - 1;
while(end > 0) {
// 交换堆顶元素 与 待排序区间的末尾元素
swap(array, 0, end);
// 重新调整将剩下区间调整成大根堆
shiftDown(array, 0, end);
end--;
}
}
时间复杂度:O(nlog₂ n)。最坏情况下的时间时间复杂度为也稳定在O(n * log₂ n)。
空间复杂度:O(1)。需要一个额外空间进行元素交换。
稳定性:不稳定。
适用情况:堆排序是一种较快的排序算法,它与快速排序,归并排序的平均时间都为O(nlog₂ n),适用于数据量较大时的排序。
算法基本思想:(从大到小排序)
通过两两比较相邻记录的关键字,如果后一个元素的值大于前一个元素,则将两个元素进行交换,从而使关键字大的元素如煮开水时的“气泡”一样逐渐向上“漂浮”(右移)。
算法步骤:
(1)具有 n 个元素的数组,进行 n-1 趟冒泡排序。
(2)每趟冒泡排序从第一个元素开始向后遍历,相邻两两元素比较,将关键字大的元素交换到后面,直到当前排序区间最大元素到区间末尾,这趟冒泡排序结束。
第一趟的交换过程如下图:(某待排序数组)
当第二趟排序完成后,数组的元素分布情况如下:
不难发现,在第二趟排序结束后,数组已经属于有序状态,很明显进行接下来的几趟排序是“多此一举”的。
因此我们可以对普通的冒泡排序做一个小小的优化:即设置一个标志位 flag = 1,当次轮排序过程发生了元素交换,则将 flag 置为 0,当本趟排序完成后,若 flag == 1,则代表数组已经有序,直接跳出整个循环,完成排序操作。
冒泡排序代码实现如下:
public static void bubbleSort(int[] array) {
for (int i = 0; i < array.length - 1; i++) {
// 判断是否继续冒泡排序
int flag = 1;
for (int j = 0; j < array.length - 1 - i; j++) {
if(array[j] > array[j+1]) {
swap(array,j,j+1);
flag = 0;
}
}
if(flag == 1) break;
}
}
时间复杂度:O(n²)。即使设置标志位对冒泡排序进行优化,但排序的平均时间复杂度仍为O(n²)。
空间复杂度:O(1)。需要一个额外空间进行元素交换。
稳定性:稳定。
适用情况:适合数据量较小的数据排序,当数据规模较大时,排序效率较低。
算法基本思想:
快速排序由冒泡排序改进而成。快速排序采用“分治”的思想,即把一个大问题分解成为多个子问题,通过解决每个子问题,从而解决整个大问题。
在每个子问题中,需要选一个元素值 pivot 作为“基准”,通过一定次数的两两元素交换,最终将某个区间区间分为两个区间,左区间的元素全部不大于 pivot,右区间的元素全部不小于 pivot。
快速排序区间划分的两个常见方式:
(1) Hoare法(2)“挖坑”法
通过以上过程我们不难看出:快速排序是一个采用递归思想的排序过程。
注意:当你也尝试着去模拟上述的过程,你也许会发现:好像将left(right)“指针”指向的元素 与 第一个元素交换后,并不会出现分区的现象。
那么造成这种现象的原因是否与 left“指针” 和 right“指针” 移动的先后顺序有关呢?
答案是肯定的,这种情况是因为在某些数据先移动 left“指针”造成的。因此,在每次寻找符合交换条件的元素时,应先让 right“指针”向左寻找待交换的元素。
注意: left“指针”要寻找比 pivotkey 大的元素,right“指针”要寻找比 pivotkey 小的元素,那循环找元素的条件能否直接写成 array[left] < pivotkey 和 array[right] > pivotkey呢?
答案是否定的。原因是数组可能出现 元素值key == pivotkey 的情况,如果写成上述判断条件,可能会出现排序出差的情况。
Hoare法具体的代码实现如下:
public static void quiackSort(int[] array) {
quick(array, 0, array.length - 1);
}
private static void quick(int[] array, int left, int right) {
//代表此时 此时排序区间 只有一个元素 或者 排序区间不存在
if(left >= right) return;
// 在 partition方法中,完成分区操作,并返回 pivot的下标
int pivot = partition(array, left, right);
// 对 pivot 的左右区间进行递归操作
quick(array,pivot + 1, right);
quick(array,left, pivot - 1);
}
private static int partition(int[] array, int left, int right) {
int tmp = array[left];
int i = left; // 记录基准值的下标
while(left < right) {
//必须先让 right 去寻找比tmp小的元素, 因为这样可以防止 一个大的元素 被错误地放到 数组的前面
while(left < right && array[right] >= tmp) right--;
while(left < right && array[left] <= tmp) left++;
//此时 left 和 right 分别指向 大于和小于 tmp的元素
swap(array,left,right);
}
swap(array, i, right);
return right;
}
具体的排序过程如下图所示:(数组的初始序列为:8,10,0,20,5,1,6,9)
“挖坑”法具体的代码实现如下:
public static void quickSort2(int[] array) {
quick2(array, 0, array.length - 1);
}
private static void quick2(int[] tmpArray, int left, int right) {
if(left >= right) return; //代表此时 此时排序区间 只有一个元素 或者 排序区间不存在
// 在 partition方法中,完成分区操作,并返回 pivot的下标
int pivot = partition2(tmpArray, left, right);
// 对 pivot 的左右区间进行递归操作
quick2(tmpArray, left, pivot - 1);
quick2(tmpArray, pivot + 1, right);
}
private static int partition2(int[] tmpArray, int left, int right) {
int tmp = tmpArray[left];
while(left < right) {
//必须先让 right 去寻找比tmp小的元素, 因为这样可以防止 一个大的元素 被错误地放到 数组的前面
while(left < right && tmpArray[right] >= tmp) right--;
// right “指针” 入左坑
swap(tmpArray, left, right);
while(left < right && tmpArray[left] <= tmp) left++;
// left “指针” 入右坑
swap(tmpArray, left, right);
}
tmpArray[left] = tmp;
return left;
}
时间复杂度:O(n log n)。在数组原来有序的情况下(非递减或非递增),快速排序的递归过程会变成单分支树的递归,退化成冒泡排序,即最坏情况下时间复杂度为O(n²);但平均时间复杂度仍为O(n log n)。
空间复杂度:O(1)。需要一个额外空间进行元素交换或值存储。
稳定性:不稳定。
适用情况:虽然快速排序存在退化成冒泡排序的可能性,但绝大多数情况下,数据均为乱序状态,因此快速排序可以称为“内部排序”中最快的排序方法。
具体的排序过程如下图所示:(数组的初始序列为:8,10,20,0,5,1,6)
从上图不难看出,只要知道每次两两归并时,每个子数组的起始下标 start 和终点坐标 end ,我们即可根据学过的合并两个有序列表的方法,最终将整个数组排列成有序序列。
那么如何将含 n 个元素的数组分成 n 个子序列,再进行合并呢?
答案是:”采用“分而治之”的思想。先得到待排序数组中间元素的下标 mid ,将数组第一个元素的下标 start 到 mid 这个区间分为一个区间,将 mid+1 到 数组最后一个元素的下标 end 这个区间分为另一个区间。递归重复上述步骤,直到 start >= end,停止递归操作,开始合并的过程。每次合并的左区间都为 start - mid,右区间为 mid+1 - end。
归并排序具体的代码实现如下:
public static void mergeSort(int[] array) {
merge(array, 0 , array.length - 1);
}
private static void merge(int[] array, int left, int right) {
if(left >= right) return;
//分解小区间
int mid = (left + right) / 2;
merge(array, left, mid);
merge(array, mid + 1, right);
//合并小区间的有序数组
int start1 = left;
int start2 = mid + 1;
int[] mergeArray = new int[right - left + 1];
int k = 0;
while(start1 <= mid && start2 <= right) {
if(array[start1] <= array[start2]) {
mergeArray[k++] = array[start1++];
}else {
mergeArray[k++] = array[start2++];
}
}
while(start1 <= mid) {
mergeArray[k++] = array[start1++];
}
while(start2 <= right) {
mergeArray[k++] = array[start2++];
}
//将mergeArray数组中的元素 放回到 原来数组的合并区间内
k = 0;
while (left <= right) {
array[left++] = mergeArray[k++];
}
}
时间复杂度:O(n log n)。
空间复杂度:O(n)。
例如:要对整数类型的数组进行排序,数组中的元素分别为:12,44,8,26,40,99,3,76,60,25。我们可以将每个元素的十位和个位“分解”成2个关键字,因为每个关键字的范围是0~9,因此我们每次需要先准备10个桶,每个桶依次存放关键字为 0 ~ 9 的元素。
接着采用“最低位优先法”,即先匹配个位这个关键字,将元素依次放入对应的桶,按桶的顺序和元素放入的顺序依次取出后,接着“分配”并“收集”十位这个关键字,最终完成排序。(如下图)
计数排序的具体代码实现如下:
public static void radixSort(int[] array) {
// 1. 先获得所有元素中的最大位数
int count = 1; // 记录最大位数
int multiple = 10; // 记录当前倍数
for (int x : array) {
while (x / multiple > 0) {
multiple *= 10;
count++;
}
}
// 2. 采用“最低位优先法”进行 count 次“分配”与“收集”
multiple = 1; // 描述当前关键字的取值方式
for (int i = 0; i < count; i++) {
// 准备 0~9,总共10个桶存放元素
int[][] buckets = new int[10][0];
for (int j = 0; j < 10; j++) {
buckets[j] = new int[0];
}
// 针对关键字进行“分配”
for (int x : array) {
int key = x / multiple % 10;
int len = buckets[key].length;
buckets[key] = Arrays.copyOf(buckets[key], len + 1);
buckets[key][len] = x;
}
// 对元素按顺序收集
int k = 0;
for (int j = 0; j < 10; j++) {
// 遍历每个桶
for (int x : buckets[j]) {
array[k++] = x;
}
}
multiple *= 10;
}
}
时间复杂度:O(d (n+t))。对于 n 个元素的序列(假设每个元素含有 d 个关键字,每个关键字的取值范围有 t 个值),每一趟“分配”的时间复杂度为O(n),每一趟“收集”的时间复杂度为O(t),要进行 d 趟“分配”与“收集”,因此总的时间复杂度为O(d (n+t))。
空间复杂度:O(n+d)。
稳定性:稳定。
适用情况:有较为苛刻的要求,需要知道各个关键字的主次关系和每个关键字的所有可能取值范围。
注意:计数数组下标 = array[i] - array[min]
public static void countSort(int[] array) {
int minVal = array[0];
int maxVal = array[0];
// 找到数组元素的最大值和最小值
for (int i = 1; i < array.length; i++) {
if(array[i] < minVal) minVal = array[i];
if(array[i] > maxVal) maxVal = array[i];
}
int[] countArray = new int[maxVal - minVal + 1];
// 记录每个元素出现的次数
count(array, countArray , minVal);
//把排序好的数据写回到 tmpArray 数组中
int k = 0;
for (int i = 0; i < countArray.length; i++) {
while(countArray[i] > 0) {
array[k++] = i + minVal;
countArray[i]--;
}
}
}
private static void count(int[] array, int[] countArray, int minVal) {
for (int i = 0; i < array.length; i++) {
countArray[array[i] - minVal]++;
}
}
时间复杂度:O(n + k),计数的时间复杂度不仅数组元素的个数 n 有关,也取决于数组中最大和最小元素的区间 k。
空间复杂度:O(K)。(针对当前代码实现方式)
稳定性:不稳定。(针对当前代码实现方式)
适用情况:当待排序数组数据量较大且数据分布较集中时,计数排序的适用程度较高。当数据分别较分散,差值很大时应使用其他排序算法。