简介
我们在讨论求一个数组中最小的元素时,相对来说很简单,也很容易找到一个高效率的办法。在一些特殊的情况下甚至还可能有更加优化的方法。如果再把这个问题再深入一点。比如说,我们要求第二小的元素,那么有没有足够高效率的方法呢?
问题描述
在我们思考这个问题的时候,书上的有一个问题就是要求我们来验证在n个元素里,在最坏情况下找到第二小的元素需要经过n + lgn - 2次的比较。那么,我们能不能找到这么一种办法呢?先理一下大致的思路。
解决思路
遍历两次
这种办法相对比较简单直接,就是第一次遍历找到最小的元素。然后在剩下的集合里找最小的那个。这样就找到了第二小的。这种方法在最坏情况下的具体比较次数是n - 1 + n - 2 = 2n -3。很显然,虽然这是一个可以达到O(n)级别的方法。但是如果以问题中n + lgn - 2的标准来衡量的话,还是不够的。这种方法的实现代码如下:
public static int findSec(int[] a, int size) { int smallest = 0, second = 1; for(int i = 1; i < size; i++) { if(a[i] < a[smallest]) smallest = i; } swap(a, 0, smallest); for(int j = 2; j < size; j++) { if(a[j] < a[second]) second = j; } return a[second]; } public static void swap(int[] a, int i, int j) { int temp = a[i]; a[i] = a[j]; a[j] = temp; }
前面的办法是用了两个循环来遍历数组,我们也可以通过一次遍历来实现:
public static int findSecond(int[] a, int size) { int first = 0, second = 0; if(size > 1) { if(a[0] < a[1]) second = 1; else first = 1; } for(int i = 2; i < size; i++) { if(a[i] < a[second]) { if(a[i] < a[first]) { second = first; first = i; } else second = i; } } return a[second]; }
虽然我们能实现达到O(n) 这个量级的方法,但是和前面的要求来说,还是没达到。很显然,第一种办法没法解决。
从堆排序里借鉴的思路
我们前面如果看过堆排序就知道它是一个数组,但是用一种完全二叉树的角度来操作它。比如说我们有一个数组[16, 14, 10, 8, 7, 9, 3, 2, 4, 1]。那么,按照堆排序的思路,它对应的二叉树关系如下图:
这部分的特性对于我们构造调整的结构有什么帮助呢?毕竟这里是在一个已经有的数组中间来构造。但是这里有一个比较有意思的性质,对于我们后面的分析有帮助。假设有一棵完全的二叉树,它的叶结点层也是完全覆盖的,那么这一层的叶结点数目必然为2**k(2的k次方)。从叶结点往上的结点每次都为它的一半,直到根结点。那么,从根结点到叶结点上面一层的所有结点数目正好构成一个等比数列,他们的总数为2**k - 1。也就是说比叶结点数目小1。这给了我们一个提示。就是假设我们有一组元素作为叶结点,然后在它的基础上来构造完全二叉树的上层部分,比如说构造成一个最小堆,那么这部分的花费如果有办法控制的话,有希望实现通过n-1次的比较得到一个这样的最小堆。那么我们看如下的一种构造树的方式:
在这里,假设我们有数组[1, 2, 3, 4] 作为叶结点一层。然后每次两个两个的比较,将较小的那个取出来作为上面一层的结点。那么,在一个理想的情况下,我们假设叶结点总共有2**k个。这次比较生成它上一层的叶结点需要的比较次数为2**(k-1),也就是说为叶结点数字的一半。再往上一层构造的比较则在原来的基础上再减半。和前面的比较类似,前面所有的比较次数正好构造成一个等比数列,从1到2**(k-1),那么他们的和就刚好为2**k - 1。经过这一番推导,我们发现通过这么构造一番的方式是可以实现在n - 1次的比较来构造出一个最小堆的。而这个最小堆的构造方式和堆排序的情况不一样。更确切的来说,这个堆有一个明确的说法。它就是胜者树。
后面我们会详细分析胜者树。那么好吧,按照我们原来的要求,是要求实现最后得到第二小的元素,而且比较次数为n + lgn - 2。我们这里只是通过n - 1次比较得到了最小的元素。怎么来找到这个第二小的元素呢?这里又利用到了胜者树一个有意思的地方。我们看前面的图。最终站在顶上的元素是最小的元素。他们所有的比较过程相当于是一个竞赛的淘汰过程。所有参加竞赛的队伍捉对厮杀,然后淘汰到只剩下最后一个。该到哪里找这个第二的呢?这个和具体的比赛不一样。具体的比赛肯定是决赛里头赢的那个第一,输的那个第二。这里头就好像所有pk的怪物都给设定了武力值,武力高的那个赢武力低的那个。可是如果一开始那个武力第二的就和第一的分到一起呢?那么这个老二就很悲催的第一轮被灭了。既然是老二嘛,肯定火力还是比较威猛,也可能一开始没碰到呢?那么他们也可能在中间的某一个阶段碰到,或者在最后的决赛阶段给碰上。总之,最后的冠军已经定下来的情况下,它必然要和这么个老大碰面的。换一个角度来说,我们可以说,它必然会和这么个老大会一面,成为老大夺冠路上的一个冤魂。这样,如果我们要找这个第二小的元素,只要找这个最小元素它曾经比较过的对象就知道了。而我们得到的胜者树可是一路记录了最小元素从小组赛一直pk到决赛的记录的。我们从前面的图中可以看到,只要顺着根结点向下,只要沿着和根结点值一样的结点遍历,那么对应的另外一个结点就是和它pk过的对手。而那个第二小的元素就在它所有pk过的对手里面。
我们看到,我们构造的树是一棵完全二叉树,那么树的高度为lgn。从根结点开始向下走到叶结点进行比较,实际的比较次数为lgn - 1。到这里,我们的思路就完全理清楚了。原来,找这么个老二就是这么一个过程:1. 构造一棵胜者树,这样就可以找到最小的元素。 2. 从根结点开始向下比较每个和它值不同的子结点,然后找到第二小的元素。他们总共的比较次数为n + lgn - 2。
胜者树
前面是通过一个简单的讨论引出了胜者树。我们来看一个更一般化的胜者树的样子:
胜者树一个更正式的定义是通过锦标赛思想来建立的树,它的每个非终端结点存储的是左右子结点中的优胜者。胜者树实质上是对n个记录的关键字进行两两比较,得到个优胜者,作为第一步比较的结果保留下来。然后这个较小(大)者之间再进行两两比较,…,如此重复,直到选择出最小关键字的记录为止。现在,我们再来看看具体胜者树的构造。
构造
我们前面构思的一个思路是一种比较理想的状态,要求我们的叶结点个数也就是元素的个数为2**k,实际中元素的个数很可能不是正好这么多。那么,按照我们前面堆排序的构造和分析,我们必须要构造这么一个完全的二叉树。我们可以采用两种方式,一种就是将原来的元素补齐,补到刚好大于它数字的2**k个。然后再来构造。还有一种就是我不需要叶结点有2**k个,只要保证从叶结点往上的层正好符合2**k个的标准也可以。就像堆排序构造的堆一样,叶结点不一定是满的,但是上面的结点肯定都是满的。相对来说,第二种方式需要用到的额外空间少一点。我们就以第二种方法为例来讨论。
假定有n个元素,我们总共这棵树需要多少个结点呢?从前面的分析可以得到,需要的元素数目为2**k - 1 + n个。其中2**k为假设叶结点是满的,它所有叶结点的数目。为什么是这么多个呢?我们这里就不再详细推导,只要记住一点,对于一个叶结点是满的二叉树,假设叶结点数目为2**k个,那么它上面其他所有结点的总和为2**k -1,也就是说比满的叶结点数目少一个。
还有一个问题就是,我们是从叶结点向根结点来推导,这n个元素应该放在这个新数组的最后。这部分初始化的代码可以实现如下:
public void buildTree(int[] t) { int length; for(length = 2; length < t.length; length *= 2); treeLength = length - 1 + t.length; a = new int[treeLength]; for(int i = 0; i < t.length; i++) a[length - 1 + i] = t[i]; for(int j = 0; j < length -1; j++) a[j] = Integer.MAX_VALUE; build(length - 1, treeLength - 1); }
因为这里要比较数值的大小,我们从前面的图里可以看到,有一些上层空缺的位置需要补对应的数值来满足整个满二叉树的结构又不能破坏胜者树的结果,所以我们首先将非n个元素之前的结点值都置成Integer.MAX_VALUE。这里的build方法则是构造的详细步骤,其代码实现如下:
public void build(int start, int end) { while(start != end) { for(int k = start; k <= end; k += 2) { if(k + 1 <= end) a[parent(k)] = a[k] > a[k + 1] ? a[k + 1] : a[k]; else a[parent(k)] = a[k]; } start = parent(start); end = parent(end); } } public int parent(int i) { if(i % 2 == 0) return i / 2 - 1; else return i / 2; }
在build方法里,每次我们取这一层结点的最左边一个结点和最右边的结点,在遍历的过程中两两比较,将较小的那个设为相邻两个结点的父结点。一直遍历到根结点。
查找
查找的基本过程如下:1. 从根结点开始,看它的左右子结点,如果一个结点和根结点值相同,则取另外一个结点的值作为第二小结点的比较值。2. 进入和根结点值相同的这个子结点,重复步骤1。这个整体过程虽然表述起来比较简单,其实现还是有不少诡异的地方:
public int findSecondMin() { int secondMin = Integer.MAX_VALUE; int i = 0; while(left(i) < treeLength || right(i) < treeLength) { if(left(i) < treeLength && a[left(i)] == a[0]) { if(right(i) < treeLength && a[right(i)] < secondMin) { secondMin = a[right(i)]; } i = left(i); } else if(right(i) < treeLength && a[right(i)] == a[0]) { if(left(i) < treeLength && a[left(i)] < secondMin) { secondMin = a[left(i)]; } i = right(i); } } return secondMin; } public int left(int i) { return 2 * i + 1; } public int right(int i) { return 2 * i + 2; }
这部分的代码就不再详细解释了。有了前面的分析应该也很容易理解。
调整
这一部分是后面新增加的内容。我们在某些情况下,纯粹出于对胜者树的性质的一个维护。如果我们修改了叶结点的一个值,就有可能引起一个连锁的变化。我们看如下的一个示例:
这是按照我们前面的讨论构造的一棵胜者树。如果我们将叶结点1的值修改为4,那么,为了保证树的性质,我们需要做一些如下的修改:
这个过程在于,在原来结点被修改的地方,我们需要重新进行比较和调整,一直向上回溯到根结点。每次需要和它的兄弟结点进行比较。
具体实现的代码如下:
public void adjust(int i, int val) { a[i] = val; while(parent(i) >= 0) { if(i % 2 == 0 && i - 1 >= 0) { a[parent(i)] = a[i] > a[i - 1] ? a[i - 1] : a[i]; } else if(i % 2 == 1 && i + 1 <= treeLength) { a[parent(i)] = a[i] > a[i + 1] ? a[i + 1] : a[i]; } i = parent(i); } }
这里有一个判断i % 2的地方,是用于判断当前结点的下标值是否为奇数或者偶数。因为胜者树本身的满二叉树属性。所有在左子结点的元素下标值为奇数,右结点的元素下标值为偶数。
总结
这里是根据一个求第二小的数进行的推导。为了找到一个可以更加优化的思路居然要折腾这么多。可见,这些问题背后的思想还是很丰富的。除了胜者树,其实还有类似的数据结构败者树。他们的思想也很类似。在一些更通用的问题比如求最小的若干个数时,还有一些其他的方法和思路,不过和堆也有很强的联系。胜者树,败者树他们和堆排序,求最小(大)的k个元素的问题以及多路归并排序等外排序的算法也有很强的关系。在后续的一些文章中也会针对这几个点八卦八卦。
相关推荐
第二章“逻辑门电路”是数字电路的基础。逻辑门包括与门、或门、非门、与非门、或非门等,它们通过简单的布尔逻辑运算实现了数字信号的处理。这些基本单元电路可以组合出各种复杂的功能,是构建其他数字电路的基础。...
习题4则讨论了信号的对称性,并引出了实序列分解为偶对称序列和奇对称序列的理论。偶对称序列是指关于原点对称的序列,而奇对称序列则是关于原点反对称的序列。在信号处理中,这种分解方法可以简化某些特定操作。 ...
此外,第二题可以让学生尝试自己编号后再进行小组讨论,分享各自的操作过程,培养他们的合作和表达能力。 "身边的数学"部分,将数学知识与生活实际相结合,让学生在家庭环境中实践排序,如按年龄大小排列家庭成员的...
在探究过程中,教师适时答疑解惑,解释了地址码因行政区划可能变化,出生日期码中0的作用是避免误会和保持编码长度一致,顺序码用于区分同一天出生的人,性别信息隐藏在倒数第二个数字中,而校验码则确保了号码的...
7. **教学流程**:课堂导入阶段通过“119”这个数字引出编码的话题,接着引导学生自学邮政编码的规律,教师再进行讲解和演示。然后,通过“小小侦探员”的游戏,引入身份证号码的探究,通过活动让学生实践解码身份证...
综上所述,新西师大版六年级上册数学的第二课时练习二教学课件,通过各种实际问题,全面地涵盖了数学的多个知识点,包括长度、面积、质量、数量、速度、比例、资金管理、生物学统计、地理知识以及人口学等,旨在提升...
数对中,第一个数字代表列,第二个数字代表行。第三,通过参与实践活动,让学生体验到数形结合的数学思想,发展他们的空间观念,同时认识到数学与生活的紧密联系,体验数学在实际问题中的应用。 教学过程中,教师会...
教学过程中,通过创设情境——海龟年龄的比较,激发学生的学习兴趣,引导他们观察、分析和比较数字,培养他们的逻辑思维和分析归纳能力。教学的重点是教授学生如何比较不同位数和相同位数的数的大小。 教学过程中,...
第二,初步教导孩子们“0”在自然数列中的位置,即0比1小。这样的教育旨在建立孩子对数字序列的基本认知。 在教学准备阶段,教师准备了10个篮子,分别放入不同数量的玩具,以此来展示数字1到9。而第10个篮子空着,...
《比大小》是一节数学新授课,主要针对一年级上册的学生,内容涵盖人教版小学数学第17页的例题2及相关练习,旨在教授学生比较两个数大小的基本方法,包括认识并理解“>”、“<”和“=”这三个关系符号的含义、读法和...
第二关引入了数字1、2和3,让学生找出由这三个数字中的任意两个组成的两位数中最小的一个。通过这个活动,学生需要学习有序思考,找出所有可能的组合,并确定最小值为12。 第三关涉及组合问题,讨论了在三个人之间...
在第二页中,强调了当什么都没有时,我们使用“0”来表示。这有助于孩子们理解“0”作为基数的概念。 第三页提到了直尺的使用,这是介绍长度测量的初步概念。“0”在直尺上标志着起始点,而数的大小则与距离“0”的...
当孩子们发现无法分辨谁是第一时,引导他们思考并提出解决办法,从而引出车牌号码的概念。 2. 展示车牌样本,让孩子们观察车牌的组成,了解车牌上的文字(如“鲁”代表山东,“C”代表淄博)和数字。 3. 使用两个...
2. 第二重悖论:信息化、数字化虽然对传统文化构成威胁,但也为传统文化的传播和重焕生机提供了新的可能。例如,3D全景声京剧电影的成功展示了传统文化利用数字化技术走向世界的潜力。 文章强调,保护和传承传统...
在课件的开始部分,展示了如何用数字①、②、③进行简单的排列,例如第2页和第3页中,分别列出这些数字的不同排列方式,引导学生思考数字排列的多样性。接着,课件引入了一个更具体的例子,即用数字3、6、8、2、4、9...
这样的标记方式有助于孩子们理解数对的逻辑:第一个数字代表列,第二个数字代表行。通过这种方式,可以清晰地区分和描述二维空间内的每个位置。 在PPT的第6页,提到了用(x, y)的形式表示未知点的位置,这里的x和y是...
第一个数字表示列,第二个数字表示行,例如(5,3)表示第五列第三行。 3. **教学目标**: - 知识与技能目标:学生应能在具体情境中探索确定位置的方法,用数对来描述物体的位置,并具备一定的空间观念和解决问题...
第二课时的重点在于进一步学习人民币单位间的换算和简单的加法计算,通过例题和练习,强化学生的计算能力,培养他们的合作精神。 在教学过程中,通过复习和新授环节,如例5、例6和例7,逐步引导学生掌握小面值...
标题《Data Compression》第二章的知识点主要涵盖了数据压缩的基础知识,这一章节可能侧重于对基本变长编码(VLC,Variable Length Coding)的介绍和讲解。VLC是数据压缩技术中的一个重要概念,通过不同的符号或数值...