Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

leetcode 887. 鸡蛋掉落 #19

Open
xxleyi opened this issue Apr 11, 2020 · 0 comments
Open

leetcode 887. 鸡蛋掉落 #19

xxleyi opened this issue Apr 11, 2020 · 0 comments

Comments

@xxleyi
Copy link
Owner

xxleyi commented Apr 11, 2020

题:

你将获得 K 个鸡蛋,并可以使用一栋从 1 到 N  共有 N 层楼的建筑。

每个蛋的功能都是一样的,如果一个蛋碎了,你就不能再把它掉下去。

你知道存在楼层 F ,满足 0 <= F <= N 任何从高于 F 的楼层落下的鸡蛋都会碎,从 F 楼层或比它低的楼层落下的鸡蛋都不会破。

每次移动,你可以取一个鸡蛋(如果你有完整的鸡蛋)并把它从任一楼层 X 扔下(满足 1 <= X <= N)。

你的目标是确切地知道 F 的值是多少。

无论 F 的初始值如何,你确定 F 的值的最小移动次数是多少?

示例 1:

输入:K = 1, N = 2
输出:2
解释:
鸡蛋从 1 楼掉落。如果它碎了,我们肯定知道 F = 0 。
否则,鸡蛋从 2 楼掉落。如果它碎了,我们肯定知道 F = 1 。
如果它没碎,那么我们肯定知道 F = 2 。
因此,在最坏的情况下我们需要移动 2 次以确定 F 是多少。

示例 2:

输入:K = 2, N = 6
输出:3

示例 3:

输入:K = 3, N = 14
输出:4
 

提示:

1 <= K <= 100
1 <= N <= 10000

来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/super-egg-drop
著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。


解:

此题据说是谷歌的经典面试题。确实够劲。

此题的关键是搞懂怎么做。大部分题目在初看之后,总能有个暴力穷举的解决方案,虽然很可能行不通。但总归是个安慰嘛。

此题最容易挫败人的地方在于:读过题目之后,一脸懵逼,连暴力穷举的解决方案都想不出来

遇到这种情况怎么办呢?只能耐心多理一理了。

理顺之后,就能想明白该怎么解决:

  • K 个蛋,N 层楼,最佳策略肯定是把每一个蛋都用上,用上的意思是都摔碎
  • 每一个蛋都摔碎,但要碎的有价值
  • 假设只有一个蛋,想要找到 F 唯一奏效的策略就是从 1 层逐级往上,直到摔碎。最后就是楼层数 N
  • 如果有两个蛋,那我们就可以利用起来,不用逐级往上,可以间隔开来,比如从 x 层扔
    • 如果蛋碎了,则 F 介于 [0, x), 即 (1, x - 1)
    • 如果蛋没碎,则 F 介于 [x + 1, N], 问题规模缩小,即 (2, N - x)
    • 则 (2, N) 有可能等于 1 + max{(1, x - 1), (2, N - x)}
    • 考虑到 x 的取值可能有哪些?对,有可能是 [1, N ] 中的任何一个
    • 则递推方程出来了 f(k, n) = 1 + min{ max{f(k - 1, x - 1), f(k, n - x)} }, x 介于 [1, n]

然后根据递归方程,就能使用带记忆的递归暴力求解了。

然后发现超时,之后观察递推方程自身的性质来逐步优化:

  • 第一步,寻找 x 可以不用暴力遍历,可以使用二分查找,将复杂度降到 O(logn)
  • 第二步,寻找 x 还可以不用每次都二分查找,因为外层循环 k 不变时,x 随内层循环 n 的减少而减少
  • 也就是说,每一个内层循环下,x 随 n 的增大而增大,且自身具有单调性
  • 也就是说,寻找 x 的复杂度,可以均摊成 O(1)

该是见代码的时候了:

// 自顶向下的解法,略粗暴,但有效
// 「循环不变式」隐藏的特别深,不够明显
var superEggDrop = function(K, N) {
  // 「循环不变式」相关的变量
  let cache = Array(K + 1)
  for (let i = 0; i <= K; i++) cache[i] = Array(N + 1)

  // 辅助函数:二分法查找最小值
  function bin(k, n) {
    // 二分法中必用的「循环不变式」相关的变量
    let lo = 1, hi = n
    while (true) {
      let mid = lo + (hi - lo) / 2 | 0
      let up = loop(k - 1, mid - 1)
      let down = loop(k, n - mid)
      // 判断循环是否终止
      if(up === down || lo + 1 >= hi) return Math.max(up, down)
      // 更新变量以确保「循环不变式」
      if (up < down) lo = mid
      else hi = mid
    }
  }

  // 暴力递归版循环函数
  function loop(k = K, n = N) {
    // 检查是否已经计算过
    if (cache[k][n]) return cache[k][n]
    // 正确更新 cache 以维护「循环不变式」
    if (k === 1) cache[k][n] = n
    else if (n === 0) cache[k][n] = 0
    else {
      // 自顶向下的递推方程
      cache[k][n] = 1 + bin(k, n)
    }
    // 循环终止时,返回正确答案
    return cache[k][n]
  }

  return loop()
};


// 改写为自底向上的迭代版循环函数
// 「循环不变式」以及相关变量变得清晰明确
// 注:测试用例规模过大时会报递归过深的错误,但其实已经是尾递归,只不过 JS 没有这块的优化
var superEggDrop = function(K, N) {
  // 初始化「循环不变式」相关的变量
  let cacheK = Array(N + 1)
  for (let i = 0; i <= N; i++) cacheK[i] = i
  let cacheKNext = Array(N + 1).fill(0)
  let x = 1

  // 辅助函数,用于计算 k n 已知时,单个 (k, x) 的值(x < n)
  let dropAt = (x, n) => Math.max(cacheK[x - 1], cacheKNext[n - x])

  function loop (k = 2, n = 1, x = 1, cacheK, cacheKNext) {
    // 循环终止
    if (k > K && n > N) return cacheK[N]
    // 更新 cacheKNext 与 x 以确保「循环不变式」始终成立
    while (x < n && dropAt(x, n) >= dropAt(x + 1, n)) x++
    cacheKNext[n] = 1 + dropAt(x, n)
    
    // 下一个内层循环,n 加 1
    if (n < N + 1) return loop(k, n + 1, x, cacheK, cacheKNext)
    else {
      // 更新 cacheK 与 x 以确保「循环不变式」始终成立
      cacheKNext.forEach((e, i) => cacheK[i] = e)
      // 下一个外层循环,k 加 1,n 和 x 从 1 重新开始
      return loop(k + 1, 1, 1, cacheK, cacheKNext)
    }
  }

  return loop(k = 2, n = 1, x, cacheK, cacheKNext)
}


// 迭代版循环
// 和上述递归版循环同构,「循环不变式」完全一致
var superEggDrop = function(K, N) {
  // 初始化「循环不变式」相关的变量
  let cacheK = Array(N + 1)
  for (let i = 0; i <= N; i++) cacheK[i] = i
  let cacheKNext = Array(N + 1).fill(0)
  let x = 1

  // 辅助函数,用于计算 k n 已知时,单个 (k, x) 的值(x < n)
  let dropAt = (x, n) => Math.max(cacheK[x - 1], cacheKNext[n - x])

  for (let k = 2; k <= K; k++) {
    for (let n = 1; n <= N; n++) {
      // 每一个内层循环均需更新 cacheKNext 与 x 以确保「循环不变式」始终成立
      while (x < n && dropAt(x, n) >= dropAt(x + 1, n)) x++
      cacheKNext[n] = 1 + dropAt(x, n)
    }
    // 开始下一个外层循环前,更新 cacheK 与 x 以确保「循环不变式」始终成立
    x = 1
    cacheKNext.forEach((e, i) => cacheK[i] = e)
  }
  // 循环终止:答案为 cacheK 的最后一个元素
  return cacheK[N]
}

此题还没完,还有更牛逼的解法:反过来想,有 K 个蛋,最多扔 T 次,最少能搞定多少楼层?

T 肯定 小于等于 N,递归方程也很简单,双层循环单调递增,能搞定楼层大于等于 N 时,返回此时扔的次数即可。

// 逆向思维,让递归方程变得更简单
var superEggDrop = function(K, N) {
  // 初始化「循环不变式」相关的变量
  let cache = Array(N + 1)
  // 一次不扔时,为 0
  cache[0] = Array(K + 1).fill(0)
  for (let i = 1; i <= N; i++) {
    cache[i] = Array(K + 1)
    // 没有蛋时,得零层
    cache[i][0] = 0
    // 只有一个蛋时,扔几次得几层
    cache[i][1] = i
  }
  // 只扔一次时,只要有蛋,都得 1 层
  for (let k = 1; k <= K; k++) cache[1][k] = 1
  // 从扔一次开始
  let t = 1

  finish: for (; t <= N; t++) {
    for (let k = 1; k <= K; k++) {
      // 更新 cache 以确保「循环不变式」始终成立
      cache[t][k] = 1 + cache[t - 1][k] + cache[t - 1][k - 1]
      // 判断循环是否应该终止
      if (cache[t][k] >= N) break finish
    }
  }

  // 循环终止:正确答案为 t
  return t
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

1 participant