当前位置:   article > 正文

算法系列——排序算法总结_简单选择排序在逆序交换次数

简单选择排序在逆序交换次数

排序分类

按照是否在内存中分类

根据在排序过程中待排序的记录是否全部被放置在内存中,排序分为:内排序和外排序。

对于内排序来说,排序算法的性能主要是受3个方面影响:
时间性能,辅助空间,算法的复杂性。

按照算法的实现复杂度分类

简单算法

冒泡排序、简单选择排序和直接插入排序属于简单算法

改进算法

希尔排序、堆排序、归并排序、快速排序属于改进算法。

各种排序算法分析和实现

注意:所有排序关键字均按照习惯采取升序排列。

冒泡算法

冒泡排序(Bubble Sort)一种交换排序,它的基本思想是:两两比较相邻记录的关键字,如果反序则交换,直到没有反序的记录为止。

基础冒泡算法
//冒泡排序,较小数字上浮,将较大数字往下沉
    //平均时间复杂度为O(n²) 空间复杂度为O(1) 稳定
    public void bubbleSort(int[] nums) {
        int len = nums.length;
        int temp = 0;
        //外层控制当前冒泡区间范围 i∈[0,len-2]
        for(int i=0;i<=len-2;i++)
            //内层从后往前,j∈[i,len-2]
            for(int j=len-2;j>=i;j--){
                if (nums[j] > nums[j + 1]) {
                    temp = nums[j];
                    nums[j] = nums[j + 1];
                    nums[j + 1] = temp;
                }
            }
        print("冒泡排序", nums);
    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
优化冒泡排序

例如对于 1,2,3,5,4 这样的关键字,只需要一次交换 5,4即可。剩下的交换都是无意义的。所以我们可以添加一个标识位swap,只有当发生交换时swap=true ,才执行下一次冒泡过程。
代码改动的关键就是在i变量的for循环中,增加了对swap是否为true的判断。经过这样的改进,冒泡排序在性能上就有了一些提升,可以避免因已经有序的情况下的无意义循环判断。

 //冒泡排序,较小数字上浮,将较大数字往下沉
    //平均时间复杂度为O(n²) 空间复杂度为O(1) 稳定
    public void bubbleSort(int[] nums) {
        int len = nums.length;
        int temp = 0;
        //交换标识
        boolean swap = true;
        //最多执行 len-1次冒泡过程
        for (int i = 0; i <= len - 2 && swap; i++) {
            //内层从后往前,j∈[i,len-2]
            swap = false;
            for (int j = len - 2; j >= i; j--) {
                if (nums[j] > nums[j + 1]) {
                    temp = nums[j];
                    nums[j] = nums[j + 1];
                    nums[j + 1] = temp;
                    swap = true;
                }
            }
        }
        print("冒泡排序", nums);
    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
复杂度分析

当最好的情况,也就是要排序的表本身就是有序的,那么我们比较次数,根据最后改进的代码,可以推断出就是n-1次的比较,没有数据交换,时间复杂度为O(n)。当最坏的情况,即待排序表是逆序的情况,此时需要比较sigma(i=2, n, i-1)=1+2+3+…+(n-1)=n(n-1)/2次,并作等数量级的记录移动。因此,总的时间复杂度为O(n²)。
空间复杂度为O(1).

简单选择排序算法

简单选择排序法(Simple Selection Sort)就是通过n-i次关键字间的比较,从n-i+1个记录中选出关键字最小的记录,并和第i(1≤i≤n)个记录交换之。

算法实现

Java实现算法如下

 //简单选择排序,基本思路是每次从剩余元素选出最小值,放到应放的位置,交换
    //平均时间复杂度O(n²) 空间复杂度 O(1) 不稳定
    public void selectSort(int[] nums) {
        int minIndex, minValue;
        int temp;
        //外层循环不用执行到最后一个
        for (int i = 0; i < nums.length - 1; i++) {
            minValue = nums[i];
            minIndex = i;
            //找出[i..len] 中的最小值,从i+1开始即可
            for (int j = i + 1; j < nums.length; j++) {
                if (nums[j] < minValue) {
                    minValue = nums[j];
                    minIndex = j;
                }
            }
            //执行交换
            if (minValue != i) {
                //执行交换
                temp = nums[i];
                nums[i] = nums[minIndex];
                nums[minIndex] = temp;
            }
        }
        print("简单选择排序", nums);
    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
算法复杂度分析

从简单选择排序的过程来看,它最大的特点就是交换移动数据次数相当少,这样也就节约了相应的时间。分析它的时间复杂度发现,无论最好最差的情况,其比较次数都是一样的多,第i趟排序需要进行n-i次关键字的比较,此时需要比较sigma(i=1, n-1, n-i)=(n-1)+(n-2)+…+1=n(n-1)/2次。而对于交换次数而言,当最好的时候,交换为0次,最差的时候,也就初始降序时,交换次数为n-1次,基于最终的排序时间是比较与交换的次数总和,因此,总的时间复杂度依然为O(n2)。

应该说,尽管与冒泡排序同为O(n²),但简单选择排序的性能上还是要略优于冒泡排序。因为在逆序情况下,冒泡排序的交换次数要大于简单选择排序。
空间复杂为O(1)

直接插入排序算法

直接插入排序(Straight Insertion Sort)的基本操作是将一个记录插入到已经排好序的有序表中,从而得到一个新的、记录数增1的有序表。

程序实现
 //直接插入排序,[]不断读取元素并扩充原有集合
    //平均时间复杂度O(n²) 空间复杂度为O(1) 稳定
    public void insertSort(int nums[]) {
        //cur保存当前元素
        int cur;
        //index内层循环索引 最终指向待插入位置的前一个位置
        int index;
        for (int i = 1; i < nums.length; i++) {
            cur = nums[i];
            index = i - 1;
            //将大于cur的元素向后移动,找到插入位置
            while (index >= 0 && cur < nums[index]) {
                nums[index + 1] = nums[index];
                index--;
            }
            //注意index+1
            nums[index + 1] = cur;
        }

        print("直接插入排序", nums);
    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
算法复杂度分析

当最好的情况,也就是要排序的表本身就是有序的,比如关键字排序为{2,3,4,5,6},共比较了(n-1)sigma(i=2, n, 1)=(n-1)*(n-1)次,由于没有移动的记录,时间复杂度为O(n)。

当最坏的情况,即待排序表是逆序的情况,比如{6,5,4,3,2},此时需要比较sigma(i=2, n, i)=2+3+…+n=(n+2)(n-1)/2次,而记录的移动次数也达到最大值sigma(i=2, n, i+1)=(n+4)(n-1)/2次。

如果排序记录是随机的,那么根据概率相同的原则,平均比较和移动次数约为n2/4次。因此,我们得出直接插入排序法的时间复杂度为O(n²)。从这里也看出,同样的O(n²)时间复杂度,直接插入排序法比冒泡和简单选择排序的性能要好一些,因为冒泡排序在逆序时会有更多的交换操作。
空间复杂为O(1).

希尔排序算法

希尔排序是对直接插入排序的改进。核心思想是对待排序记录进行分割,是减少待排序记录的个数,并使整个序列向基本有序发展。具体采取跳跃分割的策略:将相距某个“增量”d的记录组成一个子序列,这样才能保证在子序列内分别进行直接插入排序后得到的结果是基本有序而不是局部有序。

增量d的选取

这里“增量”的选取就非常关键。可究竟应该选取什么样的增量才是最好,目前还是一个数学难题,迄今为止还没有人找到一种最好的增量序列。不过大量的研究表明,当增量序列为dlta[k]=2t-k+1-1(0≤k≤t≤log2(n+1))时,可以获得不错的效率,其时间复杂度为O(n3/2次方),要好于直接排序的O(n²)。需要注意的是,增量序列的最后一个增量值必须等于1才行。另外由于记录是跳跃式的移动,希尔排序并不是一种稳定的排序算法。

算法实现

这里选取一次选择增量 d=n/2 d=n/4 …. d=1

//希尔排序,选取增量区间,区间内直接插入排序,直接插入排序的改进版本
    //增量的选取 例如d=len d/=2
    public void shellSort(int[] nums) {
        int len = nums.length;
        int d = len;
        while (true) {
            //增量缩减为1/2
            d /= 2;
            //cur保存当前元素
            int cur;
            //pos内层循环索引 最终指向待插入位置的前一个位置
            int pos;
            //最外层遍历每一个组
            for (int i = 0; i < d; i++) {
                //初始位置 i+d 步长为d
                for (int j = i + d; j < len; j += d) {
                    cur = nums[j];
                    pos = j - d;
                    //将大于cur的元素向后移动,找到插入位置,注意步长为d
                    while (pos >= i && cur < nums[pos]) {
                        nums[pos + d] = nums[pos];
                        pos -= d;
                    }
                    //注意pos 指向最终插入位置的前一个位置
                    nums[pos + d] = cur;
                }
            }
            //这个放到最后,保证d==1的插入排序执行
            if (d == 1)
                break;
        }
        print("希尔排序", nums);
    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
复杂度分析

跟增量选取有关系,当增量合适时,可以将时间复杂度提高到O(n3/2)
好于O(n²)。空间复杂度为O(1).

堆排序算法

堆排序(Heap Sort)就是利用堆(假设利用大顶堆)进行排序的方法。它的基本思想是,将待排序的序列构造成一个大顶堆。此时,整个序列的最大值就是堆顶的根结点。将它移走(其实就是将其与堆数组的末尾元素交换,此时末尾元素就是最大值),然后将剩余的n-1个序列重新构造成一个堆,这样就会得到n个元素中的次大值。如此反复执行,便能得到一个有序序列了。

其中最关键的操作有两个,建堆(假设大顶堆)操作和交换操作。建堆操作是依次将[0,len-1-i] 区间中的所有元素构造成大顶堆,此时堆顶元素nums[0]则为最大值,然后交换堆顶元素nums[0] 和nums[len-1-i]。

程序实现

建堆操作

//建立大顶堆,将最大值交换到了根节点(索引为0)
    private void buildMaxHeap(int[] data, int lastIndex) {
        //从lastIndex 的父节点开始
        for (int i = (lastIndex - 1) / 2; i >= 0; i--) {
            //保存当前索引
            int k = i;
            //如果当前k结点有子节点,需要找到自身和子节点中的最大值并进行交换
            if ((2 * k + 1) <= lastIndex) {
                //左子节点索引
                int leftIndex = 2 * k + 1;
                int rightIndex = leftIndex + 1;
                //左右结点都有,如果是lastIndex,那么lastIndex 就是k的右子节点
                if (rightIndex <= lastIndex) {
                    //左子节点最大,包含两个节点相等且都大于k的情况
                    if (data[k] < data[leftIndex] &&
                            data[leftIndex] >= data[rightIndex])
                        swap(data, k, leftIndex);
                        //右子节点最大
                    else if (data[k] < data[rightIndex] &&
                            data[rightIndex] > data[leftIndex])
                        swap(data, k, rightIndex);
                }
                //只有左子节点
                else {
                    //只有左子节点大于k才执行交换
                    if (data[leftIndex] > data[k])
                        swap(data, k, leftIndex);
                }
            }
        }
    }
    //交换函数
    private void swap(int[] data, int i, int j) {
        int temp = data[i];
        data[i] = data[j];
        data[j] = temp;
    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38

主程序

 //堆排序,构建完全二叉树(根最大是大顶堆,最小则是小顶堆)
    //平均时间复杂度为O(n*logn)空间复杂度O(1) 不稳定
    public void heapSort(int[] nums) {
        int len = nums.length;
        //循环建堆
        for (int i = 0; i < len; i++) {
            buildMaxHeap(nums, len - 1 - i);
            //交换堆顶和最后一个元素,交换之后下次建队得到次大元素
            swap(nums, 0, len - 1 - i);
        }
        print("堆排序", nums);
    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
算法复杂度分析

它的运行时间主要是消耗在初始构建堆和在重建堆时的反复筛选上。

在构建堆的过程中,因为我们是完全二叉树从最下层最右边的非终端结点开始构建,将它与其孩子进行比较和若有必要的互换,对于每个非终端结点来说,其实最多进行两次比较和互换操作,因此整个构建堆的时间复杂度为O(n)。

在正式排序时,第i次取堆顶记录重建堆需要用O(logi)的时间(完全二叉树的某个结点到根结点的距离为),并且需要取n-1次堆顶记录,因此,重建堆的时间复杂度为O(nlogn)。

所以总体来说,堆排序的时间复杂度为O(nlogn)。由于堆排序对原始记录的排序状态并不敏感,因此它无论是最好、最坏和平均时间复杂度均为O(nlogn)。这在性能上显然要远远好过于冒泡、简单选择、直接插入的O(n²)的时间复杂度了。

空间复杂度上,它只有一个用来交换的暂存单元,复杂度为O(1).由于记录的比较与交换是跳跃式进行,因此堆排序也是一种不稳定的排序方法。
由于初始构建堆所需的比较次数较多,因此堆排序并不适合待排序序列个数较少的情况。

归并排序算法

归并排序(Merging Sort)就是利用归并的思想实现的排序方法。它的原理是假设初始序列含有n个记录,则可以看成是n个有序的子序列,每个子序列的长度为1,然后两两归并,得到|n/2|(|x|表示不小于x的最小整数)个长度为2或1的有序子序列;再两两归并,……,如此重复,直至得到一个长度为n的有序序列为止,这种排序方法称为2路归并排序。
归并排序是典型的分治法思想分解:将原问题分解成一系列子问题
递归地求解各子问题。若子问题足够小,则直接求解,最后将子问题的结果合并成原问题的解。
核心操作有两个:两个有序数组的合并merge操作,原问题的分割操作。

算法实现

merge操作

 //merge操作 [low,mid] [mid+1,high]
    private void merge(int[] nums, int[] arr, int low, int mid, int high) {
        int i = low;
        int j = mid + 1;
        int k = 0;
        while (i <= mid && j <= high) {
            if (nums[i] < nums[j]) {
                arr[k] = nums[i];
                i++;
            } else {
                arr[k] = nums[j];
                j++;
            }
            k++;
        }
        //处理剩余
        while (i <= mid) {
            arr[k] = nums[i];
            i++;
            k++;
        }
        while (j <= high) {
            arr[k] = nums[j];
            j++;
            k++;
        }
        //复制到原有数组中
        for (int index = 0; index < k; index++) {
            nums[low + index] = arr[index];
        }
    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32

递归调用的分割操作

 //分治法,递归调用
    private void mSort(int[] nums, int[] arr, int low, int high) {
        if (low < high) {
            int mid = (low + high) / 2;
            mSort(nums, arr, low, mid);    //左边有序
            mSort(nums, arr, mid + 1, high); //右边有序
            merge(nums, arr, low, mid, high); //再将二个有序数列合并
        }
    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

主程序

//归并排序,分治法,
    //时间复杂度为O(nlogn),空间复杂度为O(n),稳定

    public void mergeSort(int[] nums) {

        int[] arr = new int[nums.length];
        mSort(nums, arr, 0, nums.length - 1);
        print("归并排序", nums);

    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
算法复杂度分析

我们来分析一下归并排序的时间复杂度,一趟归并需要将nums中相邻的长度为h的有序序列进行两两归并。并将结果放到原数组中,这需要将待排序序列中的所有记录扫描一遍,因此耗费O(n)时间,而由完全二叉树的深度可知,整个归并排序需要进行log2n(2为底数)次,因此,总的时间复杂度为O(nlogn),而且这是归并排序算法中最好、最坏、平均的时间性能。

由于归并排序在归并过程中需要与原始记录序列同样数量的存储空间存放归并结果以及递归时深度为log2n的栈空间,因此空间复杂度为O(n+logn)。另外,Merge函数中有if(SR[i]

快速排序

快速排序(Quick Sort)的基本思想是:通过一趟排序将待排记录分割成独立的两部分,其中一部分记录的关键字均比另一部分记录的关键字小,则可分别对这两部分记录继续进行排序,以达到整个序列有序的目的。
也是典型的分治法思想,大问题分解成子问题。关键操作有这么几个。枢轴的选取操作getPivot,选择哪一个关键字来进行分割;一趟排序操作,根据选取的枢轴进行排序partition函数,递归调用的区间分割操作。

算法实现

选择枢轴

 //取得枢轴,这里选取第一个
    private int getPivot(int[] nums, int low, int high) {
        return nums[low];
    }
  • 1
  • 2
  • 3
  • 4

一趟排序操作

//partition函数将nums 以枢轴元素分界,[左边<=]pivot[<=右边]
    private int partition(int[] nums, int low, int high) {
        //取枢轴元素
        int pivot = getPivot(nums, low, high);
        while (low < high) {
            //从后往前找第一个小于pivot的元素
            while (low < high && nums[high] >= pivot)
                high--;
            //交换
            swap(nums, low, high);
            //从前往后找到第一个大于pivot的元素
            while (low < high && nums[low] <= pivot)
                low++;
            //交换
            swap(nums, low, high);
        }
        //low=high 最终枢轴位置在此
        return low;
    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20

区间分割操作

 //递归调用
    private void qSort(int[] nums, int low, int high) {
        //此条件不可缺少,递归终止条件
        if (low >= high)
            return;
        int pivot = partition(nums, low, high);
        //左区间递归排序
        qSort(nums, low, pivot - 1);
        //右区间递归排序
        qSort(nums, pivot + 1, high);

    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12

主程序调用入口

//快速排序
    //平均时间复杂度O(nlogn) 空间复杂度O(1) 不稳定
    public void quickSort(int[] nums) {
        qSort(nums, 0, nums.length - 1);
        print("快速排序", nums);
    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
算法复杂度分析

由数学计算可以得出,在最优的情况下,快速排序算法的时间复杂度为O(nlogn)。
在最坏的情况下,待排序的序列为正序或者逆序,每次划分只得到一个比上一次划分少一个记录的子序列,注意另一个为空。如果递归树画出来,它就是一棵斜树。此时需要执行n-1次递归调用,且第i次划分需要经过n-i次关键字的比较才能找到第i个记录,也就是枢轴的位置,因此比较次数为sigma(i=1, n-1, n-i)=(n-1)+(n-2)+…+1=n(n-1)/2,最终其时间复杂度为O(n²)。
平均的情况,由数学归纳法可证明,其数量级为O(nlogn)。

从空间复杂度来看,主要是递归造成的栈空间的使用,最好情况,递归树的深度为log2n,其空间复杂度也就为O(logn),最坏情况,需要进行n-1递归调用,其空间复杂度为O(n),平均情况,空间复杂度也为O(logn)。

由于关键字的比较和交换是跳跃进行的,因此,快速排序是一种不稳定的排序方法。

快速排序优化
1. 优化枢轴选取

选取首个枢轴在待排序的序列是基本有序的情况下是不合理的,会造成很多无效操作。

随机选取枢轴法
随机获得一个low与high之间的数rnd,让它的关键字L.r[rnd]与L.r[low]交换,这被称为随机选取枢轴法。在某种程度上,解决了对于基本有序的序列快速排序时的性能瓶颈。

三数取中法

对随机选取法的进一步改进,优化随机选取出现极值的情况。
即取三个关键字先进行排序,将中间数作为枢轴,一般是取左端、右端和中间三个数,也可以随机选取。这样至少这个中间数一定不会是最小或者最大的数,从概率来说,取三个数均为最小或最大数的可能性是微乎其微的,因此中间数位于较为中间的值的可能性就大大提高了。

2. 优化不必要的交换

将枢轴元素值暂存起来,将交换操作改成直接赋值操作。然后在之前是swap时,只作替换的工作,最终当low与high会合,即找到了枢轴的位置时,再将枢纽元素归位。因为这当中少了多次交换数据的操作,在性能上又得到了部分的提高。

3. 优化小数组时的排序方案

如果数组非常小,其实快速排序反而不如直接插入排序来得更好(直接插入是简单排序中性能最好的)。其原因在于快速排序用到了递归操作,在大量数据排序时,这点性能影响相对于它的整体算法优势而言是可以忽略的,但如果数组只有几个记录需要排序时,这就成了一个大炮打蚊子的大问题。因此可以增加一个关键字数量的阈值判断,当小于这个阈值时,采用直接插入排序,大于这个阈值才采用快速排序。阈值可以选取7 或50均可。有资料认为7比较合适,也有认为50更合理,实际应用可适当调整。

 private static final int MAX_LENGTH_INSERT_SORT = 7;
 //改进qSort
    private void qSort1(int[] nums, int low, int high) {
        //此条件不可缺少,递归终止条件
        if (low >= high)
            return;
        if (low - high > MAX_LENGTH_INSERT_SORT) {
            int pivot = partition1(nums, low, high);
            //左区间递归排序
            qSort(nums, low, pivot - 1);
            //右区间递归排序
            qSort(nums, pivot + 1, high);
        } else
            insertSort(nums);
    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
4. 优化递归操作

递归对性能是有一定影响的,qSort函数在其尾部有两次递归操作。如果待排序的序列划分极端不平衡,递归深度将趋近于n,而不是平衡时的log2n,这就不仅仅是速度快慢的问题了。栈的大小是很有限的,每次递归调用都会耗费一定的栈空间,函数的参数越多,每次递归耗费的空间也越多。因此如果能减少递归,将会大大提高性能。

 //递归优化
    private void qSort2(int[] nums, int low, int high) {
        //此条件不可缺少,递归终止条件
        if (low >= high)
            return;
        if (low - high > MAX_LENGTH_INSERT_SORT) {
            while (low < high) {
                int pivot = partition1(nums, low, high);
                //左区间递归排序
                qSort(nums, low, pivot - 1);
                //尾递归
                low = pivot + 1;
                //下次循环相当于再循环后,
                // 来一次Partition(L,low,high),
                // 其效果等同于“QSort(L,pivot+1,high)
            }
        } else
            insertSort(nums);
    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20

当我们将if改成while后,因为第一次递归以后,变量low就没有用处了,所以可以将pivot+1赋值给low,再循环后,来一次partition(nums,low,high),其效果等同于“qSort(nums,pivot+1,high);”。结果相同,但因采用迭代而不是递归的方法可以缩减堆栈深度,从而提高了整体性能。

各种排序算法的比较和选择

排序算法特性汇总

从算法的简单性来看,我们将7种算法分为两类:
简单算法:冒泡、简单选择、直接插入。
改进算法:希尔、堆、归并、快速。

时间复杂度比较

平均情况比较

从平均情况来看,显然最后3种改进算法要胜过希尔排序,并远远胜过前3种简单算法。

最好情况比较

从最好情况看,反而冒泡和直接插入排序要更胜一筹,也就是说,如果你的待排序序列总是基本有序,反而不应该考虑4种复杂的改进算法。

最坏情况比较

从最坏情况看,堆排序与归并排序又强过快速排序以及其他简单排序。

空间复杂度比较

从空间复杂度来说,归并排序,快速排序都有相应的空间要求,反而堆排序等对空间要求是O(1)。如果内存有限,就别选择归并排序和快速排序了。

稳定性比较

从稳定性来看,归并排序只对于非常在乎排序稳定性的应用中,归并排序是个好算法。

根据待排序数据本身特征来选择

排序记录数

从待排序记录的个数上来说,待排序的个数n越小,采用简单排序方法越合适。反之,n越大,采用改进排序方法越合适。这也就是我们为什么对快速排序优化时,增加了一个阀值,低于阀值时换作直接插入排序的原因。

关键字本身所占存储空间较大

似乎简单选择排序在3种简单排序中性能最差,其实也不完全是,比如,如果记录的关键字本身信息量比较大(例如,关键字都是数十位的数字),此时表明其占用存储空间很大,这样移动记录所花费的时间也就越多,所以应该尽量减少关键字的交换次数。我们给出3种简单排序算法的移动次数比较,
移动次数

此时简单选择排序就变得非常有优势,原因也就在于,它是通过大量比较后选择明确记录进行移动,具有最少的记录交换次数。因此对于数据量不是很大而记录的关键字信息量较大的排序要求,简单排序算法是占优的。另外,记录的关键字信息量大小对那四个改进算法影响不大。
总之,从综合各项指标来说,经过优化的快速排序是性能最好的排序算法,但是不同的场合我们也应该考虑使用不同的算法来应对它。

其他排序

计数排序

当输入的元素是 n 个 0 到 k 之间的整数时,它的运行时间是 Θ(n + k)。计数排序不是比较排序,排序的速度快于任何比较排序算法。
由于用来计数的数组C的长度取决于待排序数组中数据的范围(等于待排序数组的最大值与最小值的差加上1),这使得计数排序对于数据范围很大的数组,需要大量内存。计数排序是用来排序0到100之间的数字的最好的算法,但是它不适合按字母顺序排序人名。但是,计数排序可以用在基数排序中的算法来排序数据范围很大的数组。
算法的步骤如下:
1.找出待排序的数组中最大和最小的元素
2. 统计数组中每个值为i的元素出现的次数,存入数组C的第i项
3. 对所有的计数累加(从C中的第一个元素开始,每一项和前一项相加)
4. 反向填充目标数组:将每个元素i放在新数组的第C(i)项,每放一个元素就将C(i)减去1

算法实现
//计数排序
    public void countSort(int[] nums) {
        int max = Integer.MIN_VALUE;
        int min = Integer.MAX_VALUE;

        //找出数组中的最大最小值
        for (int i = 0; i < nums.length; i++) {
            max = Math.max(max, nums[i]);
            min = Math.min(min, nums[i]);
        }

        int[] help = new int[max - min + 1];

        //找出每个数字出现的次数
        for (int i = 0; i < nums.length; i++) {
            int mapPos = nums[i] - min;
            help[mapPos]++;
        }
        int index = 0;
        for (int i = 0; i < help.length; i++) {
            while (help[i] > 0) {
                nums[index++] = i + min;
                help[i]--;
            }
        }
        print("计数排序", nums);
    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
算法复杂度分析

计数排序的时间复杂度为O(n+k),空间复杂度为Ο(k)(其中k是整数的范围)

桶排序

桶排序是计数排序的变种,把计数排序中相邻的m个”小桶”放到一个”大桶”中,在分完桶后,对每个桶进行排序(一般用快排),然后合并成最后的结果。

基本思想:

桶排序假设序列由一个随机过程产生,该过程将元素均匀而独立地分布在区间[0,1)上。我们把区间[0,1)划分成n个相同大小的子区间,称为桶。将n个记录分布到各个桶中去。如果有多于一个记录分到同一个桶中,需要进行桶内排序。最后依次把各个桶中的记录列出来记得到有序序列。

算法实现
 //桶排序 适用于均匀分布的记录

    public void bucketSort(int[] nums) {
        int max = Integer.MIN_VALUE;
        int min = Integer.MAX_VALUE;

        for (int i = 0; i < nums.length; i++) {
            max = Math.max(max, nums[i]);
            min = Math.min(min, nums[i]);
        }

        //桶数
        int bucketNum = (max - min) / nums.length + 1;
        ArrayList<ArrayList<Integer>> bucketArr = new ArrayList<>(bucketNum);
        for (int i = 0; i < bucketNum; i++) {
            bucketArr.add(new ArrayList<>());
        }

        //将每个元素放入桶,相当于做了映射
        for (int i = 0; i < nums.length; i++) {
            int num = (nums[i] - min) / (nums.length);
            bucketArr.get(num).add(nums[i]);
        }

        //对每个桶进行排序
        for (int i = 0; i < bucketArr.size(); i++) {
            Collections.sort(bucketArr.get(i));
        }
        //遍历并还原到原来数组中
        int k = 0;
        for (int i = 0; i < bucketArr.size(); i++)
            for (int j = 0; j < bucketArr.get(i).size(); j++)
                if (bucketArr.get(i).get(j) != null)
                    nums[k++] = bucketArr.get(i).get(j);

        print("桶排序", nums);

    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
算法复杂度分析

桶排序的平均时间复杂度为线性的O(N+C),其中C为桶内快排的时间复杂度。如果相对于同样的N,桶数量M越大,其效率越高,最好的时间复杂度达到O(N)。 当然桶排序的空间复杂度 为O(N+M),如果输入数据非常庞大,而桶的数量也非常多,则空间代价无疑是昂贵的。此外,桶排序是稳定的。

基数排序

基本思想:将待排数据中的每组关键字依次进行桶分配。

算法实现

    //基数排序
    public void radixSort(int[] nums) {
        //找出并计算最大值的位数
        int max = Integer.MIN_VALUE;
        for (int val : nums)
            max = Math.max(max, val);
        int d = 1;//最高位数 1,10,100...
        while (max > 0) {
            max /= 10;
            d*=10;
        }
        int len = nums.length;
        //bucket 保存排序结果
        int[][] bucket = new int[10][len];
        //记录每个槽内的数量
        int[] order = new int[10];
        int index = 1;//表示位数(1(个),2(十),3(百))
        //索引
        int i, j, k, m;
        int digit;
        while (index <=d) {
            k = 0;
            for (i = 0; i < len; i++) {
                //计算当前位数字
                digit=(nums[i]/index)%10;
                //保存在bucket对应位置
                if (digit != -1)
                    bucket[digit][order[digit]++] = nums[i];
            }
            //一次排序完成后,还原到原来数组当中
            for (j = 0; j < bucket.length; j++) {
                m = 0;
                while (order[j] > 0 && m < order[j]) {
                    nums[k++] = bucket[j][m];
                    //置空bucket[j][m]
                    bucket[j][m++] = 0;
                }
                //清空order
                order[j] = 0;
            }
            index*=10;
        }

        print("基数排序", nums);
    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
算法复杂度分析

基数排序的性能比桶排序要略差。每一次关键字的桶分配都需要O(N)的时间复杂度,而且分配之后得到新的关键字序列又需要O(N)的时间复杂度。假如待排数据可以分为d个关键字,则基数排序的时间复杂度将是O(d*2N) ,当然d要远远小于N,因此基本上还是线性级别的。基数排序的空间复杂度为O(N+M),其中M为桶的数量。一般来说N>>M,因此额外空间需要大概N个左右。

但是,对比桶排序,基数排序每次需要的桶的数量并不多。而且基数排序几乎不需要任何“比较”操作,而桶排序在桶相对较少的情况下,桶内多个数据必须进行基于比较操作的排序。因此,在实际应用中,基数排序的应用范围更加广泛。

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/笔触狂放9/article/detail/163999
推荐阅读
相关标签
  

闽ICP备14008679号