`
bencode
  • 浏览: 109235 次
  • 性别: Icon_minigender_1
  • 来自: 杭州
社区版块
存档分类
最新评论

《算法导论》读书笔记4 (快速排序)

阅读更多
敢取这个名字,应该很快!

直切主题, 它的算法像这样:

 

	void quickSort(int[] a, int low, int high) {
		if (low >= high) {
			return;
		}
		
		int mid = partition(a, low, high);  // 先做一个划分, mid左边的元素都比a[mid]小...
		
		quickSort(a, low, mid - 1);	// 排左边的元素
		quickSort(a, mid + 1, high);	// 排右边的元素
	}

 

简单,容易理解,高效!

 

关键是怎么划分。

 

先来一个容易理解的。

 

	int partition(int[] a, int low, int high) {
		int x = a[low];	// 就以第一个元素为界,对 [low, high] 内的元素进行划分
		int mid = low;	// mid及其左边的元素都 <= x
		for (int i = low + 1; i <= high; i++) {  //挑出比 x 小的元素, 放到 mid中
			if (a[i] < x) {
				swap(a, ++mid, i);
			}
		}
		// 到这里为止, [low + 1, mid] <= x <= [mid + 1, high]
		swap(a, low, mid);  // 把 x 放到"中间"来
		
		return mid;
	}

 

 完成了, 然后立马写一段代码进行无情的测试, 我们排序一万次。

 

	@Test
	public void testQuickSort() {
		for (int i = 0; i < 10000; i++) {
			int[] array = genRandAry(i);
			quickSort(array);
			assertTrue(isSorted(array));
		}
	}

 

green bar!

 

 

当年Hoare设计的quicksort 的划分,不是这样滴。 比上面的有意思一点点

 

	int partition2(int[] a, int low, int high) {
		int x = a[low]; // 同样,暂时以第一个元素进行划分
		int i = low;
		int j = high + 1;
		while (true) { // 我们从左向右找到一个比 x 大数, 再从右向左找到一个比 x 小的数,交换
			do {
				j--;
			} while (i < j && a[j] >= x);	// 先从右向左找到一个比 x 小的数
			
			do { // 再找一个比x 大的数, 这里不检查上一步有没找到,因为如果没找到 将会使i > j
				i++;
			} while (i < j && a[i] <= x);  
			
			if (i < j) {	// 都找到了,就交换, 此时 [low + 1, i] <= x <= [j, high]
				swap(a, i, j); 
			} else {
				break;
			}
		}
		 if (i > j) {
  			 i--;
  		}
  		swap(a, low, i);
  		return i;

}

 

partition2比partition 代码长了好多,比较次数一样,因为所有元素都要和 x 比较一次。 不过 partition2 交换次数会比partition 少,因为它只做“必要”的交换, 而 partition 中,只要比 x小的都进行一次swap

 

下面来看复杂度。


我们来重写quickSort,计算比较次数,现在我们不关心数组

 

	int quickSort2(int n) {
		if (n <= 1) {
			return 0;	// 1个以下,不用排
		}

		int times = 0;  // 比较次数
		times += n - 1;	// partiation 需要进行 n - 1 次比较

			
		// partiation 后成这样: [..k个..][x][n - k - 1] 个
		times += quickSort2(k);
		times += quickSort2(n - k - 1);	
		
		return times;
	}

 

 

 1.在最优情况下, partiation 每次都划分得很均匀

 

   此时:T(n) = 2 T(n / 2) + (n - 1) ; // 在笔记1中, 我们知道,这个解是 nlog(n)

  所以快速排序的最好情况是 nlog(n)

 

2.在最坏情况下, partiation 每次都类似划分这样 [x][n-1个元素]

 

此时: T(n) = T(n - 1) + (n - 1) ;   //  这个是 n^2 

 

3. 在一般情况下, partiation 即使划分得很偏,比如划分在 1 / 10 位置

 

  此时 T(n) = T(1 / 10) + T(9 / n) + n - 1,  这个东西也是nlog(n)

 

4. 所以在平均情况下,快速排序的复杂度是 nlog(n)

 

 

上面我们选择第一个元素进行划分,可以采用随机或者三数取中以获得更好的划分。

 

 

对于quick sort的另一个精彩的分析,请看《代码之美》中的《我从未编写过的最漂亮的代码》

 

 

 那还有一个问题:标准库是怎么做的呢?我们看看代码(开源多么好呀~~ 可以让我们有无数免费学习资源)

 

public class Arrays {
    // Suppresses default constructor, ensuring non-instantiability.
    private Arrays() {
    }
    // 中间省略好多
    public static void sort(int[] a) {
	sort1(a,  0,  a.length);
    }

 

go on!

 

	void sort1(int x[], int off, int len) {
		// Insertion sort on smallest arrays
		if (len < 7) {
			for (int i = off; i < len + off; i++)
				for (int j = i; j > off && x[j - 1] > x[j]; j--)
					swap(x, j, j - 1);
			return;
		}

 

 

当数组长度小于7个时, 采用插入排序更快

 

接下来是选划分元素, 看看它是怎么选的

 

        // Choose a partition element, v
	int m = off + (len >> 1);       // Small arrays, middle element
	if (len > 7) {
	    int l = off;
	    int n = off + len - 1;
	    if (len > 40) {        // Big arrays, pseudomedian of 9
		int s = len / 8;
		l = med3(x,  l,  l + s,  l + 2 * s);
		m = med3(x,  m - s, m, m + s);
		n = med3(x, n-  2 * s, n - s, n);
	    }
	    m = med3(x,  l, m, n); // Mid-size, med of 3
	}
	long v = x[m];

 
三数取中,只是当数组大于40个元素时, 更复杂些,让划分元素选得更平均

 

下面就是划分:(中文注释是我注的)

 

		int a = off, b = a, c = off + len - 1, d = c;
		// 下面的while 把数组分成四部分
		// [.., a) [a, b) (c, d]  (d, ..) 
		// [.., a)  == v, [a, b) < v, (c, d] > v, (d, ..) == v
		while (true) {
			while (b <= c && x[b] <= v) {
				if (x[b] == v)
					swap(x, a++, b);
				b++;
			}
			while (c >= b && x[c] >= v) {
				if (x[c] == v)
					swap(x, c, d--);
				c--;
			}
			if (b > c)
				break;
			swap(x, b++, c--);
		}

		// Swap partition elements back to middle
		// 然后把开始和结束两个部分交换到中间来, 就OK啦!
		int s, n = off + len;
		s = Math.min(a - off, b - a);
		vecswap(x, off, b - s, s);
		s = Math.min(d - c, n - d - 1);
		vecswap(x, b, n - s, s);

 

可以看到,标准库的算法也比较简单明了

 

那C标准库是怎么实现的呢?,会不会飞快?

 

于是我在vs目录下找到了qsort.c, 看看微软的c标准库的实现

 

注:代码中的中文注释是我注的:)

 

 /* below a certain size, it is faster to use a O(n^2) sorting method */
    if (size <= CUTOFF) {	// 宏 CUTOFF = 8
        __SHORTSORT(lo, hi, width, comp, context);
    }
   

  

当数组小于8个,采用“插入排序”,因为更快 (在算法笔记1中, 我们比较了插入排序和归并排序,当n<=9 时, 插入排序比较次数会少于 归并排序, 这里选用8)

 

再来看看 qsort是怎么样选用划分元素的:

 

/* First we pick a partitioning element.  The efficiency of the
           algorithm demands that we find one that is approximately the median
           of the values, but also that we select one fast.  We choose the
           median of the first, middle, and last elements, to avoid bad
           performance in the face of already sorted data, or data that is made
           up of multiple sorted runs appended together.  Testing shows that a
           median-of-three algorithm provides better performance than simply
           picking the middle element for the latter case. */

        mid = lo + (size / 2) * width;      /* find middle element */

        /* Sort the first, middle, last elements into order */
        if (__COMPARE(context, lo, mid) > 0) {
            swap(lo, mid, width);
        }
        if (__COMPARE(context, lo, hi) > 0) {
            swap(lo, hi, width);
        }
        if (__COMPARE(context, mid, hi) > 0) {
            swap(mid, hi, width);
        }

 

代码的注释很清楚了: 这里先将第一,最后,中间三个元素进行排序,然后选用中间的元素作为划分元素。

并且没有把他交换到第一个来,以提高效率。

 

选好了划分元素,下面就是划分了:

 

for (;;) {
            if (mid > loguy) {
                do  {
                    loguy += width;
                } while (loguy < mid && __COMPARE(context, loguy, mid) <= 0);
            }
            if (mid <= loguy) {
                do  {
                    loguy += width;
                } while (loguy <= hi && __COMPARE(context, loguy, mid) <= 0);
            }

 

上面代码仅是一部分, 而且我删除了注释(循环不变式等内容), 但是从代码中我们可以看出,它是采用了我们partation2的划分策略。 而且做了适当修改(因为划分元素在中间)

/* We've finished the partition, now we want to sort the subarrays
           [lo, higuy] and [loguy, hi].
           We do the smaller one first to minimize stack usage.
           We only sort arrays of length 2 or more.*/

        if ( higuy - lo >= hi - loguy ) {
            if (lo < higuy) {
                lostk[stkptr] = lo;
                histk[stkptr] = higuy;
                ++stkptr;
            }                           /* save big recursion for later */

            if (loguy < hi) {
                lo = loguy;
                goto recurse;           /* do small recursion */
            }
        }

 

当划分好元素后,是否进行递归 qsort呢? 标准库中为了提高效率,不是采用递归, 而是使用goto来进行

 

因为每次分割有两部分,先处理小的部分以减少栈的使用空间。

 

最后,再处理保存在栈中的另外一部分

 

    /* We have sorted the array, except for any pending sorts on the stack.
       Check if there are any, and do them. */

    --stkptr;
    if (stkptr >= 0) {
        lo = lostk[stkptr];
        hi = histk[stkptr];
        goto recurse;           /* pop subarray from stack */
    }
    else
        return;                 /* all subarrays done */

 

 到此完结:)

 

可以看到算法是一样的,但充分进行了优化优化再优化:)

 

分享到:
评论

相关推荐

    算法导论系列读书笔记之三

    作为“算法导论系列读书笔记之三”,本文将主要探讨第三章的内容,这一章通常聚焦于排序与选择算法,这些是数据处理的基础,对理解和优化程序性能至关重要。 在第一章和第二章中,我们可能已经接触到了基本的数据...

    算法导论系列读书笔记之二

    作为“算法导论系列读书笔记之二”,本文将主要探讨第二章的内容,这一章通常涵盖基础的数据结构和算法,为后续章节的学习打下坚实的基础。 在算法分析中,"循环不变式"是一个至关重要的概念。它是指在循环开始前、...

    算法导论读书笔记

    通过阅读和理解《算法导论》的这些章节,我们可以掌握基础的算法思想,提升问题解决能力,并为后续深入学习打下坚实基础。学习和实践这些算法,不仅可以提升编程技能,也有助于培养分析和解决复杂问题的能力。

    算法导论读书笔记(整理别人的)

    这篇读书笔记主要涵盖了以下几个方面的重要知识点: 1. **算法基础**:算法是解决问题的步骤序列,是计算机科学的灵魂。书中首先介绍了算法的基本概念、设计方法和分析技巧,包括时间复杂度和空间复杂度的计算,...

    麻省理工算法导论读书笔记

    《麻省理工算法导论读书笔记》是一份深入学习算法理论和实践的宝贵资源,源自世界顶级学府麻省理工学院的教学资料。这份笔记涵盖了算法分析、设计策略、数据结构等多个核心主题,对于想要深入了解算法的朋友们极具...

    算法导论系列读书笔记之附录A的习题解答

    在排序方面,可能会涉及冒泡排序、插入排序、选择排序、快速排序、归并排序等经典算法的分析和比较,包括它们的时间复杂度和空间复杂度。这些习题旨在帮助读者理解不同排序算法的优劣以及如何根据具体问题选择合适的...

    MIT 算法导论 课堂笔记

    通过深入学习这本《MIT算法导论》的课堂笔记,你可以系统地掌握算法的精髓,提高解决问题的能力,为未来的编程生涯打下坚实的基础。同时,Word文档的格式使得笔记易于阅读和整理,方便你在学习过程中随时查阅和复习...

    算法导论系列读书笔记之七

    本篇读书笔记主要聚焦于第七章,我们将探讨其中的核心主题——快速排序。 快速排序是一种由C.A.R. Hoare在1960年提出的高效排序算法,它的基本思想是分治策略。快速排序的基本流程如下: 1. **选择基准元素(Pivot...

    算法导论(麻省大学还有笔记)

    1. **排序与搜索算法**:如快速排序、归并排序、堆排序、二分查找等,这些都是基础且实用的算法。 2. **图算法**:包括Dijkstra算法、Floyd-Warshall算法、Prim算法和Kruskal算法等,用于解决最短路径、最小生成树...

    算法导论系列读书笔记之九

    在“算法导论系列读书笔记之九”中,我们聚焦于其中的两个重要概念:中位数和顺序统计学。 中位数是统计学中的一个关键概念,它是将一组数据从小到大排列后位于中间位置的数值。对于奇数个数据,中位数是中间的那个...

    算法导论系列读书笔记之四

    例如,在分析分治算法(如快速排序或归并排序)的时间复杂度时,经常会用到递归树。通过对递归树的分支进行计算,可以得出算法的总运行时间。 总的来说,第四章的学习不仅涵盖了理论知识,也强调了实际应用。通过主...

    算法导论教师手册(含答案)

    - **讲座笔记**:探讨快速排序算法的设计思想及其实现步骤。 - **习题解答**:提供快速排序算法的实际案例分析。 ##### 7. 第8章:线性时间排序 - **讲座笔记**:介绍几种可以在线性时间内完成排序的算法,如计数...

    算法导论第二版书与习题答案

    2. 分治策略:讲解如何将大问题分解为小问题来解决,如快速排序和归并排序。 3. 动态规划:用于解决最优化问题,如背包问题和最长公共子序列。 4. 贪心算法:每次做出局部最优选择,期望达到全局最优,如霍夫曼编码...

    麻省理工公开课:算法导论(书、讲义、字幕、笔记、答案)

    - **分治策略**:将大问题分解成小问题,如归并排序、快速排序等。 - **动态规划**:通过构建子问题解决复杂问题,如斐波那契数列、背包问题等。 - **贪心算法**:每一步选择局部最优解,如霍夫曼编码、Prim最小...

    MIT算法导论公开课的课程笔记(个人总结)

    这门课程不仅讲解了基础的排序和搜索算法,如冒泡排序、选择排序、快速排序、二分查找等,还深入到了图算法、动态规划、贪心算法等领域。通过这些算法的学习,我们可以提升解决问题的能力,优化程序性能,甚至解决...

    算法导论 教师手册 英文版 课后思考题答案

    - **讲座笔记**:介绍了快速排序算法的核心思想,分析了其平均时间复杂度为O(n log n)的原因。 - **课后思考题答案**:通过对快速排序不同变种的讨论,帮助学生理解如何根据具体情况选择合适的算法。 ##### 7. **...

    麻省理工学院-算法导论-课件习题 全

    《麻省理工学院-算法导论》是一门深入探讨计算机科学中算法设计与分析的课程。这门课程的课件习题集包含了丰富的学习材料,旨在帮助学生掌握算法的核心概念,提升解决实际问题的能力。课件习题的全面性使得学习者...

    《算法导论(第2版)》教师手册

    《算法导论(第2版)》教师手册是针对Thomas H. Cormen、Charles E. Leiserson、Ronald L. Rivest以及Clifford Stein合著的经典算法教材——《Introduction to Algorithms, Second Edition》(算法导论第二版)的...

    [麻省理工学院-算法导论].Introduction.to.Algorithms.-.Lecture.Notes

    通过阅读《麻省理工学院-算法导论》的Lecture Notes,学生不仅能深入了解各种算法的原理,还能掌握如何在实际问题中选择和应用合适的算法,从而提高编程能力和问题解决能力。每个讲座文件(如lecture01.pdf到lecture...

    算法导论第三版

    《算法导论》第三版还介绍了一些重要的算法,如斯特拉森算法、最大子数组问题、快速排序和堆排序等。其中,斯特拉森算法是矩阵乘法的一个高效算法,而最大子数组问题则是一个具有广泛应用背景的算法问题。快速排序是...

Global site tag (gtag.js) - Google Analytics