笔记
对于一个有
n
n
n个元素的集合,要寻找其中的最小元素,需要做多少次比较?我们很容易得出需要
n
−
1
n−1
n−1次比较。寻找最小元素的过程如下所示。
那么
n
−
1
n−1
n−1次比较是寻找最小值问题的最优解吗?答案是肯定的。可以这样来分析。对于任意一个寻找最小值的算法,可以把算法看成一场所有元素都参与的锦标赛。每两个元素之间的比较都是锦标赛中的一场比赛,两个元素中较小者获胜。那么最终获胜者即是所有元素中的最小值。除最终获胜者以外,每个元素都至少会输掉一场比赛。因此可以断定,寻找最小值至少需要进行
n
−
1
n−1
n−1次比较。
同理,寻找最大元素也需要
n
−
1
n−1
n−1次比较。
在某些应用中,我们可能需要同时找出最小值和最大值。如果分别独立地寻找最小值和最大值,那么各需要
n
−
1
n−1
n−1次比较,一共需要
2
n
−
2
2n−2
2n−2次比较。然而,有一种方法所需的比较次数比这更少。这种方法将输入元素两两分组,将每一组元素互相进行比较,然后把其中较小者与已知的当前最小值进行比较,把其中较大者与已知的当前最大值进行比较。这样一来,每
2
2
2个元素需要比较
3
3
3次。
假设输入数组一共有
n
n
n个元素。如果
n
n
n为奇数,那么就将初始的最小值和最大值都设为第一个元素,然后成对地处理余下的元素。如果
n
n
n为偶数,那么先对前
2
2
2个元素做一次比较,较小者作为初始的最小值,较大者作为初始的最大值,然后成对地处理余下的元素。以下是这种方法的伪代码。
下面来分析该算法所需的比较次数。如果
n
n
n为奇数,那么总共需要进行
3
(
n
−
1
)
/
2
3(n-1)/2
3(n−1)/2次比较。如果
n
n
n为偶数,则先进行一次初始比较,然后进行
3
(
n
−
2
)
/
2
3(n-2)/2
3(n−2)/2次比较,加起来一共需要进行
3
n
/
2
−
2
3n/2-2
3n/2−2次比较,这小于
3
⌊
n
/
2
⌋
3⌊n/2⌋
3⌊n/2⌋。所以,无论n为奇数还是偶数,总的比较次数都不超过
3
⌊
n
/
2
⌋
3⌊n/2⌋
3⌊n/2⌋。
书本上的内容介绍完了,这里再扩展一部分内容,主要是为解决练习题9.1-1做一下铺垫。上文提到,寻找最小值至少需要进行
n
−
1
n−1
n−1次比较,这里提供另外一种证明方法。
对于任意一个寻找最小值的算法,可以把所有比较过程构造成为一棵比较树。举例说明,有一个包含
8
8
8个元素的数组
<
10
,
6
,
8
,
5
,
7
,
9
,
12
,
11
>
<10, 6, 8, 5, 7, 9, 12, 11>
<10,6,8,5,7,9,12,11>,利用代码9.1-1来寻找该数组的最小值,比较树如下图所示。
对比较树来说,任何一个非叶结点代表一次比较过程,非叶结点的两个孩子表示参与比较的两个元素,非叶结点本身则是它的两个孩子比较下来的较小者。所以一棵比较树有多少个非叶结点,就说明一共包含多少次比较过程。而叶结点则表示一个元素首次参与比较,如果比较之后该元素被淘汰,则它不会参与之后的比较;如果没被淘汰,则它会继续参与之后的比较。
上图这棵比较树除根结点外,每一层均有两个结点。比较过程从树的最底层开始,逐步往上进行,直到根结点为止。其中红色实线标示了每一次比较后留下来的元素。这棵比较树一共有
7
7
7个非叶结点,说明一共有
7
7
7次比较。
接下来换一种寻找最小值的算法:将元素两两分组,分别进行比较,淘汰每一组的较大值;对剩下的元素再进行两两分组,进行同样的操作;如此迭代,直到得到最小值为止。将该算法作用于同一个数组
<
10
,
6
,
8
,
5
,
7
,
9
,
12
,
11
>
<10, 6, 8, 5, 7, 9, 12, 11>
<10,6,8,5,7,9,12,11>,得到的比较树如下图所示。
这棵比较树与上一棵比较完全不同,但是也有
7
7
7个非叶结点,说明一共也进行了
7
7
7次比较。
我们举了两个例子,展示两种不同的寻找最小值的算法,两种算法所需要的比较次数是一样的,都是
n
−
1
n−1
n−1,这并非是巧合。我们看到以上两棵比较树形状相差很大,然而它们有一些共同点,即二者都是二叉树,并且二者所有非叶结点的度都为
2
2
2。因为比较树的任意一个非叶结点都是由两个子结点比较得到的,即任意一个非叶结点都包含两个孩子。也就是说,任意一棵比较树,只有度为
0
0
0的结点(叶结点)和度为
1
1
1的结点(非叶结点)。
二叉树有一个有趣的性质:任意一棵二叉树,如果度为
0
0
0的结点个数为
n
0
n_0
n0,度为
2
2
2的结点个数为
n
2
n_2
n2,则有
n
0
=
n
2
+
1
n_0 = n_2 + 1
n0=n2+1。这里不给出这一性质的证明过程,感兴趣的可以在网络上搜索。对比较树来说,
n
0
n_0
n0就是叶结点个数,
n
2
n_2
n2就是非叶结点个数。
上文又提到,比较树中的叶结点表示一个元素首次参与比较。在寻找最小值过程中,任何一个元素都至少要参与一次比较,任意一棵比较树至少包含
n
n
n个叶结点,于是非叶结点至少有
n
−
1
n−1
n−1个。上文提到“一个非叶结点代表一次比较过程”,因此任意一棵比较树都包含至少
n
−
1
n−1
n−1次比较。这也说明
n
−
1
n−1
n−1次比较是寻找最小值问题的最优解。
练习
9.1-1 证明:在最坏情况下,找到
n
n
n个元素中第二小的元素需要
n
+
⌈
l
g
n
⌉
−
2
n+⌈{\rm lg}n⌉-2
n+⌈lgn⌉−2次比较。(提示:可以同时找最小元素。)
证
利用上文提到的比较树很容易证明这一结论。首先寻找最小元素,一共需要
n
−
1
n−1
n−1次比较,并且得到一棵比较树
T
T
T,这是第一轮比较。在寻找最小元素的过程中,第二小元素必然会在某次与最小元素的比较过程中被淘汰。
所有被最小元素淘汰掉的元素构成一个集合,假设该集合包含
m
m
m个元素。接下来寻找该集合中的最小元素,它就是所有元素中的第二小元素,这就是第二轮比较。这一过程需要
m
−
1
m−1
m−1次比较。最坏情况下,
m
m
m的值取决于比较树
T
T
T的高度。再看上文给出的例子,如下图所示。在这一棵比较树中,最小元素是
5
5
5,
3
3
3个标为红色结点都被最小元素给淘汰掉的。第二小元素必然是这
3
3
3个元素中的最小值。
最坏情况下,最小元素在比较树的最底层就出现了,并且最小元素在比较树的每一层(根结点所在层除外)都淘汰掉一个元素。在这种情况下被最小元素淘汰掉的元素个数正好等于比较树的高度。而一棵含有
n
n
n个叶结点的二叉树,它的高度至少为
⌈
l
g
n
⌉
⌈{\rm lg}n⌉
⌈lgn⌉。这意味着最坏情况下,第二轮寻比较过程一共包含至少
⌈
l
g
n
⌉
⌈{\rm lg}n⌉
⌈lgn⌉个元素,至少需要
⌈
l
g
n
⌉
−
1
⌈{\rm lg}n⌉-1
⌈lgn⌉−1次比较。
第一轮
n
−
1
n−1
n−1次比较,加上第二轮
⌈
l
g
n
⌉
−
1
⌈{\rm lg}n⌉-1
⌈lgn⌉−1次比较,正好是
n
+
⌈
l
g
n
⌉
−
2
n+⌈{\rm lg}n⌉-2
n+⌈lgn⌉−2次比较。
文末给出的代码链接,有一个比较次数为
n
+
⌈
l
g
n
⌉
−
2
n+⌈{\rm lg}n⌉-2
n+⌈lgn⌉−2的算法实现。在这一实现中,第一轮寻找最小值过程采用了上文提到过的一种算法:将元素两两分组,分别进行比较,淘汰每一组的较大值;对剩下的元素再进行两两分组,进行同样的操作;如此迭代,直到得到最小值为止。这一算法对应的比较树高度正好为
⌈
l
g
n
⌉
⌈{\rm lg}n⌉
⌈lgn⌉。第二轮比较过程则是在比较树的辅助之下,遍历被最小元素淘汰掉的元素,从而找到第二小的元素。具体过程请参考代码,这里不赘述。
9.1-2 证明:在最坏情况下,同时找到
n
n
n个元素中最大值和最小值的比较次数的下界是
⌈
3
n
/
2
⌉
−
2
⌈3n/2⌉-2
⌈3n/2⌉−2。(提示:考虑有多少个数有成为最大值或最小值的潜在可能,然后分析一下每一次比较会如何影响这些计数。)
证
假设
n
n
n个元素分别为
a
1
、
a
2
、
…
、
a
n
a_1、a_2、…、a_n
a1、a2、…、an。在未做任何比较之前,任何一个元素都可能是最大值,也都可能是最小值。定义可能的最大值的集合为
A
m
a
x
A_{max}
Amax,可能的最小值的集合为
A
m
i
n
A_{min}
Amin。那么在未做任何比较之前,这两个集合应当各自都包含所有的
n
n
n个元素,即
A
m
a
x
=
{
a
1
、
a
2
、
…
、
a
n
}
A_{max} = \{a_1、a_2、…、a_n\}
Amax={a1、a2、…、an};
A
m
i
n
=
{
a
1
、
a
2
、
…
、
a
n
}
A_{min} = \{a_1、a_2、…、a_n\}
Amin={a1、a2、…、an}。
寻找最大最小值的流程是:每次随机取
2
2
2个元素进行比较,根据比较结果从
A
m
a
x
A_{max}
Amax中剔除不可能为最大值的元素,从
A
m
i
n
A_{min}
Amin中剔除不可能为最小值的元素;反复进行比较和剔除;最后
A
m
a
x
A_{max}
Amax和
A
m
i
n
A_{min}
Amin会各自剩下一个元素,就分别是最大值和最小值。
由于每次取
2
2
2个元素是随机的,所以这一流程理论上可以涵盖任意基于比较的寻找最大最小值的算法。
初始时
A
m
a
x
A_{max}
Amax和
A
m
i
n
A_{min}
Amin一共有
2
n
2n
2n个元素,最后一共剩下
2
2
2个元素,因此一共需要剔除
(
2
n
−
2
)
(2n−2)
(2n−2)个元素。除最后剩下的
2
2
2个元素外,其他元素各自都要被剔除
2
2
2次(从
A
m
a
x
A_{max}
Amax和
A
m
i
n
A_{min}
Amin中各剔除
1
1
1次)。最后剩下的
2
2
2个元素各自被剔除
1
1
1次(最大值要从
A
m
i
n
A_{min}
Amin中剔除
1
1
1次,最后保留在
A
m
a
x
A_{max}
Amax中;最小值要从
A
m
a
x
A_{max}
Amax中剔除
1
1
1次,最后保留在
A
m
i
n
A_{min}
Amin中)。
现在分析单次比较可以剔除几个元素。任取一对元素
(
a
i
,
a
j
)
(a_i , a_j)
(ai,aj),注意
i
≠
j
i ≠ j
i=j,分以下
3
3
3种情况讨论。
-
a
i
<
a
j
a_i < a_j
ai<aj
能够确定 a i a_i ai肯定不是最大值,可将 a i a_i ai从 A m a x A_{max} Amax中剔除;并且 a j a_j aj肯定不是最小值,可将 a j a_j aj从 A m i n A_{min} Amin中剔除。一共可以剔除 2 2 2个元素。 -
a
i
>
a
j
a_i > a_j
ai>aj
能够确定 a i a_i ai肯定不是最小值,可将 a i a_i ai从 A m i n A_{min} Amin中剔除;并且 a j a_j aj肯定不是最大值,可将 a j a_j aj从 A m a x A_{max} Amax中剔除。一共可以剔除 2 2 2个元素。 -
a
i
=
a
j
a_i = a_j
ai=aj
a i a_i ai和 a j a_j aj相等的情况比较特殊,因为 a i 、 a j a_i、a_j ai、aj既可能是最大值,也可能是最小值。比如所有元素都相等的极端情况, a i 、 a j a_i、a_j ai、aj就同时是最大值和最小值。但是仍然可以分别从 A m a x A_{max} Amax和 A m i n A_{min} Amin中各自剔除 a i 、 a j a_i、a_j ai、aj二者之一,这样的剔除动作没有改变 “ “ “ a i 、 a j a_i、a_j ai、aj既可能是最大值也可能是最小值 ” ” ”的可能性,因此不影响最终的结果。在接下来的讨论中, a i = a j a_i = a_j ai=aj的情况将统一按照 a i > a j a_i > a_j ai>aj的情况来处理,即从 A m i n A_{min} Amin中剔除 a i a_i ai,从 A m a x A_{max} Amax中剔除 a j a_j aj。
关于 a i = a j a_i = a_j ai=aj的情况,还有一点需要特别注意。举个例子,假设两个元素 a 1 a_1 a1和 a 2 a_2 a2相等,接下来对二者连续做两次比较。第一次比较取 i = 1 i = 1 i=1和 j = 2 j = 2 j=2。由于 a i = a j a_i = a_j ai=aj的情况统一按照 a i > a j a_i > a_j ai>aj来处理,因此第一次比较按照 a 1 > a 2 a_1 > a_2 a1>a2来处理,也就是从 A m i n A_{min} Amin中剔除 a 1 a_1 a1,从 A m a x A_{max} Amax中剔除 a 2 a_2 a2。第二次比较反过来,取 i = 2 i = 2 i=2和 j = 1 j = 1 j=1。那么第二次比较就按照 a 2 > a 1 a_2 > a_1 a2>a1来处理,也就是从 A m i n A_{min} Amin中剔除 a 2 a_2 a2,从 A m a x A_{max} Amax中剔除 a 1 a_1 a1。于是在经过这两次比较之后, a 1 、 a 2 a_1、a_2 a1、a2在 A m i n A_{min} Amin和 A m a x A_{max} Amax中全部被剔除。根据上文分析,在 a 1 = a 2 a_1 = a_2 a1=a2的情况下, a 1 、 a 2 a_1、a_2 a1、a2既可能是最大值,也可能是最小值。因此至少要在 A m i n A_{min} Amin和 A m a x A_{max} Amax中各自保留 a 1 、 a 2 a_1、a_2 a1、a2二者之一,这样才能保留 “ a i 、 a j “a_i、a_j “ai、aj既可能是最大值也可能是最小值 ” ” ”的可能性。而不能将 a 1 、 a 2 a_1、a_2 a1、a2从 A m i n A_{min} Amin和 A m a x A_{max} Amax中全部剔除。 “ “ “全部剔除 ” ” ”的发生,是 “ “ “对相等的两个元素先后以不同的次序做了两次比较 ” ” ”以及 “ a i = a j “a_i = a_j “ai=aj统一按照 a i > a j a_i > a_j ai>aj来处理 ” ” ”这两个原因共同导致的。于是可以规定:在选取两个元素 ( a i , a j ) (a_i , a_j) (ai,aj)做比较的时候,必须满足 i < j i < j i<j。这样一来,即使对相等的两个元素进行了多次比较,它们参与比较的次序也是一致的,可以避免 “ “ “全部剔除 ” ” ”的发生。
根据以上分析,如果不考虑重复剔除,单次比较都可以剔除 2 2 2个元素。但是每次参与比较的元素,有可能已经被剔除过 1 1 1次,甚至已从 A m i n A_{min} Amin和 A m a x A_{max} Amax全部被剔除。这种情况仍然是按照上文描述的方法处理,只不过可能会出现重复剔除一个元素的情况。所以接下来分析,如果考虑重复剔除(也就是说重复剔除不计入总的剔除次数),那么单次比较可以剔除几个元素。根据比较前两个元素的被剔除状态,可以细分为 16 16 16种情况,这 16 16 16种情况可以归类为 5 5 5种情况。 - 两个元素都未被剔除过
- 两个元素一共被剔除过
1
1
1次
- 两个元素一共被剔除过
2
2
2次
- 两个元素一共被剔除过
3
3
3次
- 两个元素一共被剔除过
4
4
4次
可以看到,除了case-1一定可以剔除
2
2
2个元素之外,其他case在最坏情况下都只能剔除
1
1
1个或
0
0
0个元素。如果要设计一个寻找最大最小值的算法,并且希望最坏情况下的比较次数尽可能少,那么要使得剔除
2
2
2个元素的比较尽可能多,并且要尽量避免剔除
0
0
0个元素的比较。
由于case-1要求两个元素都未被剔除过,因此case-1的比较最多只能进行
⌊
n
/
2
⌋
⌊n/2⌋
⌊n/2⌋次(
n
n
n个元素两两分组)。又由于只有case-1能保证最坏情况下一定可以剔除
2
2
2个元素,因此最坏情况下剔除
2
2
2个元素的比较最多也只能进行
⌊
n
/
2
⌋
⌊n/2⌋
⌊n/2⌋次,相应的被剔除的元素是
2
⌊
n
/
2
⌋
2⌊n/2⌋
2⌊n/2⌋个。上文提到,一共需要剔除
(
2
n
−
2
)
(2n−2)
(2n−2)个元素,于是还剩下
2
n
−
2
−
2
⌊
n
/
2
⌋
2n−2−2⌊n/2⌋
2n−2−2⌊n/2⌋个元素需要剔除。现在case-1比较已经不可能再用了,其他case在最坏情况下最多也只能剔除
1
1
1个元素,因此在最坏情况下至少还需要进行
2
n
−
2
−
2
⌊
n
/
2
⌋
2n−2−2⌊n/2⌋
2n−2−2⌊n/2⌋次比较。加上case-1的比较次数 ,可以得到最坏情况下总的比较次数为
(
2
n
−
2
−
2
⌊
n
/
2
⌋
)
+
⌊
n
/
2
⌋
=
2
n
−
2
−
⌊
n
/
2
⌋
=
⌈
3
n
/
2
⌉
−
2
(2n-2-2⌊n/2⌋)+⌊n/2⌋=2n-2-⌊n/2⌋=⌈3n/2⌉-2
(2n−2−2⌊n/2⌋)+⌊n/2⌋=2n−2−⌊n/2⌋=⌈3n/2⌉−2
这就是我们要证明的最坏情况下比较次数的下界。
笔者用代码实现了通过反复“随机比较-剔除”来寻找最大最小值的流程,并做了大量的测试,以验证这种流程的正确性。
代码链接
https://github.com/yangtzhou2012/Introduction_to_Algorithms_3rd/tree/master/Chapter09/Section_9.1