一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第20天,点击查看活动详情。
题目链接:440.字典序的第K小数字
题目描述
给定整数 n 和 k,返回 [1, n] 中字典序第 k 小的数字。
提示:
示例 1:
输入: n = 13, k = 2
输出: 10
解释: 字典序的排列是 [1, 10, 11, 12, 13, 2, 3, 4, 5, 6, 7, 8, 9],所以第二小的数字是 10。
示例 2:
输入: n = 1, k = 1
输出: 1
整理题意
题目要求返回 [1, n] 内所有整数按照字典序排序后的第 k 小的数字,注意这里是要求按 字典序 排序。
解题思路分析
习惯性动作:首先确定题目数据范围 。数据范围很大,极端情况下连 的时间复杂度都是不能通过的,遍历这 n 个数的时间复杂度都成问题,更别说对这 n 个数排序了。我们需要想办法把复杂度降低到 以下。
首先需要对字典序和字典树有一定了解:
- 字典序:简单来说是将数字按照字符串的排序规则来排序,也就是根据数字的前缀进行排序。(例如:
"10" < "2"、"113" < "13" < "2") - 字典树:本题不需要真正的构建字典树,但是需要这样的模型来方便我们理解。利用字典树的特性将所有小于等于
n的数字按照字典序的方式进行构建,本题构建的字典树模型大致如下:
我们可以发现前序遍历该字典树即可得到字典序从小到大的数字序列。先序遍历到第 k 个节点即为第 k 小的数字。
1、10、100、……、101、……、102、……
为什么要构建这样模型,这样构建的好处是什么
我们可以发现这样构建之后,每个父节点都是子节点的前缀,对于字典序来说就是根据前缀进行排序。也就是说 子节点的字典序一定小于父亲节点,并且子节点都是以父亲节点作为前缀 。那我们通过计算当前节点(也就是当前前缀)下有多少个子节点(注意每个节点值都是小于等于 n 的),通过子节点的个数来判断第 k 个节点是否在当前节点下,也就是说第 k 个节点是否以当前节点作为前缀 。
我们假设当前节点为 pre (也就是当前前缀) ,pos 为当前节点 pre 字典序排列的值,count 为当前节点下所有子节点的个数,那 判断第 k 个节点是否在当前节点下只需要判断 pos + count 与 k 的大小关系即可 。
- 如果
pos + count <= k:第k个节点在当前节点下,继续往子树中寻找第k个节点; - 如果
pos + count > k:第k个节点在不当前节点下,往旁边节点寻找第k个节点,也就是扩大前缀。
如何求得当前节点(前缀)下所有子节点个数
现在只需要考虑如何求得当前节点(前缀)下所有子节点个数即可,这也是本题的 难点。
我们用 下一个前缀的起点 减去 当前前缀的起点 :
11 - 10 = 1:当前前缀子节点增加1个110 - 100 = 10:当前前缀子节点增加10个1100 - 1000 = 100:当前前缀子节点增加100个- ……
那么累加求和就是当前前缀下的所有子节点的总个数。
注意: 我们不要忘了题目的限制要求,节点的数值需要在 [1, n] 中:
具体实现
求得当前节点(前缀)下所有子节点个数
- 令当前前缀为
cur,当前前缀的下一个前缀为nxt = pre + 1,当cur <= n时表示当前前缀在n范围内,不断用下一个前缀和当前前缀相减,计算出当前前缀与下一个前缀之间有多少个小于等于n的节点个数,累加求和res += min(n + 1, nxt) - cur;注意我们需要统计的是小于等于n的节点个数,所以需要和n + 1取min()。 - 更新
cur *= 10和nxt *= 10不断向字典树深处搜索,也就是找到n所在的层数,并且不断统计中途的节点个数,被统计的节点值都是小于n且前缀都是以pre开始的。 - 最后返回总个数
res。
找到 [1, n] 中字典序第 k 小的数字
- 首先我们初始化当前前缀
pre为"1",当前字典序排列pos的值为1。 - 当
pos < k时,不断获取当前前缀下的所有子节点的总个数count,判断第k个节点是否在当前节点下。- 如果
pos + count > k,说明第k个节点在当前节点下,继续往子树中寻找第k个节点,更新前缀pre和字典序排列pos的值。 - 如果
pos + count <= k,说明第k个节点在不当前节点下,不用继续往pre的子树找了,就要往pre旁边的更大的前缀里找,所以更新pre++,更新pos指向下一个前缀,那么也就是加上中间跳过的个数pos += count。
需要注意这里为啥
pos + count == k也说明第k个节点在不当前前缀节点下,是因为当前pos指向的是pre,而我们计算count的时候算上了pre,所以pre + count == k也就表示pre + 1正好是第k个。 - 如果
复杂度分析
- 时间复杂度:,其中
n为给定的数值的大小。每次计算子树下的节点数目的搜索深度最大为 ,最多需要搜索 层,每一层最多需要计算10次,最多需要计算 次,因此时间复杂度为 。 - 空间复杂度:,不需要开辟额外的空间,只需常数空间记录常量即可。
代码实现
class Solution {
private:
long getCount(long pre, long n){
//初始化统计个数为0
long res = 0;
//初始化当前前缀cur为pre
long cur = pre;
//初始化当前前缀的下一个前缀为pre + 1
long nxt = pre + 1;
//通过当前前缀和下一个前缀可以计算出当前前缀和下一个前缀之间有多少个小于等于n的节点个数
while(cur <= n){
//注意这里的min(),如果nxt的值大于n的话就会多统计
res += min(n + 1, nxt) - cur;
//不断向字典树深处搜索,也就是找到n所在的层数,并且不断统计中途的节点个数
//被统计的节点值都是小于n且前缀都是以pre开始的
cur *= 10;
nxt *= 10;
}
//最后返回统计的节点个数
return res;
}
public:
int findKthNumber(int n, int k) {
//初始化前缀为1
long pre = 1;
//初始化指针指向字典序排列的值为1
long pos = 1;
//当指针小于k时不断搜索字典树,直到pos == k
while(pos < k){
//获取以当前的前缀下小于n的所有子节点个数
long count = getCount(pre, n);
//如果当前指针pos加上以pre为前缀且小于n的节点个数大于k了,说明第k个数就在以pre为前缀的里面
if(pos + count > k){
//进入当前前缀的下一层
pre *= 10;
//pos指针也只前进了1个,也就相当于pos指向了pre的第一个子节点
pos++;
}
//如果当前指针pos加上以pre为前缀且小于n的节点个数小于k了,说明第k个数不在以pre为前缀的里面,还要在后面
else if(pos + count < k){
//此时就不用继续往pre的深处找了,就要往pre旁边的更大的前缀里找
//因为此时以pre为前缀的所有个数加上当前pos都小于k了
pre++;
//此时记得更新pos之间指向下一个前缀,那么也就是加上中间跳过的个数
pos += count;
}
//★注意这里单独拎出来pos + count == k的情况详细说明为啥可以归类到pos + count < k里面
//当pos + count == k的时候,说明找到第k个数了,正好是 pre++,
//因为count计数包含pre,而pos指向pre,所以这里是多加了一个
else{
//此时更新pos指向第k个数
pos = k; //相当于pos += count
//第k个数就是pre + 1,因为pre的子节点个数刚好等于k
pre++;
}
}
//最后当pos == k时会跳出循环,此时的pre就为字典序中第k小的数字
return pre;
}
};
总结
该题的 核心思想 是 字典树 ,难在我们并不需要构造这么一颗字典树,但是我们需要有这么一个抽象的模型来辅助我们理解,并在这样抽象的问题建模上进行遍历和统计等操作。其中操作的难点在于如何统计当前节点(前缀)下所有子节点个数,这个是不容易想到的。但这些都是在字典树的基础上进行操作的,所以核心还是需要我们抽象构建模型来理解。
结束语
人生,就是一场自己与自己的较量。有些人愿意努力十年来实现自己的梦想,而有些人却连十天都坚持不了。生活难免遇到荆棘坎坷,但命运掌握在你自己手里。努力不是为了超越谁,而是为了成为更好的自己。