这一篇主要是关于快速排序的随机化版本和线性时间排序。前者即跟递归有关,又跟随机算法和概率分布有关,与后者合并作为一篇。主要记录一些算法的实现,和部分题目的思路。
快速排序的随机版本
快速排序性能的好坏和partition过程中数组最后一个值(主元)密切相关,事实上我们希望待排序数组的任意一个排列都是等可能出现,从而使主元的期望落在 $n/2$ 的位置。现实情况并非如此,待排序的数组几乎不可能是随机的。随机化的快速排序在partition过程中并不选取数组中最后一个值作为主元,而是随机地选取一个值分割,这样就保证了主元的期望落在 $n/2$ 的位置。
int partitionRandom(int *x, int p, int r){ int i = rand()%(r-p+1) + p; int tmp = x[i]; x[i] = x[r]; x[r] = tmp; return partition(x, p, r); } void quickSortRandom(int *x, int p, int r){ if(p < r){ int q = partitionRandom(x, p, r); quickSortRandom(x, p, q-1); quickSortRandom(x, q+1, r); } }
思考题7-3 “漂亮”的Stooge-Sort排序算法
STOOGE-SORT(A, i, j) if A[i]>A[j] then exchange A[i] and A[j] if i+1>=j return int k = (j-i+1)/3 STOOGE-SORT(A, i, j-k) STOOGE-SORT(A, i+k, j) STOOGE-SORT(A, i, j-k)
- 证明该算法可以对 $A[1...n]$ 正确排序:交换首尾项的工作,实际上只有在数组规模为2的时候才有实际意义,而且数组规模为2时确实能够正确对其排序。算法最后三次对自身的递归调用,保证了整个数组都能够完全排序了。我没有作具体的证明,但是想象一个全逆序的数组(最极端的情况)运行这个算法,就能很好地体会其中的含义了——为什么对前 2/3 要调用两次?这让原本在后 1/3 段的,但排序在前 1/3 的元素能够“翻”到前面去。为什么是 2/3 这个数字?这样重叠的部分就有足够的空间容纳最极端的元素,当全逆序的时候,第一次和第二次调用间,中间重叠的 1/3 全部是排序后 1/3的元素,第二次和第三次调用间,中间重叠的 1/3 部分全部是排序前 1/3 的元素。
- 给出该算法的代价 $T(n)$ 。
$$T(n)=3T(2n/3)+\Theta(1)=\Theta(n^{\log_{3/2}3})>\Theta(n^{2})$$
这不是一个好算法。
思考题7-4 尾递归。函数中对自己的最后一次递归调用,可以用迭代来实现。比如QuickSort算法可以这样写。
QUICKSORTTAIL(A, p, r) while p<r int q = PARTITION(A, p, r) QUICKSORTTAIL(A, p, q-1) p = q+1
- 给出耗用堆栈深度 $\Theta(n)$ 的情况。思路:递归过程耗用栈空间,迭代则不会,算法中每次划分数组为两部分,前面一半递归调用,后面一半迭代调用。如果每次前面一半都常数地大,后面一半都常数地小,就会使堆栈深度耗用为 $\Theta(n)$ 。这样的排列会产生上述情况(数字代表该元素在排序后的数组中的索引):$n,n-2,n-4.....4,2,1,3,5,......n-3,n-1$
- 修改QUICKSORTTAIL使得耗费堆栈深度至多为 $\Theta(\lg n)$:很简单,划分后判断一下,短的那部分拿来递归,长的那部分拿来迭代。
QUICKSORTTAIL(A, p, r) while p<r int q = PARTITION(A, p, r) if(r-q > q-p) QUICKSORTTAIL(A, p, q-1) p = q+1 else QUICKSORTTAIL(A, q+1, r) r = q-1
思考题7-5 三数取中。在快速排序的随机化版本中,一种更好的方法是“仔细地选择”主元而不是随机选择。选择的过程是:随机选三个数,取中间的一个数作为主元。
- 与一般实现相比,取到中值的概率增加了多少,给出 $n$ 趋向于极限时两个概率比值。
一般的实现:$f(i)=Pr\{X=i\}=1/n$
三数取中:
$$g(i)=Pr'\{X=i\}=\frac{1\cdot (i-1)\cdot (n-i)}{A_{n}^{3}}=\frac{6\cdot (i-1)\cdot (n-i)}{n\cdot (n-1)\cdot (n-2)}$$
当 $n$ 较大时,二者的比值
$$\lim_{n\rightarrow \infty } \frac{g(n/2)}{f(n/2)}=\frac{3n^{2}/2n(n-1)(n-2)}{1/n}=\frac{3}{2}$$ - 如果定义一个“好的”划分是:主元取值在 $n/3\leq i\leq 2n/3$,得到一个“好的”划分的概率增加了多少?
一般的实现,很显然: $Pr\{n/3\leq i\leq 2n/3\}=1/3$
三数取中:
$$Pr'\{n/3\leq i\leq 2n/3\}=\lim_{n\rightarrow \infty}\int _{n/3}^{2n/3} Pr'\{X=i\}di=\lim_{n\rightarrow \infty}\int _{n/3}^{2n/3}\frac{6(i-1)(n-i)}{n^3}di=\frac{13}{27}$$
增加了 $4/27$ 。
思考题7-6 对区间的模糊排序。考虑对一组区间 $A[x_{i}, y_{i}],i=1,2...n$ 排序,只要满足存在一组已排序的 $c_{i},i=1,2...n$ 使得 $c_{i}\in [x_{i}, y_{i}]$,就认为这组区间是已模糊排序的。对端点 $x_{i}$ 排序可以达到排序区间的目的。设计一个更好的算法对区间进行模糊排序,充分利用重叠区域。
思路:对端点进行快速排序,并且一边排序一边判断:如果两个区间有重叠区域,就认为他们相等,是同一个区间(问题的规模缩小了1),并且二者都等于该区间的重叠区域。
计数排序
计数排序用于具有可数的离散关键字的排序,通过计算某一个关键字出现过的次数来统计每个关键字应当处在的位置。比如给0~99的1000个整数排序,对每一个数字(一共100个)统计出现的次数(比如:76这个数字出现了12次),再对每个数字,统计比他小或相等的数字出现了多少次,如比76小或相等的数字出现了8000次。最后再还原出排序后的数组。我的实现如下。
void countSort(int *x, int length, int k1, int k2){ int *a = arrayCopy(x, length); int *c = new int[k2-k1+1]; for (int i=0; i<=k2-k1; i++){ c[i] = 0; } for (int i=0; i<length; i++){ c[a[i]]++; } for (int i=1; i<k2-k1+1; i++){ c[i] += c[i-1]; } for (int i=length-1; i>=0; i--){ x[c[a[i]]-1] = a[i]; c[a[i]]--; } delete a; delete c; }
基数排序
排序十进制的 $n$ 位整数,先将其对个位排序,再对十位排序……直到最高位。因为个位的取值只可能是 $0~9$ 所以对某一位的排序非常适合使用计数排序。这种方法也适合用来对字符串进行排序。我的实现如下。
void countSortRadix(int *x, int length, int k1, int k2, int radix){ int *a = arrayCopy(x, length); int *c = new int[k2-k1+1]; int reminder = 10; int divider = 1; while (radix > 1){ reminder *= 10; divider *= 10; radix--; } for (int i=0; i<=k2-k1; i++){ c[i] = 0; } for (int i=0; i<length; i++){ c[(a[i]%reminder)/divider]++; } for (int i=1; i<k2-k1+1; i++){ c[i] += c[i-1]; } for (int i=length-1; i>=0; i--){ x[c[(a[i]%reminder)/divider]-1] = a[i]; c[(a[i]%reminder)/divider]--; } delete a; a = NULL; delete c; c = NULL; } void radixSort(int* x, int length, int radixCount){ for (int i=1; i<=radixCount; i++) { countSortRadix(x, length, 0, 9, i); } }
思考题8-4 水壶问题。共有 $n$ 个红色水壶和 $n$ 个蓝色水壶,两两是一对,容积一样。红色水壶内部体积各不一样,蓝色水壶也是。只能比较红色和蓝色水壶的体积,而不能比较红色和红色、蓝色和蓝色水壶体积。提出 $O(n\lg n)$ 的算法为所有水壶配对。
思路:随机挑选一只红色水壶与所有的蓝色水壶比较,可以找出容积一样的蓝色水壶,并将剩余的蓝色水壶分为两类。再随机挑选一个红色水壶,与刚才配对成功地蓝色水壶比较,再从两类蓝色水壶中选择适合自己的一类水壶,逐一比较……以此类推,第一个比较的红色水壶是二叉树的根节点。
思考题8-5 平均排序。一个数组是 $k$ 排序的,当且仅当满足:对于所有的 $i=1,2,...,n-k$ 有:
$$\sum_{j=i}^{i+k-1}A[j]\leq\sum_{j=i+1}^{i+k}A[j]$$
- 证明一个数组是 $k$ 排序的,当且仅当对所有的 $i=1,2,...,n-k$ 有 $A[i]\leq A[i+k]$ 。思路:将上式展开,就能得到这个结论。虽然过程是显而易见的,但结论却是反直觉的,事实上一个 $k$ 排序的数组并非直觉中的第一印象:平缓上升偶有波动的曲线,而是 $k$ 个独立的严格排序数组,交错穿插着,比如以下这样的数组 $11,21,31,12,22,32,13,23,33$ 就是一个 $k=3$ 排序的数组,实际上是三个严格排序的数组 $11,12,13$,$21,22,23$,$31,32,33$ 。
- 给出一个算法在 $O(n\lg(n/k))$ 代价内对一个数组进行 $k$ 排序。思路:直接均分三份,对每一份进行快速排序(或者其他什么代价为 $O(n\lg n)$的排序),然后在 $O(n)$ 代价内合并起来。总代价为 $O(k\cdot(n/k)\cdot\lg(n/k)))=O(n\lg(n/k))$ 。
- 长度为 $n$ 的 $k$ 排序数组可以在 $O(n\lg k)$ 代价内排序。思路:相当于 $k$ 个严格排序数组合并成一个严格排序数组,利用最大堆性质,参照6.5-8。