【C/C++】440.字典序的第K小数字

215 阅读5分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第20天,点击查看活动详情


题目链接:440.字典序的第K小数字

题目描述

给定整数 n 和 k,返回 [1, n] 中字典序第 k 小的数字。

提示:

  • 1kn1091 \leqslant k \leqslant n \leqslant 10^9

示例 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 小的数字,注意这里是要求按 字典序 排序。

解题思路分析

习惯性动作:首先确定题目数据范围 1kn1091 \leqslant k \leqslant n \leqslant 10^9 。数据范围很大,极端情况下连 O(n)O(n) 的时间复杂度都是不能通过的,遍历这 n 个数的时间复杂度都成问题,更别说对这 n 个数排序了。我们需要想办法把复杂度降低到 O(n)O(n) 以下。

首先需要对字典序和字典树有一定了解:

  • 字典序:简单来说是将数字按照字符串的排序规则来排序,也就是根据数字的前缀进行排序。(例如: "10" < "2""113" < "13" < "2"
  • 字典树:本题不需要真正的构建字典树,但是需要这样的模型来方便我们理解。利用字典树的特性将所有小于等于 n 的数字按照字典序的方式进行构建,本题构建的字典树模型大致如下:

字典树.jpg

我们可以发现前序遍历该字典树即可得到字典序从小到大的数字序列。先序遍历到第 k 个节点即为第 k 小的数字。

110100……101……102、……

为什么要构建这样模型,这样构建的好处是什么

我们可以发现这样构建之后,每个父节点都是子节点的前缀,对于字典序来说就是根据前缀进行排序。也就是说 子节点的字典序一定小于父亲节点,并且子节点都是以父亲节点作为前缀 。那我们通过计算当前节点(也就是当前前缀)下有多少个子节点(注意每个节点值都是小于等于 n 的),通过子节点的个数来判断第 k 个节点是否在当前节点下,也就是说第 k 个节点是否以当前节点作为前缀

字典树 (1).jpg

我们假设当前节点为 pre (也就是当前前缀) ,pos 为当前节点 pre 字典序排列的值,count 为当前节点下所有子节点的个数,那 判断第 k 个节点是否在当前节点下只需要判断 pos + countk 的大小关系即可

字典树 (2).jpg

  • 如果 pos + count <= k :第 k 个节点在当前节点下,继续往子树中寻找第 k 个节点;
  • 如果 pos + count > k :第 k 个节点在不当前节点下,往旁边节点寻找第 k 个节点,也就是扩大前缀。

如何求得当前节点(前缀)下所有子节点个数

现在只需要考虑如何求得当前节点(前缀)下所有子节点个数即可,这也是本题的 难点

字典树 (3).jpg

我们用 下一个前缀的起点 减去 当前前缀的起点

  • 11 - 10 = 1:当前前缀子节点增加 1
  • 110 - 100 = 10:当前前缀子节点增加 10
  • 1100 - 1000 = 100:当前前缀子节点增加 100
  • ……

那么累加求和就是当前前缀下的所有子节点的总个数。

注意: 我们不要忘了题目的限制要求,节点的数值需要在 [1, n] 中:

字典树 (4).jpg

具体实现

求得当前节点(前缀)下所有子节点个数

  1. 令当前前缀为 cur,当前前缀的下一个前缀为 nxt = pre + 1,当 cur <= n 时表示当前前缀在 n 范围内,不断用下一个前缀和当前前缀相减,计算出当前前缀与下一个前缀之间有多少个小于等于 n 的节点个数,累加求和 res += min(n + 1, nxt) - cur; 注意我们需要统计的是小于等于 n 的节点个数,所以需要和 n + 1min()
  2. 更新 cur *= 10nxt *= 10 不断向字典树深处搜索,也就是找到 n 所在的层数,并且不断统计中途的节点个数,被统计的节点值都是小于 n 且前缀都是以 pre 开始的。
  3. 最后返回总个数 res

找到 [1, n] 中字典序第 k 小的数字

  1. 首先我们初始化当前前缀 pre"1",当前字典序排列 pos 的值为 1
  2. 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 个。

复杂度分析

  • 时间复杂度:O(log2n)O(\log^2 n),其中 n 为给定的数值的大小。每次计算子树下的节点数目的搜索深度最大为 log10n\log_{10}n ,最多需要搜索 log10n\log_{10}n 层,每一层最多需要计算 10 次,最多需要计算 10×(log10n)210 \times (\log_{10}n)^2 次,因此时间复杂度为 O(log2n)O(\log^2 n)
  • 空间复杂度:O(1)O(1),不需要开辟额外的空间,只需常数空间记录常量即可。

代码实现

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;
    }
};

总结

该题的 核心思想字典树 ,难在我们并不需要构造这么一颗字典树,但是我们需要有这么一个抽象的模型来辅助我们理解,并在这样抽象的问题建模上进行遍历和统计等操作。其中操作的难点在于如何统计当前节点(前缀)下所有子节点个数,这个是不容易想到的。但这些都是在字典树的基础上进行操作的,所以核心还是需要我们抽象构建模型来理解。


结束语

人生,就是一场自己与自己的较量。有些人愿意努力十年来实现自己的梦想,而有些人却连十天都坚持不了。生活难免遇到荆棘坎坷,但命运掌握在你自己手里。努力不是为了超越谁,而是为了成为更好的自己。