主要对常见的排序算法做一些总结,针对各种算法的应用场景以及时间空间度的分析。这部分也是在校招面试过程中常见的考点。
常见的排序算法如下:
直接插入排序
希尔排序
简单选择排序
堆排序
冒泡排序
快速排序
归并排序
基数排序
它们都属于内部排序,所谓内部排序就是排序的数据是在内存上的,这也就告诉我们,以上几种排序算法适用于数据量不大的情况下。它们之间的关系如下:
稳定与非稳定
如果一个排序算法能够保留数组的中重复元素的相对位置则可以被称为是稳定的,反之就是不稳定的。 (A1=A2,排序前A1在A2前面,排序后A1还在A2前面)。
直接插入排序
基本思想
通常人们在整理桥牌的方法是一张一张的来,将每一张牌插入到其他已经有序的牌中的适当位置。在计算机的实现中,为了给即将插入的元素腾出空间,我们需要将剩余的所有元素在插入之前都向右移动一位。
算法描述
一般来说,插入排序是采用in-place的方式在原数组上直接操作的,并不需要重新开辟新的数组空间。具体实现如下:
- 从第一个元素开始,该元素可以认为已经被排好序。
- 取出下一个元素,在已经排好序的元素序列中从后向前扫描。
- 如果该元素(已排好序)大于新的元素,那么将该排好序的元素移动到下一位。
- 重复步骤3,知道找到已经排好序的元素里面小于或者等于新的即将要插入的元素位置。
- 将新的元素插入到该位置后。
- 重复步骤2-5.
代码实现
/*
通过交换进行插入排序,借鉴冒泡排序,理解为水平冒泡
*/
public static void sort(int[] a){
for(int i=0;i<a.length-1;i++){
for(int j=i+1;j>0;j--){
if(a[j]<a[j-1]){
int temp=a[j];
a[j]=a[j-1];
a[j-1]=temp;
}
}
}
}
/*
通过将较大的元素都向右移动而不总是交换两个元素
*/
public static void sort2(){
for(int i=1;i<a.length;i++){
int j;
int temp=a[i];
for(j=i;j>0 && temp<a[j-1];j--){
a[j]=a[j-1];
}
a[j]=temp;
}
}
复杂度分析
希尔排序
希尔排序,也称 递减增量排序算法,是插入排序的一种更高效的改进版本。希尔排序是 非稳定排序算法。
希尔排序是基于插入排序的以下两点性质而提出改进方法的:
插入排序在对几乎已经排好序的数据操作时,效率高,即可以达到线性排序的效率
但插入排序一般来说是低效的,因为插入排序每次只能将数据移动一
希尔排序是先将整个待排序的记录序列分割成为若干子序列分别进行直接插入排序,待整个序列中的记录“基本有序”时,再对全体记录进行依次直接插入排序。
基本思想
选择一个增量序列 t1,t2,……,tk,其中 ti > tj, tk = 1;
按增量序列个数 k,对序列进行 k 趟排序;
每趟排序,根据对应的增量 ti,将待排序列分割成若干长度为 m 的子序列,分别对各子表进行直接插入排序。仅增量因子为 1 时,整个序列作为一个表来处理,表长度即为整个序列的长度。
效果如下:
代码实现
public static void shell_Sort(int[] a){
//获取数组的长度
int N=a.length;
//第一轮循环决定分组比较的间隔
for(int i=N/2;i>0;i=i/2){
//第二轮循环是根据间隔,将整个数组分成的若干子数组个数
for(int j=i;j<N;j++){
//第三轮循环在划分的每个子数组中利用直接插入的排序算法进行排序
for(int k=j;k>=i && a[k]<a[k-i];k=k-i){
int temp=a[k];
a[k]=a[k-i];
a[k-i]=temp;
}
}
}
}
复杂度分析
希尔排序更高效的原因是它权衡了子数组的规模和有序性。排序之初,各个子数组都很短,排序之后子数组都是部分有序的,这两种情况都很适合插入排序。
简单选择排序
基本思想
选择排序(Selection sort)是一种简单直观的排序算法。它的工作原理如下。首先在未排序序列中找到最小(大)元素,存放到排序序列的起始位置,然后,再从剩余未排序元素中继续寻找最小(大)元素,然后放到已排序序列的末尾。以此类推,直到所有元素均排序完毕。
选择排序的主要优点与数据移动有关。如果某个元素位于正确的最终位置上,则它不会被移动。选择排序每次交换一对元素,它们当中至少有一个将被移到其最终位置上,因此对 n个元素的表进行排序总共进行至多 n-1 次交换。在所有的完全依靠交换去移动元素的排序方法中,选择排序属于非常好的一种。
算法描述
从未排序序列中,找到关键字最小的元素
如果最小元素不是未排序序列的第一个元素,将其和未排序序列第一个元素互换
重复1、2步,直到排序结束。
代码实现
public static void sort(int[] a){
for(int i=0;i<a.length;i++){
int min=i;
for(int j=i+1;j<a.length;j++){
if(a[j]<a[min]){
min=j;
}
}
//最小值不等于当前值时进行交换
if(min!=i){
int temp=a[i];
a[i]=a[min];
a[min]=temp;
}
}
}
复杂度分析
选择排序的简单和直观名副其实,这也造就了它”出了名的慢性子”,无论是哪种情况,哪怕原数组已排序完成,它也将花费将近n²/2次遍历来确认一遍。即便是这样,它的排序结果也还是不稳定的。 唯一值得高兴的是,它并不耗费额外的内存空间。
堆排序
此处以大顶堆为例,堆排序的过程就是将待排序的序列构造成一个堆,选出堆中最大的移走,再把剩余的元素调整成堆,找出最大的再移走,重复直至有序。
堆是具有以下性质的完全二叉树:每个结点的值都大于或等于其左右孩子结点的值,称为大顶堆;或者每个结点的值都小于或等于其左右孩子结点的值,称为小顶堆。如下图:
堆排序的基本思想是:将待排序序列构造成一个大顶堆,此时,整个序列的最大值就是堆顶的根节点。将其与末尾元素进行交换,此时末尾就为最大值。然后将剩余n-1个元素重新构造成一个堆,这样会得到n个元素的次小值。如此反复执行,便能得到一个有序序列了。
步骤一 构造初始堆。将给定无序序列构造成一个大顶堆(一般升序采用大顶堆,降序采用小顶堆)。
2、此时我们从最后一个非叶子结点开始(叶结点自然不用调整,第一个非叶子结点 arr.length/2-1=5/2-1=1,也就是下面的6结点),从左至右,从下至上进行调整。
3、找到第二个非叶节点4,由于[4,9,8]中9元素最大,4和9交换。
这时,交换导致了子根[4,5,6]结构混乱,继续调整,[4,5,6]中6最大,交换4和6。
此时,我们就将一个无需序列构造成了一个大顶堆。
步骤二 将堆顶元素与末尾元素进行交换,使末尾元素最大。然后继续调整堆,再将堆顶元素与末尾元素交换,得到第二大元素。如此反复进行交换、重建、交换。
重新调整结构,使其继续满足堆定义
再将堆顶元素8与末尾元素5进行交换,得到第二大元素8.
后续过程,继续进行调整,交换,如此反复进行,最终使得整个序列有序
再简单总结下堆排序的基本思路:
a.将无需序列构建成一个堆,根据升序降序需求选择大顶堆或小顶堆;
b.将堆顶元素与末尾元素交换,将最大元素"沉"到数组末端;
c.重新调整结构,使其满足堆定义,然后继续交换堆顶元素与当前末尾元素,反复执行调整+交换步骤,直到整个序列有序。
代码实现
public class HeapSort {
public static void main(String []args){
int []arr = {7,6,7,11,5,12,3,0,1};
System.out.println("排序前:"+Arrays.toString(arr));
sort(arr);
System.out.println("排序前:"+Arrays.toString(arr));
}
public static void sort(int []arr){
//1.构建大顶堆
for(int i=arr.length/2-1;i>=0;i--){
//从第一个非叶子结点从下至上,从右至左调整结构
adjustHeap(arr,i,arr.length);
}
//2.调整堆结构+交换堆顶元素与末尾元素
for(int j=arr.length-1;j>0;j--){
swap(arr,0,j);//将堆顶元素与末尾元素进行交换
adjustHeap(arr,0,j);//重新对堆进行调整
}
}
/**
* 调整大顶堆(仅是调整过程,建立在大顶堆已构建的基础上)
*/
public static void adjustHeap(int []arr,int i,int length){
int temp = arr[i];//先取出当前元素i
for(int k=i*2+1;k<length;k=k*2+1){//从i结点的左子结点开始,也就是2i+1处开始
if(k+1<length && arr[k]<arr[k+1]){//如果左子结点小于右子结点,k指向右子结点
k++;
}
if(arr[k] >temp){//如果子节点大于父节点,将子节点值赋给父节点(不用进行交换)
arr[i] = arr[k];
i = k;
}else{
break;
}
}
arr[i] = temp;//将temp值放到最终的位置
}
/**
* 交换元素
*/
public static void swap(int []arr,int a ,int b){
int temp=arr[a];
arr[a] = arr[b];
arr[b] = temp;
}
}
复杂度分析
建立堆的过程, 从length/2 一直处理到0, 时间复杂度为O(n);
调整堆的过程是沿着堆的父子节点进行调整, 执行次数为堆的深度, 时间复杂度为O(lgn);
堆排序的过程由n次第2步完成, 时间复杂度为O(nlgn).
由于堆排序中初始化堆的过程比较次数较多, 因此它不太适用于小序列。 同时由于多次任意下标相互交换位置, 相同元素之间原本相对的顺序被破坏了, 因此, 它是不稳定的排序。
冒泡排序
基本思想
冒泡排序(Bubble Sort)是一种简单的排序算法。它重复地走访过要排序的数列,一次比较两个元素,如果他们的顺序错误就把他们交换过来。走访数列的工作是重复地进行直到没有再需要交换,也就是说该数列已经排序完成。这个算法的名字由来是因为越小的元素会经由交换慢慢“浮”到数列的顶端。
算法描述
冒泡排序算法的运作如下:
比较相邻的元素。如果第一个比第二个大,就交换他们两个。
对每一对相邻元素作同样的工作,从开始第一对到结尾的最后一对。这步做完后,最后的元素会是最大的数。
针对所有的元素重复以上的步骤,除了最后一个。
持续每次对越来越少的元素重复上面的步骤,直到没有任何一对数字需要比较。
代码实现
public static void sort(int[] a) {
//外层循环控制比较的次数
for (int i = 0; i < a.length - 1; i++) {
//内层循环控制到达位置
for (int j = 0; j < a.length - i - 1; j++) {
//前面的元素比后面大就交换
if (a[j] > a[j + 1]) {
int temp = a[j];
a[j] = a[j + 1];
a[j + 1] = temp;
}
}
}
}
复杂度分析
以下是冒泡排序算法复杂度:
冒泡排序是最容易实现的排序, 最坏的情况是每次都需要交换, 共需遍历并交换将近n²/2次, 时间复杂度为O(n²). 最佳的情况是内循环遍历一次后发现排序是对的, 因此退出循环, 时间复杂度为O(n). 平均来讲, 时间复杂度为O(n²). 由于冒泡排序中只有缓存的temp变量需要内存空间, 因此空间复杂度为常量O(1).
由于冒泡排序只在相邻元素大小不符合要求时才调换他们的位置, 它并不改变相同元素之间的相对顺序, 因此它是稳定的排序算法。
快速排序
快速排序,说白了就是给基准数据找其正确索引位置的过程。如下图所示,假设最开始的基准数据为数组第一个元素23,则首先用一个临时变量去存储基准数据,即tmp=23;然后分别从数组的两端扫描数组,设两个指示标志:low指向起始位置,high指向末尾。
首先从后半部分开始,如果扫描到的值大于基准数据就让high减1,如果发现有元素比该基准数据的值小(如上图中18<=tmp),就将high位置的值赋值给low位置 ,结果如下:
然后开始从前往后扫描,如果扫描到的值小于基准数据就让low加1,如果发现有元素大于基准数据的值(如上图46=>tmp),就再将low位置的值赋值给high位置的值,指针移动并且数据交换后的结果如下
然后再开始从后向前扫描,原理同上,发现上图11<=tmp,则将low位置的值赋值给high位置的值,结果如下:
然后再开始从前往后遍历,直到low=high结束循环,此时low或high的下标就是基准数据23在该数组中的正确索引位置.如下图所示
这样一遍走下来,可以很清楚的知道,其实快速排序的本质就是把基准数大的都放在基准数的右边,把比基准数小的放在基准数的左边,这样就找到了该数据在数组中的正确位置.
以后采用递归的方式分别对前半部分和后半部分排序,当前半部分和后半部分均有序时该数组就自然有序了。
public class QuickSort {
public static void main(String[] args) {
int[] arr = { 49, 38, 65, 97, 23, 22, 76, 1, 5, 8, 2, 0, -1, 22 };
quickSort(arr, 0, arr.length - 1);
System.out.println("排序后:");
for (int i : arr) {
System.out.println(i);
}
}
private static void quickSort(int[] arr, int low, int high) {
if (low < high) {
// 找寻基准数据的正确索引
int index = getIndex(arr, low, high);
// 进行迭代对index之前和之后的数组进行相同的操作使整个数组变成有序
quickSort(arr, low, index - 1);
quickSort(arr, index + 1, high);
}
}
private static int getIndex(int[] arr, int low, int high) {
// 基准数据
int tmp = arr[low];
while (low < high) {
// 当队尾的元素大于等于基准数据时,向前挪动high指针
while (low < high && arr[high] >= tmp) {
high--;
}
// 如果队尾元素小于tmp了,需要将其赋值给low
arr[low] = arr[high];
// 当队首元素小于等于tmp时,向前挪动low指针
while (low < high && arr[low] <= tmp) {
low++;
}
// 当队首元素大于tmp时,需要将其赋值给high
arr[high] = arr[low];
}
// 跳出循环时low和high相等,此时的low或high就是tmp的正确索引位置
// 由原理部分可以很清楚的知道low位置的值并不是tmp,所以需要将tmp赋值给arr[low]
arr[low] = tmp;
return low; // 返回tmp的正确位置
}
}
复杂度分析
归并排序
基本思想
归并排序是利用归并的思想实现的排序方法,该算法采用经典的分治(divide-and-conquer)策略(分治法将问题分(divide)成一些小的问题然后递归求解,而治(conquer)的阶段则将分的阶段得到的各个结果有序的合并在一起,即分而治之)。
治阶段,我们需要将两个已经有序的子序列合并成一个有序序列,比如上图中的最后一次合并,要将[4,5,7,8]和[1,2,3,6]两个已经有序的子序列,合并为最终序列[1,2,3,4,5,6,7,8],来看下实现步骤
代码如下
public class MergerSort {
public static void main(String[] args) {
Long startTime = System.currentTimeMillis();
//int[] array = new int[]{10, 1, 9, 2, 8, 3, 7, 4, 6, 5};
int[] array = new int[100000];
for (int i = 0; i < 100000; i++) {
array[i] = (int) (Math.random() * 100000);
}
mergeSort(array,0,array.length-1);
}
public static void mergeSort(int[] array, int low, int high){
if(low<high){ //这一步确定保证排序的只有一个元素
//中间索引
int mid=(low+high)/2;
//左递归分解
mergeSort(array,low,mid);
//右递归分解
mergeSort(array,mid+1,high);
//有序合并
merge(array,low ,mid,high);
}
}
private static void merger(int array[], int low, int mid, int high){
int[] temp=new int[high-low+1];
//左侧有序序列起始坐标
int i=low;
// 右侧有序序列起始索引
int j = mid + 1;
// 临时数组起始索引
int k = 0;
// 左右两侧的有序序列按照顺序填充至temp数组
while (i <= mid && j <= high) {
if (array[i] <= array[j]) {
temp[k++] = array[i++];
} else {
temp[k++] = array[j++];
}
}
// 左侧数组还剩余元素入temp
while (i <= mid) {
temp[k++] = array[i++];
}
// 右侧数组还剩余元素入temp
while (j <= high) {
temp[k++] = array[j++];
}
// 将temp数组复制到array
for (int x = 0; x < temp.length; x++) {
array[x + low] = temp[x];
}
}
}
复杂度分析
从效率上看,归并排序可算是排序算法中的”佼佼者”. 假设数组长度为n,那么拆分数组共需logn,, 又每步都是一个普通的合并子数组的过程, 时间复杂度为O(n), 故其综合时间复杂度为O(nlogn)。另一方面, 归并排序多次递归过程中拆分的子数组需要保存在内存空间, 其空间复杂度为O(n)。归并排序最吸引人的性质是它能够保证将任意长度为N的数组排序所需时间和NlogN成正比,它的主要缺点则是他所需的额外空间和N成正比。