【青训营】月影老师告诉我写好JavaScript的四大技巧——保证正确

1,070 阅读4分钟

如何写好JavaScript是每一个前端工程师一直以来在思考的问题,月影老师告诉我们一些写好JavaScript的原则,同时也教了一些我们如何写好JavaScript的技巧,今天来继续跟着月影老师学JavaScript吧~~

起步

我们在编写代码的时候,最重要的是要保证我们的代码的正确性,然而,在有些情况下,代码可以正常运行,看上去也挺对的,但实际上代码可能不是那么正确~

我们来看一个例子

洗牌算法

让你实现一个洗牌算法,你会怎么实现,很快就能想到我们可以直接对数组进行随机排序,就是洗牌了,代码如下

function shuffle(cards) {
  return [...cards].sort(() => (Math.random() > 0.5 ? -1 : 1));
}

const cards = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
console.log(shuffle(cards)); // [3, 1, 5, 4, 8, 7, 2, 6, 9, 0]

多运行几次看上去效果不错,确实打乱了顺序

image.png

这个算法真的正确吗?或者说这个算法真的公平吗?

验证正确性

我们来验证这个洗牌算法的正确性,如何验证呢?

我们将这个洗牌程序重复一百万次,result数组用来记录每个位置出现过的数字之和,如果这是一个公平的算法的话,result数组中的数字应该都很相近。

const result = Array(10).fill(0);

for (let i = 0; i < 1000000; i++) {
  const c = shuffle(cards);
  for (let j = 0; j < 10; j++) {
    result[j] += c[j];
  }
}

console.log(result);

得到的结果是

[3863812, 3862770, 4544657, 4648808, 4669379, 4364000, 4362095, 4722847, 4852688, 5108944]

可以看出这个结果是呈现递增的,而且第一个和最后一个位置的所有数字之和相差还比较大,也就是说,越大的数字出现在数组后面的概率要大一些。每个元素被安排在每个位置的概率是不同的,这是一个不公平的算法。

如何解决这个问题呢?

解决方案一:多洗几次

洗两次

const result = Array(10).fill(0);

for (let i = 0; i < 1000000; i++) {
  const c = shuffle(shuffle(cards));
  for (let j = 0; j < 10; j++) {
    result[j] += c[j];
  }
}

console.log(result);
[4431933, 4414334, 4501168, 4514001, 4527342, 4493793, 4496849, 4537253, 4540943, 4542384]

洗三次

const result = Array(10).fill(0);

for (let i = 0; i < 1000000; i++) {
  const c = shuffle(shuffle(shuffle(cards)));
  for (let j = 0; j < 10; j++) {
    result[j] += c[j];
  }
}

console.log(result);
[4487963, 4491386, 4495428, 4499063, 4494726, 4505270, 4498303, 4510195, 4508869, 4508797]

可以看出,多洗几次之后,result数组中的数字基本相当,也就是我们的算法相对公平了!

解决方案二: 随机采样

重复洗牌总是没有在算法层面解决问题,我们希望通过修改洗牌算法来从根本上解决问题

之前的算法之所以存在问题,是因为我们使用了sort方法,他在两两进行交换的时候,都是就近交换的,所以导致交换的位置不够随机

我们采用随机采样的方法来进行洗牌

  1. 我们从数组中随机选一个数,把他当前数组最后一个位置的数进行交换
  2. 去除刚刚交换的末尾的数组进行步骤1的操作
  3. 直到所有数字都被交换 image.png

算法实现

function shuffle(cards) {
  const c = [...cards];
  // 逆序遍历数组
  for (let i = c.length; i > 0; i--) {、
    // 随机选一个位置
    const pIdx = Math.floor(Math.random() * i);
    // 将选到的位置上的元素和数组末尾位置元素交换
    [c[pIdx], c[i - 1]] = [c[i - 1], c[pIdx]];
  }
  return c;
}

相当于组合数学中的不放回摸球模型,假设有n个球,通过数学归纳法很容易证明这个算法对每个一球取到的概率都是1/n

用上面的验证方法验证此算法

const result = Array(10).fill(0);

for (let i = 0; i < 1000000; i++) {
  const c = shuffle(cards);
  for (let j = 0; j < 10; j++) {
    result[j] += c[j];
  }
}

console.log(result);

得到的结果

[4498337, 4502249, 4502001, 4498385, 4504714, 4500172, 4498057, 4502210, 4498232, 4495643]

可以看到,数字都很相近,很均匀,所以这样的算法就是公平的,是正确的

应用

抽奖

比如我们要抽奖,可以直接取一个任意位置上的元素就行了

Math.floor(Math.random() * length)

但是我们的抽奖是一个过程,比如抽出一等奖,二等奖,三等奖,幸运奖之类的,就需要封装一下,采用我们上面的洗牌算法

将函数改成生成器,将return改成yield,就能够实现部分洗牌,或者用作抽奖

function* shuffle(items) {
  items = [...items];
  for (let i = items.length; i > 0; i--) {
    const idx = Math.floor(Math.random() * i);
    [items[idx], items[i - 1]] = [items[i - 1], items[idx]];
    yield items[i - 1];
  }
}

可以全部展示出来

let items = [1, 2, 3, 4, 5, 6, 7, 8, 9];
items = shuffle(items);
console.log(...items); // 7 1 2 8 5 3 9 4 6

也可以只选取部分,实现部分洗牌,或者说抽奖的功能

100个号随机抽取5个

let items = [...new Array(100).keys()];

let n = 0;
// 100个号随机抽取5个
for (let item of shuffle(items)) {
  console.log(item);
  if (n++ >= 5) break;
}
// 24 62 60 16 42 21

分红包

在APP中的抢红包功能中,内部进行随机的分红包的算法

为了不出现,一次随机分之后,一个红包太大,导致剩下的红包不够分的情况,可以采用下面这种分法,也就是每次划分之后,都选取存在的最大的那一个红包继续进行划分,这样就能保证红包肯定能被分够

function generate(amount, count){
  let ret = [amount];
  
  while(count > 1){
    //挑选出最大一块进行切分
    let cake = Math.max(...ret),
        idx = ret.indexOf(cake),
        part = 1 + Math.floor((cake / 2) * Math.random()),
        rest = cake - part;
    
    ret.splice(idx, 1, part, rest);
    
    count--;
  }
  return ret;
}

image.png 上面这种分法,会导致每次分的红包都很均匀

有时候,为了增加抢红包的趣味性,我们不希望我们红包分的那么平均

比如100元分给10个人,相当于在一个(0,100.00)的数轴上进行切分,随机切九刀在不同的位置,

所以可以转换成我们的洗牌程序,在0到100.00中间的 10000个位置中,随机抽取九个位置,将红包分成了十份,这样红包就不会被那么均匀的分配了

function * shuffle(cards){
  const c = [...cards];

  for(let i = c.length; i > 0; i--) {
    const pIdx = Math.floor(Math.random() * i);
    [c[pIdx], c[i - 1]] = [c[i - 1], c[pIdx]];
    yield c[i - 1];
  }
}

function generate(amount, count){
  if(count <= 1) return [amount];
  const cards = Array(amount - 1).fill(0).map((_, i) => i + 1);
  const pick = shuffle(cards);
  const result = [];
  for(let i = 0; i < count; i++) {
    result.push(pick.next().value);
  }
  result.sort((a, b) => a - b);
  for(let i = count - 1; i > 0; i--) {
    result[i] = result[i] - result[i - 1];
  }
  return result;
}

image.png

总结

我们写好程序,一定要确保它的正确性!

使用sort方法来随机洗牌,可能会导致算法不公平

更多相关博文

【青训营】月影老师告诉我写好JavaScript的三大原则——各司其责

【青训营】月影老师告诉我写好JavaScript的三大原则——组件封装

【青训营】月影老师告诉我写好JavaScript的三大原则——过程抽象

【青训营】月影老师告诉我写好JavaScript的四大技巧——风格优先