提示
给你一个整数 n
,对于 0 <= i <= n
中的每个 i
,计算其二进制表示中 1
的个数 ,返回一个长度为 n + 1
的数组 ans
作为答案。
示例 1:
输入:n = 2
输出:[0,1,1]
解释:
0 --> 0
1 --> 1
2 --> 10
示例 2:
输入:n = 5
输出:[0,1,1,2,1,2]
解释:
0 --> 0
1 --> 1
2 --> 10
3 --> 11
4 --> 100
5 --> 101
提示:
0 <= n <= 105
进阶:
- 很容易就能实现时间复杂度为
O(n log n)
的解决方案,你可以在线性时间复杂度O(n)
内用一趟扫描解决此问题吗? - 你能不使用任何内置函数解决此问题吗?(如,C++ 中的
__builtin_popcount
)
1 我的想法:
可以用两个int类型xor异或运算的底层逻辑就是在比较二进制每一位,直接int x ,x和0做异或运算,然后一个i作为循环计数就可以了。我不清楚这个时间复杂度怎么样,还有进阶的函数怎么写?
2 对您想法的评价
您提到使用异或运算来计算二进制中 1 的个数,这个思路需要进一步细化。
异或运算(XOR)的特性是 "相同为 0,不同为 1",但直接用 x 与 0 进行异或运算(x ^ 0)的结果还是 x 本身,并不能直接统计 1 的个数。
【解释一下,我没明白,我以为返回的就是1的个数,一位一位的比较?】
您可能对异或运算的结果有一些误解,我们来详细解释一下为什么 x ^ 0
不能直接得到 1 的个数:
异或运算(XOR)的实际结果
异或运算的规则是:相同位为 0,不同位为 1,但它的运算结果是一个完整的二进制数,而不是 1 的个数。
举例来说:
- 当
x = 5
(二进制101
)时,x ^ 0
的结果是101 ^ 000 = 101
(仍然是 5) - 当
x = 6
(二进制110
)时,x ^ 0
的结果是110 ^ 000 = 110
(仍然是 6)
可以看到,x ^ 0
的结果还是 x 本身,它只是把 x 的二进制原样保留了下来,并没有统计 1 的个数。
为什么会有这样的误解?
您提到 "一位一位的比较",这个思路是对的,但异或运算的输出并不是 "比较后统计的数量",而是 "比较后得到的新二进制数"。
如果想通过位运算统计 1 的个数,需要额外的步骤:
- 用
x & (x-1)
消除最右边的 1(每次操作减少一个 1) - 记录消除操作的次数(次数就是 1 的个数)
例如统计 5(101)的 1 的个数:
plaintext
5 & 4 = 101 & 100 = 100(剩1个1)
4 & 3 = 100 & 011 = 000(剩0个1)
共执行2次操作,所以1的个数是2
总结
x ^ 0
的结果是 x 本身,不会直接得到 1 的个数- 异或运算的核心是 "位的比较",而不是 "位的计数"
- 统计 1 的个数需要结合循环和
x & (x-1)
这样的位操作技巧
如果您想基于异或运算实现统计,可以设计这样的逻辑:不断用 x 与自身右移后的结果 异或,逐步消除高位 1,但这种方法本质上还是需要循环计数,和 x & (x-1)
方法复杂度类似。
如果要基于异或运算来实现,可以考虑使用 "x & (x-1)" 的技巧,这个操作能消除二进制中最右边的 1。通过循环执行这个操作直到 x 变为 0,循环的次数就是 1 的个数。这种方法的时间复杂度是 O (k),其中 k 是二进制中 1 的个数,整体时间复杂度为 O (n log n),因为每个数最多有 log n 个 1。
3 Java 实现分析
下面提供两种实现方案,分别对应基础要求和进阶要求:
1. 基础方案(O (n log n) 时间复杂度)【Brian Kernighan 算法】
这种方法使用 "x & (x-1)" 的技巧来统计每个数中 1 的个数:
java
public class CountBitsBasic {
public int[] countBits(int n) {
int[] ans = new int[n + 1];
for (int i = 0; i <= n; i++) {
ans[i] = countOnes(i);
}
return ans;
}
// 统计一个数的二进制表示中1的个数
private int countOnes(int x) {
int count = 0;
while (x > 0) {
// 消除最右边的1
x &= x - 1;
count++;
}
return count;
}
public static void main(String[] args) {
CountBitsBasic solution = new CountBitsBasic();
System.out.println(java.util.Arrays.toString(solution.countBits(5))); // 输出 [0,1,1,2,1,2]
}
}
2. 进阶方案(O (n) 时间复杂度)
这种方法利用动态规划和位运算的特性,实现线性时间复杂度:
java
public class CountBitsAdvanced {
// 主方法:计算0到n每个数的二进制中1的个数,返回结果数组
public int[] countBits(int n) {
// 创建结果数组,长度为n+1(因为要包含0到n共n+个数)
int[] ans = new int[n + 1];
// 初始化:0的二进制表示是0,没有1,所以个数为0
ans[0] = 0;
// 从1开始遍历到n,计算每个数的1的个数
for (int i = 1; i <= n; i++) {
// 核心逻辑:利用动态规划和位运算推导当前数的1的个数
// 1. i >> 1 等价于 i除以2(整数除法),获取i右移一位后的数
// 例如:i=5(101),右移一位是2(10);i=6(110),右移一位是3(11)
// 2. i & 1 用于判断i的奇偶性:
// - 若i是奇数,二进制最后一位是1,i&1=1
// - 若i是偶数,二进制最后一位是0,i&1=0
// 3. 状态转移规律:
// - 偶数i:二进制最后一位是0,1的个数和i/2相同(ans[i] = ans[i>>1])
// - 奇数i:二进制最后一位是1,1的个数比i/2多1(ans[i] = ans[i>>1] + 1)
ans[i] = ans[i >> 1] + (i & 1);
}
// 返回结果数组
return ans;
}
// 主函数:测试代码
public static void main(String[] args) {
// 创建当前类的实例
CountBitsAdvanced solution = new CountBitsAdvanced();
// 调用countBits方法计算n=5时的结果,并打印数组
// 预期输出:[0,1,1,2,1,2],对应0到5每个数的1的个数
System.out.println(java.util.Arrays.toString(solution.countBits(5)));
}
}
进阶方案解析
进阶方案的核心思路是利用数字之间的二进制关系:
-
对于偶数 i(二进制最后一位是 0):i 的二进制表示中 1 的个数与 i/2 相同
- 例如:6 (110) 和 3 (11) 都有 2 个 1
-
对于奇数 i(二进制最后一位是 1):i 的二进制表示中 1 的个数比 i/2 多 1
- 例如:5 (101) 有 2 个 1,而 2 (10) 有 1 个 1
通过位运算可以高效实现这一判断:
i >> 1
相当于 i/2(整数除法)i & 1
用于判断 i 是否为奇数(结果为 1 则是奇数,0 则是偶数)
这种方法不需要调用任何内置函数,并且只需一次遍历即可完成计算,时间复杂度为 O (n),空间复杂度为 O (n)(用于存储结果)。
4 什么是动态规划?
动态规划(Dynamic Programming,简称 DP)是一种通过将复杂问题分解为重叠子问题 ,并存储子问题的解(避免重复计算) 来高效解决问题的方法。
它的核心思想是 "以空间换时间 "【是这样的】,适用于具有重叠子问题 和最优子结构特性的问题。
动态规划的核心特性
要使用动态规划解决问题,需满足两个关键条件:
-
重叠子问题
问题的求解过程中,会反复遇到相同的子问题。例如计算斐波那契数列时,
fib(5) = fib(4) + fib(3)
,而fib(4) = fib(3) + fib(2)
,其中fib(3)
被重复计算。 -
最优子结构
问题的最优解包含子问题的最优解。例如求 "从起点到终点的最短路径",若路径
A→B→C
是最优解,则A→B
和B→C
也分别是对应子问题的最优解。
动态规划的基本步骤
-
定义状态
用一个数组(或变量)
dp[i]
表示 "问题在第i
种情况下的解"。例如:- 斐波那契问题中,
dp[i]
表示第i
个斐波那契数; - 本题(统计二进制中 1 的个数)中,
dp[i]
表示i
的二进制中 1 的个数。
- 斐波那契问题中,
-
推导状态转移方程
找到
dp[i]
与dp[i-1]
或其他子问题的关系。例如:- 斐波那契数列:
dp[i] = dp[i-1] + dp[i-2]
; - 本题中,若
i
是偶数(二进制末尾为 0),则dp[i] = dp[i/2]
(右移一位后 1 的个数不变);若i
是奇数(二进制末尾为 1),则dp[i] = dp[i-1] + 1
(比前一个数多一个 1)。
- 斐波那契数列:
-
初始化边界条件
确定最小子问题的解。例如:
- 斐波那契数列:
dp[0] = 0
,dp[1] = 1
; - 本题:
dp[0] = 0
(0 的二进制没有 1)。
- 斐波那契数列:
-
计算最终结果
按照状态转移方程,从边界条件逐步推导到目标问题的解。
动态规划 vs 暴力递归
- 暴力递归会重复计算重叠子问题,时间复杂度极高(如斐波那契递归的时间复杂度为
O(2^n)
)。 - 动态规划通过存储子问题的解(如用数组
dp
记录),将时间复杂度优化为O(n)
或O(n^2)
等,但需要额外的空间存储dp
数组(空间复杂度通常为O(n)
)。
举例:用动态规划解决本题(统计二进制中 1 的个数)
以 n=5
为例,步骤如下:
- 定义
dp[i]
为i
的二进制中 1 的个数。 - 状态转移方程:
- 若
i
是偶数(i % 2 == 0
):dp[i] = dp[i >> 1]
(右移一位等价于除以 2,1 的个数不变); - 若
i
是奇数(i % 2 == 1
):dp[i] = dp[i - 1] + 1
(比前一个数多一个末尾的 1)。
- 若
- 初始化:
dp[0] = 0
。 - 计算:
dp[1] = dp[0] + 1 = 1
(1 是奇数);dp[2] = dp[1] = 1
(2 是偶数,2>>1=1);dp[3] = dp[2] + 1 = 2
(3 是奇数);dp[4] = dp[2] = 1
(4 是偶数,4>>1=2);dp[5] = dp[4] + 1 = 2
(5 是奇数)。
最终结果为 [0,1,1,2,1,2]
,与示例一致,且时间复杂度为 O(n)
(只需遍历一次),空间复杂度为 O(n)
(存储 dp
数组)。
总结
动态规划的核心是用数组记录子问题的解,避免重复计算 ,通过状态转移方程从已知推未知。它特别适合解决具有重叠子问题和最优子结构的问题,能大幅优化时间复杂度。本题的进阶解法(线性时间 O(n)
)正是利用了动态规划的思想。
【以空间换时间到底怎么理解,类似于多线程并行吗(比喻而已,我知道thread不能这么定义)】
"以空间换时间" 和 "多线程并行" 是完全不同的概念,用一个生活场景的比喻或许能更清晰地理解:
假设你需要每天早上快速找到一双干净的袜子出门:
- 不额外占用空间的做法:每次穿完袜子随手扔在抽屉里,第二天早上需要在一堆乱袜子里翻找配对,每次都要花 10 分钟(时间成本高,但抽屉里的袜子是乱堆的,没额外占用空间)。
- 以空间换时间的做法 :你买了几个袜子收纳盒,每次穿完袜子按颜色分类放进盒子里,第二天早上直接从对应盒子里拿,只要 1 分钟就能找到**(时间成本低了,但多花了买收纳盒的钱,占用了额外的空间)。**
核心区别:
-
以空间换时间
- 本质:通过额外存储中间结果 (比如动态规划中的
dp
数组、缓存技术中的缓存空间),避免重复计算或重复操作,从而减少时间消耗。 - 特点:是单线程内的优化思路,不涉及并行处理,只是用更多的内存 / 存储空间换取更快的执行速度。
- 例子:
- 计算斐波那契数列时,用数组存储已计算的
fib(i)
,避免递归中的重复计算(原本O(2^n)
的时间复杂度优化为O(n)
,但多了O(n)
的空间); - 数据库的索引机制,通过额外存储索引文件(空间),让查询时不用全表扫描(时间)。
- 计算斐波那契数列时,用数组存储已计算的
- 本质:通过额外存储中间结果 (比如动态规划中的
-
多线程并行
- 本质:将一个大任务拆分成多个子任务 ,让多个线程同时执行(比如一个线程算
fib(5)
,另一个线程同时算fib(4)
),从而缩短总时间。 - 特点:是利用多核 CPU 的并行能力,不依赖额外空间,核心是 "同时做多个事"。
- 局限性:如果任务之间有依赖(比如
fib(5)
依赖fib(4)
的结果),就无法并行,此时多线程也无法加速。
- 本质:将一个大任务拆分成多个子任务 ,让多个线程同时执行(比如一个线程算
总结:
- "以空间换时间" 是用存储换效率,属于 "空间资源→时间优化";
- "多线程并行" 是用并行能力换效率,属于 "CPU 资源→时间优化"。
两者都是优化手段,但原理和适用场景完全不同。
动态规划中的 "以空间换时间",就是通过存储子问题的解(占用空间),避免重复计算(节省时间),和多线程无关。