CSAPP习题思考(位操作)
现在发现写技术方面的文章真是不容易,不像写随感文,随便热血一下两三个小时就出来了。这篇文章至少用了5、6个小时,但依然感觉没写到位,很多想说的却写不出来。想和说(写)是两种境界,所以每次看pongba洋洋散散五六千字,看着不累而且有趣,其背后的心血和深厚沉淀都不是一时半会儿练就的。
记得以前哪个牛人说过:“如何把一个问题解释给一个毫无技术背景但同样很聪明的人听,让别人理解,这才代表自己真正理解了问题。”所以Joel把写作列为优秀程序员的必备素养之一。写作的过程就像和心灵对话,不断在找自己的茬(各种论证的弱点、表述的清晰性、思维过程的总结等等),然后慢慢提高。
言归正传,最近在做《Computer Systems: A Programmer’s Perspective》(CSAPP)的习题,有些题目做得还是挺吐血的,但题目总体颇具思考价值,对增加知识的理解很有帮助。举一些有意思的,主题是位操作。
1) 比较两数是否相等
可用操作:! ~ & ^ | + << >>
最多5步
可以想到判断两数相等就是判断两数的每个位是否相等,所以想个方法来检测每个位是关键。又想到位操作中异或操作其实就是判断两个位是否相等的,即0^0=0, 0^1=1, 1^0=1, 1^1=0。于是只要将两个数异或一下,然后如果为0则是相等,反之则不相等。最后要把结果颠倒下,因为函数要求返回为1是相等,0则不相等。于是得到
int isEqual(int x, int y){return !(x ^ y);
}
2) 逻辑右移n位
可用操作:~ & ^ | + << >>
最多16步
可以想到右移有两种情况:1)负数,右移前n位都为1。2)正数,右移前n位都为0。对于负数如何把前n位置为0就成了问题所在。可以想到如果有一个数前n位为0,后32-n位为1,再把它和移位后的x与一下就是结果了。
于是,我想到做这样一个掩码让它的符号位为1,再右移n位,于是得到一个前n位为1,而后32-n位为0的数,再把这个数取反,就能满足条件了。
int logicalShift(int x, int n){int mask = ~((1 << 31) >> n); int mask = ~((1 << 31) >> (n -1));
return (x >> n) & mask;
}
3)如果数中含有1的个数为奇数则返回1,否则返回0
事例:bitParity(5)=0,bitParity(7)=1
可用操作:! ~ & ^ + << >>
最多20步
最容易想到的就是线性的方法了,检查每一个位,为1则累加1。最后检查累加值最低位(奇数的判断)是1,则输出1,否则返回0。但可以看到这样的话需要检查的32步,再加上累加操作的32步,总共64步。大于20步。
经过上述思考,可以发现用线性的方法极有可能是要超出20步的。那么最容易降低复杂度的就是O(logN)的算法。鉴于此,我想到可以用二分的方法试一下。那么,怎么用二分呢?再仔细一看,由于检查的是含有1是否为奇数,并没有要求计算总的数量,所以是否可以用一个操作符来判断奇偶。
怎样的操作数可以判断奇偶,于是想到xor。由于xor在两数都不相同时为1,所以多个位作xor操作,得出为1则说明有奇数个1。并且利用二分的思想分解成两个16位->两个8位……->两个1位,如此不断xor。就得出了最后答案。
int bitParity(int x){int ret = (x >> 16) ^ x;
ret = (ret >> 8) ^ ret;ret = (ret >> 4) ^ ret;ret = (ret >> 2) ^ ret;ret = (ret >> 1) ^ ret;return ret & 1;
}
4)完成!x运算,且不使用!运算符
事例:bang(3)=0,bang(0)=1
可用操作:~ & ^ | + << >>
最多12步
首先,这里的!运算其实就是判断这个数是否为0,!0=1其余都为0。由于只能使用位操作,所以不能直接x==0来输出。而且最多12步,所以线性的32步的位判断是没出路的。这里最初的想法还是用二分来减少运算。
想到因为0的编码位都为0,所以只要检测出有一位1的就可以得出结果了。那么判断是否有一个位为1就成了解决问题的关键。怎么解决呢?想到了或运算可以保持1的存在。所以似乎觉得可以用或,那么如何结合二分呢?想到了这样一幅图,解决32位中是否有1,其实可以分解成两个16位是否有1,一个16位又可以分解成两个8位……如此不断,就到了两个1位,那最后不就只剩下1位了嘛。
这里的核心关键是只要有一位存在1,我们就找出答案了,所以可以使用或操作。于是,想到可以把每个位都互相或一下,答案是1的话就说明有一位存在1。这里就用到了二分思想,把32位分成两部分或一下,再把结果分成8位或一下,最后只剩下一位。可以看出每位的互相或一下和这边的二分之后或其实是等价的。
另外最开始,我设定了一个掩码(mask)把A前部分的不需要的去掉,mask = ~((1 << 31) >> 15),即mask = 0000….11111,A = (x >> 16) & mask,这样A的前16位为0,对B=x & mask。而后发现,这样做会超过步数。仔细分析,其实我们关注的只是那16位,对于不需要的部分其实也不用去掉,于是得到以下代码:
int bang(int x){int ret = (x >> 16) | x;
ret = (ret >> 8) | ret;ret = (ret >> 4) | ret;ret = (ret >> 2) | ret;ret = (ret >> 1) | ret;return (~ret) & 1;
}
用了正好12步,还是很惊险。
5)判断加法是否溢出
事例:addOK(0x80000000, 0x80000000)=0, addOK(0x80000000, 0x70000000)=1
可用操作:! ~ & ^ | + << >>
最多20步
想破头的一题,开始的对溢出的理解错误。如下:
以下为初始的错误想法:
所谓溢出,我想到的就是溢出到了第33位,那么问题就变成了如何判断相加后第33位是否为1的问题。这里,我再次想到了二分的问题。考虑一个小的问题,比如两个16位的数相加怎么判断溢出,我想只要把它们放到两个32位的数中然后,判断第17位就行了,这个问题很容易。那么现在问题扩大了一倍,而且我手头不能用64位去存储32位数的变量,怎么办呢?所谓二分就是减小问题,不如把32位的数拆成两半,如图:
根据加法原理,数从低位加到高位,那么把x和y拆成两部分,判断低16位是很容易的,在把第17位放到高16位(sx2和sy2)的加法运算中,判断是否溢出就可以解决了。
代码如下:
int addOK(int x, int y){int mask = ~((1 << 31) >> 15); //0000....1111int sx = x & mask; //sx1int sy = y & mask; //sy1int sum = sx + sy;
sum >>= 16; // get the 16th bit, sum = 0 or 1, check wethter sx1 + sy1 is overflow(16bit)
sx = ((x >> 16) & mask); //sx2
sy = ((y >> 16) & mask); //sy2
sum += sx + sy;return (sum >> 16);
}
正解:
而后发现一个简单的反例:0xFF,FF,FF,FF(-1) + 0xFF,FF,FF,FF(-1)=-2,显然没有溢出,但按我的算法是溢出的。错误的想法其实对原码是正确的,但补码的机制不是这样。于是找了好几个例子来看,终于算是搞清了溢出的机制。观察两个正负数相加是肯定不会溢出的,溢出的情况有两种:1)两个负数得正数。2)两个正数得负数。也就是问题可以转换为检查初始两个加数的符号位和运算结果的符号位。
于是,我得出两种情况是非法的x和y的符号位1,1得出的符号位0。以及x和y的符号位0,0得出的符号位1。问题是怎么判断,这个时候想念到if的好了,没有if的日子真是非常难过啊,还有那些个||和&&运算符。经过无数的尝试,终于想到一个关键的地方:用两个加数的符号位作为开关。也就是这样一个形式:? & 开关。?代表的应该是加数的符号位和结果的符号位的一些运算。说实话,这道题我是在不断地尝试,至于其背后的机制为什么会想到,现在都感觉像是暴力搜索一样,只是感觉上应该是那样。代码如下:
int addOK(int x, int y){int sign = ~((x ^ y) >> 31); //提取符号位,相同为1,不同为0int result = x + y;
int xSign = (x >> 31) ^ (result >> 31); //用某一个加数的符号位去xor结果的符号位,为什么只用一个加数而不考虑另一个,因为开关在这里起了一个重要的作用可以忽略这样的情况而保证答案的正确性return !(xSign & sign & 1); // 取出最后一位且取反}
以上的一些题都用到了二分的方法,虽然二分的具体实现方法不同,但本质的思想。想到用二分的原因有两点。一者,之前看《编程珠玑》时,作者用二分很优美的解决了一些问题。于是二分在印象中比较深刻。二者,这些问题最初的考虑都是用线性方法,也就是最简单的方法,但由于步数的限制必须降低算法复杂度。由O(n)自然会考虑用O(logn)来降低,于是考虑二分就比较自然了。以上只是练习的一部分,还有一些比较类似的就不说了。
再者,列出一些至今还没有想到办法解决的,当然暴力的线性总可以解决,但始终觉得不够优美。
1) 找出最低位1的位置。
事例:leastBitPos(96)=0x20
可用操作:! ~ & ^ | + << >>
最多30步
只想到了暴力方法,完全没头绪。
2) 计算数中为1的位的个数
事例:bitCount(5)=2, bitCount(7)=3
可用操作:! ~ & ^ | + << >>
最多40步
考虑用xor结合and的方法,但细节方面的二分或者其他具体实现还是想不明白。