面试题43:1到n整数中1出现的次数
先以逐数拆位建立正确基线,再按最高位、其余位和后缀递归分解十进制区间,并推导等价的high/cur/low逐位公式。
学习目标
- 能按最高位、其余位和后缀递归分解十进制区间,统计 1 出现次数
- 能解释"其余位为何均匀出现"的数学原理
- 能推导等价的 high/cur/low 逐位公式
从“逐个数”转向“逐个位置”开始
先预测:1到12中,1、10、11、12分别贡献1、1、2、1个数字1,合计5。逐个整数拆十进制位能得到答案,却在n很大时重复做大量工作。更快的方向是一次统计某个位置在整个区间里有多少次等于1。
题目“1到n整数中1出现的次数”统计十进制表示里的字符1,不是二进制置位数。作者先给出逐数基线,再把n写成字符串,做↡。
作者解法一:逐数拆位基线
外层从1枚举到n,内层不断取模10并除以10。每个十进制位恰检查一次,因此结果直接正确;时间是O(n乘十进制位数),空间O(1)。
int NumberOf1(unsigned int n) {
int number = 0;
while (n) {
if (n % 10 == 1) {
++number;
}
n /= 10;
}
return number;
}
int NumberOf1Between1AndN_Solution1(
unsigned int n) {
int number = 0;
for (unsigned int i = 1; i <= n; ++i) {
number += NumberOf1(i);
}
return number;
}它很适合作为短输入参考实现,能与优化解随机对拍。接口参数是unsigned int,而测试包装接收int;若新增负数测试,负n会转换成很大的无符号数,不会得到第二解“非正返回0”的语义。n等于无符号最大值时,i递增后回绕到0,i <= n 永远成立,还会形成无限循环。
作者解法二:最高位三部分递归
把当前十进制字符串记为s,首位数字为f,总长度为m,去掉首位的后缀为tail。作者把答案分成:最高位为1的数量H、除最高位外其他位置为1的数量O,以及后缀区间的递归数量F(tail)。
这个每层减少一个字符,直到一位数:0返回0,1到9都返回1。
↡贡献的三种情况
若首位大于1,区间完整覆盖从10的m减1次幂到2乘该幂之前的全部数字,最高位1恰出现一个完整块。若首位等于1,最高位1只从该幂持续到n,数量由后缀值决定。首位等于0只会出现在递归后缀,不产生当前最高位1。
这里的对21345为10000,因为10000到19999共有10000个数字,万位都是1。对1345则是345加1,即346,范围从1000到1345。
| 首位条件 | 贡献 | 区间解释 | 示例 |
|---|---|---|---|
| first等于0 | 0 | 最高位范围尚未进入1区间 | 递归处理后缀 |
| first等于1 | suffix + 1 | 从10的幂到n,后缀从0走到suffix | 21345的千位递归层 |
| first大于1 | 10的length-1次方 | 完整覆盖首位为1的一整块 | 21345贡献10000 |
| 其余位置 | first × (length-1) × 10的length-2次方 | 每个非首位在完整前缀块中均匀轮换 | 21345贡献8000 |
↡为何均匀出现
考虑首位从0到f减1形成的f个完整前缀块。每个块里,其余m减1个位置中的任意一个,都有10的m减2次幂种后缀组合让该位置为1。因此:
这个对21345是2乘4乘1000,得到8000。最后递归统计1到1345的821个1,三部分合计18821。
递归后缀可能有前导0,例如处理1000时tail是字符串000。作者保留字符串长度逐层递归,首位0的H和O都为0,直到一位0基例返回0;不能先用atoi丢掉长度再套同一层公式。
忠实还原字符串实现
作者先拒绝非正n,用固定50字符数组和sprintf写十进制文本。递归函数以strlen取长度,以atoi读取首位为1时的后缀值,以PowerBase10循环计算10的幂。
#include <cstdio>
#include <cstdlib>
#include <cstring>
int PowerBase10(unsigned int n) {
int result = 1;
for (unsigned int i = 0; i < n; ++i) {
result *= 10;
}
return result;
}
int NumberOf1(const char* strN) {
if (!strN ||
*strN < '0' ||
*strN > '9' ||
*strN == '\0') {
return 0;
}
const int first = *strN - '0';
const unsigned int length =
static_cast<unsigned int>(
std::strlen(strN));
if (length == 1 && first == 0) {
return 0;
}
if (length == 1 && first > 0) {
return 1;
}
int numFirstDigit = 0;
if (first > 1) {
numFirstDigit =
PowerBase10(length - 1);
} else if (first == 1) {
numFirstDigit =
std::atoi(strN + 1) + 1;
}
const int numOtherDigits =
first * (length - 1) *
PowerBase10(length - 2);
const int numRecursive =
NumberOf1(strN + 1);
return numFirstDigit +
numOtherDigits +
numRecursive;
}
int NumberOf1Between1AndN_Solution2(int n) {
if (n <= 0) {
return 0;
}
char strN[50];
std::sprintf(strN, "%d", n);
return NumberOf1(strN);
}作者int输入最多10位,递归深度有界。按渐近分析,源码每层重新strlen剩余字符串并循环求10的幂,d层总工作约O(d²),d是位数;若预计算幂或携带长度可到O(d)。这不改变它相对逐数O(n乘d)的巨大改进,但应准确区分源码与理想化算法。
| 方法 | 身份 | 时间 | 空间 | 说明 |
|---|---|---|---|---|
| 逐数拆位 | 作者解法一 | O(n × 位数) | O(1) | 简单参考实现 |
| 字符串递归 | 作者解法二 | 源码约O(位数²) | O(位数)栈 | 每层strlen和幂循环 |
| high/cur/low迭代 | 等价扩展 | O(位数) | O(1) | 逐十进制位统计 |
| 预计算幂的递归 | 优化作者结构 | O(位数) | O(位数)栈 | 避免每层重复求幂 |
三部分为何不重不漏
把不足m位的数字在左侧补0,不会增加数字1,却能把0到n统一看成m位字符串。先按最左字符把范围分成first个完整块和一个最终不完整块,每个完整块包含10的m减1次幂个后缀。
最高位贡献只统计位置0:首位为1的完整块或最终部分块。其余位贡献只统计前first个完整块中的位置1到m减1;这些位置与最高位不同,所以不会重叠。最终不完整块的非最高位置,恰对应后缀从0到tail的所有表示,由F(tail)递归统计。
于是每一个数字1都由“所在数属于完整块还是最终块”和“所在位置是否为最高位”唯一归入H、O或递归项;三类两两不交,合并又覆盖0到n的全部数字。数字0本身不含1,因此把范围从1到n扩成0到n不改变答案。
这个划分也解释了为什么不能删除递归后缀的前导0:补零是保持每层位置身份的工具。虽然前导0不计作1,但它决定后续字符当前属于百位、十位还是个位,从而决定完整块大小。
等价扩展:按十进制位统计
旧页只写了high、cur、low迭代法。它是正确且更直接的工程扩展,但不是作者源码。对位权p,把n分成高位high、当前位cur和低位low。当前位置为1的数量按cur分三种:
这个与作者最高位块计数是同一组合事实。递归法按最高位拆一层,逐位法固定一个位置观察所有前缀周期。
对21345,个位到万位贡献依次为2135、2140、2200、2346、10000,总和18821。千位cur等于1,必须加入low 345和端点,得到2346;这是最容易少一的分支。
#include <cstdint>
std::uint64_t countDigitOne(
std::uint32_t n) {
std::uint64_t answer = 0;
for (std::uint64_t factor = 1;
factor <= n;) {
const std::uint64_t quotient =
n / factor;
const std::uint64_t high =
quotient / 10;
const std::uint64_t current =
quotient % 10;
const std::uint64_t low =
n - quotient * factor;
if (current == 0) {
answer += high * factor;
} else if (current == 1) {
answer +=
high * factor + low + 1;
} else {
answer +=
(high + 1) * factor;
}
if (factor > n / 10) {
break;
}
factor *= 10;
}
return answer;
}uint64中间值覆盖作者正int输入的计数范围,并避免int结果对较大n溢出。循环在乘10前检查边界,防止位权回绕。若把输入扩展到完整uint64,答案本身也可能超过uint64,需要更宽整数或显式上限。
作者八组测试的准确覆盖
Test对同一n分别调用两个解法并与expected比较:
- n等于1,期望1。
- n等于5,期望1。
- n等于10,期望2。
- n等于55,期望16。
- n等于99,期望20。
- n等于10000,期望4001。
- n等于21345,期望18821。
- n等于0,期望0。
99覆盖两个位置完整0到9周期;10000覆盖整十幂边界和端点新增的一个最高位1;21345覆盖首位大于1、后缀首位等于1和多层递归。源码没有负数、int最大值、随机值或溢出检查。
由于解法一本身简单,可以对可承受的随机短n作为参考。还可枚举从0到9999的全部n,比较字符串递归与逐位公式;对10的幂前后各取一个值,专门检查边界增量。
#include <cassert>
#include <cstdint>
std::uint64_t bruteCount(std::uint32_t n) {
std::uint64_t result = 0;
for (std::uint32_t value = 1;
value <= n; ++value) {
std::uint32_t current = value;
while (current != 0) {
result += current % 10 == 1;
current /= 10;
}
}
return result;
}
void testCountDigitOne() {
assert(countDigitOne(0) == 0);
assert(countDigitOne(1) == 1);
assert(countDigitOne(10) == 2);
assert(countDigitOne(55) == 16);
assert(countDigitOne(99) == 20);
assert(countDigitOne(10000) == 4001);
assert(countDigitOne(21345) == 18821);
for (std::uint32_t n = 0; n <= 9999; ++n) {
assert(countDigitOne(n) == bruteCount(n));
}
}bruteCount的循环同样不适合无符号最大值,因为value会回绕;这里测试上限明确为9999。测试工具也要遵守自身输入域,不能拿有回绕缺陷的参考去证明大范围实现。
计数范围与泛化边界
作者返回int。随着n增大,累计出现次数可超过int最大值,即使n本身仍是合法int;工程接口应先估算最大结果并选择64位或更宽类型。sprintf固定50字节对int足够,但通用大整数应直接操作字符串,避免先解析进有限整数。
若统计数字0,公式不能直接把1替换为0,因为普通十进制表示没有前导0;最高位块要扣除未开始的那些周期。统计1到9中的其他非零数字可以平移公式,统计任意数字集合则可按位置贡献相加。
若范围是L到R,可用F(R)减F(L减1),但L为最小整数时要避免下溢。若输入包含负数,还要先定义是否统计负号、是否取绝对值以及0本身怎样表示;作者只定义正整数1到n。
递归字符串版本天然适合超长n,但计数结果也会非常大,需要大整数。此时PowerBase10和atoi都应换成十进制大整数运算,后缀值加1也不能落回机器int。
本章练习
练习
问题 1: 递归分解的三部分是什么?
问题 2: 为什么其余位是均匀出现的?
问题 3: 逐位公式 high/cur/low 如何理解?
概念说明
本章核心概念包括:按十进制位统计。理解这些概念是掌握解题方法的关键,需要结合具体示例与边界条件反复练习。
本章回顾
- 逐数拆位正确但时间O(n乘位数),适合作为短输入参考。
- 作者递归把答案拆成最高位贡献、其余位贡献和后缀贡献。
- 首位大于1给一个完整块,等于1由后缀加1决定。
- 其余m减1位在f个完整前缀块中均匀轮换。
- 21345递归得到10000加8000加821,合计18821。
- high、cur、low逐位公式是等价扩展,不是作者源码。
- 作者源码因每层strlen和求幂约为O(位数²),可优化到O(位数)。
- 无符号回绕、int结果溢出与数字0的前导位语义必须单独处理。
名词解释
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 递归分解
- 把 n 按十进制最高位拆成三部分递归统计。
- 最高位
- 十进制表示中最左边的数字,决定区间划分。
- 均匀分布
- 其余位在完整区间内各数字出现的对称性。