azarasi / LeetCode 29. Divide Two Integers

Created Tue, 21 Jun 2022 15:53:24 +0800 Modified Wed, 18 Sep 2024 14:00:22 +0000

Given two integers dividend and divisor, divide two integers without using multiplication, division, and mod operator.

The integer division should truncate toward zero, which means losing its fractional part. For example, 8.345 would be truncated to 8, and -2.7335 would be truncated to -2.

Return the quotient after dividing dividend by divisor.

Note: Assume we are dealing with an environment that could only store integers within the 32-bit signed integer range: \([−2^{31}, 2^{31} − 1]\). For this problem, if the quotient is strictly greater than \(2^{31} - 1\), then return \(2^{31} - 1\), and if the quotient is strictly less than \(-2^{31}\), then return \(-2^{31}\).

Example 1:

Input: dividend = 10, divisor = 3
Output: 3
Explanation: 10/3 = 3.33333.. which is truncated to 3.

Example 2:

Input: dividend = 7, divisor = -3
Output: -2
Explanation: 7/-3 = -2.33333.. which is truncated to -2.

Constraints:

  • \(-2^{31} <= dividend, divisor <= 2^{31} - 1\)
  • divisor != 0

题解

前言

由于题目规定了「只能存储 \(32\) 位整数」,本题解的正文部分和代码中都不会使用任何 \(64\) 位整数。诚然,使用 \(64\) 位整数可以极大地方便我们的编码,但这是违反题目规则的。

如果除法结果溢出,那么我们需要返回 \(2^{31} - 1\) 作为答案。因此在编码之前,我们可以首先对于溢出或者容易出错的边界情况进行讨论

  • 当被除数为 \(32\) 位有符号整数的最小值 \(-2^{31}\) 时

    • 如果除数为 \(1\),那么我们可以直接返回答案 \(-2^{31}\)

    • 如果除数为 \(-1\),那么答案为 \(2^{31}\),产生了溢出。此时我们需要返回 \(2^{31} - 1\)。

  • 当除数为 \(32\) 位有符号整数的最小值 \(-2^{31}\) 时:

    • 如果被除数同样为 \(-2^{31}\),那么我们可以直接返回答案 \(1\);

    • 对于其余的情况,我们返回答案 \(0\)。

  • 当被除数为 \(0\) 时,我们可以直接返回答案 \(0\)。

对于一般的情况,根据除数和被除数的符号,我们需要考虑 \(4\) 种不同的可能性。因此,为了方便编码,我们可以将被除数或者除数取相反数,使得它们符号相同。

如果我们将被除数和除数都变为正数,那么可能会导致溢出。例如当被除数为 \(-2^{31}\) 时,它的相反数 \(2^{31}\) 产生了溢出。因此,我们可以考虑将被除数和除数都变为负数,这样就不会有溢出的问题,在编码时只需要考虑 \(1\) 种情况了。

如果我们将被除数和除数的其中(恰好)一个变为了正数,那么在返回答案之前,我们需要对答案也取相反数。

方法一:二分查找

思路与算法

根据「前言」部分的讨论,我们记被除数为 \(X\),除数为 \(Y\),并且 \(X\) 和 \(Y\) 都是负数。我们需要找出 \(X/Y\) 的结果 \(Z\)。\)Z\) 一定是正数或 \(0\)。

根据除法以及余数的定义,我们可以将其改成乘法的等价形式,即:$$Z \times Y \geq X > (Z+1) \times Y$$因此,我们可以使用二分查找的方法得到 \(Z\),即找出最大的 \(Z\) 使得 \(Z \times Y \geq X\) 成立。

由于我们不能使用乘法运算符,因此我们需要使用「快速乘」算法得到 \(Z \times Y\) 的值。「快速乘」算法与「快速幂」类似,前者通过加法实现乘法,后者通过乘法实现幂运算。「快速幂」算法可以参考「50. Pow(x, n)」的官方题解,「快速乘」算法只需要在「快速幂」算法的基础上,将乘法运算改成加法运算即可。

细节

由于我们只能使用 \(32\) 位整数,因此二分查找中会有很多细节。

首先,二分查找的下界为 \(1\),上界为 \(2^{31} - 1\)。唯一可能出现的答案为 \(2^{31}\) 的情况已经被我们在「前言」部分进行了特殊处理,因此答案的最大值为 \(2^{31} - 1\)。如果二分查找失败,那么答案一定为 \(0\)。

在实现「快速乘」时,我们需要使用加法运算,然而较大的 \(Z\) 也会导致加法运算溢出。例如我们要判断 \(A + B\) 是否小于 \(C\) 时(其中 \(A, B, C\) 均为负数),\(A + B\) 可能会产生溢出,因此我们必须将判断改为 \(A < C - B\) 是否成立。由于任意两个负数的差一定在 \([-2^{31} + 1, 2^{31} - 1]\) 范围内,这样就不会产生溢出。

读者可以阅读下面的代码和注释,理解如何避免使用乘法和除法,以及正确处理溢出问题。

代码

class Solution {
public:
    int divide(int dividend, int divisor) {
        // 考虑被除数为最小值的情况
        if (dividend == INT_MIN) {
            if (divisor == 1) {
                return INT_MIN;
            }
            if (divisor == -1) {
                return INT_MAX;
            }
        }
        // 考虑除数为最小值的情况
        if (divisor == INT_MIN) {
            return dividend == INT_MIN ? 1 : 0;
        }
        // 考虑被除数为 0 的情况
        if (dividend == 0) {
            return 0;
        }

        // 一般情况,使用二分查找
        // 将所有的正数取相反数,这样就只需要考虑一种情况
        bool rev = false;
        if (dividend > 0) {
            dividend = -dividend;
            rev = !rev;
        }
        if (divisor > 0) {
            divisor = -divisor;
            rev = !rev;
        }

        // 快速乘
        auto quickAdd = [](int y, int z, int x) {
            // x 和 y 是负数,z 是正数
            // 需要判断 z * y >= x 是否成立
            int result = 0, add = y;
            while (z) {
                if (z & 1) {
                    // 需要保证 result + add >= x
                    if (result < x - add) {
                        return false;
                    }
                    result += add;
                }
                if (z != 1) {
                    // 需要保证 add + add >= x
                    if (add < x - add) {
                        return false;
                    }
                    add += add;
                }
                // 不能使用除法
                z >>= 1;
            }
            return true;
        };
        
        int left = 1, right = INT_MAX, ans = 0;
        while (left <= right) {
            // 注意溢出,并且不能使用除法
            int mid = left + ((right - left) >> 1);
            bool check = quickAdd(divisor, mid, dividend);
            if (check) {
                ans = mid;
                // 注意溢出
                if (mid == INT_MAX) {
                    break;
                }
                left = mid + 1;
            }
            else {
                right = mid - 1;
            }
        }

        return rev ? -ans : ans;
    }
};

复杂度分析

  • 时间复杂度:\(O(\log^2 C)\),其中 \(C\) 表示 \(32\) 位整数的范围。二分查找的次数为 \(O(\log C)\),其中的每一步我们都需要 \(O(\log C)\) 使用「快速乘」算法判断 \(Z \times Y \geq X\) 是否成立,因此总时间复杂度为 \(O(\log^2 C)\)。

  • 空间复杂度:\(O(1)\)。

方法二:类二分查找

前言

常规意义下的二分查找为:给定区间 \([l, r]\),取该区间的中点 \(\textit{mid} = \lfloor \dfrac{l+r}{2} \rfloor\),根据 \(\textit{mid}\) 处是否满足某一条件,来决定移动左边界 \(l\) 还是右边界 \(r\)

我们也可以考虑另一种二分查找的方法

  • 记 \(k\) 为满足 \(2^k \leq r-l < 2^{k+1}\) 的 \(k\) 值。

  • 二分查找会进行 \(k+1\) 次。在第 \(i ~ (1 \leq i \leq k+1)\) 次二分查找时,设区间为 \([l_i, r_i]\),我们取 \(\textit{mid} = l_i + 2^{k+1-i}\)

  • 如果 \(\textit{mid}\) 不在 \([l_i, r_i]\) 的范围内,那么我们直接忽略这次二分查找;

  • 如果 \(\textit{mid}\) 在 \([l_i, r_i]\) 的范围内,并且 \(\textit{mid}\) 处满足某一条件,我们就将 \(l_i\) 更新为 \(\textit{mid}\),否则同样忽略这次二分查找。

最终 \(l_i\) 即为二分查找的结果。这样做的正确性在于

设在常规意义下的二分查找的答案为 \(\textit{ans}\),记 \(\delta\) 为 \(\textit{ans}\) 与左边界的差值 \(\textit{ans} - l\)。\(\delta\) 不会大于 \(r-l\),并且 \(\delta\) 一定可以用 \(2^k, 2^{k-1}, 2^{k-2}, \cdots, 2^1, 2^0\) 中的若干个元素之和表示(考虑 \(\delta\) 的二进制表示的意义即可)。因此上述二分查找是正确的。

思路与算法

基于上述的二分查找的方法,我们可以设计出如下的算法

  • 我们首先不断地将 \(Y\) 乘以 \(2\)(通过加法运算实现),并将这些结果放入数组中,其中数组的第 \(i\) 项就等于 \(Y \times 2^i\)。这一过程直到 \(Y\) 的两倍严格小于 \(X\) 为止。

  • 我们对数组进行逆序遍历。当遍历到第 \(i\) 项时,如果其大于等于 \(X\),我们就将答案增加 \(2^i\),并且将 \(X\) 中减去这一项的值。

本质上,上述的逆序遍历就模拟了二分查找的过程。

代码

class Solution {
public:
    int divide(int dividend, int divisor) {
        // 考虑被除数为最小值的情况
        if (dividend == INT_MIN) {
            if (divisor == 1) {
                return INT_MIN;
            }
            if (divisor == -1) {
                return INT_MAX;
            }
        }
        // 考虑除数为最小值的情况
        if (divisor == INT_MIN) {
            return dividend == INT_MIN ? 1 : 0;
        }
        // 考虑被除数为 0 的情况
        if (dividend == 0) {
            return 0;
        }
        
        // 一般情况,使用类二分查找
        // 将所有的正数取相反数,这样就只需要考虑一种情况
        bool rev = false;
        if (dividend > 0) {
            dividend = -dividend;
            rev = !rev;
        }
        if (divisor > 0) {
            divisor = -divisor;
            rev = !rev;
        }

        vector<int> candidates = {divisor};
        // 注意溢出
        while (candidates.back() >= dividend - candidates.back()) {
            candidates.push_back(candidates.back() + candidates.back());
        }
        int ans = 0;
        for (int i = candidates.size() - 1; i >= 0; --i) {
            if (candidates[i] >= dividend) {
                ans += (1 << i);
                dividend -= candidates[i];
            }
        }

        return rev ? -ans : ans;
    }
};

复杂度分析

  • 时间复杂度:\(O(\log C)\),即为二分查找需要的时间。方法二时间复杂度优于方法一的原因是:方法一的每一步二分查找都需要重新计算 \(Z \times Y\) 的值,需要 \(O(\log C)\) 的时间复杂度;而方法二的每一步的权重都是 \(2\) 的幂,在二分查找开始前就都是已知的值,因此我们可以在 \(O(\log C)\) 的时间内,一次性将它们全部预处理出来。

  • 空间复杂度:\(O(\log C)\),即为需要存储的 \(Y \times 2^i\) 的数量