使用 JavaScript 刷题时如何解决 MLE (超出内存限制)

512 阅读8分钟

今天在写 P1901 发射站 这题时,遇到超时内存限制的问题,这里记录下我是如何排查和解决这个问题的

这题的题目大意是有一排高低各不相同的发射站,对于每个发射站,会向两边发射能量,其他发射站能接收它能量,但每边只有高于它且离他最近的发射站能接收到能量,问能接收到最多能量的发射站能接收到多少能量.

输入的第一行是一个整数 NN,接下去会输出 NN 行,每行包含两个数字,分别是第 ii 个发射站的高度和能发射的能量.

比如题目中的示例

3
4 2
3 5
6 10

总共有 3 个发射站,发射站的高度和能量分别是[[4,2],[3,5],[6,10]],其中发射站 0 能向右发射 2 的能量,并被发射站 2 接收.发射站 1 会像两边发射能量 5 分别被发射站 0 和发射站 2 接收.发射站 2 会向右边发射 10 能量,但没有发射站能接收.这时发射站 2 接收到总共 7 的能量,是接收到最多的能量,所以输出 7.

解法: 单调栈

这题的思路很简单,对于每个位置,看下左边和右边比他高的是哪个发射站,则会把能量发射到那个发射站上,算法也不难,可以使用单调栈分别从左到右和从右到左扫一遍就可以,或者放在一次循环里面也可以

很快写好下面这段代码,提交后,却发现有两个点总是会显示超出内存,于是开始了后面的优化之旅

提示中会有部分数据 NN 的范围是 1N1061≤N≤10^6,估计就是这些数据卡着的

const fs = require('fs')
const inputs = fs
  .readFileSync(0, 'utf-8')
  .split('\n')
  .map(line => line.split(' ').map(Number))

const [n] = inputs[0]
const data = inputs.slice(1, n + 1)
main(n, data)
function main(n, data) {
  let res = new Array(n).fill(0),
    stack = []
  const top = () => stack[stack.length - 1]
  for (let i = 0; i < n; i++) {
    let [h, v] = data[i]
    while (stack.length && data[top()][0] <= h) stack.pop()

    if (stack.length) res[top()] += v
    stack.push(i)
  }

  stack = []
  for (let i = n - 1; i >= 0; i--) {
    let [h, v] = data[i]
    while (stack.length && data[top()][0] <= h) stack.pop()

    if (stack.length) res[top()] += v
    stack.push(i)
  }

  console.log(Math.max(...res))
}

排查问题

我平常刷题常用的调试法: console.log 输出调试,删代码调试,通过 VSCode 运行调试,但在洛谷中不给看输出信息,则剩下删代码调试法了

首先我先分析下这段代码中哪些地方占用着内存,按照我的理解,这段程序中内存的占用应该是像下图标出来的这样

memory.png

但实际上测试结果只要运行以下代码那两个测试点的内存就会超出

const fs = require('fs')
const inputs = fs
  .readFileSync(0, 'utf-8')
  .split('\n')
  .map(line => line.split(' ').map(Number))

感觉给的内存是不是太小了,光是读入数据就差不多超内存了,还让不让人好好写题了 😓

于是,我用不同的代码做了下测试

comparison.png

其中从 1 到 2 的变化是对输入的文本按照换行符进行拆分成为一个一维的字符串数组,从 2 到 3 的变化主要是将一维数组中的字符串按照空格进行拆分,将一维数组变成二维数组

根据对比,我猜测一个可能是数组会消耗比字符串更多的内存,另外一个是有可能在转换的过程中会产生中间变量,这些中间变量的叠加,造成内存成倍的增加

为了排除第二个猜测,我换一种方式读取数据,用 readline 来读入数据,然后直接将每一行存入数组,这样可以不用经过一个中间变量

guess2.png

针对第一个猜测,我做了如下测试,发现确实会多使用很多内存

guess1.png

这样虽然将每一行保存了下来,但却不是很好用,我们只能保存每一行的字符串,每一行的字符串中包含以空格分割的两个数字,我们之后需要用到这两个数字,第一个数字需要进行判断大小,第二个数字需要进行累加的操作.如果只是保存为字符串,之后到每次具体运算中去拆分和转换成数字的话,结果是会 TLE(超时),而如果将这些字符串进行拆分保存,则是 MLE(超过内存).

两头堵死了,如果有什么办法能够对两个字符串中的数字进行直接比较大小,那就完美了,只是如果不借助其他数据结构的话,只能一位一位去比较,这样会增加时间复杂度造成超时,而如果要借助其它数据结构,比如 LCP 数组,那则需要消耗额外的内存.

解法办法

这时我想起之前做原地归并排序时,用到的一个算法.归并排序有一个合并数组的步骤,将前后两部分分别都是有序的数组,要求不适用额外的空间进行排序.比如像[1,7,8,10,3,4,6,9],如果不限制空间的话就很好办,另外开一个数组,用双指针按照大小一个个排到新数组里面,然后在搬回来就好了.但如果限制空间,就不那么好做了.

我知道的两种做法,一种是手摇算法,通过双指针确定一个最小区间,将这个区间通过三次翻转排到前面来,有兴趣的可以看最后的参考链接,或者去谷歌搜一下.

另外一种是标记法,通过给每个数字加上一个足够大的数组用以额外存储一个排序的信息,这里我们也可以通过通过标记法,用一个数字来存储两个数字的信息.

具体可以查看后面对于标记法的详细介绍 标记法原地排序

题目中给的提示前一个数字 hh 最大为 10910^9,后一个数字 vv 最大为 10410^4,那我们可以通过公式 h100000+vh*100000+v 得到一个数字 numnum 来存储两个数字的信息,因为题目保证每个 h 均不同,所以通过比较 numnum 则相等于比较 hh 的大小,当我们想要获取 vv 时,只需要将 nummod100000num \mod 100000 即可.

这样将每一行包含的信息都存储到一个数字里,大大降低了内存,又能包含需要的信息.最终通过了全部测试,AC 这一题.

这题的数据刚好是在最大安全数范围内,如果预测数有可能达到 101610^16 以上,则直接使用 Number 有可能会超过最大安全数,可以考虑使用 BigInt

ac.png

const readline = require('readline')
const rl = readline.createInterface({
  input: process.stdin,
  output: process.stdout,
})

const MOD = 100000
let n = 0,
  i = 0,
  data = []
rl.on('line', line => {
  const [a, b] = line.split(' ').map(Number)
  if (i++ === 0) n = a
  else data.push(a * MOD + b)
})

rl.on('close', () => main(n, data))

function main(n, data) {
  let res = new Array(n).fill(0),
    left = [],
    right = []
  const topl = () => left[left.length - 1]
  const topr = () => right[right.length - 1]

  for (let i = 0; i < n; i++) {
    let num = data[i]
    while (left.length && data[topl()] <= num) left.pop()
    if (left.length) res[topl()] += num % MOD
    left.push(i)

    let j = n - i - 1
    num = data[j]
    while (right.length && data[topr()] <= num) right.pop()
    if (right.length) res[topr()] += num % MOD
    right.push(j)
  }

  let ans = 0
  for (let num of res) ans = Math.max(ans, num)

  console.log(ans)
}

标记法原地排序

通过在给每个数字加上对应排序的标记,然后根据这个标记将数字移动到对应的位置,之后在恢复数字.

其中添加标记是取一个比当前数组中最大数字大的数字 aa,之后我跟将每个数其对应的排序的位置 ii 乘以 aa,然后加上原来的数就得到一个做好标记的数 bb,我们可以通过 b/a\lfloor b/a \rfloor (b 除以 a 向下取整)得到这个数是排第几位,然后通过 bmodab \mod a 得到这个数原来的值.

一般我会取一个最小的 10n10^n 这样在调试的时候,可以很容易看出来这个数对应的顺序

比如 [1,7,8,10,3,4,6,9] 这个数组

01234567
378101469

我们取 a=100a=100,很显然第一个数 1 是最小的数,应该把它放在索引为 0 的位置,则能得出标记后的数字为 1+1000>11+100*0 -> 1

第二小的数为 3 应该放在索引为 1 的位置,则能得出标记后的数字为 3+1001>1033+100*1 -> 103

这个过程可以通过双指针 O(n)O(n) 的时间完成,得到下面的数组

[103, 407, 508, 710, 1, 204, 306, 609]
01234567
1034075087101204306609

然后我们从左往右扫描数组,将当前索引跟标记索引不同的数字都一一归位,比如 103 可以通过 103/100\lfloor 103/100 \rfloor 得到其索引为 1 和当前索引 0 不同,则我们需要把其放到位置 1 上,将位置 1 上的 407 和 103 进行交换,接着我们判断 407 的索引应该是 4,跟当前索引 0 不同,则把其放到 4 上,在把当前 4 中的 1 放到 0 中,这是我们发现 1 的索引就是 0 则可以继续向右扫描

扫描完之后,可以获得下面的数组

[1, 103, 204, 306, 407, 508, 609, 710]

这个过程中,我们最多之后访问每个数字两次,也就是时间复杂度是 O(2n)O(2n) 去掉前面的常数项即为 O(n)O(n)

01234567
1103204306407508609710

最后我们再将每个位置的数都还原,也就是将每个数对 100 取余得到下面的数组,最终完成排序

[1, 3, 4, 6, 7, 8, 9, 10]
01234567
134678910