LeetCode 面试题 10.05. 稀疏数组搜索
题目描述
题意分析
给定一个按字典序排好序的字符串数组
words,其中散布着一些空字符串,要求找出目标串s所在的下标,不存在则返回-1。所谓"排好序",指的是忽略那些空串之后,剩下的非空串是字典序非递减的;空串本身可以出现在任意位置。
约束里最关键的信号有两个。第一,数组是有序的,这是在明示要用二分而不是线性扫描。第二,空串会打断有序性——如果直接拿
words[mid]去和s比较,mid落在空串上时得到的比较结果毫无意义:空串字典序最小,无论s是什么都会得出"该往右找"的结论,可答案完全可能在左边。所以标准二分的判定函数在空串处失效,必须先想办法把mid"挪"到一个有意义的位置。
边界上要覆盖:
s本身是空串(题目要求返回-1,因为空串不构成有效目标);整个数组全是空串;mid到区间右端全是空串(此时右边给不出任何信息);以及目标不存在时的正常收尾。
解法:二分查找判定答案
核心思路
暴力做法是从左到右扫一遍找相等的串,$O(n)$ 次字符串比较。它一定正确,但完全没用上"有序"这个条件——面试官出这题就是想看你怎么在有序性被空串破坏的情况下,还能把二分用起来。
二分能成立,靠的是"比较
words[mid]和s之后能排除掉一整边"。问题出在words[mid]可能是空串,此时这次比较不携带任何关于s位置的信息。关键观察是:空串虽然不能用来做判定,但它左右两侧的非空串仍然是有序的——只要把探针从mid出发向右挪到第一个非空串probe,words[probe]就是一个合法的判定点,而且它和s的比较结果依然能排除掉一整边。
由此定义搜索状态:闭区间
[lo, hi]表示"s若存在,必定落在这个下标范围内",这就是全程要维持的不变量。每轮循环取中点mid,然后:
- 向右探测:从
mid起找到第一个非空下标probe(探测范围限制在hi以内)。- 若
probe > hi,说明[mid, hi]这一整段全是空串,右边给不出任何信息,也不可能藏着s(s非空),于是把区间收缩成[lo, mid - 1]。注意收缩到mid - 1而不是probe - 1,因为[mid, hi]已被整体排除,右端点退到mid之前即可。- 否则用
words[probe]与s比较:相等就返回probe;words[probe] < s说明s只可能在probe右侧,令lo = probe + 1;words[probe] > s说明s只可能在mid左侧(注意是mid不是probe,因为[mid, probe]之间全是空串、不可能是答案),令hi = mid - 1。
每一轮都严格砍掉至少一个元素(
lo至少推进到mid + 1之后,或hi至少退到mid - 1),所以循环必然终止。区间空掉仍未命中,就返回-1。
为什么向右探测而不是向左?两个方向都正确,选右边是为了让"排除逻辑"更简单:向右探测时,被跳过的
[mid, probe-1]全是空串,无论比较结果往哪边收缩,这一段都被顺带排除掉了;若向左探测则要额外注意lo的更新不能越过探针。实现上还可以两侧同时向外探测取更近的那个,常数更好,但代码复杂度上升,面试里单向探测已经足够。
复杂度上,正常情况下每轮区间减半、探测只走几步,是 $O(\log n)$ 次比较;但最坏情况(数组几乎全是空串)探测本身就要走 $O(n)$ 步,整体退化成 $O(n)$。这是空串带来的本质代价,面试里要主动说明"没有对数级的最坏保证"。
解题步骤
- 初始化
lo = 0、hi = words.length - 1,用闭区间表示待搜索范围。空数组时hi = -1,循环条件lo <= hi直接不成立,返回-1,不需要额外特判。
- 循环条件写
lo <= hi。闭区间下lo == hi时区间里还有一个元素必须检查,写成<会漏掉最后一个候选。
- 取
mid = lo + (hi - lo) / 2,避免lo + hi溢出。
- 从
mid向右探测第一个非空串,探测边界是hi。条件写成probe <= hi && words[probe].isEmpty():先判越界再取值,且探测不能越过hi——越过就是在读区间外的数据,那些下标已经被前几轮排除,用它们做判定会破坏不变量。
- 探测失败(
probe > hi)时令hi = mid - 1并进入下一轮。这一步是"[mid, hi]全空 → 整段排除"的落地。因为s非空,空串段里绝不可能是答案;而这段之外的信息本轮拿不到,只能缩小到左边继续。
- 探测成功后用
words[probe]做三路比较。相等直接返回probe——注意返回的是探针位置而不是mid,mid上可能是空串。小于时lo = probe + 1:probe以及它左边的整段(含空串)都被排除。大于时hi = mid - 1:probe右边的都更大所以出局,[mid, probe]是空串也出局,剩下的只有mid左边。
- 循环结束返回
-1。此时不变量保证"若s存在必在区间内",而区间已空,所以不存在。
以
words = ["at", "", "", "", "ball", "", "", "car", "", "", "", "dad", "", ""](下标 0..13)、s = "ball"走一遍:第 1 轮:
lo = 0,hi = 13,mid = 6。words[6]是空串,探针右移到probe = 7,words[7] = "car"。比较"car" > "ball",说明答案在mid左边,令hi = mid - 1 = 5。区间变成[0, 5]。第 2 轮:
lo = 0,hi = 5,mid = 2。words[2]是空串,探针右移:words[3]仍空,probe = 4,words[4] = "ball"。比较相等,返回 4,正确。注意第 1 轮里
hi被设成5而不是probe - 1 = 6:两者都正确(下标 6 是空串,留着也不会被误判),设成mid - 1是更保守也更好证明的写法。再以
words = ["at", "", ""]、s = "at"走一遍,覆盖"探测失败"分支:第 1 轮:
lo = 0,hi = 2,mid = 1。words[1]空,probe = 2仍空,probe = 3已经> hi,探测失败。令hi = mid - 1 = 0,区间变成[0, 0]。第 2 轮:
mid = 0,words[0] = "at"非空,比较相等,返回 0,正确。如果探测失败时误写成lo = mid + 1(往右缩),区间会变成[2, 2],第二轮读到空串再次探测失败,最终返回-1,答案就丢了。最后看
s = ""的边界:第 1 轮probe指向的任何非空串都大于空串,比较结果恒为"大于",hi不断左移直到区间为空,返回-1,符合题目要求。
代码实现
class Solution {
public int findString(String[] words, String s) {
int lo = 0;
int hi = words.length - 1;
while (lo <= hi) {
int mid = lo + (hi - lo) / 2;
int probe = mid;
while (probe <= hi && words[probe].isEmpty()) {
probe++;
}
if (probe > hi) {
hi = mid - 1;
continue;
}
int cmp = words[probe].compareTo(s);
if (cmp == 0) {
return probe;
} else if (cmp < 0) {
lo = probe + 1;
} else {
hi = mid - 1;
}
}
return -1;
}
}
func findString(words []string, s string) int {
lo, hi := 0, len(words)-1
for lo <= hi {
mid := lo + (hi-lo)/2
probe := mid
for probe <= hi && words[probe] == "" {
probe++
}
if probe > hi {
hi = mid - 1
continue
}
if words[probe] == s {
return probe
} else if words[probe] < s {
lo = probe + 1
} else {
hi = mid - 1
}
}
return -1
}
复杂度分析
- 时间复杂度:平均 $O(\log n \cdot L)$,最坏 $O(n \cdot L)$,其中
L为字符串平均长度(一次字符串比较不是 $O(1)$)。区间每轮至少减少一个元素、正常情况下减半,所以是对数轮;但当数组中空串极多(极端情况全为空串)时,单轮的向右探测就要走 $O(n)$ 步,退化成线性。- 空间复杂度:$O(1)$。只用了
lo、hi、mid、probe四个下标变量,是纯迭代实现,没有递归栈也没有辅助数组。
关键点总结
- 二分的前提不是"数组有序",而是"存在一个能排除半边的判定"。本题数组确实有序,但空串处的比较不携带信息,判定失效——先确认判定在每个可能的
mid上都有意义,再动手写二分。- 判定点失效时,把探针挪到最近的有效位置,而不是放弃二分。这是一类可迁移的修补手法:
mid不可用就向一侧线性探测到可用点,用它做判定,同时把跳过的那段一并排除。代价是最坏复杂度退化,但平均情况仍保留二分的优势。- 收缩边界时要区分"探针位置"和"中点位置"。向右探测后,若判定结果是"往左找",右边界必须退到
mid - 1而不是probe - 1;若是"往右找",左边界推进到probe + 1。搞混这两个下标是本题最隐蔽的错误来源。- "整段无效"要有独立的处理分支。
[mid, hi]全是空串时,这一轮拿不到任何判定信息,唯一安全的动作是把区间整体缩到左边。缺了这个分支,循环会因为lo、hi不更新而死循环。- 面试视角:主动交代最坏复杂度并给出改进方向。面试官几乎必问"全是空串时你的算法多快"。标准答法是:最坏 $O(n)$,因为探测本身是线性的;若追求更好的常数可以从
mid向左右同时探测取更近的非空位置;但只要空串比例不受限,就不存在 $O(\log n)$ 的最坏保证。能主动说清这一点,比写出代码更能体现对二分适用边界的理解。
易错点总结
- 错误写法:不做探测,直接拿
words[mid]与s比较 → 用例words = ["at", "", "", "", "ball"]、s = "at":mid = 2是空串,"" < "at"得出"往右找",令lo = 3,此后再也回不到下标 0,返回-1,正确答案是0。- 错误写法:探测时不限制上界,写成
while (words[probe].isEmpty()) probe++;→ 用例words = ["at", "", ""]、s = "at":第一轮mid = 1起探测,probe一路走到 3 时数组越界抛异常(Go 里是 panic)。探测条件必须先判probe <= hi。- 错误写法:探测越过
hi但仍用区间外的words[probe]做判定 → 用例words = ["a", "", "z"]、s = "a",且某轮区间已收缩到[0, 1]:探针跑到下标 2 拿"z"比较,得出"往左"结论虽然凑巧不错,但下标 2 早已被排除,用它做判定破坏了不变量,在别的数据上会得出错误的收缩方向。- 错误写法:探测失败时写成
lo = mid + 1→ 用例words = ["at", "", ""]、s = "at":区间从[0, 2]变成[2, 2],下标 0 上的答案被跳过,返回-1,正确答案是0。整段空串意味着答案只可能在左边。- 错误写法:探测失败时不更新
lo/hi直接continue→ 用例words = ["", ""]、s = "a":mid恒为 0、探测恒失败、区间恒不变,死循环直到超时。- 错误写法:命中时返回
mid而不是probe→ 用例words = ["at", "", "", "", "ball", "", "", "car", "", "", "", "dad", "", ""]、s = "ball":第二轮mid = 2是空串、probe = 4命中,返回2,正确答案是4。- 错误写法:
words[probe] > s时写成hi = probe - 1,同时又把探测方向改成向左 → 混用两个方向后,向左探测得到的probe小于mid,再令hi = probe - 1会把[probe, mid]里可能的答案一并砍掉;用例words = ["a", "", "b"]上会漏解。探测方向和边界更新必须成套使用。- 错误写法:Java 里用
words[probe] == s比较字符串 → 用例words = ["at"]、s = new String("at"):比较的是引用地址而非内容,返回-1。Java 必须用equals或compareTo。- 错误写法:
words[probe].isEmpty()写成words[probe] == null→ 用例words = ["at", "", ""]:空串不是null,探测条件恒为假,探针停在空串上做比较,退化成第一条错误。- 错误写法:为
s是空串加一条if (s.isEmpty()) return -1;之外,还加了if (words.length == 0) return -1;却把它写在取words[0]之后 → 用例words = []:先访问words[0]就已经越界。空数组应当由hi = -1与循环条件自然接住,任何提前访问都要放在判空之后。- 错误写法:改成递归分治,先无条件搜左半再搜右半 → 用例
n = 10^5的稀疏数组:这种写法访问了每一个下标,本质是打乱顺序的线性扫描,复杂度恒为 $O(n)$,完全没用上有序性;虽然能通过判题,但在面试里等于没有回答"如何二分"这个问题。
相似题目
| 题目 | 难度 | 考察点 |
|---|---|---|
| 704. 二分查找 | 简单 | 判定处处有效的标准模板,用来对照本题探针修补的必要性 |
| 面试题 08.03. 魔术索引 | 简单 | 同样是有序但判定失效,修补方式是区间剪枝而非探针右移 |
| 34. 在排序数组中查找元素的第一个和最后一个位置 | 中等 | 判定完好但要定位边界,考的是左右开闭区间与收缩方向的配套 |
| 81. 搜索旋转排序数组 II | 中等 | 重复元素让"哪半边有序"无法判定,处理办法是收缩端点而非探测 |
| 1095. 山脉数组中查找目标值 | 困难 | 先二分峰顶把数组切成两段单调区间,再分别二分,且访问次数受限 |