ALGORITHM 六月 24, 2020

力扣实战之移动零、盛最多的水、爬楼梯

文章字数 16k 阅读约需 15 mins. 阅读次数 0

练题法则

5-10 分钟读题与思考

  • 不要纠结没有思路就直接看题解;
  • 不要死磕觉得自己很失败,怎么我们就想不出来;
  • 基本上这些算法题,让我们自己想出来是不可能的;
  • 拿跳表的来说,如果我们能从 0-1 把它想出来,那我们就可以拿到图灵奖了;
  • 所以记住!无思路就直接看题解,无思路就直接看题解,无思路就直接看题解
  • 我们只需要知道并且能运用即可!

有思路

  • 自己开始写代码,没有,就马上看题解!
    默写背题,熟练
  • 做完题目后,我们需要记住这种题的思路和有N 种解决办法
  • 重复再重复的默写,直到自己有深刻的影响;

最后开始自己写(闭卷)

  • 到了这里如果我们还需要看别人代码,那就要回去背题;
  • 能到达这个阶段基本这种题你已经开始熟悉的,接下来就是反复练习;

在哪里练题?

那肯定是力扣了!没有账号的小伙伴,马上就去注册个账号开始日复一日的练习吧!~

283 题 - 移动零

283. 移动零难度简单

题目讲解

给定一个数组 nums,编写一个函数将所有 0 移动到数组的末尾,同时保持非零元素的相对顺序。

示例:

输入: [0,1,0,3,12]
输出: [1,3,12,0,0]

说明:

  1. 必须在原数组上操作,不能拷贝额外的数组。
  2. 尽量减少操作次数。

这里需要注意的重点:

  1. 所有 0 移动到数组的末尾;
  2. 保持非零元素的相对顺序;
  3. 必须在原数组上操作,不能拷贝额外的数组;

解题思路

思考题解时,使用MECE 原则 — 每一个思路都相对独立的思维,然后想到完全穷尽。首先不要管附加条件,先把有可能解决这个问题的思路都想出来,再评估哪一个办法是最优解。面试的时候也是一样,说出你所有可以想到的思路,然后分别讲出各自的优点与缺点,最后提出最优答案。

  1. 统计 0 的个数
    • 循环数组找到 0 的位置,遇到 0 就为 0 的个数加一;
    • 遇到不是 0 的时候,把非 0 的元素值与 0 的元素交换即可;
  2. 开新数组
    • 给一个指针i从数组的头部开始递增;
    • 给一个指针j从数组的尾部开始递减(也就是原数组的总长度);
    • 遇到零就往j指针的位置放,然后j--
    • 遇到非零就往i指针的位置放,然后i++
    • 缺点:内存使用会高;
    • 不符合条件:必须在原数组上操作,所以可以实现但是不符合条件;
  3. 双指针交换
    • 给两个指针ij,并且默认都从 0 开始;
    • i指向的是当前位置;
    • j指针会一直移动,直到找到一个非零元素,然后与i位置的值交换;
    • 如果j的位置与i不是一致的话,就可以给j的值换成 0;
  4. 双指针替换后清零
    • 这个与第三种方法一致,也是双指针;
    • 唯一的区别是不在i指针扫描的时候替换零;
    • 而是在替换完毕所有非零元素后,把剩余的全部位数都改为 0;

解题代码

「方法一」 - 统计 0 的个数:

  • 时间复杂度:$O(n)$ - N 个元素就需要遍历 N 次
  • 空间复杂度:$O(1)$ - 只对原数组进行替换操作
/**
 * @param {number[]} nums
 * @return {void} Do not return anything, modify nums in-place instead.
 */
var moveZeroes = function (nums) {
  let zeroCount = 0;
  for (let i = 0; i < nums.length; i++) {
    if (nums[i] == 0) {
      zeroCount += 1;
    } else if (zeroCount > 0) {
      nums[i - zeroCount] = nums[i];
      nums[i] = 0;
    }
  }
};

「方法二」 - 双指针交换:

  • 时间复杂度:$O(n)$ - N 个元素就需要遍历 N 次
  • 空间复杂度:$O(1)$ - 只对原数组进行替换操作
/**
 * @param {number[]} nums
 * @return {void} Do not return anything, modify nums in-place instead.
 */
var moveZeroes = function (nums) {
  let j = 0;
  for (let i = 0; i < nums.length; i++) {
    if (nums[i] !== 0) {
      nums[j] = nums[i];
      if (j !== i) {
        nums[i] = 0;
      }
      j++;
    }
  }
};

「方法三」 - 双指针替换后清零:

  • 时间复杂度:$O(n)$ - N 个元素就需要遍历 N 次,加上最后清零是走了n减非零的个数,那就是O(n+n-i),总的来说还是O(n)
  • 空间复杂度:$O(1)$ - 只对原数组进行替换操作
/**
 * @param {number[]} nums
 * @return {void} Do not return anything, modify nums in-place instead.
 */
var moveZeroes = function (nums) {
  var j = 0;
  for (let i = 0; i < nums.length; i++) {
    if (nums[i] != 0) {
      nums[j] = nums[i];
      j++;
    }
  }

  for (let k = j; k < nums.length; k++) {
    nums[k] = 0;
  }
};

边界测试用例

[0,1,0,3,12][1,2] > [0,0]

题解对比与分析

注意:以下数据都是在力扣中提交后返回的结果,每次提交都有可能不一致。所以相近的方案输出的结果有所差异也是正常的,最终最优方案要通过分析代码来确定不能只以力扣输出的数据为准,只能供于我们作为参考

方法 执行时间 内存消耗
「方法一」- 统计 0 的个数 96 ms(战胜 17.82%) 37.1 MB
「方法二」- 双指针交换 72 ms(战胜 87.23%) 37.2 MB
「方法三」- 双指针替换后清零 76 ms(战胜 73.98%) 37.2 MB

分析一下:

  • 第一种方法是通过统计 0 出现的次数来定位到需要替换 0 的所在位置,里面涉及一个i - zeroCount的运算,所以相对其他方法来说运行时间会更长一些;
  • 第二个方法是通过两个指针一起运行,一个固定在 0 元素,一个一直走找到非 0 元素,最后做一个交换,这种方法没有涉及运算,同时也是一个循环就可以完成,相对来说是最优解;
  • 第三种方法也是用了双指针,与第二种方法的唯一区别就是先替换掉所有 0 的元素,最后把剩余的元素全部一次性替换成 0。可读性来说,个人觉得更容易懂,但是时间和空间复杂度和第二种方法是一致的。

11 题 - 盛最多水的容器

283. 盛最多水的容器难度中等

题目讲解

给你 n 个非负整数 a1,a2,…,an,每个数代表坐标中的一个点 (i, ai) 。在坐标内画 n 条垂直线,垂直线 i 的两个端点分别为 (i, ai) 和 (i, 0)。找出其中的两条线,使得它们与 x 轴共同构成的容器可以容纳最多的水。

说明:你不能倾斜容器,且 n 的值至少为 2。

图中垂直线代表输入数组 [1,8,6,2,5,4,8,3,7]。在此情况下,容器能够容纳水(表示为蓝色部分)的最大值为 49。

示例:

输入:[1,8,6,2,5,4,8,3,7]
输出:49

题目重点:

  1. 首先我们的目标是挑选两条柱子,从而让两个柱子之前可以得出最大的面积(面积越大自然可容纳的水就越多);
  2. 挑选最长的两个柱子不等于拥有最大的面积,因为它们之间的距离也是决定空间的一个维度;
  3. 所以重点是找到高度和宽度比例最大的一对柱子,从而得出最大面积;
  4. 注意在运算面积时,我们只能用一对柱子中最短的一条作为高度,因为水只能填满到最短的那条柱子的高度;
  5. 面积运算公式: 高度 x 宽度 = 面积

解题思路

  1. 枚举 —— 暴力解法

    • 遍历左边和右边,找出所有面积;
    • 列出所有柱子的组合;
    • 算出所有组合各自的面积;
    • 最后输出最大的面积的一组;
    • 缺点:遍历次数过高,所以时间复杂度会相对偏高
    • 复杂度:时间复杂度 $O(n^2)$、空间复杂度 $O(1)$
  2. 双指针

    • 左右两边都往中间移动;

    • 需要移动左右两头的问题都可以考虑双指针;

    • 相同情况下两遍距离越远越好;

    • 区域受限于较短边的高度;

    • 所以让较矮的那边的指针往内移动;

    • 一直以上面的规则移动知道两个指针重合;

解题代码

「方法一」 - 枚举(暴力破解):

  • 时间复杂度:$O(n^2)$ - 双循环,所以总计循环了 N^2。
  • 空间复杂度:$O(1)$
/**
 * @param {number[]} height
 * @return {number}
 */
var maxArea = function (height) {
  let max = 0;
  for (let i = 0; i < height.length - 1; i++) {
    for (let j = i + 1; j < height.length; j++) {
      let area = (j - i) * Math.min(height[i], height[j]);
      max = Math.max(max, area);
    }
  }
  return max;
};

「方法二」 - 双指针:

  • 时间复杂度:$O(n)$ - 双指针总计最多遍历整个数组一次。
  • 空间复杂度:$O(1)$ - 只需要额外的常数级别的空间。
/**
 * @param {number[]} height
 * @return {number}
 */
var maxArea = function (height) {
  let max = 0;

  for (let i = 0, j = height.length - 1; i < j; ) {
    let minHeight = height[i] < height[j] ? height[i++] : height[j--];
    let area = (j - i + 1) * minHeight;
    max = Math.max(max, area);
  }

  return max;
};

题解对比与分析

方法 执行时间(毫秒) 内存消耗
枚举(暴力破解) 984 ms (战胜 9.99%) 35.9 MB
双指针 56 ms(战胜 99.88%) 36 MB

分析一下

  • 通过使用第二种方法,我们从$O(n^2)$的时间复杂度降到$O(n)$,总的执行时间大概是快了 17 倍

70 题 - 爬楼梯

283. 移动零难度简单

题目讲解

假设你正在爬楼梯。需要 n 阶你才能到达楼顶。

每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?

注意:给定 n 是一个正整数。

示例 1:

输入: 2
输出: 2
解释: 有两种方法可以爬到楼顶。

  1. 1 阶 + 1 阶
  2. 2 阶

示例 2:

输入: 3
输出: 3
解释: 有三种方法可以爬到楼顶。

  1. 1 阶 + 1 阶 + 1 阶
  2. 1 阶 + 2 阶
  3. 2 阶 + 1 阶

题解重点

其实题目本身并不难,在力扣(LeetCode)是属于“简单”级别的题目,但是如果没有思路,或者对这个题目完全不了解的话,一点头绪都没有也是正常的,这种题目也就是属于套路题。如果我们是不知道的话,我们自然会难到不知道怎么做。我们要是知道了的话,那就变得相当容易了。

这里讲一下解题的思想:

首先我们解题时最大的误区是什么?

  • 做题只做了一遍
  • 至少要做五遍

然后我们优化的思想是什么?

  • 空间换时间
  • 升维思想(升级到二维)

看题时懵了怎么办?

  • 首先我们能不能暴力破解?
  • 最基本的情况我们应该怎么解决?能否化繁为简?

破解所有问题的法则:

  • 找最近重复的子问题
  • 为什么?因为写程序我们只能写ifelseforwhilerecursion(递归)
  • 计算机是人类发明的,计算机肯定是没有人脑那么强的,它其实就是一个简单的重复式机器
  • 那么计算机运行的程序也是同理,它是用重复的东西来解决问题的
  • 如果我们遇到算法题的时候,就是需要我们用程序去解决的问题,那问题的本身就是可重复的
  • 无论是算法中的回述、分治、动态规划、递归等,全部都是在找重复性的原理
  • 所以重点都是“找规律

深度分析题目:

首先我们使用化繁为简的思维来分析:

要到达第一个台阶,我们只能爬 1 个台阶,所以只有一种方法的可能性,所以 n = 1 的时候,只有 1 种可能。

那如果我们要到达第二个台阶,我们要不就是连续爬 2 次 1 个跨度,要不就是一次性爬两个台阶到达第二个台阶。所以有 2 种可能性。

那如果是需要到达第三个台阶呢

这里有个小技巧,要到达第三个台阶我们可以换一种思维去想,如果我们还是像第一个和第二个台阶的方式去列出可以到达第三个台阶的所有可能性,那如果n很大的时候,我们只靠人的大脑去想,那真的是太费劲了。但是这里有一个很巧妙的思维方式。


返过来想,我们想到达第三个台阶,只有两种可能可以到达:
  1. 要不就是从第二个台阶爬 1 个台阶到达
  2. 要不就是从第一个台阶爬 2 个台阶到达

那其实如果是第四个台阶是不是也是一样的?
  1. 要不就是从第三个台阶爬 1 个台阶到达
  2. 要不就是从第二个台阶爬 2 个台阶到达

这里就有一个`规律`了。要到达第`n`个台阶我们需要知道:
  1. 到达第n-1的台阶有多少种可能
  2. 到达第n-2的台阶有多少种可能
  3. 然后这两个相加就是到达第n的台阶有多少种可能

那其实这里就是老生常谈的斐波拉次数列:

$f(n) = f(n-1) + f(n-2)$

解题思路

  1. 斐波拉次(Fibonacci)- “傻递归“
    • 直接使用递归循环使用斐波拉次公式即可
    • 但是时间复杂度就很高 - $O(2^n)$
  2. 动态规划
    • 用上面讲到的原理,到达第n个台阶只需要:爬上 $n-1$ 台阶的方式数 + 爬上 $n - 2$ 台阶的方法数 = 爬上第 $n$ 个台阶的方式数
    • 所以得出的公式是 $dp[n] = dp[n-1] + dp[n-2]$
    • 同时需要初始化: $dp[0]=1$ 和 $dp[1] = 1$
    • 使用这种方式时间复杂度降到 $O(n)$
  3. 动态规划 2 - 只记录最后 3 个的方法量
    • 与上面的动态规划的方法一样,但是这里我们只记录最后 3 个的台阶的爬楼方法数
    • 使用f1f2f3作为储存变量
    • 默认 $f1 = 1$ 和 $f2 = 2$ 即可
  4. 通项公式(Binet’s Formular )
    • 有观察数学规律的同学,或者数学比较好的同学,会发现本题是斐波那次数列,那么我们也可以用斐波那次的“通项公式”
    • 公式是:$F_n = \frac{1}{\sqrt{5}}[(\frac{1+\sqrt{5}}{2})^n - (\frac{1-\sqrt{5}}{2})^n]$
    • 时间复杂度:$O(logn)$

解题代码

「方法一」斐波那次

  • 时间复杂度:$O(2^n)$
  • 空间复杂度:$O(1)$
/**
 * @param {number} n
 * @return {number}
 */
var climbStairs = function (n) {
  if (n <= 2) return n;
  return climbStairs(n - 1) + climbStairs(n - 2);
};

「方法二」动态规划

  • 时间复杂度:$O(n)$
  • 空间复杂度:$O(n)$
/**
 * @param {number} n
 * @return {number}
 */
var climbStairs = function (n) {
  const dp = [];
  dp[0] = 1;
  dp[1] = 1;
  for (let i = 2; i <= n; i++) {
    dp[i] = dp[i - 1] + dp[i - 2];
  }
  return dp[n];
};

「方法三」动态规划 2

  • 时间复杂度:$O(n)$
  • 空间复杂度:$O(1)$
/**
 * @param {number} n
 * @return {number}
 */
var climbStairs = function (n) {
  if (n <= 2) {
    return n;
  }
  let f1 = 1,
    f2 = 2,
    f3;
  for (let i = 3; i <= n; i++) {
    f3 = f1 + f2;
    f1 = f2;
    f2 = f3;
  }
  return f3;
};

「方法四」通项公式

  • 时间复杂度:$O(logn)$
  • 空间复杂度:$O(1)$
/**
 * @param {number} n
 * @return {number}
 */
var climbStairs = function (n) {
  const sqrt_5 = Math.sqrt(5);
  const fib_n = Math.pow((1 + sqrt_5) / 2, n + 1) - Math.pow((1 - sqrt_5) / 2, n + 1);
  return Math.round(fib_n / sqrt_5);
};

题解对比与分析

方法 执行时间(毫秒) 内存消耗
「方法一」斐波那次 超出时间限制 N/A
「方法二」动态规划 68 ms 32.4 MB
「方法三」动态规划 2 53 ms 32.3 MB
「方法三」通项公式 67 ms 32.4 MB

分析一下

  • 按照时间复杂度来说,应该“通项公式”是性能最优的,但是力扣的执行时间不是很靠谱,这一点我在上面也说到,就不多解释了。
  • 所以最优解还是第三种方法“通项公式
  • 接着就是“动态规划 2”,因为只储存了 3 个变量,第二种方法需要用到数组。在空间复杂度上就占了优势。
  • 而最后输一下傻瓜式的斐波那次递归,这种方法还没有执行完就已经被淘汰了。时间复杂度过高。

推荐专栏

小伙伴们可以查看或者订阅相关的专栏,从而集中阅读相关知识的文章哦。

  • 📖 《数据结构与算法》 — 到了如今,如果想成为一个高级开发工程师或者进入大厂,不论岗位是前端、后端还是 AI,算法都是重中之重。也无论我们需要进入的公司的岗位是否最后是做算法工程师,前提面试就需要考算法。

  • 📖 《FCC 前端集训营》 — 根据 FreeCodeCamp 的学习课程,一起深入浅出学习前端。稳固前端知识,一起在 FreeCodeCamp 获得证书

  • 📖 《前端星球》 — 以实战为线索,深入浅出前端多维度的知识点。内含有多方面的前端知识文章,带领不懂前端的童鞋一起学习前端,在前端开发路上童鞋一起燃起心中那团火 🔥

0%