单调递增的数字 图解题解
这道题到底在问什么
- n
- 332
- 输出
- 299
- 为什么
- 332 不递增(3>2);299 是 ≤332 里最大的递增数
最优解:为什么这么做
一句话答案:LeetCode 738 单调递增的数字:贪心找 ≤n 的最大各位非递减整数。从右往左扫,遇左位>右位就把左位减 1、其后全填 9,变小到 ≤n 又尽量大,时间 O(len)。
求 ≤n 的最大数,还要各位从左到右不下降
给一个整数 n,要返回不超过 n、且各位数字单调递增的最大整数——单调递增就是从左到右每一位都不大于右边那位,像 1234、1359 都算。题面给 n=332,答案是 299:332 自己中间的 3 比右边的 2 大、不满足,而 299 是 ≤332 的递增数里最大的一个。
从 n 往下一个个试,为什么会超时
n 能到 10 位数,要是从 n 开始一个一个往下减、每个都拆开检查是不是递增,最坏得试上亿次才碰到答案,交上去必然超时。所以不能挨个验,得看出数字本身的规律,一遍扫描就定位到答案。
出现下降,该把哪位动手、动成什么样
盯住相邻两位:只要左边一位比右边大,这个数在这里就下降了,必须改。改法是把左边那位减 1,减 1 是为了把整个数压到 ≤n;可光减 1 会让它变得没必要地小,所以从被减的这位往后,全部填成 9——在已经比 n 小的前提下,每位取到最大的 9,整个数才尽量大。这种见下降就把左位借走 1、其后全填 9 顶到最大的改法,就是贪心:每一处改动都在当前局面下把数做到能做的最大。
为什么这一遍必须从右往左扫
把 n 拆成一排数字,从最右边相邻的一对开始、向左逐对检查:遇到左位>右位就把左位减 1、并记下这个位置 mark(表示从这位起后面都要填 9)。为什么方向不能反?因为减 1 只会在更左边引出新的下降:332 把中间的 3 减成 2 后,最左的 3 又比它大了。从右往左走,新冒出来的下降都落在还没检查的左侧,会被接着处理;要是从左往右,改了前面、后面已检查过的又被破坏,来不及补救。扫完再从 mark 一路填 9 到末尾。
332 是怎么一步步变成 299 的
拆成 [3, 3, 2],下标 0、1、2,mark 先记成 3(3 已越过最后一位、代表眼下没有要填 9 的位置)。从右往左:先看下标 1 和 2,3>2 是下降,把下标 1 的 3 减成 2、mark=2。再往左看下标 0 和 1,此时下标 1 已是 2,3>2 还是下降,下标 0 的 3 减成 2、mark=1。到最左没有邻居了,扫描停,数字成了 [2, 2, 2]、mark=1。接着从 mark=1 起填 9:下标 1、下标 2 都改成 9,得到 [2, 9, 9],转成整数就是 299。
这题最常见的三种写错
方向反了最隐蔽:从左往右扫,减 1 引出的连锁下降会被漏掉,332 就算不出 299,只能从右往左。填 9 也别抠着减 1 那一位的右边只补一格,减 1 发生在最左时,它右边每一位都得填成 9,整个数才最大,所以要从 mark 铺到末尾。还有 n=10 这种,中途会得到「09」,别慌着特判,最后统一转成整数,前导 0 自然消失、结果就是 9。复杂度上只扫数字的位数、最多十来位,时间 O(len);把数字拆成字符存下来占 O(len) 空间。
▶ 动画逐步走查(共 24 步)——想跟着动画一帧帧对照就展开
- 3n 可能很大(到 10 位数),一个个往下试要试几亿次,必然超时。得找规律。
- 4把「左位减 1、右边全填 9」记牢。减 1 是为了变小到 ≤n,填 9 是为了在变小的前提下尽量大。下面一帧帧演给你看。
- 5把数字按位排开把 332 拆成三位数字 [3, 3, 2] 排成一排,下标 0、1、2。我们要从最右边的相邻对开始,一对对往左查「有没有下坡」。
- 6比较 s[1]=3 与 s[2]=2指针落在最右位 下标 2,看它和左边邻居 下标 1 这一对。问:左边 3 是不是大于右边 2?
- 7左位 s[1] 减 1,记 mark=23 > 2,是下坡!按规律:左边这位 3 减 1 变成 2(数字真的变了),并记下 mark=2——意思是「从下标 2 开始往后都要填 9」。
- 8比较 s[0]=3 与 s[1]=2指针往左挪到 下标 1,看它和更左的 下标 0 这一对。注意此刻 s[1] 已经是刚减过的 2。再问:3 > 2 吗?
- 9左位 s[0] 减 1,mark 更新为 1又是下坡!s[0] 由 3 减为 2,mark 更新成 1(因为减 1 发生在更左边,填 9 的起点也要跟着左移)。数字现在是 [2, 2, 2]。
- 10没有更左的邻居指针到了最左位,再没有「左边邻居」可比,比较阶段结束。现在数字是 [2, 2, 2],记着的 mark = 1。下一步:从 mark 开始填 9。
- 11从 mark=1 起填 9进入填 9 阶段:从 mark=1 这位开始,下标 1 改成 9。为什么填 9?因为前面减了 1 已经保证比 n 小,后面就该尽量大,9 最大。
- 12继续填到末尾再把 下标 2 也改成 9。填到末尾,数字变成 [2, 9, 9],也就是 299——正是答案!
- 13完成检验:2 ≤ 9 ≤ 9 确实单调递增,且不超过 332。一次从右往左的扫描就拿到了答案 299。
- 14本来就递增换个数 1234 看看「本来就递增」会怎样。同样从最右一对开始往左查下坡。
- 15下标 2 与 3最右一对 3 和 4:3 < 4,不是下坡,什么都不改,指针往左挪。
- 16下标 1 与 2再往左一对 2 和 3:还是 2 < 3,不是下坡,继续左移。
- 17下标 0 与 1最左一对 1 和 2:依旧 1 < 2。全程没找到下坡,mark 始终为「无」。
- 18本来递增,原样返回既然没下坡,就不减也不填,1234 本身已经单调递增,直接返回 1234。
- 19会出现前导 0看一个易错例 10:最右一对 1 和 0,1 > 0 是下坡。左位要减 1。
- 20s[0]→0, s[1]→9s[0] 减成 0,mark=1 让 s[1] 填 9,字符串变成 "09"。注意最高位成了 前导 0!
- 21转回整数,前导 0 自然消失把 "09" 转回整数,前导 0 自动消失(下标 0 那位被丢弃,变灰),得到 9。所以代码最后用「转整数」一步天然处理了前导 0,不用特判。
- 22先无下坡,后出下坡再看 100。最右一对是 0 和 0,相等不算下坡(要求是「左 > 右」),不改,指针左移。
- 231 > 0 是下坡往左一对 1 和 0:1 > 0 是下坡!左位要减 1,并记 mark。
- 24s[0]→0,mark=1s[0] 由 1 减成 0,记下 mark=1。数字暂时是 [0, 0, 0]。
- 25下标 1、2 → 9从 mark=1 起把下标 1、2 都填 9,得到 "099",转整数去掉前导 0 → 99。
- 26如果从左往右,改了前面的位,后面已经检查过的就可能又被破坏、来不及补救。从右往左保证每次改动只影响「更左、还没查的」位,一遍扫描就够。
⚠️ 容易写错的地方
✗ 错:从左往右扫
✓ 对:必须从右往左
减 1 会向左连锁(如 332),从左扫会漏掉后续新产生的下坡
✗ 错:填 9 时只填减 1 的那一位右边一格
✓ 对:从 mark 起填到末尾
减 1 在最左发生时,它右边所有位都要填 9 才最大(如 332→299)
✗ 错:忘了把 "09" 当 9 处理
✓ 对:最后统一转 int
如 n=10 会得到 "09",转整数前导 0 自动消失变 9
完整代码(Python / C++ / Java)
Python
class Solution:
def monotoneIncreasingDigits(self, n):
s = list(str(n)) # 拆成各位数字字符
mark = len(s) # 从这一位起填 9, 初始无
for i in range(len(s) - 1, 0, -1): # 从右往左
if s[i - 1] > s[i]: # 发现下坡
s[i - 1] = str(int(s[i - 1]) - 1) # 左位减 1
mark = i # 记下填 9 起点
for i in range(mark, len(s)): # 从 mark 起全填 9
s[i] = '9'
return int(''.join(s)) # 转整数, 前导 0 自然消失C++
class Solution {
public:
int monotoneIncreasingDigits(int n) {
string s = to_string(n);
int mark = s.size(); // 从此位起填 9
for (int i = s.size() - 1; i > 0; i--) { // 从右往左
if (s[i - 1] > s[i]) { // 下坡
s[i - 1]--; // 左位减 1
mark = i;
}
}
for (int i = mark; i < (int)s.size(); i++) s[i] = '9';
return stoi(s);
}
};Java
class Solution {
public int monotoneIncreasingDigits(int n) {
char[] s = String.valueOf(n).toCharArray();
int mark = s.length; // 从此位起填 9
for (int i = s.length - 1; i > 0; i--) { // 从右往左
if (s[i - 1] > s[i]) { // 下坡
s[i - 1]--; // 左位减 1
mark = i;
}
}
for (int i = mark; i < s.length; i++) s[i] = '9';
return Integer.parseInt(new String(s));
}
}复杂度
时间复杂度
O(L)
L 是数字位数(int 范围内 ≤ 10)。从右往左扫一遍 + 从 mark 填一遍,都是线性 → O(L)
空间复杂度
O(L)
把数字转成字符数组/字符串存了 L 个字符 → O(L)
看不够?换成动画再走一遍
上面的推演每一步都对应一帧动画。点开交互动画版,能一步步看着 单调递增的数字 的数据怎么变、指针怎么走,还能切 Python / Java / C++ 跟着练。
面试官可能追问
为什么减 1 之后一定要把后面全填 9,保留原数字不行吗?+
减 1 是被逼的:这一位比右边大、数已经不递增,只有把它压低才可能 ≤n。但压低之后如果后面还留着原来的数字,那就白白变小了。填 9 是在「已经比 n 小」这个前提下把每一位都顶到最大——9 是单个数字能取的上界,从被减位往后一路填 9,得到的就是所有 ≤n 递增数里最大的那个。少填、只填一位,都会漏掉更大的候选。
从右往左扫,一遍真的够吗,会不会漏掉后冒出来的下降?+
够。关键在减 1 的连锁只朝一个方向走:把某位减 1,只可能让它更左边的邻居变得比它大、也就是只在左侧引出新下降,右侧已经处理过的不会再被破坏。从右往左扫时,指针每往左挪一步面对的都是「还没查的左侧」,新产生的下降正好落在前方等着被处理,所以一趟扫描能兜住全部连锁,不用回头再补。
n=10、n=100 这种最后会出现前导 0,要不要特判?+
不用。以 n=10 为例,1>0 是下降,把最高位的 1 减成 0、后面填 9,中途得到「09」。这时最高位是 0 看着别扭,但只要最后统一把字符串转成整数(int / stoi / parseInt),前导 0 会被自动丢掉,「09」就变成 9,正是 ≤10 的最大递增数。n=100 同理,先减出「099」再转整数得 99。整个前导 0 的问题交给「转整数」这一步天然消化,代码里不必写额外分支。
想听吴师兄把这道题讲给你听?
文字版和动画都随便看。开通图解算法年卡,可以听吴师兄把 单调递增的数字 一步步讲透(全站已上线 905 份讲透,持续扩充), 卡住的地方还有 AI 私教小欧就着动画帮你拆到懂。
把这道题真正拿下
本平台为独立第三方培训机构,与华为技术有限公司无任何关联;课程的服务内容与权益以购买协议为准,学习效果因个人情况而异。「华为 OD」「华为可信」等仅为对岗位与考试方向的客观描述,相关商标归各自权利人所有。