暴力枚舉法
暴力枚舉的方法從較小整數的一半開始,試圖找到一個合適的整數 i,看看這個整數能否被 a 和 b 同時整除。
public static int getGreatestCommonDivisor(int a, int b) {
int big = a > b ? a : b;
int small = a < b ? a : b;
if (big % small == 0) {
return small;
}
for (int i = small / 2; i > 1; i--) {
if (small % i == 0 && big % i == 0) {
return i;
}
}
return 1;
}
效率不高。如果傳入的整數是 10000 和 10001,就需要循環 10000 / 2 - 1 = 4999 次。
輾轉相除法
又名歐幾里得算法(Euclidean algorithm),該算法的目的是求出兩個正整數的最大公約數。它是已知最古老的算法, 其產生時間可追溯至公元前 300 年前。
這條算法基於一個定理:兩個正整數 a 和 b(a > b),它們的最大公約數等於 a 除以 b 的餘數 c 和 b 之間的最大公約數。
例如 10 和 25,25 除以 10 商 2 餘 5,那麼 10 和 25 的最大公約數,等同於 10 和 5 的最大公約數。
有了這條定理,求最大公約數就變得簡單了。可以使用遞歸的方法把問題逐步簡化。
首先,計算出a除以b的餘數c,把問題轉化成求b和c的最大公約數;然後計算出b除以c的餘數d,把問題轉化成求c和d的最大公約數;再計算出c除以d的餘數e,把問題轉化成求d和e的最大公約數……
以此類推,逐漸把兩個較大整數之間的運算簡化成兩個較小整數之間的運算,直到兩個數可以整除,或者其中一個數減小到1爲止。
public static int getGreatestCommonDivisor(int a, int b) {
int big = a > b ? a : b;
int small = a < b ? a : b;
if (big % small == 0) {
return small;
}
return getGreatestCommonDivisor(big % small, small);
}
該算法的缺點是當兩個整數較大時,做 a % b 取模運算的性能會比較差。
更相減損術
出自中國古代的《九章算術》,也是一種求最大公約數的算法。
它的原理更加簡單:兩個正整數 a 和b(a > b),它們的最大公約數等於 a - b 的差值 c 和較小數 b 的最大公約數。
例如 10 和 25,25 減 10 的差是 15,那麼 10 和 25 的最大公約數,等同於 15 和 10 的最大公約數。
public static int getGreatestCommonDivisor(int a, int b) {
if (a == b) {
return a;
}
int big = a > b ? a : b;
int small = a < b ? a : b;
return getGreatestCommonDivisor(big - small, small);
}
該算法避免了大整數取模可能出現的性能問題。
但是更相減損術依靠兩數求差的方式來遞歸,運算次數遠大於輾轉相除法的取模方式。
更相減損術是不穩定的算法,當兩數相差懸殊時,如計算 10000 和 1 的最大公約數,就要遞歸 9999 次。
最優算法
該算法既能避免大整數取模,又能儘可能地減少運算次數。
思想是把輾轉相除法和更相減損術的優點結合起來,在更相減損術的基礎上使用移位運算。
衆所周知,移位運算的性能非常好。對於給出的正整數 a 和 b,不難得到如下的結論:
-
當 a 和 b 均爲偶數時,gcd(a,b) = gcd(a/2, b/2) * 2 = gcd(a>>1, b>>1) * 2
-
當 a 爲偶數,b 爲奇數時,gcd(a,b) = gcd(a/2,b) = gcd(a>>1, b)
-
當 a 爲奇數,b 爲偶數時,gcd(a,b) = gcd(a,b/2) = gcd(a, b>>1)
-
當 a 和 b 均爲奇數時,先利用更相減損術運算一次,gcd(a,b) = gcd(b,a-b),此時 a-b 必然是偶數,然後又可以繼續進行移位運算。
例如計算10 和 25 的最大公約數的步驟如下。
- 整數 10 通過移位,可以轉換成求 5 和 25 的最大公約數。
- 利用更相減損術,計算出25-5=20,轉換成求5和20的最大公約數。
- 整數20通過移位,可以轉換成求5和10的最大公約數。
- 整數10通過移位,可以轉換成求5和5的最大公約數。
- 利用更相減損術,因爲兩數相等,所以最大公約數是5。
該算法在兩數都比較小時,可能看不出計算次數的優勢;當兩數越大時,計算次數的減少就會越明顯。
public static int gcd(int a, int b) {
if (a == b) {
return a;
}
if ((a & 1) == 0 && (b & 1) == 0) {
return gcd(a >> 1, b >> 1) << 1;
} else if ((a & 1) == 0 && (b & 1) != 0) {
return gcd(a >> 1, b);
} else if ((a & 1) != 0 && (b & 1) == 0) {
return gcd(a, b >> 1);
} else {
int big = a > b ? a : b;
int small = a < b ? a : b;
return gcd(big - small, small);
}
}
在上述代碼中,判斷整數奇偶性的方式是讓整數和 1 進行與運算,如果 (a&1) == 0,則說明整數 a 是偶數;如果 (a&1) != 0,則說明整數 a 是奇數。
各算法的時間複雜度
暴力枚舉法:時間複雜度是 O(min(a, b))
輾轉相除法:時間複雜度不太好計算,可以近似爲 O(log(max(a, b))),但是取模運算性能較差。
更相減損術:避免了取模運算,但是算法性能不穩定,最壞時間複雜度爲 O(max(a,b))。
更相減損術與移位相結合:不但避免了取模運算,而且算法性能穩定,時間複雜度爲 O(log(max(a, b)))。