1. 程式人生 > >一道簡單的演算法題:不借助第三變數來交換兩個變數的值

一道簡單的演算法題:不借助第三變數來交換兩個變數的值

今天做筆試碰到一道簡單的演算法題:不借助第三變數來交換兩個變數的值,記錄一下。

交換兩個變數的值的普遍做法都是藉助第三變數,這樣具有較高的可讀性。

a = 3
b = 5

t = a
a = b
b = t

但是,如果記憶體有限,只允許用2個變數呢?
強大的CS當然有辦法解決,不過可能要利用指標、位運算之類的技巧。

1.算術運算:利用兩數之和(差)減去另一個數

原理:把a、b看做數軸上的點,圍繞兩點間的距離 a b

s ( a b ) abs(a-b) 來進行計算。
思路:先把兩數之和儲存在其中一個變數 a 中,然後用和減去另一個變數 b,b= b
b_原
,用這個差覆蓋這個變數b,此時 b 就擁有原來 a 的值(b= a a_原 );接著,用和與新的 b 的差來覆蓋 a,新的 b 等於原來的 a,那麼 a 就擁有原來 b 的值(a= b
b_原
),從而達到交換變數的值的目的。

1.1 和:

a = 3
b = 5
a = a + b  # 和; 運算後,a = 8, b = 5
b = a - b  # b_new == a_ori; a=8, b=3
a = a - b  # a_new == b_ori; a=5, b=3,達到交換變數的目的

依樣畫葫蘆,當然可以藉助兩數之差來實現,思路與上面類似。

1.2 差:

a = 3
b = 5
a = a - b  # 差
b = b + a  # b_new == a_ori
a = b - a  # a_new == b_ori

1.3 乘除也是可能的。

優點:此演算法與標準演算法相比,多了三個計算的過程,但是沒有藉助臨時變數。(以下稱為算術演算法)
缺點:只能用於數字型別,字串之類的就不可以了。a+b有可能溢位(超出int的範圍),溢位是相對的, +了溢位了,-回來不就好了,所以溢位不溢位沒關係,就是不安全。
因為:a + b 向上溢位後,後面的兩次 a - b 又會向下溢位,又溢回來了:)
而MSDN也說得很清楚:對於不用任何 checked 或 unchecked 運算子或語句括起來的非常數表示式(在執行時計算的表示式),除非外部因素(如編譯器開關和執行環境配置)要求 checked 計算,否則預設溢位檢查上下文為 unchecked。
即這種情況下預設是不檢查溢位的,如果我們實在擔心外部因素,大不了加個unchecked:

unchecked
{
  a = a + b;
  a = a - b;
  a = a - b;
}

所以:這個方法也是沒問題的。

2.位運算 ^ (異或):

   異或的特點是:一個數據a與另一個數據b做異或運算之後,變成了另外一個數c,再讀取這個資料就不是原來的資料了,我們如果再拿這個資料c和資料b異或一次,這個資料又變回原來的資料a。**這裡用的實際上是位運算的異或。**
a=3
b=5
a = a^b  # 異或
b = a^b  # b_new == a_ori
a = a^b  # a_new == b_ori
a = 3
b = 5
b = a^b  # 異或
a = b^a  # a_new == b_ori
b = b^a  # b_new == a_ori

如果異或用於或非表示的話,就是:a ^ b = (a or b) and (!a or !b)。
Python中,
(1)位運算與或非用以下符號表示:

&,按位與
|,按位或
~,按位非

(2)邏輯運算與或非用以下符號表示:

and,邏輯與
or,邏輯或
not,邏輯非

兩個數作異或,其實就是用兩個數的二進位制形式作位運算,相同(都為0或1)就得到0,不同為1。因此,需要用位運算的與或非這一套符號

因此,把上面的異或展開成與或非的表示,可得

a = 3
b = 5
a = (a | b) & (~a | ~b)    # 想想異或為什麼要同時滿足兩個條件?
b = (a | b) & (~a | ~b)
a = (a | b) & (~a | ~b)
a = 3
b = 5
b = (a | b) & (~a | ~b)    # 想想異或為什麼要同時滿足兩個條件?
a = (b | a) & (~b | ~a)
b = (b | a) & (~b | ~a)

3. 如果用C語言,那麼還可以用指標

對地址的操作實際上進行的是整數運算,比如:
(1)兩個地址相減得到一個整數,表示兩個變數在記憶體中的儲存位置隔了多少個位元組(位移)
(2)地址和一個整數相加,如“a+10”,表示以a為基地址,在a後10個a類資料單元的地址
所以理論上可以通過和算術演算法類似的運算來完成地址的交換,從而達到交換變數的目的。即:

int *a,*b; //假設
*a=new int(10);
*b=new int(20); //&a=0x00001000h,&b=0x00001200h
a=(int*)(b-a); //&a=0x00000200h,&b=0x00001200h
b=(int*)(b-a); //&a=0x00000200h,&b=0x00001000h
a=(int*)(b+int(a)); //&a=0x00001200h,&b=0x00001000h

通過以上運算a、b的地址真的已經完成了交換,且a指向了原先b指向的值,b指向原先a指向的值了嗎?上面的程式碼可以通過編譯,但是執行結果卻不對!

原因何在?

(1)首先必須瞭解,作業系統把記憶體分為幾個區域:系統程式碼/資料區、應用程式程式碼/資料區、堆疊區、全域性資料區等等。在編譯源程式時,常量、全域性變數等都放入全域性資料區,區域性變數、動態變數則放入堆疊區。

(2)當演算法執行到“a=(int*)(b-a)”時,a的值並不是0x00000200h,而是要加上變數a所在記憶體區的基地址,實際的結果是:0x008f0200h,其中0x008f即為基地址(基地址可以看成座標軸原點),0200即為a在該記憶體區的位移。它是由編譯器自動新增的。【區域性變數的地址為所在記憶體區的基地址+變數在該記憶體區的偏移量。具體原理見文末的註釋。
因此導致以後的地址計算均不正確,使得a,b指向所在記憶體區的其他記憶體單元。

(3)再次,地址運算不能出現負數,即當a的地址大於b的地址時,b-a<0,系統自動採用補碼的形式表示負的位移,由此會產生錯誤,導致與前面同樣的結果。**

有辦法解決嗎?當然!以下是改進的演算法:

int *a,*b; //假設
*a=new int(10);
*b=new int(20); //&a=0x00001000h,&b=0x00001200h

if (a < b)
{
a = (int*)(b-a)
b = (int*)( b - (int(a)&0x0000ffff) )
a = (int*)( b +(int(a)&0x0000ffff) )
}
else
{
b = (int*)(a - b)
a = (int*)(a - (int(b)&0x0000ffff))
b = (int*)(a + (int(b)&0x0000ffff))
}

演算法做的最大改進就是採用位運算中的與運算“int(a)&0x0000ffff”,因為地址中高16位為段地址,後16位為位移地址將它和 0x0000ffff 進行與運算後,段地址被遮蔽,只保留位移地址。這樣就與原始演算法吻合,從而得到正確的結果。
【段地址、基地址的區別見文末註釋】
【為什麼是16位?因為這裡用16進位制,16進位制的1位可以表示的數字的數目等同於二進位制的4位可以表示的數字的數目, 1 6 1 = 2 4 16^1=2^4 ,所以16進位制的4位就等價於2進位制的16位
【與運算的威力可見一斑,還有移位運算,左移一位等於乘以2,右移一位等於除以2······本科的數電模電要好好學】

此演算法同樣沒有使用第三變數就完成了值的交換,與算術演算法比較它顯得不好理解,但是它有它的優點,即在交換很大的資料型別時(比如具有10000位的數字),執行速度比算術演算法快。因為它交換的是地址,而變數值在記憶體中是沒有移動過的。【指標的威力】

4. 棧實現

棧(stack)的特點是 last in first out(LIFO),而佇列的特點是 first in first out(FIFO)。
棧實現真妙!

{
stack S;
push(S,x);
push(S,y);
x=pop(S);
y=pop(S);
}

5. 一行程式碼解決問題

b = (a + b) - (a = b);

假如x和y是字串:string x = “x”,y = “y”;

1可以改裝成:
x = x + y;
y = x.Substring(0, x.Length - y.Length);
x = x.Substring(y.Length);
5可以改裝成:
x = y + (y = x).Substring(0, 0);
或:
x = y + (y = x) == “” ? “” : “”;

總結:算術演算法和位演算法計算量相當,地址演算法中計算較複雜,卻可以很輕鬆的實現大型別(比如自定義的類或結構)的交換,而前兩種只能進行整形資料的交換(理論上過載“^”運算子,也可以實現任意結構的交換)

注:

  1. 彙編中段地址基地址是什麼意思 ?
    段地址其實就是一種基地址,但基地址並不等於就是段地址。
    所謂基地址,顧名思義就可以理解為基本地址,他是相對偏移量的計算基準。 【變數地址=基地址+相對偏移量】
    (1)在真實模式下,通常都是以段+偏移來定位地址,因此說,這時,段地址是基地址的一種
    (2)但是在堆疊上,常常不以ss暫存器來作為定址基準,而是經常用bp暫存器來定址,因此,此時堆疊段的段址就不能說是基地址 。
    (3)而保護模式下,不再有“段”的概念,這時的段暫存器裡儲存的是“段選擇子”,和基地址根本就是兩回事。

  2. 偏移地址是什麼?
    偏移地址就是計算機裡的記憶體分段後,在段內某一地址相對於段首地址(段地址)的偏移量. 如8086儲存系統中 20位的實體地址(就是資料儲存的實際地址)=16位的段基地址*16+16位的偏移量。
    為什麼有偏移地址?
    由於8086/8088CPU內部的ALU只能進行16位的運算,而8086/8088有20條地址線,直接定址能力1MB。因此,8086/8088所使用的20位實體地址,是由相應的段地址加上偏移地址組成的。【由於記憶體往往大於處理器的運算速率,例如目前常見的16G記憶體+2.4G/s主頻的CPU。】
    原理:
    8086/8088有20條地址線,它的直接定址能力為1MB。也就是在一個系統中可以有多達1MB的儲存器,地址從00000H—FFFFFH。給定任意一個20位實體地址,就可以從中取出需要的指令和運算元。但是8086/8088CPU只能進行16位運算。與地址有關的暫存器SP、IP、BP、SI、DI也都是16位的,所以對地址的運算也只能是16位的。對於8086/8088來說,無論採用哪種定址方式,尋找運算元的範圍最大是2^16,也就是64K。如何才能形成20位的實體地址呢。系統先將1MB儲存器以64KB為範圍分成若干段。在定址一個具體實體地址時,由一個基本地址再加上由SP或IP等可由CPU處理的16位偏移量來形成20位實體地址。
    當系統需要產生一個20位地址的時候,一個段暫存器會自動被選擇。且自動左移4位再與一個16位地址偏移量相加產生所需的20位地址 [1] 。
    例如:資料段DS暫存器的值=0088H
    偏移地址=22H
    那麼生成的20位實體地址等於 00880H+22H=008A2H

3. 那快取(cache)又是什麼東西?
 快取記憶體(Cache)主要是為了解決CPU運算速度與記憶體讀寫速度不匹配的矛盾而存在, 是CPU與記憶體之間的臨時存貯器,容量小,但是交換速度比記憶體快。
 CPU要讀取一個數據時,首先從Cache中查詢,如果找到就立即讀取並送給CPU處理;如果沒有找到,就用相對慢的速度從記憶體中讀取並送給CPU處理,同時把這個資料所在的資料塊調入Cache中,可以使得以後對整塊資料的讀取都從Cache中進行,不必再呼叫記憶體。
  正是這樣的讀取機制使CPU讀取Cache的命中率非常高(大多數CPU可達90%左右),也就是說CPU下一次要讀取的資料90%都在Cache中,只有大約10%需要從記憶體讀取。這大大節省了CPU直接讀取記憶體的時間,也使CPU讀取資料時基本無需等待。總的來說,CPU讀取資料的順序是先Cache後記憶體。

cache,中譯名高速緩衝儲存器,其作用是為了更好的利用區域性性原理,減少CPU訪問主存的次數。簡單地說,CPU正在訪問的指令和資料,其可能會被以後多次訪問到,或者是該指令和資料附近的記憶體區域,也可能會被多次訪問。因此,第一次訪問這一塊區域時,將其複製到cache中,以後訪問該區域的指令或者資料時,就從cache中獲取,就不用再從主存中取出。詳情請查閱教程[5]。

[參考教程]
1.不用第三方變數如何交換兩個數的值
2.C程式中交換兩個變數數值,不使用第三方變數(四種方式)
3.Python運算子——菜鳥教程
4.關於不使用第三個變數交換2個變數的值
5.cache機制
6. 圖解資料讀寫與Cache操作