1、基本排序算法性能
算法性能评估标准:
- 时间复杂度
- 空间复杂度
- 稳定性
稳定性: 假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,r[i]=r[j],且r[i]在r[j]之前,而在排序后的序列中,r[i]仍在r[j]之前,则称这种排序算法是稳定的;否则称为不稳定的
1.1 选择排序
选择出数组中的最小元素,将它与数组的第一个元素交换位置。再从剩下的元素中选择出最小的元素,将它与数组的第二个元素交换位置。不断进行这样的操作,直到将整个数组排序。无论什么数据进去都是O(n2)
的时间复杂度且不稳定
,唯一的好处可能就是不占用额外的内存空间.
选择排序是一种不稳定的排序算法。从我前面画的那张图中,你可以看出来,选择排序每次都要找剩余未排序元素中的最小值,并和前面的元素交换位置,这样破坏了稳定性;
public void selectionSort(int[] arr) {
if (arr.length < 1) {
return;
}
for (int i = 0; i < arr.length; i++) {
int minIndex = i;
for (int j = i + 1; j < arr.length; j++) {
if (arr[j] < arr[minIndex]){
minIndex = j;
}
}
if (minIndex != i){
swap(arr, i, minIndex);
}
}
}
private void swap(int[] arr, int i, int j) {
int t = arr[i];
arr[i] = arr[j];
arr[j] = t;
}
1.2 冒泡排序
通过从左到右不断交换相邻逆序的相邻元素,在一轮的交换之后,可以让未排序的元素上浮到右侧。在一轮循环中,如果没有发生交换,就说明数组已经是有序的,此时可以直接退出。
冒泡排序只会操作相邻的两个数据。每次冒泡操作都会对相邻的两个元素进行比较,看是否满足大小关系要求。如果不满足就让它俩互换。一次冒泡会让至少一个元素移动到它应该在的位置,重复 n 次,就完成了 n 个数据的排序工作。
冒泡过程还可以优化。当某次冒泡操作已经没有数据交换时,说明已经达到完全有序,不用再继续执行后续的冒泡操作。
冒泡排序的时间复杂度是多少?
最好情况下,要排序的数据已经是有序的了,我们只需要进行一次冒泡操作,就可以结束了,所以最好情况时间复杂度是 O(n)。而最坏的情况是,要排序的数据刚好是倒序排列的,我们需要进行 n 次冒泡操作,所以最坏情况时间复杂度为 O(n2);
public void bubbleSort(int[] arr) {
if (arr.length < 1) {
return;
}
for (int i = arr.length - 1; i > 0; i--) {
for (int j = 0; j < i; j++) {
if (arr[j] > arr[j + 1]) {
swap(arr, j, j + 1);
}
}
}
}
private void swap(int[] arr, int i, int j) {
int t = arr[i];
arr[i] = arr[j];
arr[j] = t;
}
1.3 插入排序
将数组中的数据分为两个区间,已排序区间和未排序区间。初始已排序区间只有一个元素,就是数组的第一个元素。插入算法的核心思想是取未排序区间中的元素,在已排序区间中找到合适的插入位置将其插入,并保证已排序区间数据一直有序。重复这个过程,直到未排序区间中元素为空,算法结束。
插入排序从左到右进行,每次都将当前元素插入到左侧已经排序的数组中,使得插入之后左部数组依然有序。第 j 元素是通过不断向左比较并交换来实现插入过程:当第 j 元素小于第 j - 1 元素,就将它们的位置交换,然后令 j 指针向左移动一个位置,不断进行以上操作。
如果要排序的数据已经是有序的,我们并不需要搬移任何数据。如果我们从尾到头在有序数据组里面查找插入位置,每次只需要比较一个数据就能确定插入的位置。所以这种情况下,最好是时间复杂度为 O(n)。注意,这里是从尾到头遍历已经有序的数据。如果数组是倒序的,每次插入都相当于在数组的第一个位置插入新的数据,所以需要移动大量的数据,所以最坏情况时间复杂度为 O(n2)。
public void insertSort(int[] arr) {
if (arr.length < 1) {
return;
}
for (int i = 0; i < arr.length - 1; i++) {
for (int j = i + 1; j > 0; j--) {
if (arr[j] < arr[j-1]){
swap(arr, j, j-1);
}else {
break;
}
}
}
}
private void swap(int[] arr, int i, int j) {
int t = arr[i];
arr[i] = arr[j];
arr[j] = t;
}
public void insertSort2(int[] arr) {
if (arr.length < 1) {
return;
}
for (int i = 0; i < arr.length; i++) {
int cur = arr[i];
int j = i;
while (j > 0) {
if (cur < arr[j - 1]){
arr[j] = arr[j - 1];
}else {
break;
}
j--;
}
arr[j] = cur;
}
}
1.4 希尔排序
第一个突破O(n2)的排序算法,是简单插入排序的改进版。它与插入排序的不同之处在于,它会优先比较距离较远的元素。希尔排序又叫缩小增量排序。对于大规模的数组,插入排序很慢,因为它只能交换相邻的元素,每次只能将逆序数量减少 1。希尔排序的出现就是为了改进插入排序的这种局限性,它通过交换不相邻的元素,每次可以将逆序数量减少大于 1。希尔排序使用插入排序对间隔 h 的序列进行排序。通过不断减小 h,最后令 h=1,就可以使得整个数组是有序的。
public void shellSort(int[] arr) {
if (arr.length < 1) {
return;
}
int n = arr.length;
for (int h = n / 2; h > 0; h = h / 2) {
for (int i = 0; i < n; i = i + h) {
int e = arr[i];
int j = i;
for (; j > 0; j = j - h) {
if (e < arr[j - h]){
arr[j] = arr[j - h];
} else {
break;
}
}
arr[j] = e;
}
}
}
1.5 桶排序
核心思想是将要排序的数据分到几个有序的桶里,每个桶里的数据再单独进行排序。桶内排完序之后,再把每个桶里的数据按照顺序依次取出,组成的序列就是有序的了。
时间复杂度接近 O(n);数据在各个桶之间的分布是比较均匀的。如果数据经过桶的划分之后,有些桶里的数据非常多,有些非常少,很不平均,那桶内数据排序的时间复杂度就不是常量级了。在极端情况下,如果数据都被划分到一个桶里,那就退化为 O(nlogn) 的排序算法了。
桶排序比较适合用在外部排序中。所谓的外部排序就是数据存储在外部磁盘中,数据量比较大,内存有限,无法将数据全部加载到内存中。
比较占空间
private int[] buckets=new int[10];
private int[] array;
public void sort(){
if(array!=null&&array.length>1){
for(int i=0;i<array.length;i++){
buckets[array[i]]++;
}
}
}
1.6 归并排序
归并排序的思想是将数组分成两部分,分别进行排序,然后归并起来。把长度为n的输入序列分成两个长度为n/2的子序列;对这两个子序列分别采用归并排序;将两个排序好的子序列合并成一个最终的排序序列。
归并排序是稳定排序,能利用完全二叉树特性的排序一般性能都不会太差。java中Arrays.sort()用了一种名为TimSort的排序算法,就是归并排序的优化版本。每次合并操作的平均时间复杂度为O(n),总的平均时间复杂度为O(nlogn)。而且,归并排序的最好,最坏,平均时间复杂度均为O(nlogn)。
归并排序的合并函数,在合并两个有序数组为一个有序数组时,需要借助额外的存储空间。递归代码的空间复杂度并不能像时间复杂度那样累加。尽管每次合并操作都需要申请额外的内存空间,但在合并完成之后,临时开辟的内存空间就被释放掉了。在任意时刻,CPU 只会有一个函数在执行,也就只会有一个临时的内存空间在使用。临时内存空间最大也不会超过 n 个数据的大小,所以空间复杂度是O(n)。
private void mergeSort(int[] arr) {
mergeSort(arr, 0, arr.length - 1);
}
private void mergeSort(int[] arr, int low, int high) {
if (low < high) {
int mid = (low + high) / 2;
mergeSort(arr, low, mid);
mergeSort(arr, mid + 1, high);
merge(arr, low, mid, high);
}
}
// merge() 合并函数借助哨兵
private void merge(int[] arr, int left, int mid, int right) {
int[] aux = Arrays.copyOfRange(arr, left, right + 1);
int i = left;
int j = mid + 1;
for (int k = left; k <= right; k++) {
if (i > mid) {
arr[k] = aux[j - left];
j++;
} else if (j > right) {
arr[k] = aux[i - left];
i++;
} else if (aux[i - left] < aux[j - left]) {
arr[k] = aux[i - left];
i++;
} else {
arr[k] = aux[j - left];
j++;
}
}
}
private void mergeSort2(int[] arr) {
int[] tmp = new int[arr.length];
mergeSort2(arr, 0, arr.length - 1, tmp);
}
private void mergeSort2(int[] arr, int low, int high, int[] tmp) {
if (low < high) {
int mid = (low + high) / 2;
mergeSort2(arr, low, mid, tmp);
mergeSort2(arr, mid + 1, high, tmp);
merge2(arr, low, mid, high, tmp);
}
}
private void merge2(int[] arr, int left, int mid, int right, int[] temp) {
int i = left;
int j = mid + 1;
int k = 0;
while (i <= mid && j <= right) {
if (arr[i] <= arr[j]) {
temp[k++] = arr[i++];
} else {
temp[k++] = arr[j++];
}
}
while (i <= mid) {
temp[k++] = arr[i++];
}
while (j <= right) {
temp[k++] = arr[j++];
}
k = 0;
while (left <= right) {
arr[left++] = temp[k++];
}
}
1.7 快速排序
排序算法的思想非常简单,在待排序的数列中,我们首先要找一个数字作为基准数。接下来我们需要把这个待排序的数列中小于基准数的元素移动到待排序的数列的左边,把大于基准数的元素移动到待排序的数列的右边。这时,左右两个分区的元素就相对有序了;接着把两个分区的元素分别按照上面两种方法继续对每个分区找出基准数,然后移动,直到各个分区只有一个数时为止。
快速排序是在实践中最快的已知排序算法,平均运行时间为O(NlogN),最坏的运行时间为O(N^2)。不稳定,最坏情况为完全顺序或逆序。
快排的思想是这样的:如果要排序数组中下标从 p 到 r 之间的一组数据,我们选择 p 到 r之间的任意一个数据作为 pivot(分区点)。遍历 p 到 r 之间的数据,将小于 pivot 的放到左边,将大于 pivot 的放到右边,将pivot 放到中间。经过这一步骤之后,数组 p 到 r 之间的数据就被分成了三个部分,前面 p到 q-1 之间都是小于 pivot 的,中间是 pivot,后面的 q+1 到 r 之间是大于 pivot 的。
根据分治、递归的处理思想,我们可以用递归排序下标从 p 到 q-1 之间的数据和下标从q+1 到 r 之间的数据,直到区间缩小为 1,就说明所有的数据都有序了。
归并排序的处理过程是由下到上的,先处理子问题,然后再合并。而快排正好相反,它的处理过程是由上到下的,先分区,然后再处理子问题。归并排序虽然是稳定的、时间复杂度为 O(nlogn) 的排序算法,但是它是非原地排序算法。我们前面讲过,归并之所以是非原地排序算法,主要原因是合并函数无法在原地执行。快速排序通过设计巧妙的原地分区函数,可以实现原地排序,解决了归并排序占用太多内存的问题。
举一个比较极端的例子。如果数组中的数据原来已经是有序的了,比如 1,3,5,6,8。如果我们每次选择最后一个元素作为 pivot,那每次分区得到的两个区间都是不均等的。我们需要进行大约 n 次分区操作,才能完成快排的整个过程。每次分区我们平均要扫描大约 n/2 个元素,这种情况下,快排的时间复杂度就从 O(nlogn) 退化成了 O(n2)。
public void quickSort(int[] arr) {
if (arr == null || arr.length < 2){
return;
}
quickSort(arr, 0, arr.length - 1);
}
public void quickSort(int[] arr, int left, int right) {
if (left < right) {
int index = partition(arr, left, right);
quickSort(arr, left, index - 1);
quickSort(arr, index + 1, right);
}
}
快排重点在于partition过程,分为以下三个方法:
- 固定基准法
public int partition(int[] arr, int left, int right) {
int p = arr[left];
int i = left;
int j = right;
while (i < j) {
while (arr[j] >= p && i < j) {
j--;
}
while (arr[i] <= p && i < j) {
i++;
}
swap(arr, i, j);
}
swap(arr, left, i);
return i;
}
- 单向扫描法
public int partition2(int[] arr, int low, int high) {
int i = low - 1;
int j = low;
int v = arr[high];
while (j < high) {
if (arr[j] <= v) {
swap(arr, ++i, j);
}
j++;
}
swap(arr, i + 1, high);
return i + 1;
}
- 三数取中法
我们从区间的首、尾、中间,分别取出一个数,然后对比大小,取这 3 个数的中间值作为分区点。这样每间隔某个固定的长度,取数据出来比较,将中间值作为分区点的分区算法,肯定要比单纯取某一个数据更好。但是,如果要排序的数组比较大,那“三数取中”可能就不够了,可能要“五数取中”或者“十数取中”。
private int partition3(int[] arr, int left, int right) {
dealPivot(arr, left, right);
int pivot = right - 1;
int i = left;
int j = right - 1;
while (true) {
while (arr[++i] < arr[pivot]) {
}
while (j > left && arr[--j] > arr[pivot]) {
}
if (i < j) {
swap(arr, i, j);
} else {
break;
}
}
if (i < right) {
swap(arr, i, right - 1);
}
return i;
}
private void dealPivot(int[] arr, int left, int right) {
int mid = (left + right) / 2;
if (arr[left] > arr[mid]) {
swap(arr, left, mid);
}
if (arr[left] > arr[right]) {
swap(arr, left, right);
}
if (arr[right] < arr[mid]) {
swap(arr, right, mid);
}
swap(arr, right - 1, mid);
}
- 补充: 荷兰国旗问题
该算法核心在于将数值分为三等分
// 0 2 1 2 0 2 1 1 0
// 0 0 0 1 1 1 2 2 2
// p为中位值,例如:1
public int[] partition(int[] arr, int l, int r, int p) {
// 左边界
int less = l-1;
// 右边界
int more = r+1;
// 当前值
int k = l;
while (k < more) {
if (arr[k] < p) {
// 小于中间值,指针与左边界移动
swap(arr, ++less, k++);
} else if (arr[k] > p) {
// 大于中间值,右边界移动
swap(arr, --more, k);
} else {
k++;
}
}
return new int[]{less + 1, more - 1};
}
public void swap(int[] arr,int x,int y){
int tmp = arr[x];
arr[x] = arr[y];
arr[y] = tmp;
}
1.8 堆排序
堆的某个节点的值总是大于等于子节点的值,并且堆是一颗完全二叉树。堆可以用数组来表示,因为堆是完全二叉树,而完全二叉树很容易就存储在数组中。位置 k 的节点的父节点位置为 k/2,而它的两个子节点的位置分别为 2k 和 2k+1
堆排序是一种选择排序,整体主要由构建初始堆+交换堆顶元素和末尾元素并重建堆两部分组成。构建初始堆经推导复杂度为O(n),在交换并重建堆的过程中,需交换n-1次,而重建堆的过程近似为nlogn。所以堆排序时间复杂度一般认为就是O(nlogn)
级,它也是不稳定排序。
一般升序采用大顶堆,降序采用小顶堆
/**
* 构建大顶堆
* 1、从第一个非叶子结点从下至上,
* 从右至左调整结构
* 2、堆顶元素最大
* 将堆顶元素与尾部元素交换
* 调整堆,重复
*
* @param arr
*/
public void heapSort(int[] arr) {
for (int i = arr.length / 2 - 1; i >= 0; i--) {
adjustHeap(arr, i, arr.length);
}
for (int j = arr.length - 1; j > 0; j--) {
swap(arr, 0, j);
adjustHeap(arr, 0, j);
}
}
private void adjustHeap(int[] arr, int i, int length) {
int temp = arr[i];
for (int k = i*2+1; k < length; k = k*2+1) {
if (k + 1 < length && arr[k] < arr[k + 1]) {
k++;
}
if (arr[k] > temp) {
arr[i] = arr[k];
i = k;
} else {
break;
}
}
arr[i] = temp;
}
1.9 计数排序
计数排序其实是桶排序的一种特殊情况。当要排序的 n 个数据,所处的范围并不大的时候,比如最大值是 k,我们就可以把数据划分成 k 个桶。每个桶内的数据值都是相同的,省掉了桶内排序的时间。
考生的满分是 900 分,最小是 0 分,这个数据的范围很小,所以我们可以分成 901 个桶,对应分数从 0 分到 900 分。根据考生的成绩,我们将这 50 万考生划分到这 901 个桶里。桶内的数据都是分数相同的考生,所以并不需要再进行排序。我们只需要依次扫描每个桶,将桶内的考生依次输出到一个数组中,就实现了 50 万考生的排序。因为只涉及扫描遍历操作,所以时间复杂度是 O(n);
1.10 基数排序
假设要比较两个手机号码 a,b 的大小,如果在前面几位中,a 手机号码已经比 b 手机号码大了,那后面的几位就不用看了。根据每一位来排序,我们可以用刚讲过的桶排序或者计数排序,它们的时间复杂度可以做到O(n)。如果要排序的数据有 k 位,那我们就需要 k 次桶排序或者计数排序,总的时间复杂度是 O(k*n)。当 k 不大的时候,比如手机号码排序的例子,k 最大就是 11,所以基数排序的时间复杂度就近似于 O(n)