8.2 再谈排序与检索

假设有n个整数,希望把它们按照从小到大的顺序排列,应该怎样做呢?也许你会说:调用STL中的sort或者stable_sort即可。可是读者们有没有想过:这些现成的排序函数是怎样工作的呢?

8.2.1 归并排序

第一种高效排序算法是归并排序。按照分治三步法,对归并排序算法介绍如下。

划分问题:把序列分成元素个数尽量相等的两半。

递归求解:把两半元素分别排序。

合并问题:把两个有序表合并成一个。

前两部分是很容易完成的,关键在于如何把两个有序表合成一个。图8-2演示了一个合并的过程。每次只需要把两个序列的最小元素加以比较,删除其中的较小元素并加入合并后的新表即可。由于需要一个新表来存放结果,所以附加空间为n

图8-2 合并过程:时间是线性的,需要线性的辅助空间

这个过程极为重要,希望读者仔细体会。代码如下:

程序8-4 归并排序(从小到大)


void merge_sort(int* A, int x, int y, int* T){
if(y-x > 1){
  int m = x + (y-x)/2;                     //划分
  int p = x, q = m, i = x;
  merge_sort(A, x, m, T);                     //递归求解
  merge_sort(A, m, y, T);                    //递归求解
  while(p < m || q < y){
    if(q >= y || (p < m && A[p] <= A[q])) T[i++] = A[p++];
                                              //从左半数组复制到临时空间
    else T[i++] = A[q++];                    //从右半数组复制到临时空间
  }
  for(i = x; i < y; i++) A[i] = T[i];     //从辅助空间复制回A数组
}
}

代码中的两个条件是关键。首先,只要有一个序列非空,就要继续合并(while(p<m|| q<y)),因此在比较时不能直接比较A[p]和A[q],因为可能其中一个序列为空,从而A[p]或者A[q]代表的是一个实际不存在的元素。正确的方式是:

 

上面的代码巧妙地利用短路运算符“||”把两个条件连接在了一起:如果条件1满足,就不会计算条件2;如果条件1不满足,就一定会计算条件2。这样的技巧很实用,请读者细心体会。另外,读者如果仍然不太习惯T[i++]=A[p++]这种“复制后移动下标”的方式,是时候把它们弄懂、弄熟了。

不难看出,归并排序的时间复杂度和最大连续和的分治算法一样,都是O(nlogn)的。

逆序对问题。给一列数a1, a2,…, an,求它的逆序对数,即有多少个有序对(i,j),使得i<jai>ajn可以高达106

【分析】

n这么大,O(n2)的枚举将超时,因此需要寻找更高效的方法。受到归并排序的启发,下面来试试“分治三步法”是否适用。“划分问题”过程是把序列分成元素个数尽量相等的两半;“递归求解”是统计ij均在左边或者均在右边的逆序对个数;“合并问题”则是统计i在左边,但j在右边的逆序对个数。

和归并排序一样,划分和递归求解都好理解,关键在于合并:如何求出i在左边,而j在右边的逆序对数目呢?统计的常见技巧是“分类”。下面按照j的不同把这些“跨越两边”的逆序对进行分类:只要对于右边的每个j,统计左边比它大的元素个数f(j),则所有f(j)之和便是答案。

幸运的是,归并排序可以“顺便”完成f(j)的计算:由于合并操作是从小到大进行的,当右边的A[j]复制到T中时,左边还没来得及复制到T的那些数就是左边所有比A[j]大的数。此时在累加器中加上左边元素个数m-p即可(左边所剩的元素在区间[p,m)中,因此元素个数为m-p)。换句话说,在代码上的唯一修改就是把"else T[i++] = A[q++];"改成"else { T[i++] = A[q++]; cnt += m-p; }"。当然,在调用之前应给cnt清零。

提示8-7:归并排序的时间复杂度为O(nlogn)。对该算法稍加修改,可以统计序列中的逆序对的个数,时间复杂度不变。

8.2.2 快速排序

快速排序是最快的通用内部排序算法。它由Hoare于1962年提出,相对归并排序来说不仅速度更快,并且不需辅助空间(还记得那个T数组吗)。按照分治三步法,将快速排序算法作如下介绍。

划分问题:把数组的各个元素重排后分成左右两部分,使得左边的任意元素都小于或等于右边的任意元素。

递归求解:把左右两部分分别排序。

合并问题:不用合并,因为此时数组已经完全有序。

读者也许会觉得这样的描述太过笼统,但事实上,快速排序本来就不是只有一种实现方法。“划分过程”有多个不同的版本,导致快速排序也有不同版本。读者很容易在互联网上找到各种快速排序的版本,这里不再给出代码。

快速选择问题。输入n个整数和一个正整数k(1≤kn),输出这些整数从小到大排序后的第k个(例如,k=1就是最小值)。n≤107

【分析】

选择第k大的数,最容易想到的方法是先排序,然后直接输出下标为k-1的元素(别忘了C语言中数组下标从0开始),但107的规模即使对于O(nlogn)的算法来说较大。有没有更快的方法呢?

答案是肯定的。假设在快速排序的“划分”结束后,数组A[p…r]被分成了A[p…q]和A[q+1…r],则可以根据左边的元素个数q-p+1和k的大小关系只在左边或者右边递归求解。可以证明,在期望意义下,程序的时间复杂度为O(n)。

提示8-8:快速排序的时间复杂度为:最坏情况O(n2),平均情况O(nlogn),但实践中几乎不可能达到最坏情况,效率非常高。根据快速排序思想,可以在平均O(n)时间内选出数组中第k大的元素。

8.2.3 二分查找

排序的重要意义之一,就是为检索带来方便。试想有106个整数,希望确认其中是否包含12345,最容易想到的方法就是把它们放到数组A中,然后依次检查这些整数是否等于12345。这样的方式对于“单次询问”来说运行得很好,但如果需要找10000个数,就需要把整个数组A遍历10000次。而如果先将数组A排序,就可以查找得更快——好比在字典中查找单词不必一页一页翻一样。

在有序表中查找元素常常使用二分查找(Binary Search),有时也译为“折半查找”,基本思路就像是“猜数字游戏”:你在心里想一个不超过1000的正整数,我可以保证在10次之内猜到它——只要你每次告诉我猜的数比你想的大一些、小一些,或者正好猜中。

猜的方法就是“二分”。首先我猜500,除了运气特别好正好猜中之外(1),不管你说“太大”还是“太小”,我都能把可行范围缩小一半:如果“太大”,那么答案在1~499之间;如果“太小”,那么答案在501~1000之间。只要每次选择可行区间的中点去猜,每次都可以把范围缩小一半。由于log21000<10,10次一定能猜到。

这也是二分查找的基本思路。

提示8-9:逐步缩小范围法是一种常见的思维方法。二分查找便是基于这种思路,它遵循分治三步法,把原序列划分成元素个数尽量接近的两个子序列,然后递归查找。二分查找只适用于有序序列,时间复杂度为O(logn)。

尽管可以用递归实现,但一般把二分查找写成非递归的:

程序8-5 二分查找(迭代实现)


int bsearch(int*A, int x, int y, int v){
int m;
while(x < y) {
   m = x+(y-x)/2;
   if(A[m] == v) return m;
   else if(A[m] > v) y = m;
   else x = m+1;
  }
  return -1;
}

上述while循环常常直接写在程序中。二分查找常常用在一些抽象的场合,没有数组A,也没有要查找的v,但是二分的思想仍然适用。

提示8-10:二分查找一般写成非递归形式。

下面提一个有趣的问题:如果数组中有多个元素都是v,上面的函数返回的是哪一个的下标呢?第一个?最后一个?都不是。不难看出,如果所有元素都是要找的,它返回的是中间那一个。有时,这样的结果并不是很理想,能不能求出值等于v的完整区间呢(由于已经排好序,相等的值会排在一起)?

下面的程序,当v存在时返回它出现的第一个位置。如果不存在,返回这样一个下标i:在此处插入v(原来的元素A[i], A[i+1],…全部往后移动一个位置)后序列仍然有序。

程序8-6 二分查找求下界


int lower_bound(int*A, int x, int y, int v){
int m;
while(x < y){
   m = x+(y-x)/2;
   if(A[m]>=v) y=m;
   else x=m+1;
}
return x;
}

下面来分析一下这段程序。首先,最后的返回值不仅可能是x, x+1, x+2,…, y-1,还可能是y——如果v大于A[y-1],就只能插入这里了。这样,尽管查找区间是左闭右开区间[x,y),返回值的候选区间却是闭区间[x,y]。A[m]和v的各种关系所带来的影响如下。

 

合并一下,A[m]≥v时新区间为[x,m];A[m]<v时新区间为[m+1,y]。这里有一个潜在的危险:如果[x,m]或者[m+1,y]和原区间[x,y]相同,将发生死循环!幸运的是,这样的情况并不会发生,原因留给读者思考。

类似地,可以写一个upper_bound程序,当v存在时返回它出现的最后一个位置的后面一个位置。如果不存在,返回这样一个下标i:在此处插入v(原来的元素A[i], A[i+1],…全部往后移动一个位置)后序列仍然有序。不难得出,只需把"if(A[m]>=v) y=m; else x=m+1;"改成"if(A[m]<=v) x=m+1; else y=m;"即可。

这样,对二分查找的讨论就相对比较完整了:设lower_bound和upper_bound的返回值分别为L和R,则v出现的子序列为[L,R)。这个结论当v不在时也成立:此时L=R,区间为空。这里实现的lower_bound和upper_bound就是STL中的同名函数。

提示8-11:用“上下界”函数求解范围统计问题的技巧非常有用,建议读者用心体会左闭右开区间的使用方法和上下界函数的实现细节。