从减治法到插入排序再到希尔排序

减治法和分治法

在算法学习的路上,我们必定会听过一个名词:分治法。这个算法设计思想的应用的广泛就和他的名声一样广为人知。但是不少初学者往往却会弄混减治法分治法的区别。这里引用一下 wiki 上对这个区别的介绍 Divide and conquer algorithm :

Therefore, some authors consider that the name “divide and conquer” should be used only when each problem may generate two or more subproblems. The name decrease and conquer has been proposed instead for the single-subproblem class.

可以理解为,分治法是将问题分解为多个子问题,解决这些子问题并得到最终大问题的结果;而减治法是将大问题的规模缩小为小问题,解决一个小问题而得出大问题的解。

插入排序

减治技术利用了一个问题给定实例的解和同样问题较小实例的解之间的某种关系。一旦建立起这种关系,我们就可以从顶至下,也可以从底至上地来运用这种关系。
从顶至下会自然而然地导出递归算法,典型例子是在一棵树中查找一个元素,这个不是今天文章的重点,不多赘述。
相反,从底至上就容易导出迭代的算法,从求解问题的一个较小的实例开始,所以该方法有时也被称为增量法

插入排序就是减治法的一种典型的例子:我们考虑对一个数组 A[0….n-1] 进行排序,假设我们对数组 A 的子数组 A[0….n-2] 的排序已经完成,那么只要将剩下的元素 n-1 在 A[0….n-2] 中找到合适的位置插入即可完成排序。然后我们再假设对 A[0….n-3] 的排序已经完成,那么将 n-2 插入到合适的位置即可完成 A[0….n-2] 的排序

很明显,插入排序的思想是基于递归思想的,但是我们常见的插入排序的实现代码是基于增量法的,要谈为什么使用增量法而不使用易于理解的递归算法,那就要扯到递归算法的缺点了,以后有空会补上一篇《论迭代和递归的优缺点》来细细评判这个问题了,在此就不叙述了。至于如何实现增量法版本的插入排序算法,只要我们把思想反转过来,先考虑得到只含一个元素的有序数组,然后在这个数组中找到正确的位置插入第二个元素、第三个元素……直到插入完第 n 个元素,就可以得到有序的数组 A[0….n-1] 了。

下面是插入排序算法的 Java 版实现代码:

public void insertSort(int[] arr){
        for (int i = 1; i < arr.length; i++) {
            int val = arr[i];
            int j = i - 1;
            while (j >= 0 && arr[j] > val){
                arr[j + 1] = arr[j];
                j--;
            }
            arr[j + 1] = val;
        }
    }

从算法的实现代码可以看出插入排序的基本操作是比较 arr[j] > val。所以它的性能也跟这个键值对的比较次数息息相关。

分析可见该算法的键值对比较次数取决于输入的数组。最坏情况下,每轮循环的比较次数都会走满,此时输入的数组是一个纯降序数组。比较次数为 (n - 1) * n / 2。由此也可以知道插入排序算法的性能是 O(n²) 级别。最优的情况是输入的数组是一个纯升序的数组,每轮循环比较次数仅发生一次。键值对的比较次数为 n - 1。而对于平均情况,研究表明插入排序处理无序数组时,性能是降序数组,即最坏情况的一半 n² / 4

插入排序在平均情况下的性能比最差性能快一半,以及它在处理接近有序的数组时表现出优异的性能,使它在基本排序算法中占据无可撼动的地位。

希尔排序

希尔排序的名字来自于它的提出者 DL.Shell ,它是对插入排序的改进算法。希尔排序的算法思想是将整个待排序列
(R1, R2, R3, ……, Rn)
按增量 d 划分成 d 个子序列,其中第 i (1 <= i <=d) 个子序列为
(Ri, Ri+d, Ri+2d, ……, Ri+kd)
并分别对每个子序列进行插入排序(如果以增量 d 划分原数组,原数组就有 d 个子序列,就要进行 d 次插入排序);不断减小增量 d,重复这一过程,直到 d 减少到 1,对整个数组进行一次插入排序。基于增量不断减少的特点,希尔排序也称为缩小增量排序

希尔排序前几趟排序(除了最后一次)和上面我们写的插入排序算法代码的的最大区别是这几次插入排序操作数组时下标移动的间隔 d 不是 1,而是 d 个元素!同时由于每次移动的间隔较大,可以跨越多个元素,所以实现的调整是宏观上的调整。

当希尔排序走到最后一趟排序,即增量为 1 时,得益于前几趟排序宏观上的调整,数组已经基本上有序,此时最后一趟排序就相当于最好情况下的插入排序。将前面各趟的调整看成是最后一趟的预处理,比只做一次增量为 1 的直接插入排序操作的效率高很多。

当然,我相信只是这样书面式的解释肯定不好理解希尔希尔排序为什么调大增量就能改进插入排序算法,下面引用知乎 希尔排序为什么会那么牛那么快,能够证明吗? 问题下 冒泡 用户的回答:

为啥希尔能突破 O(n²) 的界,可以用逆序数来理解,假设我们要从小到大排序,一个数组中取两个元素如果前面比后面大,则为一个逆序,容易看出排序的本质就是消除逆序数,可以证明对于随机数组,逆序数是
O(n²) 的,而如果采用“交换相邻元素”的办法来消除逆序,每次正好只消除一个,因此必须执行 O(n²) 的交换次数,这就是为啥冒泡、插入等算法只能到平方级别的原因,反过来,基于交换元素的排序要想突破这个下界,必须执行一些比较,交换相隔比较远的元素,使得一次交换能消除一个以上的逆序,希尔、快排、堆排等等算法都是交换比较远的元素,只不过规则各不同罢了

即关键是:希尔排序使用大增量一次消除一个以上的数组元素逆序

同样给出希尔排序的实现代码

public void shellSort(int[] arr){
        // 增量初始值取数组长度的一半,并每次减半直到增量为 1
        for (int d = arr.length / 2; d >= 1; d /= 2){
            // 对于增量 d 划分出来的 d 个子序列分别进行排序
            // i 是子序列的起点
            for (int i = 0; i < d; i++){
                // 增量为 d 的选择排序
                for (int j = i + d; j < arr.length; j += d){
                    int val = arr[j];
                    int k = j - d;
                    while (k >= i && arr[k] > val){
                        arr[k + d] = arr[k];
                        k -= d;
                    }
                    arr[k + d] = val;
                }
            }
        }
    }

因为希尔排序每次交换元素跨越了多个下标,所以有可能造成相同的元素在经过排序后位置发生了变化的结果,即希尔排序是不稳定的排序算法,在使用希尔排序时需注意这种不稳定的特性是否会造成不良的影响。

另外上面这段希尔排序的代码仍然不是最优情况下的希尔排序代码,希尔排序的时间复杂度是所取增量变化情况的函数,目前尚难以分析出最好的增量减少规律。

    原文作者:wean_a23e
    原文地址: https://www.jianshu.com/p/d840caa9815d
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞