数据结构*排序

排序的一些相关概念

稳定性

假设在待排序序列中,存在两个元素A和B,A和B的值相同。在排序后,A和B的相对位置没有变化,就说这排序是稳定的。反之不稳定。

内部排序与外部排序

内部排序:数据完全存储在内存中,排序过程无需访问外部存储设备(如硬盘),直接在内存中完成。

外部排序:数据量过大,无法一次性装入内存,需借助外部存储设备(如硬盘、U 盘),通过内存与外存的多次数据交换完成排序。

一些重要的排序算法

直接插入排序

网上找的动画演示可看一下,深刻理解直接插入的过程。

文字描述如下:

代码展示:

java 复制代码
/**
 * 时间复杂度为:O(N^2)
 *      进行优化后,当数组本身有序,时间复杂度最好为:O(N)
 * 空间复杂度为:O(1)
 * 稳定性:稳定
 * @param array
 */
public void directInsertionSort(int[] array) {
    for (int i = 1; i < array.length; i++) {
        int temp = array[i];
        int j = i - 1;
        //将比temp大的元素往后移
        while (j >=0 && array[j] > temp) {
            array[j + 1] = array[j];
            j--;
        }
        //此时j下标的值比temp小,插入到其前面
        array[j + 1] = temp;
    }
}

代码分析:

上述代码的时间复杂度为:O(N^2);空间复杂度为:O(1);稳定性:稳定

当已是正序的情况下,代码不会进入内循环,此时时间复杂度为:O(N)。

所以说:元素集合越接近有序,直接插入法的时间效率就高

希尔排序

希尔排序在直接插入排序的基础上,对数组进行了分块排序。先让数组元素尽可能地接近有序状态,最终使整个数组完全有序。

代码展示:

java 复制代码
/**希尔排序
 *时间复杂度为:
 *      根据实际来确定的,一般认为在 O(N*1.3) ~ O(N*1.5)
 * 空间复杂度:O(1)
 * 稳定性:不稳定
 * @param array
 */
public void shellSort(int[] array) {
    int gap = array.length;
    while (gap > 1) {
        gap = gap / 2;//每次缩小分组,让数组逐渐有序
        shell(array,gap);
    }
}
private void shell(int[] array,int gap) {
    //直接插入排序
    for (int i = gap; i < array.length; i++) {//这里i++,使得组与组之间交替进行插入排序
        //由于是组内的直接插入排序,所以元素与元素直接差值是gap,而不是1了。
        int temp = array[i];
        int j = i - gap;
        //将比temp大的元素往后移
        while (j >= 0 && array[j] > temp) {
            array[j + gap] = array[j];
            j = j - gap;
        }
        array[j + gap] = temp;
    }
}

代码分析:

这里相比于直接用直接插入排序,这里将数组逐渐有序起来,直接插入时间复杂度更低。其次,采用交叉分组的方式,将大的元素能放在数组后面,小的元素放在数组前面。

上述代码的时间复杂度为:不同情况不同结果;空间复杂度为:O(1);稳定性:不稳定。最坏时间复杂度为:O(N^2),平均情况下约为:O(N*1.3)

选择排序

将数据分为已排序的和未排序的。在未排序中找到最小(大)值的下标,然后和未排序的第一个进行交换,这样就能保证是有序的。

代码展示:

java 复制代码
/**
 * 时间复杂度为:O(N^2)
 * 空间复杂度为:O(1)
 * 稳定性:不稳定
 * @param array
 */
public void selectionSort(int[] array) {
    for (int i = 0; i < array.length; i++) {
        int minIndex = i;//minIndex初始为未排序的起始下标i
        for (int j = i + 1; j < array.length; j++) {//从i + 1这个下标开始找最小值下标
            if(array[minIndex] > array[j]) {
                minIndex = j;//找到未排序中最小值的下标
            }
        }
        //交换未排序的第一个与最小值
        swap(array, minIndex, i);
    }
}
private void swap(int[] array, int a ,int b) {
    int temp = array[a];
    array[a] = array[b];
    array[b] = temp;
}

代码分析:

上述代码的时间复杂度为:O(N^2);空间复杂度为:O(1);稳定性:不稳定

堆排序

在堆的学习中有学习过
数据结构*堆

代码展示:

java 复制代码
/**
 * 从小到大排序
 * 时间复杂度为:O(N*logN)
 * 空间复杂度为:O(1)
 * 稳定性:不稳定
 * @param array
 */
public void heapSort(int[] array) {
    //先创建一个最大堆,时间复杂度为:O(N)
    for (int parent = ((array.length - 1) - 1) / 2; parent >= 0 ; parent--) {
        shiftDown(array,parent,array.length);
    }
    int end = array.length - 1;
    //进行排序,时间复杂度为:O(N*logN) <---- 有N次调整,每次调整的时间复杂度为:O(logN)
    while (end > 0) {
        swap(array,0,end);
        end--;
        shiftDown(array,0,end + 1);
    }
}
private void swap(int[] array,int a,int b) {
    int temp = array[a];
    array[a] = array[b];
    array[b] = temp;
}
private void shiftDown(int[] array ,int parent,int useSize) {
    int child = 2 * parent + 1;
    while (child < useSize) {
        if(child + 1 <useSize && array[child] < array[child + 1]) {
            child++;
        }
        if(array[parent] < array[child]) {
            swap(array,parent,child);
            parent = child;
            child = 2 * parent + 1;
        }else {
            break;
        }
    }
}

代码分析:

上述代码的时间复杂度为:O(N*logN);空间复杂度为:O(1);稳定性:不稳定

冒泡排序

是将数据两两比较,大的数据往后移(交换)。每趟就将最大的数据放到后面。一共要走N - 1趟。每趟元素从开头进行比较,比较到有序的数据就停止比较。
别人制作的冒泡排序视频

代码展示:

java 复制代码
/**
 * 时间复杂度:O(N^2)
 *      进行优化后,当数组本身有序时间复杂度最好为:O(N)
 * 空间复杂度:O(1)
 * 稳定性:稳定
 * @param array
 */
public void BubbleSort(int[] array) {
    //一共要走N - 1趟
    for (int i = 1; i < array.length; i++) {
        boolean flag = false;
        //每趟对未有序的数据进行比较交换
        for (int j = 0; j < array.length - i; j++) {
            if(array[j] > array[j + 1]) {
                swap(array,j,j + 1);
                flag = true;
            }
        }
        if(!flag) {//说明没有在进行交换了,已经有序了
            return;
        }
    }
}
private void swap(int[] array, int a ,int b) {
    int temp = array[a];
    array[a] = array[b];
    array[b] = temp;
}

代码分析:

上述代码的时间复杂度为:O(N^2);空间复杂度为:O(1);稳定性:稳定

快速排序

选择一个基准元素,使其左边都是小于基准元素的值,右边都是大于基准元素的值。再进行分区操作,重复这个过程。

代码展示:

java 复制代码
/**
 *时间复杂度为:
 *      最好情况下为:O(N*logN),每次找的基准都是中间的值
 *      最坏情况下为:O(N^2),正序或逆序(即是一棵单分支的树)
 * 空间复杂度:
 *      最好情况下为:O(logN),每次找的基准都是中间的值
 *      最坏情况下为:O(n),正序或逆序(即是一棵单分支的树)
 * 稳定性:不稳定
 * @param array
 */
public void quickSort(int[] array) {
    quick(array,0,array.length - 1);
}
private void quick(int[] array,int start,int end) {
     if(start >= end) {
         return;
     }
     int par = standard(array,start,end);//standard()方法用来获得在区间范围内的基准,并使左边小于基准,右边大于基准
     quick(array,start,par - 1);
     quick(array,par + 1,end);
}

对于实现standard()方法有三种方法:

1、挖坑法:

java 复制代码
private int standardDigging(int[] array,int low,int high) {
    int temp = array[low];
    while (low < high) {
        while (low < high && array[high] >= temp) {
            high--;
        }
        array[low] = array[high];
        while (low < high && array[low] <= temp) {
            low++;
        }
        array[high] = array[low];
    }
    array[low] = temp;
    return low;
}

2、Hoare法:

java 复制代码
private int standardHoare(int[] array,int low,int high) {
    int pivot = array[low];
    int i = low;
    while (low < high) {
        while (low < high && array[high] >= pivot) {
            high--;
        }
        while (low < high && array[low] <= pivot) {
            low++;
        }
        swap(array,low,high);
    }
    swap(array,i,low);
    return low;
}
private void swap(int[] array, int a ,int b) {
    int temp = array[a];
    array[a] = array[b];
    array[b] = temp;
}

3、前后指针法:

java 复制代码
private int standardPoint(int[] array,int low,int high) {
    int pivot = array[low];
    int prev = low;//prev用来标记小于等于基准值
    for(int cur = prev + 1;cur <= high;cur++) {//cur用来标记大于基准值
        if(array[cur] <= pivot) {
            prev++;//扩大小于等于基准值的区域
            swap(array,cur,prev);//将小于等于基准值的值放到前面来
        }
    }
    swap(array,low,prev);//将基准值放到最终位置
    return prev;//返回基准值索引
}
private void swap(int[] array, int a ,int b) {
    int temp = array[a];
    array[a] = array[b];
    array[b] = temp;
}

代码分析:

下图主要展示了排序的总逻辑:(类似二叉树的形式)

具体的实现"左边都是小于基准元素的,右边都是大于基准元素",可以用挖坑法、Hoare法、前后指针法。(使用频率:挖坑法 > Hoare法 > 前后指针法)

上述代码的时间复杂度为:1、最好情况下为:O(N*logN) 2、最坏情况下为:O(N^2);空间复杂度:1、最好情况下为:O(logN) 2、最坏情况下为:O(n);稳定性:不稳定

注意:

在找基准的方法中:

1、挖坑法代码中的while循环的条件那里需要有等号

java 复制代码
while (low < high) {
    while (low < high && array[high] > temp) {少了等号
    high--;
    }
    array[low] = array[high];
    while (low < high && array[low] < temp) {//少了等号
    low++;
    }
    array[high] = array[low];
}

当原数组首元素和尾元素相等,就会导致一直交换这两个相同的元素,死循环。

2、在Hoare法代码中调整数组的while循环中要先high找小于基准值的值。

java 复制代码
while (low < high) {
    while (low < high && array[low] <= pivot) {
        low++;
    }//错误
    while (low < high && array[high] >= pivot) {
        high--;
    }
    swap(array,low,high);
}

由于我们一开始是定义首元素为基准值,如果low先动的话,最后low和high相遇的值要比基准值大,导致并没有实现左边都是小于基准元素的值,右边都是大于基准元素的值。

代码优化:

1、三数取中

在最坏情况下(正序、逆序等),当我们取区间首元素为基准值时,这样分割不均匀。这时候我们可以在区间首元素、尾元素、中间元素中找到中间大的数字

java 复制代码
private void quick(int[] array,int start,int end) {
     if(start >= end) {
         return;
     }
     //三数取中
     int standard = medianOfNumbers(array,start,end);
     swap(array,start,standard);//找到了中间值,将它和原来定的标准值交换(标准值还在首下标)
     int par = standardDigging(array,start,end);
     quick(array,start,par - 1);
     quick(array,par + 1,end);
}
private int medianOfNumbers(int[] array,int low,int high) {
    int mid = (low + high) / 2;
    //确保array[low] <= array[high]
    if(array[low] > array[high]) {
        swap(array,low,high);
    }
    //确保array[low] <= array[mid]
    if(array[low] > array[mid]) {
        swap(array,low,mid);
    }
    //到这里说明array[low]是最小的
    //确保array[mid] <= array[high]
    if(array[mid] > array[high]) {
        swap(array,mid,high);
    }
    //说明array[mid]是中间值,返回mid下标
    return mid;
}
2、小数组优化

在排序到后面的时候,数组已趋于有序。此时数组被分成了许多小数组,但如果继续递归,效率会相比其他的排序低很多。这时候我们可以考虑使用插入排序来提高效率。

java 复制代码
private void quick(int[] array,int start,int end) {
    if(start >= end) {
         return;
    }
    //小数组优化
    int VALUE = 10;
    if(end - start + 1 == VALUE) {
        //直接插入排序
        directInsertionSort(array,start,end);
        return;
    }
    //三数取中
    int standard = medianOfNumbers(array,start,end);
    swap(array,start,standard);
    int par = standardDigging(array,start,end);
    quick(array,start,par - 1);
    quick(array,par + 1,end);
}
public void directInsertionSort(int[] array,int start,int end) {
    for (int i = start + 1; i <= end; i++) {
        int temp = array[i];
        int j = i - 1;
        //将比temp大的元素往后移
        while (j >= 0 && array[j] > temp) {
            array[j + 1] = array[j];
            j--;
        }
    }
}

非递归实现快速排序

代码展示:
java 复制代码
private void quickNor(int[] array,int start,int end) {
    int par = standardDigging(array,start,end);
    // 创建栈用于保存待排序子数组的左右边界
    Stack<Integer> stack = new Stack<>();
    //当分成的数组长度为1,1个元素本身就是有序的,无需继续分
    if(start < par - 1) {//分的数组长度大于等于二
        stack.push(start);
        stack.push(par - 1);
    }
    if(end > par + 1) {//分的数组长度大于等于二
        stack.push(par + 1);
        stack.push(end);
    }
    while (!stack.empty()) {
        end = stack.pop();//一开始弹出来的是后进去的end
        start = stack.pop();
        //接下来就是重复上面的过程,直至栈为空,说明没有子数组需要排序了
        par = standardDigging(array,start,end);
        if(start < par - 1) {
            stack.push(start);
            stack.push(par - 1);
        }
        if(end > par + 1) {
            stack.push(par + 1);
            stack.push(end);
        }
    }
}
代码分析:

1、初始划分:

选择基准值,将数组分为【左半部分 ≤ 基准值】和【右半部分 ≥ 基准值】,获取基准值位置par。

2、栈管理子数组:

用栈保存待排序子数组的左右边界(start和end)。

先处理初始划分后的左右子数组(若长度≥2),将其边界压入栈。

3、迭代处理栈元素:

循环弹出栈顶子数组,对其进行划分,得到新基准值位置。

将新产生的左右子数组边界(若长度≥2)压入栈,确保所有子数组被处理。

4、终止条件:

栈空时,所有子数组排序完成。

归并排序

将数组不断地平分成两个数组,最后合并两个有序数组并排序。一开始合并是两个单元素数组合并。

代码展示:

java 复制代码
/**归并排序
 * 时间复杂度:O(N*logN)
 * 空间复杂度:O(N)
 * 稳定性:稳定
 * @param array
 */
public void mergeSort(int[] array) {
    mergeSortChild(array,0,array.length - 1);
}
private void mergeSortChild(int[] array,int left,int right) {
    if(left == right) {
        return;
    }
    int mid = (left + right) / 2;
    mergeSortChild(array,left,mid);
    mergeSortChild(array,mid + 1,right);
    //合并两个有序数组
    merge(array,left,mid,right);
}
private void merge(int[] array,int left,int mid,int right) {
    int s1 = left;
    int e1 = mid;
    int s2 = mid + 1;
    int e2 = right;
    int[] temp = new int[right - left + 1];
    int index = 0;
    while (s1 <= e1 && s2 <= e2) {
        if(array[s1] > array[s2]) {
            temp[index] = array[s2];
            s2++;
        }else {
            temp[index] = array[s1];
            s1++;
        }
        index++;
    }
    while (s2 <= e2) {
        temp[index] = array[s2];
        index++;
        s2++;
    }
    while (s1 <= e1) {
        temp[index] = array[s1];
        index++;
        s1++;
    }
    //此时temp数组就是排好序的数组
    for (int i = 0; i < temp.length; i++) {
        array[i + left] = temp[i];
    }
}

代码分析:

上述代码采用递归的方法,先完成左边数组的归并,在完成右边数组的归并。大致总逻辑如下图所示:

上述代码的时间复杂度为:O(N*logN);空间复杂度为:O(N);稳定性:稳定

非递归实现归并排序

代码展示:
java 复制代码
public void mergeSortNor(int[] array) {
    int gap = 1;
    while (gap < array.length) {//当gap >= array.length,说明整个数组都完成了归并
    	//遍历所有子数组
        for (int i = 0; i < array.length; i++) {
            int left = i;
            int mid = left + gap -1;
            //当gap很大的时候,mid下标可能越界
            if(mid >= array.length) {
                mid = array.length - 1;
            }
            int right = mid + gap;
            //当gap很大的时候,right下标可能越界
            if(right >= array.length) {
                right = array.length - 1;
            }
            merge(array,left,mid,right);
        }
        gap*=2;
    }
}
代码分析:

gap用来表示要排序的子数组大小,每次扩大子数组的大小。

总结:

排序名称 时间复杂度(最好) 时间复杂度(最坏) 空间复杂度 稳定性 使用场景
直接插入排序 O(N) O(N^2) O(1) 稳定 数据量较小,数据本身基本有序。
希尔排序 O(N) O(N^2) O(1) 不稳定 适用于大规模数据排序。如对大型文件中的数据进行初步排序。
选择排序 O(N^2) O(N^2) O(1) 不稳定 数据量较小且对排序稳定性无要求时可使用。
堆排序 O(N*logN) O(N*logN) O(1) 不稳定 适合处理大量数据且要求时间复杂度为O(N*logN) 级别的场景。
冒泡排序 O(N^2) O(N^2) O(1) 稳定 数据量较少,或者数据基本有序的情况。
快速排序 O(N*logN) O(N^2) O(logN) ~ O(N) 不稳定 大量数据的排序场景,是实践中平均性能最优的排序算法之一。
归并排序 O(N*logN) O(N*logN) O(N) 稳定 当处理大规模数据且要求排序稳定时使用。

每一种排序都有适合场景,甚至可以采用多种排序组合实现最优解。

相关推荐
chenyuhao202428 分钟前
链表面试题9之环形链表进阶
数据结构·算法·链表·面试·c#
chenyuhao20241 小时前
链表的面试题8之环形链表
数据结构·算法·链表·面试·c#
晴空闲雲2 小时前
数据结构与算法-线性表-循环链表(Circular Linked List)
数据结构·算法·链表
一梦浮华2 小时前
自学嵌入式 day21 - 数据结构 双向链表
数据结构·链表
比奇堡在逃帅哥2 小时前
数据结构-DAY05
数据结构
四谷夕雨2 小时前
C++数据结构——红黑树
开发语言·数据结构·c++
SylviaW083 小时前
python-leetcode 69.最小栈
数据结构·算法·leetcode
飞川撸码3 小时前
【LeetCode 热题100】搜索旋转排序数组(力扣33 / 81/ 153/154)(Go语言版)
数据结构·算法·leetcode·golang
叒卮4 小时前
小白编程学习之巧解「消失的数字」
数据结构·学习·算法