目录
交换排序:
冒泡排序 快速排序
插入排序:
直接插入排序 希尔排序
选择排序:
简单选择排序 堆排序
其他排序:
归并排序 基数排序
1. 冒泡排序
基本思想:
将序列划分为有序区和无序区,每次对无序区遍历,筛选出最大的元素"浮"到排序完成的区域。
算法描述:
1. 遍历整个无序序列,比较相邻元素,若array[i] > array[i+1],交换位置,筛选出最大元素浮到有序区。遍历结束后,无序区元素-1,有序区元素+1。
2. 重复以上步骤,直到无序区元素为0。
图片描述:
代码实现:
public static void bubbleSort(int array[]){
for(int i = 0 ;i <array.length-1;i++){
for(int j = 0;j<array.length-1-i;j++){
if(array[j]>array[j+1]){
int temp = array[j];
array[j] = array[j+1];
array[j+1] = temp;
}
}
}
}
算法总结:
冒泡排序最坏情况是序列有序且倒叙,即每次都需要交换,共需遍历并交换将近(n^2)/2次,时间复杂度为O(n^2)。最佳的情况是内循环遍历一次后发现排序是对的,因此退出循环,时间复杂度为O(n)。平均来讲,时间复杂度为O(n^2)。由于冒泡排序中只有缓存的temp变量需要内存空间,因此空间复杂度为常量O(1)。
因为冒泡排序都是进行前后两两比较后交换位置,所以不会改变重复元素的相对位置,所以它是稳定算法。
2. 快速排序
快速排序是冒泡排序的改良版,可以把它看作是冒泡排序+递归分治,它与冒泡排序都属于交换排序。快速排序顾名思义,其速度很快,效率也很高,是处理大数据最快的排序算法之一。虽然最差情况下的时间复杂度达到了O(n^2),但在大多数情况下都比平均时间复杂度为 O(nlogn)的排序算法表现要更好。
基本思想:
其基本思想是分治和挖坑填数。分治:通过一个基准将序列分为两部分,使基准左半部分的所有元素都小于基准,使基准右半部分的所有元素都大于基准,再继续对其左/右半部分继续分治,直到整个序列有序。
算法描述:
①先找到一个基准ref(这里我把它形容为坑位),一般都是最左边或最右边的那个元素。
②两个指针i,j分别指向最左端和最右端
③首先j会去从右向左寻找第一个比ref小的元素,然后将其填充到坑位ref也就是a[ i ]的位置,此时新的坑位为a[ j ]
④然后i再从左往右寻找第一个比ref大的元素,然后将其填充到坑位a[ j ],此时新的坑位为a[ i ]
⑤重复以上③④步骤直到指针i = j。
⑥然后将ref的值填充坑位a[ i ]。
此时分区完成,只需要对ref左边和右边的区域分别作递归便能够完成排序。
图片描述:
代码实现:
public static void quickSortRecursive(int array[], int low, int high){
if(low >= high) return;
int left = low;
int right = high;
int ref = array[left];
while (left<right){
while (left < right && array[right] >= ref) {
right--;
}
array[left] = array[right];
while (left < right && array[left] <= ref) {
left++;
}
array[right] = array[left];
}
array[left] = ref;
quickSortRecursive(array,0,left-1);
quickSortRecursive(array,right+1,high);
}
快速排序除了递归的方式,还可以通过栈实现。因为递归的本质是栈,所以可以通过栈保存每一次分区后的左右指针就可以实现非递归快速排序了。
public static void quickSort(int[] array){
Stack<Integer> stack = new Stack<>();
stack.push(0);
stack.push(array.length-1);
while (!stack.isEmpty()){
int high = stack.pop();
int low = stack.pop();
int refIndex = partition(array,low,high);
if(refIndex > low){
stack.push(low);
stack.push(refIndex - 1);
}
if(refIndex < high && refIndex >= 0){
stack.push(refIndex + 1);
stack.push(high);
}
}
private static int partition(int[] array,int low,int high){
if(low>=high) return -1;
int left = low;
int right = high;
int ref = array[left];
while(left < right){
while (left<right && array[right]>= ref){
right--;
}
array[left] = array[right];
while (left<right && array[left]<= ref){
left++;
}
array[right] = array[left];
}
array[left] = ref;
}
算法总结:
速排序是通常被认为在同数量级(O(nlogn))的排序算法中平均性能最好的。其最好的情况为O(nlogn),最坏的情况为O(n^2),但比同样为O(nlog2n)的归并排序还要快。如果是个有序的倒序序列的话,快速排序会蜕变为冒泡排序,因此快速排序更适合用于排序乱序的数列,越乱效率越高。
快速排序的优化:
和大多数递归算法一样,改进快速排序性能的一个简单方法基于以下两点:
1. 对于小数组,快速排序比插入排序慢
2. 因为递归,快速排序会在分区后采用quickSort()调用自己
因此,对于分区后的小数组,我们可以切换到插入排序对快排进行优化。
因为快排在序列有序或基本有序时,会蜕化为冒泡排序,因此可以通过三者取中法选取基准。
即排序区间的左右指针和mid指针这三个记录关键码居中地调整为支点记录。这里不再作详细描述。(因为我也不太懂)
3. 直接插入排序
基本思想:
序列分为有序区和无序区,遍历无序序列,将每个元素与有序区中的元素相比较后插入到正确的位置。
算法描述:
①遍历所有元素除了第一个,该元素可默认处于有序区
②取出的元素与有序区的元素逐个进行比较后插入到正确的位置
③重复以上步骤,直到有序。
图片描述:
代码实现:
public static void insertSort(int array[]){
for(int i = 1;i<array.length;i++){
for(int j = i; j>= 1 ;j--){
if(array[j-1]>array[j]){
int temp = array[j];
array[j] = array[j-1];
array[j-1] = temp;
}
}
}
}
算法总结:
该算法和冒泡排序有点相似,都是采用了划分有序区无序区的思想,区别在于冒泡排序的有序部分已经确定好相对位置,直接插入排序的有序部分的相对位置还可能会随着插入而改变。
直接插入排序的时间取决于序列的初始位置,对一个基本有序的序列进行排序的效率将会比随机序列或逆序序列排序的效率要块得多。其平均时间复杂度为O(n^2),最好情况也就是当序列为有序则时间复杂度为O(n),最坏情况为O(n^2),因为只需要两个变量暂存当前数以及下标,空间复杂度为O(1)。
因为插入排序都是进行前后两两比较后交换位置,所以不会改变重复元素的相对位置,所以它是稳定算法。
算法优化:
因为直接插入排序在插入前需要通过与有序区元素之间的两两比较才能找到插入的位置。既然是与有序区比较,又需要进行一个查找的操作,我们就可以通过二分查找替换掉两两比较的方式,更有效率地去确定插入元素的位置。
实现思路也基本和直接插入排序差不太多,只是确定位置的时候采用二分查找。
public static void binaryInsertSort(int array[]){
for(int i = 1;i<array.length;i++){
int left = 0;
int right = i-1;
int mid;
int temp = array[i];
while (left <= right){
mid = (left + right )/2;
if(array[i] > array[mid]){
left = mid + 1;
}else {
right = mid - 1;
}
}
for(int j = i-1;j>=left;j--){
array[j+1] = array[j];
}
array[left] = temp;
}
}
4. 希尔排序
希尔排序是直接插入排序的改进版。
基本思想:
根据序列的长度计算出一个gap值对序列进行分组,然后将每组的元素分别进行直接插入排序,每次排序后gap折半减小,循环上述步骤直到gap=1的时候,序列已经基本有序了,此时再对序列进行一次直接插入排序,然后获得有序序列。
算法描述:
以 {9,8,6,4,3,2,1} 这个数组为例子,逐步分析shell排序的步骤(因为有些地方口头上实在说不清楚...)
①首先计算出步长gap = arr.length/2,也就是3。
②遍历步长中的所有元素。数组长度为7,则需要从第一个元素开始,遍历4次。
③第一个元素与第四个元素进行比较,并交换位置,此时数组为 {4,8,6,9,3,2,1}
④重复以上步骤,当比较到第4个元素的时候,第4个元素会先和第7个元素比较。然后第4个元素会回退gap距离到第1个元素的位置(之前的三个元素因为距离不够回退,所以只比较了一次),第1个元素再次与gap距离的元素比较。
从这里就能看出shell排序是将序列分组后分别对其进行直接插入排序了。为了方便理解,提取第四步中进行比较的三个元素{ 4,9,1},首先进行了9和1的比较和交换,然后1再和4进行比较交换,其特征就是很明显的直接插入排序。
⑤完成gap为3的比较,此时数组为{1,3,2,4,8,6,9},可以发现该数组已经倾向于有序了,而直接插入排序就是专门用于对付准有序数列的。
⑥重复以上步骤,gap=1,对这个准有序数列进行一次直接插入排序,最终有序。
图片描述:
代码实现:
public static void shellSort(int arr[]){
for(int i = arr.length/2; i > 0;i = i /2){
for(int j = i; j< arr.length; j++){
for(int d = j - i;d >= 0;d = d - i){
if(arr[d+ i] < arr[d]){
int temp = arr[d + i];
arr[d + i] = arr[d];
arr[d] = temp;
}
}
}
}
}
算法总结:
希尔排序更高效的原因是它权衡了子数组的规模和有序性。排序之初,各个子数组都很短,排序之后子数组都是部分有序的,这两种情况都很适合插入排序。
5. 简单选择排序
简单选择排序是最稳定的算法之一,因为无论是什么样的序列通过简单选择排序都需要经历同样的过程。因此其时间复杂度都为O(n^2)。
基本思想:
将序列分为了有序区和无序区两个部分,首先在无序区中遍历寻找一个最小的值,将其放在有序区的起始位置,然后再从无序区中继续寻找最小的元素,然后放在有序区的末尾。重复以上步骤,直到序列有序。
算法描述:
①遍历无序区,找到最小值并放在有序区起始位置。
②遍历无序区,找到最小值放在有序区末尾。
③重复以上步骤。
图片描述:
代码实现:
public static void easySelectSort(int array[] ){
for(int i = 0; i < array.length; i++){
int temp = i;
for(int j = i;j<array.length;j++){
if(array[j] < array[temp]){
temp = j;
}
}
int tempArr = array[temp];
array[temp] = array[i];
array[i] = tempArr;
}
}
算法总结:
简单选择排序的简单和直观名副其实,无论是哪种情况,哪怕原数组已排序完成,它也将花费将近(n^2)/2次遍历来确认一遍。即便是这样,它的排序结果也还是不稳定的。 唯一值得高兴的是,它并不耗费额外的内存空间。
6. 堆排序
基本思想:
堆排序利用了堆的特性:可以很快取出数组中的最大或最小的元素。以最大堆为例,堆排序的过程就是将待排序的序列构造成一个最大堆,选出堆中最大的移走,再把剩余的元素调整成堆,找出最大的再移走,重复直至有序。
堆的数据结构可以参考:
算法描述:(从小到大排序)
假设有长度为n的序列
①将初始序列构造成为一个最大堆,此时array[0]最大,将其array[0]与无序区末尾元素交换位置。
②此时无序区为array[0]~array[n-2],再次构造最大堆,将其array[0]与序区末尾元素交换位置。
③此时无序区为array[0]~array[n-3],重复以上步骤,直到有序区元素为0;
图片描述:
代码实现:
//取出根节点放置于无序区末尾
public static void heapSort(int[] array) {
for(int i = array.length-1;i>0;i--){
maxHeap(array,i);
int temp = array[0];
array[0] = array[i];
array[i] = temp;
}
}
//构建最大堆
public static void maxHeap(int[] array, int n) {
int child;
for(int i = (n-1)/2; i>=0 ;i--){
child = i*2 + 1;
if(child != n && array[child] < array[child+1]){
child++;
}
if(array[i] < array[child]){
int temp = array[i];
array[i] = array[child];
array[child] = temp;
}
}
}
算法总结:
由于堆排序中初始化堆的过程比较次数较多, 因此它不太适用于小序列。
由于多次任意下标相互交换位置, 相同元素之间原本相对的顺序被破坏了, 因此, 它是不稳定的排序。
7. 归并排序
基本思想:
递归将原始数组对半分隔,直到不能再分(只剩下一个元素)后,开始从最小的数组向上归并排序
算法描述:
1. 创建一个暂存数组temp,大小为此时分组的长度。
2. 将分组分割为两个部分,从左至右比较左分组和右分组的第一个元素,并从小到大按顺序暂存在temp中。
3. 将temp覆盖原始数组(也就是分组的合并)。
4. 对子分组继续递归执行上述步骤,直到有序。
图片描述:
代码实现:
public static void merge(int array[],int low,int high,int mid){
int[] temp = new int[high - low+1];
int left = low;
int right = mid+1;
int k = 0;
while (left <= mid && right <= high){
if(array[left] > array[right]){
temp[k++] = array[right++];
}else {
temp[k++] = array[left++];
}
}
while (left <= mid){
temp[k++] = array[left++];
}
while (right <= high){
temp[k++] = array[right++];
}
for(int i = 0;i < temp.length ;i++){
array[i+low] = temp[i];
}
}
private 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,high,mid);
}
}
算法总结:
从效率上看,归并排序可算是排序算法中的佼佼者。假设数组长度为n,那么拆分数组共需logn次,又因为合并是一个时间复杂度为O(n)的合并子分组的过程,则综合时间复杂度为O(nlogn)。
每次两个数组进行归并排序的时候,都会利用一个长度为n的数组作为辅助数组用于保存合并序列,所以空间复杂度为O(n)
8. 基数排序
基数排序是基于元素值的每个位上的字符进行排序的。 对于数字而言就是分别基于个位,十位, 百位或千位等进行排序。其原理是将整数按位数切割成不同的数字,然后按位数分别比较。由于整数也可以表达字符串(比如名字或日期)和特定格式的浮点数,所以基数排序也不是只能使用于整数。
基本思想:
将所有待比较元素(正整数)统一为同样的数位长度,数位较短的数前面补零。然后,从最低位开始,依次进行一次排序。这样从最低位排序一直到最高位排序完成以后,数列就变成一个有序序列。
算法描述:
假设按照从小到大排序
1. 遍历序列找出最大值并计算出最大值的最大位数。
2. 创建二维位桶数组用于存储数据,int[][] = new int[10][array.length]。
3. 遍历位数,对当前位数下的所有元素,按位数大小进行排序并存放至二维位桶数组。
4. 按顺序逐个取出位桶数组的元素(从底部先取)存放至数组。
5. 位数+1,再次排序,直到位数=maxDigits,最后整个序列有序。
图片描述:
代码实现:
public static void cardinalSort(int array[]){
int max = 0;
for(int i = 0;i<array.length;i++){
if(array[i]>max){
max = array[i];
}
}
int maxDigit = 0;
while (max/10 > 0){
maxDigit++;
max/=10;
}
int base = 10;
int buckets[][] = new int[10][array.length];
for(int i = 0;i<=maxDigit;i++){
int [] bucketLen = new int[10];
for(int j = 0;j<array.length;j++){
int whichBkt = (array[j]%base)/(base/10);
buckets[whichBkt][bucketLen[whichBkt]] = array[j];
bucketLen[whichBkt]++;
}
int k = 0;
for(int a = 0;a<buckets.length;a++){
for(int b = 0;b<bucketLen[a];b++){
array[k++] = buckets[a][b];
}
}
base *= 10;
}
}
算法总结:
基数排序更适合用于对时间,字符串等这些整体权值未知的数据进行排序。
基数排序不改变相同元素之间的相对顺序,因此它是稳定的排序算法。
每一次为位桶数组分配元素都需要O(n)的时间复杂度,而且分配之后从位桶数组中取出元素,得到新的序列又需要O(n)的时间复杂度。
假如待排序列最大位数(maxDigit)为d,序列长度为n,则基数排序的时间复杂度将是O(d*2n) ,系数2可以省略。且无论数组是否有序,都需要从个位排到最大位数,所以时间复杂度始终为O(d*n) 。
全文总结:
看一下这几个排序算法的性能:
从时间复杂度来说:
1. 复杂度O(n^2)的排序:直接插入,简单选择,冒泡排序,都是比较简单的排序算法
2. 线性对数阶O(nlogn)的排序:归并,堆,快速排序
3. 线性阶O(n)的排序:基数排序(此外还有桶,箱排序,本文并不涉及)
4. O(n^(1+x))的排序:希尔排序(x介于0~1之间)
从算法是否稳定来说:
注:若一个算法排序后,序列中相等元素的相对位置改变了,那么这个算法就是不稳定的。
1. 稳定排序:直接插入,冒泡,归并,基数
2. 不稳定排序:快速,希尔,简单选择,堆
论序列是否有序或基本有序对算法效率的影响:
1. 当序列有序或基本有序:直接插入和冒泡排序将大大减少比较次数和移动次数,时间复杂度可降低至O(n)。
2. 而快速排序在序列有序或基本有序的情况下,会蜕化为冒泡排序,时间复杂度提高到O(n^2)。
3. 此外序列是否有序,对简单选择,堆,归并,基数排序的时间复杂度影响并不大。
所有实现的代码均放置在GitHub:https://github.com/whl-1998/algorithm