你管这玩意叫异或运算?

对于底层开发来讲,位运算是很是重要的一类操做。而对于位运算来讲,最有意思的,应该就是异或运算(XOR)了。
linux


提到异或运算,不少同窗可能首先想到的就是一个经典的,和异或运算相关的面试问题:nginx

给你一个包含有 n - 1 个元素的数组,其中每一个数字在 [1, n] 的范围内,且不重复。也就是从 1 到 n 这 n 个数字,有一个数字没有出如今这个数组中。编写一个算法,找到这个丢失的数字web


诚然,这样的问题能够考察你们是否真正理解异或运算,但其实这种问题没什么意义。面试


是的,可能你们发现了,做为一个喜欢算法,常常玩儿算法,天天在慕课网的课程问答区回答你们算法问题的老师,我却常常怼各类算法问题没有什么意义... 算法


由于咱们在实际编程中,很难遇到这样的场景:有一个数组,有 n - 1 个元素,其中刚好其中一个元素丢失了...编程


但在这篇文章中,你将看到,真实世界的异或运算是被怎样应用的。数组





1.缓存


为了文章的完整性,咱们先简单来看一下,什么是异或运算ruby


很是简单:相同为 0,不一样为 1微信


大多数编程语言使用符号 ^ 来表示异或运算。


若是咱们使用真值表来表示的话,异或运算是这样的:


在这里,你们能够仔细体会一下,什么叫相同为 0;不一样为 1


异或运算真值表的第 1 行和第 4 行在说:相同为 0


异或运算真值表的第 2 行和第 3 行在说:不一样为 1


相同为 0,是异或运算的最重要的性质之一。即:

x ^ x = 0


而异或运算最重要的性质之二,能够经过这个真值表的前两行看出来。就是 0 和任何一个数字(y)异或的结果,都是这个数字自己。即:

0 ^ y = y


固然,咱们经过这个真值表,能够很轻易看出来,异或运算知足交换律,即:

x ^ y = y ^ x


因此,上面的性质,咱们也能够说成是:任意一个数字(x),和 0 异或的结果,仍是这个数字自己。即:

x ^ 0 = x


好了,了解了异或运算的这些性质,咱们就已经彻底能够理解绝大多数异或的应用了。



2.


在具体看异或逻辑更加实际的应用以前,咱们仍是先来简单分析一下文章开始,那个经典的面试问题,来作一作热身。

给你一个包含有 n - 1 个元素的数组,其中每一个数字在 [1, n] 的范围内,且不重复。也就是从 1 到 n 这 n 个数字,有一个数字没有出如今这个数组中。编写一个算法,找到这个丢失的数字


若是使用异或解决的话,只须要首先计算出从 1n 这 n 个数字的异或值,而后,再将数组中的全部元素依次和这个值作异或,最终获得的结果,就是这个丢失的数字。


写成式子就是:

1 ^ 2 ^ 3 ^ ... ^ n ^ A[0] ^ A[1] ^ A[2] ^ ... ^ A[n - 2]


这个算法为何是正确的?


由于在这个式子中,除了丢失的那个数字只出现了一次,其余数字都出现了两次。


因此,两个相同的数字作异或,结果为 0;最终只出现一次的那个数字,和 0 作异或,结果就是这个丢失的数字。



值得一提的是,对于这个问题,咱们彻底能够不使用异或运算,也设计出一个时间复杂度是 O(n),空间复杂度是 O(1) 的算法。方法是,先计算出 1 到 n 的和,再用这个和,依次减去数组中的数字就行了。


1 到 n 的和,能够经过等差数列求和公式直接计算出:


(1 + n) * n / 2 - A[0] - A[1] - A[2] - ... - A[n - 2]


可是,这个方法有一个问题,就是若是 n 比较大的话,1 到 n 的数字和会超出整型范围,致使整型溢出。


实际上,当 n 到达 7 万这个规模的时候,1n 的数字和就已经不能使用 32 位 int 表示了。固然,咱们可使用 long 来表示,但使用 long 作运算,性能是比使用 int 慢的。


使用异或,则彻底没有这个问题。



这个经典的面试问题,能够很容易地被改变成以下版本:

多余的数:给你一个包含有 n + 1 个元素的数组,其中每一个数字在 [1, n] 的范围内,且 1 到 n 每一个数字都会出现。也就是从 1 到 n 这 n 个数字,有一个数字在这个数组中出现了两次。编写一个算法,找到这个多余的数字


相信理解了上面的问题,这个问题就很简单了。答案是首先计算出从 1n 这 n 个数字的异或值,而后,再将数组中的全部元素依次和这个值作异或,最终获得的结果,就是这个多余的数字。


是的,算法如出一辙。只不过如今,第二部分有 n + 1 个元素,而非 n - 1 个元素而已:


1 ^ 2 ^ 3 ^ ... ^ n ^ A[0] ^ A[1] ^ A[2] ^ ... ^ A[n]


这个算法为何是正确的?


由于在这个式子中,除了多余的那个数字出现了三次,其余数字都出现了两次。因此,其余数字经过异或,结果都为 0,而一个数字和本身作 3 次异或运算,结果仍是它本身:

x ^ x ^ x = 0 ^ x = x


据此,咱们能够很是简单地获得结论:

一个数字和本身作偶数次异或运算,结果为 0;

一个数字和本身作奇数次异或运算,结果为 1。



3.


异或运算最典型的一个应用,是作两个数字的交换


传统的两个数字的交换,是使用这样的三个赋值语句:

int t;x = t;x = y;y = t;


这样作的问题是,须要一个额外的临时变量 t。为一个新的变量开空间,是性能的损耗,哪怕这只是一个 int 值而已。这一点,在高级编程语言中体现不出来,可是在底层开发中,就会有影响。


而咱们使用异或运算,彻底能够不使用这个额外的临时变量。只须要这样就好:

x ^= y;y ^= x;x ^= y;


为了理解这个过程为何是正确的,咱们能够画以下的示意图:


初始的时候,x 里就是 xy 里就是 y


第一句话 x^=y,实际上,让 x 里放的是 x ^ y


第二句话 y^=x,实际上,让 y 和当下 x 里存放的值:x ^ y 进行了异或:


注意,此时,y 里有一个 x 和两个 y 。两个 y 异或的结果就是 0,因此,此时 y 里存放的是 x


最后,第三句话,再一次 x ^= y,但由于如今 x 里存放的是 x^yy 里存放的是 x,因此,这句话之后,x 中是 (x^y)^x


此时,x 里有两个 x 和一个 y 。两个 x 异或的结果就是 0。因此此时,x 里存放的是 y 的值:


至此,xy 的交换完成了。



4.


大多数资料关于使用异或运算进行两个数字的交换,介绍到此,就结束了。而实际上,这个算法是有 bug 的。


这个 bug 在 2005 年,第一次被 Iain A. Fleming 发现。


在上面的演示中,若是 xy 是两个不一样的地址,才成立。


但若是 xy 是同一个地址呢?好比,咱们调用的是 swap(A[i], A[j]),其中 i == j。此时,上面的算法是错误的。


由于,在这种状况下,咱们第一步作的 x ^= y,实际上就是 A[i] ^= A[i]。这将直接让 A[i] 中的元素等于 0,而丢失本来存在 A[i] 中的元素。后续,这个元素就再也找不回来了。


针对这个 bug,解决方案是,在作这个交换以前,判断一下 xy 的地址是否相同。


因为在一些语言中,拿到变量的地址并不容易(甚至没有这个能力),因此,能够把逻辑改变为,判断一下 x 和 y 是否相同。若是相同,则什么都不作。


由于若是 x 和 y 的地址同样,x 和 y 的值确定也同样,什么都不作,则避免了这个 bug;


即使 x 和 y 的地址不同,但若是 x 和 y 的值相同,什么都不作也是正确的。


因此,咱们的逻辑变成了这样:

if(x != y){  x ^= y;  y ^= x;  x ^= y;}


由于在底层编程中,if 判断也是比较耗费性能的,因此,一个更优雅的写法是这样的(C / C++):

(x == y) || ((x ^= y), (y ^= x), (x ^= y))


在这个写法中,巧妙地使用了逻辑短路,若是第一个表达式 x == y 成立,后面的交换过程就不会被执行了;不然,运行后面的交换逻辑。


这样写,整个逻辑中没有了 if 判断。


在极端状况下,即便在高级语言编程中,没有 if 运算也将大大提高程序性能。能够参考我以前的文章:用简单的代码,看懂 CPU 背后的重要机制



值得一提的是,2009 年,Hallvard Furuseth 提出,下面的写法性能更优,由于表达式 x^y 能够被缓存重复利用:

(x ^ y) && (y ^= x ^= y, x ^= y)


在 2007 年和 2008 年,Sanjeev Sivasankaran 和 Vincent Lefèvre 提出,这个交换过程也可使用加减运算完成:

(&a == &b) || ((a -= b), (b += a), (a = b - a))


篇幅缘由,在这里,我就不对这个逻辑作模拟了。感兴趣的同窗,可使用文章中的方法,自行模拟,验证这个算法的正确性:)



5.


异或运算的另外一个直接应用,是编译器的优化,或者是 CPU 底层的优化


举个简单的例子,在不少编译器的内部,判断 if(x != y)


本质是在判断:if((x ^ y) != 0)


不少同窗可能会从数学的角度,认为判断 x 是否等于 y,是看 x - y 的结果是否为 0


但实际上,减法是一个比异或操做复杂得多的操做。若是学习过数字电路的同窗会知道,设计一个减法器,并不容易。


可是,两个数字按位异或,就很是容易了。



另外一方面,在计算机底层,异或的一个重要的应用,是清零


由于本身和本身异或的结果是零,因此,近乎全部的 CPU 指令中,清零操做都是使用异或完成的。

xor same, same


还记得以前说的,两个元素交换的 bug 吗?这个 bug 的本质,就是当两个元素的地址同样的时候,至关于对这个地址作清零了。


固然,从体系结构的角度,这个清零不只仅能够发生在内存,也能够发生在寄存器。

xor reg, reg


对于这个问题,在 stackoverflow 上有一个很是好的讨论。感兴趣的同窗能够阅读一下:

https://stackoverflow.com/questions/33666617/what-is-the-best-way-to-set-a-register-to-zero-in-x86-assembly-xor-mov-or-and

What is the best way to set a register to zero in x86 assembly: xor, mov or and?



6.


真正让异或运算大获异彩的,实际上是在密码学领域,尤为是在对称加密领域


实际上,异或运算近乎被应用在了全部的对称加密算法中


系统地讲解密码学已经远超这篇文章的范畴了。在这里,我只给出一个简单的例子,让你们能够直观地理解,为何异或运算能够用在对称加密算法中。


好比说,咱们有一个密文。这个密文就是 hi 吧。它所对应的二进制是:

01101000 01101001


下面,咱们能够生成一个秘钥。为了简单起见,咱们假设生成的秘钥和密文是等长度的。好比密钥是 66,对应的二进制是这样的:

00110110 00110110


那么,咱们将密文和秘钥作异或操做,获得的结果,就是加密后的信息:

01101000 01101001 (密文)异或00110110 00110110 (秘钥)=01011110 01011111 (加密信息)


这个加密信息,对应的字符串是 ^_


这个字符串显然没有意义。可是,若是你知道秘钥 66 的话,将这个加密信息和秘钥 66 再作异或运算,就能够恢复原先的密文 hi


相信看到这里,这背后的原理,你们都已经了解了。是异或运算性质最基本的应用,其实很是简单。



固然,生产环境的对称加密没有这么简单,但这是最基础的原理。


若是有兴趣的同窗,能够搜索学习一下 DES(Data Encryption Standard)AES(Advanced Encryption Standard),就会看到异或运算在其中所起的重要做用。



实际上,在编码学领域,特别是各种纠错码校验码,异或运算也常常出现。


好比奇偶校验,好比 CRC 校验,好比 MD5 或者 SHA256,好比 Hadamard 编码或者 Gray 码(格雷码)


格雷码可能不少同窗都据说过,通常在离散数学或者组合数学中会接触。


最近力扣有一次周赛的问题,本质实际上是格雷码和对应二进制数字之间的转换,有兴趣的同窗能够了解一下:



若是明白格雷码的原理,这个 Hard 问题就是 Easy 问题,一通异或运算就解决了 




7.


最后,说一个我最喜欢的异或的应用。


使用异或,能够编写更加节省空间的双向链表,被称为是异或双向链表(XOR linked list)


在维基百科中,专门收录了这个词条:



这种双向链表,由 Prokash Sinha 在 2004 年第一次提出,而且发表在了 Linux Journal 上。被称为是:A Memory-Efficient Doubly Linked List(一种更有效利用空间的双向链表)。


感兴趣的同窗,能够在这里阅读这篇文章:

https://www.linuxjournal.com/article/6828


在原文中,做者对相关的数据结构进行了代码级别的定义。




实际上,这种数据结构的原理很是简单。


在一般的双向链表中,每个节点须要有两个指针,一个 prev,指向以前的节点;一个 next,指向以后的节点。


可是,异或双向链表中,只有一个指针,咱们能够管它叫 xor_ptr。这个指针指向的地址,是 prevnext 两个地址异或的结果。其中,头结点的 prev 地址取 0;尾结点的 next 地址取 0


这样一来,若是咱们须要得到一个节点的 next 的地址,只须要 xor_ptr ^ prev 就好;

若是咱们须要得到一个节点的 prev 的地址,只须要 xor_ptr ^ next 就好。


咱们之因此能够这么作,是由于对于双向链表,全部的查询操做,确定是从头至尾,或者从尾到头进行的,而不可能直接从中间进行。也就是所谓的链表不支持随机访问。


所以,在咱们遍历异或双向链表的过程当中,若是咱们是从头至尾遍历的话,咱们就能够一直跟踪每个节点的 prev  值。用这个值和 xor_ptr 作异或操做,拿到每个节点的 next


同理,若是咱们是从尾到头遍历的话,咱们就能够一直跟踪每个节点的 next 值。用这个值和 xor_ptr 作异或操做,就能够拿到每个节点的 prev


我强烈建议感兴趣的同窗,本身动手编程实现一个异或双向链表,是一个颇有意思,也很酷的编程练习:)



8.


文章的最后,聊一个我第一次接触异或运算,产生的疑问,相信不少同窗都有。


那就是,异或运算,为何叫异或?这个名称命名的来源,显然和或运算(or)有一些关系,可是这个关系究竟是什么?


答案是,异或运算能够表示成这样:


x ^ y = (!x and y)  or (x and !y)


右边的式子也很好理解。由于异或运算就是 xy 不一样为真。


因此,!x and y 表示 x 和 y 不一样,其中 x0y1


x and !y 也表示 x 和 y 不一样,其中 x1y0


这两种状况的任何一个,在异或的定义下,都是真。因此,这两种状况,是或的关系。


看,异或这个概念就被这样对应起来了:


异,就是 x 和 y 不一样;或,就是这两种状况取或的关系。


是否是很酷?


你们加油!:)


推荐阅读:
一道 LeetCode 周赛的题目,让我自信满满!
LeetCode 全站第一,牛逼!


   


本文分享自微信公众号 - 五分钟学算法(CXYxiaowu)。
若有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一块儿分享。

相关文章
相关标签/搜索