最大公约数是在很小的时候就学过的东西,似乎日常生活的应用也不广泛,大多数只应用在材料使用率最大化等问题上,但是算法却不见得高效。
那么求最大公约数有哪些算法呢?
Q:要求方法传两个正整型参数,返回值就是他们的最大公约数,尽可能保证性能
暴力枚举法
public static int getGreatestCommonDivisor(int numberA, int numberB) {
int smallNumber = numberA < numberB ? numberA : numberB;
int bigNumber = numberA >= numberB ? numberA : numberB;
if (bigNumber % smallNumber == 0) {
return smallNumber;
}
int greatestCommonDivisor = 1;
for (int i = 2; i <= smallNumber / 2; i++) {
if (numberA % i == 0 && numberB % i == 0) {
greatestCommonDivisor = i;
}
}
return greatestCommonDivisor;
}
思路十分简单,使用暴力枚举的方法,试图寻找到一个合适的整数 i
,看看这个整数能否被两个整型参数 numberA
和 numberB
同时整除。
这个整数 i
从 2
开始循环累加,一直累加到 numberA
和 numberB
中较小的参数的一半为止。循环结束后,上一次寻找到的能够被两数整除的最大 i
值,就是两数的最大公约数。
这样虽然可以得到正确结果,但效率却比较低。比如传入两个参数 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 numberA, int numberB) {
int result = 1;
if (numberA > numberB) {
result = gcd(numberA, numberB);
} else {
result = gcd(numberB, numberA);
}
return result;
}
// 递归计算最大公约数
private static int gcd(int a, int b) {
if (a % b == 0) {
return b;
} else {
return gcd(b, a % b);
}
}
不过一个新问题产生了,当两个整型数较大时,做 a%b
取模运算的性能会比较低。
更相减损术
更相减损术,出自中国古代的《九章算术》,也是一种求最大公约数的算法。
原理更加简单:两个正整数 a
和 b
(a>b
),它们的最大公约数等于 a-b
的差值 c
和较小数 b
的最大公约数。
比如:10
和 25
,25
减去 10
的差是 15
,那么 10
和 25
的最大公约数,等同于 10
和 15
的最大公约数。
由此,我们同样可以通过递归来简化问题。首先,我们先计算出 a
和 b
的差值 c
(假设 a>b
),把问题转化成求出 b
和 c
的最大公约数;然后计算出 c
和 b
的差值 d
(假设 c>d
),把问题转化成求出 b
和 d
的最大公约数;再然后计算出 b
和 d
的差值 e
(假设 b>d
),把问题转化成求出 d
和 e
的最大公约数……
以此类推,逐渐把两个较大整数之间的运算简化成两个较小整数之间的运算,直到两数可以相等为止,最大公约数就是最终相等的两个数。
public static int gcd(int numberA, int numberB) {
if (numberA == numberB) {
return numberA;
} else if (numberA < numberB) {
return gcd(numberB - numberA, numberA);
} else {
return gcd(numberA - numberB, numberB);
}
}
更相减损术避免了大整数取模的性能问题,已经越来越接近最优了,但是,依靠两数求差的方式来递归,运算的次数肯定远大于辗转相除法的取模方式。因此,更相减损术是不稳定的算法,当两数相差悬殊时,比如计算 10000
和 1
,就要递归 9999 次。
更相减损术与移位结合
有什么方法,可以既避免大整数取模,又能尽可能减少运算次数呢?
最优方法——把辗转相除法和更相减损术的优势结合起来,在更相减损术的基础上使用移位运算。
众所周知,移位运算的性能非常快。对于给定的正整数a和b,不难得到如下的结论(其中 gcb(a, b)
的意思是 a
和 b
的最大公约数函数):
当 a
和 b
均为偶数,gcb(a, b) = 2 * gcb(a/2, b/2) = 2 * gcb(a>>1, b>>1);
当 a
为偶数,b
为奇数,gcb(a, b) = gcb(a/2, b) = gcb(a>>1, b);
当 a
为奇数,b
为偶数,gcb(a, b) = gcb(a, b/2) = gcb(a, b>>1);
当 a
和 b
均为奇数,利用更相减损术运算一次,gcb(a, b) = gcb(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 numberA, int numberB) {
if (numberA == numberB) {
return numberA;
} else if (numberA < numberB) {
return gcd(numberB, numberA); // 保证参数 numberA 永远大于等于参数 numberB,减少代码量
} else {
// 和 1 做按位与运算,判断奇偶
if (!numberA&1 && !numberB&1) {
return gcd(numberA >> 1, numberB >> 1) << 1;
} else if (!numberA&1 && numberB&1) {
return gcd(numberA >> 1, numberB);
} else if (numberA&1 && !numberB&1) {
return gcd(numberA, numberB >> 1);
} else {
return gcd(numberA, numberA - numberB);
}
}
}
总结上述所有算法的时间复杂度
- 暴力枚举法: 时间复杂度是
O(min(a, b)))
。 - 辗转相除法: 时间复杂度不太好计算,可以近似为
O(log(max(a, b)))
,但是取模运算性能较差。 - 更相减损术: 避免了取模运算,但是算法性能不稳定,最坏时间复杂度为
O(max(a, b)))
。 - 更相减损术与移位结合: 不但避免了取模运算,而且算法性能稳定,时间复杂度为
O(log(max(a, b)))
。
写在后面
- 方法的参数默认必定是正整数,所以在代码中省去了合法性检查。
- 文中描述的更相减损术是简化了的方式。在九章算术原文中多了一步验证:如果两数都是偶数,计算差值之前会首先让两个数都折半,使得计算次数更少。这种方法做到了部分优化,但古人似乎没想到一奇一偶的情况也是可以优化的。