[轉]分享文章《真實世界的異或運算》

位運算是神器,異或是神器中的神器,強烈推薦閱讀@liuyubobobo 老師的文章:真實世界的異或運算。如下內容所有轉載於原文!java


對於底層開發來講,位運算是很是重要的一類操做。而對於位運算來講,最有意思的,應該就是異或運算(XOR)了。linux

提到異或運算,不少同窗可能首先想到的就是一個經典的,和異或運算相關的面試問題:c++

給你一個包含有 n - 1 個元素的數組,其中每一個數字在 [1, n] 的範圍內,且不重複。也就是從 1 到 n 這 n 個數字,有一個數字沒有出如今這個數組中。編寫一個算法,找到這個丟失的數字程序員

誠然,這樣的問題能夠考察你們是否真正理解異或運算,但其實這種問題沒什麼意義。面試

是的,可能你們發現了,做爲一個喜歡算法,常常玩兒算法,天天在慕課網的課程問答區回答你們算法問題的老師,我卻常常懟各類算法問題沒有什麼意義...算法

由於咱們在實際編程中,很難遇到這樣的場景:有一個數組,有 n - 1 個元素,其中剛好其中一個元素丟失了...編程

但在這篇文章中,你將看到,真實世界的異或運算是被怎樣應用的。數組


1.

爲了文章的完整性,咱們先簡單來看一下,什麼是異或運算?緩存

很是簡單:相同爲 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) 的算法。方法是,先計算出 1n 的和,再用這個和,依次減去數組中的數字就行了。

1n 的和,能夠經過等差數列求和公式直接計算出:

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

可是,這個方法有一個問題,就是若是 n 比較大的話,1n 的數字和會超出整型範圍,致使整型溢出。

實際上,當 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 的地址是否相同。

因爲在一些語言中,拿到變量的地址並不容易(甚至沒有這個能力),因此,能夠把邏輯改變爲,判斷一下 xy 是否相同。若是相同,則什麼都不作。

由於若是 xy 的地址同樣,xy 的值確定也同樣,什麼都不作,則避免了這個 bug;

即使 xy 的地址不同,但若是 xy 的值相同,什麼都不作也是正確的。

因此,咱們的邏輯變成了這樣:

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 表示 xy 不一樣,其中 x0y1

x and !y 也表示 xy 不一樣,其中 x1y0

這兩種狀況的任何一個,在異或的定義下,都是真。因此,這兩種狀況,是或的關係。

看,異或這個概念就被這樣對應起來了:

異,就是 xy 不一樣;或,就是這兩種狀況取或的關係。

是否是很酷?

你們加油!:)


本文相關閱讀推薦:

用簡單的代碼,看懂 CPU 背後的重要機制

Java 程序員,別用 Stack?!

Tarjan 這個大神

不會翻轉二叉樹的大神


位運算是神器,異或是神器中的神器,強烈推薦閱讀@liuyubobobo 老師的文章:真實世界的異或運算。以上內容所有轉載於原文!

相關文章
相關標籤/搜索