面试必问——排序算法
概述
- 排序:使一串记录,按照其中的某个或者某些关键字的大小,递增或递减的排列起来。
- 稳定性:两个相等的数据,如果经过排序之后,能够保证相对位置不发生变化,该排序算法就是稳定的。
- 原地排序:通常情况下的排序都是原地排序。
- 内外排序
- 内排序:所有的排序操作都在内存中完成
- 外排序:由于数据太大,因此把数据放在磁盘中,而排序通过磁盘和内存的数据传输才能进行
- 时间复杂度:描述算法运行的时间
- 空间复杂度:描述算法运行所需要的内存空间大小
- 比较排序与非比较排序:
- 常见的快速排序、归并排序、冒泡排序、堆排序等都属于比较排序。在排序的最终结果里,元素的次序依赖于它们之间的比较,每个数都必须和其他数进行比较,才能确定自己的位置。
- 基数排序、计数排序、桶排序属于非比较排序。非比较排序是通过确定每个元素之前,应该有多少个元素来排序。
排序方法总览:
基于比较的排序
插入排序
直接插入排序
基本原理及思路
整个区间被分为有序区间和无序区间两个部分,前面是有序区间,后面是无序区间。
每次从无序区间的第一个元素,依次和有序区间的元素进行比较,选择合适的位置进行插入。
代码
public static void insertSort(int[] array){
//认为第一个是有序的
for (int i=1;i<array.length;i++){
//有序区间[0,i)
//无序区间[i,arr.length]
int key = array[i];// 无序区间的第一个数
int j =0;
//无序区间的第一个数是下标为i的元素,要在有序区间从后往前进行比较
for (j=i-1;j>=0;j--){
if (key < array[j]){
array[j+1] = array[j];
}else {
break;
}
}
array[j+1] = key;
}
}
性能分析
稳定性:稳定
插入排序,初始的数据越接近有序,时间效率越高。
希尔排序
基本原理及思路
希尔排序又称缩小增量排序,它的实质是分组进行插入排序。
希尔排序是把记录按下标的一定增量分组,对每组使用直接插入排序算法排序;
随着增量逐渐减少,每组包含的关键词越来越多,当增量减至1时,恰好被分成一组,算法便终止。
代码
public static void shellSort(int[] array){
//分组进行插排
int gap = array.length/2;
while (true){
insertSortGap(array,gap);
if (gap == 1){
break;
}
gap = gap/2;
}
}
//插排
private static void insertSortGap(int[] array, int gap) {
//从gap位置开始
for (int i = gap;i<array.length;i++){
int key = array[i];
int j ;
for (j = i-gap;j>=0;j = j-gap){//每gap跳一个
if (key < array[j]){
array[j+gap] = array[j];
}else {
break;
}
}
array[j+gap] = key;
}
}
性能分析
稳定性:不稳定
选择排序
直接选择排序
基本原理及思路
一开始数据都是无序的,在无序区间中选择最大/最小的元素,把它和无序区间中的最后一个元素/第一个元素进行交换,重复上面的过程,直到所有的数据都变成有序的。
在无序区间中选择最大/最小的元素,采用遍历的方式。
- 外层的循环表示需要进行多少次的选择
- 内层的循环用来遍历找无序区间的最大/最小数字
代码
public static void selectSort(int[] array){
for (int i=0;i< array.length;i++){
//无序区间[0,array.length-i)
//有序区间[array.length-i,array.length)
int maxIndex =0;//选择下标为0 的元素作为最大值
//遍历找无序区间的最大值
for (int j =1;j<array.length-i;j++){//无序区间
if (array[j] > array[maxIndex]){
maxIndex = j;
}
}
//交换最大值和无序区间的最后一个元素
swap(array,maxIndex,array.length-i-1);
}
}
public static void swap(int[] array,int i ,int j){
int temp = array[i];
array[i] = array[j];
array[j] = temp;
}
性能分析
稳定性:不稳定
双向选择排序
基本原理及思路
对直接选择排序的变形
每一次从无序区间中选出最小和最大的元素,存放在无序区间的最前和最大,直到全部数据有序。
代码
public static void doubleSelectSort(int[] array){
int low = 0;
int high = array.length-1;
while (low < high){
int min = low;
int max = low;
for (int i = low+1;i<=max;i++){
if (array[i] < array[min]){
min = i;
}
if (array[i] > array[max]){
max = i;
}
}
swap(array,min,low);
if (max == low){
max = min;
}
swap(array,max,high);
}
}
性能分析
时间复杂度依然是O(n^2)
空间复杂度是O(1)
堆排序
基本原理及思路
选择排序的变形,基本的原理也是选择排序,但是不再使用遍历的方式来查找无序区间的最大数,而是通过堆来实现。
排升序要建大堆;排降序要建小堆。
- 先将原来的数据进行建堆
- 选择无序区间最大的与最后一个元素进行交换
- 交换完之后,进行向下调整调整成为大顶堆
- 再选择无序区间最大的与无序区间的最后一个元素进行交换
- 再进行向下调整
- 重复进行交换和向下调整和,直到所有的数据都变为有序的。
代码
public static void heapSort(int[] array){
//先建大堆
createHeap(array,array.length);
//选择的过程:一共需要选择array.length-1组
for (int i= 0;i< array.length-1;i++){
//交换之前:
//无序区间[0,array.length-i)
//有序区间[array.length-i,array.length)
swap(array,0,array.length-i-1);
//交换之后无序区间的最后一个元素就不需要再参与向下调整的过程
//交换之后:
//无序区间[0,array.length-i-1)
//有序区间[array.length-i-1,array.length)
//无序区间的长度:array.length-i-1
shiftDown(array,array.length-i-1,0);
}
}
private static void createHeap(int[] array, int length) {
for (int i = (length-1)/2; i >=0; i--) {
//从最后一个非叶子结点(最后一个结点的父结点)开始进行向下调整
shiftDown(array,array.length,i);
}
}
public static void shiftDown(int[] array,int size,int index) {
int left = 2*index+1;
while ( left< size){
int maxIndex = left;
if (maxIndex+1 < size && array[maxIndex+1] > array[maxIndex]){
maxIndex = maxIndex+1;
}
if (array[index] >= array[maxIndex]){
break;
}
swap(array,index,maxIndex);
index = maxIndex;
left = 2*index+1;
}
}
public static void swap(int[] array,int i ,int j){
int temp = array[i];
array[i] = array[j];
array[j] = temp;
}
性能分析
时间复杂度:
建堆的时间复杂度+向下调整的时间复杂度:n*log(n) + n *log(n) = 2n *log(n)
空间复杂度:O(1)——没有使用额外的空间
稳定性:不稳定——在向下调整的过程中很难保证数据的相对位置不发生改变。
交换排序
冒泡排序
基本原理及思路
数组分为【无序区间】 【有序区间】
在无序区间中,通过相邻数的比较,将最大的数冒泡到无序区间的最后,持续进行,直到数组整体有序。
注意:冒泡的过程都是在无序区间进行的。
- 外层循环确定冒泡的次数
- 内层循环进行相邻数的比较(冒泡的过程)
代码
public static void bubbleSort(int[] array){
//外层循环——冒泡的次数
for (int i=0;i< array.length-1;i++){
//无序区间[0,array.length-i)
//有序区间[array.length-i,array.length)
boolean isSorted = true;//优化
//内层循环:每一次冒泡交换的操作
for (int j =0;j< array.length-i-1;j++){//在无序区间进行
if (array[j] > array[j+1]){//相等不交换,保证稳定性
swap(array,j,j+1);
isSorted = false;
}
}
//经过一次循环,如果isSorted依然是true说明已经是有序的
if (isSorted){
break;//已经是有序的了,没有再进行交换操作
}
}
}
public static void swap(int[] array,int i ,int j){
int temp = array[i];
array[i] = array[j];
array[j] = temp;
}
性能分析
如果数据是有序的,外层的循环只需要执行一次,通过内层循环就可以判断数据是有序的,所以时间复杂度就是O(n)
如果数据是全部逆序排列的,外层循环和内层循环都需要遍历整个数组,所以时间复杂度是O(n^2)
稳定性:具有稳定性——相等的元素不进行交换,保证了排序的稳定性
快速排序
详细讲解参考上一篇:快速排序
基本原理及思路
- 从要排序的区间中选择一个数作为基准数key
- Partition(重点):遍历整个要排序的区间,将比key小的数放在key的左边,比key大的数放在key的右边
- 采取分治的思想,对已经划分出来的左右区间再进行相同的处理
- 直到小区间有序(小区间中的元素为1【已经有序】或0【没有数据】)
代码
public static void quickSort(int[] array){
quickInternal(array,0,array.length-1);
}
private static void quickInternal(int[] array, int left, int right) {
if (left == right){
return;
}
if (left > right){
return;
}
int keyIndex = partition2(array,left,right);
quickInternal(array,left,keyIndex-1);
quickInternal(array,keyIndex+1,right);
}
Partition的方法有三种:
//Hoare法
private static int partition(int[] array, int left, int right) {
int i =left;
int j = right;
int key = array[left];
while (i < j){
//先从后往前
while (i < j && array[j] >=key){
j--;
}
while (i < j && array[i] <= key){
i++;
}
swap(array,i,j);
}
swap(array,i,left);
return i;
}
//挖坑法
private static int partition(int[] array, int left, int right) {
int i =left;
int j = right;
int key = array[left];
while (i < j){
//先从后往前
while (i < j && array[j] >=key){
j--;
}
array[i] = array[j];
while (i< j && array[i] <= key){
i++;
}
array[j] = array[i];
}
array[i] = key;
return i;
}
//前后遍历法
private static int partition(int[] array,int lowIndex,int highIndex){
int separateIndex = lowIndex +1; //用来分隔,当出现小于lowIndex位置的数时,交换,separateIndex往后走
for(int i = lowIndex+1;i<=highIndex;i++) {//用来遍历数组
if(array[i] < array[lowIndex]) {
swap(array,i,separateIndex);
separateIndex++;
}
}
swap(array,lowIndex,separateIndex-1);
return separateIndex-1;
}
性能分析
稳定性:不稳定
归并排序
基本原理及思路
归并排序是分治法的一个典型的应用,要对一个数组进行排序:
- 首先将数组分为平均的两份
- 然后分别对左右两个区间,进行相同方式的处理(进行归并排序)
- 直到区间内数据的个数为0或1
- 合并左右两个有序的数组
代码
public static void mergeSort(int[] array){
mergeSortInternal(array,0,array.length);
}
//区间范围是左闭右开的array[low,high)
private static void mergeSortInternal(int[] array, int low, int high) {
int size = high-low;
if (size <=1){
return;
}
int mid = (low+high)/2;
//区间被分为左右两个部分
mergeSortInternal(array,low,mid);
mergeSortInternal(array,mid,high);
//合并两个有序区间
merge(array,low,mid,high);
}
//合并两个有序数组
private static void merge(int[] array, int low, int mid, int high) {
//需要一个额外的数组
int length = high-low;
int[] extraArray = new int[length];
int leftIndex = low;
int rightIndex = mid;
int extraIndex = 0;
//左右两个区间中都有数据
while (leftIndex < mid && rightIndex < high ){
if (array[leftIndex] <= array[rightIndex]){
extraArray[extraIndex++] = array[leftIndex++];
}else {
extraArray[extraIndex++] = array[rightIndex++];
}
}
while (leftIndex < mid){//左边有数据
extraArray[extraIndex++] = array[leftIndex++];
}
while (rightIndex < high){
extraArray[extraIndex++] = array[rightIndex++];
}
//最后把数据从新数组统一搬回去
//新数组[0,length),要搬回去的下标[low,high)
//从extraArray搬回array
for (int i=0;i < length;i++){
array[i+low] = extraArray[i];
}
}
性能分析
小结
时间复杂度和空间复杂度总结:
稳定排序:插入排序、冒泡排序、归并排序
数据大概率是有序的:插入排序、冒泡排序
数据大概率是逆序的:希尔排序或者堆排序
主要遍历有序区间:插入排序
主要是遍历无序区间:冒泡排序和堆排序
插入排序和冒泡排序——一般会选择插入排序
时间复杂度不受数据影响:选择排序、归并排序、堆排序
如果数据比较少时,选择插入排序最快
其他非基于比较的排序
计数排序
核心在于将输入的数据值转化为键存储在额外开辟的数组空间中以达到排序的效果
对于每一个待排序元素a,如果知道了待排序数组中有多少个比它小的数,那么就可以直接知道在排序后的数组中 a 应该在什么位置上。比如,如果一个数组中有3个数是比a小的,那么,在排序后的数组里,a必然会出现在第4位。
时间复杂度:O(n+m)
空间复杂度:O(n+m)
稳定排序
基数排序
基数排序是将待排序序列的每个元素统一为同样位数长度的元素,位数较短的通过补0达到长度一致,然后从最低位或从最高位开始,依次进行稳定的计数排序,最终形成有序的序列。
基数排序主要是针对整数的排序,能用整数表达的其他数据类型也能用基数排序。
桶排序
桶排序是计数排序算法的升级版,将数据分到有限数量的桶子里,然后每个桶再分别排序。
JDK中提供的排序算法
- 对数组进行排序,可以使用Arrays类下提供的sort方法
- 对List进行排序,可以使用List本身的sort方法或者Collections提供的sort方法
- 如果是对对象进行排序,而不是基本类型。需要考虑Comparable或者Comparator的问题
//对对象进行排序
class Person{
int age;
String name;
public Person(int age, String name) {
this.age = age;
this.name = name;
}
@Override
public String toString() {
return "Person{" +
"age=" + age +
", name='" + name + '\'' +
'}';
}
}
public class Sort {
public static void main(String[] args) {
Person person1 = new Person(14,"王刚");
Person person2 = new Person(17,"李四");
Person person3 = new Person(20,"张三");
Person[] personArray = new Person[3];
personArray[0] = person1;
personArray[1] = person2;
personArray[2] = person3;
/*Arrays.sort(personArray, new Comparator<Person>() {
@Override
public int compare(Person o1, Person o2) {
return o1.name.compareTo(o2.name);
}
});*/
Arrays.sort(personArray, new Comparator<Person>() {
@Override
public int compare(Person o1, Person o2) {
return o1.age - o2.age;
}
});
for (Person person : personArray){
System.out.println(person);
}
}
}
海量数据的排序
使用归并排序——多路归并
海量数据的特点:内存的空间是优先的,内存中存不下,只能借助硬盘进行存储。
排序的步骤:
- 把数据平均分成n份(每份的大小较小)
- 分别对每份数据进行排序;(可以将任务分配给多个机器共同参与排序)
- 得到n个分别有序的数据文件
- 借助内存,进行n个有序数据文件的合并。
- 将每份文件中最小的数放入内存中
- 将其中最小的数选出来,尾插到最后有序的文件中去。(就是归并排序的思想)