有 个元素的无序数组,用顺序查找找某个值(假定值一定存在),平均要比较多少次?
规则:顺序查找平均比较次数是 。
考点:顺序查找的比较次数口径。
解析:目标值等概率出现在任一位置,第 个位置要比较 次(1 个元素就找到 = 比较 1 次,第 个 = 比较 次),平均为 。
排除法:「约 」少了那半次,只有 才准确;「约 」是最坏情况不是平均;「约 」是二分查找的比较次数,顺序查找最坏要 次。
顺序查找找 x,常见写法每轮循环都要检查 i <= n(防止下标越界)。有人把 x 先放到 a[n] 这个哨兵位,然后循环里只判断 a[i] != x,不再判断下标。请问为什么这样能省掉越界判断?
01int seqFind(int a[], int n, int x) { 02 a[n] = x; 03 int i = 0; 04 while (a[i] != x) i++; 05 return i; 06}
规则:哨兵写法把查找目标放到数组末尾「哨兵位」,让「找不到」的情况也变成「找到哨兵」,循环因此必然命中退出,无需每次检查下标。
考点:顺序查找哨兵写法的正确性。
解析:目标存在时,循环会在它所在位置命中退出;目标不存在时,扫描会一路走到哨兵位 a[n],那里恰好是 x,也命中退出——总之循环一定在到达真正越界之前退出。所以循环体里只需要判断值是否相等,下标检查被整体省掉。
排除法:哨兵不改变数组有序性,选项内容「数组就变有序了」是错的;越界位置在哨兵位之后、不会被走到,「移出数组之外」说法不成立;「编译器去掉越界检查」既不是哨兵的目的也不可靠。
二分查找能正确工作、并达到 时间的前提条件是什么?
规则:二分需要「有序」+「可随机访问」两个前提同时成立。
考点:二分的前提条件。
解析:有序保证了「和 a[mid] 比较后能排除一半」; 随机访问(数组下标)保证每次取 a[mid] 是常数时间。两者缺一不可:无序无法排除一半,链表(只能顺序访问)取 a[mid] 就要 。
排除法:链表有序也做不了 二分,选项内容「无论什么结构」是陷阱;有序性不能省,「不必有序」直接破坏正确性;元素重复只影响返回哪个位置,不影响能否二分,「互不相同」不是前提。
有人想:把顺序查找改成「每次取中间位置比一比,比目标小就取右半边」。对无序数组这样做的后果是什么?
规则:只有数组有序,「a[mid] 大 → 目标在左半边」这个排除才是可靠的;无序时两边都可能含有目标,砍掉一边就可能丢掉答案。
考点:二分必须有序的根本原因。
解析:二分的每一步都在做「既然 a[mid] 和 x 的大小关系已知,那么含 x 的区域只可能在一边」。这个论断依赖整段有序:若 a[mid] > x,只有「左边所有元素都 」才能推出目标在左半边——无序数组不保证这一点。所以无序时比较 a[mid] 什么都排除不了。
排除法:「稍慢但正确」与「更快」都建立在还能排除的基础上,实际是结果错误;越界是无稽之谈,mid 始终在区间内。
的有序数组只查一次,老师却建议用顺序查找而不是二分查找。为什么?
规则:复杂度说的是「规模变大时的增长趋势」,小规模下常数因子往往更关键,顺序查找更简单更快。
考点:复杂度分析的应用边界。
解析:二分比较次数约 次,顺序查找平均约 次,差距极小;但二分每轮要做两次比较、算 mid、多次分支,常数更大。对 只查一次,简单直接的顺序查找通常更快,也更不容易写错。复杂度优势在 很大时才体现。
排除法:题目说数组有序,「顺序查找不要求有序」答非所问;二分在小数组不会越界(区间端点始终受控);二分在任意 上都是 ,不会退化为 。
对 个有序元素做二分查找,最多要比较多少次?为什么不是 次?
规则:二分最多比较次数是 ( 时为 )。
考点:比较次数上界公式。
解析:每次比较最多把区间一分为二。 次比较最多能区分 个位置,要覆盖 个位置需要 ,即 ,取整得到 。 不够覆盖 100 个位置,所以必须 7 次。注意「」本身没错,但取的是向上取整不是四舍五入,得到 7。
排除法:「四舍五入 6.64 得 7」数值碰巧对了,但理由错误—— 也四舍五入到 7,可 的根据是向上取整;「排除 2 个元素」是把顺序查找的排除量安到二分头上;「等于数组长度」是顺序查找的最坏情况。
对下列 个有序元素做二分查找,最大比较次数依次是 和 时?
规则: 在 时为 ,在 时为 。
考点:手算比较次数表。
解析:,所以 ;,所以 。二分查找 100 万个数也只要 20 次比较——这是它强大的直观体现。
排除法:、,所以 是 10 不是 11 也不是 9;「等于数组长度」把顺序查找的最坏情况安到了二分头上。
下面哪个场景的核心思路属于「每次把搜索范围缩小一半」的二分思想?
规则:二分的标志是「一次比较后确定性排除约一半」,猜数游戏(大了/小了)正是它的日常原型。
考点:二分思想的本质。
解析:猜数时对方回答「大了」就能排除右半边,回答「小了」排除左半边,每轮区间减半, 次内必中——这与二分查找、二分答案同构。
排除法:「逐核对」是顺序查找;「拆 10 份同时找」是并行不是二分;「随机碰运气」无法保证每次都排除一半,最坏可能一直不中。
顺序查找数组 a[0..n-1] 找目标 x,三种需求分别要求「找到第一个匹配的下标」「只判断是否存在」「数一数有几个匹配」。下面哪种说法正确?
规则:找第一个匹配位置和判断存在性都可以「见 x 就停」;只有统计个数必须扫完整个数组。
考点:顺序查找的三种变体口径。
解析:找第一个:循环里 a[i] == x 就 return i,第一个命中的必然最先被遇到;判存在:命中就 break 并标记 found,同样不用扫完;统计个数:后面的元素还可能有匹配,必须 i 走到底才能给总数。三个版本的中途退出行为不同,返回值口径也不同。
排除法:「都必须扫完」只对统计个数成立;「找第一个和判存在都要扫完」错在忽略了命中即可停;「中途退出复杂度不是 」是把最坏 理解成了必须跑满 步。
有 n = 9 个元素的数组,用顺序查找找一个确定存在的值。最坏情况下比较几次?平均情况下比较几次?
01int seqFind(const int a[], int n, int x) { 02 for (int i = 0; i < n; i++) 03 if (a[i] == x) return i; 04 return -1; 05}
规则:最坏 = 目标在最后一个位置( 次);平均 = 次。
考点:顺序查找的次数计算。
解析:最坏情况目标在 a[8],要比较完 9 个元素才找到, 次。平均情况目标等概率落在任一位置,比较次数 的平均是 次。
排除法:最坏是 9 不是 10(不用比较越界哨兵位)也不是 8;平均是 5 不是 9 或 4。
数组是无序的,而且只查这一次——判断某个值在不在,以后不再查它。老师建议用顺序查找而不是先排序再二分。为什么?
规则:单次查找 + 无序数据 = 顺序查找更优( vs 排序 )。
考点:顺序查找 vs 排序后二分的复杂度权衡。
解析:若只查一次,排序 + 二分 ≈ ,比直接顺序查找 还慢;而且排序可能破坏原数组顺序。只有「查很多次」( 次)时,先排序再二分才划算。这正是「什么时候顺序查找反而合适」的答案。
排除法:「更准确」无关,两者都能查存在性;「排序丢数据」排序是重排不是丢;「无序会死循环」二分前提不满足时结果未定义,但通常不是死循环,是错误结果。
猜数游戏:心里想一个 的整数,对方每次问「比这个数大还是小」并如实回答。用「每次砍一半」的策略,最多猜几次必中?
规则:——每次回答最多排除一半,7 次足够区分 种可能。
考点:猜数游戏与二分比较次数的对应。
解析:每次「大了/小了」的回答都让候选区间减半。 次回答最多区分 个数, 不够, 够,所以最多 7 次。这与二分查找的最大比较次数公式完全一致。
排除法: 次只能区分 64 个数不够覆盖 100; 次是顺序猜; 次没有依据。
下面的二分查找,有人担心它会不会死循环:如果 a[mid] != x 一直不命中,循环会不会永远转下去?
01int findX(const int a[], int n, int x) { 02 int l = 0, r = n - 1; 03 while (l <= r) { 04 int mid = l + (r - l) / 2; 05 if (a[mid] == x) return mid; 06 else if (a[mid] < x) l = mid + 1; 07 else r = mid - 1; 08 } 09 return -1; 10}
规则:闭区间写法每轮都通过 l = mid + 1 或 r = mid - 1 把 mid 排除出区间,区间至少缩小 1,因此必然结束。
考点:循环能终止的根本保证 = 区间严格收缩。
解析:不管 a[mid] 和 x 是什么关系,三个分支都处理了:相等就返回;a[mid] < x 时目标只可能在右边,l 变成 mid + 1;a[mid] > x 时 r 变成 mid - 1。无论哪条路,mid 都被排除,[l, r] 长度严格减小,不会原地踏步。
易错:有些同学把「不死循环」寄托在 return mid 上,其实把命中分支删掉、改成继续收缩,循环照样能结束(只是丢了答案)——终止性来自区间收缩,不来自 return。
排除法:「编译器优化」在 C++ 里没有这回事;「靠 return 退出」把退出条件和终止性混为一谈;「mid 等于 l 区间不变」恰恰说反——l = mid + 1 保证即使 mid == l,新区间也从 l+1 开始,一样缩小。
把上面代码里的 else r = mid - 1; 误写成 else r = mid;,对 a = {1, 3, 5, 7, 9}、x = 2 运行,为什么程序永远停不下来?
01int l = 0, r = n - 1; 02while (l <= r) { 03 int mid = l + (r - l) / 2; 04 if (a[mid] == x) return mid; 05 else if (a[mid] < x) l = mid + 1; 06 else r = mid - 1; 07} 08return -1;
规则:死循环的病根是收缩时没有把 mid 排除出区间——r = mid 在 mid == r 时让 r 保持原值,区间不再缩小。
考点:死循环的唯一病根 = 收缩不排 mid;两元素极端情况自查法。
解析:手动模拟 a = {1,3,5,7,9}、x = 2:初始 l=0, r=4。第 1 轮 mid=2,a[2]=5 > 2,r = mid = 2;第 2 轮 mid=1,a[1]=3 > 2,r = mid = 1;第 3 轮 l=0, r=1,mid=0,a[0]=1 < 2,l=1。此时 l=1, r=1,mid = 1,a[1]=3 > 2,执行 r = mid = 1——r 不变!下一轮 l=1, r=1 又是同样的 mid=1,无限循环。问题就出在区间缩到 2 个元素后,r = mid 让 r 钉死在 mid 上。
排除法:「数据选得不好」——数据没问题,任何 l == r 且 a[l] > x 的局面都会触发死循环;「越界」——mid 始终落在 [l, r] 内,不会越界;「只是变慢」——这不是效率问题,程序直接卡死,永远给不出答案。
对「把 r = mid - 1 改成 r = mid」的错误代码,判断死循环的标准调试术是:手动模拟区间缩到只剩两个元素时是否还在收缩。用 a = {2, 4, 6, 8}、x = 3 追踪:当区间第一次变成 l = 1, r = 2(只剩 4 和 6 两个元素)后,下一轮会怎样?
01int l = 0, r = n - 1; 02while (l <= r) { 03 int mid = l + (r - l) / 2; 04 if (a[mid] == x) return mid; 05 else if (a[mid] < x) l = mid + 1; 06 else r = mid - 1; 07} 08return -1;
规则:两元素自查法——模拟只剩 2 个元素时若出现「l = mid 或 r = mid 使指针钉住」,必死循环。
考点:死循环两元素自查法。
解析:a = {2,4,6,8}、x = 3:第 1 轮 l=0, r=3, mid=1,a[1]=4 > 3,误写代码执行 r = mid = 1。第 2 轮 l=0, r=1, mid=0,a[0]=2 < 3,l = mid + 1 = 1。此时 l=1, r=1——注意区间是直接「从两个元素跳到只剩一个」,没有经历 l=1, r=2。再看题目给的起点 l=1, r=2:mid = 1 + (2-1)/2 = 1,a[1]=4 > 3,r = mid = 1,区间变 l=1, r=1;下一轮 mid = 1 还是它自己,a[1]=4 > 3 恒成立,r = mid = 1 永远不动,死循环。
排除法:「r = mid - 1 = 0」是正确写法会做的事,误写版本没有减一;「l = mid + 1」只有在 a[mid] < x 才走,这里 4 > 3 走的是 else;「mid = 2」错在 mid = l + (r - l) / 2 = 1 + 0 = 1,不会取到 2。
闭区间写法查不到 x 时,循环以 l == r + 1 结束。为什么此时 l 恰好是「第一个大于 x 的位置」?
01int l = 0, r = n - 1; 02while (l <= r) { 03 int mid = l + (r - l) / 2; 04 if (a[mid] == x) return mid; 05 else if (a[mid] < x) l = mid + 1; 06 else r = mid - 1; 07} 08return -1;
规则:循环不变量「a[l-1] < x 且 a[r+1] > x」在结束时合并成 l = r+1,得到 a[l-1] < x 且 a[l] > x——l 正是第一个大于 x 的位置。
考点:闭区间写法的结束状态。
解析:看代码的分支:a[mid] < x 时 l = mid + 1,这保证新 l 之前的所有元素(含 a[mid])都小于 x,即 a[l-1] < x;a[mid] > x 时 r = mid - 1,这保证新 r 之后的所有元素都大于 x,即 a[r+1] > x。两个性质在每轮循环里都保持(循环不变量)。结束时 l = r + 1,代入得 a[l-1] < x 且 a[l] > x,所以第一个大于 x 的元素下标就是 l。
排除法:「l 一直增大停在末尾」只对全小于 x 的情形成立,不是一般规律;「r 是第一个大于 x 的位置」方向反了,r 是最后一个小于 x 的位置;「l 一定等于数组长度」同样是只有全小于时才成立。
把闭区间写法的命中分支从 return mid 改成 l = mid + 1(找到后不返回,继续往右收缩),对 a = {3, 5, 7}、x = 5 运行会怎样?
01int l = 0, r = n - 1; 02while (l <= r) { 03 int mid = l + (r - l) / 2; 04 if (a[mid] == x) return mid; 05 else if (a[mid] < x) l = mid + 1; 06 else r = mid - 1; 07} 08return -1;
规则:终止性来自区间收缩(不来自 return),但答案会被排除出区间而丢失——删掉 return 且改成继续收缩,结果是正常结束却返回 -1。
考点:命中分支的作用是「记录答案并退出」,而不是「保证终止」。
解析:模拟 a = {3,5,7}、x = 5:l=0, r=2, mid=1,a[1]=5 == x,但代码不返回,执行 l = mid + 1 = 2;继续 l=2, r=2, mid=2,a[2]=7 > 5,r = mid - 1 = 1;l=2, r=1 退出循环,返回 -1。循环没有死(每轮都收缩),但 5 所在的下标 1 已经被排除,答案回不来了。
排除法:「命中后不再更新任何指针」是另一种错误改法(什么都不做)才会死循环,本情境是继续收缩,不会死;「返回值不受影响」显然错——返回的是 -1;「返回错误下标」它根本没返回下标,返回的是 -1。
把闭区间写法误写成:a[mid] < x 时执行 r = mid - 1,a[mid] > x 时执行 l = mid + 1(两个分支写反),对 a = {2, 4, 6, 8}、x = 6(存在)运行会怎样?
01int l = 0, r = n - 1; 02while (l <= r) { 03 int mid = l + (r - l) / 2; 04 if (a[mid] == x) return mid; 05 else if (a[mid] < x) l = mid + 1; 06 else r = mid - 1; 07} 08return -1;
规则:分支写反 = 排除方向写反,每次都在排除「可能含答案」的那一半,最后区间交叉返回 -1。
考点:比较方向与收缩方向的配套关系。
解析:a = {2,4,6,8}、x = 6:l=0, r=3, mid=1,a[1]=4 < 6,误写代码走 a[mid] < x 分支执行 r = mid - 1 = 0;l=0, r=0,mid=0,a[0]=2 < 6,r = mid - 1 = -1;l=0, r=-1 退出,返回 -1。看:正确的 x=6 明明存在,却在第一轮就被排除——因为 4 < 6 本该往右找,误写版本却把右半砍掉。
排除法:「死循环」不会发生,l、r 每次都在变,很快交叉;「偶尔找到」不对——方向恒定错误,每次都丢答案;「仍然正确」忽略了两分支走反的事实。
补全下面的二分查找:当 a[mid] < x 时,左边全部元素(包括 a[mid])都小于 x,都不可能命中,所以下一轮从 mid + 1 开始。
01int findX(const int a[], int n, int x) { 02 int l = 0, r = n - 1; 03 while (l <= r) { 04 int mid = l + (r - l) / 2; 05 if (a[mid] == x) return mid; 06 else if (a[mid] < x) l = ____; 07 else r = mid - 1; 08 } 09 return -1; 10}
规则:已比较过的 mid 必须排除出新区间,所以左边界推进一格。
考点:闭区间写法两分支的配套收缩。
解析:a[mid] < x 说明 a[mid] 不可能是答案(它太小),而比它还小的左边元素更不可能,所以整个左半边都排除,l 越过 mid 到 mid + 1。这与 else r = mid - 1 对称:两个分支都把 mid 排除出区间,这正是「不死循环」的保证。
排除法:填 mid 会把 a[mid] 留回区间,下一轮重复比较它(虽不会立即死循环,但多一轮且破坏不变量);填 mid - 1 更糟,又把左半边元素含回区间;填 mid + 2 跳过了 mid + 1,可能漏掉答案。
用闭区间写法对 a = {2, 4, 6, 8, 10} 查找 x = 7(不存在)。循环结束后,l 和 r 分别等于多少?l 是什么位置?
01int l = 0, r = n - 1; 02while (l <= r) { 03 int mid = l + (r - l) / 2; 04 if (a[mid] == x) return mid; 05 else if (a[mid] < x) l = mid + 1; 06 else r = mid - 1; 07} 08return -1;
规则:查不到时结束状态恒为 l == r + 1,且 l 是第一个大于 x 的插入位置。
考点:闭区间写法的结束状态。
解析:模拟 a = {2,4,6,8,10}、x = 7:l=0, r=4, mid=2,a[2]=6 < 7,l=3;l=3, r=4, mid=3,a[3]=8 > 7,r=2;l=3, r=2 退出循环。此时 a[2]=6 < 7 且 a[3]=8 > 7,7 插入在 6 和 8 之间,位置正好是 l = 3。
排除法:「l 是最后一个小于 7 的下标」那个是 r=2,不是 l;「l=4, r=3」数值错——l 到 3 就停了;「l=2」错,6 的位置是 2,而插入位置在其后一位。
递归版二分与迭代版二分,为什么它们在正确性上完全等价、速度也只差常数?
01 02int findX1(int a[], int n, int x) { 03 int l = 0, r = n - 1; 04 while (l <= r) { 05 int mid = l + (r - l) / 2; 06 if (a[mid] == x) return mid; 07 if (a[mid] < x) l = mid + 1; 08 else r = mid - 1; 09 } 10 return -1; 11} 12 13int findX2(int a[], int l, int r, int x) { 14 if (l > r) return -1; 15 int mid = l + (r - l) / 2; 16 if (a[mid] == x) return mid; 17 if (a[mid] < x) return findX2(a, mid + 1, r, x); 18 return findX2(a, l, mid - 1, x); 19}
规则:递归版与迭代版是同一算法的两种表达——「出口 = 循环条件失败」「每层递归 = 循环一轮」,规模每层至少减半,复杂度同为 。
考点:递归二分与迭代二分的等价性。
解析:对照看:迭代版的 while (l <= r) 条件失败(l > r)对应递归版的 if (l > r) return -1 出口;迭代版一轮里做的「算 mid、比较、缩一半」正是递归版一层调用做的事,且下一层区间 [mid+1, r] 或 [l, mid-1] 与迭代版的 l = mid + 1 或 r = mid - 1 完全一致。所以比较次数相同,递归版只多一次函数调用的常数开销。递归版的「死循环」长这样:如果收缩写错导致区间不缩小,会无限递归,最终栈溢出——和迭代版死循环是同一种病。
排除法:「慢一个数量级」不对,同是 只差常数;「递归版不会终止」只在写错时成立,正确写法照样 层就结束;「只能查精确值」递归同样能写边界查找,只是传参形式不同。
关于递归版二分查找,下面哪种说法正确?
规则:递归二分的出口是循环条件失败(区间空),深度 。
考点:递归二分的终止条件与栈深。
解析:每次递归调用都把当前区间切成两半,只往含目标的那一半走,所以调用链长度最多 层。每层占用一个栈帧,栈深 。出口 l > r 表示区间为空、目标不存在,返回 -1 逐层回溯。
排除法:「找不到就永远递归」忽略了 l > r 出口——任何查找最终都会把区间缩空;「深度 」错,每层减半,深度是对数;「系统自动处理」没有这回事,出口必须自己写。
「ans 记录版」找左边界:找到 a[mid] >= x 就把 ans = mid 记下来,再继续往左收缩,最后返回 ans。为什么 ans 初值要取 n 而不是 0?
01int lowerB2(const int a[], int n, int x) { 02 int ans = n; 03 int l = 0, r = n - 1; 04 while (l <= r) { 05 int mid = l + (r - l) / 2; 06 if (a[mid] >= x) { ans = mid; r = mid - 1; } 07 else l = mid + 1; 08 } 09 return ans; 10}
规则:ans 初值 = 「不存在」的统一口径,必须落在合法下标范围之外,取 n 正好。
考点:ans 记录版的哨兵初值设计。
解析:合法下标是 。只有当某次 a[mid] >= x 时 ans 才被更新成真实下标;如果整个数组都小于 x,ans 一直保持初值。这个初值不能是合法下标,否则调用方会把一个从未命中的位置当答案。取 n 后,调用方判断 ans == n 即「不存在」。
排除法:「下标就是 n」错,数组下标最大是 n-1,n 恰好越界;「效果一样」错——全小于 x 时取 0 会返回假答案 a[0];「整型上限」没必要,n 就够了。
红蓝染色表述把二分想象成:左半元素染成满足条件的颜色,右半染成不满足的颜色。下面哪种说法正确?
规则:红蓝染色是二分的循环不变量表述:两种颜色的分界线就是答案位置,二分是在反复逼近这条分界线。
考点:二分的循环不变量视角。
解析:不管当前区间多大,循环始终保证:l 之前的元素都满足某性质(蓝色),r 之后都违背(红色),中间的 [l, r] 是「未知区」。每次取 mid 判断颜色,就能把未知区砍掉一半,直到 l 与 r 相邻,l 和 r 分别是最后一个蓝色和第一个红色,答案就是这条分界线。这与普通二分「找第一个满足性质的位置」完全同构,只是换了一套说法。
排除法:「完全不同算法」错,就是二分的另一套表述;「物理重排」多此一举,颜色是逻辑概念;「只适用实数」错,整数二分照样能用。
有人用位运算写 mid = (l & r) + ((l ^ r) >> 1)。这个表达式算出的结果等价于下面哪个?
01int mid = (l & r) + ((l ^ r) >> 1); 02
规则:按位看,(l & r) 是两数相同的位,(l ^ r) >> 1 是不同位之和的一半,相加正好是 向下取整。
考点:位运算求平均数的原理。
解析:任意两个二进制数相加 l + r,可以拆成「同位相加无进位的结果」加上「进位」。l ^ r 给出哪些位不同(这些位相加不会进位),l & r 给出哪些位相同(这些位相加会进位 1 到高一位)。因此 (l & r) + ((l ^ r) >> 1) = 进位项 + 不同位的一半 = (l + r) / 2 向下取整。它与更常用的 l + (r - l) / 2 数学等价,只是换成了位运算的形式,面试里偶尔见到,正常写法用 l + (r - l) / 2 即可。
排除法:不是两倍平均数,方向反了;不是绝对值也不是最大公约数,那些运算与按位拆无关。
运行下面的程序,输出多少?
01#include <iostream> 02using namespace std; 03int main() { 04 int a[5] = {1, 3, 5, 7, 9}; 05 int n = 5, x = 6; 06 int ans = n; 07 int l = 0, r = n - 1; 08 while (l <= r) { 09 int mid = l + (r - l) / 2; 10 if (a[mid] >= x) { ans = mid; r = mid - 1; } 11 else l = mid + 1; 12 } 13 cout << ans << endl; 14 return 0; 15}
规则:ans 记录版照常收敛——6 不存在但第一个大于等于 6 的位置是 3,返回 3。
考点:ans 记录版与左边界的一致性。
解析:l=0, r=4, mid=2:a[2]=5 >= 6 假,l=3;l=3, r=4, mid=3:a[3]=7 >= 6 真,ans=3, r=2;l=3, r=2 退出,返回 3。7 是第一个不小于 6 的元素。注意 ans 初值 n 只在「全数组都小于 x」时保留,这里命中过,ans 被更新为 3。
排除法:4 是初值口径,本数据命中了真实下标;2 是 5 的位置,5 < 6 不满足;本写法不用 -1 表示不存在。
用闭区间写法对 a = {2, 4, 6, 8, 10} 查找不存在的 x = 7。运行下面的代码,循环体里的比较(判断 a[mid] == x、<、> 的分支判断)一共执行了几次?
01int findX(const int a[], int n, int x) { 02 int l = 0, r = n - 1, cnt = 0; 03 while (l <= r) { 04 int mid = l + (r - l) / 2; 05 cnt++; 06 if (a[mid] == x) return mid; 07 else if (a[mid] < x) l = mid + 1; 08 else r = mid - 1; 09 } 10 return -1; 11}
规则:x = 7 落在 6 和 8 之间,二分只走了 2 轮区间就空了。
考点:闭区间写法的追踪与比较次数。
解析:第 1 轮 l=0, r=4, mid=2,a[2]=6 < 7,l=3;第 2 轮 l=3, r=4, mid=3,a[3]=8 > 7,r=2;l=3, r=2 退出。循环体执行 2 次,比较了 6 和 8 两个位置。这正是闭区间写法查不到的典型过程:l 和 r 夹向插入点两侧然后交叉。
排除法:3 次是 a = {2,4,6,8,10} 找其他值时的可能轮数,这里只走 2 轮;4、5 次更多。
用下面的递归版二分对 a = {1, 3, 5, 7, 9} 查找 x = 5,函数最终返回多少?递归了几层?
01int findX(int a[], int l, int r, int x) { 02 if (l > r) return -1; 03 int mid = l + (r - l) / 2; 04 if (a[mid] == x) return mid; 05 if (a[mid] < x) return findX(a, mid + 1, r, x); 06 return findX(a, l, mid - 1, x); 07}
规则:n = 5 时第一次 mid = 2 恰好命中 a[2] = 5,只递归 1 层。
考点:递归二分的层数与返回值。
解析:l=0, r=4, mid = 0 + (4-0)/2 = 2,a[2] = 5 == x,直接返回 2,没有进入更深的递归。findX 只被调用了 1 层。若查不存在的值,层数约等于 。
排除法:mid 第一次就中,递归不是 3 层;返回的是下标 2 不是值 3;递归 2 层也不对。
题目(洛谷 P2249):给出 个单调不减(非降序)的整数, 次询问,每次给一个整数 x,输出 x 第一次出现的位置(从 1 开始编号);若不存在输出 -1。
本题数据:n = 10,数组为 a = {1, 2, 3, 3, 3, 5, 7, 9, 11, 13},询问 x = 3、x = 4、x = 13。
01#include <iostream> 02using namespace std; 03int a[15]; 04int firstX(int x, int n) { 05 int l = 1, r = n, ans = -1; 06 while (l <= r) { 07 int mid = l + (r - l) / 2; 08 if (a[mid] == x) { ans = mid; r = mid - 1; } 09 else if (a[mid] < x) l = mid + 1; 10 else r = mid - 1; 11 } 12 return ans; 13} 14int main() { 15 int n = 10; 16 int q[3] = {3, 4, 13}; 17 for (int i = 1; i <= n; i++) cin >> a[i]; 18 for (int i = 0; i < 3; i++) cout << firstX(q[i], n) << endl; 19 return 0; 20}
本题第一问:对 x = 3,程序输出多少?
本题第二问:对 x = 4(数组中不存在),程序输出多少?
本题第三问:为什么命中分支要写 r = mid - 1 继续向左,而不是直接 return mid?
本题第四问:若把命中分支误改成直接 return mid,对 x = 3 程序会输出多少?
本题第五问:对 x = 13(数组最后一个元素),下面哪种说法正确?
数组有序,元素可重复。下面关于「左边界」和「右边界」的说法,哪个正确?
规则:左边界统一为「第一个 」,右边界统一为「第一个 的前一位」=「最后一个 」。
考点:左右边界的标准定义。
解析:左边界 lower_bound(x) 返回第一个不小于 x 的位置;右边界则借「第一个大于 x 的位置减 1」得到最后一个不大于 x 的位置。这两者都不要求 x 一定存在——比如找 x 的插入位置时用的就是左边界。
排除法:「最后一个小于 x」那是左边界的前一个位置;「不存在就返回 -1」与边界版的设计相反,边界版用返回 n 表示越界;「只在有重复时不同」不对,左边界右边界定义本身不同,与重复无关。
找「第一个大于等于 x 的下标」,代码是下面这样。为什么 a[mid] >= x 时要写 r = mid,而不是 r = mid - 1?
01int lowerB(const int a[], int n, int x) { 02 int l = 0, r = n; 03 while (l < r) { 04 int mid = l + (r - l) / 2; 05 if (a[mid] >= x) r = mid; 06 else l = mid + 1; 07 } 08 return l; 09}
规则:只要 mid 可能是答案就不能排除它——a[mid] >= x 时 mid 就是候选,必须 r = mid 保留。
考点:边界写法中「可能成为答案的元素不能删」。
解析:a[mid] >= x 说明 mid 已经满足「不小于 x」,它可能是第一个(前面也许还有更小的合法下标,需要继续往左找),也可能是唯一的一个。写成 r = mid 把 mid 留在新区间 [l, mid] 继续左压,最终 l == r 收敛到第一个满足者。若写 r = mid - 1,就把 mid 这个候选排除掉——若 mid 恰是唯一的第一个,答案就丢了。
排除法:「排除更多更高效」——多排一个 mid 不是效率问题,是正确性问题;「无法退出循环」不会,r = mid 配合 l < r 与 mid 下取整保证 mid < r,区间照常收缩;「完全等价」恰恰是本题要纠正的误区。
求「最大可行值」的代码写成下面这样。为什么 lo 和 hi 只剩两个相邻整数时,mid 必须向上取整,否则死循环?
01int maxOK(int lo, int hi) { 02 while (lo < hi) { 03 int mid = lo + (hi - lo) / 2; 04 if (check(mid)) lo = mid; 05 else hi = mid - 1; 06 } 07 return lo; 08}
规则:l = mid 会保留 mid,因此必须上取整保证 mid > l;否则相邻两数时 mid == l,区间不收缩。
考点:取整方向与收缩方式的配套。
解析:lo = 3, hi = 4 时,下取整 mid = 3 + (4-3)/2 = 3。若 check(3) 为真,lo = mid = 3,lo、hi 仍是 3、4——死循环。正确写法是 mid = lo + (hi - lo + 1) / 2(上取整),相邻时 mid = 4,check(4) 为真则 lo = 4 区间变空,为假则 hi = 3 同样收缩。口诀:l = mid 配 mid + 1 的取整,让 mid 严格大于 l。
排除法:「四舍五入更平均」没有这回事,取整方向由收缩方式决定;「取整方向不影响正确性」恰好相反,选错方向就是死循环;「mid < lo」不可能,mid 恒在 [lo, hi] 内,问题是 mid == lo 而不是越界。
求「最小可行值」的代码,把 mid 误写成上取整。为什么当 lo 和 hi 相邻时会死循环?
01int minOK(int lo, int hi) { 02 while (lo < hi) { 03 int mid = lo + (hi - lo + 1) / 2; 04 if (check(mid)) hi = mid; 05 else lo = mid + 1; 06 } 07 return lo; 08}
规则:r = mid 会保留 mid,因此必须下取整保证 mid < r;否则相邻两数时 mid == r,区间不收缩。
考点:取整方向的对称配套(与上题的镜像)。
解析:lo = 3, hi = 4 时,上取整 mid = 3 + (4-3+1)/2 = 4。若 check(4) 为真,hi = mid = 4,lo、hi 仍是 3、4——死循环。正确写法是 mid = lo + (hi - lo) / 2(下取整),相邻时 mid = 3,无论 check(3) 真假区间都收缩。规律很对称:l = mid 上取整,r = mid 下取整——哪个端点被保留,就让 mid 别落在那个端点上。
排除法:「上取整不是可行值」与 check 的真假无关,问题在区间不收缩;「奇数位置不稳定」无稽之谈;「两种取整都死循环」错,配对的取整方向是安全的。
左边界写法 while (l < r) 结束时,为什么一定满足 l == r,而且这个共同的值就是所求边界?
01int lowerB(const int a[], int n, int x) { 02 int l = 0, r = n; 03 while (l < r) { 04 int mid = l + (r - l) / 2; 05 if (a[mid] >= x) r = mid; 06 else l = mid + 1; 07 } 08 return l; 09}
规则:l < r 是循环唯一条件,停止时 l == r;配合「答案始终在区间内」的不变量,这个共同位置就是答案。
考点:边界写法的结束状态。
解析:循环变量 l、r 是整数,while (l < r) 只可能因 l == r 退出(不会出现 l > r——l = mid + 1 在 mid 下取整时最多让 l 达到 r,r = mid 最多让 r 回到 l)。再看不变量:a[mid] >= x 时答案若存在必在 [l, mid],r = mid 保留;否则答案必在 [mid+1, r],l = mid + 1。每轮都保留了答案所在区间,所以收敛点必然就是第一个 >= x 的位置。
排除法:「l > r」是闭区间写法的结束状态,边界写法不可能越过;「还要再判断 a[l]」边界写法不要求 x 存在,返回的 l 直接就是答案(可能为 n 表示越界);「相差 1」不可能,整数下 l < r 为真的最小差距是 1,缩到 0 就退出。
找「最后一个小于等于 x 的下标」,常见做法是:先找第一个大于 x 的位置,再减 1。为什么不直接二分「最后一个小于等于 x」?
01int upperB(const int a[], int n, int x) { 02 int l = 0, r = n; 03 while (l < r) { 04 int mid = l + (r - l) / 2; 05 if (a[mid] > x) r = mid; 06 else l = mid + 1; 07 } 08 return l; 09}
规则:统一口径——所有边界都表达为「第一个大于等于/大于 x 的位置」,右边界只是「第一个大于 x 再减一」。
考点:右边界与半开区间的统一性。
解析:若直接写「最后一个 <= x」,循环里要维护的是 l = mid(保留候选)+ 上取整,与左边界模板方向正好相反,两套模板容易背混。而「第一个 > x 减一」用的还是同一套「第一个…」模板,只是比较符号从 >= 换成 >,返回前减一即可。例:a = {1,3,3,3,5,5,8},第一个大于 3 是下标 4,减一得 3,正是最后一个 3。
排除法:「会死循环」「会越界」都无依据,直接写也能正确实现,只是要多维护一套相反方向的模板;「不减会差一位」减一是逻辑需要(最后 <=x = 第一个 >x 前一位),不是下标从 0 起的修正。
对 a = {1, 3, 5, 7} 调 lowerB(a, 4, 100)(找第一个大于等于 100 的下标)。返回值是 4。为什么说直接写 a[idx] 会越界?
01int l = 0, r = n; 02while (l < r) { 03 int mid = l + (r - l) / 2; 04 if (a[mid] >= x) r = mid; 05 else l = mid + 1; 06} 07return l;
规则:边界写法的答案域是 ,n 专门留给「全部不满足」的情形;拿到 n 必须先判「不存在」再访问。
考点:返回 n 的含义与越界防护。
解析:数组下标是 ,而 lowerB 的答案域包含 n(半开区间 [l, r) 的 r 初值就是 n)。全数组都小于 x 时没有任何合法位置满足,函数返回 n 作为「不存在」的记号。此时直接访问 a[n] 就越界。正确的用法是 int idx = lowerB(...); if (idx < n) { ... } else { /* 不存在 */ }。
排除法:「4 是最后一个元素下标」最后一个下标是 3,4 恰好越界一位;「比较时已经越界」比较发生在 mid 上,mid 恒在 [0, n-1],不会越界;「返回 -1」那是闭区间写法的口径,边界写法用 n。
第三种写法用 l = -1, r = n 的开区间,while (l + 1 < r)。为什么它不用纠结取整方向,天然不会死循环?
01int lowerB3(const int a[], int n, int x) { 02 int l = -1, r = n; 03 while (l + 1 < r) { 04 int mid = l + (r - l) / 2; 05 if (a[mid] >= x) r = mid; else l = mid; 06 } 07 return r; 08}
规则:悬空端点保证 mid 永远取不到 l 或 r,于是 l = mid 和 r = mid 两条路都严格收缩——取整方向自然无所谓。
考点:开区间悬空写法免疫死循环的原理。
解析:l 初始为 -1、r 初始为 n,它们不是合法下标、不会被访问(a[mid] 里 mid 严格在 (l, r) 内,即 )。循环里无论 mid 怎么取整,它都在 l 和 r 之间,所以 l = mid 使 l 至少增 1(mid > l),r = mid 使 r 至少减 1(mid < r)。区间长度 r - l - 1 每轮至少减 1,必然收敛。
排除法:「靠 return 退出」这个函数没有 return mid,靠的是循环条件 l + 1 < r 自然失效;「特殊公式」没有,就是普通下取整;「l 是答案」终止时返回 r,l 是最后一个不满足的位置。
用开区间悬空版代码对 a = {2, 4, 6, 8}、x = 5 查找第一个大于等于 5 的下标,逐轮追踪:l、r 依次如何变化?最终返回多少?
01int l = -1, r = n; 02while (l + 1 < r) { 03 int mid = l + (r - l) / 2; 04 if (a[mid] >= x) r = mid; 05 else l = mid; 06} 07return r;
规则:开区间写法逐轮把 l 或 r 拉向答案,终止时 l + 1 == r,返回 r。
解析:a = {2,4,6,8},x = 5。第 1 轮 mid = -1 + (4-(-1))/2 = 1,a[1] = 4 >= 5 为假,l = mid = 1;第 2 轮 mid = 1 + (4-1)/2 = 2,a[2] = 6 >= 5 为真,r = mid = 2;此时 l + 1 = 2 == r,循环结束,返回 r = 2。第一个大于等于 5 的元素是 a[2] = 6,正确。
排除法:「(-1,4) → (0,4)」错在 mid 从 0 开始——mid = -1 + (4-(-1))/2 = 1,第一轮就跳到 1,l 直接变 1 不会经过 0;「返回 3」错,终止时 r = 2。
题目:给一个非降序数组,多次询问,每次输出第一个大于等于 x 的元素的下标(从 0 开始);若整个数组都小于 x,输出 n。
本题数据:n = 7,数组 a = {1, 3, 3, 3, 5, 5, 8},依次查询 x = 3、x = 4、x = 10、x = 0。
01int lowerB(const int a[], int n, int x) { 02 int l = 0, r = n; 03 while (l < r) { 04 int mid = l + (r - l) / 2; 05 if (a[mid] >= x) r = mid; 06 else l = mid + 1; 07 } 08 return l; 09}
本题第一问:查询 x = 3,函数返回多少?
本题第二问:查询 x = 4,函数返回多少?
本题第三问:为什么 a[mid] >= x 分支要 r = mid,而 < 分支要 l = mid + 1?
本题第四问:查询 x = 10(大于所有元素),返回多少?这意味着什么?
本题第五问:查询 x = 0(小于所有元素),函数返回多少?
题目:给一个非降序数组,多次询问,每次输出最后一个小于等于 x 的元素的下标(从 0 开始);若所有元素都大于 x,输出 -1。
本题数据:n = 7,数组 a = {1, 3, 3, 3, 5, 5, 8},依次查询 x = 3、x = 5、x = 8。
01int upperB(const int a[], int n, int x) { 02 int l = 0, r = n; 03 while (l < r) { 04 int mid = l + (r - l) / 2; 05 if (a[mid] > x) r = mid; 06 else l = mid + 1; 07 } 08 return l; 09} 10int lastLE(const int a[], int n, int x) { 11 return upperB(a, n, x) - 1; 12}
本题第一问:查询 x = 3,upperB 返回多少?lastLE 返回多少?
本题第二问:为什么 upperB 里是 a[mid] > x 而不是 >= x?
本题第三问:查询 x = 5,lastLE 返回多少?
本题第四问:查询 x = 8(等于最大元素),lastLE 返回多少?
本题第五问:用 upperB(x) - 1 求右边界,和直接二分「最后一个小于等于 x」相比,好处是什么?
用左边界模板 lowerB(第一个 >= x)和右边界模板 upperB(第一个 > x),对 a = {1, 2, 2, 2, 3, 4, 4, 5} 统计 x = 2 和 x = 4 的出现次数,ub - lb 分别是多少?
01int L = lowerB(a, n, x); 02int R = upperB(a, n, x); 03int cnt = R - L;
规则:出现次数 = upperB(x) - lowerB(x),等于「最后一个小于等于减一」的反向。
考点:出现次数的 lb/ub 计数法。
解析:a = {1,2,2,2,3,4,4,5}。x = 2:lowerB 返回 1(第一个 2),upperB 返回 4(第一个 > 2 是 3 的位置),4 - 1 = 3 个 2。x = 4:lowerB 返回 5,upperB 返回 7(第一个 > 4 是 5 的位置),7 - 5 = 2 个 4。
排除法:x=2 是 3 不是 2 或 1;x=4 是 2 不是 1 或 3。
对 a = {1, 1, 1, 1}(全是 1),分别统计 x = 1 和 x = 0 的出现次数(用 ub - lb),结果是多少?
01int L = lowerB(a, n, x); 02int R = upperB(a, n, x); 03int cnt = R - L;
规则:边界情形——ub - lb 在元素全等时给出全长,在元素不存在时给出 0,两种极端都自动正确处理。
考点:出现次数计数的边界行为。
解析:a = {1,1,1,1}。x = 1:lowerB(1) = 0(第一个 >= 1 是下标 0),upperB(1) = 4(第一个 > 1 不存在,返回 n = 4),4 - 0 = 4 个。x = 0:lowerB(0) = 0,upperB(0) = 0(第一个 > 0 是下标 0),0 - 0 = 0 个。两个极端值都对。
排除法:x=1 是 4 不是 3 或 1;x=0 是 0 不是 4 或 1。
a = {5, 2, 8, 2, 9},sort(a, a + 5) 后数组变成 {2, 2, 5, 8, 9}。此时 lower_bound(a, a + 5, 5) - a 是多少?lower_bound(a, a + 5, 7) - a 呢?
01int L = lowerB(a, n, x); 02int R = upperB(a, n, x); 03int cnt = R - L;
规则:排序后 lower_bound 直接定位元素位置或插入位置。
考点:排序与边界查找的配合。
解析:排序后 a = {2,2,5,8,9}。lower_bound(5):第一个 >= 5 是下标 2(a[2] = 5),返回 2。lower_bound(7):第一个 >= 7 是 8 的下标 3,返回 3——这就是 7 的插入位置。
排除法:5 前面有两个 2,位置是 2 不是 1;7 插在 5 和 8 之间,位置 3 不是 4。
对 a = {5, 6, 7, 8} 调左边界模板 lowerB(a, 4, 3)(找第一个大于等于 3 的下标),返回多少?
01int l = 0, r = n; 02while (l < r) { 03 int mid = l + (r - l) / 2; 04 if (a[mid] >= x) r = mid; 05 else l = mid + 1; 06} 07return l;
规则:x 比所有元素都小时,第一个大于等于它的就是 a[0],返回 0。
考点:左边界的下界情形。
解析:a[0] = 5 >= 3,第一个满足的就是下标 0。二分会一路把 r 往左压:l=0, r=4, mid=2,a[2]=7 >= 3 真,r=2;mid=1,a[1]=6 >= 3 真,r=1;mid=0,a[0]=5 >= 3 真,r=0;l=0, r=0 退出,返回 0。
排除法:1、4、-1 都是错误方向;-1 是闭区间写法的不存在口径,本模板用 0 表示「最小位置满足」。
对无重复有序数组 a = {1, 3, 5, 7, 9} 找「最后一个小于等于 x = 5 的下标」(用「第一个大于 5 减一」),结果是多少?
01int l = 0, r = n; 02while (l < r) { 03 int mid = l + (r - l) / 2; 04 if (a[mid] <= x) l = mid + 1; 05 else r = mid; 06} 07return l;
规则:无重复数组上,右边界 <= x 就是 x 本身的位置。
考点:右边界与精确位置在无重复时的重合。
解析:第一个大于 5 的元素是 7,下标 3,减一得 2,即 5 的位置。无重复时「最后一个 <= 5」只有 5 自己,所以右边界恰好就是精确查找的答案。有重复时两者才分离(右边界 = 最后一个相等元素)。
排除法:3 是第一个大于 5 的位置(减一前的结果);1 是 3 的位置;0 是 1 的位置。
如果数组长度接近 2e9,l 和 r 都接近 1e9,下面两行求 mid 的写法有什么区别?
01int mid1 = (l + r) / 2; 02int mid2 = l + (r - l) / 2;
规则:l + (r - l) / 2 与 (l + r) / 2 数学等价,但后者在 l + r 溢出时出错,前者用差值规避溢出。
考点:防溢出的 mid 写法。
解析:int 上限约 。若 l = r = 1.5 \times 10^9,l + r = 3 \times 10^9 超过上限,结果溢出(常见是变成负数),mid 变成负下标直接越界。而 r - l 最大也不超过整个数组长度(比如 2 \times 10^9 以内),l + (r - l) / 2 每步都在合法范围内。(l + r) / 2 在竞赛中数据不大时没问题,但 l + (r - l) / 2 是写不坏的稳妥形式。
排除法:「完全等价永远一样」忽略了溢出——数学等价但机器实现会溢出;「加法比减法快」编译后基本无差别,不是设计理由;「l 很小时算负数」不可能,l + (r-l)/2 恒在 [l, r] 之间。
下面的二分代码有个 bug。用「两元素自查法」判断:区间缩到哪一步时开始不再收缩?
01int search(int a[], int n, int x) { 02 int l = 0, r = n - 1; 03 while (l < r) { 04 int mid = l + (r - l) / 2; 05 if (a[mid] == x) return mid; 06 if (a[mid] < x) l = mid; 07 else r = mid - 1; 08 } 09 return -1; 10}
规则:l = mid 配下取整,在区间只剩两个元素且 l 被保留时,l 钉住不动——自查法立刻暴露。
考点:死循环两元素自查法。
解析:取 a = {2, 4, 6}、x = 8(一直走 a[mid] < x 分支)。第 1 轮 l=0, r=2, mid=1,a[1]=4 < 8,l = mid = 1;第 2 轮 l=1, r=2, mid=1,a[1]=4 < 8,l = mid = 1——区间一直是 [1, 2]。问题精确出现在「只剩两个元素」时:mid 下取整等于 l,l = mid 不产生任何变化。正确写法是 l = mid + 1(mid 已比较过,排除)。
排除法:「a[1] > x 死循环」——a[1] > x 走 r = mid - 1 = 0,区间变空结束,不会死;「l=2, r=3」这个数据下区间只会到 [1,2];「第一轮就不收缩」第一轮正常收缩,问题在第 2 轮才开始。
实数二分求 的近似值,有人把精度写成了 while (r - l > 1e-15)。为什么这行代码可能死循环?
01double lo = 0, hi = 2; 02for (int i = 0; i < 100; i++) { 03 double mid = (lo + hi) / 2; 04 if (mid * mid < 2) lo = mid; 05 else hi = mid; 06}
规则:机器精度有限,double 大约 16 位有效数字;eps 取得比机器精度还小时,差值会「停住」不再缩小,while (r - l > eps) 永不满足退出条件。
考点:实数二分的 eps 陷阱。
解析:假设区间 [a, b] 已经缩到 这种相邻双精度值,两个数中间没有别的 double 可表示,mid = (l + r) / 2 会等于 l 或 r,r - l 停在约 ,不再变化。若 eps = 1e-15,循环条件 r - l > 1e-15 恒为真,死循环。解决:用固定迭代次数(如 100 次)或把 eps 放宽到 1e-7 左右(满足题目精度即可)。
排除法:「只是慢」——不是慢,是永远不结束;「r - l 变负」不可能,r 始终不小于 l;「sqrt 有误差」与判断逻辑无关。
「钢条切割(成本版)」:一根长 的钢条,每切一刀的费用 = 这一刀时钢条的当前长度,求把钢条切成若干小段的最小总费用。最优解依赖切割顺序。有人想用二分答案求最小费用,为什么不行?
规则:二分答案的前提是「可行性关于候选值单调」;切割成本版的最优解依赖切割顺序,可行性不单调,二分给的是安静的错误答案(不会报错)。
考点:单调性缺失时二分的失效方式。
解析:二分答案能工作的本质是:候选值 x 的可行性是「假…假 真…真」的阶梯形。切割成本版的费用取决于切割顺序(先切中间大块费用高、先切两端小块费用省),同样的目标段长集合,按不同顺序总费用不同——「费用不超过某值」不构成单调阶梯。于是二分夹出的位置可能落在非单调区间的中间,返回一个错误值,程序不崩溃也不死循环,安静地错。反例直觉:与「等长切段」版(每段长度确定、可不可行单调)对比,那才是二分适用的模型。
排除法:「整数无效」错,二分答案大量处理整数(跳石头、切蛋糕);「钢条太短」与算法正确性无关;「必须动态规划」不是不能用二分的理由,关键是单调性,而非算法类别。
二分答案「切 段等长求最大段长」,段长范围取 lo = 1、hi = 5,但正确答案其实大于 5。会发生什么?
规则:初始上界必须盖住答案;不盖住时二分会把答案「夹」到 hi 上,返回一个偏小的错误值。
考点:答案域选取,上界常取「最大可能 + 1」。
解析:二分答案的区间不变量是「答案在 [lo, hi] 内」。若真答案 6 大于 hi = 5,则区间里根本没有答案。最大可行值二分会发现 check(5) 为真(可行)就保留 5 并向上收缩,可 hi 已经是上界,最终 lo = hi = 5,返回 5——一个「最大可行」但小于真答案的值。所以工程上常把上界取成「最大可能 + 1」(如所有蛋糕长度和 + 1),宁可多一个不可行的顶格值,保证真答案在区间内。
排除法:「越界报错」二分下标不会越界,答案只是错;「死循环」正常收缩不会死;「自动扩展」代码不会自我修正。
「砍树」问题(最大化伐木高度 h)中,check(h) 为真表示「得到木材够需求」。有人却按「最小可行」的模板写收缩方向。下面哪种说法正确?
规则:check 的语义(多大可行)必须和收缩方向(l = mid 还是 r = mid)配套,否则收敛到另一侧的边界值。
考点:最大可行 / 最小可行的收缩方向。
解析:砍树中 h 越高可得木材越少,check 是「小真大假」(低高度可行、高高度不可行),所以求最大可行用 check(mid) 为真时 lo = mid(往大方向保留)。若误按「大真小假」的最小可行逻辑写(真时 hi = mid),收敛结果会偏小,得到的是「最矮的可行高度」附近的错误值。注意它不会死循环——收缩仍进行,只是答案错方向。判断方向的口诀:看 check 为真时答案应该往哪边更大/更小,然后决定保留哪一端。
排除法:「只是慢」错,答案错;「死循环」两分支都收缩不会死;「越界」无关。
把闭区间写法(while (l <= r),命中返回)改写成边界写法(while (l < r),不返回直接收缩到底),为什么要「三件套」整体改,只改循环条件行不行?
01 02int l = 0, r = n - 1; int l = 0, r = n; 03while (l <= r) { while (l < r) { 04 int mid = l + (r - l) / 2; int mid = l + (r - l) / 2; 05 if (a[mid] == x) return mid; if (a[mid] >= x) r = mid; 06 else if (a[mid] < x) l = mid + 1; else l = mid + 1; 07 else r = mid - 1; } 08} return l; 09return -1;
规则:写法迁移必须整体换「区间口径 + 收缩 + 结束判定」,只动一行必然破坏一致性。
考点:写法族之间的等价迁移。
解析:三个要素耦合在一起:① 区间定义(闭区间 [l, r] 还是半开 [l, r),决定 r 初值是 n-1 还是 n);② mid 处置(能不能排除 mid——闭区间两分支都排除,边界写法保留候选那侧不排除);③ 结束状态(l > r 返回 -1 vs l == r 直接当答案)。只把 while (l <= r) 改成 while (l < r) 而保留 r = mid - 1:当答案是 r 端点时会被 mid 取到吗?可能因 r 被 mid - 1 排除而丢解,或在相邻时死循环。所以等价迁移要么整套替换,要么用教材里现成的等价模板。
排除法:「只改循环条件」是本题纠正的误区,必出问题;「循环条件本来就一样」两种模板一个 <= 一个 <;「加 return -1 才能编译」纯属虚构,编译与写不写 return 无关。
把标准升序二分代码直接拿来对降序数组查找 x,下面哪种说法正确?
规则:降序数组的比较方向全部镜像:a[mid] > x 时目标在左、a[mid] < x 时目标在右,分支、收缩、边界三者配套翻转。
考点:降序数组的二分。
解析:升序时 a[mid] < x 说明目标在右半边(l = mid + 1);降序时 a[mid] < x 说明 a[mid] 右边更小,目标在左半边(r = mid - 1)。即 l = mid + 1 与 r = mid - 1 的角色对调,同时查边界时比较符号也反转(找第一个 <= x 之类)。若只改一处,比如只把分支条件对调而收缩方向照旧,就会两分支同时排除错误半区,答案丢失。这与 C++ 的 greater<int>() 比较器对应:把 a[mid] > x 的判定整体按 > 与 < 互换。
排除法:「直接能用」错,前提变了;「只换比较符号」漏了收缩方向也要反;「无法二分只能排序」错——降序只需镜像写,不必排序(排序反而破坏降序结构或增加开销)。
为什么二分查找在链表上没法做到 ?
规则:二分依赖 随机访问取 mid;链表只能顺序访问,每轮找中间元素都要 时间。
考点:二分的数据结构前提。
解析:数组用 a[mid] 一步取到中间元素;链表要取第 mid 个节点必须从头遍历 mid 步。一轮 O(n),共 轮,总 ——还不如直接顺序扫描()。所以对链表找元素就用顺序扫描,或用跳表/平衡树这类支持快速跳转的结构。
排除法:「不能存放整数」链表当然能放任意类型;「链表没法排序」链式归并排序是常见算法,能排序;「死循环」与数据结构无关的臆测。
一个非降序数组在某个未知位置旋转(如 {4, 5, 6, 7, 0, 1, 2}),要在里面找最小值,二分思路为什么是「先判断哪一半有序」?
规则:旋转数组折半后必有一半有序,判断哪半有序后根据「最小值在无序半」决定排除哪边, 收敛到最小值。
考点:旋转数组的二分。
解析:对 {4,5,6,7,0,1,2}:l=0, r=6, mid=3,a[mid]=7。左半 {4,5,6,7} 是递增的(a[l] <= a[mid]),说明最小值不在左半有序段,在右半 [mid+1, r];反之若右半有序,最小值在左半。每次排除一半,最后 l 停在最小值处。注意有重复元素时 a[l] == a[mid] 无法判断,需 l++ 退化处理。
排除法:「必须先排序」排序会丢失旋转信息、更慢;「只有一半能二分」两半都是候选,只是先定哪半有序;「最小值一定在中间」没有这回事,旋转点在哪最小值就在哪。
用「判断哪半有序」的二分对 a = {4, 5, 6, 7, 0, 1, 2} 找最小值(l=0, r=6,每次取 mid,若 a[mid] > a[r] 则 l = mid + 1 否则 r = mid),最后输出多少?
01int l = 0, r = n - 1; 02while (l < r) { 03 int mid = l + (r - l) / 2; 04 if (a[mid] > a[r]) l = mid + 1; 05 else r = mid; 06} 07return l;
规则:按「a[mid] > a[r] 则最小值在右半」的规则逐轮排除,最终收敛到 0。
考点:旋转数组二分的标准模板追踪。
解析:l=0, r=6, mid=3:a[3]=7 > a[6]=2,最小值在右半,l=4;l=4, r=6, mid=5:a[5]=1 > a[6]=2 假,r=5;l=4, r=5, mid=4:a[4]=0 > a[5]=1 假,r=4;l=4, r=4 退出,输出 a[4] = 0。最小值在旋转点处。
排除法:4 是数组第一个元素不是最小值;2 是最后一个元素;7 是旋转点前的最大值。
假设 int 上限是 2147483647()。若 l = 1500000000、r = 1800000000,下面哪种说法正确?
01int l = 1500000000, r = 1800000000; 02int bad = (l + r) / 2; 03int good = l + (r - l) / 2;
规则:,直接相加溢出;差值写法安全。
考点:防溢出 mid 的数值验证。
解析:l + r = 3300000000,超过 int 上限 ,溢出后是错误值(可能为负)。而 r - l = 300000000,l + (r - l) / 2 = 1500000000 + 150000000 = 1650000000,全部在 int 范围内,恰好是下取整的平均数。这就是为什么竞赛代码统一用 l + (r - l) / 2。
排除法: 明显超上限,「没超」错误;差值写法是最安全的;位运算只是另一种等价写法,不是必需。
二分答案的本质是「如果候选值 x 可行,那么比它更宽松的候选值也一定可行」。为什么有了这条单调性,二分就能收敛到答案?
规则:二分答案 = 在候选值轴上做「谓词二分」——check 的可行性单调时,分界点两侧一边全真一边全假,二分夹的就是这个分界点。
考点:二分答案的单调性论证。
解析:以切蛋糕为例:候选段长 x 越大能切出的段数越少,「能切出至少 k 段」随 x 增大从真变假,存在唯一分界点。二分每次用 mid 的可行性决定往哪半走,最终 lo == hi 落在分界点,即最优解。所以「单调性论证」不是锦上添花,是二分答案能工作的前提。
排除法:「换个名字」掩盖了本质差别——二分查找在数组下标上分、二分答案在值域上分;「排序后自然单调」排序是让下标有序,二分答案的单调来自问题本身(可行性随候选值变化);「不用检查」错了,每个二分答案题第一件事就是确认 check 是否单调。
「最大化最小值」和「最小化最大值」两类二分答案,check 的「难度方向」怎么确定?
规则:最大化最小:答案越大越难可行,check 随候选值从真变假,用 l = mid(可行保大端);最小化最大:答案越小越难可行,check 随候选值从假变真,用 r = mid(可行保小端)。
考点:check 难度方向与收缩方式配对。
解析:写 check 时问自己:「x 变大,这个条件更容易满足还是更难?」最大化最小值(跳石头:最短距离越大越难跳,可行 → 假)用 check(mid) 真时 lo = mid;最小化最大值(数列分段:段和上限越小越难分,可行 → 真随 x 增大从假变真)用 check(mid) 真时 hi = mid。两方向是镜像,背反了收敛到错误端。
排除法:「同一个 check」两方向语义不同,不能复用;「都用 l = mid」最小化最大必须 r = mid 保小;「返回真就行」方向错则答案错。
「切木材」:若干根原木长度 ,要切出至少 k 段长度都为 x 的小段(余料丢弃),check 写成 sum(L[i] / x) >= k。为什么这个 check 是对的?x = 0 为什么必须特判?
01bool check(int x) { 02 int cnt = 0; 03 for (int i = 0; i < n; i++) cnt += L[i] / x; 04 return cnt >= k; 05}
规则:floor(L_i / x) 是一根原木按段长 x 最多切出的段数,累加即总段数,随 x 单调不增——但 x = 0 会触发除零。
考点:整除求和 check 的正确性。
解析:一根长 L 的原木,每段长 x,最多切 floor(L / x) 段(余料丢)。所有原木加起来就是总段数,>= k 即可行。x 越大每根切的段越少,总段数单调不增,满足二分条件。特判 x = 0:L_i / 0 是未定义/除零错误,且如果允许 x = 0,段长 0 想切几段切几段,没有意义。所以答案区间从 1 开始。
排除法:「x=0 恒真」——若程序不先判除零就崩溃,谈何恒真;「取整为了避开小数」取整是段数必须是整数的直接要求,不是规避手段;「实数切段不适用」实数二分切段(如立方根)也存在,只是本模型用整数段数。
二分答案的题,check 的复杂度为什么必须控制在 左右?整个算法总复杂度是多少?
规则:二分答案 = 轮 × 每轮 的 check = ,其中 V 是值域,不是 n。
考点:二分答案的复杂度构成。
解析:答案候选值域的宽度是 V(如段长 ),每轮二分排除一半,需要 轮。每轮要完整扫一遍数组算 check(贪心计数),即 。相乘得 。、 时约 次操作,轻松通过;若 check 写成 ,总 就超时。常见误区是把值域 V 当成 n,说成「」。
排除法:「」混淆了值域与数据规模;「check 只做一次」二分要做几十轮;「与 check 无关」复杂度恰由 check 主导。
「切蛋糕」: 条蛋糕,长度分别为 a = {6, 8, 3, 5},要切出至少 k = 10 段等长蛋糕,每段长度是整数。求最大可能的段长。
先看边界数据:若 x = 3,四块蛋糕分别能切 6/3 = 2、8/3 = 2、3/3 = 1、5/3 = 1,共 6 段。那么下面哪种说法正确?
01bool check(int x) { 02 int cnt = 0; 03 for (int i = 0; i < n; i++) cnt += L[i] / x; 04 return cnt >= k; 05}
规则:边界数据先验 check——x = 3 只能切 段 < ,不可行;答案要往更小的 x 找。
考点:二分答案的边界数据考法(先手算端点 check)。
解析:check 值 = 各块 floor(a[i]/x) 求和。x = 3 时 2 + 2 + 1 + 1 = 6 < 10,不可行。因为 x 越小段越多,答案(最大可行段长)必然小于 3。往下试:x = 2 时 3 + 4 + 1 + 2 = 10 >= 10 可行;x = 1 时 6 + 8 + 3 + 5 = 22 >= 10 可行。所以答案是 2。边界考法就是先手算 x 取上/下界时 check 的值,判断答案在哪一侧。
排除法:「 段 > 一半所以可行」——可行性看 >= 10, 就是不可行;「答案就是 3」最大可行是 2 不是 3;「答案在 6 附近」段长不可能超过最短蛋糕 3,思路错。
题目: 条蛋糕,长度分别为 a = {6, 8, 3, 5},要把每条蛋糕切成等长小段(余料丢弃,不拼接),共得到至少 k = 10 段。每段长度必须是正整数。求能得到的最大段长。
01#include <iostream> 02using namespace std; 03int a[5] = {6, 8, 3, 5}; 04int n = 4, k = 10; 05bool check(int x) { 06 int cnt = 0; 07 for (int i = 0; i < n; i++) cnt += a[i] / x; 08 return cnt >= k; 09} 10int main() { 11 int lo = 1, hi = 1000000; 12 while (lo < hi) { 13 int mid = lo + (hi - lo + 1) / 2; 14 if (check(mid)) lo = mid; 15 else hi = mid - 1; 16 } 17 cout << lo << endl; 18 return 0; 19}
本题第一问:程序最终输出多少?(最大可行段长)
(1 分)本题第二问:为什么 mid 要写成上取整 lo + (hi - lo + 1) / 2?
本题第三问:若把 hi 初始值改成 3,程序输出多少?
本题第四问:若误把 check 的返回条件写成 cnt > k(严格大于)会怎样?
本题第五问:若数组改成 a = {4, 4}、k = 2,程序输出多少?
题目(NOIP2015 跳石头):河道从起点(0 米)到终点(L 米)有若干块岩石,选手从起点跳到终点,必须踩在岩石上。组委会最多能移走 m 块岩石(起点终点的岩石不能移),求「最短跳跃距离」的最大值。
本题数据:L = 25,岩石位置(不含起点 0 和终点 25)为 a = {2, 11, 14, 17, 21}(n = 5),m = 2(最多移走 2 块)。
01#include <iostream> 02using namespace std; 03int L = 25, n = 5, m = 2; 04int a[6] = {0, 2, 11, 14, 17, 21}; 05bool check(int d) { 06 int cnt = 0, last = 0; 07 for (int i = 1; i <= n; i++) { 08 if (a[i] - last < d) cnt++; 09 else last = a[i]; 10 } 11 return cnt <= m; 12} 13int main() { 14 int lo = 1, hi = L; 15 while (lo < hi) { 16 int mid = lo + (hi - lo + 1) / 2; 17 if (check(mid)) lo = mid; 18 else hi = mid - 1; 19 } 20 cout << lo << endl; 21 return 0; 22}
本题第一问:程序最终输出多少?
(1 分)本题第二问:check 里为什么是「a[i] - last < d 就挪走」这个贪心?
本题第三问:为什么终点 L 不用放进 a[] 一起判断,但 check 最后要保证终点可达?
本题第四问:若把 m 改成 1(只能挪 1 块),程序输出多少?
本题第五问:若 last 初值误写成 a[0](即 last = 2),会怎样?
题目(牛棚放牛 / Aggressive cows):n 个牛棚位置(坐标),把 M 头奶牛放进棚里,任意两头牛的距离必须至少是 d。求最大可能的 d。
本题数据:牛棚位置 a = {1, 2, 8, 4, 9},排序后为 {1, 2, 4, 8, 9},n = 5,M = 3 头牛。
01#include <iostream> 02#include <algorithm> 03using namespace std; 04int a[6] = {1, 2, 8, 4, 9}; 05int n = 5, M = 3; 06bool check(int d) { 07 int cnt = 1; 08 int last = a[0]; 09 for (int i = 1; i < n; i++) { 10 if (a[i] - last >= d) { 11 cnt++; 12 last = a[i]; 13 } 14 } 15 return cnt >= M; 16} 17int main() { 18 sort(a, a + n); 19 int lo = 1, hi = 1000000; 20 while (lo < hi) { 21 int mid = lo + (hi - lo + 1) / 2; 22 if (check(mid)) lo = mid; 23 else hi = mid - 1; 24 } 25 cout << lo << endl; 26 return 0; 27}
本题第一问:程序最终输出多少?
(1 分)本题第二问:为什么 check 里第一头牛直接放在 a[0]?
本题第三问:跳石头 check(a[i] - last < d 挪走)和本题 check(a[i] - last >= d 放下),两种写法什么关系?
本题第四问:若把 check 里的 a[i] - last >= d 误写成 >,对 d = 3 结果如何?
本题第五问:若牛棚位置改为 a = {1, 3, 5, 7, 9}、M = 3,输出多少?
题目(洛谷 P1873 砍树):n 棵树,高度 。把锯子设在一个高度 h,锯掉所有高于 h 的树冠,收集到木材的总量必须 ≥ need 立方米。求最大可能的 h。
本题数据:树高 a = {20, 15, 10, 17},n = 4,需求 need = 7 立方米。
01#include <iostream> 02using namespace std; 03int a[5] = {20, 15, 10, 17}; 04int n = 4, need = 7; 05bool check(int h) { 06 long long tot = 0; 07 for (int i = 0; i < n; i++) { 08 if (a[i] > h) tot += a[i] - h; 09 } 10 return tot >= need; 11} 12int main() { 13 int lo = 1, hi = 1000000000; 14 while (lo < hi) { 15 int mid = lo + (hi - lo + 1) / 2; 16 if (check(mid)) lo = mid; 17 else hi = mid - 1; 18 } 19 cout << lo << endl; 20 return 0; 21}
本题第一问:程序最终输出多少?
(1 分)本题第二问:check 随 h 怎么变化?这决定收缩方向吗?
本题第三问:为什么 tot 要用 long long?
本题第四问:若把 need 改成 3,输出多少?
本题第五问:若把主循环误写成最小可行版(check(mid) 真时 hi = mid),need = 7 时输出多少?
题目(洛谷 P1182 数列分段):把 n 个正整数分成连续的若干段,段数不能超过 m 段,要求「最大段和」最小。求这个最小值。
本题数据:a = {4, 2, 4, 5, 1},n = 5,m = 3(最多 3 段)。
01#include <iostream> 02using namespace std; 03int a[6] = {4, 2, 4, 5, 1}; 04int n = 5, m = 3; 05bool check(int x) { 06 int cnt = 1; 07 int cur = 0; 08 for (int i = 0; i < n; i++) { 09 if (cur + a[i] <= x) cur += a[i]; 10 else { cnt++; cur = a[i]; } 11 } 12 return cnt <= m; 13} 14int main() { 15 int lo = 5, hi = 16; 16 while (lo < hi) { 17 int mid = lo + (hi - lo) / 2; 18 if (check(mid)) hi = mid; 19 else lo = mid + 1; 20 } 21 cout << lo << endl; 22 return 0; 23}
本题第一问:程序最终输出多少?
(1 分)本题第二问:为什么答案区间下界取 lo = 5(最大单个元素)?
本题第三问:为什么 mid 用下取整,且 check 真时 hi = mid?
本题第四问:若把「不超过 段」误写成「恰好 段」的 check(cnt == m),会怎样?
本题第五问:若数组改为 a = {1, 2, 3, 4, 5}、m = 2,输出多少?
题目:不排序,求 n 个数中第 k 小(从 1 开始计)的数。
本题数据:a = {5, 3, 7, 3, 9},n = 5,k = 3(第三小)。
01#include <iostream> 02using namespace std; 03int a[6] = {5, 3, 7, 3, 9}; 04int n = 5, k = 3; 05int countLE(int x) { 06 int cnt = 0; 07 for (int i = 0; i < n; i++) if (a[i] <= x) cnt++; 08 return cnt; 09} 10int main() { 11 int lo = 1, hi = 100; 12 while (lo < hi) { 13 int mid = lo + (hi - lo) / 2; 14 if (countLE(mid) >= k) hi = mid; 15 else lo = mid + 1; 16 } 17 cout << lo << endl; 18 return 0; 19}
本题第一问:程序最终输出多少?
(1 分)本题第二问:为什么第 k 小要在「值域」上二分而不是先排序再按下标二分?
(1 分)本题第三问:为什么 count(<=mid) >= k 时收缩 hi = mid?
本题第四问:若数据改为 a = {10, 20, 30}、k = 2,值域改成 [1, 100],输出多少?
本题第五问:这个「值域二分」与「二分查找在数组上找数」的本质区别是什么?
(1 分)「跳石头」是移走岩石让最短跳跃距离最大;「路标设置」是加路标让最大空旷距离最小。为什么说它们是同一情境的镜像?
规则:镜像关系 = 同一模型的两个方向:跳石头限制「最短距离的下界」,路标限制「最大空隙的上界」,check 一个数删、一个数加。
解析:跳石头:给定石头间距下限 d,问要挪多少块 ≤ m——最大化 d(最小化最大值对称形式)。路标设置:给定最大空隙上限 x,问要加多少路标 ≤ m——最小化 x。路标的 check:对每段相邻路标 pos - last > x 时,在中间补 (pos - last - 1) / x 个路标,累加判断 ≤ m。两者 check 的「计数方向相反」:跳石头跳不过去就删,路标空隙太大就加。
排除法:「毫无关系」忽视同构性;「多一个变量」都是固定 L、n、m 三个参数;「互相照抄」计数方向相反,照抄就错。
牛棚位置 a = {1, 4, 8, 10, 14},用奶牛 check(cnt = 1, last = a[0],a[i] - last >= d 就放)检查 d = 4,最后 cnt 是多少?能放下 M = 3 头牛吗?
01int cnt = 1, last = a[0]; 02for (int i = 1; i < n; i++) 03 if (a[i] - last >= d) { cnt++; last = a[i]; } 04return cnt >= M;
规则:手动跑一遍 check——d = 4 时在 {1,4,8,10,14} 里能放 3 头(位置 1、8、14)。
考点:奶牛 check 的数值追踪。
解析:last=1, cnt=1;a[1]=4,4-1=3 < 4 不放;a[2]=8,8-1=7 >= 4,放,cnt=2,last=8;a[3]=10,10-8=2 < 4 不放;a[4]=14,14-8=6 >= 4,放,cnt=3,last=14。cnt = 3 >= M = 3,可行。
排除法:cnt=2 是漏数(8 和 14 都被放上);cnt=4 不可能(5 个棚最多放 3 头时 cnt 也不会到 4);cnt=3 显然满足放 3 头的需求,不是放不下。
路标设置:一条 L = 10 米的公路,起点 0、终点 10 处各有一个路标,中间已有 1 个路标在位置 3。最多可以再设 m = 2 个路标,要求「相邻路标的最大间距」尽量小。
用 check(x):若相邻间距 gap > x,就每隔 x 补一个路标。运行下面的程序,输出多少?
01#include <iostream> 02using namespace std; 03int pos[4] = {0, 3, 10}; 04int n = 3, L = 10, m = 2; 05bool check(int x) { 06 int cnt = 0; 07 for (int i = 1; i < n; i++) { 08 int gap = pos[i] - pos[i - 1]; 09 cnt += (gap - 1) / x; 10 } 11 return cnt <= m; 12} 13int main() { 14 int lo = 1, hi = L; 15 while (lo < hi) { 16 int mid = lo + (hi - lo) / 2; 17 if (check(mid)) hi = mid; 18 else lo = mid + 1; 19 } 20 cout << lo << endl; 21 return 0; 22}
规则:最多补 2 个路标时,最小化最大间距,答案是 3。
考点:路标设置(与跳石头镜像的最小化问题)。
解析:cnt += (gap - 1) / x 的意思是:一段 gap 米要分成若干小段使每段不超过 x,最少需要 ceil(gap / x) - 1 个新路标,而 ceil(gap/x) - 1 在整数除法下恰等于 (gap - 1) / x。
x = 3:gap1 = 3 - 0 = 3,(3-1)/3 = 0 个;gap2 = 10 - 3 = 7,(7-1)/3 = 2 个。cnt = 2 ≤ 2 可行(3 到 10 之间补 2 个,如 3, 5, 8, 10,各段 2,3,2 都 ≤ 3)。
x = 2:gap1 = 3,(3-1)/2 = 1 个;gap2 = 7,(7-1)/2 = 3 个。cnt = 4 > 2 不可行。所以答案是 3。
排除法:4 也可行但 3 更小;2 需要补 4 个路标超上限;5 可行但不是最小。
「月度开销」是数列分段的双胞胎:把 天每天的花费 a[i] 分成连续的 m 个月(月),使「开销最大的月」最小。数据:a = {1, 2, 3, 4, 5}、n = 5、m = 3。
套用数列分段的 check(cnt 个月,段和 ≤ x),最大月开销最小为多少?
01bool check(int x) { 02 int cnt = 1; long long cur = 0; 03 for (int i = 0; i < n; i++) { 04 if (cur + a[i] <= x) cur += a[i]; 05 else { cnt++; cur = a[i]; } 06 } 07 return cnt <= m; 08}
规则:5 个数分 3 段,最小化最大段和,答案是 6。
解析:x = 6:1,2,3 塞(cur=6);4 → 10 > 6,cnt=2,cur=4;5 → 9 > 6,cnt=3,cur=5。cnt=3 ≤ 3 可行。x = 5:1,2 → cur=3;3 → 6 > 5,cnt=2,cur=3;4 → 7 > 5,cnt=3,cur=4;5 → 9 > 5,cnt=4,cur=5。cnt=4 > 3 不可行。答案 6。
排除法:5 段数超限;7 可行但 6 更小;9 可行但不是最小。
「抄书/书本分配」:n 本书每本页数 a[i],按顺序分给 k 个人,每人至少一本、连续分配,求「抄书页数最多的人」最少是多少页。数据:a = {10, 20, 30, 40}、n = 4、k = 2。
用「最大页数和 ≤ x」的 check,最小化最大页数,答案是多少?
01bool check(int x) { 02 int cnt = 1; long long cur = 0; 03 for (int i = 0; i < n; i++) { 04 if (cur + a[i] <= x) cur += a[i]; 05 else { cnt++; cur = a[i]; } 06 } 07 return cnt <= m; 08}
规则:4 本书分 2 人,最小化最大页数,答案是 60(如 [10,20,30] 和 [40])。
解析:x = 60:10,20,30 塞(cur=60);40 → 100 > 60,cnt=2,cur=40。cnt=2 ≤ 2 可行。x = 50:10,20 → cur=30;30 → 60 > 50,cnt=2,cur=30;40 → 70 > 50,cnt=3,cur=40。cnt=3 > 2 不可行。答案 60。
排除法:70 可行(如 [10,20,30] 和 [40] 最大 60,或 [10,20] [30,40] 最大 70)但 60 更小;90 不是最优;50 分 2 人不够。
木材加工厂有 n = 4 根原木,长度 a = {10, 7, 8, 4},要切出至少 k = 6 段长度相等的木材(余料丢弃)。用「每根 floor(a[i]/x) 段」的 check,求最大段长。
(背景换成木材,模型和切蛋糕完全一样。)
01bool check(int x) { 02 int cnt = 0; 03 for (int i = 0; i < n; i++) cnt += L[i] / x; 04 return cnt >= k; 05}
规则:换背景不改模型——二分最大段长,答案是 4。
考点:双胞胎模型的识别(切蛋糕/切木头)。
解析:x = 4:10/4 + 7/4 + 8/4 + 4/4 = 2 + 1 + 2 + 1 = 6 >= 6 可行。
x = 5:10/5 + 7/5 + 8/5 + 4/5 = 2 + 1 + 1 + 0 = 4 < 6 不可行。所以最大段长是 4。
排除法:5 只能切 4 段不够;6 段数更少;3 可行但段长太小。
复印店要把一份长文档按页连续分给 k = 3 个复印员,每份页数为 a = {7, 2, 5, 10, 8}(共 5 份),求「页数最多的那份」最少是多少页?(每人至少分到连续的一份)
01bool check(int x) { 02 int cnt = 1; long long cur = 0; 03 for (int i = 0; i < n; i++) { 04 if (cur + a[i] <= x) cur += a[i]; 05 else { cnt++; cur = a[i]; } 06 } 07 return cnt <= m; 08}
规则:连续段分割的最小化最大,答案是 14。
考点:书本分配模型的识别。
解析:x = 14:7,2 塞(cur=9);5 → 14 塞(cur=14);10 → 24 > 14,cnt=2,cur=10;8 → 18 > 14,cnt=3,cur=8。cnt=3 ≤ 3 可行([7,2,5]、[10]、[8] 最大 14)。
x = 13:7,2 → 9;5 → 14 > 13,cnt=2,cur=5;10 → 15 > 13,cnt=3,cur=10;8 → 18 > 13,cnt=4,cur=8。cnt=4 > 3 不可行。答案 14。
排除法:12 不可行;15、16 可行但不是最小。
题目(钢条等长切段):工厂有 n 根钢条,长度分别为 a = {9, 8, 7, 6, 5} 米。要把它们切成若干等长的小段(余料丢弃、不能拼接),一共要得到至少 k = 5 段。求最大可能的段长。
01#include <iostream> 02using namespace std; 03int a[6] = {9, 8, 7, 6, 5}; 04int n = 5, k = 5; 05bool check(int x) { 06 int cnt = 0; 07 for (int i = 0; i < n; i++) cnt += a[i] / x; 08 return cnt >= k; 09} 10int main() { 11 int lo = 1, hi = 9; 12 while (lo < hi) { 13 int mid = lo + (hi - lo + 1) / 2; 14 if (check(mid)) lo = mid; 15 else hi = mid - 1; 16 } 17 cout << lo << endl; 18 return 0; 19}
本题第一问:程序最终输出多少?
(1 分)本题第二问:为什么第二步答案域取 [1, 9] 而不是 [1, 100]?
本题第三问:check 里用 a[i] / x 整除,9 / 5 = 1,为什么不是 1.8?
本题第四问:若把 k 改成 8,程序输出多少?
本题第五问:若把主循环误写成最小可行版(check(mid) 真时 hi = mid),k = 5 时输出多少?
下面的跳石头 check 有个 bug:它把 a[n] 当成了最后一块岩石,循环里检查了 a[i] - last 后结束,却没有单独检查最后一段(终点到 last)的距离。
01bool check(int d) { 02 int cnt = 0, last = 0; 03 for (int i = 1; i <= n; i++) { 04 if (a[i] - last < d) cnt++; 05 else last = a[i]; 06 } 07 return cnt <= m; 08}
规则:终点不是「可挪的岩石」,它是必须到达的约束;check 里漏掉终点段判断,会把「跳不到终点」的 d 判成可行。
考点:跳石头 check 的终点边界。
解析:循环扫描的是岩石 a[1]..a[n],其中 a[n] 恰好是终点 L。若 a[n] - last < d,代码把它当「最后一块跳不过去的岩石」挪走(cnt++)——但终点不能挪!挪走终点后比赛直接结束,这不是合法方案。正确做法是把终点距离单独判定:if (L - last < d) return false;(跳不到终点则 d 不可行)。
排除法:把 a[n] 当可挪岩石就是 bug 所在,不是「天然被处理」;cnt++ 方向没错;last 初值必须是起点 0,不是 a[1]。
切蛋糕要求「切出至少 k 段」,check 写 cnt >= k。为什么改成「恰好 k 段」(cnt == k)答案不变?
01bool check(int x) { 02 int cnt = 0; 03 for (int i = 0; i < n; i++) cnt += L[i] / x; 04 return cnt >= k; 05}
规则:只要「恰好 k 段」可行,它的最优值与「至少 k 段」一致——最优值满足 ≤ k 段时总能通过拆分凑成恰好 k 段。
考点:至少/恰好段数的等价性。
解析:最优段长 x 是使 cnt >= k 成立的最大值。此时若 cnt > k(切多了),我们总能从某段上再切一刀(段长仍 ≥ 某正整数)把总段数凑到恰好 k——只要切出的段长仍不小于 x 且凑得上。所以「恰好 k 段」的可行域包含了「至少 k 段」的最优解,最优值相同。反之若恰好 k 可行,那它当然也是至少 k 可行。两个语义在二分答案里答案一致,这也是「至少/恰好」不必写两个 check 的原因。
排除法:「永远等于 k」不是必然,cnt 可能大于 k;「排序固定段数」与语义无关;「>= 与 == 等价」是本题要澄清的误区——答案相同是因为可行域边界重合,不是运算符等价。
数组已升序排序,下面关于 C++ STL 三个二分函数的说法,哪个正确?
规则:三件套分工:binary_search 只回答「有没有」,返回 bool;lower_bound 找第一个 >= x;upper_bound 找第一个 > x。
考点:STL 二分三件套。
解析:binary_search 内部就是做一次二分,找到就返回 true;lower_bound / upper_bound 返回迭代器(指针),差一就在 > 和 >=。它们都要求数组有序。
排除法:binary_search 不返回下标(那是 find);lower_bound 是 >= 不是 >;「返回值相同」是搞混了三个函数。
为什么 lower_bound 返回「第一个大于等于 x」,而 upper_bound 返回「第一个大于 x」,一个带等号一个不带?
规则:lower_bound(第一个 >= x)与 upper_bound(第一个 > x)恰好框出「所有等于 x 的元素」区间,区间长度就是出现次数。
考点:STL 差一设计的意义。
解析:对 a = {1,2,2,2,3},x = 2:lower_bound 返回下标 1(第一个 >= 2),upper_bound 返回下标 4(第一个 > 2)。[1, 4) 恰是三个 2 的连续区间,4 - 1 = 3 就是出现次数。这个「差一」不是随意定的,它让「数值区间 ↔ 下标区间」完全对应。
排除法:「给更大范围」没有依据;「C++ 规定」不存在这样的规定;「必须两个都返回」恰恰相反,它们各自返回区间两端。
下面关于 lower_bound / upper_bound 返回值的说法,哪个正确?
规则:lower_bound/upper_bound 返回迭代器;end() 是哨兵,不可解引用。
考点:STL 迭代器用法。
解析:STL 容器元素访问靠迭代器。auto it = lower_bound(v.begin(), v.end(), x); 后,it - v.begin() 是下标;*it 是元素。若 it == v.end(),表示整个容器都小于/等于 x(无满足者),v.end() 指向容器尾后位置,解引用是未定义行为。
排除法:直接返回下标是数组版手写函数的特征,STL 返回迭代器;end() 不是最后一个元素,是「尾后」;STL 用 end() 不用 NULL。
对空数组 v 调 lower_bound(v.begin(), v.end(), 5),返回 v.end()。为什么不能写 *it 访问?
规则:end() 是「尾后哨兵」,指向最后一个元素之后,不是合法元素,解引用未定义。
考点:STL 的空容器与 end() 语义。
解析:end() 设计出来就是「区间结束」的标记,它可能是容器最后一个元素的下一个位置。空数组的 begin() == end(),lower_bound 立即返回 end()。判断「找不到」的方式是 it == v.end(),而不是解引用。
排除法:「end() 是最后一个元素」错,最后一个元素是 end() - 1;「空数组应返回 -1」STL 的设计是返回 end(),没有 -1;「先排序」与能否解引用无关,lower_bound 本来就要求有序。
运行下面的程序,输出多少?
01#include <iostream> 02#include <algorithm> 03using namespace std; 04int main() { 05 int a[7] = {1, 2, 2, 2, 3, 3, 4}; 06 int x = 2; 07 int L = lower_bound(a, a + 7, x) - a; 08 int R = upper_bound(a, a + 7, x) - a; 09 cout << L << " " << R << " " << R - L << endl; 10 return 0; 11}
规则:出现次数 = upper_bound(x) - lower_bound(x)。
考点:用 STL 数重复元素个数。
解析:a = {1,2,2,2,3,3,4}。第一个 >= 2 的位置是下标 1(a[1] = 2),所以 L = 1。第一个 > 2 的位置是下标 4(a[4] = 3),所以 R = 4。R - L = 3 正是三个 2 的个数。
排除法:L = 1 而不是 0(a[0] = 1 < 2);R = 4 而不是 3(a[3] = 2 不大于 2);R - L = 3 不是 2。
a = {1, 3, 5, 7},要在有序数组里插入 x = 4 且保持有序,应该插在哪个位置?(下标从 0 开始)
01int l = 0, r = n; 02while (l < r) { 03 int mid = l + (r - l) / 2; 04 if (a[mid] >= x) r = mid; 05 else l = mid + 1; 06} 07return l;
规则:插入位置 = 第一个大于等于 x 的位置 = lower_bound 的返回值。
考点:边界二分与插入位置。
解析:a = {1,3,5,7},x = 4。第一个 >= 4 的元素是 5,下标 2。把 4 插在下标 2,数组变 {1,3,4,5,7},依然有序。这与前面学的「闭区间写法查不到时 l 是第一个大于 x 的位置」一致——插入位置就是边界二分的结果。
排除法:下标 1 是 3 的位置,4 插在 3 后面而不是前面;下标 3 是 7 的位置,但 5 已经比 4 大,插在 5 之前即可;下标 4 只在 x 大于所有元素时。
有人说「质数在数列里的位置可以用二分找」。数组 a = {4, 6, 9, 10, 2, 3, 5, 7}(前四个非质数、后四个质数),下面哪种说法正确?
01int l = 0, r = n; 02while (l < r) { 03 int mid = l + (r - l) / 2; 04 if (isPrime(a[mid])) r = mid; 05 else l = mid + 1; 06} 07return l;
规则:谓词二分成立的条件是「判定关于下标单调」——序列按性质分段(假…假 真…真);质数散布时不满足,二分失效。
考点:谓词二分的适用边界。
解析:{4, 6, 9, 10, 2, 3, 5, 7} 中,isPrime 的取值序列是「假假假假 真真真真」,单调分段,mid 落在质数区则第一个质数在左侧,落在非质数区则第一个质数在右侧(因为前面全非质),可以排除一半。而 {2, 9, 5, 7, 4} 取值「真 假 真 真 假」不单调,mid 处是真是假都无法确定答案在哪边。
排除法:「任何数组都能」恰恰是误区,单调性才是本质;「质数有偶数个」与质数分布无关;「先算所有质数」那是筛法,不是二分的任务。
运行下面的程序,输出多少?
01#include <iostream> 02using namespace std; 03bool isPrime(int x) { 04 if (x < 2) return false; 05 for (int i = 2; i * i <= x; i++) 06 if (x % i == 0) return false; 07 return true; 08} 09int main() { 10 int a[8] = {4, 6, 9, 10, 2, 3, 5, 7}; 11 int n = 8; 12 int l = 0, r = n; 13 while (l < r) { 14 int mid = l + (r - l) / 2; 15 if (isPrime(a[mid])) r = mid; 16 else l = mid + 1; 17 } 18 cout << l << endl; 19 return 0; 20}
规则:第一个质数是 a[4] = 2,下标 4。
考点:谓词二分在分段序列上的追踪。
解析:模拟:l=0, r=8, mid=4,isPrime(a[4]=2) 真,r=4;l=0, r=4, mid=2,isPrime(a[2]=9) 假,l=3;l=3, r=4, mid=3,isPrime(a[3]=10) 假,l=4;l=4, r=4 退出,返回 4。a[4] = 2 是第一个质数。
排除法:0 是 4 的下标,非质数;5 是 3 的下标,那是第二个质数;7 是 7 的下标,最后一个质数。
「二分的本质」是什么?下面哪种说法最准确?
规则:二分本质 = 谓词单调(可行/满足性质随位置分段),有序数组是它的特例而非全部。
考点:二分思想的泛化。
解析:普通二分:「a[mid] 与 x 比大小」这个判定随 mid 递增而单调(前面小、后面大)。谓词二分:判定换成「是否质数」「是否可行」,只要它在下标/候选值上单调分段,照样二分。所以「有序」不是目的,「可排除一半」才是——而排除一半依赖判定单调。
排除法:「本质是数组有序」缩小了二分的适用范围;「本质是递归」递归是实现手段;「每次取中间比较」是操作细节,没说到「为什么能排除一半」。
题目(洛谷 P1102 a-b 数对):给 n 个数和整数 C,统计有多少个数对 (i, j) 满足 a[i] - a[j] = C 且 i != j。
本题数据:a = {1, 5, 3, 4, 2},n = 5,C = 2。
01#include <iostream> 02#include <algorithm> 03using namespace std; 04int a[6] = {1, 5, 3, 4, 2}; 05int n = 5, C = 2; 06int main() { 07 sort(a, a + n); 08 long long cnt = 0; 09 for (int i = 0; i < n; i++) { 10 int t = a[i] - C; 11 int L = lower_bound(a, a + n, t) - a; 12 int R = upper_bound(a, a + n, t) - a; 13 cnt += R - L; 14 } 15 cout << cnt << endl; 16 return 0; 17}
本题第一问:程序最终输出多少?
(1 分)本题第二问:为什么对每个 a[i] 只要数 a[i] - C 的出现次数就够?
本题第三问:若把 C 改成 0,程序输出多少?(注意题意要求 i != j)
本题第四问:若数据改成 a = {1, 1, 2, 3, 3}、C = 2,程序输出多少?
本题第五问:整个算法的时间复杂度是多少?
(1 分)题目:用实数二分求一个非负数 x 的立方根,误差不超过 。
本题数据:x = 27 和 x = 2。
01#include <iostream> 02using namespace std; 03double cubeRoot(double x) { 04 double lo = 0, hi = x; 05 if (x < 1) hi = 1; 06 for (int i = 0; i < 100; i++) { 07 double mid = (lo + hi) / 2; 08 if (mid * mid * mid < x) lo = mid; 09 else hi = mid; 10 } 11 return lo; 12} 13int main() { 14 cout << cubeRoot(27.0) << endl; 15 cout << cubeRoot(2.0) << endl; 16 return 0; 17}
本题第一问:cubeRoot(27.0) 输出多少?
本题第二问:为什么推荐固定迭代 100 次,而不是 while (r - l > 1e-6)?
本题第三问:初始区间 [0, 27],迭代 轮后区间长度大约是多少?
本题第四问:若把 mid^3 < x 误写成 mid^3 > x(比较方向写反),cubeRoot(27.0) 会输出什么?
本题第五问:对 cubeRoot(2.0),程序输出大约是多少?
一元三次方程 x^3 - 3x + 1 = 0 在 内可能有 3 个根。为什么不能直接对整个区间 [-10, 10] 做二分找根?
规则:二分求根的前提是「区间内恰有一个根」——先隔离根(把含根小区间逐个找出来),再逐段二分。
考点:三次方程的根隔离。
解析:标准做法:先求出函数在整数点上的值 f(-10), f(-9), …, f(10),符号变化的相邻整数区间内必有根(中值定理),每个小区间内再二分。三个根分别落在不同小区间,各段二分互不干扰。若直接对 [-10, 10] 二分,mid 附近可能含多个根,f(mid) 的符号不能可靠指示根在哪边,结果可能是错根或根被跳过。
排除法:三次方程(实系数奇次)必有实根;二分完全能处理实数;「太大超时」不是问题——二分 轮与区间大小无关。
对 f(x) = x^3 - x - 2,已知 f(1) = -2 < 0、f(2) = 4 > 0,在区间 [1, 2] 内二分求根(每次取 mid = (lo + hi) / 2,f(mid) < 0 则 lo = mid,否则 hi = mid)。做 3 轮后,区间变成多少?
01double lo = 1, hi = 2; 02for (int i = 0; i < 100; i++) { 03 double mid = (lo + hi) / 2; 04 if (f(mid) < 0) lo = mid; 05 else hi = mid; 06}
规则:逐轮按 f(mid) 符号更新端点,3 轮后区间为 [1.5, 1.625]。
解析:
轮 1:mid = 1.5,f(1.5) = 3.375 - 1.5 - 2 = -0.125 < 0,lo = 1.5,区间 [1.5, 2];
轮 2:mid = 1.75,f(1.75) = 5.359 - 1.75 - 2 = 1.609 > 0,hi = 1.75,区间 [1.5, 1.75];
轮 3:mid = 1.625,f(1.625) = 4.291 - 1.625 - 2 = 0.666 > 0,hi = 1.625,区间 [1.5, 1.625]。
真实根约 ,落在该区间内。
排除法:[1.25, 1.5] 少做了一轮;[1.5, 1.75] 只做了两轮;[1.625, 1.75] 方向算反了。
二分查找的最大比较次数 在 、、 时分别是多少?
规则::、、。
考点:比较次数手算表。
解析::,1 次比较就能区分两个位置,。:,2 次。:,4 次。与 、 一起构成常用手算表。
排除法: 是 1 不是 2; 是 2 不是 3; 是 4 不是 5 或 8。
「山脉数组」a 满足先递增后递减(如 {1, 3, 5, 4, 2}),要求峰值位置。为什么对 mid 和 mid + 1 比较就能二分排除一半?
01int peakIndex(int a[], int n) { 02 int l = 0, r = n - 1; 03 while (l < r) { 04 int mid = l + (r - l) / 2; 05 if (a[mid] < a[mid + 1]) l = mid + 1; 06 else r = mid; 07 } 08 return l; 09}
规则:把「还在上坡吗」当作谓词——上坡段在左、下坡段在右,谓词单调分段,所以能二分找分界(峰)。
考点:山脉数组二分(与三分求函数极值的区别)。
解析:山脉数组的下标域被峰值分成两段:0..peak 递增、peak..n-1 递减。「a[mid] < a[mid+1]」(还在上坡)这个判定在下标上是「假…假 真…真」还是「真…真 假…假」?看数据 {1,3,5,4,2}:mid=2(a[2]=5),a[2] < a[3] 即 5 < 4 假(下坡)。在递增段 a[mid] < a[mid+1] 恒真,递减段恒假——谓词单调,二分夹出分界点即峰值位置。
注意这跟「三分求单峰函数极值」不是一回事:山脉数组是下标域的谓词单调(用 mid 与 mid+1 比较即可二分),单峰函数是值域的极值问题(需三分)。
排除法:「二次函数算顶点」混淆了模型;「必须找中间点」二分本身就在找;「峰值一定在中间」数据是 {1,3,5,4,2} 峰在中间恰好,但换成 {1,2,3,1} 峰在别处,谓词照样能找。
用上面的 peakIndex 代码对 a = {1, 2, 3, 4, 3, 1} 运行,函数返回多少?
01int l = 0, r = n - 1; 02while (l < r) { 03 int mid = l + (r - l) / 2; 04 if (a[mid] < a[mid + 1]) l = mid + 1; 05 else r = mid; 06} 07return l;
规则:峰值是 4,下标 3。
解析:n = 6。l=0, r=5, mid=2:a[2]=3 < a[3]=4 真(上坡),l = 3;l=3, r=5, mid=4:a[4]=3 < a[5]=1 假(下坡),r = 4;l=3, r=4, mid=3:a[3]=4 < a[4]=3 假,r = 3;l=3, r=3 退出,返回 3。a[3] = 4 是峰值。
排除法:2 是 3 的位置(上坡中);4 是第二个 3 的位置;5 是 1 的位置。
简化贷款模型:借 100 元,按年利率 r 复利两年,两年后要还 121 元。求年利率 r 使 100(1+r)^2 = 121。下面用实数二分求 r(r 在 [0, 1],pay = 100*(1+r)*(1+r),pay < 121 则 lo = r,否则 hi = r)。这个 r 大约是多少?
01double lo = 0, hi = 1; 02for (int i = 0; i < 100; i++) { 03 double mid = (lo + hi) / 2; 04 double pay = 100 * (1 + mid) * (1 + mid); 05 if (pay < 121) lo = mid; 06 else hi = mid; 07}
规则:解方程 ,得 ,。
考点:贷款利率的实数二分模型。
解析: 两边除 100 得 。开方( 取正根)得 ,。二分收敛到 附近。真实贷款模型(P1163)是按月等额本息,check 里每月按 (1 + 月利率) 计息累减,思想一致——先验证方程单调,再二分利率。
排除法: 时 ,; 更高更不对; 是把利率和还款倍数混为一谈。
三分法(每次取两个三分点比较)用来求单峰函数的极值。为什么二分法不能直接用来找单峰函数的峰值?
规则:二分靠「一个点与目标的比较」排除一半,这在有序数组上行(值单调),但单峰函数的值先增后减,f(mid) 一个点的信息不足以判断峰在哪一侧;三分比较两点的相对大小来定向。
考点:三分 vs 二分。
解析:设 f 在 [l, r] 上先增后减(单峰)。取三分点 m1 < m2。若 f(m1) < f(m2),峰在 m1 右侧(排除 [l, m1));若 f(m1) > f(m2),峰在 m2 左侧(排除 (m2, r])。必须有两个点的比较才能定向。二分只有一个 mid,f(mid) 的大小无法告诉我们峰在左还是右——这正是「二分不能直接处理单峰」的原因。
排除法:「非整数」二分完全可以处理实数;「区间装不下」无关;「值不满足有序性」说到点子上的反面——单峰函数不是单调的,二分依赖单调。
关于「三分」和「LIS 的二分优化」,下面哪种说法正确?
规则:三分用于单峰函数极值;LIS 用「末尾值数组 + 二分替换」优化到 。
考点:三分的适用对象与 LIS 二分优化。
解析:LIS(最长上升子序列):维护数组 d,d[k] 表示「长度为 k 的上升子序列的最小末尾值」,d 严格递增。对每个新元素 a[i],用二分找第一个大于等于它的位置替换,或追加到末尾。复杂度 。与查找的二分不同,这里的二分是在「候选末尾值」上做替换决策——又一个「二分不止用来查数」的例子。
排除法:「三分就是二分多取点」三分是独立算法,用于不同问题形态;「LIS 只能 」二分优化是标准做法;「三分求数组最大值」数组最大值用扫描,三分对象是单峰函数。
题目:5 根钢条长度分别为 a = {9, 8, 7, 6, 5},要把它们切成若干等长的小段(余料丢弃、不拼接),一共要得到至少 5 段。求最大可能的段长。
三步法:① 设计 check;② 定答案域;③ 套主循环。运行下面的程序,输出多少?
01#include <iostream> 02using namespace std; 03int a[6] = {9, 8, 7, 6, 5}; 04int n = 5, k = 5; 05bool check(int x) { 06 int cnt = 0; 07 for (int i = 0; i < n; i++) cnt += a[i] / x; 08 return cnt >= k; 09} 10int main() { 11 int lo = 1, hi = 9; 12 while (lo < hi) { 13 int mid = lo + (hi - lo + 1) / 2; 14 if (check(mid)) lo = mid; 15 else hi = mid - 1; 16 } 17 cout << lo << endl; 18 return 0; 19}
规则:三步法走一遍——check 用整除求和,答案域取 [1, 9],主循环取最大可行,答案是 5。
考点:二分答案的三步法(check → 答案域 → 主循环)。
解析:第一步设计 check:段长 x,每根钢条切 a[i] / x 段,总段数 >= 5 可行。第二步定答案域:段长至少 1,至多是最长钢条 9。第三步跑最大可行版:
x = 5:9/5 + 8/5 + 7/5 + 6/5 + 5/5 = 1+1+1+1+1 = 5 >= 5 可行;
x = 6:1+1+1+1+0 = 4 < 5 不可行。
所以最大可行段长是 5。注意每根 9 米条切 1 段 5 米、8 切 1、7 切 1、6 切 1、5 切 1,正好 5 段。
排除法:4 可行但不是最大;6 只有 4 段不够;3 能切很多段但段长太小。
数组 a = {9, 7, 5, 3, 1} 是降序的,要在里面找元素 3 是否存在。有人用 binary_search(a, a + 5, 3)(默认升序比较),得到的结果会怎样?
规则:STL 二分默认要求数组按升序;降序数组要用 greater<int>() 比较器配对,否则结果不确定。
考点:STL 与降序数组。
解析:binary_search 内部按升序逻辑比较(comp(a[mid], x) 等)。降序数组上这个「有序假设」不成立,二分排除的方向全错,结果不可靠(可能恰好对也可能错)。正确做法:binary_search(a, a + 5, 3, greater<int>()),或先把数组反转/排序。手写二分遇到降序也要把比较方向全反(见「降序数组三处全反」)。
排除法:STL 不会自动识别升降序;不会崩溃——只是逻辑错;「3 存在所以可靠」是碰巧,算法前提仍被违反。
要求「最大的整数段长」和「精度 的实数立方根」,分别该用哪种二分控制?
规则:答案是整数 → 整数二分(端点以 1 为单位移动);答案是实数 → 实数二分(迭代/eps 控制)。
考点:整数二分与实数二分的选型。
解析:段长是「米」的整数倍,答案必须是整数,用 l = mid + 1 / r = mid - 1 式整数二分。立方根是实数,区间要缩到 内,用实数二分(固定 100 次或 while (r - l > eps))。两者的 mid 取整、端点移动方式都不同——先判断答案的「类型」,再选控制方式。
排除法:「都用实数二分最后取整」对整数答案会因取整方向引入错误;「都用整数二分」实数答案会被 1 的步长卡死;「整数用三分」三段无关。
用实数二分在 [1, 2] 上求 (mid^2 < 2 则 lo = mid,否则 hi = mid)。做 3 轮后,区间变成多少?
01double lo = 0, hi = 2; 02for (int i = 0; i < 100; i++) { 03 double mid = (lo + hi) / 2; 04 if (mid * mid < 2) lo = mid; 05 else hi = mid; 06}
规则:逐轮按 mid^2 与 2 比较,3 轮后区间为 [1.375, 1.5]。
考点:实数二分逐轮追踪。
解析:
轮 1:mid = 1.5,1.5^2 = 2.25 > 2,hi = 1.5,区间 [1, 1.5];
轮 2:mid = 1.25,1.25^2 = 1.5625 < 2,lo = 1.25,区间 [1.25, 1.5];
轮 3:mid = 1.375,1.375^2 = 1.890625 < 2,lo = 1.375,区间 [1.375, 1.5]。
真实 ,落在区间内。
排除法:[1.25, 1.5] 是 2 轮结果;[1.375, 1.4375] 需要第 4 轮(mid=1.4375,1.4375^2 ≈ 2.066 > 2,hi=1.4375)才到;[1.5, 1.75] 方向错。
用 cubeRoot 求 的立方根。若代码里没有 if (x < 1) hi = 1 这句(直接 hi = x = 0.125),会发生什么?
01double lo = 0; 02double hi = (x < 1) ? 1 : x; 03for (int i = 0; i < 100; i++) { 04 double mid = (lo + hi) / 2; 05 if (mid * mid * mid <= x) lo = mid; 06 else hi = mid; 07}
规则: 时立方根大于 本身,初始上界必须调整(取 hi = 1 盖住根),否则答案被夹在上界。
考点:实数二分的上界选取(x < 1 陷阱)。
解析: 的立方根是 ,大于 。若 hi = 0.125,整个区间 [0, 0.125] 里没有任何点满足 mid^3 >= 0.125(最大 0.125^3 = 0.00195 仍小于 0.125),mid^3 < x 恒真,lo 一路被推到 hi = 0.125,输出错误值 0.125。加上 if (x < 1) hi = 1 后区间 [0, 1] 包含 0.5,二分才能收敛到正确值。
排除法:上界错则答案错,不是「无所谓」;不会死循环(每轮都收缩);不越界(都是浮点比较)。
设有 个已排好序的数据元素,采用折半查找时,最大比较次数为( )。
D:7。折半查找最大比较次数 ⌊log₂n⌋+1:100 满足 2⁶=64<100≤128=2⁷,最多比较 6+1=7 次;其余 6/8/10 均与公式不符。
新学期开学了,小胖想减肥,健身教练给小胖制定了两个训练方案。方案一:每次连续跑 公里可以消耗 千卡(耗时半小时);方案二:每次连续跑 公里可以消耗 千卡(耗时 小时)。小胖每周周一到周四能抽出半小时跑步,周五到周日能抽出一小时跑步。另外,教练建议小胖每周最多跑 公里,否则会损伤膝盖。请问如果小胖想严格执行教练的训练方案,并且不想损伤膝盖,每周最多通过跑步消耗多少千卡?( )
B:2400。每公里千卡:3km 方案 100/公里,5km 方案 120/公里,5km 更划算。21 公里上限内:3 次 5km+2 次 3km=21km,消耗 3×600+2×300=2400 千卡。
1 #include <iostream> 2 using namespace std; 3 const int maxn = 10000; 4 int n; 5 int a[maxn]; 6 int b[maxn]; 7 int f(int l, int r, int depth) { 8 if (l > r) 9 return 0; 10 int min = maxn, mink; 11 for (int i = l; i <= r; ++i) { 12 if (min > a[i]) { 13 min = a[i]; 14 mink = i; 15 } 16 } 17 int lres = f(l, mink - 1, depth + 1); 18 int rres = f(mink + 1, r, depth + 1); 19 return lres + rres + depth * b[mink]; 20 } 21 int main() { 22 cin >> n; 23 for (int i = 0; i < n; ++i) 24 cin >> a[i]; 25 for (int i = 0; i < n; ++i) 26 cin >> b[i]; 27 cout << f(0, n - 1, 1) << endl; 28 return 0; 29 }
如果 a 数组有重复的数字,则程序运行时会发生错误。( )
如果 b 数组全为 ,则输出为 。( )
当 时,最坏情况下,与第 行的比较运算执行的次数最接近的是( )。
(1 分)当 时,最好情况下,与第 行的比较运算执行的次数最接近的是( )。
(1 分)当 时,若 b 数组满足对任意 都有 b[i] = i + 1,那么输出最大为( )。
当 时,若 b 数组满足对任意 都有 b[i] = 1,那么输出最小为( )。
(矩阵变幻)有一个奇幻的矩阵,在不停地变幻,其变幻方式为:数字 变成矩阵 ,数字 变成矩阵 。最初该矩阵只有一个元素 ,变幻 次后,矩阵会变成什么样?
例如,矩阵最初为:;矩阵变幻 次后:;矩阵变幻 次后:。
输入一行一个不超过 的正整数 。输出变幻 次后的矩阵。
试补全程序。
提示:
<< 表示二进制左移运算符,例如 << ;
而 ^ 表示二进制异或运算符,它将两个参与运算的数中的每个对应的二进制位一一进行比较,若两个二进制位相同,则运算结果的对应二进制位为 ,反之为 。
1 #include <cstdio> 2 using namespace std; 3 int n; 4 const int max_size = 1 << 10; 5 6 int res[max_size][max_size]; 7 8 void recursive(int x, int y, int n, int t) { 9 if (n == 0) { 10 res[x][y] = ①; 11 return; 12 } 13 int step = 1 << (n - 1); 14 recursive(②, n - 1, t); 15 recursive(x, y + step, n - 1, t); 16 recursive(x + step, y, n - 1, t); 17 recursive(③, n - 1, !t); 18 } 19 20 int main() { 21 scanf("%d", &n); 22 recursive(0, 0, ④); 23 int size = ⑤; 24 for (int i = 0; i < size; ++i) { 25 for (int j = 0; j < size; ++j) 26 printf("%d", res[i][j]); 27 puts(""); 28 } 29 return 0; 30 }
①处应填( )。
(1 分)②处应填( )。
(1 分)③处应填( )。
(1 分)④处应填( )。
(1 分)⑤处应填( )。
(1 分)设 是 个实数的数组,考虑下面的递归算法:
XYZ(A[1..n]) 1 if n = 1 then return A[1] 2 else temp ← XYZ(A[1..n-1]) 3 if temp < A[n] 4 then return temp 5 else return A[n]
请问算法 XYZ 的输出是什么?( )
B:A 数组的最小值。递归返回 min(前 n-1 个的递归结果, A[n]),逐步求全局最小值;A 求和、C 中值(错误,D 求最大值用 max 而非 min,均不符。
1 #include <algorithm> 2 #include <iostream> 3 using namespace std; 4 5 int n; 6 int d[50][2]; 7 int ans; 8 9 void dfs(int n, int sum) { 10 if (n == 1) { 11 ans = max(sum, ans); 12 return; 13 } 14 for (int i = 1; i < n; ++i) { 15 int a = d[i - 1][0], b = d[i - 1][1]; 16 int x = d[i][0], y = d[i][1]; 17 d[i - 1][0] = a + x; 18 d[i - 1][1] = b + y; 19 for (int j = i; j < n - 1; ++j) 20 d[j][0] = d[j + 1][0], d[j][1] = d[j + 1][1]; 21 int s = a + x + abs(b - y); 22 dfs(n - 1, sum + s); 23 for (int j = n - 1; j > i; --j) 24 d[j][0] = d[j - 1][0], d[j][1] = d[j - 1][1]; 25 d[i - 1][0] = a, d[i - 1][1] = b; 26 d[i][0] = x, d[i][1] = y; 27 } 28 } 29 30 int main() { 31 cin >> n; 32 for (int i = 0; i < n; ++i) 33 cin >> d[i][0]; 34 for (int i = 0; i < n; ++i) 35 cin >> d[i][1]; 36 ans = 0; 37 dfs(n, 0); 38 cout << ans << endl; 39 return 0; 40 }
假设输入的 是不超过 的正整数,d[i][0]、d[i][1] 都是不超过 的正整数,完成下面的判断题和单选题。
若输入 为 ,此程序可能会死循环或发生运行错误。( )
(1 分)若输入 为 ,接下来的输入全为 ,则输出为 。( )
(1 分)输出的数一定不小于输入的 d[i][0] 和 d[i][1] 的任意一个。( )
若输入的 为 ,接下来的输入是 个 和 个 ,则输出为( )。
(1 分)若输入的 为 ,接下来的输入是 个 和 个 ,则输出为( )。
(1 分)若输入的 为 ,接下来的输入是 到 ,以及 到 ,则输出为( )。
(1 分)(最小区间覆盖)给出 个区间,第 个区间的左右端点是 。现在要在这些区间中选出若干个,使得区间 被所选区间的并覆盖(即每一个 都在某个所选的区间中)。保证答案存在,求所选区间个数的最小值。
输入第一行包含两个整数 和 (,)。
接下来 行,每行两个整数 、()。
提示:使用贪心法解决这个问题。先用 的时间复杂度排序,然后贪心选择这些区间。
试补全程序。
1 #include <iostream> 2 3 using namespace std; 4 5 const int MAXN = 5000; 6 int n, m; 7 struct segment { int a, b; } A[MAXN]; 8 9 void sort() // 排序 10 { 11 for (int i = 0; i < n; i++) 12 for (int j = 1; j < n; j++) 13 if (①) 14 { 15 segment t = A[j]; 16 ② 17 } 18 } 19 20 int main() 21 { 22 cin >> n >> m; 23 for (int i = 0; i < n; i++) 24 cin >> A[i].a >> A[i].b; 25 sort(); 26 int p = 1; 27 for (int i = 1; i < n; i++) 28 if (③) 29 A[p++] = A[i]; 30 n = p; 31 int ans = 0, r = 0; 32 int q = 0; 33 while (r < m) 34 { 35 while (④) 36 q++; 37 ⑤; 38 ans++; 39 } 40 cout << ans << endl; 41 return 0; 42 }
①处应填( )。
(1 分)②处应填( )。
(1 分)③处应填( )。
(1 分)④处应填( )。
(1 分)⑤处应填( )。
(1 分)在数据压缩编码中的哈夫曼编码方法,在本质上是一种( )的策略。
B:贪心。哈夫曼每次合并频率最小的两棵树,逐步构建最优前缀码,本质是贪心(局部最优达全局最优)。
有四个人要从 点坐一条船过河到 点,船一开始在 点。该船一次最多可坐两个人。已知这四个人中每个人独自坐船的过河时间分别为 、、、,且两个人坐船的过河时间为两人独自过河时间的较大者。则最短( )时间可以让四个人都过河到 点(包括从 点把船开回 点的时间)。
B:15。最优策略:① 1,2 过河(用 2 分钟),1 独自返回(1 分钟)→ 4 分钟;② 3,4 过河(用 8 分钟),2 返回(2 分钟)→ 10 分钟;③ 1,2 再过河(用 2 分钟)→ 12 分钟;再加回程?实际常见策略 1&2→, 1←, 3&4→, 2←, 1&2→ 共 2+1+8+2+2=15 分钟。
(矩形计数)平面上有 个关键点,求有多少个四条边都和 x 轴或者 y 轴平行的矩形,满足四个顶点都是关键点。给出的关键点可能有重复,但完全重合的矩形只计一次。
试补全枚举算法。
1 #include <iostream> 2 3 using namespace std; 4 5 struct point { 6 int x, y, id; 7 }; 8 9 bool equals(point a, point b) { 10 return a.x == b.x && a.y == b.y; 11 } 12 13 bool cmp(point a, point b) { 14 return ①; 15 } 16 17 void sort(point A[], int n) { 18 for (int i = 0; i < n; i++) 19 for (int j = 1; j < n; j++) 20 if (cmp(A[j], A[j - 1])) { 21 point t = A[j]; 22 A[j] = A[j - 1]; 23 A[j - 1] = t; 24 } 25 } 26 27 int unique(point A[], int n) { 28 int t = 0; 29 for (int i = 0; i < n; i++) 30 if (②) 31 A[t++] = A[i]; 32 return t; 33 } 34 35 bool binary_search(point A[], int n, int x, int y) { 36 point p; 37 p.x = x; 38 p.y = y; 39 p.id = n; 40 int a = 0, b = n - 1; 41 while (a < b) { 42 int mid = ③; 43 if (④) 44 a = mid + 1; 45 else 46 b = mid; 47 } 48 return equals(A[a], p); 49 } 50 51 const int MAXN = 1000; 52 point A[MAXN]; 53 54 int main() { 55 int n; 56 cin >> n; 57 for (int i = 0; i < n; i++) { 58 cin >> A[i].x >> A[i].y; 59 A[i].id = i; 60 } 61 sort(A, n); 62 n = unique(A, n); 63 int ans = 0; 64 for (int i = 0; i < n; i++) 65 for (int j = 0; j < n; j++) 66 if (⑤ && binary_search(A, n, A[i].x, A[j].y) && binary_search(A, n, A[j].x, A[i].y)) { 67 ans++; 68 } 69 cout << ans << endl; 70 return 0; 71 }
①处应填( )
(1 分)②处应填( )
(1 分)③处应填( )
(1 分)④处应填( )
(1 分)⑤处应填( )
(1 分)1 #include <iostream> 2 3 using namespace std; 4 5 int n, k; 6 7 int solve1() 8 { 9 int l = 0, r = n; 10 while (l <= r) { 11 int mid = (l + r) / 2; 12 if (mid * mid <= n) l = mid + 1; 13 else r = mid - 1; 14 } 15 return l - 1; 16 } 17 18 double solve2(double x) 19 { 20 if (x == 0) return x; 21 for (int i = 0; i < k; i++) 22 x = (x + n / x) / 2; 23 return x; 24 } 25 26 int main() 27 { 28 cin >> n >> k; 29 double ans = solve2(solve1()); 30 cout << ans << ' ' << (ans * ans == n) << endl; 31 return 0; 32 }
假设 int 为 位有符号整数类型,输入的 n 是不超过 的自然数、k 是不超过 int 表示范围的自然数,
该算法最准确的时间复杂度分析结果为 。( )
(1 分)当输入为 9801 1 时,输出的第一个数为 99。( )
对于任意输入的 n,随着所输入 k 的增大,输出的第二个数会变成 1。( )
该程序有存在缺陷。当输入的 n 过大时,第 行的乘法有可能溢出,因此应当将 mid 强制转换为 位整数再计算。( )
当输入为 2 1 时,输出的第一个数最接近( )。
当输入为 3 10 时,输出的第一个数最接近( )。
当输入为 256 11 时,输出的第一个数( )。
试补全程序。
1 #include <iostream> 2 #include <vector> 3 4 using namespace std; 5 6 int find_missing(vector<int>& nums) { 7 int left = 0, right = nums.size() - 1; 8 while (left < right) { 9 int mid = left + (right - left) / 2; 10 if (nums[mid] == mid + ①) { 11 ②; 12 } else { 13 ③; 14 } 15 } 16 return ④; 17 } 18 19 int main() { 20 int n; 21 cin >> n; 22 vector<int> nums(n); 23 for (int i = 0; i < n; i++) cin >> nums[i]; 24 int missing_number = find_missing(nums); 25 if (missing_number == ⑤) { 26 cout << "Sequence is consecutive" << endl; 27 } else { 28 cout << "Missing number is " << missing_number << endl; 29 } 30 return 0; 31 }
①处应填( )
(1 分)②处应填( )
(1 分)③处应填( )
(1 分)④处应填( )
(1 分)⑤处应填( )
(1 分)假设有序表中有 1000 个元素,则用二分法查找元素 X 最多需要比较( )次。
B:10。1000 元素二分最多比较 ⌈log₂1000⌉=10(2¹⁰=1024≥1000)。
已知 ,并且对于所有 有 。那么 的值是多少?( )
A:完全二叉树。完全二叉树除最后一层外全满,最后一层左对齐。
1 #include <algorithm> 2 #include <cstdio> 3 #include <cstring> 4 #define ll long long 5 6 int n, k; 7 int a[200007]; 8 int ans[200007]; 9 10 int main() { 11 scanf("%d%d", &n, &k); 12 for (int i = 1; i <= n; ++i) { 13 scanf("%d", &a[i]); 14 } 15 std::sort(a + 1, a + n + 1); 16 n = std::unique(a + 1, a + n + 1) - a - 1; 17 for (int i = 1, j = 0; i <= n; ++i) { 18 for (; j < i && a[i] - a[j + 1] > k; ++j) 19 ; 20 ans[i] = ans[j] + 1; 21 } 22 printf("%d\n", ans[n]); 23 return 0; 24 }
当输入为 3 1 3 2 1 时,输出结果为 。( )
假设输入的 n 为正整数,输出的答案一定满足 且 。( )
将第 行的:
1 n = std::unique(a + 1, a + n + 1) - a - 1;
删除后,有可能出现与原本代码不同的输出结果。( )
(1 分)假设输入的 a 数组和 k 均为正整数,执行第 行代码时,一定满足的条件 不包括( )。
当输入为:n = 100, k = 2, a = {1, 2, ..., 100} 时,输出为( )。
假设输入的 a 数组和 k 均为正整数,但 a 数组不一定有序,则若误删去第 行的:
std::sort(a + 1, a + n + 1);
程序有可能出现的问题有( )。
(1 分)(精明与糊涂)有 个人,分为两类:
i) 精明人:永远能正确判断其他人是精明还是糊涂。
ii) 糊涂人:判断不可靠,会给出随机的判断。
已知精明人严格占据多数,即如果精明人有 个,则满足 。
你只能通过函数 query(i, j) 让第 个人判断第 个人:返回 true 表示判断结果为“精明人”;返回 false 表示判断结果为“糊涂人”。你的目标是,通过这些互相判断,找出至少一个百分之百能确定的精明人。同时,你无需关心 query(i, j) 的内部实现。
以下程序利用“精明人占多数”的优势。设想一个“消除”的过程,让人们互相判断并进行抵消。经过若干轮抵消后,最终留下的候选者必然属于多数派,即精明人。
例如,假设有三个人 、、。如果 说 是糊涂人,而 也说 是糊涂人,则 和 至少有一个是糊涂人。程序将同时淘汰 和 。由于三人里至少有两个精明人,我们确定 是精明人。试补全程序。
1 #include <iostream> 2 #include <vector> 3 using namespace std; 4 5 int N; 6 bool query(int i, int j); 7 8 int main() { 9 cin >> N; 10 11 int candidate = 0; 12 int count = ___①___; 13 14 for (int i = 1; i < N; ++i) { 15 if (___②___) { 16 candidate = i; 17 count = 1; 18 } else { 19 if (___③___) { 20 ___④___; 21 } else { 22 count++; 23 } 24 } 25 } 26 27 cout << ___⑤___ << endl; 28 return 0; 29 }
①处应填( )
(1 分)②处应填( )
(1 分)③处应填( )
(1 分)④处应填( )
(1 分)⑤处应填( )
(1 分)