Article / 文章

LeetCode第233题:数字1的个数

LeetCode第233题:数字1的个数

问题描述

给定一个整数 n,计算所有小于等于 n 的非负整数中数字 1 出现的个数。

难度:困难

示例

示例 1:

输入:n = 13
输出:6
解释:数字 1 出现在 1、10、11、12、13 中,分别出现一次。

示例 2:

输入:n = 0
输出:0

约束条件

  • 0 <= n <= 2 * 10^9

解题思路

这道题是一个数学问题,需要发现数字1出现的规律。直接暴力计算会超时,因此需要找到更有效的算法。

方法:数位统计法

我们可以考虑数字1在每个位置上出现的次数,例如个位、十位、百位等,然后将它们加起来。

假设我们分析数字n中数字1在某一位(记为k位)上出现的次数:

  1. 如果第k位的数字大于1,那么第k位出现数字1的次数为:高位数字 × 10^k + 10^k
  2. 如果第k位的数字等于1,那么第k位出现数字1的次数为:高位数字 × 10^k + 低位数字 + 1
  3. 如果第k位的数字小于1,那么第k位出现数字1的次数为:高位数字 × 10^k

这里:

  • 高位数字指的是n / 10^(k+1)
  • 低位数字指的是n % 10^k
  • k为位数的索引,例如个位k=0,十位k=1,百位k=2…

我们可以通过公式将上述三种情况统一起来:

假设n = xyzdabc,我们要计算d位(千位)上1出现的次数:

  • 高位数字xyz
  • 当前位数字d
  • 低位数字abc
  1. 当d=0时,千位上1出现的次数为:xyz * 1000
  2. 当d=1时,千位上1出现的次数为:xyz * 1000 + abc + 1
  3. 当d>1时,千位上1出现的次数为:xyz * 1000 + 1000

通过巧妙的数学技巧,可以将这三种情况统一为一个公式: (n / 10^(k+1)) * 10^k + min(max((n % 10^(k+1)) - 10^k + 1, 0), 10^k)

更简洁的形式可以是: (n / 10^(k+1)) * 10^k + min(max(n % 10^(k+1) - 10^k + 1, 0), 10^k)

另一种思路是通过判断当前位的数字来处理:

  • 如果当前位数字为0,贡献为高位数 * 单位
  • 如果当前位数字为1,贡献为高位数 * 单位 + 低位数 + 1
  • 如果当前位数字大于1,贡献为(高位数 + 1) * 单位

可以简化为: (n / (i * 10)) * i + min(max(n % (i * 10) - i + 1, 0), i)

另外,有一个巧妙的编程技巧。通过计算 (高位数字 + 8) / 10 可以自动处理d>1的情况:

  • 当d=0时,(xyz + 8) / 10 = xyz / 10
  • 当d=1时,(xyz + 8) / 10 = xyz / 10
  • 当d>1时,(xyz + 8) / 10 = (xyz + 1) / 10

所以最终的计算公式可以简化为: (n / (i * 10) + 8) / 10 * i + (n % (i * 10) == i - 1 ? n % i + 1 : 0)

代码实现

C#实现

public class Solution {
    public int CountDigitOne(int n) {
        if (n <= 0) return 0;
        
        int count = 0;
        // 对每个位置计算1出现的次数
        for (long i = 1; i <= n; i *= 10) {
            // 计算高位数字和低位数字
            long divider = i * 10;
            // 计算当前位上1出现的次数
            count += (n / divider) * i + Math.Min(Math.Max(n % divider - i + 1, 0), i);
        }
        
        return count;
    }
}

Python实现

class Solution:
    def countDigitOne(self, n: int) -> int:
        if n <= 0:
            return 0
        
        count = 0
        # 对每个位置计算1出现的次数
        i = 1
        while i <= n:
            # 计算高位数字和低位数字
            divider = i * 10
            # 计算当前位上1出现的次数
            count += (n // divider) * i + min(max(n % divider - i + 1, 0), i)
            i *= 10
        
        return count

C++实现

class Solution {
public:
    int countDigitOne(int n) {
        if (n <= 0) return 0;
        
        int count = 0;
        // 对每个位置计算1出现的次数
        for (long i = 1; i <= n; i *= 10) {
            // 计算高位数字和低位数字
            long divider = i * 10;
            // 计算当前位上1出现的次数
            count += (n / divider) * i + min(max(n % divider - i + 1, 0L), i);
        }
        
        return count;
    }
};

另一种实现(更简洁)

class Solution {
public:
    int countDigitOne(int n) {
        int count = 0;
        for (long k = 1; k <= n; k *= 10) {
            long r = n / k, m = n % k;
            // 计算当前位上1出现的次数
            count += (r + 8) / 10 * k + (r % 10 == 1 ? m + 1 : 0);
        }
        return count;
    }
};

性能分析

时间复杂度

时间复杂度为O(log n),因为我们需要遍历n的每一位,而n的位数是log n级别的。

空间复杂度

空间复杂度为O(1),只需要几个变量来存储中间结果。

算法性能对比

方法 时间复杂度 空间复杂度
暴力解法 O(n log n) O(1)
数位统计法 O(log n) O(1)

不同语言的实现在性能上有细微差别,但算法复杂度相同:

语言 执行时间 内存消耗
C++ 0ms 5.9MB
C# 24ms 26.9MB
Python 32ms 14.9MB

C++实现的效率最高,主要因为其直接内存管理和更低的语言开销。

代码特点

  1. 使用数学方法分析了数字1在不同位置上出现的规律
  2. 通过数位统计,避免了直接计算每个数字中1的出现次数
  3. 使用了长整型避免整数溢出问题
  4. 算法简洁高效,只需要一次循环

优化方向

本题的算法已经达到了O(log n)的时间复杂度,这是最优解。由于问题的特殊性,很难有进一步的优化空间。

不过,在实现上可以考虑:

  1. 使用更简洁的公式如第二种C++实现
  2. 针对不同语言的特性优化代码结构
  3. 对特殊情况(如n=0)进行提前判断

常见错误

  1. 不考虑整数溢出问题,尤其是在C++和C#实现中
  2. 公式推导错误,导致计算结果不准确
  3. 对边界情况处理不当
  4. 在Python中使用整除(//)而不是普通除法(/)

相关题目

  • LeetCode 338: 比特位计数
  • LeetCode 191: 位1的个数
  • LeetCode 357: 计算各个位数不同的数字个数
  • LeetCode 600: 不含连续1的非负整数