本节包含10个算法中的第2、3个。加油奥~
排序
这里只涉及到众多排序算法中的一小撮,也是最经典的、最常用的8类:冒泡排序、插入排序、选择排序、归并排序、快速排序、计数排序、基数排序、桶排序。
按照时间复杂度把它们分成了三类,根据这三类来梳理:
先给你出一个思考题:插入排序和冒泡排序的时间复杂度相同,都是 O( n^{2} ),在实际的软件开发里,为什么我们更倾向于使用插入排序算法而不是冒泡排序算法呢?带着这个问题,我们开始今天的内容!
如何分析一个“排序算法”?
执行效率
- 最好情况、最坏情况、平均情况时间复杂度 -(对于要排序的数据,有的接近有序,有的完全无序。有序度不同的数据,对于排序的执行时间肯定是有影响的,我们要知道排序算法在不同数据下的性能表现);
- 时间复杂度的系数、常数 、低阶 -(在对同一阶时间复杂度的排序算法性能对比的时候,我们就要把系数、常数、低阶也考虑进来);
- 比较次数和交换(或移动)次数 -(基于比较的排序算法的执行过程,会涉及两种操作,一种是元素比较大小,另一种是元素交换或移动)。
内存消耗
算法的内存消耗可以通过空间复杂度来衡量,针对排序算法的空间复杂度,引入了一个新的概念,原地排序(Sorted in place)。原地排序算法,就是特指空间复杂度是 O(1) 的排序算法。我们今天讲的三种排序算法,都是原地排序算法。
稳定性
这个概念是说,如果待排序的序列中存在值相等的元素,经过排序之后,相等元素之间原有的先后顺序不变。比如我们有一组数据 2,9,3,4,8,3,按照大小排序之后就是 2,3,3,4,8,9。这组数据里有两个 3。经过某种排序算法排序之后,如果两个 3 的前后顺序没有改变,那我们就把这种排序算法叫作稳定的排序算法。
比如说,我们现在要给电商交易系统中的“订单”排序。订单有两个属性,一个是下单时间,另一个是订单金额。如果我们现在有 10 万条订单数据,我们希望按照金额从小到大对订单数据排序。对于金额相同的订单,我们希望按照下单时间从早到晚有序。对于这样一个排序需求,我们怎么来做呢?
- 最先想到的方法是:我们先按照金额对订单数据进行排序,然后,再遍历排序之后的订单数据,对于每个金额相同的小区间再按照下单时间排序。这种排序思路理解起来不难,但是实现起来会很复杂。
- 借助稳定排序算法,这个问题可以非常简洁地解决。解决思路是这样的:我们先按照下单时间给订单排序,注意是按照下单时间,不是金额。排序完成之后,我们用稳定排序算法,按照订单金额重新排序。两遍排序之后,我们得到的订单数据就是按照金额从小到大排序,金额相同的订单按照下单时间从早到晚排序的。为什么呢?
稳定排序算法可以保持金额相同的两个对象,在排序之后的前后顺序不变。第一次排序之后,所有的订单按照下单时间从早到晚有序了。在第二次排序中,我们用的是稳定的排序算法,所以经过第二次排序之后,相同金额的订单仍然保持下单时间从早到晚有序。
冒泡排序(Bubble Sort)
知道了排序的分析方法,来学习的第一种排序算法:冒泡
每次冒泡操作都会对相邻的两个元素进行比较,看是否满足小在前关系要求。如果不满足就让它俩互换。一次冒泡会让至少一个元素移动到它应该在的位置,重复 n 次,就完成了 n 个数据的排序工作。
用一个例子,要对一组数据 4,5,6,3,2,1,从小到大进行排序。第一次冒泡操作的详细过程就是这样:
实际上,刚讲的冒泡过程还可以优化。当某次冒泡操作已经没有数据交换时,说明已经达到完全有序,不用再继续执行后续的冒泡操作。我这里还有另外一个例子,这里面给 6 个元素排序,只需要 4 次冒泡操作就可以了
冒泡排序算法的原理比较容易理解,具体的代码贴到下面,可以结合着代码来看前面讲的原理
结合刚才分析排序算法的三个方面,有三个问题请你分析。
- 第一,冒泡排序是原地排序算法吗?冒泡的过程只涉及相邻数据的交换操作,只需要常量级的临时空间,所以它的空间复杂度为 O(1),是一个原地排序算法。
- 第二,冒泡排序是稳定的排序算法吗?在冒泡排序中,只有交换才可以改变两个元素的前后顺序。为了保证冒泡排序算法的稳定性,当有相邻的两个元素大小相等的时候,我们不做交换,相同大小的数据在排序前后不会改变顺序,所以冒泡排序是稳定的排序算法。
- 第三,冒泡排序的时间复杂度是多少?最好情况下,要排序的数据已经是有序的了,我们只需要进行一次冒泡操作,就可以结束了,所以最好情况时间复杂度是 O(n)。而最坏的情况是,要排序的数据刚好是倒序排列的,我们需要进行 n 次冒泡操作,所以最坏情况时间复杂度为 O( n^{2} )。
那平均情况下的时间复杂是多少呢?
我们前面讲过,平均时间复杂度就是加权平均期望时间复杂度,分析的时候要结合概率论的知识。对于包含 n 个数据的数组,这 n 个数据就有 n! 种排列方式。不同的排列方式,冒泡排序执行的时间肯定是不同的。比如我们前面举的那两个例子,其中一个要进行 6 次冒泡,而另一个只需要 4 次。
如果用概率论方法定量分析平均时间复杂度,涉及的数学推理和计算就会很复杂。我这里还有一种思路,通过“有序度”和“逆序度”这两个概念来进行分析。
有序度是数组中具有有序关系的元素对的个数,例:
同理,对于一个倒序排列的数组,比如 6,5,4,3,2,1,有序度是 0;对于一个完全有序的数组,比如 1,2,3,4,5,6,有序度就是 n*(n-1)/2,也就是 15,我们把这种完全有序的数组的有序度叫作满有序度。
我们还可以得到一个公式: 逆序度 = 满有序度 - 有序度
我们排序的过程就是一种增加有序度,减少逆序度的过程,最后达到满有序度,就说明排序完成了。
冒泡排序包含两个操作原子,比较和交换。每交换一次,有序度就加 1。不管算法怎么改进,交换次数总是确定的,即为逆序度,也就是n*(n-1)/2–初始有序度。此例中就是 15–3=12,要进行 12 次交换操作。(交换次数 = 逆序度)
对于包含 n 个数据的数组进行冒泡排序,平均交换次数是多少呢?最坏情况下,初始状态的有序度是 0,所以要进行 n*(n-1)/2 次交换。最好情况下,初始状态的有序度是 n*(n-1)/2,就不需要进行交换,平均情况下,需要 n*(n-1)/4 次交换操作,比较操作肯定要比交换操作多,而复杂度的上限是 O( n^{2} ),所以平均情况下的时间复杂度就是 O( n^{2} )。
这个平均时间复杂度推导过程其实并不严格,但是很多时候很实用,毕竟概率论的定量分析太复杂,不太好用。等我们讲到快排的时候,我还会再次用这种“不严格”的方法来分析平均时间复杂度。
插入排序(Insertion Sort)
一个有序的数组,我们往里面添加一个新的数据后,如何继续保持数据有序呢?很简单,我们只要遍历数组,找到数据应该插入的位置将其插入即可。
这是一个动态排序的过程,即动态地往有序集合中添加数据,我们可以通过这种方法保持集合中的数据一直有序。对于一组静态数据,我们也可以借鉴上面讲的插入方法,来进行排序,于是就有了插入排序算法。
具体来说:
首先,我们将数组中的数据分为两个区间,已排序区间和未排序区间。初始已排序区间只有一个元素,就是数组的第一个元素。插入算法的核心思想是取未排序区间中的元素,在已排序区间中找到合适的插入位置将其插入,并保证已排序区间数据一直有序。重复这个过程,直到未排序区间中元素为空,算法结束。
如图所示,要排序的数据是 4,5,6,1,3,2,其中左侧为已排序区间,右侧是未排序区间。
插入排序也包含两种操作,一种是元素的比较,一种是元素的移动。
当我们需要将一个数据 a 插入到已排序区间时,需要拿 a 与已排序区间的元素依次比较大小,找到合适的插入位置。找到插入点之后,我们还需要将插入点之后的元素顺序往后移动一位,这样才能腾出位置给元素 a 插入。
对于不同的查找插入点方法(从头到尾、从尾到头),元素的比较次数是有区别的。但对于一个给定的初始序列,移动操作的次数总是固定的,就等于逆序度。
满有序度是 n*(n-1)/2=15,初始序列的有序度是 5,所以逆序度是 10。插入排序中,数据移动的个数总和也等于 10=3+3+4。
代码实现贴在下面,可以结合着代码再看下:
这里还是有三个问题要你分析:
第一,插入排序是原地排序算法吗?从实现过程可以很明显地看出,插入排序算法的运行并不需要额外的存储空间,所以空间复杂度是 O(1)。
第二,插入排序是稳定的排序算法吗?在插入排序中,对于值相同的元素,我们可以选择将后面出现的元素,插入到前面出现元素的后面,这样就可以保持原有的前后顺序不变。
第三,插入排序的时间复杂度是多少?如果要排序的数据已经是有序的,我们并不需要搬移任何数据。如果我们从尾到头在有序数据组里面查找插入位置,每次只需要比较一个数据就能确定插入的位置。所以这种情况下,最好是时间复杂度为 O(n)。注意,这里是从尾到头遍历已经有序的数据。如果数组是倒序的,每次插入都相当于在数组的第一个位置插入新的数据,所以需要移动大量的数据,所以最坏情况时间复杂度为 O( n^{2} )。
还记得我们在数组中插入一个数据的平均时间复杂度是多少吗?没错,是 O(n)。所以,对于插入排序来说,每次插入操作都相当于在数组中插入一个数据,循环执行 n 次插入操作,所以平均时间复杂度为 O( n^{2} )。
选择排序(Selection Sort)
选择排序算法的实现思路有点类似插入排序,也分已排序区间和未排序区间。但是选择排序每次会从未排序区间中找到最小的元素,将其放到已排序区间的末尾。
照例,也有三个问题需要你分析
1,选择排序空间复杂度为 O(1),是一种原地排序算法。
2,选择排序的最好情况时间复杂度、最坏情况和平均情况时间复杂度都为 O( n^{2} )。
3,那选择排序是稳定的排序算法吗?。答案是否定的,选择排序是一种不稳定的排序算法。从图中,可以看出来,选择排序每次都要找剩余未排序元素中的最小值,并和前面的元素交换位置,这样破坏了稳定性。比如 5,8,5,2,9 这样一组数据,使用选择排序算法来排序的话,第一次找到最小元素 2,与第一个 5 交换位置,那第一个 5 和中间的 5 顺序就变了,所以就不稳定了。相对于冒泡排序和插入排序,选择排序就稍微逊色了
那么冒泡排序和插入排序的时间复杂度都是 O(n2),都是原地排序算法,为什么插入排序要比冒泡排序更受欢迎呢?
冒泡排序不管怎么优化,元素交换的次数是一个固定值,是原始数据的逆序度。插入排序是同样的,不管怎么优化,元素移动的次数也等于原始数据的逆序度。
但是,从代码实现上来看,冒泡排序的数据交换要比插入排序的数据移动要复杂,冒泡排序需要 3 个赋值操作,而插入排序只需要 1 个。我们来看这段操作:
我们把执行一个赋值语句的时间粗略地计为单位时间(unit_time),然后分别用冒泡排序和插入排序对同一个逆序度是 K 的数组进行排序。
- 用冒泡排序,需要 K 次交换操作,每次需要 3 个赋值语句,所以交换操作总耗时就是 3*K 单位时间。
- 而插入排序中数据移动操作只需要 K 个单位时间。
虽然冒泡排序和插入排序在时间复杂度上是一样的,都是 O( n^{2} ),但是如果我们希望把性能优化做到极致,那肯定首选插入排序。
插入排序的算法思路也有很大的优化空间,可以自行学习一下希尔排序。
希尔排序 ( 周末研究下 )
特定算法是依赖特定的数据结构的。几种排序算法都是基于数组实现的。如果数据存储在链表中,这三种排序算法还能工作吗?如果能,那相应的时间、空间复杂度又是多少呢?
。。。
上面讲了冒泡排序、插入排序、选择排序这三种排序算法,它们的时间复杂度都是 O( n^{2} ),比较高,适合小规模数据的排序。今天,介绍两种时间复杂度为 O(nlogn) 的排序算法,归并排序和快速排序。这两种排序算法适合大规模的数据排序,比上面三种排序算法要更常用。
归并排序和快速排序都用到了分治思想,非常巧妙。我们可以借鉴这个思想,来解决非排序的问题,比如:如何在 O(n) 的时间复杂度内查找一个无序数组中的第 K 大元素? 这就要用到接下来的知识。
归并排序(Merge Sort)
其思想为:先把数组从中间分成前后两部分,然后对前后两部分分别排序,再将排好序的两部分合并在一起,这样整个数组就都有序了。
你有没有感觉到,分治思想跟我们前面讲的递归思想很像。是的,分治算法一般都是用递归来实现的。分治是一种解决问题的处理思想,递归是一种编程技巧,这两者并不冲突。分治算法的思想我后面会有专门的一节来讲,现在不展开讨论,重点还是排序算法。
那如何用递归代码来实现归并排序?
要想写出归并排序的代码,我们先写出归并排序的递推公式:
merge_sort(p…r) 表示,给下标从 p 到 r 之间的数组排序。将其转化为了两个子问题,merge_sort(p…q) 和 merge_sort(q+1…r),q = (p+r)/2。
有了递推公式,转化成代码就简单多了
你可能已经发现了,merge(A[p...r], A[p...q], A[q+1...r]) 这个函数的作用就是,将已经有序的 A[p...q]和 A[q+1....r]合并成一个有序的数组,并且放入 A[p....r]。
那这个过程具体该如何做呢?如图所示:
我们申请一个临时数组 tmp,大小与 A[p...r]相同。我们用两个游标 i 和 j,分别指向 A[p...q]和 A[q+1...r]的第一个元素。比较这两个元素 A[i]和 A[j],如果 A[i]<=A[j],我们就把 A[i]放入到临时数组 tmp,并且 i 后移一位,否则将 A[j]放入到数组 tmp,j 后移一位。继续上述比较过程,直到其中一个子数组中的所有数据都放入临时数组中,再把另一个数组中的数据依次加入到临时数组的末尾,这个时候,临时数组中存储的就是两个子数组合并之后的结果了。最后再把临时数组 tmp 中的数据拷贝到原数组 A[p...r]中。
merge() 合并函数如果借助哨兵,代码就会简洁很多,这个问题留给你思考。
归并排序的性能分析
第一,归并排序是稳定的排序算法吗?归并排序稳不稳定关键要看 merge() 函数,那我们可以像伪代码中那样,先把 A[p...q]中的元素放入 tmp 数组。这样就保证了值相同的元素,在合并前后的先后顺序不变。所以,归并排序是一个稳定的排序算法。
第二,归并排序的时间复杂度是多少?归并排序涉及递归,时间复杂度的分析稍微有点复杂。我们正好借此机会来学习一下,如何分析递归代码的时间复杂度。递归的适用场景是,一个问题 a 可以分解为多个子问题 b、c,问题 b、c 解决之后,我们再把 b、c 的结果合并成 a 的结果。如果我们定义求解问题 a 的时间是 T(a),求解问题 b、c 的时间分别是 T(b) 和 T( c),那我们就可以得到这样的递推关系式: T(a) = T(b) + T(c) + K ,其中 K 等于将两个子问题 b、c 的结果合并成问题 a 的结果所消耗的时间。
从刚刚的分析,我们可以得到一个重要的结论:不仅递归求解的问题可以写成递推公式,递归代码的时间复杂度也可以写成递推公式。假设对 n 个元素进行归并排序需要的时间是 T(n),那分解成两个子数组排序的时间都是 T(n/2)。我们知道,merge() 函数合并两个有序子数组的时间复杂度是 O(n)。所以,套用前面的公式,归并排序的时间复杂度的计算公式就是:
通过这个公式,如何来求解 T(n) 呢?还不够直观?那我们再进一步分解一下计算过程
通过这样一步一步分解推导,我们可以得到 T(n) = 2^{k} *T(n/2^{k})+kn 。
当 T(n/ 2^{k} )=T(1) 时,也就是 n/ 2^{k} =1,我们得到 k= log_{2}n 。我们将 k 值代入上面的公式,得到 T(n)=Cn+n log_{2}n 。如果我们用大 O 标记法来表示的话,T(n) 就等于 O(nlogn)。所以归并排序的时间复杂度是 O(nlogn)。
从我们的原理分析和伪代码可以看出,归并排序的执行效率与要排序的原始数组的有序程度无关,所以其时间复杂度是非常稳定的,不管是最好情况、最坏情况,还是平均情况,时间复杂度都是 O(nlogn)。
第三,归并排序的空间复杂度是多少?归并排序的时间复杂度任何情况下都是 O(nlogn),看起来非常优秀,但是,归并排序并没有像快排那样,应用广泛,这是为什么呢?因为它有一个致命的“弱点”,那就是归并排序不是原地排序算法,需要借助额外的存储空间,那归并排序的空间复杂度到底是多少呢?
如果我们继续按照分析递归时间复杂度的方法,通过递推公式来求解,那整个归并过程需要的空间复杂度就是 O(nlogn)。不过在任意时刻,CPU 只会有一个函数在执行,也就只会有一个临时的内存空间在使用。临时内存空间最大也不会超过 n 个数据的大小,所以空间复杂度是 O(n)。
快速排序(Quicksort)
快排的思想是这样的:如果要排序数组中下标从 p 到 r 之间的一组数据,我们选择 p 到 r 之间的任意一个数据作为 pivot(分区点)。
我们遍历 p 到 r 之间的数据,将小于 pivot 的放到左边,将大于 pivot 的放到右边,将 pivot 放到中间。经过这一步骤之后,数组 p 到 r 之间的数据就被分成了三个部分,前面 p 到 q-1 之间都是小于 pivot 的,中间是 pivot,后面的 q+1 到 r 之间是大于 pivot 的。根据分治、递归的处理思想,我们可以用递归排序下标从 p 到 q-1 之间的数据和下标从 q+1 到 r 之间的数据,直到区间缩小为 1,就说明所有的数据都有序了。
我将递推公式转化成递归代码。跟归并排序一样,我还是用伪代码来实现,你可以翻译成你熟悉的任何语言。
归并排序中有一个 merge() 合并函数,我们这里有一个 partition() 分区函数。partition() 分区函数实际上我们前面已经讲过了,就是随机选择一个元素作为 pivot(一般情况下,可以选择 p 到 r 区间的最后一个元素),然后对 A[p...r]分区,函数返回 pivot 的下标。如果我们不考虑空间消耗的话,partition() 分区函数可以写得非常简单。我们申请两个临时数组 X 和 Y,遍历 A[p...r],将小于 pivot 的元素都拷贝到临时数组 X,将大于 pivot 的元素都拷贝到临时数组 Y,最后再将数组 X 和数组 Y 中数据顺序拷贝到 A[p....r]。
partition() 函数就需要很多额外的内存空间,所以快排就不是原地排序算法了。如果我们希望快排是原地排序算法,那它的空间复杂度得是 O(1),那 partition() 分区函数就不能占用太多额外的内存空间,我们就需要在 A[p...r]的原地完成分区操作。
这里的处理有点类似选择排序。我们通过游标 i 把 A[p...r-1]分成两部分。A[p...i-1]的元素都是小于 pivot 的,我们暂且叫它“已处理区间”,A[i...r-1]是“未处理区间”。我们每次都从未处理的区间 A[i...r-1]中取一个元素 A[j],与 pivot 对比,如果小于 pivot,则将其加入到已处理区间的尾部,也就是 A[i]的位置。数组的插入操作还记得吗?使用交换,在 O(1) 的时间复杂度内完成插入操作。这里只需要将 A[i]与 A[j]交换,就可以在 O(1) 时间复杂度内将 A[j]放到下标为 i 的位置。文字不如图直观,所以我画了一张图来展示分区的整个过程。
因为分区的过程涉及交换操作,如果数组中有两个相同的元素,比如序列 6,8,7,6,3,5,9,4,在经过第一次分区操作之后,两个 6 的相对先后顺序就会改变,当j到3时,3小于4,发生A[i]( 值为6,i=0 )和A[j]( 值为3,j=4 )的交换, A[0]的值移动到A[3]的后面,也就是第一个6移动到了第二个6的后面
所以,快速排序并不是一个稳定的排序算法。到此,快速排序的原理你应该也掌握了。
现在,我再来看另外一个问题:快排和归并用的都是分治思想,递推公式和递归代码也非常相似,那它们的区别在哪里呢?
- 归并的处理过程是由下到上的,先处理子问题,然后再合并,但合并函数无法在原地执行;
- 快排的处理过程是由上到下的,先分区,然后再处理子问题,可以实现原地排序,解决了归并排序占用太多内存的问题。
快速排序的性能分析
快排也是用递归来实现的。对于递归代码的时间复杂度,我前面总结的公式,这里也还是适用的。如果每次分区操作,都能正好把数组分成大小接近相等的两个小区间时,快排的时间复杂度也是 O(nlogn)。
我举一个比较极端的例子。如果数组中的数据原来已经是有序的了,比如 1,3,5,6,8。如果我们每次选择最后一个元素作为 pivot,那每次分区得到的两个区间都是不均等的。我们需要进行大约 n 次分区操作,才能完成快排的整个过程。每次分区我们平均要扫描大约 n/2 个元素,这种情况下,快排的时间复杂度就从 O(nlogn) 退化成了 O( n^{2} )。我们刚刚讲了两个极端情况下的时间复杂度,一个是分区极其均衡,一个是分区极其不均衡。它们分别对应快排的最好情况时间复杂度和最坏情况时间复杂度。
那快排的平均情况时间复杂度是多少呢?
我们假设每次分区操作都将区间分成大小为 9:1 的两个小区间。我们继续套用递归时间复杂度的递推公式,就会变成这样:
n>1这个公式的递推求解的过程非常复杂,虽然可以求解,但我不推荐用这种方法。实际上,递归的时间复杂度的求解方法除了递推公式之外,还有递归树,在树那一节我再讲,这里暂时不说。这里直接给结论:
T(n) 在大部分情况下的时间复杂度都可以做到 O(nlogn),只有在极端情况下,才会退化到 O(n2)。而且,我们也有很多方法将这个概率降到很低,如何来做?我们后面再讲。
那如何在 O(n) 的时间复杂度内查找一个无序数组中的第 K 大元素?
比如,4, 2, 5, 12, 3 这样一组数据,第 3 大元素就是 4。我们选择数组区间 A[0...n-1]的最后一个元素 A[n-1]作为 pivot,对数组 A[0...n-1]原地分区,这样数组就分成了三部分,A[0...p-1]、A[p]、A[p+1...n-1]。
如果 p+1=K,那 A[p]就是要求解的元素;如果 K>p+1, 说明第 K 大元素出现在 A[p+1...n-1]区间,我们再按照上面的思路递归地在 A[p+1...n-1]这个区间内查找。同理,如果 K<p+1,那我们就在 A[0...p-1]区间查找。
为什么上述解决思路的时间复杂度是 O(n)?第一次分区查找,我们需要对大小为 n 的数组执行分区操作,需要遍历 n 个元素。第二次分区查找,我们只需要对大小为 n/2 的数组执行分区操作,需要遍历 n/2 个元素。依次类推,分区遍历元素的个数分别为、n/2、n/4、n/8、n/16.……直到区间缩小为 1。如果我们把每次分区遍历的元素个数加起来,就是:n+n/2+n/4+n/8+...+1。这是一个等比数列求和,最后的和等于 2n-1。所以,上述解决思路的时间复杂度就为 O(n)。
你可能会说,我有个很笨的办法,每次取数组中的最小值,将其移动到数组的最前面,然后在剩下的数组中继续找最小值,以此类推,执行 K 次,找到的数据不就是第 K 大元素了吗?不过,时间复杂度就并不是 O(n) 了,而是 O(K * n)。你可能会说,时间复杂度前面的系数不是可以忽略吗?O(K * n) 不就等于 O(n) 吗?这个可不能这么简单地划等号。当 K 是比较小的常量时,比如 1、2,那最好时间复杂度确实是 O(n);但当 K 等于 n/2 或者 n 时,这种最坏情况下的时间复杂度就是 O( n^{2} ) 了。
测试题:
现在你有 10 个接口访问日志文件,每个日志文件大小约 300MB,每个文件里的日志都是按照时间戳从小到大排序的。你希望将这 10 个较小的日志文件,合并为 1 个日志文件,合并之后的日志仍然按照时间戳从小到大排列。如果处理上述排序任务的机器内存只有 1GB,你有什么好的解决思路,能“快速”地将这 10 个日志文件合并吗?
下面会讲三种时间复杂度是 O(n) 的排序算法:桶排序、计数排序、基数排序。因为这些排序算法的时间复杂度是线性的,所以我们把这类排序算法叫作线性排序(Linear sort)。之所以能做到线性的时间复杂度,主要原因是,这三个算法是非基于比较的排序算法,都不涉及元素之间的比较操作。这几种排序算法理解起来都不难,时间、空间复杂度分析起来也很简单,但是对要排序的数据要求很苛刻,所以我们的学习重点是掌握这些排序算法的适用场景。
桶排序(Bucket sort)
桶排序,顾名思义,会用到“桶”,核心思想是将要排序的数据分到几个有序的桶里,每个桶里的数据再单独进行排序。桶内排完序之后,再把每个桶里的数据按照顺序依次取出,组成的序列就是有序的了。
桶排序的时间复杂度为什么是 O(n) 呢?如果要排序的数据有 n 个,我们把它们均匀地划分到 m 个桶内,每个桶里就有 k=n/m 个元素。每个桶内部使用快速排序,时间复杂度为 O(k * logk)。m 个桶排序的时间复杂度就是 O(m * k * logk),因为 k=n/m,所以整个桶排序的时间复杂度就是 O(n*log(n/m))。当桶的个数 m 接近数据个数 n 时,log(n/m) 就是一个非常小的常量,这个时候桶排序的时间复杂度接近 O(n)。
- 首先,桶排序要排序的数据需要很容易就能划分成 m 个桶,并且,桶与桶之间有着天然的大小顺序。这样每个桶内的数据都排序完之后,桶与桶之间的数据不需要再进行排序。
- 其次,数据在各个桶之间的分布是比较均匀的。如果数据经过桶的划分之后,有些桶里的数据非常多,有些非常少,很不平均,那桶内数据排序的时间复杂度就不是常量级了。在极端情况下,如果数据都被划分到一个桶里,那就退化为 O(nlogn) 的排序算法了。
桶排序比较适合用在外部排序中。所谓的外部排序就是数据存储在外部磁盘中,数据量比较大,内存有限,无法将数据全部加载到内存中。
比如说我们有 10GB 的订单数据,我们希望按订单金额(假设金额都是正整数)进行排序,但是我们的内存有限,只有几百 MB,没办法一次性把 10GB 的数据都加载到内存中。这个时候该怎么办呢?
如何借助桶排序的处理思想来解决这个问题?我们可以先扫描一遍文件,看订单金额所处的数据范围。假设经过扫描之后我们得到,订单金额最小是 1 元,最大是 10 万元。我们将所有订单根据金额划分到 100 个桶里,第一个桶我们存储金额在 1 元到 1000 元之内的订单,第二桶存储金额在 1001 元到 2000 元之内的订单,以此类推。每一个桶对应一个文件,并且按照金额范围的大小顺序编号命名(00,01,02...99)。
理想的情况下,如果订单金额在 1 到 10 万之间均匀分布,那订单会被均匀划分到 100 个文件中,每个小文件中存储大约 100MB 的订单数据,我们就可以将这 100 个小文件依次放到内存中,用快排来排序。等所有文件都排好序之后,我们只需要按照文件编号,从小到大依次读取每个小文件中的订单数据,并将其写入到一个文件中,那这个文件中存储的就是按照金额从小到大排序的订单数据了。
不过,你可能也发现了,订单按照金额在 1 元到 10 万元之间并不一定是均匀分布的 ,所以 10GB 订单数据是无法均匀地被划分到 100 个文件中的。有可能某个金额区间的数据特别多,划分之后对应的文件就会很大,没法一次性读入内存。这又该怎么办呢?针对这些划分之后还是比较大的文件,我们可以继续划分,比如,订单金额在 1 元到 1000 元之间的比较多,我们就将这个区间继续划分为 10 个小区间,1 元到 100 元,101 元到 200 元,201 元到 300 元....901 元到 1000 元。如果划分之后,101 元到 200 元之间的订单还是太多,无法一次性读入内存,那就继续再划分,直到所有的文件都能读入内存为止。
计数排序(Counting sort)
计数排序其实是桶排序的一种特殊情况。当要排序的 n 个数据,所处的范围并不大的时候,比如最大值是 k,我们就可以把数据划分成 k 个桶。每个桶内的数据值都是相同的,省掉了桶内排序的时间。
例如:考生的满分是 900 分,最小是 0 分,这个数据的范围很小,所以我们可以分成 901 个桶,对应分数从 0 分到 900 分。根据考生的成绩,我们将这 50 万考生划分到这 901 个桶里。桶内的数据都是分数相同的考生,所以并不需要再进行排序。我们只需要依次扫描每个桶,将桶内的考生依次输出到一个数组中,就实现了 50 万考生的排序。因为只涉及扫描遍历操作,所以时间复杂度是 O(n)。
跟桶排序非常类似,只是桶的大小粒度不一样。不过,为什么这个排序算法叫“计数”排序呢?“计数”的含义来自哪里呢?
假设只有 8 个考生,分数在 0 到 5 分之间。这 8 个考生的成绩我们放在一个数组 A[8]中,它们分别是:2,5,3,0,2,3,0,3。考生的成绩从 0 到 5 分,我们使用大小为 6 的数组 C[6]表示桶,其中下标对应分数。不过,C[6]内存储的并不是考生,而是对应的考生个数。像我刚刚举的那个例子,我们只需要遍历一遍考生分数,就可以得到 C[6]的值。
那我们如何快速计算出,每个分数的考生在有序数组中对应的存储位置呢?这个处理方法非常巧妙,很不容易想到。思路是这样的:我们对 C[6]数组顺序求和,C[6]存储的数据就变成了下面这样子。C[k]里存储小于等于分数 k 的考生个数。
所以,成绩为 3 分的考生在排序之后的有序数组 R[8]中,会保存下标 4,5,6 的位置,同时也说明,3分的考生一定在R[8]第7个位置上,也就是R[6]上。
我们从后到前依次扫描数组 A。比如,当扫描到 3 时,我们可以从数组 C 中取出下标为 3 的值 7,当 第一个3 放入到数组 R 中后,小于等于 3 的元素就只剩下了 6 个了,所以相应的 C[3]要减 1,变成 6。以此类推,当我们扫描到第 2 个分数为 3 的考生的时候,就会把它放入数组 R 中的第 6 个元素的位置(也就是下标为 5 的位置)。当我们扫描完整个数组 A 后,数组 R 内的数据就是按照分数从小到大有序排列的了。
总结一下,计数排序只能用在数据范围不大的场景中,如果数据范围 k 比要排序的数据 n 大很多,就不适合用计数排序了。而且,计数排序只能给非负整数排序,如果要排序的数据是其他类型的,要将其在不改变相对大小的情况下,转化为非负整数。
比如,还是拿考生这个例子。如果考生成绩精确到小数后一位,我们就需要将所有的分数都先乘以 10,转化成整数,然后再放到 9010 个桶内。再比如,如果要排序的数据中有负数,数据的范围是[-1000, 1000],那我们就需要先对每个数据都加 1000,转化成非负整数。
基数排序(Radix sort)
我们再来看这样一个排序问题。假设我们有 10 万个手机号码,希望将这 10 万个手机号码从小到大排序,你有什么比较快速的排序方法呢?
桶排序、计数排序能派上用场吗?手机号码有 11 位,范围太大,显然不适合用这两种排序算法。针对这个排序问题,有没有时间复杂度是 O(n) 的算法呢?现在我就来介绍一种新的排序算法,基数排序。
刚刚这个问题里有这样的规律:假设要比较两个手机号码 a,b 的大小,如果在前面几位中,a 手机号码已经比 b 手机号码大了,那后面的几位就不用看了。
借助稳定排序算法,这里有一个巧妙的实现思路。还记得排序算法的稳定性的时候举的订单的例子吗?我们这里也可以借助相同的处理思路,先按照最后一位来排序手机号码,然后,再按照倒数第二位重新排序,以此类推,最后按照第一位重新排序。经过 11 次排序之后,手机号码就都有序了。
用字符串排序的例子,画了一张基数排序的过程分解图:
这里按照每位来排序的排序算法要是稳定的,否则这个实现思路就是不正确的。因为如果是非稳定排序算法,那最后一次排序只会考虑最高位的大小顺序,完全不管其他位的大小关系,那么低位的排序就完全没有意义了。
- 分析下基数排序的时间复杂度?
- 根据每一位来排序,可以用刚讲过的桶排序或者计数排序,它们的时间复杂度可以做到 O(n)。
- 如果要排序的数据有 k 位,那我们就需要 k 次桶排序或者计数排序,总的时间复杂度是 O(k*n)。当 k 不大的时候,比如手机号码排序的例子,k 最大就是 11,所以基数排序的时间复杂度就近似于 O(n)。
实际上,有时候要排序的数据并不都是等长的,比如我们排序牛津字典中的 20 万个英文单词,最短的只有 1 个字母,最长的我特意去查了下,有 45 个字母,中文翻译是尘肺病。对于这种不等长的数据,基数排序还适用吗?实际上,我们可以把所有的单词补齐到相同长度,位数不够的可以在后面补“0”,因为根据ASCII 值,所有字母都大于“0”,所以补“0”不会影响到原有的大小顺序。这样就可以继续用基数排序了。
总结一下,基数排序对要排序的数据是有要求的,需要可以分割出独立的“位”来比较,而且位之间有递进的关系,如果 a 数据的高位比 b 数据大,那剩下的低位就不用比较了。除此之外,每一位的数据范围不能太大,要可以用线性排序算法来排序,否则,基数排序的时间复杂度就无法做到 O(n) 了。
那如何根据年龄给 100 万用户排序?根据年龄给 100 万用户排序,就类似按照成绩给 50 万考生排序。我们假设年龄的范围最小 1 岁,最大不超过 120 岁。我们可以遍历这 100 万用户,根据年龄将其划分到这 120 个桶里,然后依次顺序遍历这 120 个桶中的元素。这样就得到了按照年龄排序的 100 万用户数据。
测试题:
假设我们现在需要对 D,a,F,B,c,A,z 这个字符串进行排序,要求将其中所有小写字母都排在大写字母的前面,但小写字母内部和大写字母内部不要求有序。比如经过排序之后为 a,c,z,D,F,B,A,这个如何来实现呢?如果字符串中存储的不仅有大小写字母,还有数字。要将小写字母的放到前面,大写字母放在最后,数字放在中间,不用排序算法,又该怎么解决呢?(实际上,还有很多看似是排序但又不需要使用排序算法就能处理的排序问题。)
排序函数
如何实现一个通用的、高性能的排序函数?乎所有的编程语言都会提供排序函数,那你知道这些排序函数是如何实现的吗?底层都利用了哪种排序算法呢?如何选择合适的排序算法?
- 线性排序算法的时间复杂度比较低,适用场景比较特殊。所以如果要写一个通用的排序函数,不能选择线性排序算法。
- 如果对小规模数据进行排序,可以选择时间复杂度是 O( n^{2} ) 的算法;如果对大规模数据进行排序,时间复杂度是 O(nlogn) 的算法更加高效。所以,为了兼顾任意规模数据的排序,一般都会首选时间复杂度是 O(nlogn) 的排序算法来实现排序函数。
- 我们已经讲过的有归并排序、快速排序,后面讲堆的时候我们还会讲到堆排序。堆排序和快速排序都有比较多的应用,比如 Java 语言采用堆排序实现排序函数,C 语言使用快速排序实现排序函数。
- 如果要排序 100MB 的数据,归并排序除了数据本身占用的内存之外,排序算法还要额外再占用 100MB 的内存空间,空间耗费就翻倍了。快速排序比较适合来实现排序函数,但是,我们也知道,快速排序在最坏情况下的时间复杂度是 O( n^{2} ),如何来解决这个“复杂度恶化”的问题呢?
如何优化快速排序?
我们前面讲过,如果数据原来就是有序的或者接近有序的,每次分区点都选择最后一个数据,那快速排序算法就会变得非常糟糕,时间复杂度就会退化为 O( n^{2} )。实际上,这种时间复杂度出现的主要原因还是因为我们分区点选得不够合理。
最理想的分区点是:被分区点分开的两个分区中,数据的数量差不多。
这里介绍两个比较常用、比较简单的分区算法,可以直观地感受一下:
1. 三数取中法:我们从区间的首、尾、中间,分别取出一个数,然后对比大小,取这 3 个数的中间值作为分区点;
2. 随机法:从概率的角度来看,不大可能会出现每次分区点都选得很差的情况。
要警惕堆栈溢出。我们知道,快速排序是用递归来实现的。我们在递归那一节讲过,递归要警惕堆栈溢出。为了避免快速排序里,递归过深而堆栈过小,导致堆栈溢出,我们有两种解决办法:
- 第一种是限制递归深度。一旦递归过深,超过了我们事先设定的阈值,就停止递归。
- 第二种是通过在堆上模拟实现一个函数调用栈,手动模拟递归压栈、出栈的过程,这样就没有了系统栈大小的限制。(???)
举例分析排序函数
这里拿 Glibc 中的 qsort() 函数举例说明一下。虽说 qsort() 从名字上看,很像是基于快速排序算法实现的,实际上它并不仅仅用了快排这一种算法。
看源码,你就会发现,qsort() 会优先使用归并排序来排序输入数据,因为归并排序的空间复杂度是 O(n),所以对于小数据量的排序,比如 1KB、2KB 等,归并排序额外需要 1KB、2KB 的内存空间,这个问题不大。现在计算机的内存都挺大的,我们很多时候追求的是速度。还记得我们前面讲过的用空间换时间的技巧吗?这就是一个典型的应用。但如果数据量太大,就跟我们前面提到的,排序 100MB 的数据,这个时候我们再用归并排序就不合适了。所以,要排序的数据量比较大的时候,qsort() 会改为用快速排序算法来排序。实际上,在快速排序的过程中,当要排序的区间中,元素的个数小于等于 4 时,qsort() 就退化为插入排序,不再继续用递归来做快速排序,因为我们前面也讲过,在小规模数据面前,O( n^{2} ) 时间复杂度的算法并不一定比 O(nlogn) 的算法执行时间长,对于小数据量的排序,我们选择比较简单、不需要递归的插入排序算法。
那 qsort() 是如何选择快速排序算法的分区点的呢?qsort() 选择分区点的方法就是“三数取中法”。
还有前面提到的递归太深会导致堆栈溢出的问题,qsort() 是通过自己实现一个堆上的栈,手动模拟递归来解决的。我们之前在讲递归那一节也讲过,不知道你还有没有印象?(没有,,,)
还记得我们之前讲到的哨兵来简化代码,提高执行效率吗?在 qsort() 插入排序的算法实现中,也利用了这种编程技巧。虽然哨兵可能只是少做一次判断,但是毕竟排序函数是非常常用、非常基础的函数,性能的优化要做到极致。
C 语言的 qsort() 已经分析完了,你有没有觉得其实也不是很难?基本上都是用了我们前面讲到的知识点,有了前面的知识积累,看一些底层的类库的时候是不是也更容易了呢?
测试题:
你能否分析一下你所熟悉的语言中的排序函数都是用什么排序算法实现的呢?都有哪些优化技巧?
二分查找(Binary Search)
二分查找的思想非常简单,很多非计算机专业的同学很容易就能理解,但是看似越简单的东西往往越难掌握好,想要灵活应用就更加困难。
我们还是来看一道思考题。假设我们有 1000 万个整数数据,每个数据占 8 个字节,如何设计数据结构和算法,快速判断某个整数是否出现在这 1000 万数据中?且内存空间,最多不要超过 100MB。带着这个问题,让我们进入今天的内容吧!
二分思想:比如说,我随机写一个 0 到 99 之间的数字,然后你来猜我写的是什么。猜的过程中,你每猜一次,我就会告诉你猜的大了还是小了,直到猜中为止。你来想想,如何快速猜中我写的数字呢?
7 次就猜出来了,是不是很快?这个例子用的就是二分思想,按照这个思想,即便我让你猜的是 0 到 999 的数字,最多也只要 10 次就能猜中。不信的话,你可以试一试。
假设有 1000 条订单数据,已经按照订单金额从小到大排序,每个订单金额都不同,并且最小单位是元。我们现在想知道是否存在金额等于 19 元的订单。如果一个一个遍历这 1000 个订单,查找会比较慢,最坏情况下,可能要遍历完这 1000 条记录才能找到。那用二分查找能不能更快速地解决呢?为了方便讲解,我们假设只有 10 个订单,订单金额分别是:8,11,19,23,27,33,45,55,67,98。还是利用二分思想,每次都与区间的中间数据比对大小,缩小查找区间的范围。为了更加直观,我画了一张查找过程的图。其中,low 和 high 表示待查找区间的下标,mid 表示待查找区间的中间元素下标。
O(logn) 惊人的查找速度
二分查找是一种非常高效的查找算法,高效到什么程度呢?我们来分析一下它的时间复杂度。假设数据大小是 n,每次查找后数据都会缩小为原来的一半,也就是会除以 2。最坏情况下,直到查找区间被缩小为空,才停止。
可以看出来,这是一个等比数列。其中 n/ 2^{k} =1 时,k 的值就是总共缩小的次数。而每一次缩小操作只涉及两个数据的大小比较,所以,经过了 k 次区间缩小操作,时间复杂度就是 O(k),通过 n/ 2^{k} =1,我们可以求得 k= log_{2}n ,所以时间复杂度就是 O(logn)。
二分查找是我们目前为止遇到的第一个时间复杂度为 O(logn) 的算法。后面章节我们还会讲堆、二叉树的操作等等,它们的时间复杂度也是 O(logn)。我这里就再深入地讲讲 O(logn) 这种对数时间复杂度。这是一种极其高效的时间复杂度,有的时候甚至比时间复杂度是常量级 O(1) 的算法还要高效。为什么这么说呢?
因为 logn 是一个非常“恐怖”的数量级,即便 n 非常非常大,对应的 logn 也很小。比如 n 等于 2 的 32 次方,这个数很大了吧?大约是 42 亿。也就是说,如果我们在 42 亿个数据中用二分查找一个数据,最多需要比较 32 次。我们前面讲过,用大 O 标记法表示时间复杂度的时候,会省略掉常数、系数和低阶。对于常量级时间复杂度的算法来说,O(1) 有可能表示的是一个非常大的常量值,比如 O(1000)、O(10000)。所以,常量级时间复杂度的算法有时候可能还没有 O(logn) 的算法执行效率高。反过来,对数对应的就是指数。有一个非常著名的“阿基米德与国王下棋的故事”,就是让国王拿出 2^{64-1} 粒米,这个值是非常吓人的,这也是为什么我们说,指数时间复杂度的算法在大规模数据面前是无效的(数据规模n才63时,指数时间复杂度已经很大了)。
简单的二分查找实现
最简单的情况就是有序数组中不存在重复元素,我们在其中用二分查找值等于给定值的数据。 Java 代码实现了一个最简单的二分查找算法。
这个代码我稍微解释一下,low、high、mid 都是指数组下标,初始 low=0, high=n-1。mid 表示[low, high]的中间位置。我们通过对比 a[mid]与 value 的大小,来更新接下来要查找的区间范围,直到找到或者区间缩小为 0,就退出。
现在,我就着重强调一下容易出错的 3 个地方。
- 循环退出条件注意是 low<=high,而不是 low<high
- mid 的取值实际上,mid=(low+high)/2 这种写法是有问题的。因为如果 low 和 high 比较大的话,两者之和就有可能会溢出。改进的方法是将 mid 的计算方式写成 low+(high-low)/2。更进一步,如果要将性能优化到极致的话,我们可以将这里的除以 2 操作转化成位运算 low+((high-low)>>1)。因为相比除法运算来说,计算机处理位运算要快得多。
- low 和 high 的更新low=mid+1,high=mid-1。注意这里的 +1 和 -1,如果直接写成 low=mid 或者 high=mid,就可能会发生死循环。比如,当 high=3,low=3 时,如果 a[3]不等于 value,就会导致一直循环不退出。
将一个二进制数据左移或右移是相当于对改数据乘2或者除2
实际上,二分查找除了用循环来实现,还可以用递归来实现:
应用场景的局限性
首先,二分查找依赖的是顺序表结构,简单点说就是数组。那二分查找能否依赖其他数据结构呢?比如链表。答案是不可以的,主要原因是二分查找算法需要按照下标随机访问元素。我们在数组和链表那两节讲过,数组按照下标随机访问数据的时间复杂度是 O(1),而链表随机访问的时间复杂度是 O(n)。所以,如果数据使用链表存储,二分查找的时间复杂就会变得很高。
其次,二分查找针对的是有序数据。二分查找只能用在插入、删除操作不频繁,一次排序多次查找的场景中。针对动态变化的数据集合,二分查找将不再适用。那针对动态数据集合,如何在其中快速查找某个数据呢?别急,等到二叉树那一节我会详细讲。
再次,数据量太小不适合二分查找。如果要处理的数据量很小,完全没有必要用二分查找,顺序遍历就足够了。不过,这里有一个例外,比如,数组中存储的都是长度超过 300 的字符串,如此长的两个字符串之间比对大小,就会非常耗时,这个时候二分查找就比顺序遍历更有优势。
最后,数据量太大也不适合二分查找。二分查找的底层需要依赖数组这种数据结构,而数组为了支持随机访问的特性,要求内存空间连续,对内存的要求比较苛刻。比如,我们有 1GB 大小的数据,如果希望用数组来存储,那就需要 1GB 的连续内存空间。
开头的问题解答:如何在 1000 万个整数中快速查找某个整数?我们的内存限制是 100MB,每个数据大小是 8 字节,最简单的办法就是将数据存储在数组中,1000 万个整数内存占用差不多是 80MB,符合内存的限制。借助今天讲的内容,我们可以先对这 1000 万数据从小到大排序,然后再利用二分查找算法,就可以快速地查找想要的数据了。这里使用二分查找底层依赖的是数组,除了数据本身之外不需要额外存储其他信息,是最省内存空间的存储方式,所以刚好能在限定的内存大小下解决这个问题。用散列表、二叉树看似都可以解决,但是100MB 的内存肯定不够用。
测试题:
如何编程实现“求一个数的平方根”?要求精确到小数点后 6 位。
变形的二分查找的实现
不知道你有没有听过这样一个说法:“十个二分九个错”。二分查找虽然原理极其简单,但是想要写出没有 Bug 的二分查找并不容易。唐纳德·克努特(Donald E.Knuth)在《计算机程序设计艺术》的第 3 卷《排序和查找》中说到:“尽管第一个二分查找算法于 1946 年出现,然而第一个完全正确的二分查找算法实现直到 1962 年才出现。”
二分查找的变形问题很多,我只选择几个典型的来讲解,其他的你可以借助我今天讲的思路自己来分析。
变体一:查找第一个值等于给定值的元素
上面的二分查找是最简单的一种,即有序数据集合中不存在重复的数据,我们在其中查找值等于某个给定值的数据。如果我们将这个问题稍微修改下,有序数据集合中存在重复的数据,希望找到第一个值等于给定值的数据,这样之前的二分查找代码还能继续工作吗?
比如下面这样一个有序数组,其中,a[5],a[6],a[7]的值都等于 8,是重复的数据。我们希望查找第一个等于 8 的数据,也就是下标是 5 的元素。
如果用前面讲的二分查找的代码实现,首先拿 8 与区间的中间值 a[4]比较,8 比 6 大,于是在下标 5 到 9 之间继续查找。下标 5 和 9 的中间位置是下标 7,a[7]正好等于 8,所以代码就返回了。所以,针对这个变形问题,我们要稍微改造一下代码。
100 个人写二分查找就会有 100 种写法。网上有很多关于变形二分查找的实现方法,有很多写得非常简洁,比如下面这个写法。但是,尽管简洁,理解起来却非常烧脑,也很容易写错。
看完这个实现之后,你是不是觉得很不好理解?如果你只是死记硬背这个写法,我敢保证,过不了几天,你就会全都忘光,再让你写,90% 的可能会写错。所以,我换了一种实现方法,你看看是不是更容易理解呢?
我们求解的是第一个值等于给定值的元素,当 a[mid]等于要查找的值时,我们就需要确认一下这个 a[mid]是不是第一个值等于给定值的元素。看第 11 行代码:
- 如果 mid 等于 0,那这个元素已经是数组的第一个元素,那它肯定是我们要找的;
- 如果 mid 不等于 0,但 a[mid]的前一个元素 a[mid-1]不等于 value,那也说明 a[mid]就是我们要找的第一个值等于给定值的元素。
- 如果经过检查之后发现 a[mid]前面的一个元素 a[mid-1]也等于 value,那说明此时的 a[mid]肯定不是我们要查找的第一个值等于给定值的元素。那我们就更新 high=mid-1,因为要找的元素肯定出现在[low, mid-1]之间。
而对于我们做工程开发的人来说,代码易读懂、没 Bug,其实更重要,所以我觉得第二种写法更好。
变体二:查找最后一个值等于给定值的元素
变体三:查找第一个大于等于给定值的元素
比如,数组中存储的这样一个序列:3,4,6,7,10。如果查找第一个大于等于 5 的元素,那就是 6。实际上,实现的思路跟前面的那两种变形问题的实现思路类似,代码写起来甚至更简洁。
变体四:查找最后一个小于等于给定值的元素
比如,数组中存储了这样一组数据:3,5,6,8,9,10。最后一个小于等于 7 的元素就是 6。是不是有点类似上面那一种?实际上,实现思路也是一样的。
如何快速定位出一个 IP 地址的归属地?
当我们想要查询 202.102.133.13 这个 IP 地址的归属地时,我们就在地址库中搜索,发现这个 IP 地址落在[202.102.133.0, 202.102.133.255]这个地址范围内,那我们就可以将这个 IP 地址范围对应的归属地“XXX”显示给用户了。
根据上面的知识,现在这个问题应该很好解决。如果 IP 区间与归属地的对应关系不经常更新,我们可以先预处理这 12 万条数据,让其按照起始 IP 从小到大排序。如何来排序呢?我们知道,IP 地址可以转化为 32 位的整型数。所以,我们可以将起始地址,按照对应的整型值的大小关系,从小到大进行排序。然后,这个问题就可以转化为我刚讲的第四种变形问题“在有序数组中,查找最后一个小于等于某个给定值的元素”了。当我们要查询某个 IP 归属地时,我们可以先通过二分查找,找到最后一个起始 IP 小于等于这个 IP 的 IP 区间,然后,检查这个 IP 是否在这个 IP 区间内,如果在,我们就取出对应的归属地显示;如果不在,就返回未查找到。
小结:
实际上,求“值等于给定值”的二分查找确实不怎么会被用到,二分查找更适合用在“近似”查找问题,在这类问题上,二分查找的优势更加明显。比如今天讲的这几种变体问题,用其他数据结构,比如散列表、二叉树,就比较难实现了。变体的二分查找算法写起来非常烧脑,很容易因为细节处理不好而产生 Bug,这些容易出错的细节有:终止条件、区间上下界更新方法、返回值选择。所以代码你最好能用自己实现一遍,对锻炼编码能力、逻辑思维、写出 Bug free 代码,会很有帮助。
测试题:
这是一个非常规的二分查找问题。如果有序数组是一个循环有序数组,比如 4,5,6,1,2,3。针对这种情况,如何实现一个求“值等于给定值”的二分查找算法呢?