读书笔记——-分治法与归并排序,快速排序(基本思路+细节处理)
分治法简述
分治法,顾名思义,分而治之。对于一个规模为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。等我哪天大彻大悟,搞懂了就回来改代码,或者哪位大佬指点我一下。