数据结构(六) 排序
一、排序(Sorting)
概念: 重排数据元素,使其按关键字有序。
内部排序:待排序的数据元素全部存入计算机内存中,排序过程中不需要访问外存;
外部排序:待排序的数据元素不能全部装入内存,排序过程中需要不断访问外存。
主要讨论内部排序。
按排序过程中依据的原则不同:插入排序、交换排序、选择排序、归并排序。
按排序过程的工作量:
简单排序,时间复杂度O(n^2);
先进排序,时间复杂度O(n logn);
基数排序,时间复杂度O(d*n)。
对于排序表,假设每个数据元素可以与一个关键字相关,并且记录都可以按关键字进行比较,可以使用类类型转换函数自动将数据元素转换为关键字类型,从而实现对数据元素的比较自动转换为关键字的比较。
二、插入排序
直接插入排序:
一种简单的排序算法,基本思想是将第一个数据元素看成是一个有序子序列,再依次从第二个数据元素起逐个插入到这个有序的子序列中。
第i趟插入排序InsertSort,当elem[i]比它前面的元素小时,就向前移动此元素,直到遇到一个比它小或相等的元素为止。
时间复杂度:O(n^2)
插入排序一般是低效的。
插入排序的第n躺排序,可以有两种方式把元素放到正确的位置:第一种是比较一次交换一次,直到正确的位置;第二种是先比较,然后右移不满足比较条件的元素(不交换),最后再将元素放入正确的位置。
/*比较一次,交换一次*/ public static void insertion_sort( int[] arr ){ for( int i=0; i<arr.length-1; i++ ) { for( int j=i+1; j>0; j-- ) { if( arr[j-1] <= arr[j] ) break; int temp = arr[j]; arr[j] = arr[j-1]; arr[j-1] = temp; } } } /*比较一次,右移一次元素,直到正确的位置*/ public static void directInsert(int arr[]){ for(int i=1;i<arr.length;i++){ int temp=arr[i]; int j=i-1; for(;(j>=0&&temp<arr[j]);j--){ arr[j+1]=arr[j]; } arr[j+1]=temp; } }
Shell希尔排序
也称递减增量排序算法,对插入排序的一种更高效的改进版本,非稳定算法。插入排序最坏情况下时间复杂度为O(n^2),最好情况下时间复杂度为O(n)。
希尔排序基于以下两点性质而提出改进:
若待排序元素按关键字基本有序,插入排序的效率将大大提高,达到线性排序的效率;
元素个数n较小的时候,效率也会比较高。
基本思想:
先将整个待排序的全部数据元素序列分割成几个区域(若干个子序列),然后分别对各子序列进行插入排序,这样做可以让一个元素一次性地朝最终位置前进一大步。接着算法再取越来越小的步长进行排序,最后再对全体数据元素进行一次直接插入排序,这时整个序列已经“基本有序”了。
假设有一个很小的数据在一个已按升序排好序的数组的末端。如果用复杂度为O(n^2)的排序(冒泡排序或插入排序),可能会进行n次的比较和交换才能将该数据移至正确位置。而希尔排序会用较大的步长移动数据,所以小数据只需进行少数比较和交换即可到正确位置。
一个更好理解的希尔排序实现:
可以认为将数组列在一个表中并对列排序(用插入排序)。重复这过程,不过每次用更长的列来进行。最后整个表就只有一列了。将数组转换至表是为了更好地理解这算法,算法本身仅仅对原数组进行排序(通过增加索引的步长,例如是用i += step_size而不是i++)。
对于一般的排序算法,排序过程是从增量由(n/2)到1的过程。增量是子序列中相邻元素的下标差。当步长为1时,算法变为插入排序,这就保证了数据一定会被排序。
关于步长的选择,参考wiki。
/*希尔排序*/ public static void shell_sort(int[] arr) { int gap = 1; int i, j; int len = arr.length; int temp; while (gap < len / 3) gap = gap * 3 + 1; // <O(n^(3/2)) by Knuth,1973>: 1, 4, 13, 40, 121, ... for (; gap > 0; gap /= 3) for (i = gap; i < len; i++) { temp = arr[i]; for (j = i - gap; j >= 0 && arr[j] > temp; j -= gap) arr[j + gap] = arr[j]; arr[j + gap] = temp; } }
三、交换排序
最简单的是起泡排序(Bubble Sort),最先进的是快速排序(Quick Sort)。
Bubble Sort
将序列中的第1个元素与第2个元素比较,若前>后,交换位置,否则不交换;再将第2个元素与第3个元素比较,若前>后,交换位置,否则不交换;
……依此进行,直到第n-1个元素与第n个元素比较。
经过一趟排序后,使得n个元素最大者被安置在第n个位置上。
这时,再对前n-1个元素进行同样的过程~
直到对前2个元素完成排序。
起泡排序的最好、最坏和平均情况下的时间复杂度是相同的:O(n^2)。
/*冒泡排序*/ public static void bubbleSort(int arr[]){ int len=arr.length; while(len>0) { for (int i = 0; i < len-1; i++) { if (arr[i] > arr[i + 1]) { //交换两个元素 exChange(arr,i,i+1); } } len--; } }
Quick Sort
快速排序又称划分交换排序
基本思想:任选序列中的一个数据元素(通常选第1个)作为枢轴(pivot),以它和所有剩余数据元素进行比较,将所有比它小的数据元素排在它前面,将比它大的数据元素排在它后面。经过一趟排序,可按此数据元素所在位置为界,将序列划分为两个部分,再对这两个部分重复上述过程至每一部分中只剩下一个数据元素为止。
平均时间复杂度:O(nlogn)
最坏:O(n^2)
快排平均时间性能最快,但是在初始序列有序的情况下,快排时间性能最差,退化为起泡排序。
关于快排中序列的切分,一般策略是先随意地取第一个元素作为切分元素,这个元素会在一次排序后位置被排定。然后我们从数组的左端开始扫描直到找到一个大于等于它的元素,再从数组的右端开始向左扫描直到找到一个小于等于它的元素,交换他们的位置。这样我们完全可以保证左指针左边的元素都不大于切分元素,右指针右边的元素都不小切分元素。当两个指针相遇时,只需要将切分元素与左子数组最右侧的元素(a[j])交换,然后返回j。
/*快排*/ public static void qSort(int arr[] ){ sort(arr,0,arr.length-1); } private static void sort(int []arr,int head,int tail ){ if(head>=tail) return; int j=partition(arr,head,tail); sort(arr,head,j-1); sort(arr,j+1,tail); } private static int partition(int []a,int head,int tail){ //将数组切分为a[head, ... ,i-1],a[i],a[i+1, ... ,tail] int i=head,j=tail+1; int pivot=a[head]; //切分元素 while(true) { while (a[++i] < pivot) { if (i == tail) break; } while (a[--j] > pivot) { if (j == head) break; } if (i >= j) break; SortHelper.exChange(a,i,j); //交换元素 } //将pivot放入正确的位置 SortHelper.exChange(a,head,j); return j; }
四、选择排序
选择排序的基本思想是:每一趟在n-i个元素中选择最小的数据元素作为有序序列的第i个数据元素。
简单选择排序
O(n^2)
从未排序的序列中选择最小元素,接着是次小的,依此类推。为寻找下一个最小元素,需检索数组整个未排序部分,但只一次交换即将待排序元素放到正确位置上。
形象来说,选择排序是固定位置,选择元素。从第一个位置起寻找对应的元素。
/*选择排序*/ /*每次选择最大的元素放入数组末尾*/ public static void selectSort(int arr[]){ int n=arr.length-1; while(n>=0){ int max=0; for(int i=0;i<=n;i++){ if(arr[i]>arr[max]){ max=i; } } SortHelper.exChange(arr,n,max); n--; } }
堆排序(Heap Sort)
基于选择排序的先进排序方法,只需要一个元素的辅助存储空间。
堆:
定义:n个元素的序列{k1,k2,...,kn}当且仅当满足以下关系:
{k(i)<=k(2i),k(i)<=k(2i+1)}
or {k(i)>=k(2i),k(i)>=k(2i+1)}
若将满足堆定义的关系的序列的一维数组看成是一个完全二叉树,则堆的含义表明,完全二叉树中所有非终端结点的值均不大于(或不小于)其左、右孩子结点的值。
小顶堆、大顶堆:
若序列{k1,k2,...,kn}是堆,则堆顶元素(完全二叉树的根)必为序列中n个元素的最小值(or最大值)。
对于大顶堆,堆顶元素最大,在输出堆顶元素之后,如果能使剩下的n-1个元素重新构成一个堆,则可以得到次大的元素,照此进行,将得到有序序列。
因此,实现堆排序需要实现如下算法:
(1) 将一个无序序列构成一个堆;
(2) 在输出堆顶元素后,调整剩余元素成为一个新的堆。
在最坏的情况下时间复杂度为O(nlogn),相对快排,这是最大的优势。而且只占用一个用于交换元素的临时存储空间,在实现时比快排用栈更节约存储空间。
import java.util.Arrays; public class HeapSort { private int[] arr; public HeapSort(int[] arr){ this.arr = arr; } /** * 堆排序的主要入口方法,共两步。 */ public void sort(){ /* * 第一步:将数组堆化 * beginIndex = 第一个非叶子节点。 * 从第一个非叶子节点开始即可。无需从最后一个叶子节点开始。 * 叶子节点可以看作已符合堆要求的节点,根节点就是它自己且自己以下值为最大。 */ int len = arr.length - 1; int beginIndex = (len - 1) >> 1; for(int i = beginIndex; i >= 0; i--){ maxHeapify(i, len); } /* * 第二步:对堆化数据排序 * 每次都是移出最顶层的根节点A[0],与最尾部节点位置调换,同时遍历长度 - 1。 * 然后从新整理被换到根节点的末尾元素,使其符合堆的特性。 * 直至未排序的堆长度为 0。 */ for(int i = len; i > 0; i--){ swap(0, i); maxHeapify(0, i - 1); } } private void swap(int i,int j){ int temp = arr[i]; arr[i] = arr[j]; arr[j] = temp; } /** * 调整索引为 index 处的数据,使其符合堆的特性。 * * @param index 需要堆化处理的数据的索引 * @param len 未排序的堆(数组)的长度 */ private void maxHeapify(int index,int len){ int li = (index << 1) + 1; // 左子节点索引 int ri = li + 1; // 右子节点索引 int cMax = li; // 子节点值最大索引,默认左子节点。 if(li > len) return; // 左子节点索引超出计算范围,直接返回。 if(ri <= len && arr[ri] > arr[li]) // 先判断左右子节点,哪个较大。 cMax = ri; if(arr[cMax] > arr[index]){ swap(cMax, index); // 如果父节点被子节点调换, maxHeapify(cMax, len); // 则需要继续判断换下后的父节点是否符合堆的特性。 } } /** * 测试用例 * * 输出: * [0, 0, 0, 1, 1, 1, 2, 2, 2, 3, 3, 3, 4, 4, 4, 5, 5, 5, 6, 6, 6, 7, 7, 7, 8, 8, 8, 9, 9, 9] */ public static void main(String[] args) { int[] arr = new int[]{3,5,3,0,8,6,1,5,8,6,2,4,9,4,7,0,1,8,9,7,3,1,2,5,9,7,4,0,2,6}; new HeapSort(arr).sort(); System.out.println(Arrays.toString(arr)); } }
五、归并排序(Merging Sort)
归并是指将两个有序子序列合并成一个新的有序子序列。设在初始序列里有n 个元素,归并排序的基本思想是:
将序列看成n个有序的子序列,每个序列长度为1,然后两两归并……重复下去,直到得到一个长度为n的有序序列。这是2-路归并排序。
如果每次将3个有序子序列合并为一个新的有序序列,则称为3-路归并排序。只不过2路完全可以满足内部排序的需要,
归并排序的时间代价并不依赖于待排序列数组的初始情况,也就是归并排序的最好、平均、最坏的时间复杂度都为O(nlogn),这一点比快排更好,而且归并排序是稳定的。当然,在平均情况下,快排最快~
归并排序的实现有递归和迭代两种方式:
/*递归方式*/ static void merge_sort_recursive(int[] arr, int[] result, int start, int end) { if (start >= end) return; int len = end - start, mid = (len >> 1) + start; int start1 = start, end1 = mid; int start2 = mid + 1, end2 = end; merge_sort_recursive(arr, result, start1, end1); merge_sort_recursive(arr, result, start2, end2); int k = start; while (start1 <= end1 && start2 <= end2) result[k++] = arr[start1] < arr[start2] ? arr[start1++] : arr[start2++]; while (start1 <= end1) result[k++] = arr[start1++]; while (start2 <= end2) result[k++] = arr[start2++]; for (k = start; k <= end; k++) arr[k] = result[k]; } public static void merge_sort(int[] arr) { int len = arr.length; int[] result = new int[len]; merge_sort_recursive(arr, result, 0, len - 1); } /*迭代方式*/ public static void merge_sort(int[] arr) { int len = arr.length; int[] result = new int[len]; int block, start; // 原版代码的迭代次数少了一次,没有考虑到奇数列数组的情况 for(block = 1; block < len; block *= 2) { for(start = 0; start <len; start += 2 * block) { int low = start; int mid = (start + block) < len ? (start + block) : len; int high = (start + 2 * block) < len ? (start + 2 * block) : len; //两个块的起始下标及结束下标 int start1 = low, end1 = mid; int start2 = mid, end2 = high; //开始对两个block进行归并排序 while (start1 < end1 && start2 < end2) { result[low++] = arr[start1] < arr[start2] ? arr[start1++] : arr[start2++]; } while(start1 < end1) { result[low++] = arr[start1++]; } while(start2 < end2) { result[low++] = arr[start2++]; } } int[] temp = arr; arr = result; result = temp; } result = arr; }
六、内部排序方法讨论
关于排序算法的稳定性:假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,ri=rj ,且ri在rj之前,而在排序后的序列中,ri仍在rj之前,则称这种排序算法是稳定的;否则称为不稳定的。
平均时间性能快排最优,但最坏情况下,快排不如堆排序和归并排序。n较大时,归并排序所需时间较堆排序较少,但它所需的辅助存储量最多。
当序列中的记录基本有序或n较小时,直接插入排序是最佳的排序方法,因此常将它与快排、归并排序结合一起使用。