Loading

剑指offer一刷:数学

剑指 Offer 39. 数组中出现次数超过一半的数字

难度:简单

方法一:哈希表统计法

遍历数组 nums,用 HashMap 统计各数字的数量,即可找出众数。此方法时间和空间复杂度均为 O(N)

方法二:数组排序法

将数组 nums 排序,数组中点的元素一定为众数。

方法三:摩尔投票法

核心理念为票数正负抵消。此方法时间和空间复杂度分别为 O(N) 和 O(1),为本题的最佳解法。

详细如下:

设输入数组 nums 的众数为 x,数组长度为 n

推论一:若记众数的票数为 +1非众数的票数为 -1,则一定有所有数字的票数和 > 0

推论二:若数组的前 a 个数字的票数和 = 0,则 数组剩余 (n-a) 个数字的票数和一定仍 >0,即后 (n-a) 个数字的众数仍为 x

根据以上推论,记数组首个元素为 n1,众数为 x,遍历并统计票数。当发生票数和 = 0时,剩余数组的众数一定不变,这是由于:

  • 当 n1 = x: 抵消的所有数字,有一半是众数 x
  • 当 n1x: 抵消的所有数字,众数 x 的数量最少为 0 个最多为一半

利用此特性,每轮假设发生 票数和 = 0都可以缩小剩余数组区间。当遍历完成时,最后一轮假设的数字即为众数。

算法流程:

  1. 初始化:票数统计 votes = 0,众数 x;
  2. 循环:遍历数组 nums 中的每个数字 num;
    1. 当 票数 votes 等于 0,则假设当前数字 num 是众数;
    2. 当 num = x 时,票数 votes 自增 1;当 num != x 时,票数 votes 自减 1;
  3. 返回值:返回 x 即可;
class Solution {
    public int majorityElement(int[] nums) {
        int x = 0, votes = 0;
        for(int num : nums){
            if(votes == 0) x = num;
            votes += num == x ? 1 : -1;
        }
        return x;
    }
}

作者:Krahets
链接:https://leetcode.cn/leetbook/read/illustration-of-algorithm/99ussv/
来源:力扣(LeetCode)
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

时间复杂度:O(N),空间复杂度:O(1)。

剑指 Offer 66. 构建乘积数组

难度:中等

本题难点在于不能使用除法

我自己的做法是把乘积拆分成 B[i] 的左边和右边,构建 2 个累乘数组,对应相乘得到最终结果。

本质上和题解是一样的,题解是根据下面表格的主对角线(全为 1),将表格分为上三角下三角两部分。分别迭代计算下三角和上三角两部分的乘积。

算法流程:

  1. 初始化:数组 B,其中 B[0] = 1;辅助变量 tmp = 1;
  2. 计算 B[i] 的下三角各元素的乘积,直接乘入 B[i];
  3. 计算 B[i] 的上三角各元素的乘积,记为 tmp,并乘入 B[i];
  4. 返回 B。
class Solution {
    public int[] constructArr(int[] a) {
        int len = a.length;
        if(len == 0) return new int[0];
        int[] b = new int[len];
        b[0] = 1;
        int tmp = 1;
        for(int i = 1; i < len; i++) {
            b[i] = b[i - 1] * a[i - 1];
        }
        for(int i = len - 2; i >= 0; i--) {
            tmp *= a[i + 1];
            b[i] *= tmp;
        }
        return b;
    }
}

作者:Krahets
链接:https://leetcode.cn/leetbook/read/illustration-of-algorithm/570i11/
来源:力扣(LeetCode)
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

时间复杂度:O(N),空间复杂度:O(1)(变量 tmp 使用常数大小额外空间,数组 b 作为返回值,不计入复杂度考虑)。

剑指 Offer 14- I. 剪绳子

难度:中等

给出以下两个推论,证明见题解

推论一:将绳子以相等的长度等分为多段,得到的乘积最大。

推论二:尽可能将绳子以长度 3 等分为多段时,乘积最大。

切分规则:

  1. 最优:3。把绳子尽可能切为多个长度为 3 的片段,留下的最后一段绳子的长度可能为 0, 1, 2 三种情况。
  2. 次优:2。若最后一段绳子长度为 2;则保留,不再拆为 1 + 1。
  3. 最差:1。若最后一段绳子长度为 1;则应把一份 3 + 1 替换为 2 + 2,因为 2 × 2 > 3 × 1。

算法流程:

  1. 当 n ≤ 3 时,按照规则应不切分,但由于题目要求必须剪成 m >1 段,因此必须剪出一段长度为 1 的绳子,即返回 n - 1
  2. 当 n > 3 时,求 n 除以 3 的整数部分 a余数部分 b(即 n = 3a + b ),并分为以下三种情况:
    • 当 b = 0 时,直接返回 3a
    • 当 b = 1 时,要将一个 1 + 3 转换为 2 + 2,因此返回 3a-1 × 4
    • 当 b = 2 时,返回 3a × 2
class Solution {
    public int cuttingRope(int n) {
        if(n <= 3) return n - 1;
        int a = n / 3, b = n % 3;
        if(b == 0) return (int)Math.pow(3, a);
        if(b == 1) return (int)Math.pow(3, a - 1) * 4;
        return (int)Math.pow(3, a) * 2;
    }
}

作者:Krahets
链接:https://leetcode.cn/leetbook/read/illustration-of-algorithm/5vyva2/
来源:力扣(LeetCode)
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

时间复杂度:O(1),空间复杂度:O(1)。

贪心算法见题解

另外,官方题解中还有动态规划解法,我刚开始想的也是动态规划,但是时间复杂度比较高(O(N2))

这里给出一个我看了官方题解中优化的动态规划解法的另一种思考(不一定对,主要是官方那个太难理解了。。):

首先,

这个是普通的动态规划的方程。

这个是优化的动态规划的方程。

2 × (i - 2) 和 3 × (i - 3) 暂且不谈。为什么只分成了 2 × dp[i - 2] 和 3 × dp[i - 3] 呢?

  1. dp[i] 必定 大于 1 × dp[i - 1],无需考虑该情况
  2. 4 × dp[i - 4] == 2 × 2 × dp[i - 4],而显然 dp[i - 2] ≥ 2 × dp[i - 4],所以该情况不需要考虑
  3. 5 × dp[i - 5] < 3 × 2 × dp[i - 5],而显然 dp[i - 3] ≥ 2 × dp[i - 5],所以该情况不需要考虑
  4. ……以此类推

所以只需要考虑 2 × dp[i - 2] 和 3 × dp[i - 3]。至于 2 × (i - 2) 和 3 × (i - 3) 主要是确保 i 比较小的时候(如 dp[3] = 2 < 3)能够不出错。

此时突然发现 i ≥ 7 后 2 × dp[i - 2] 也不需要算(根据前面数学解法的结果来看,无论余数是几,只要前面至少有一个 3,都可以除掉一个 3,由于余数一样,此时必然是 dp[i-3]):dp[7] = 3 × dp[4],dp[8] = 3 × dp[5],dp[9] = 3 × dp[6],dp[10] = 3 × dp[7],……。这其实已经接近数学的解法了,可以推导出数学公式了。这个时候算法流程就成了:

  1. i < 4,return i - 1;
  2. i = 4,return i;
  3. i > 4,给 dp[2] ~ dp[4] 分别赋值 2 ~ 4,然后 dp[i] = 3 × dp[i-3]。

进一步想,也不需要从 2 开始一个一个算到 i。先除以 3,看有几个 3 的幂,再求余,看是几,最后一综合(实质上就跟数学法完全一样了(⊙o⊙)…)

进一步想

剑指 Offer 57 - II. 和为 s 的连续正数序列

难度:简单

方法一:求和公式

设连续正整数序列的左边界 i 和右边界 j,则此序列的元素和 target等于元素平均值 (i + j) / 2乘以元素数量 (j - i + 1),即:

计算出 j 的唯一解求取公式为:

通过从小到大遍历左边界 i 来计算以 i 为起始数字的连续正整数序列。每轮中,由以上公式计算得到右边界 j,当 j 为整数时符合题目的连续正整数数列要求,此时记录结果即可。

class Solution {
    public int[][] findContinuousSequence(int target) {
        int i = 1;
        double j = 2.0;
        List<int[]> res = new ArrayList<>();
        while(i < j) {
            j = (-1 + Math.sqrt(1 + 4 * (2 * target + (long) i * i - i))) / 2;
            if(j == (int)j) {
                int[] ans = new int[(int)j - i + 1];
                for(int k = i; k <= (int)j; k++)
                    ans[k - i] = k;
                res.add(ans);
            }
            i++;
        }
        return res.toArray(new int[0][]);
    }
}

作者:Krahets
链接:https://leetcode.cn/leetbook/read/illustration-of-algorithm/eth6p5/
来源:力扣(LeetCode)
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

时间复杂度:O(N)(i < j,最多遍历 target/2 次),空间复杂度:O(1)。

方法二:滑动窗口(双指针)

算法流程:

  1. 初始化:左边界 i = 1,右边界 j = 2,元素和 s = 3,结果列表 res;
  2. 循环:当 i ≥ j 时跳出;
    1. 当 s > target 时: 向右移动左边界 i = i + 1,并更新元素和 s;
    2. 当 s < target 时: 向右移动右边界 j = j + 1,并更新元素和 s;
    3. 当 s = target 时: 记录连续整数序列,并向右移动左边界 i = i + 1;
  3. 返回值:返回结果列表 res;

 

class Solution {
    public int[][] findContinuousSequence(int target) {
        int i = 1, j = 2, s = 3;
        List<int[]> res = new ArrayList<>();
        while(i < j) {
            if(s == target) {
                int[] ans = new int[j - i + 1];
                for(int k = i; k <= j; k++)
                    ans[k - i] = k;
                res.add(ans);
            }
            if(s >= target) {
                s -= i;
                i++;
            } else {
                j++;
                s += j;
            }
        }
        return res.toArray(new int[0][]);
    }
}

作者:Krahets
链接:https://leetcode.cn/leetbook/read/illustration-of-algorithm/eth6p5/
来源:力扣(LeetCode)
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

时间复杂度:O(N)(i < j,最多遍历 target 次,i、j 都移动到 target/2 处),空间复杂度:O(1)。

以上两种解法,考虑到解的稀疏性,将列表构建时间简化考虑为 O(1)。

注意最后的 res.toArray(new int[0][]),将列表转换成二维数组。

剑指 Offer 62. 圆圈中最后剩下的数字

难度:简单

模拟法需要循环删除 n - 1 轮,每轮在链表中寻找删除节点需要 m 次访问操作(链表线性遍历),因此总体时间复杂度为 O(nm)

实际上,本题是著名的“约瑟夫环”问题,可使用动态规划解决。

输入 n, m,记此约瑟夫环问题为 「n, m 问题」 ,设解(即最后留下的数字)为 f(n),则有:

  • 「n, m 问题」:数字环为 0, 1, 2, ..., n - 1,解为 f(n);
  • 「n-1, m 问题」:数字环为 0, 1, 2, ..., n - 2,解为 f(n-1);
  • 以此类推……

对于「n, m 问题」,首轮删除环中第 m 个数字后,得到一个长度为 n - 1 的数字环。由于有可能 m > n,因此删除的数字为 (m - 1) % n,删除后的数字环从下个数字(即 m % n)开始,设 t = m % n,可得数字环:

删除一轮后的数字环也变为一个「n-1, m 问题」,观察以下数字编号对应关系:

设「n-1, m 问题」某数字为 x,则可得递推关系:

换而言之,若已知「n-1, m 问题」的解 f(n - 1),则可通过以上公式计算得到「n, m 问题」的解 f(n),即:

f(n) 可由 f(n - 1) 得到,f(n - 1) 可由 f(n - 2) 得到,……,f(2) 可由 f(1) 得到;因此,若给定 f(1) 的值,就可以递推至任意 f(n)。而「1, m 问题」的解 f(1) = 0 恒成立,即无论 m 为何值,长度为 1 的数字环留下的是一定是数字 0。

以上数学推导本质是得出动态规划的转移方程初始状态

动态规划解析:

  1. 状态定义:设「i, m 问题」的解为 dp[i];
  2. 转移方程:通过以下公式可从 dp[i - 1] 递推得到 dp[i];
    • dp[i] = (dp[i - 1] + m) % i
  3. 初始状态:「1, m 问题」的解恒为 0,即 dp[1] = 0;
  4. 返回值:返回「n, m 问题」的解 dp[n];

根据状态转移方程的递推特性,无需建立状态列表 dp,而使用一个变量 x 执行状态转移即可。

class Solution {
    public int lastRemaining(int n, int m) {
        int x = 0;
        for (int i = 2; i <= n; i++) {
            x = (x + m) % i;
        }
        return x;
    }
}

作者:Krahets
链接:https://leetcode.cn/leetbook/read/illustration-of-algorithm/oxp3er/
来源:力扣(LeetCode)
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

时间复杂度:O(N),空间复杂度:O(1)。

剑指 Offer 14- II. 剪绳子 II

难度:中等

此题与剪绳子 I 等价,具体原理不再介绍,主要介绍一下大数求余的解法。

大数求余解法:

大数越界:当 a 增大时,最后返回的 3a 小以指数级别增长,可能超出 int32 甚至 int64 的取值范围,导致返回值错误。

大数求余问题:在仅使用 int32 类型存储的前提下,正确计算 xa 对 p 求余(即 xa ⊙ p)的值。

解决方案:循环求余快速幂求余,其中后者的时间复杂度更低,两种方法均基于以下求余运算规则推出:

1. 循环求余:

  • 根据求余运算性质推出

  • 解析:利用此公式,可通过循环操作依次求 x1, x2, ..., xa-1, xa 对 p 的余数,保证每轮中间值 rem 都在 int32 取值范围中。
  • 时间复杂度 O(N)其中 N = a,即循环的线性复杂度。

2. 快速幂求余:

  • 根据求余运算性质可推出:

  • 当 a 为奇数时 a/2 不是整数,因此分为以下两种情况(''//'' 代表向下取整的除法):

  • 解析:利用以上公式,可通过循环操作每次把指数 a 问题降低至指数 a//2 问题,只需循环 log2(N) 次,因此可将复杂度降低至对数级别。
  • 帮助理解:根据下表,初始状态 rem = 1, x = 3, a = 19, p = 1000000007,最后会将 rem × (xa ⊙ p) 化为 rem × (x0 ⊙ p) = rem × 1 的形式,即 rem 为余数答案。

  • 时间复杂度 O(log2N):其中 N = a,二分法为对数级别复杂度,每轮仅有求整、求余、次方运算。

根据二分法计算原理,至少要保证变量 x 和 rem 可以正确存储 10000000072,而 264 > 10000000072 > 232,因此我们选取 long 类型。

class Solution {
    public int cuttingRope(int n) {
        if(n <= 3) return n - 1;
        int b = n % 3, p = 1000000007;
        long rem = 1, x = 3;
        for(int a = n / 3 - 1; a > 0; a /= 2) {
            if(a % 2 == 1) rem = (rem * x) % p;
            x = (x * x) % p;
        }
        if(b == 0) return (int)(rem * 3 % p);
        if(b == 1) return (int)(rem * 4 % p);
        return (int)(rem * 6 % p);
    }
}

作者:Krahets
链接:https://leetcode.cn/leetbook/read/illustration-of-algorithm/5vbrri/
来源:力扣(LeetCode)
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

剑指 Offer 43. 1~n 整数中 1 出现的次数

难度:困难

将 1 ~ n 的个位、十位、百位、...的 1 出现次数相加,即为 1 出现的总次数。

设数字 n 是个 x 位数,记 n 的第 i 位为 ni,则可将 n 写为 nxnx-1⋯n2n1;本文名词规定如下:

  • 称「 ni 」称为当前位,记为 cur
  • 将「 ni-1ni-2⋯n2n1 」称为低位,记为 low
  • 将「 nxnx-1⋯ni+2ni+1 」称为高位,记为 high;
  • 将「 10i 」称为位因子,记为 digit

某位中 1 出现次数的计算方法:

根据当前位 cur 值的不同,分为以下三种情况:

  1. 当 cur = 0 时:此位 1 的出现次数只由高位 high 决定,计算公式为:high × digit
  2. 当 cur = 1 时:此位 1 的出现次数由高位 high 和低位 low 决定,计算公式为:high × digit + low + 1
  3. 当 cur = 2, 3, , 9 时:此位 1 的出现次数只由高位 high 决定,计算公式为:(high + 1) × digit

变量递推公式:

设计按照 “个位、十位、...” 的顺序计算,则 high / cur / low / digit 应初始化为:

int high = n / 10;
int cur = n % 10;
int low = 0;
int digit = 1; // 个位

因此,从个位到最高位的变量递推公式为:

while(high != 0 || cur != 0) { // 当 high 和 cur 同时为 0 时,说明已经越过最高位,因此跳出
    low += cur * digit; // 将 cur 加入 low ,组成下轮 low
    cur = high % 10; // 下轮 cur 是本轮 high 的最低位
    high /= 10; // 将本轮 high 最低位删除,得到下轮 high
    digit *= 10; // 位因子每轮 × 10
}

代码如下:

class Solution {
    public int countDigitOne(int n) {
        int digit = 1, res = 0;
        int high = n / 10, cur = n % 10, low = 0;
        while(high != 0 || cur != 0) {
            if(cur == 0) res += high * digit;
            else if(cur == 1) res += high * digit + low + 1;
            else res += (high + 1) * digit;
            low += cur * digit;
            cur = high % 10;
            high /= 10;
            digit *= 10;
        }
        return res;
    }
}

作者:Krahets
链接:https://leetcode.cn/leetbook/read/illustration-of-algorithm/57nyhd/
来源:力扣(LeetCode)
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

时间复杂度:O(logN),空间复杂度:O(1)。

剑指 Offer 44. 数字序列中某一位的数字

难度:中等

观察上表,可推出各 digit 下的数位数量 count 的计算公式:count = 9 × start × digit

根据以上分析,可将求解分为三步:

  1. 确定 n 所在数字位数,记为 digit;
  2. 确定 n 所在的数字,记为 num;
  3. 确定 n 是 num 中的哪一数位,并返回结果;

1. 确定所求数位的所在数字的位数

如下图所示,循环执行 n 减去 一位数、两位数、... 的数位数量 count,直至 n ≤ count 时跳出。

由于 n 已经减去了一位数、两位数、...、(digit−1) 位数的数位数量count,因而此时的 n 是从起始数字 start 开始计数的。

int digit = 1;
long start = 1;
long count = 9;
while (n > count) {
   n -= count;
   start *= 10; // 1, 10, 100, ...
   digit += 1;  // 1,  2,  3, ...
   count = digit * start * 9; // 9, 180, 2700, ...
}

结论:所求数位 ① 在某个 digit 位数中; ② 为从数字 start 开始的第 n 个数位。

2. 确定所求数位所在的数字

如下图所示,所求数位 在从数字 start 开始的第 [(n - 1) / digit] 个数字中(start 为第 0 个数字)。

long num = start + (n - 1) / digit;

结论:所求数位在数字 num 中。

3. 确定所求数位在 num 的哪一数位

如下图所示,所求数位为数字 num 的第 (n - 1) % digit 位( 数字的首个数位为第 0 位)。

String s = Long.toString(num); // 转化为 string
int res = s.charAt((n - 1) % digit) - '0'; // 获得 num 的 第 (n - 1) % digit 个数位,并转化为 int

结论:所求数位是 res

class Solution {
    public int findNthDigit(int n) {
        int digit = 1;
        long start = 1;
        long count = 9;
        while (n > count) { // 1.
            n -= count;
            start *= 10;
            digit += 1;
            count = digit * start * 9;
        }
        long num = start + (n - 1) / digit; // 2.
        return Long.toString(num).charAt((n - 1) % digit) - '0'; // 3.
    }
}

作者:Krahets
链接:https://leetcode.cn/leetbook/read/illustration-of-algorithm/57w6b3/
来源:力扣(LeetCode)
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

时间复杂度:O(logN),空间复杂度:O(logN)。

posted @ 2022-10-29 11:53  幻梦翱翔  阅读(60)  评论(0)    收藏  举报