ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

递归与分治:如何用3n/2-2次比较同时求最大最小值

递归与分治:如何用3n/2-2次比较同时求最大最小值 这学期在啃《算法设计与分析》教材,刷到第2章递归与分治的习题时,2.2这道题特别有代表性。原题大意是:给定n个整数,设计一个分治算法,同时找出数组里的最大元素和最小元素,并分析算法在最坏情况下的比较次数。这道题肉眼看着不难,其实把分治的三个核心动作——分解、递归、合并——全都压进去了。我们班不少同学第一反应就是写个循环,一遍遍历、一遍更新max和min,也能交差。可题目后面那个“比较次数尽量少”的要求一出来,事情就没那么简单了。这篇文章就按我实际做题、调试、总结的过程,把这道题的完整解法、递推推导、正确性证明和一些踩坑记录整理出来,给正在啃算法设计与分析习题的同学一个可以照着落地的参考。这道题适合谁看?三类人:期中期末考前突击的本科生,考研复试前补算法底子的同学,还有刚接触分治思想、想找一道小而完整例题练手的程序员。你不需要掌握太多前置知识,能看懂递归就行。读完你能带走三样东西:一个可以直接运行的求解函数、一套递推方程的求解方法,以及几个我自己调试时踩进去又爬出来的坑。1. 题目背景与解题思路1.1 原题描述与核心考点教材版本不同,习题2.2的具体文字会有点差异。我按自己遇到的版本展开:假设数组A[0..n-1]中存放n个整数,可能存在重复值,要求用分治策略同时求出最大值和最小值,并分析比较操作的次数。为什么题目强调“同时求”?因为如果先求最大值再求最小值,明显要两趟扫描,比较次数大约是2n,浪费得比较多。而同时维护max和min的普通单循环,最坏情况也要2(n-1)次比较。所以这道题真正想考的不是“能不能解”,而是“能不能用更少的信息冗余”。这里有一个很容易被忽视的考点:分治算法重在“合并”环节。很多同学把数组一分为二,递归求出左右两半的max和min,然后直接返回,就以为做完了。其实合并时只需要四次比较:左max和右max比一次,左min和右min比一次。注意,不需要把左右两个子数组里的所有元素再做交叉比较,这是整个算法省比较次数的关键。把这一点想透,下面所有推导就顺了。1.2 为什么用分治而不是普通遍历我先把两个方案摆在一起说说。普通单循环的做法是:初始化maxminA[0],然后从i1到n-1依次判断。如果A[i]比max大,更新max;否则再判断是否比min小,更新min。这个写法在数据顺序随机时,平均比较次数大约n n/2,最坏情况则是2(n-1)。老实说,这个复杂度在很多场景下已经可以接受,代码短、易读、不容易出错。但习题特意卡比较次数,目的就是引导你思考:能不能让每一次比较产生更多“有效信息”。分治的策略是先把数组切成长度大致相等的两半,递归求出左半的最大值和最小值、右半的最大值和最小值,然后把左右两个“局部结果”合并成全局结果。合并时,左半的最大值只需要和右半的最大值比,胜者就是全局最大;最小值同理。换句话说,比较结果在一次合并里被复用了,不会出现“某个元素被反复拿来和max、min分别比较”的情况。这种“信息复用”的思想,在后面学归并排序、最大子数组和、锦标赛排序时还会反复出现,习题2.2算是第一次正式把它推到你面前。2. 算法设计与递推建模2.1 分治策略的三段式拆解分治算法基本是固定套路:分解、递归、合并。这道题的拆解如下。分解:取中点mid(leftright)/2,把区间[left, right]分成[left, mid]和[mid1, right]两个子区间。这里建议左半和右半都包含实际元素,不要留空区间。递归:分别递归求解左右两个子区间的最大值和最小值。递归出口是区间里只剩一个元素时,此时最大值和最小值都是这个元素本身;区间里只剩两个元素时,做一次比较就能同时确定max和min。合并:假设左半返回(lmax, lmin),右半返回(rmax, rmin),那么整个区间的最大值就是max(lmax, rmax),最小值就是min(lmin, rmin)。这里恰好两次比较。你可能会问:如果左右两个子区间的元素个数不一样,比如总长度是奇数,上面的划分还成立吗?成立。中点midn/2向下取整,左半可能比右半多一个元素,但递归出口照样能处理。这种“不完全均分”不会影响复杂度的数量级,只是让精确比较次数在常数级上略有浮动,这一点后面详细说。2.2 递归代码与每一步的意图我给出可以直接跑的Python实现。之所以选Python,是因为它写起来接近伪代码,方便验证思路;不管是把它翻译成C语言还是Java,逻辑都是同一套。def find_max_min(arr, left, right): # 返回区间 [left, right] 内的 (最大值, 最小值) if left right: return arr[left], arr[left] if right - left 1: if arr[left] arr[right]: return arr[right], arr[left] else: return arr[left], arr[right] mid (left right) // 2 lmax, lmin find_max_min(arr, left, mid) rmax, rmin find_max_min(arr, mid 1, right) # 合并:两次比较 return max(lmax, rmax), min(lmin, rmin) if __name__ __main__: import random data [random.randint(0, 999) for _ in range(100)] mx, mn find_max_min(data, 0, len(data) - 1) print(mx, mn, max(data), min(data))第一个if处理区间只有一个元素的场景。此时无需比较,直接把该元素同时作为最大值和最小值返回。很多同学容易漏掉这个出口,导致无限递归。第二个if处理区间恰好有两个元素的场景。一次比较同时确定大小关系,大的作为max返回,小的作为min返回。为什么单独处理这个场景?因为它能显著减少递归深度,也让递推方程有一个清晰的初始条件。如果把两个元素也继续二分,各变成一个元素之后再合并,结果一样,但递归层数会多一层,常数开销更大。合并部分的两次比较是整个算法的点睛之笔。第一次比较两个局部max,第二次比较两个局部min。注意我返回的是元组(最大值, 最小值),顺序别搞反。我一开始写的时候顺手返回(min, max),结果外层合并时一头雾水,排查了半天才发现是返回顺序问题。这个问题看起来低级,实际调试时真的容易忽略。3. 复杂度分析与正确性证明3.1 递推方程的建立与逐层展开复杂度分析是算法设计与分析这门课的重头戏。设T(n)表示n个元素时最多需要的比较次数。基准情形:T(1)0,T(2)1。当n2时,一次分解平均分成两半,分别处理n/2规模的子问题,然后合并时额外做2次比较。因此递推方程为:T(n) 2 * T(n/2) 2这个方程很简单,可以直接展开求解。假设n是2的幂,令klog2 n。逐层展开: T(n) 2T(n/2) 2 4T(n/4) 2 4 8T(n/8) 2 4 8看到规律了吗?第j层,子问题规模是n/(2^j),该层累加的合并开销是2 4 ... 2^j。当子问题规模缩减为2时停止,也就是n/(2^k) 2,此时k log2 n - 1。代入展开式: T(n) 2^k * T(2) (2^(k1) - 2) 这里T(2)1,所以T(n) 2^k 2^(k1) - 2 3 * 2^k - 2 3n/2 - 2。这个结果就是分治法的核心收益:比较次数从普通遍历的2(n-1)降到了1.5n左右,减少约25%。在算法竞赛或大规模数据场景下,这个常数因子确实能带来肉眼可见的提升。3.2 比较次数为什么是3n/2-2,而不是其他值我见过不少同学套公式得到3n/2-2,但说不清“每次合并消耗2次比较”到底是怎么累计的。换个角度看:整个递归树里,每个叶子节点对应一个原始元素,每个内部节点对应一次合并。内部节点的总数是n-1(二叉树性质)。除最底层外,每个内部节点合并时都产生2次比较。问题是叶子上一层的内部节点处理的是两个元素的区间,这里只做了1次比较而不是2次。这个差异最终体现在常数项上。如果每个内部节点都是2次比较,总次数就是2(n-1)。但因为最底层有n/2个内部节点的合并只消耗1次比较,所以总数变成2(n-1) - n/2 3n/2 - 2。这样解释,直观得多,也更容易记住。当n不是2的幂时,递推展开不会这么整齐,但结论仍然成立,只是常数项略有变化。精确表达式通常写成ceil(3n/2) - 2,也有人写成floor(3n/2) - 1,不同教材的写法有差异,本质都是渐进意义上的1.5n。做题时如果老师比较较真,建议在答案里说明“假设n是2的幂”这个前提,再给出渐进结果O(n)。下面这个表格列出不同n规模下,普通遍历和分治法的比较次数对比,方便直观感受差距。n普通遍历(最坏)分治法(T(n))节省比例46433.3%8141028.6%16302226.7%32624625.8%10242046153425.0%可以看到,随着n增大,节省比例稳定在25%附近。这就是“常数因子优化”的实际意义。3.3 正确性证明的完整思路作业里光写代码和复杂度还不够,通常还要有正确性证明。这道题用数学归纳法可以讲得很清楚。基准情形:当区间长度为1时,算法直接返回该元素作为max和min,显然正确;区间长度为2时,通过一次比较也能得到正确的max和min,基准成立。归纳假设:假设对于所有长度小于k的区间,算法都能正确返回该区间的最大值和最小值。现在考虑长度为k的区间(设k2)。因为算法将区间划分为左右两个子区间,每个子区间长度都小于k,根据归纳假设,左半返回(lmax, lmin)是左半真实的最大值和最小值,右半返回(rmax, rmin)是右半真实的最大值和最小值。整个区间的最大值必然是左半最大值和右半最大值中较大的那个,由于max(lmax, rmax)恰好取了两者中的较大者,所以这个结果正确。最小值同理。合并后返回的元组必然正确。归纳完成。这种证明方法可以套用到绝大多数分治算法上:先证小规模基准,再假设小问题正确,最后验证组合操作正确。面试和考试里很爱考这类证明,建议大家练熟。3.4 决策树角度:为什么很难再优化为了让答案更有深度,可以提一下最优性。任何基于比较的算法,要在n个元素中同时确定最大和最小,至少需要ceil(3n/2) - 2次比较。这个结论可以用决策树证明,也可以用对手策略(adversary argument)说明。大致思路是:每个元素必须输过一次才会被淘汰出“最大值候选”,也必须赢过一次才会被淘汰出“最小值候选”。除了全局最大值永远不需要输、全局最小值永远不需要赢之外,其他n-2个元素都至少各输一次、赢一次,因此至少需要(n-1)(n-1)-?次比较,最终下界就是3n/2量级。把这个下界写进习题答案里,通常能拿到额外加分,因为它说明你不仅会套分治模板,还知道这个算法几乎踩在最优解上,没法再做常数级的再优化。当然,考试时间紧的话,可以只写一句“该分治算法已达到比较最优”即可。4. 实操中的坑与调试心得4.1 边界条件最容易漏:单元素和两元素区间我一开始写递归函数时,只写了一个出口:if left right就返回。然后,n为偶数时能正常运行,n为奇数时却在递归深处出现left right的区间,或者进入死循环。问题出在两元素区间没有特判,mid1可能越界,或者会一直递归到空区间。后来我把两元素区间单独拎出来处理,一切顺畅了。这个特判不是可选项,而是必须项。很多分治题目的坑都在这里:边界条件没想清楚就写代码,往往小数据能过,大数据直接栈溢出或越界崩溃。建议拿到题目先手推几个输入:单元素、两元素、三元素、四元素,把递归树画出来,再动笔写代码。4.2 返回顺序错误引发的“灵异现象”用元组返回(最大值, 最小值)时,最容易犯的错误是把return写成(min, max)。本地测试时如果只打印最后一个return结果,很可能会被误导,因为对于单个区间,min和max碰巧还能对上;一旦递归到上层合并,顺序错了就会出现“最大值比最小值还小”的诡异输出。调试技巧:在合并前后分别打印(lmax, lmin, rmax, rmin, max(...), min(...)),一眼就能看出哪一步出错。这种打印调试法虽然土,但在递归函数里比断点调试还高效,因为你能直接看到每一层传上来的数据变化。我在实际调试时就靠这一招定位问题,前后花了不到十分钟。4.3 递归深度与性能实测Python默认递归深度是1000,如果直接对n10万的数据跑这个递归函数,会立刻报RecursionError: maximum recursion depth exceeded。这不是算法错了,而是Python递归开销大、深度受限。实际学习时,建议把n控制在几千以内做正确性验证就好;如果要跑大数据,有两种思路。第一种是把递归改成显式栈的迭代写法,利用栈模拟“分治”的压栈和弹栈过程。这种做法能完全规避递归深度限制,但代码会复杂不少,不太适合习题场景。第二种是只在作业里证明复杂度和正确性,再用Python验证小规模数据。真要追求大数据性能,换C语言直接跑递归,深度到几百万通常也没问题;或者用并行分治、GPU规约那种工程化方案。工程化不是这道题的重点,但可以打开思路。下面是一段简单的比较次数统计代码,供作业参考。用闭包或全局变量计数都行。comp 0 def find_max_min_count(arr, left, right): global comp if left right: return arr[left], arr[left] if right - left 1: comp 1 if arr[left] arr[right]: return arr[right], arr[left] else: return arr[left], arr[right] mid (left right) // 2 lmax, lmin find_max_min_count(arr, left, mid) rmax, rmin find_max_min_count(arr, mid 1, right) comp 2 return max(lmax, rmax), min(lmin, rmin) data list(range(1000)) mx, mn find_max_min_count(data, 0, len(data) - 1) print(mx, mn, comp)这段代码跑下来,comp的值正好是1498,也就是3*1000/2 - 2。看到这个数字的一瞬间,你对递推公式的理解会非常实。强烈建议自己动手验证一遍。4.4 边界数据与随机数据的测试清单我在作业里整理了一份测试清单,以后做任何分治算法都可以照抄:空数组(直接抛异常或返回None)、单元素、两个递增元素、两个递减元素、全相同元素、随机大数组、已经排序的数组。每一类测试都要跑一遍,保证没有隐藏bug。尤其注意“全相同元素”的情况,例如所有元素都是7。此时无论怎么比较,max和min都应该是7。如果代码里用了严格的“”而不是“”,或者用了不太稳的排序思路,很容易在相等元素上出问题。我测试时发现,正确实现完全支持重复元素,不需要额外去重,这一点也是面试官经常追问的细节。5. 延伸思考:从一道题到一类题5.1 求第二大元素的锦标赛法做完习题2.2,不妨顺手想一个进阶问题:如何只用一个分治(或者叫锦标赛)结构,找出一组数中的第二大元素?常规做法是排序后取倒数第二个,时间复杂度O(n log n),比较次数多很多。更优的做法是用锦标赛树:两两比较,胜者晋级,先找到最大元素;第二大的元素必然在“曾经输给过最大值”的那些元素里产生,只需要从这些败者里再找最大值即可。整个过程的比较次数是(n - 1) ceil(log2 n) - 1,约等于n log n,比排序快得多。这个思路和习题2.2一样,核心都是“复用比较结果”。学有余力的同学可以做这道延伸题,做完之后你对分治和比较下界的理解会上一个台阶。5.2 与归并排序、最大子数组问题的同构性分治三板斧在教材第2章后面几节还会反复出现。归并排序的合并阶段要比较两个有序子数组的元素并按序输出;最大子数组和的合并阶段要同时考虑“跨中点子数组”;习题2.2的合并阶段则只做两次比较。三者共享同一个框架:分解、递归、合并。如果你能把习题2.2的递归树画熟,再去看归并排序的递归树,会发现只是每层合并动作不同,树的形态几乎一样。这也是为什么很多老师偏爱这道题:它小,但五脏俱全。把它的复杂度推导过程吃透,后面学主定理(Master Theorem)时会轻松很多,因为主定理的本质就是“把递归树各层代价加总”。5.3 工程视角:局部最优如何汇总为全局最优最后说一点工程上的联想。分布式系统里经常遇到类似问题:每个节点算出一批数据的局部统计量,主节点再合并这些局部统计量得到全局结果。习题2.2就是最简单的一对一原型。你在单机递归里做的“合并动作”,换成MapReduce框架里的“Reduce阶段”,思路一模一样。所以别看这道题简单,它背后串起的知识网络,能一直连到分布式计算和大数据排序,价值远比表面上一道课后题大得多。我个人做这道题最大的体会是:不要因为题目看起来简单就直接写循环。先停下来,把比较次数的浪费点想清楚,再决定要不要用分治。还有,递归题一定要先画递归树再写代码,纸上推演五分钟,能省下调试两小时。以后遇到任何分治题,我都会先问自己三个问题:基准情形是什么?递归怎么缩小规模?合并时需要额外比较几次?想清楚这三件事,代码基本不会出大错。
返回列表