题目原型
神奇的数字:如果正整数X可以被A或B整除,那么它是神奇的。
求:返回第N个神奇数字。由于答案可能非常大,返回它模10^9 + 7 的结果。
示例:
输入N=1, A =2, B =3; 输出:2
输入N=4, A =2, B =3; 输出:6
输入N=5, A =2, B =4; 输出:10
输入N=3, A =6, B =4; 输出:8
提示:
1 <= N <= 10^9
2 <= A <= 40000
2 <= B <= 40000
求解思路
求解此题时,我们可以直接使用暴力破解,但是对于大数据类型的就比较头疼了。我们这里求解的思想是基于二分法的,在讲解方法之前,我们先列几条信息:
- 对于一个数A,和C,小于C并且可以整除A的数的个数N_a = C/A(这里默认使用了整型强转,也就是向下取整)
- 整数A和B,判断小于数字C并且同时可以整除A和B的数的个数N_ab = C/最小公倍数(A, B)
其实根据上面两点我们就可以求解题目。但是为了更为直观,再补充一点: - 小于数字C,并且可以整除A或B的数字的个数N_a|b = C/A + C/B - C/最小公倍数(A, B) = N_a + N_b - N_ab
根据上面三点我们可以确定数字C是否是那个神奇的数字,但是如何找到这个C呢,我们可以根据二分法寻找。题目中给出了N和A、B的范围,我们可以在int64的范围内寻找。也就是left = 0,right= 1<<63 - 1(int64最大数值)。通过计算中位数mid = (left + right)/2,并计算出小于中位数mid,并且可以整除A或B有多少个数字:N_mid = mid/A + mid/B - mid/最小公倍数(A, B)。如果N_mid < N,则表明那个神奇的数字,在mid的右侧,将left = mid +1 重新进行循环;如果N_mid >= N,则表明神奇数字在mid的左侧,可以将right= mid,进行计算。计算直到 left > right 结束,最后输出的right就是最中结果。
代码
/*
二分法
1.如果一个数对于一个数C,判断小于C,并且可以整除A、B的数的个数,可以由C/A, C/B得到。
2.对于一个数C,判断小于C的所有数,同时可以整除A、B的数的个数可以通过计算C/最小公倍数(A,B)来得到。
3.根据上面两点,我们就可以计算出小于C下,同时可以整除A和B的数的个数:
C/A + C/B - C/(最小公倍数(A,B))
这样计算,是因为C/A 和C/B中同时存在可以整除A和B的数,所以要减去一次
*/
func NthMagicalNumber(N int, A int, B int) int {
left := 0
right := math.MaxInt64
//最小公倍数
minMul := A * B / MaxDiv(A, B)
for left < right {
mid := (left + right) / 2
temp := mid/A + mid/B - mid/minMul
if temp < N {
left = mid + 1
} else {
right = mid
}
}
return right % 1000000007
}
//求最大公约数
func MaxDiv(a, b int) int {
if a < b {
tmp := a
a = b
b = tmp
}
for a%b != 0 {
t := b
b = a % b
a = t
}
return b
}
改进
二分法固然比暴力破解法好多了,但是二分法的范围是一个问题,因为这里的范围是0-1<<63 - 1(int64最大数值)。这范围内使用二分法也是比较消耗时间的。所以根据范围,我们可以做出改进。
我们都知道两个数字A、B的最小公倍数minMul可以表示成A和B的一个周期。这个周期指的是长度为minMul- Min(A,B),并且每个周期之间相差Min(A, B) -1,并且每个周期可以整除A或B的数字的数量是一样的。
我们可以利用这个周期,来将二分法的范围缩小。同样我们需要计算最小公倍数minMul,以及将A、B按着从小到大重新进行赋值(之后就默认A是小的那个数字)。为了计算那个神奇的数字在第几个周期内,我们需要得到每个周期内有多少个神奇数字:cycleDivNum = minMul/A + minMul/B -1。知道了每个周期内有多少个这样的神奇数字,就可以计算出第N个神奇数字在第几个周期:cycleN = N/cycleDivNum + (if N%cycleDivNum !=0 { 1 }else { 0 }(如果N%cycleDivNum取余有值,则加1)。这样我们就可以确定二分法left和right的范围了,就是这个周期的起末:left = (cycleN -1) * (minMul - A +1) + (cycleN -1)*( A -1 ) + A,
right = left + minMul - A。剩下的就是二分法了。不多说,上代码
代码
//利用周期将二分法的范围缩小
func TwoDivPro(N, A, B int) int {
//最小公倍数
minMul := A * B / MaxDiv(A, B)
if A > B {
t := A
A = B
B = t
}
//fmt.Println(minMul)
//一个最小公倍数周期内,可以整除a或b的个数
cycleDivNum := minMul/A + minMul/B - 1
//确定 数字在第几个周期
cycleN := N / cycleDivNum
if N%cycleDivNum != 0 {
cycleN++
}
//确定了周期,则可以找出周期首尾
left := (cycleN-1)*(minMul-A+1) + (cycleN-1)*( A-1) + A
right := left + minMul - A
//然后可以使用二分法进行查找
for left < right {
mid := (left + right) / 2
temp := mid/A + mid/B - mid/minMul
if temp < N {
left = mid + 1
} else {
right = mid
}
}
//fmt.Println(left, " ", right)
return right
}
//求最大公约数
func MaxDiv(a, b int) int {
if a < b {
tmp := a
a = b
b = tmp
}
for a%b != 0 {
t := b
b = a % b
a = t
}
return b
}
性能测试
为了测试改进前和改进后的算法,我们可以执行这个测试用例:
N = 10000, A = 23423, B = 34324
改进前:
改进后:
我们可以看到,相同时间内,改进后可以执行双倍的操作。也就是每次改进后执行的时间比改进前的缩短了一倍。
由于测试用例较少,提交到力扣(leetCode)上执行结果如下:
其中最后一个是改进前的,第一个是改进后的。都是0ms,是因为力扣只能精确到毫秒。
总结
算法还算成功。哈哈哈哈。。。。。。。。。