寻找比目标字母大的最小字母 图解题解
这道题到底在问什么
- 输入
- letters=['c','f','j'], target='a'
- 输出
- 'c'
- 输入
- letters=['c','f','j'], target='j'
- 输出
- 'c' (无更大者,环形回首)
最优解:为什么这么做
一句话答案:LeetCode 744 寻找比目标字母大的最小字母:升序字母表里找第一个严格大于 target 的位置,靠左闭右开二分砍半,收缩到越界就取模环形回首,时间 O(log n)、空间 O(1)。
升序字母表里,挑出比 target 大的最小那个
给一排升序(从小到大排好)的小写字母 letters 和目标 target,返回严格大于 target 的最小字母;一个都不比它大就绕回开头返回 letters[0]。题面 ['c','f','j']:target='a' 答案 'c';target='j' 环形回 'c'。题目还卡死 O(log n)。
逐个字母往后比,为什么配不上 O(log n)
从头往后逐个和 target 比,遇到第一个更大的就停。n 个字母最坏要比满 n 次,是 O(n)。字母表顶多 26 个不多,可换到几十万长的升序数组就卡死,O(log n) 正是要堵掉逐个比。
字母表排好序,半排可以一次跳过
钥匙在『升序』。挨个位置问『这字母严格大于 target 吗』,答案一定前一串否、后一串是:某字母超过 target 后右边全超过。要找从否到是的分界后第一个是。看中点,不大于 target 连左边一起丢,大于就右半不看、它自己留候选。
普通二分撞到相等就返回,这里相等不算数:'j' 不比 'j' 大得跳过。所以只分两支:≤ target 往右扔、> target 收进候选,即库函数 upper_bound(第一个严格大于目标的位置)。
中点比完,左界右界各挪到哪
用左闭右开区间 [left, right) 框住待查位置。起手 left=0、right=n(n 不是 n-1,给越界回首留落点)。每轮 mid=(left+right)//2 看 letters[mid]:≤ target 就 left=mid+1;> target 时 mid 可能是答案、不能扔,right=mid 往左继续找。收空时 left 停在第一个是。
整排都不比 target 大时 left 涨到 n,返回 letters[left % n]:取模把 n 变回 0、绕回 letters[0],省掉 if。
['c','f','j'] 找 'a'、找 'j' 各走几轮
先找 'a'。left=0、right=3,mid=1,'f' ≤ 'a' 不成立,right 收到 1。第二轮 mid=0,'c' ≤ 'a' 也不成立,right 收到 0。left=right=0 收空,返回 letters[0 % 3]='c'。
再看 target='j'(触发环形)。mid=1,'f' ≤ 'j' 成立,left 跳到 2。第二轮 mid=2,'j' ≤ 'j' 相等也算 ≤,left 跳到 3。收空,left=3=n,返回 letters[3 % 3]='c',取模绕回开头。
收完 left 可能正好等于 n,直接读就越界
每比一次区间减半,最多 log₂n 轮,时间 O(log n);只动 left、right 两个下标,空间 O(1)。
判断丢等号写成 letters[mid] < target,相等的字母混进候选。收缩后 left 可能等于 n,直接 letters[left] 会越界,靠 letters[left % n] 绕回 0。左闭右开配 left=mid+1 / right=mid,抄双闭 right=mid-1 会漏格或死循环。
▶ 动画逐步走查(共 27 步)——想跟着动画一帧帧对照就展开
- 3记住「找第一个大于 target 的位置:不大于就 left=mid+1;大于就把 mid 记成候选、right=mid 往左继续找」,下面每帧都在套它。
- 4开始二分:区间用左闭右开 [0, 8) 表示。一旦遇到比 'a' 大的字母,就把它记成「当前可行候选」标绿、再继续往它左边找更小的;灰格是已彻底排除、不再搜的范围。
- 5当前搜索区间 [0, 8) 的正中间是 letters[4]='p'。它严格大于 'a',它会成为新的、更靠左的可行候选。
- 6letters[4]='p' 比 'a' 大,把它记成新的、更靠左的可行候选(绿色移到下标 4);只把它右边灰掉。接着到它左边找有没有更小的、也合格的位置。
- 7当前搜索区间 [0, 4) 的正中间是 letters[2]='j'。它严格大于 'a',它会成为新的、更靠左的可行候选。绿色那格是目前记下的可行候选。
- 8letters[2]='j' 比 'a' 大,把它记成新的、更靠左的可行候选(绿色移到下标 2);只把它右边灰掉。接着到它左边找有没有更小的、也合格的位置。
- 9当前搜索区间 [0, 2) 的正中间是 letters[1]='f'。它严格大于 'a',它会成为新的、更靠左的可行候选。绿色那格是目前记下的可行候选。
- 10letters[1]='f' 比 'a' 大,把它记成新的、更靠左的可行候选(绿色移到下标 1);只把它右边灰掉。接着到它左边找有没有更小的、也合格的位置。
- 11当前搜索区间 [0, 1) 的正中间是 letters[0]='c'。它严格大于 'a',它会成为新的、更靠左的可行候选。绿色那格是目前记下的可行候选。
- 12letters[0]='c' 比 'a' 大,把它记成新的、更靠左的可行候选(绿色移到下标 0);只把它右边灰掉。此时区间 [0, 0) 收空,保留的候选下标 0('c')就是答案。
- 13二分结束,left 停在 0,letters[0]='c' 就是第一个严格大于 'a' 的字母(绿色),即答案。
- 14开始二分:区间用左闭右开 [0, 8) 表示。一旦遇到比 'm' 大的字母,就把它记成「当前可行候选」标绿、再继续往它左边找更小的;灰格是已彻底排除、不再搜的范围。
- 15当前搜索区间 [0, 8) 的正中间是 letters[4]='p'。它严格大于 'm',它会成为新的、更靠左的可行候选。
- 16letters[4]='p' 比 'm' 大,把它记成新的、更靠左的可行候选(绿色移到下标 4);只把它右边灰掉。接着到它左边找有没有更小的、也合格的位置。
- 17当前搜索区间 [0, 4) 的正中间是 letters[2]='j'。它不大于(≤) 'm',它和它左边都不可能是答案,答案在它右边。绿色那格是目前记下的可行候选。
- 18letters[2] 不大于 'm',把 mid 及它左边整段灰掉排除,left 收到 3。绿色候选不动,继续在它左边找更小的。
- 19当前搜索区间 [3, 4) 的正中间是 letters[3]='m'。它不大于(≤) 'm',它和它左边都不可能是答案,答案在它右边。绿色那格是目前记下的可行候选。
- 20letters[3] 不大于 'm',把 mid 及它左边整段灰掉排除,left 收到 4。区间 [4, 4) 收空,保留的候选下标 4('p')就是答案。
- 21二分结束,left 停在 4,letters[4]='p' 就是第一个严格大于 'm' 的字母(绿色),即答案。
- 22开始二分:区间用左闭右开 [0, 8) 表示。一旦遇到比 'z' 大的字母,就把它记成「当前可行候选」标绿、再继续往它左边找更小的;灰格是已彻底排除、不再搜的范围。
- 23当前搜索区间 [0, 8) 的正中间是 letters[4]='p'。它不大于(≤) 'z',它和它左边都不可能是答案,答案在它右边。
- 24letters[4] 不大于 'z',把 mid 及它左边整段灰掉排除,left 收到 5。区间每次砍一半。
- 25当前搜索区间 [5, 8) 的正中间是 letters[6]='t'。它不大于(≤) 'z',它和它左边都不可能是答案,答案在它右边。
- 26letters[6] 不大于 'z',把 mid 及它左边整段灰掉排除,left 收到 7。区间每次砍一半。
- 27当前搜索区间 [7, 8) 的正中间是 letters[7]='x'。它不大于(≤) 'z',它和它左边都不可能是答案,答案在它右边。
- 28letters[7] 不大于 'z',把 mid 及它左边整段灰掉排除,left 收到 8。区间收空且没有候选,说明没有比 'z' 大的,稍后环形回 letters[0]。
- 29二分结束 left=8 等于数组长度——说明没有字母比 'z' 大,按环形规则回到开头,答案是 letters[0]='c'(绿色)。
⚠️ 容易写错的地方
✗ 错:用 ≤ 还是 < 搞混
✓ 对:要严格大于,所以 letters[mid] ≤ target 时排除
题目要求严格大于,相等的也得跳过
✗ 错:结束后忘了取模
✓ 对:return letters[left % n] 处理环形
left 可能等于 n(无更大者),取模回到 0
✗ 错:区间开闭不一致导致死循环
✓ 对:统一左闭右开 [left, right)
收缩规则 left=mid+1 / right=mid 与开闭必须配套
完整代码(Python / C++ / Java)
Python
from typing import List
class Solution:
def nextGreatestLetter(self, letters: List[str], target: str) -> str:
left, right = 0, len(letters)
while left < right:
mid = (left + right) // 2
if letters[mid] <= target:
left = mid + 1
else:
right = mid
return letters[left % len(letters)]C++
#include <vector>
using namespace std;
class Solution {
public:
char nextGreatestLetter(vector<char>& letters, char target) {
int left = 0, right = letters.size();
while (left < right) {
int mid = left + (right - left) / 2;
if (letters[mid] <= target) left = mid + 1;
else right = mid;
}
return letters[left % letters.size()];
}
};Java
import java.util.*;
class Solution {
public char nextGreatestLetter(char[] letters, char target) {
int left = 0, right = letters.length;
while (left < right) {
int mid = left + (right - left) / 2;
if (letters[mid] <= target) left = mid + 1;
else right = mid;
}
return letters[left % letters.length];
}
}复杂度
时间
O(log n)
每次范围减半
空间
O(1)
只用 left、right 两个下标
看不够?换成动画再走一遍
上面的推演每一步都对应一帧动画。点开交互动画版,能一步步看着 寻找比目标字母大的最小字母 的数据怎么变、指针怎么走,还能切 Python / Java / C++ 跟着练。
面试官可能追问
这道题其实是哪一类二分,值得单独记吗?+
它是『二分查找第一个满足某条件的位置』这个母题的变体,条件是 letters[mid] > target,找最左的那个成立。一旦把题目翻译成『第一个成立落在哪』,找第一个 ≥ target 的(lower_bound)、第一个 > target 的(upper_bound,本题)、山脉数组第一个下降点,一大批题都套同一副左闭右开骨架,只有判断条件那一行不一样。硬背模板容易把边界记串,认出它是这个母题、套同一副左闭右开骨架就行。
为什么 right 起手取 n,而不是像普通二分那样 n-1?+
因为答案里多了一种『谁都不够、要环形回首』的可能。普通二分找一个确实在数组里的值,right=n-1 就够。这题的答案位置可以是 0 到 n-1 的某一格,也可以是『没有更大的、越界回 0』,得给越界这个结局留出 left==n 的落点,所以右边界开到 n、用左闭右开 [0, n)。收缩到最后 left 停在 0 到 n 之间,再用 % n 把 left==n 那种绕回开头。
letters[mid] 和 target 相等时,为什么归到排除而不是候选?+
题目要的是严格大于 target 的字母,相等不算。比如 target='j'、letters[mid]='j','j' 不比 'j' 大,它不能当答案,得跟着比 target 小的那些一起被扔到左半、走 left=mid+1。这就是判断写 letters[mid] <= target(小于或等于都排除)而不是 < 的原因,那个等号专门负责把相等的字母也踢出候选。如果哪天题目改成『大于等于 target 的最小字母』(lower_bound),把这个 ≤ 改成 < 就行,其余一字不动。
想听吴师兄把这道题讲给你听?
文字版和动画都随便看。开通图解算法年卡,可以听吴师兄把 寻找比目标字母大的最小字母 一步步讲透(全站已上线 905 份讲透,持续扩充), 卡住的地方还有 AI 私教小欧就着动画帮你拆到懂。
本平台为独立第三方培训机构,与华为技术有限公司无任何关联;课程的服务内容与权益以购买协议为准,学习效果因个人情况而异。「华为 OD」「华为可信」等仅为对岗位与考试方向的客观描述,相关商标归各自权利人所有。