1. 选取 + 中位数
再接下来这一节,我们将讨论由排序问题衍生和推广而来的一类问题,所谓的选取问题 selection。 这类问题的共同特点是需要从一组大小可以相互比较的元素中找到某一个特殊的元素,比如找到其中由小而大列于特定次序位置上的一个元素,或者找出其中在数值大小上恰好列于中间的那个元素。
当然,只要我们已经得到了整个数据集所对应的排序列,以上问题自然都可以迎刃而解。然而,正因为排序计算自身的高复杂度,我们在此不得不绕开它,并转而寻找更为有效的算法。
-
而在接下来的这一小节,就让我们首先来讨论如何选取众数???
如果我们需要从一组大小可比较的元素中选取出其中次序列在第 K 位置,我们就称对应的选择问题为 K-selection,比如在包括 Excel 在内的各种数据分析软件中,往往就会提供这样的命令甚至函数。
这类功能使得我们可以在尚未对所有元素进行全排序之前,找出其中按大小次序列在第 k 位置。K选择问题中的一个特例,就是所谓的中位数 media 的问题,也就说要从一组元素中找出按大小恰好列于中间位置的那个元素。
在 Excel 等数据分析工具中,同样也提供了对应的功能或函数。具体来说,如果所有元素的秩隐式的可以由0到 n 减 1 来表示,那么所谓的中位数也就是其中秩为二分之 N 下整的那个元素。
比如在秩为0到6的7个元素中,所谓的中位数,也就是其中置为3的那个元素,除了它自身,在它的左和右侧各有三个元素。又如在0到7这样8个元素中,中位数的秩应该为4。也就是说在它的左侧有四个元素,但在它的右侧却只有三个元素。
我们稍后就会看到中位数问题,虽然是 k 选择问题的一个特例,但实际上也是其中难度最大的一类问题。
2.从中位数到众数
众数也就是在一组元素中,数量占绝大多数者,因此也称作主流数。比如在这样一个由5个元素所构成地集合中,元素3总共出现了多大三次,因此它就是这里的一个众数。而在另一个规模为6的集合中,尽管元素3也出现了三次,但却没有达到4次,因此这里并没有任何的众数。需要强调的是,作为这里的计算输入,我们数据集中的数据并没有按顺序排列,而往往是以一种无序的随机状态给出的,比如构成一个无序向量。
而问题的难点也恰在此处,因为如果所有的元素已经按顺序排列,那么接下来我们只需做一趟简单的线性扫描,就可以判断出是否有众数,而且如果有众数,我们也可以相应地判断是谁。
然而遗憾的是,我们已经知道排序算法在通常的情况下都逃脱不了 n log n 的下界,因此我们并不能做这样的预处理或者假设。在这里我们进一步追求的目标是,对于任何一个这样的无序随机数据集,我们希望能够在不超过线性的时间内使用最多常数的空间来找到其中的众数。
我们构思首先从一条必要性出发。实际上我们不难验证:在一个数据集中,如果的确存在众数,那么这个众数也必然是整个数据集的中位数。
因为我们如果的确将这些数据从大到小排成一个有序的线性序列,那么以中位数为界,无论是前一半还是后一半都不足以容纳所有的众数。也就是说,众数所对应的那个区间必然会覆盖中位数。这个必要条件非常重要,因为它意味着只有中位数才是众数的唯一可能候选。
实际上,无论是中位数还是任何一个元素,我们都可以在线性时间内验证它是否的确是一个众数,你能想出具体的方法吗?没错,我们只需要遍历一趟整个数据集,统计出目标元素的数目,然后根据定义即可判断它是或者不是众数。
因此,如果我们的确能从数据集中很快地找出中位数,那么也就自然得到了一个众数的选取算法。
3. 从频繁数到众数
然而很遗憾,在我们尚未介绍高效的中位数算法之前,我们以上的设想很难兑现。
因此,或许我们应该转而去寻找其他关于众数的必要条件。比如频繁数,顾名思义,所谓的频繁数也就是在一组数据中出现次数最多的元素。自然的众数应该是频繁数的一个特例。根据定义,它的出现次数不仅最多,而且多于其他元素的总和。
因此,如果我们能够像在 Excel 中那样拥有一个现成的频繁数调用接口,那我们也自然可以用它来替换刚才的中位数,从而同样得到一个众数的选取算法。然而再一次的坏消息是频繁数的算法依然难以兼顾。此前我们在时间和空间上所设定的目标。
因此再进一步的,我们需要进而去寻找某种足够用但又更为松弛的,同时计算成本也足够低的必要条件。如果能够找到这样的必要条件以及对应的算法,我们也同样可以自然地得到一个关于众数的选取算法。
那么这样一种必要条件是否存在呢?如果存在,具体的又是什么呢?
4. 减而治之
在这里我们需要再次的应用减而治之的策略,也就是说从问题的规模上不断地缩小众数的求解范围。
这里我们不妨约定,所有的元素都是按照随机无序的次序存放于某个向量 A 中。希望能够依照某种准则安全地从 A 中减除掉某个前缀 P,从而将原先在 A 中选取众数的问题转化为在 A 减 P 中寻找众数的问题。
其实这里的准则非常简明,也就是在 P 中存在某一类元素 x,而且 x 出现的次数恰好就是 P 的长度的一半。也就是说,如果 A 中的确存在众数,那么相对于每一个这样的前缀P,A 减 P 也必然存在众数,而且进一步的 A 减 P 的众数必然也是原先 A 的众数。
也就是说在这种情况下,A 减 P 的众数构成了 A 的众数的必要条件。
实际上,既然我们最终总是要花费 O(n) 的时间通过一次线性扫描来确定候选者是否的确为众数,所以我们只需考虑 A 的确含有众数的情况。于是如果将 A 中的众数记作 M,那么无非两种情况,也就是随着 P 被减除的元素 X 等于或者不等于M。
-
先来考察 X 与 M 相等的情况。在这种情况下,既然 X,当然也就是现在的 M, 在 P 中恰好占据半壁江山。所以在将这些 M 以及同等数量的其他元素减除之后,在 A 减 P 中,M 与其他元素在数量上的差距将与此前在 A 中一样保持不变。
-
第二种情况,也就是 X 并非中位数的情况。其实更为简单,同样的,因为 X 在 P 中已经占据半壁江山,所以即便其他的元素都是中位数 M, 在 A 减 P 中 M 与其他元素的数量差相对于此前在 A 中与其他元素的数量差也不至于缩小。
也就说在这种情况下,M 依然是 A 减 P 的众数。基于这样一个必要条件,我们就自然可以导出一个相应的算法。
具体来说,这个算法将反复迭代地进行,每一次都能够从 A 中减除掉一个非空的前缀P,从而使得问题的规模能够得到有效的缩减。这个过程将持续下去,直到最终的平凡情况。
而在每一步迭代中,为了确定这样的一个前缀P,我只需将他的首元素作为 x,然后随着接下来的不断扫描,不断统计 x 的数量,直到某个时刻,X在P 中恰好占据一半的比例。那么这样一个总体的思路又该如何具体的兑现为代码呢?
5. 算法实现
在这里,我们给出以上构思可能的一种实现方法。
-
我们用 maj 来指代将要确认的众数候选者。可以看到整个算法的确是一个迭代过程。
-
在迭代的过程中,我们需要使用一个计数器 C,它的初值被赋为0。
-
我们将从前向后便利整个向量,并考察当前的每一个元素,如果它与当前的候选者相等,我们就令计数器增加一个单位。反之,计数器减少一个单位。
也就说这里的计数器将忠实的记录,在当前的前缀中,这个候选者与其他元素在出现次数上的差额。一旦这个差额归零,也就意味着当前对应的前缀可以直接减除掉。
而为了使得这个迭代能够继续进行下去,我们在进入新的一段前缀之前,需要随即将它的首元素作为新的众数候选者。当然,因为他刚出现过一次,而其他的元素还没有出现过,所以此时差额计数器的初值也自然应该设作 C 等于1。
当整个的数据集都已遍历过后,最终的那个 majority, 自然也就是我们所需要的众数候选者。因为根据我们此前的判断,如果原向量的确存在众数,那么它只可能是这个候选者。