Leetcode 第1342题:将数字变成 0 的操作次数 (位运算解题法详解)
前言
Leetcode第1342题如果用直观方式来做,其实是一道难度极低的题目。但是如果采用位运算的方式来解,则会涉及许多有趣的衍生知识点,了解其背后的原理对我们认识位运算有很大的帮助。现在,就让我们从Leetcode官方的题目描述开始吧。
Leetcode 第1342题:将数字变成 0 的操作次数
给你一个非负整数 num ,请你返回将它变成 0 所需要的步数。 如果当前数字是偶数,你需要把它除以 2 ;否则,减去 1 。
示例 1:
输入:num = 14
输出:6
解释:
步骤 1) 14 是偶数,除以 2 得到 7 。
步骤 2) 7 是奇数,减 1 得到 6 。
步骤 3) 6 是偶数,除以 2 得到 3 。
步骤 4) 3 是奇数,减 1 得到 2 。
步骤 5) 2 是偶数,除以 2 得到 1 。
步骤 6) 1 是奇数,减 1 得到 0 。
示例 2:
输入:num = 8
输出:4
解释:
步骤 1) 8 是偶数,除以 2 得到 4 。
步骤 2) 4 是偶数,除以 2 得到 2 。
步骤 3) 2 是偶数,除以 2 得到 1 。
步骤 4) 1 是奇数,减 1 得到 0 。
示例 3:
输入:num = 123
输出:12
方法1:最直观的方法
package mainimport ("fmt"
)func main() {num := 14count := 0for num > 0 {if num%2 == 0 {num = num / 2} else { num = num - 1}count++}fmt.Println(count)}
解析:过于简单,阅读即可。
方法2:位运算
- 前置基础知识
- 用位运算判断奇偶数:
首先我们必须了解什么是 “和” 运算,简单而言:
指两个二进制位,两位都为1,结果为1,任何一位为0,则结果为0,比如:
“和” 运算有很多有趣的特性,例如将任意整数与 “1” 做和运算,可以判断出这个数是奇数还是偶数比如:
10进制数字5(即二进制0101),0101 & 0001 , 结果为1,因此5是奇数
10进制数字6(即二进制0110),0110 & 0001 ,结果是0,因此6是偶数
用程序表达:
num := 7if num&1 == 1 {fmt.Println("奇数")} else {fmt.Println("偶数")}
- 知识延申
刚才讲到和运算,是两个位为1结果才为1,此外还有以下几种运算:
或运算 : 操作符 | ,表示两个位,任何其中一位为1则结果为1,例如:0001|0010,结果:0011
异或运算 : 操作符 ^ ,表示两个位,相同则为1,不同则为0,例如:0001^0010,结果:1100
移位运算
左移:操作符 << ,表示将一个二进制位整体左移,相当于将该数乘以2的n次方,例如:
10进制数5 (0101), 左移1位变成:10进制数10(1010 ) 即: 5 * 2的1次方 = 10
10进制数6 (0110), 左移2位变成:10进制数24(0001 1000),即:6 * 2的2次方 = 24右移:操作符 >>,表示将一个二进制位整体右移,相当于将该数除以2,例如:
10进制数5(0101), 右移一位变成:10进制数2(0010) 即:5 / 2 = 2 (整除不含小数点)
10进制数6(0110), 右移一位变成:10进制数3(0011) 即:6 / 2 = 3
注意,无论左移还是右移,均会产生补零问题,不同的是左移不会溢出,而右移则会产生溢出:
左移在低位补零,例如:1111<<1 则变成:11110(最低位补个0)
右移在高位补零,例如:1111>>1 则变成:0111,原有的最低位那个1,溢出后被吞掉。小技巧
在Golang里面,如何通过Printf函数打印不同进制的数字:
%b 输出标准的二进制格式化
%c 输出对应的unicode码的一个字符
%d 输出标准的十进制格式化
%o 输出标准的八进制格式化
%q 要输出的值是双引号输出就是双引号字符串;另外一种就是 go 自转义的 unicode 单引号字符
%x(小写x) 输出十六进制编码,字母形式为小写 a-f
%X(大写X) 输出十六进制编码,字母形式为大写 A-F
%U(大写U) 输出Unicode格式回到题目
用位运算的方法来解这道题,有两个要点:
右移1位代表除2,操作次数加1。
最低位为奇数代表一次减1操作,操作次数加1
用程序表达:num := 14count := 0if num <=0 {return 0} for num > 0 {count = count + (num & 1) + 1num = num >> 1}return count - 1
注意:
程序并没有用 if 语句对num&1的值进行奇偶判断, 观察这行代码:
count = count + (num & 1) + 1
因为右移操作总是固定在执行的,即 count 总是要自增1,
再看 num&1 这个表达式,其结果如果是奇数,则+1(增加1次操作次数),反之则+0 (不增加操作次数)
偶数情况:count = count + 0 + 1 (相当于自增1)
奇数情况:count = count + 1 + 1 (相当于自增2)
再观察这行代码: count = count - 1
count的终值为什么需要减少1次呢?因为任何一个二进制数的最高位肯定是1,比如:0010 这个二进制数,从数值的角度而言,它的前导零没有任何意义(无论10前面有多少个0,它仍然就是10),因此当最后一次右移的时候,判断其为奇数,操作次数加1,此时按照题目要求就应该结束了。但从程序的角度来说,还是要将它右移一次才能触发 num > 0 这个终止条件。因此如果不将这一次操作从总操作次数中去掉,则不符合题目要求。
方法3:直接求值
通过方法2,我们可以总结出一个规律:
- 需要做减1操作的次数,其实就是这个数中所有为“1”的个数,
- 需要做除2(右移1位)的次数,就是整体二进制长度减1,即最高位移动到最低位的长度的距离。
现在以10进制14这个数(1110)为例,
右移次数:
1110
0111 -> 1次
0011 -> 2次
0001 -> 3次
也就是这个二进制数的长度(4-1)次,
再加上这个二进制位1的数量:3个,
最终结果3 + 3 = 6 次
C++ 等语言可以用 __builtin_clz 和 __builtin_popcount 这类函数来求出二进制前导零数目和二进制位 1 的个数,遗憾的是并非所有语言都内置有这种函数,比如说Golang就没有,但是我们可以通过算法来实现。
一、popcount函数的实现(统计一个二进制位中 “1” 的个数)
首先是popcnt,即算出一个二进制数中有多少个“1”,有非常多的方法实现它,最简单的,可以将这个二进制数转换成一个字符串,遍历之后统计出"1"的个数。稍微进阶一点的,可以用前面说过的num&1配合num>>右移来统计出奇数位(即1)的个数。
另外还有一种极端的以空间换时间的方案,即将所有1个字节(8位),每个数字(0~255)所对于的个数记录下来,查表就可以了。但是这个方法虽然效率很高,但是对于大数而言就不现实了,因为不可能在程序中放置如此大一张表。
今天我们用分治法来实现这个函数,其他的方法还有很多,如有兴趣可参考以下网址:
参考资料1:https://zhuanlan.zhihu.com/p/147862695
参考资料2:https://zhuanlan.zhihu.com/p/341488123
参考资料3:https://www.geeksforgeeks.org/count-set-bits-in-an-integer/
使用分治法来加速求解二进制数位 1 的个数,算法如下:
对二进制数 num,它的位 为1 的个数等于所有位的值相加的结果,比如:
11010011= 1+1+0+1+0+0+1+1 = 5。
分治法的底层逻辑非常简单,比如我们可以将 8 个位的求和分解成 4 个相邻的位的求和,
然后将 4 个中间结果分解成 2 个相邻的求和,以此类推,如图:
那么,什么是“相邻的位”呢,其实就是指长度为两位(bit)的二进制数,比如以 “10” 为例:
前面我们提到过,可以用num&1来做奇偶数判断,基于同样原理,这里引入一个新的名词称为 ”比特掩码“。为了统计相邻的两个二进制位有多少个1,我们采用 ”01“ 作为比特掩码,
想象一下,如果将 “10” 这个二进制数分成左右两半的话,1在左侧,0在右侧。
首先让 “10” 直接与比特掩码 “01” 做和运算,实际上就是判断"10"的右侧是否为1;
将 “10” 右移一位后,再次与比特掩码 “01” 做一次和运算,相当于判断了"10"的左侧是否为1;
最后,右侧=00,左侧=01,两者相加,即“10” 这个二进制数中有1个 “1” 。
流程如下:
事实上,无论二进制数有多长,总是可以采用将它折成两段的方法分别计算左右两侧的1的个数,再将左右两侧的结果相加统计出所有的“1”。 理解了分治法的底层逻辑后,我们以一个8位二进制数:11010011为例,一步一步观察分治法是如何工作的。
第1步:计算出相邻两位1的个数 (8合为4)
首先用比特掩码 ”01“,对整个二进制位进行”和“运算,其中,
第一行就是要处理的二进制数 11010011 ,按照相邻为一组的方式分成了4组,用不同的颜色加以区分;
第二行(灰色),是用 ”01“ 组成的比特掩码 ;
第三行(橙色),是第一行与第二行做 ”和“ 运算之后的结果。
第一轮“和”运算得到中间结果后,并没有结束,目前为止只计算了相邻两个比特位的其中一半(右侧),另一半(左侧)还没有计算,现在,将11010011向右移动1位,变成:01101001,再与比特掩码做一次和运算:
现在,得到了完整的相邻两个比特位1的个数了,将他们相加(即将橙色两行加起来),就是相邻两个比特位 1 的个数(PS: 为了方便观察括号里为10进制数):
结合最开始出现那幅图,很容易理解,我们已经把一部分工作完成了:
第2步:再次整合(4合为2)
上一步产生中间结果:10、01、00、10 是 2个Bit位,因此在这一步我们需要用比特掩码 “0011” 才能将之完整覆盖。橙色部分为和计算的结果,原理与之前一样:
同理,在进行右移操作的时候,也需要移动2位,即 11010011>>2 = 00110100
接着,跟之前一样,将和运算的结果相加:
第2步:最后的整合(2合为1)
虽然每一步的计算原理是完全一样的,但必须要提醒的是,随着聚合的比特位越来越多,比特掩码也变得越来越宽 ,现在,因为需要覆盖4个比特位,因此比特掩码也变成了”00001111“,同理右移也需要增加到4位。
首先还是老样子,先映射出右边部分1的个数:
右移4位,然后再映射出左边的1的个数:
将左右两侧结果相加,得到最终结果:
OK,至此我们就将 11010011 这个二进制数里面1的个数统计出来了:5 个。
想象一下,如果我们继续用更大的比特掩码,比如说 “0000000011111111” 再次与上述结果做和运算会发生什么? 答案是什么也不会发生,因为比特掩码的位数已经超过8位了,任何二进制位与1做和运算其结果等于自身,因此再继续下去尽管没有必要,但也不会影响结果。
用程序表达
func popCnt(num uint) int {num = num&0x55555555 + num>>1&0x55555555num = num&0x33333333 + num>>2&0x33333333num = num&0x0F0F0F0F + num>>4&0x0F0F0F0Fnum = num&0x00FF00FF + num>>8&0x00FF00FFnum = num&0x0000FFFF + num>>16&0x0000FFFFreturn int(num)
}
因为函数的形参num是一个uint无符号整数,它的位长是32Bit,其中,
16进制数0x55555555 换算成2进制数为:01010101010101010101010101010101
也就是我们之前使用过的比特掩码”01“
16进制数0x33333333换算成2进制数为:00110011001100110011001100110011
对应的比特掩码”0011“
16进制数0x0F0F0F0F换算成2进制数为:00001111000011110000111100001111
对应的比特掩码”0000111111“
以此类推,一直到0x0000FFFF,则是: 000000000000000011111111111111111
发现规律了吗? 任意长度的32位数,总有将它”罩住“的比特掩码,而任意一个32位数,最多只需要进行5次运算即可得到其中二进制 ”1“ 的个数,因此我们说这个方法的时间复杂度是O(Log32),又因为其只需要常数空间,因此它的空间复杂度是O(1),效率很高。
二、clz 函数的实现(计算一个二进制位的长度)
前面提到过,一个 uint 无符号整数,位长为32位,我们只需要算出这个整数的前导零有几个,然后用32减去这些前导0即可。从这个角度而言,计算一个二进制位的长度,相当于就是计算其前导零的个数。如图:
例如 101001001这个二进制数,其长度就是 32 - 23个前导零 = 9位。
实现这个算法有两种方式,第一种比较简单直观,将 num 做循环左移1位操作,判断最高位是否为1,左移了几次,前导零就有几个。还有另外一种更高效的方法,首先将num一分为二,判断前半部分是否全为零,如果是,则将 clz(前导零计数器) 加上前半部分的长度,然后将后半部分作为处理对象,否则将前半部分作为处理对象。重复以上操作直到处理的对象长度为 1,直接判断是否有零,有则将clz 加 1,如图:
总结而言,这个算法只有两条规则:
前半部分如果为全0,则将其位数计入计数器,取后半部分作为下一步的处理对象;
前半部分如果不是全0,计数器不变,取前半部分作为下一步的处理对象。
如此,任何一个32位无符号整数(uint),无论长短,最多仅需要进行6次计算即可得出其前导零的个数,效率还是很高的。
用程序表达:
func bitsLen(x uint) int {clz := 0if x>>16 == 0 {clz += 16x <<= 16}if x>>24 == 0 {clz += 8x <<= 8}if x>>28 == 0 {clz += 4x <<= 4}if x>>30 == 0 {clz += 2x <<= 2}if x>>31 == 0 {clz++}return 32 - clz
}
上面这段代码采用右移的方法来区分前半部分和后半部分的判断的,例如这个判断:
if x>>16 == 0 {clz += 16x <<= 16}
首先判断 x 右移16位之后的值是否为全0,然后有两种情况:
如果成立,则clz自增16,接着改变 x 的值,将其左移16位,这相当于将 x 的后半部分作为下一步操作对象;
如果不成立,则clz不做改变,也不改变x的值,这就相当于将 x 的前半部分作为下一步操作对象。
结语:使用popcount和clz函数重新解题
绕了一大圈,现在让我们将popcount和clz函数用直接位运算的方法将题目重做一遍。
代码如下:
package mainimport ("fmt"
)// 计算二进制位的长度
func bitsLen(x uint) int {clz := 0if x>>16 == 0 {clz += 16x <<= 16}if x>>24 == 0 {clz += 8x <<= 8}if x>>28 == 0 {clz += 4x <<= 4}if x>>30 == 0 {clz += 2x <<= 2}if x>>31 == 0 {clz++}return 32 - clz
}// 统计二进制位中“1”的个数
func onesCount(num uint) int {num = num&0x55555555 + num>>1&0x55555555num = num&0x33333333 + num>>2&0x33333333num = num&0x0F0F0F0F + num>>4&0x0F0F0F0Fnum = num&0x00FF00FF + num>>8&0x00FF00FFnum = num&0x0000FFFF + num>>16&0x0000FFFFreturn int(num)
}func main() {var num uintnum = 14if num == 0 {fmt.Println(0)}fmt.Println(bitsLen(uint(num)) - 1 + onesCount(uint(num)))
}
最后这段代码就不需要再啰嗦什么了,如果你坚持读到了这里并理解了前面的知识点,那么读懂它是不费吹灰之力的,祝大家刷题愉快,也欢迎大家留言评论拍砖!
Leetcode 第1342题:将数字变成 0 的操作次数 (位运算解题法详解)相关推荐
- leetcode算法题--将数字变成 0 的操作次数
原题链接:https://leetcode-cn.com/problems/number-of-steps-to-reduce-a-number-to-zero/ class Solution {pu ...
- [leetcode双周赛]5311. 将数字变成 0 的操作次数
class Solution {public:int numberOfSteps (int num) {int res = 0;while(num > 0){if(num % 2 == 0){n ...
- 1342. 将数字变成 0 的操作次数 / 1507. 转变日期格式
1342. 将数字变成 0 的操作次数[简单题][每日一题] 思路:[模拟] 定义计数变量ans=0: 当num>0时,如果num是偶数,就将其除2,如果是奇数,就将其减1:每次操作ans加1. ...
- OJ刷题Day1 · 一维数组的动态和 · 将数字变成 0 的操作次数 · 最富有的客户资产总量 · Fizz Buzz · 链表的中间结点 · 赎金信
一.一维数组的动态和 二.将数字变成 0 的操作次数 三.最富有的客户资产总量 四.Fizz Buzz 五.链表的中间结点 六.赎金信 一.一维数组的动态和 给你一个数组 nums .数组「动态和」的 ...
- vgc机器人编程1到13题_杀戮尖塔故障机器人怎么玩 故障机器人玩法详解
杀戮尖塔故障机器人怎么玩 故障机器人玩法详解 2018-07-02 14:15:04来源:游戏下载编辑:Rysj评论(0) 再来看这些能力牌. 和战士和猎人的牌不同,智障机器人的能力牌泛用性都不错. ...
- 1342.将数字变成0的操作次数
难度:简单 目录 一.问题描述 二.思路 1.解题思路 三.解题 1.代码实现 2.时间复杂度 and 空间复杂度 一.问题描述 这里直接采用的是LeetCode上面的问题描述. 给你一个非负整数 n ...
- 【LeetCode】妙用位运算解题
[LeetCode]妙用位运算解题 文章目录 [LeetCode]妙用位运算解题 交替位二进制数★ 插入★ 数字范围按位与★★ 比特位计数★★ 下一个数★★ 消失的两个数字★★★ 修改后的最大二进制字 ...
- 【每日一题】 1319. 连通网络的操作次数
[每日一题] 1319. 连通网络的操作次数 避免每日太过咸鱼,一天搞定一道LeetCode算法题 一.题目描述 用以太网线缆将 n 台计算机连接成一个网络,计算机的编号从 0 到 n-1.线缆用 c ...
- 【2023年第十一届泰迪杯数据挖掘挑战赛】B题:产品订单的数据分析与需求预测 建模及python代码详解 问题一
相关链接 [2023年第十一届泰迪杯数据挖掘挑战赛]B题:产品订单的数据分析与需求预测 建模及python代码详解 问题一 [2023年第十一届泰迪杯数据挖掘挑战赛]B题:产品订单的数据分析与需求预测 ...
最新文章
- 学机器学习有必要懂数学吗?深入浅出机器学习与数学的关系附教程
- 懒加载中进行字典转模型
- UI Prototype Design IDE( 界面原型设计工具 )
- 【知识小课堂】 之 聚合函数
- 用碧海潮声制作的宋体(雅黑宋体)替换Windows7原生的火柴棍式的宋体
- jenkins 部署问题
- 学习Java之前先学C语言
- Android 进程生命周期 Process Lifecycle
- Java讲课笔记16:内部类
- 从BlackHat2013中我们收获了什么
- 全国计算机考试可以异地考吗,公务员省考可以异地考吗
- Welcome to Swift (苹果官方Swift文档初译与注解八)---53~57页(第二章)
- Python绘制Excel图表
- 处女的第一次不一定会流血!很感人 我都流泪了!
- Java Web项目开发项目经验总结
- 利用python爬虫(案例1)--某电影网站的小电影们
- 优化AI搜索引擎,从这3个领域入手!
- 英文单词缩写规则(转自天涯)
- 计算机无法进入bios按,BIOS无法进入实测解决教程
- pfx证书转pem、crt、key
热门文章
- android手机怎么将录音传输到qq,音频转换器安卓版本_音频转换器怎么使用
- 物联网毕业设计 单片机智能手环设计与实现
- css设置div上下左右均居中 、底部居中
- 成都宁源鑫成:拼多多商家如何做好品牌策划?
- ssm+JSP计算机毕业设计壹家吃货店网站quk1f【源码、程序、数据库、部署】
- esp8266电池供电方案_节能小妙招,8个太阳能供电案例抢先围观
- VMware vcenter 6.0 (windwos 版本) 打开网页错误503
- 微软将允许员工永久性远程办公
- HIT-ICS-计算机系统大作业-程序人生
- CorelDRAW 9发生不可预期的错误解决方法