数据结构与算法-排序

相关概念

内存消耗

算法的内存消耗可以通过空间复杂度来衡量,排序算法也不例外。不过,针对排序算法的空间复杂度,我们还引入了一个新的概念,原地排序(Sorted in place)。原地排序算法,就是特指空间复杂度是 O(1) 的排序算法

冒泡排序则是原地排序

排序的稳定性

比如我们有一组数据 2,9,3,4,8,3,按照大小排序之后就是 2,3,3,4,8,9。这组数据里有两个 3。经过某种排序算法排序之后,如果两个 3 的前后顺序没有改变,那我们就把这种排序算法叫作稳定的排序算法

主要针对多字段排序,比如先根据订单金额,再根据订单时间,传统做法就是先根据金额排序,再遍历一遍根据订单时间排序。

借助稳定排序算法,这个问题可以非常简洁地解决。解决思路是这样的:我们先按照下单时间给订单排序,注意是按照下单时间,不是金额。排序完成之后,我们用稳定排序算法,按照订单金额重新排序。两遍排序之后,我们得到的订单数据就是按照金额从小到大排序

稳定排序算法可以保持金额相同的两个对象,在排序之后的前后顺序不变。第一次排序之后,所有的订单按照下单时间从早到晚有序了。在第二次排序中,我们用的是稳定的排序算法,所以经过第二次排序之后,相同金额的订单仍然保持下单时间从早到晚有序。

排序算法的执行效率

1.最好情况、最坏情况、平均情况时间复杂度

2.时间复杂度的系数、常数 、低阶

时间复杂度反映的是数据规模 n 很大的时候的一个增长趋势,所以它表示的时候会忽略系数、常数、低阶。但是实际的软件开发中,我们排序的可能是 10 个、100 个、1000 个这样规模很小的数据,所以,在对同一阶时间复杂度的排序算法性能对比的时候,我们就要把系数、常数、低阶也考虑进来。

3.比较次数和交换(或移动)次数

排序算法的内存消耗

算法的内存消耗可以通过空间复杂度来衡量,排序算法也不例外。不过,针对排序算法的空间复杂度,我们还引入了一个新的概念,原地排序(Sorted in place)。原地排序算法,就是特指空间复杂度是 O(1) 的排序算法

冒泡排序

原理

每次遍历都会有一个元素都会像气泡一下移动到他该有的位置

4 、5 、6 、3 、 2 、1 按照升序排序

 

代码例子

// 冒泡排序,a表示数组,n表示数组大小
public void bubbleSort(int[] a, int n) {
  if (n <= 1) return;
 
 for (int i = 0; i < n; ++i) {
    // 提前退出冒泡循环的标志位
    boolean flag = false;
    for (int j = 0; j < n - i - 1; ++j) {
      if (a[j] > a[j+1]) { // 交换
        int tmp = a[j];
        a[j] = a[j+1];
        a[j+1] = tmp;
        flag = true;  // 表示有数据交换      
      }
    }
    if (!flag) break;  // 没有数据交换,提前退出
  }
}

是否是原地排序

冒泡的过程只涉及相邻数据的交换操作,只需要常量级的临时空间,所以它的空间复杂度为 O(1),是一个原地排序算法。

是否是稳定排序算法

在冒泡排序中,只有交换才可以改变两个元素的前后顺序。为了保证冒泡排序算法的稳定性,当有相邻的两个元素大小相等的时候,我们不做交换,相同大小的数据在排序前后不会改变顺序,所以冒泡排序是稳定的排序算法。

 

时间复杂度

 

最好情况排序的数据已经是有序的了,我们只需要进行一次冒泡操作,就可以结束了,所以最好情况时间复杂度是 O(n)。而最坏的情况是,要排序的数据刚好是倒序排列的,我们需要进行 n 次冒泡操作,所以最坏情况时间复杂度为 O(n2)。

插入排序

原理

分为已排序区域和未排序区域,每次遍历未排序的元素,与已排序比较。找到合适的位置插入

代码例子

    public static void main(String[] args) {
        int[] arrs = {1, 5, 3, 4, 6, 2, 11};
        insertionSort(arrs, arrs.length);
        System.out.println(Arrays.toString(arrs));
    }

    // 插入排序,a表示数组,n表示数组大小
    public static void insertionSort(int[] a, int n) {
        if (n <= 1) return;

        for (int i = 1; i < n; ++i) {
            int value = a[i];//获取比较元素
            int j = i - 1;
            // 从已排序往前查找插入的位置
            for (; j >= 0; --j) {
                if (a[j] > value) {
                    a[j + 1] = a[j];  // 满足条件往后移动继续比较
                } else {
                    break;
                }
            }
            a[j + 1] = value; // 不满足条件插入数据
        }
    }

是否是原地排序

插入排序算法的运行并不需要额外的存储空间,所以空间复杂度是 O(1),也就是说,这是一个原地排序算法。

是否是稳定排序 

在插入排序中,对于值相同的元素,我们可以选择将后面出现的元素,插入到前面出现元素的后面,这样就可以保持原有的前后顺序不变,所以插入排序是稳定的排序算法。

时间复杂度

如果要排序的数据已经是有序的,我们并不需要搬移任何数据。如果我们从尾到头在有序数据组里面查找插入位置,每次只需要比较一个数据就能确定插入的位置。所以这种情况下,最好是时间复杂度为 O(n)。注意,这里是从尾到头遍历已经有序的数据。

如果数组是倒序的,每次插入都相当于在数组的第一个位置插入新的数据,所以需要移动大量的数据,所以最坏情况时间复杂度为 O(n2)。

插入排序对于冒泡排序的优势

冒泡排序不管怎么优化,元素交换的次数是一个固定值,是原始数据的逆序度。插入排序是同样的,不管怎么优化,元素移动的次数也等于原始数据的逆序度。但是,从代码实现上来看,冒泡排序的数据交换要比插入排序的数据移动要复杂,冒泡排序需要 3 个赋值操作,而插入排序只需要 1 个。

选择排序

原理

也分已排序区间和未排序区间。但是选择排序每次会从未排序区间中找到最小的元素,将其放到已排序区间的末尾。

 

代码例子

   public static void main(String[] args) {
        int[] arrs = {6, 1, 5, 3, 4, 6, 2, 11};
        selectSort(arrs);
        System.out.println(Arrays.toString(arrs));
    }

    // 插入排序,a表示数组,n表示数组大小
    public static void selectSort(int[] a) {
        if (a.length < 2) {
            return;
        }
        for (int i = 0; i < a.length; i++) {
            // 每次从未排序中查询最小的往前移动
            for (int j = i + 1; j < a.length; j++) {
                // 每次较小往前移动 达到未排序的冒泡
                if (a[i] > a[j]) {
                    int temp = a[i];
                    a[i] = a[j];
                    a[j] = temp;
                }
            }
        }
    }

是否是原地排序 

每次都是在原来的数组上进行比较交换 所以是原地排序

是否是稳定排序

择排序是一种不稳定的排序算法。从我前面画的那张图中,你可以看出来,选择排序每次都要找剩余未排序元素中的最小值,并和前面的元素交换位置,这样破坏了稳定性。

时间复杂度

选择排序空间复杂度为 O(1),是一种原地排序算法。选择排序的最好情况时间复杂度、最坏情况和平均情况时间复杂度都为 O(n2)

归并排序

归并排序(MERGE-SORT)是利用归并的思想实现的排序方法,该算法采用经典的分治(divide-and-conquer)策略(分治法将问题(divide)成一些小的问题然后递归求解,而治(conquer)的阶段则将分的阶段得到的各答案"修补"在一起,即分而治之)。

实现逻辑

递归法
① 申请空间,使其大小为两个已经排序序列之和,该空间用来存放合并后的序列
② 设定两个指针,最初位置分别为两个已经排序序列的起始位置
③ 比较两个指针所指向的元素,选择相对小的元素放入到合并空间,并移动指针到下一位置
④ 重复步骤③直到某一指针到达序列尾
⑤ 将另一序列剩下的所有元素直接复制到合并序列尾

 

代码例子

 public static void main(String[] args) {
        int[] arr = {8, 4, 5, 7, 1, 3, 6, 2};
        sort(arr);
        System.out.println(Arrays.toString(arr));
    }

    public static void sort(int[] arr) {
        int[] temp = new int[arr.length];// 在排序前,先建好一个长度等于原数组长度的临时数组,避免递归中频繁开辟空间
        sort(arr, 0, arr.length - 1, temp);
    }

    public static void sort(int[] arr, int left, int right, int[] temp) {
        if (left < right) {
            int mid = (left + right) / 2;
            sort(arr, left, mid, temp);// 左边归并排序,使得左子序列有序 这里是递归依次进行分并归并
            sort(arr, mid + 1, right, temp);// 右边归并排序,使得右子序列有序 递归依次进行分并归并
            merge(arr, left, mid, right, temp);// 将两个有序子数组合并排序操作 作用于递归,当非递归排序执行完后 左右则是有序的进行最后一次排序
        }
    }

    public static void merge(int[] arr, int left, int mid, int right, int[] temp) {
        int i = left;// 左序列开始指针
        int j = mid + 1;// 右序列开始指针
        int t = 0;// 临时数组指针
        //依次拿左侧和右侧元素做比较,比较成功则移动对应的指针
        while (i <= mid && j <= right) {
            //如果左序列与又序列做比较 如果小于则放入temp 左序列指针向前移动
            if (arr[i] <= arr[j]) {
                temp[t++] = arr[i++];
            } else {
                //如果不小于则放入temp 右序列移动
                temp[t++] = arr[j++];
            }
        }

        //左序列比右序列大的放入temp 未比较成功
        while (i <= mid) {// 将左边剩余元素填充进temp中
            temp[t++] = arr[i++];
        }
        //将右序列移动比较不小于的放入temp中 未比较成功的
        while (j <= right) {// 将右序列剩余元素填充进temp中
            temp[t++] = arr[j++];
        }
        t = 0;
        // 将temp中的排好序的元素全部拷贝到原数组中 实现治理
        while (left <= right) {
            arr[left++] = temp[t++];
        }
    }

 

 

是否是原地排序

非原地排序,需要借助temp临时数组,空间复杂度为o(n)

是否是稳定排序 

归并排序稳不稳定关键要看 merge() 函数,也就是两个有序子数组合并成一个有序数组的那部分代码。在合并的过程中,如果 A[p...q]和 A[q+1...r]之间有值相同的元素,那我们可以像伪代码中那样,先把 A[p...q]中的元素放入 tmp 数组。这样就保证了值相同的元素,在合并前后的先后顺序不变。所以,归并排序是一个稳定的排序算法。

时间复杂度

归并排序的时间复杂度任何情况下都是 O(nlogn),看起来非常优秀。(待会儿你会发现,即便是快速排序,最坏情况下,时间复杂度也是 O(n2)。)但是,归并排序并没有像快排那样,应用广泛,这是为什么呢?因为它有一个致命的“弱点”,那就是归并排序不是原地排序算法。

快速排序

介绍

快速排序(Quick Sort)是从冒泡排序算法演变而来的,实际上是在冒泡排序基础上的递归分治法。快速排序在每一轮挑选一个基准元素,并让其他比它大的元素移动到数列一边,比它小的元素移动到数列的另一边,从而把数列拆解成了两个部分。

算法原理

1.输入数组
arr 为 [39 , 28 , 55 , 87 , 66 , 3 ,17 ,39*]
为了区别两个相同元素,将最后一个加上 * ;
初始状态如下图

2.定义一枢轴元素pivot,初始化为第一个元素的值,即39;

查询左边的元素的变量为left,初始值为第一个元素的索引,0;
查询右边的元素的变量为right,初始值为第一个元素的索引,7。
如下图:

 

演示第一轮排序过程
从右边开始,从右边找到一个比枢轴元素小的,如果没找到right一直自减1;

然后把当前left所在元素赋值为该值;
这里right所指元素并没有空,只是为了好演示,设置为空(下同);

 然后从左边开始找一个比枢轴元素pivot大的元素;如果没找到left一直自增1;

将当前left所指元素设为该值;

然后从左边开始找一个比枢轴元素pivot大的元素;如果没找到left一直自增1;

 将当前right所指元素设为该值;

 然后从右边找到一个比枢轴元素小的,如果没找到right一直自减1;

这时left和right相遇了,将枢轴元素赋值给当前位置。

然后将数组分成了

[17,28,3] 与 [66, 87, 55, 39*]两部分;
再对这两部分进行上述环节即可。
反反复复,直到只剩下一个元素。

整个过程

代码例子

import java.util.Arrays;

public class Solution {
    public static void main(String[] args) {
        quickSort(new int[]{39,28,55,87,66,3,17,39});
    }

    public static void quickSort(int[] arr){
        quickSort(arr,0,arr.length-1);
        System.out.println(Arrays.toString(arr));
    }
    public static void quickSort(int[] arr,int left,int right){
        int middle;
        if(left < right){
            middle = partition(arr,left,right);
            quickSort(arr,left,middle-1);
            quickSort(arr,middle+1,right);
        }
    }

    public static int partition(int[] arr,int left,int right){
        int pivot = arr[left];
        while(left < right){
            while(left<right && arr[right] >= pivot)
                right--;
            arr[left] = arr[right];
            while(left < right && arr[left]<= pivot)
                left++;
            arr[right] = arr[left];
        }
        arr[left] = pivot;
        return left;
    }
}

 

posted @ 2023-11-07 16:37  意犹未尽  阅读(25)  评论(0编辑  收藏  举报