读书笔记-------分治法与归并排序,快速排序(基本思路+细节处理)

读书笔记——-分治法与归并排序,快速排序(基本思路+细节处理)

分治法简述

分治法,顾名思义,分而治之。对于一个规模为n的问题,若该问题可以很容易的解决则直接解决,否则将其分解为k个规模较小的子问题,这些子问题相互独立且与原问题形式相同,递归地解决这些子问题,然后将各个子问题的解合并,得到原问题的解,这种算法设计策略叫做分治法。
在考虑用到分治法时,当前问题应该具有一下几个特征:

  • 该问题的规模缩小到一定的程度就可以很容易的解决。
  • 该问题可以分解为若干个规模较小的相同问题。
  • 利用该问题分解出的子问题的解可以合并成该问题的解。
  • 该问题所分解出的各个子问题都是相互独立的,每个子问题之间不包含公共的子问题。
    对于分治法,总是少不了递归的参与,对于递归的思想在另一篇博文的最后里有具体描述。

归并排序

归并排序的基本操作是合并,将两个有序表合并为一个有序表。因为两个表示有序的,所以要将输出放在第三个表中,就可以通过对输入数据的一趟排序来完成。基本的合并算法是取两个输入数组A,B,一个输出数组C,以及三个计数器i,j,k。它们的初始值对应各自数组的开端,将a[i]和B[j]中的较小元素放入C[k],相关计数器移动到下一位置。例如A[i]小于B[j],则将A[i]放入C[k],i+1,k+1,然后比较A[i+1]与B[j]的大小,同样将较小元素放入B[k+1],相关计数器移动到下一位置,如此循环往复,知道A,B中某一个数组为空,将另一个数组剩余部分拷贝到C中。即完成合并操作。
合并表的操作显然是线性的,因为至多比较n-1次,n为元素总数。因此归并算法很容易描述。
假设有n个元素待排序,当n=1时,当前序列自然是有序的(这也是递归的基准),当n>1时,开始分割序列(分),将序列分为前后两部分,每部分继续分割,直至分割到前后两部分都只有一个元素,这时就出现了两个有序的队列,然后就可以执行合并了。该算法是经典的分治策略。下面贴上代码:

public class gbpx {

    public static void px(int[] arr, int[] tmp,int left,int right) {

        if(left < right) {
            //分割点
            int center = (left + right)/2;
            //分
            px(arr, tmp, left, center);
            px(arr, tmp, center + 1, right);
            //治
            pxx(arr,tmp,left,center + 1,right);
        }

    }
    private static void pxx(int[] arr,int[] tem,int left,int center,int right) {
        int lright = center - 1;
        int temleft = left;
        int num = right - left + 1;

        while(left <= lright && center <= right ) {
            if(arr[left] < arr[center])
                tem[temleft++] = arr[left++];
            else
                tem[temleft++] = arr[center++];
        }
        while(center <= right ) {

                tem[temleft++] = arr[center++];
        }
        while(left <= lright) {

                tem[temleft++] = arr[left++];
        }
        for(int i = 0;i < num;i++, right--)
            arr[right] = tem[right];

    }

}

tem是一个与带排序数组等长的临时数组,由于pxx是px的最后一行,因此在任意时刻仅需要一个临时数组在活动,那么就可以在对px的驱动中建立临时数组。而且,我们在任意时刻使用的都是与参数arr相同的部分。
虽然,对临时数组的使用很精巧,但是也有一个明显的问题,合并两个序列用到了线性附加内存(临时数组)。在整个算法中还要做把数据拷贝到临时数组,在拷贝回去的这种附加工作,明显的减慢了排序的速度。这种拷贝可以通过在递归层次上谨慎的交换来避免,这就让使用更少的附加内存成为了可能,但是这种做法仅仅是理论上,而且所得到的算法也是复杂且不切实际的。
归并排序的时间复杂度是O(NlogN),与其它O(NlogN)算法相比,它严重依赖于比较元素和在数组,临时数组中移动元素的相对开销。这种开销是语言相关的。

快速排序基本思路

快速排序和归并排序一样是分之的递归算法,但是快速排序相对复杂。
快速排序的基本思想是,在待排序的n个元素中取一个元素作为基准,把该元素放入最终位置后,整个数据序列被基准分割成为两个子序列,所有小于基准的元素放置在前子序列中,所有大于基准的元素放置在后子序列中,并把基准排在这两个子序列的中间,然后对两个子序列分别重复上述过程,直至每个子序列内只有一个记录或空为止。(用一句话说就是,把每个元素都放入它们应该在的位置)上个代码:

public static void quickSort(int[] a, int l, int r) {
        if (l < r) {
            int i, j, x;

            i = l;
            j = r;
            x = a[i];
            while (i < j) {
                while (i < j && a[j] > x)
                    j--; // 从右向左找第一个小于x的数
                if (i < j)
                    a[i++] = a[j];
                while (i < j && a[i] < x)
                    i++; // 从左向右找第一个大于x的数
                if (i < j)
                    a[j--] = a[i];
            }
            a[i] = x;
            quickSort(a, l, i - 1); // 递归调用
            quickSort(a, i + 1, r); // 递归调用
        }

这是快速排序基本思想的实现。显然的,它解决了线性附加内存的问题(交换是在数组内进行的)。但是也带了一些隐患,它如同归并排序将序列分成了两个,但是这两个子问题的规模并不总是相近的,这取决于分割点的选取,子问题大小不等的递归调用会影响算法的效率。快速排序还有许多微妙的细节会影响到效率。

细节处理

枢纽元

讲道理,不管选择哪个元素做为枢纽元(对于分割点更规范的称呼),排序都会完成。但是总有一些选择会优于其他。

  • 一种需要慎重的做法:选择第一个元素作为枢纽元。对于随机的输入,是可以接受的。但是输入的是预排序或者是反序的话,这样的枢纽元就会产生一个劣质的分割——所有的元素都位于枢纽元的一侧。更蛋疼的是,这种劣质的分割将会出现在每次递归调用中。假设输入的是预排序,那么算法花费的时间是二次的,而且啥也没干。在实际中预排序的数据是相当常见的。可以说这种选择策略是一个坏主意。
  • 一种安全的做法:随机选取枢纽元。一般来说这种做法是安全的,除非随机数发生器出了问题。随机的枢纽元不可能总是在接连不断的产生劣质的分割。但是,产生随机数的开销一般也很大,减少不了算法其余部分的平均运行时间。
  • 三元中值分割法:中值就是中位数,N个数中第N/2个最大的数,这是枢纽愿最好的选择。但是不通过排序很难算出中值,也会明显减慢算法速度。因此,一般的做法是取左端,中间,右端三个元素的中值。这种方式很明显可以避免预排序产生的坏情况,而且减少了14%的比较次数(这个数据是《数据结构与算法分析Java语言描述》中给出)。下面贴取三元中值代码:
private static int median3(int[] arr,int left,int right) {
        int center = (left + right)/2;
        if(arr[left] > arr[center]) {
            swap(arr, left, center);
        }
        if(arr[left] > arr[right]) {
            swap(arr, left, right);
        }
        if(arr[center] > arr[right]) {
            swap(arr, center, right);
        }
        /*将中值与最后的元素交换与分割策略有关
         *有的是与倒数二个元素交换,看过一篇博文,说是可以在每次递归中减少一次比较(
         *这个还可以理解),还可以避免越界,我试了下,还是越界了,可能是我没搞懂
         */
        swap(arr, center, right);
        return arr[right];
    }

分割策略

现在,描述的是已被证明的能够给出好的结果的分割策略。分割的确是一种很容易出错或低效的操作,正所谓前人栽树后人乘凉,使用一种已知的方法是安全的。
分割策略:首先将枢纽元与最后的元素交换使得枢纽元离开要被分割的区域,i从第一个元素开始,j从倒数第二个元素开始。当i在j的左边时,将i向左移动,移过小于枢纽元的元素;同时将j向右移动,移过大于枢纽元的元素(移过,注意是移过)。当i.j移动都停止时,i会指向一个大元素,j指向一个小元素,将i,j指向的元素交换,继续重复上述过程,直到i,j交错为止(就是i在j右边)。此时,将i指向的元素与最后的元素交换。结束,此时枢纽元已经在它应该在的位置了。上代码

public static void quickSort(int[] arr, int left, int right) {
        //之后会解释这个定值
        final int CUTOFF = 20;
        if (light + CUTOFF <= right) {
        //判断传入数组有没有必要被处理
        if (left >= right) {
            return;
        }
        int pivot = median3(arr, left, right);

        int i = left, j = right - 1;

        //没搞明白返回三数中值返回arr[right—1]是怎么从逻辑上防止越界的
        //而且我用了还是越界,只好手动防止
        for (;;) {
            while (arr[i] < pivot) {
                    i++;
            }
            if (j != 0) {
                while (arr[j] > pivot) {
                    j--;
                }
            }
            if (i < j) {
                swap(arr, i, j);
            } else {
                break;
            }
        }
        swap(arr, i, right);
        //递归处理子问题
        quickSort(arr, left, i - 1);
        quickSort(arr, i + 1, right);
        } else {
        // 去调用插入排序之类的
        }

    }

swap是交换数组元素的方法,就不贴了,程序能跑,如果想测试可以把把if (light + CUTOFF <= right)这层判断注释掉。有这层判断是因为对于N<=20的数组而言,快速排序不如插入排序,所以定义了这个CUTOFF = 20的常数,这种策略实际上可以节省大约15%的运行时间。

智商不够

由于是智商不够哈,没理解三元中值返回arr[right—1]是怎么从逻辑上防止越界的,而且用了也越界了233333333。只好手动防止越界,虽然表面上解决了问题,但是使得每次递归都增加了一次判断的时间单位。但是对枢纽元的选取和分治策略没问题哈,千万别误会23333333。等我哪天大彻大悟,搞懂了就回来改代码,或者哪位大佬指点我一下。

猜你喜欢

转载自blog.csdn.net/qq_37721485/article/details/81413168
今日推荐