数学与数字相关算法

常用算法与数据结构模板系列(六)

Posted by John Mactavish on September 13, 2020

同余定理

同余定理:两个整数同时除以一个整数得到的余数相同,则二整数同余。记作a ≡ b(mod m)。

  1. 对于同一个除数,两个数之和(或差)与它们的余数之和(或差)同余。
  2. 对于同一个除数,两个数的乘积与它们余数的乘积同余。
  3. 对于同一个除数,如果有两个整数同余,那么它们的差就一 定能被这个除数整除。
  4. 对于同一个除数,如果两个数同余,那么他们的乘方仍然同余。

同余定理的加法乘法应用:

(a + b) % m = (a % m + b % m) % m

(a - b) % m = (a % m - b % m + m) % m

(a * b) % m = ((a % m) * (b % m)) % m

(a ^ b) % m = ((a % m) ^ b) % m

证明式一:

设 a = k1 * m + r1,b = k2 * m + r2
则 (a + b) % m = ((k1 * m + r1) + (k2 * m + r2)) % m
               = ((k1 + k2) * m + (r1 + r2)) % m
               = (r1 + r2) % m
               = (a % m + b % m) % m

证明式二:

设 a = k1 * m + r1,b = k2 * m + r2
则 (a * b) % m = ((k1 * m + r1) * (k2 * m + r2)) % m
             = (k1 * k2 * m^2 + (k1 * r2 + k2 * r1) * m + r1 * r2) % m
             = (r1 * r2) % m
             = ((a % m) * (b % m)) % m

高精度求模法和快速幂取模都是利用了同余定理。

// 高精度求模法
int mod(string a, int b) //高精度 a 除以单精度 b
{
    int d = 0;
    for (int i = 0; i < a.size(); i++)
        d = (d * 10 + (a[i] - '0')) % b; //求出余数
    return d;
}

快速幂算法是利用分治法在 O(logN) 的时间复杂度内求出 整数 X 的 N 次方 的值的算法。 算法原理为:

x^2n = (x*x)^n

x^(2n+1) = x * x^2n = x * (x*x)^n

2n 次乘法变成 n+1 次乘法(x*x 的结果可以保存下来,不用反复计算),而 2n+1 次乘法变成 n+2 次乘法(奇数次多出来的一个乘法单独计算)。

迭代实现如下:

int qpow(int x, int n) {
    int res = 1;
    while (n) { // 进行快速幂运算,n 为当前的指数值,n 为 0 的时候运算结束
        if (n & 1) { // 用位运算的方式判断 n 是否为奇数,速度更快,等价于 n % 2
            res *= x; // 如果 n 是奇数,那么需要将 x 存入运算结果中
        }  
        x *= x; 
        n >>= 1; // 用位运算的方式进行 n/2,速度更快,等价于 n /= 2
    }
    return res;
}

数据太大时,64 位甚至 128 位的整型都无法表示,因此在这个基础上加上取余运算。

int qpow(int x, int n) {
    int res = 1;
    while (n) { 
        if (n & 1) { 
            res = res * x % m; // 有乘法的地方取余
        }  
        x = x * x % m; // 有乘法的地方取余
        n >>= 1; 
    }
    return res;
}

递归实现很简单(最推荐):

int qpow(int x, int n, int m) {
    if (n == 0) {
        return 1;
    }

    return qpow(x * x % m, n >>> 1) * (n % 2 == 0 ? 1 : x) % m;
}

x 的平方根

前面讲了快速幂,现在讲讲开平方根。 但是结果只保留整数的部分,小数部分将被舍去。 由于 x 平方根的整数部分是满足 k^2 <= x 的最大 k 值, 因此我们可以对 k 进行二分查找,从而得到答案。 二分查找的下界为 0,上界可以粗略地设定为 x

def sqrt(x: int) -> int:
    l, r, ans = 0, x, -1
    while l <= r:
        mid = (l + r) // 2
        if mid * mid <= x:
            ans = mid
            l = mid + 1
        else:
            r = mid - 1
    return ans

数位操作

// 把给定的数字分成指定位数的数组
// INPUT: 14458, num[5]
// OUTPUT: num[5]{8,5,4,4,1}
public static void numToArray(int number, int[] num) {
    for (int i = 0; i < num.length; i++) {
        num[i] = number % 10;
        number /= 10;
    }
}

// 把给定的数组变成数字
// INPUT: num[5]{8,5,4,4,1}
// OUTPUT: 14458, num[5]
public static int arrayToNum(int[] num) {
    int n;

    for (int i = 0; i < num.length; i++) {
        n = n * 10 + num[i];
    }

    return n;
}

注意在上面的操作中如果把 10 换成其他基数,即可实现数字与其他进制各位数数组间转换:

def num_to_array(num: int, base: int) -> List[int]:
    ans = []
    while num != 0:
        ans.append(num % base)
        num //= base
    return ans[::-1] if ans else [0]

其他进制各位数数组转十进制数字同理。

看下面这个实例 5681. 判断一个数字是否可以表示成三的幂的和

给你一个整数 n ,如果你可以将 n 表示成若干个不同的三的幂之和,请你返回 true ,否则请返回 false 。

对于一个整数 y ,如果存在整数 x 满足 y == 3x ,我们称这个整数 y 是三的幂。

示例 1:
输入:n = 12
输出:true
解释:12 = 31 + 32

示例 2:
输入:n = 91
输出:true
解释:91 = 30 + 32 + 34

示例 3:
输入:n = 21
输出:false
 
提示:
1 <= n <= 107

要判断 n 能否表示成若干个不同的三的幂之和,不妨把其用三进制数表示。不难发现:

12 -> 0 * 3^0 + 1 * 3^1 + 1 * 3^2 -> 110(base 3) -> true
91 -> 1 * 3^0 + 0 * 3^1 + 1 * 3^2 + 0 * 3^3 + 1 * 3^4 -> 10101(base 3) -> true
21 -> 0 * 3^0 + 1 * 3^1 + 2 * 3^2 -> 210(base 3) -> false

如果某一位出现 2 说明重复使用了某个三的幂,则不满足要求。

function checkPowersOfThree(n: number): boolean {
  while(n) {
    if (n % 3 === 2) return false;
    n = Math.floor(n / 3);
  }
  return true;
};

最小公约数与最小公倍数

// 求a,b的最小公约数,利用辗转相除法,其实是利用定理 gcd(a, b) == gcd(b, a % b)
// 由此可以递归,边界是 gcd(a, 0) == a
public static int gcd(int a, int b) {
  if (b == 0) return a;
  return gcd(b, a % b);
}

// 求a,b的最小公倍数,利用上面求到的最小公约数 d,
// lcm = (a * b) / d,为了防止 a*b 溢出,写成 (a / d) * d
public static int lcm(int a, int b) {
    return a / gcd(a, b) * b;
}

分数

// 分数,保存分子分母,方便起见,使用假分数
class Fraction {
    int up, down;

    // 化简,使其满足三个条件:
    // 1. 分母始终为正数
    // 2. 如果分子是0,则分母是1
    // 3. 分子分母最小公约数是1
    public void reduction() {
        if (down < 0) {
            down = -down;
            up = -up;
        }

        if (up == 0) {
            down = 1;
        } else {
            // 记得取绝对值
            int d = gcd(Math.abs(up), Math.abs(down));
            up /= d;
            down /= d;
        }
    }

    // 分数的除法,加减乘同理,返回自己,方便链式调用
    public Fraction dividedBy(Fraction f) {
        up *= f.down;
        down *= f.up;
        // 结果记得化简
        reduction();

        return this;
    }

    // 输出分三种情况:
    // 1. 如果分母为1,则是整数,只输出分子 
    // 2. 如果分子绝对值大于分母,则是假分数,可以选择按带分数形式输出
    // 3. 其他情况,是真分数直接输出
    public void print() {
        reduction();  // 记得先化简

        if (down == 1) {
            System.out.print(up);
        } else if (Math.abs(up) > down) {
            // 注意比较和输出分子时都要取绝对值
            System.out.printf("%d %d/%d", up / down, Math.abs(up) % down, down);
        } else {
            System.out.printf("%d/%d", up, down);
        }
    }
}

素数

// 判断一个数是不是素数,注意1既不是素数又不是合数,因子只需要判断到这个数的开方
public static boolean isPrime(int n) {
    if (n <= 1) {
        return false;
    }

    int sqr = (int) Math.sqrt(n * 1.0);// 变量外提,强制类型转换,以优化循环速度
    for (int i = 2; i <= sqr; i++) { // 从2开始判断,注意i<=sqr含有等号
        if (n % i == 0) {
            return false;
        }
    }

    // 实践中,如果n比较小(10的9次方之内),可以简单的这样写
    // for(int i = 2; i * i <=n; i++){
    //     if(n % i == 0)
    //         return false;
    // }

    return true;
}

// 获取一张直到给定数的素数表,从小数字开始,每发现一个素数,就向后筛去这个素数的整倍数
public static ArrayList<Integer> getPrimeList(int max) {
    // 带标记表的方法
    boolean[] mark = new boolean[max + 1]; // 偏移1个单位,使索引与数字对应
    ArrayList<Integer> primes = new ArrayList<>();

    for (int i = 2; i <= max; i++) { // 从2开始
        if (!mark[i]) { // 注意,标记为false即是素数,否则不做任何处理
            primes.add(i);

            for (int j = i + 1; j <= max; j += i) { // 从i+1开始向后筛去整倍数,步进是i
                mark[j] = true;
            }
        }
    }

    return primes;
}

均值不等式

剑指 Offer 14- I. 剪绳子

给你一根长度为 n 的绳子,请把绳子剪成整数长度的 m 段(m、n都是整数,n>1并且m>1),每段绳子的长度记为 k[0],k[1]...k[m-1] 。请问 k[0]*k[1]*...*k[m-1] 可能的最大乘积是多少?例如,当绳子的长度是8时,我们把它剪成长度分别为2、3、3的三段,此时得到的最大乘积是18。

示例 1:
输入: 2
输出: 1
解释: 2 = 1 + 1, 1 × 1 = 1

示例 2:
输入: 10
输出: 36
解释: 10 = 3 + 3 + 4, 3 × 3 × 4 = 36

提示:
2 <= n <= 58

根据均值不等式可得推论一:将绳子以相等的长度等分为多段,得到的乘积最大。 通过实际求极值可得推论二:尽可能将绳子以长度 3 等分为多段时,乘积最大。 推导过程见 jyd 的 LeetCode 解答。 最后可得切分规则:

把绳子尽可能切为多个长度为 3 的片段,留下的最后一段绳子的长度可能为 0, 1, 2 三种情况:

若最后一段绳子长度为 0,即绳子总长度为 3 的整倍数,最优;

若最后一段绳子长度为 1,则应把一份 3 + 1 替换为 2 + 2,因为 2 × 2 > 3 × 1;

若最后一段绳子长度为 2,则保留,不再拆为 1 + 1。

实现为:

import math

class Solution:
    def cuttingRope(self, n: int) -> int:
        if n <= 3:
            return n - 1
        left = n % 3
        if left == 1:
            return int(math.pow(3, n // 3 - 1)) * 4
        else:
            return int(math.pow(3, n // 3)) * (1 if left == 0 else 2)

参考:

chengziqian 的 LeetCode 解答

“大专栏”博客

最后附上 GitHub:https://github.com/gonearewe