heapAdjust中的循环j值为何要从j = 2*i开始呢?又为什么是 j=2*j 递增呢?原因还是二叉树的性质5,因为我们这棵是完全二叉树,当前结点序号是 i,其左孩子的序号一定是 2i,右孩子的序号一定是2i+1 ,它们的孩子当然也是以 2 的位数序号增加,因此 j 变量才是这样循环。接着j
堆排序复杂度分析
它的运行时间主要是消耗在初始构建堆和在重建堆时的反复筛选上。
在构建堆的过程中,因为我们是完全二叉树从最下层最右边的非终端结点开始构建,将它与其孩子进行比较和若有必要的互换,对于每个非终端结点来说,其实最多进行两次比较和互换操作,因此整个构建堆的时间复杂度为O(n)。
在正式排序时,第 i 次取堆顶记录重建堆需要用 O(logi)的时间(完全二叉树的某个结点到根结点的距离为 log2i+ 1) ,并且需要取 n-1次堆顶记录,因此,重建堆的时间复杂度为 O(nlogn) 。
所以总体来说,堆排序的时间复杂度为 O(nlogn) 。由于堆排序对原始记录的排序状态并不敏感,因此它无论是最好、最坏和平均时间复杂度均为 O(nlogn)。这在性能上显然要远远好过于冒泡、简单选择、 直接插入的 O(n²)的时间复杂度了。
空间复杂度上它只有一个用来交换的暂存单元,也非常的不错。不过由于记录的比较与交换是跳跃式进行,因此堆排序也是一种不稳定的排序方法。
另外,由于初始构建堆所需的比较次数较多,因此,它并不适合待排序序列个数较少的情况。
1.8归并排序
"归并"一词的中文含义就是合并、并入的意思,而在数据结构中的定义是将两个或两个以上的有序表组合成一个新的有序表。
归并排序 ( Merging Sort) 就是利用归并的思想实现的排序方法。它的原理是假设初始序列含有 n 个记录 , 则可以看成是 n 个有序的子序列,每个子序列的长度为1,然后两两归并,得到[n/2] ([x]表示不小于 x 的最小整数)个长度为 2或 1 的有序子序列;再两两归并,……,如此重复, 直至得到一个长度为 n 的有序序列为止 ,这种排序方法称为2路归并排序。
好了,有了对归并排序的初步认识后,我们来看代码。
public static int[] data = { 50, 10, 90, 30, 70, 40, 80, 60, 20 };
public static int[] output = new int[9];
public static void main(String[] args) {
// TODO Auto-generated method stub
int index = data.length;
int dataLength = 1;
// 排序前的结果
System.out.print("排序之前数组:");
for (int i = 0; i < data.length; i++) {
System.out.print(" " + data[i] + " ");
}
System.out.println();
while (dataLength < index) {
System.out.println("二分排序数组长度:" + dataLength);
mergeAll(index - 1, dataLength);// 归并排序
dataLength = 2 * dataLength;
}
// 排序后的结果
System.out.print("排序后数组:");
for (int i = 0; i < data.length; i++) {
System.out.print(" " + data[i] + " ");
}
}
/** 将所有分开的数组两两合并 */
public static void mergeAll(int n, int dataLength) {
int i;
i = 0;
// 当还有长度为datalength的小数组可合并
while (i <= (n - 2 * dataLength + 1)) {
// 将[i,i+i + dataLength - 1] 和[i + dataLength,2 * dataLength -
// 1]合并为有序数组
mergeTwo(i, i + dataLength - 1, i + 2 * dataLength - 1);
i = i + 2 * dataLength;
}
if ((i + dataLength - 1) < n) {
mergeTwo(i, i + dataLength - 1, n);
} else {
// 将data剩余元素赋值给output数组
for (int t = i; t <= n; t++) {
output[t] = data[t];
}
}
// 将output数组赋值给data数组
for (int t = 0; t <= n; t++) {
data[t] = output[t];
}
System.out.print("当前排序结果:");
for (i = 0; i <= n; i++) {
System.out.print(" " + output[i] + " ");
}
System.out.println();
}
/**
* 将[i,middle] 和 [middle+1,n] 合并排序(从小到大)
*
* @param i
* @param middle
* @param n
*/
public static void mergeTwo(int i, int middle, int n) {
int j, k;
k = i;
j = middle + 1;
while (i <= middle && j <= n) {
if (data[i] <= data[j]) {
output[k++] = data[i++];
} else {
output[k++] = data[j++];
}
}
// 将[i,middle]中剩余的元素添加到output数组中
if (j > n) {
for (int t = 0; t <= middle - i; t++)
output[k++] = data[i + t];
}
// 将[middle+1,n]中剩余的元素添加到output数组中
if (i > middle) {
for (int t = 0; t <= n - j; t++)
output[k++] = data[j + t];
}
}
归并排序复杂度分析
时间复杂度对长度为n的数据需要进行log2n次二路归并,每次归并的时间为O(N)。故时间复杂度无论是在最好的情况下还是最坏的情况下都是nlog2(N)。
归并排序是一种比较占用内存,但却效率高且稳定的算法。
1.9快速排序
快速排序 (
Quick Sort) 的基本思想是:通过
一趟排序将待排记录分割成独立的两部分,其中一部分记录的关键字均比另
一部分记录的关键字小,则可分别对这两部分记录继续进行排序,以达到整个序列有序的目的
。
public static void main(String[] args) {
// TODO Auto-generated method stub
int[] data = { 50, 10, 90, 30, 70, 40, 80, 60, 20 };
int len = data.length;
// 排序前的结果
System.out.print("排序之前数组:");
for (int i = 0; i < len; i++) {
System.out.print(" " + data[i] + " ");
}
System.out.println();
quickSort(data, 0, len - 1);// 将data[0,len-1]的数组进行快速排序
// 排序后的结果
System.out.print("排序后数组:");
for (int i = 0; i < len; i++) {
System.out.print(" " + data[i] + " ");
}
}
/**
*
* @param data
* 待排序数组
* @param left
* 枢纽变量的左边数组下标
* @param right
* 枢轴变量的右边数组下标
*/
public static void quickSort(int[] data, int left, int right) {
int i, j;// 用于循环使用的变量
i = left;
j = right;
int pivot = data[left];// 用数组的第一个元素作为枢轴
if (left < right) {
// 获取枢纽元素的小标,用i接收
while (i < j) {
// 当右边值大于枢轴元素值,则右边元素下标j--
while (i < j && data[j] >= pivot) {
j--;
}
// 当小于枢轴值时,将右边元素交换到枢轴左边
swap(data, i, j);
// 当左边值小于枢轴元素值,则左边元素下标i--
while (i < j && data[i] <= pivot) {
i++;
}
// 当左边元素大于枢轴值时,将左边元素交换到枢轴右边
swap(data, i, j);
}
System.out.print("当前排序结果:");
for (int k = 0; k < data.length; k++) {
System.out.print(" " + data[k] + " ");
}
System.out.println();
// 将枢轴左边的元素继续递归排序
quickSort(data, left, i - 1);
// 将枢轴右边的元素继续递归排序
quickSort(data, i + 1, right);
}
}
/** 交换a数组中的下标为 i 和 j 的值 */
static void swap(int[] a, int i, int j) {
if (j >= 0 && i >= 0) {// 当 i,j大于等于0时执行交换
int temp = a[i];
a[i] = a[j];
a[j] = temp;
}
}
快速排序复杂度
我们来分析一下快速排序法的性能。快速排序的时间性能取决于快速排序递归的深度,可以用递归树来描述递归算法的执行情况。如图 9-9-7所示 , 它是{50,10,90,30,70,40,80,60,20}在快速排序过程中的递归过程 。 由于我们的第一个关键字是 50 ,正好是待排序的序列的中间值,因此递归树是平衡的,此时性能也比较好。
在最优情况下,枢轴关键字每次都划分得很均匀,如果排序 n 个关键字,其递归树的深度就为[log2n]+1 ([X] 表示不大子 X 的最大整数) ,即仅需递归log2n次,需要时间为 T (n) 的话,第一次查找枢轴关键字应该是需要对整个数组扫描一遍,做 n 次比较。
然后,获得的枢轴将数组一分为二,那么各自还需要T(n/2) 的时间(注意是最好情况,所以平分两半)
。于是不断地划分下去,我们就有了下面的不等式
推断,
也就是说,在最优的情况下,快速排序算法的时间复杂度为
O(log2n) 。
在最坏的情况下,待排序的序列为正序或者逆序,每次划分只得到一个比上一次划分少)个记录的子序列,注意另一个为空 。 如果递归树画出来,它就是一棵斜树。此时需要执行 n -1 次递归调用,且第 i 次划分需要经过 n-j 次关键字的比较才能找到第 i 个记录,也就是枢轴的位置 , 因此比较次数为n - 1+ n - 2+......+1 =n(n-1)/2,最终其时间复杂度为 O(n2) 。
平均的情况 ,其时间复杂度为O (logn) 。
快速排序优化
1. 优化选取枢轴
如果我们选取的枢轴关键字是处于整个序列的中间位置,那么我们可以将整个序列分成小数集合和大数集合了。但是若不在中间,如果在最前面或最后,又会出现相差较大的复杂度。
改进办法,有人提出,应该随机获得一个left与right之间的数middle,让它的关键字left与right交换,此时就不容易出现这样的情况,这被称为随机选取枢轴法。应该说 , 这在某种程度上 ,解决了对于基本有序的序列快速排序时的性能瓶颈 。不过,随机就有些撞大运的感觉,万一没撞成功,随机到了依然是很小或很大的关键字怎么办呢?
再改进,于是就有了三数取中法。即取三个关键字先进行排序,将中间数作为枢轴, 一般是取左端、右端和中间三个数, 也可以随机选取。 这样至少这个中间数一定不会是最小或者最大的数,从概率来说,取三个数均为最小或最大数的可能性是微乎其微的,因此中间数位于较为中间的值的可能性就大大提高了 。由于整个序列是无序状态,随机选取三个数和从左中右端取三个数其实是一回事,而且随机数生成器本身还会带来时间上的开销,因此随机生成不予考虑。
三数取中对小数组来说有很大的概率选择到一个比较好的枢轴关键字, 但是对于非常大的待排序的序列来说还是不足以保证能够选择出一个好的枢轴关键字, 因此还有个办法是所谓九数取中,它先从数组中分三次取样,每次取三个数, 三个样品各取出中间数,然后从这三个中数当中再取出 一个中数作为枢轴 ,显然这就更加保证了取到的枢轴关键字是比较接近中间值的关键字。
2 .优化不必要的交换
3. 优化小数组时的排序方案
如果数组非常小,其实快速排序反而不如直接插入排序来得更好(直接插入是简单排序中性能最好的)。其原因在于快速排序用到了递归操作,在大量数据排序时,这点性能影响相对于它的整体算法优势而言是可以忽略的,但如果数组只有几个记录需要排序时,这就成了一个大炮打蚊子的大问题。
4 .优化递归操作
总结
根据排序过程中借助的主要操作 , 我们将内排序分为:插入排序、交换排序、选择排序和归并排序四类。之后介绍的 7 种排序法,就分别是各种分类的代表算法。
事实上,目前还没有十全十美的排序算法,有优点就会有缺点,即使是快速排序法,也只是在整体性能上优越,它也存在排序不稳定、需要大量辅助空间、对少量数据排序无优势等不足。因此我们就来从多个角度来剖析一下提到的各种排序的长与短。
我们将
7种算法的各种指标进行对比,如表
9-
10-1所示。
从算法的简单性来看,我们将 7 种算法分为两类:
• 简单算法:冒泡、简单选择、直接插入。
• 改进算法:希尔、堆、归并、快速。
从平均情况来看,显然最后 3 种改进算法要胜过希尔排序,并远远胜过前 3 种简单算法。
从最好情况看,反而冒泡和直接插入排序要更胜一筹,也就是说,如果你的待排序序列总是基本有序,反而不应该考虑 4 种复杂的改进算法。
从最坏情况看,堆排序与归并排序又强过快速排序以及其他简单排序。从这三组时间复杂度的数据对比中,我们可以得出这样一个认识。堆排序和归并排序就像两个参加奥数考试的优等生,心理素质强,发挥稳定。而快速排序像是很情绪化的天才,心情好时表现极佳,碰到较糟糕环境会变得不尽如人意。但是他们如果都来比赛计算个位数的加减法,它们反而算不过成绩极普通的冒泡和直接插入。
从空间复杂度来说,归并排序强调要马跑得快,就得给马吃个饱。快速排序也有相应的空间要求,反而堆排序等却都是少量索取,大量付出,对空间要求是 O(1)。如果执行算法的软件所处的环境非常在乎内存使用量的多少时,选择归并排序和快速排序就不是一个较好的决策了。
从稳定性来看,归并排序独占整头,我们前面也说过,对于非常在乎排序稳定性的应用中,归并排序是个好算法。
从待排序记录的个数上来说,待排序的个数 n 越小,采用简单排序方法越合适。反之, n 越大,采用改进排序方法越合适。这也就是我们为什么对快速排序优化时,增加了一个阀值,低于阀值时换作直接插入排序的原因。
从表 9-10-1 的数据中,似乎简单选择排序在 3 种简单排序中性能最差,其实也不完全是,比如,如果记录的关键字本身信息量比较大(例如,关键字都是数十位的数字) ,此时表明其占用存储空间很大,这样移动记录所花费的时间也就越多,我们给出3 种简单排序算法的移动次数比较,如表 9-10-2 所示。
你会发现,此时简单选择排序就变得非常有优势,原因也就在于,它是通过大量比较后选择明确记录进行移动,有的放矢。因此对于数据量不是很大而记录的关键字信息最较大的排序要求,简单排序算法是占优的。另外,记录的关键字信息量大小对那四个改进算法影响不大。
总之,从综合各项指标来说,经过优化的快速排序是性能最好的排序算法,但是不同的场合我们也应该考虑使用不同的算法来应对它 。