《数据结构与算法之美》专栏阅读笔记3——排序算法

版权声明:转载请注明来源 https://blog.csdn.net/EllieYaYa/article/details/83894429

上周排计划,说花个一天的时间看完好了(藐视脸)~然后每天回家看一会,看了一个星期……做人,要多照镜子好嘛

1、简单排序

1.1 如何分析排序算法

从以下几个方方面入手。

执行效率
  • 最好情况、最坏情况、平均情况时间复杂度
  • 时间复杂度的系数、常数、低阶
    时间复杂度反映的时数据规模较大的时候的增长趋势。但实际开发中,也存在很多小规模的数据,此事稀疏、常数和低阶的占比较大,需要进行考虑。
  • 比较次数和交换次数
内存消耗

可以通过空间复杂度来衡量。新概念:原地排序。特指空间复杂度为O(1)的排序算法。

稳定性

对于同一序列,排序的结果相同。
因为实际比较中,更多的是对对象进行排序。

2、排序算法

2.1、冒泡

原理:重复地走访过要排序的元素列,依次比较两个相邻的元素,如果他们的顺序错误就把他们交换过来。重复地进行直到没有相邻元素需要交换,直到排序完成。

要点:

  • 对N个待排序元素,要排序的序列是0 ~ (N-已排序个数)。
  • 如果一次排序中没有进行任何元素交换,说明序列已经是有序的,可以停止比较。

分析:
顺便复习下均摊时间复杂度的使用场景:

  • 大部分情况下,时间复杂度都很低,只有个别情况下,时间复杂度较高。
  • 操作之间存在前后连贯的时序关系
    对排序算法的平均复杂度分析,可以使用有序度逆序度来进行分析。
  • 有序度:数组中具有有序关系的元素对的个数。
  • 满序度:完全有序的大小为n的数组的有序度,为n*(n-1)/2
  • 逆序度:与有序度相反。

关键公式:逆序度 = 满有序度 - 有序度。

排序的过程就是达到满有序度的过程

结论:交换的次数等于逆序度。
平均交换次数 = [0(最好) + n*(n-1)/2(最坏)]/2 = n*(n-1)/4

在作者给的基础上再来一点优化:

public static <T extends Comparable<T>> void BubbleSort(T[] values, int length) {
        if (length <= 1)
            return;
        int flag = length;
        while (flag > 0) {
            int end = flag - 1;
            flag = 0;
            for (int j = 0; j < end; j++) {
                if (values[j].compareTo(values[j+1]) > 0) {
                    T tmp = values[j];
                    values[j] = values[j+1];
                    values[j+1] = tmp;
                    flag = j + 1;
                }
            }
        }
    }

通过flag来缩短要排序序列

2.2、插入

原理:取未排序区间中的元素,在已排序区间中找到合适的插入位置将其插入,并保证已排序区间数据一直有序。

要点:
插入是通过移动来达到满序度的,所以移动的次数等于逆序度。

public static <T extends Comparable<T>> void InsSort(T[] values, int length) {
        if (length <= 1)
            return;
        for (int i = 0; i < length; i++) {
            T v = values[i];
            int j = i - 1;
            for (; j >= 0; --j) {
                if (values[j].compareTo(v) > 0) {
                    values[j+1] = values[j];
                } else {
                    break;
                }
            }
            values[j+1] = v;
        }
    }

直接插入排序,因为是要在已排序的序列中找到插入位置,所以需要移动是主节奏。

2.3、选择排序

原理:每次选取未排序区间的最小值,放到已排序区间的末尾。

要点:相较于插入排序,不需要移动元素,交换次数为n,消耗在遍历比较上。

实现

public static <T extends Comparable<T>> void SelSort(T[] values, int length) {
        if (length <= 1)
            return;

        for (int i = 0; i < length; i++) {
            T min = values[i];
            int minIdx = i;
            for (int j = i + 1; j < length; j++) {
                if (values[j].compareTo(min) < 0) {
                    min = values[j];
                    minIdx = j;
                }
            }
            if (minIdx != i) {
                T tmp = values[i];
                values[i] = values[minIdx];
                values[minIdx] = tmp;
            }
        }
    }

2.4、小结

适合小规模数据的排序。

3、分治思路的排序算法

3.1、归并排序

原理:将大问题分解成小问题来进行排序,然后对排序后的结果进行合并。(妥妥地递归哦)

图片链接

时间复杂度分析

T(1) = C;
T(n) = 2*T(n/2) + n;
		= 2^k * T(n/2^k) + k*n

另外一个分析思路就是:
拆分和合并的结构可以看作一棵二叉树,拆分部分和合并部分的深度都是logn,每个元素要找到自己的最终位置都要在树里跑一遍呢~所以复杂度为n*logn

实现:

private static <T extends Comparable<T>> void SortC(T[] values, int left, int right, T[] tmp) {
        if (left >= right)
            return;

        int mid = (left + right)/2;
        for (int i = left; i <= right; i++) {
            if (i == mid + 1)
                System.out.print("     +      ");
            System.out.print(values[i] + " ");
        }
        System.out.println("");

        SortC(values, left, mid, tmp);
        SortC(values, mid + 1, right, tmp);
        MergeC(values, left, mid, right, tmp);
    }
    private static <T extends Comparable<T>> void MergeC(T[] values, int left, int mid, int right, T[] tmp) {
        int i = left;
        int j = mid + 1;
        int tmpIdx = 0;
        while (i <= mid && j <= right) {
            if (values[i].compareTo(values[j]) < 1) {
                tmp[tmpIdx++] = values[i++];
            } else {
                tmp[tmpIdx++] = values[j++];
            }
        }

        while (i <= mid) {
            tmp[tmpIdx++] = values[i++];
        }
        while (j <= right) {
            tmp[tmpIdx++] = values[j++];
        }

        tmpIdx = 0;
        for (i = left; i <= right; i++) {
            values[i] = tmp[tmpIdx++];
        }
    }

利用哨兵简化编程的思路是:

MERGE(A, p, q, r)
	n1 ← q-p+1;                                 //计算左半部分已排序序列的长度
	n2 2 ← r-q;                                 //计算右半部分已排序序列的长度
	create arrays L[1..n1+1] and R[1..n2+1]     //新建两个数组临时存储两个已排序序列,长度+1是因为最后有一个标志位
	for i ← 1 to n1
	do L[i] ← A[p + i-1]    					//copy左半部分已排序序列到L中
	for j ← 1 to n2
	do R[j] ← A[q + j]      					//copy右半部分已排序序列到R中
	L[n1+1] ← ∞                             //L、R最后一位设置一个极大值作为标志位                       
	R[n2+1] ← ∞ 
	i ← 1
	j ← 1
	for k ← p to r         //进行合并
		do if L[i] < R[j]
		then A[k] ← L[i]
			i ← i + 1
		else A[k] ← R[j]
			j ← j + 1

使用极大值作为哨兵。
代码实现:

void Merge(int A[],int p,int q,int r)  
{  
    int i,j,k;  
    int n1=q-p+1;  
    int n2=r-q;  
    int *L=new int[n1+1]; //开辟临时存储空间  
    int *R=new int[n2+1];  
    for(i=0;i<n1;i++)  
        L[i]=A[i+p];      //数组下标从0开始时,这里为i+p  
    for(j=0;j<n2;j++)  
        R[j]=A[j+q+1];    //数组下标从0开始时,这里为就j+q+1  
    L[n1]=INT_MAX;        //"哨兵"设置为整数的最大值,INT_MAX包含在limits.h头文件中  
    R[n2]=INT_MAX;  
    i=0;  
    j=0;  
    for(k=p;k<=r;k++)     //开始合并  
    {  
        if(L[i]<=R[j])  
            A[k]=L[i++];  
        else  
            A[k]=R[j++];  
    }  
}  

3.2、快速排序

原理:也是利用分治的思想。先找到待排序序列中任意一点作为基准点,将小于基准点的放到基准点左边,大于的放到右边,并以基准点进行分区,递归到最小区间为1则所有数据完成排序。

小技巧:为了实现原地排序,在分区函数中使用数据交换而不是搬移。

性能分析

  • 分区极其均衡时,比如两个大小相同的区间,则跟合并排序性能差不多,O(nlogn)。
  • 分区极其不均衡时,取决于分区函数的实现,类似于冒泡+选择混合体了,退化到O(n^2)。

虽然均摊分析并不适用于此场景,分析思路可以借鉴,极其不均衡是要求每次分区都达到最不均衡的情况,概率比较小,所以平均时间复杂度还是O(nlogn)。
(作者没有继续分析,我只好挥发聪明才智瞎猜咯~)

实现

private static <T extends Comparable<T>> int partition(T[] values, int left, int right) {
        T pivot = values[right];
        int i = left;
        for (int j = left; j < right; j++) {
            if (values[j].compareTo(pivot) < 0) {
                T tmp = values[i];
                values[i] = values[j];
                values[j] = tmp;
                i++;
            }
        }

        T tmp = values[right];
        values[right] = values[i];
        values[i] = tmp;

        return i;
    }

    private static <T extends Comparable<T>> void QuickSortC(T[] values, int left, int right) {
        if (left >= right)
            return;

        int partIdx = partition(values, left, right);
        QuickSortC(values, left, partIdx - 1);
        QuickSortC(values, partIdx + 1, right);

    }
    public static <T extends Comparable<T>> void QuickSort(T[] values, int length) {
        QuickSortC(values, 0, length - 1);
    }

4、线性排序

桶排序、计数排序和基数排序都不是基于比较的排序算法,都不涉及元素之间的比较操作,时间复杂度可以达到O(n),也就是线性的。

4.1、桶排序

原理:将要排序的数据分到几个有序的桶里,每个桶里的数据再单独进行排序,排序完之后按照顺序依次取出。

桶排序对数据的要求比较严格:

  • 要排序的数据需要很容易就能划分成m个桶
  • 桶与桶之间有着天然的大小顺序(桶内排序完成后,桶之间不需要进行排序)
  • 数据在桶之间的分布比较均匀。

适用场景:外部排序。数据存储在外部磁盘中,因为内存有限。

4.2、计数排序

看了一下作者给的示意图,然后还让集中精神,就一脸懵逼。
其实好像可以小小总结下更简单呢:

  • step1:根据数值范围,按数值单位划分成K个桶。(这样可以不用进行桶内排序)
  • step2:每个桶记录的是下表对应数值的个数。(啥也不做的话我们已经知道每个数值有多少个重复的啦)
  • step3:从左到右累加。(因为要知道排序后的位置,就需要知道前面有多少个数据,累加完就晓得啦)
    数据源:2,5,3,0,2,3,0,3。
  • step4:遍历原始数组,根据“桶”组可以算出数据在排序后的数组中的下标。注意:因为重复的数据是挨着存的,对一个数据排序完毕后,需要对桶存储的值减一。

适用场景:数据范围不大的非负整数。

实现

public static void CountingSort(Integer[] values, int length) {
        if (length <= 1)
            return;
        int max = values[0];
        for (int i = 0; i < length; i++) {
            if (max < values[i]) {
                max = values[i];
            }
        }

        // 构造索引数组并初始化
        int[] c = new int[max + 1];
        for (int i = 0; i <= max; i++) {
            c[i] = 0;
        }

        // 统计
        for (int i = 0; i < length; i++) {
            c[values[i]]++;
        }

        // 累加得到索引值
        for (int i = 1; i <= max; i++) {
            c[i] += c[i-1];
        }

        int r[] = new int[length];
        for (int i = length - 1; i >= 0; --i) {
            int index = c[values[i]] - 1;
            r[index] = values[i];
            c[values[i]]--;
        }

        for (int i = 0; i < length; i++) {
            values[i] = r[i];
        }
    }

4.3、基数排序

原理:将待比较的数值分割成位,如果低位能够确定大小则无须继续比较高位。
按照低位优先比较和高位优先比较有两种写法,思路都是一致的。

适用场景:需要可以分割出独立的“位”来比较,而且位之间有递进的关系。

注意:用来比较位的算法必须是稳定的,否则低位的比较结果没有意义。对于非等长的情况可以使用0来补齐。

5、排序算法的优化

一个通用的排序算法需要兼顾性能和适用的数据规模。

5.1、快速排序中分区点选取的优化方向

最坏情况下的快速排序的时间复杂度是O(n^2),主要是分区点的选择影响的。
最理想的分区点是可以对半分。关于采样点的选取,作者给了两条思路(网友给了千千万万个<可能不太靠谱>的思路)

三数取中法

从区间的首、中、尾各取一个元素,选大小为中间值的那个作为分区点。
看上去就是采样的嘛(采样啥的当然是信号处理专业的强项丫~对不起!我给本专业丢脸了!)

随机法

从待排序区间中随机选取一个元素作为分区点。
看上去就像是掷色子(相信科学喵~)
引入随机化快速排序的作用,就是当该序列趋于有序时,能够让效率提高,大量的测试结果证明,该方法确实能够提高效率。但在整个序列数全部相等的时候,随机快排的效率依然很低,它的时间复杂度为O(N^2)。

5.2、递归优化

快速排序跟合并排序最大的不同就是它是先分区排序再进行递归。如果待排序的序列划分极端不平衡,递归的深度将趋近于n,而栈的大小是很有限的,每次递归调用都会耗费一定的栈空间,函数的参数越多,每次递归耗费的空间也越多。优化后,可以缩减堆栈深度。俩思路:

  • 限制递归深度
  • 通过再堆上模拟一个函数调用栈,手动模拟递归压栈、出栈的过程,来消除系统栈大小的限制。(啥意思?黑人问号)

大概说的是下面这个意思?

private static <T extends Comparable<T>> void QuickSortC(T[] values, int left, int right) {
        if (left >= right)
            return;

//        int partIdx = partition(values, left, right);
//        QuickSortC(values, left, partIdx - 1);
//        QuickSortC(values, partIdx + 1, right);
        LinkedListStack<Integer> stack = new LinkedListStack<>();
        int partIdx = partition(values, left, right);
        if (left < partIdx - 1) {
            stack.push(left);
            stack.push(partIdx);
        }
        if (partIdx + 1 < right) {
            stack.push(partIdx + 1);
            stack.push(right);
        }

        while(!stack.empty()) {
            right = stack.pop();
            left = stack.pop();

            partIdx = partition(values, left, right);

            if (partIdx == left || partIdx == right)
                continue;

            if (left < partIdx - 1) {
                stack.push(left);
                stack.push(partIdx);
            }
            if (partIdx + 1 < right) {
                stack.push(partIdx + 1);
                stack.push(right);
            }
        }
    }

6、小结

没有小结,谢谢。

猜你喜欢

转载自blog.csdn.net/EllieYaYa/article/details/83894429