真•扑克牌洗牌算法实现

992 阅读7分钟

我正在参加掘金社区游戏创意投稿大赛个人赛,详情请看:游戏创意投稿大赛

大家好,我是前端西瓜哥。

最近在试图做一个在线斗地主的游戏,为此需要实现一个洗牌算法,最后是给它实现了。一起来看看我是怎么将它实现的吧。

思路其实也不复杂,就两步:

  1. 拿到完整的一副牌(这里我们需要设计一下牌的数据存储方式)

  2. 洗牌

getShuffledCards

我们先从顶层的算法出发,将上面的两个流程抽为两个子函数。

function getShuffledCards() {
  const cards = getCards();
  shuffle(cards);
  return cards;
}

getCards 获取扑克牌数组

下面我们先看看 getCards 子算法。该算法的作用是返回一个完整扑克牌数组。

我们用字符串来表示一张牌。

对于牌的大小:

// 牌的大小
const nums = [345678910'J''Q''K''A'2];

至于扑克牌花色,我们用 0 到 3 表示。也可以用它们英文的首字母来表示:S、H、C、D,都可以。

// 黑桃、红心、梅花、方块
const types = [0123];

然后对它们做组合,就能表示一张特定的卡牌:

3_0 // 黑桃 3 
J_3 // 方块 J

这里还缺两张比较特殊的大小王。因为他们花色的概念,所以要做特殊处理,随意找两个字符来表示。

大小王的英文为 Joker,可以考虑 J(Joker)或 K(王),但它们都被占用了。最后我就随意找两个连续字母 M 和 N 来表示了。你看这个两个字母是不是很像小丑的帽子,其实还挺像的。

const getCards = (() => {
  let cacheCards;
  return () => {
    if (cacheCards) return [...cacheCards];
    const nums = [3456789'A'2'J''Q''K']; // 牌的大小
    const types = [0123]; // 黑桃、红心...

    cacheCards = nums.reduce((cards, curr) => {
      for (const type of types) {
        cards.push(curr + '_' + type); // 也可以使用其他分隔符
      }
      return cards;
    }, []);
    cacheCards.push('M''N'); // 大小王
    return [...cacheCards];
  }
})();

这里返回的是完整的一副扑克牌数组。

我用了闭包,主要是为了做缓存,因为我们每次调用这个函数的返回值其实都是一样的,缓存一下能够用空间换时间,降低时间复杂度。

这里需要注意的是,我们需要返回缓存数组的拷贝,而不是直接返回缓存数组。如果你直接返回缓存数组,返回的其实是对缓存数组的引用,因为它们指向同一个内存对象。

使用缓存需要拷贝数组,拷贝的时间复杂度是 O(n)。不做缓存不需要拷贝,时间复杂度也是 O(n),我好像优化了个寂寞。

如果你想返回两副牌,你可以在 return 前将数组自拷贝一下再放到数组尾部。

cacheCards.push(...cacheCards);

shuffle 洗牌算法

shuffle 方法是一个通用的洗牌算法,它会将传入的数组随机打乱。实现如下;

function shuffle(arr) {
  for (let i = arr.length - 1; i >= 0; i--) {
    const randIdx = getRand(0, i);
    [arr[randIdx], arr[i]] = [arr[i], arr[randIdx]];
  }
}

// 获取 [min, max] 区间中的一个随机整数
function getRand(min, max) {
  return Math.floor(Math.random() * (max - min + 1) + min);
}

核心逻辑为:从后往前遍历,i 递减。从 0 ~ i 的索引范围内随机找一个元素,和 arr[i] 交换。

在 i 的动态变化过程中,i 右侧为打乱的元素区间,当 i 递减到 0,整个数组就洗完了。

这种实现是一种原地算法,空间复杂度为 O(1),时间复杂度为 O(n)。

两个子函数实现完了,我们来看看执行 getShuffledCards 函数的输出结果:

[
  'K_0''Q_3''A_2''4_1''4_3''8_3',
  'N',   '8_1''Q_0''3_0''7_2''7_0',
  '8_0''9_0''3_3''A_0''9_2''2_2',
  '8_2''6_3''4_2''J_2''4_0''5_2',
  '9_3''2_0''K_1''7_1''9_1''6_0',
  'Q_2''2_1''J_1''7_3''K_3''A_1',
  'J_3''2_3''6_1''K_2''J_0''6_2',
  '3_2''M',   '5_3''3_1''5_0''A_3',
  '5_1''Q_1'
]

西瓜哥我很满意。

将乱序的牌再排序起来

这里我们再扩展一下,实现一下将乱序的牌排好序的算法。

假设我们在玩斗地主,我们把牌洗好了,先留下给地主的 3 张牌,然后每人发 17 张牌,但都是乱序的。

玩家问:“你 TMD 能不能给我把牌排好序?日内瓦!退钱!”

function doudizhu() {
  const cards = getShuffledCards();
  const dizhuCards = cards.splice(03); // 地主的额外三张牌
  const playerCards = [];
  // 剩下的牌均分
  playerCards[0] = cards.splice(0, cards.length / 3);
  playerCards[1] = cards.splice(0, cards.length / 2);
  playerCards[2] = cards;
  // 排序
  playerCards.forEach(cards => sortCards(cards));
}

玩家貌似很愤怒(无感情),我们赶紧来实现上面将卡牌数组排序的 sortCards 方法。

首先明确平时我们平时打牌时的排序规则。

  1. 大的牌在左边

  2. 同样大的两张牌,花色为黑桃的在最左边,方块在最右边。

实现思路就是用 JS 自带的 Math.sort() 方法进行排序,难点是怎么对比两个字符串。

我们无法用字典序,因为 A 比 K 大,大小王 M 和 N 又比较特殊。我使用的方案就是计算出它们的等价的数值,通过它们来比较。实现如下:

function sortCards(cards) {
  function getEqualVal(s) {
    if (s === 'M'return 9999999;
    if (s === 'N'return 999999;
    let [num, type] = s.split('_');
    if (num === 'J') num = '11'
    if (num === 'Q') num = '12'
    if (num === 'K') num = '13'
    if (num === 'A') num = '14'
    if (num == '2') num = '15'
    return parseInt(num) * 10 - parseInt(type);
  }
  // 用等价数的方式对比
  cards.sort((a, b) => {
    return getEqualVal(b) - getEqualVal(a);
  }) 
}

我们把牌大小作为更高的位(parseInt(num) * 10)。

对于花色,则要采取负收益的做法,因为我是用 1 来表示黑桃,3 来表示方块,排序要求从大到小,且黑桃要最左,所以需要对它取反,来保证黑桃的值要比方块的要大。

有些非数字字符,我们需要依照它们的大小,给它们提供对应的数字。然后是大小王,需要最特殊处理,直接返回非常大的比其他牌要大的等价数。

完整实现

const getCards = (() => {
  let cacheCards;
  return () => {
    if (cacheCards) return [...cacheCards];
    const nums = [3456789'A'2'J''Q''K']; // 牌的大小
    const types = [0123]; // 黑桃、红心...

    cacheCards = nums.reduce((cards, curr) => {
      for (const type of types) {
        cards.push(curr + '_' + type); // 也可以使用其他分隔符
      }
      return cards;
    }, []);
    cacheCards.push('M''N'); // 大小王
    return [...cacheCards];
  }
})();

function shuffle(arr) {
  for (let i = arr.length - 1; i >= 0; i--) {
    const randIdx = getRand(0, i);
    [arr[randIdx], arr[i]] = [arr[i], arr[randIdx]];
  }
}

// 获取 [min, max] 区间中的一个随机整数
function getRand(min, max) {
  return Math.floor(Math.random() * (max - min + 1) + min);
}

function getShuffledCards() {
  const cards = getCards();
  shuffle(cards);
  return cards;
}

function sortCards(cards) {
  function getEqualVal(s) {
    if (s === 'M'return 9999999;
    if (s === 'N'return 999999;
    let [num, type] = s.split('_');
    if (num === 'J') num = '11'
    if (num === 'Q') num = '12'
    if (num === 'K') num = '13'
    if (num === 'A') num = '14'
    if (num == '2') num = '15'
    return parseInt(num) * 10 - parseInt(type);
  }
  // 用等价无的方式对比
  cards.sort((a, b) => {
    return getEqualVal(b) - getEqualVal(a);
  }) 
}

function doudizhu() {
  const cards = getShuffledCards();
  const dizhuCards = cards.splice(03); // 地主的额外三张牌
  const playerCards = [];
  // 剩下的牌均分
  playerCards[0] = cards.splice(0, cards.length / 3);
  playerCards[1] = cards.splice(0, cards.length / 2);
  playerCards[2] = cards;
  // 排序
  playerCards.forEach(cards => sortCards(cards));

  console.log(
    dizhuCards,
    playerCards,
  );
}

doudizhu();

结尾

真正地给扑克牌洗牌,然后将它们发给玩家,再帮他们拍好牌,你们学会了吗?

我们看到,实现扑克牌洗牌的算法其实并没有想象中的那么简单,当然也不难。因为我们可以使用工程化的思维,将一个大问题不断地拆分,拆分成合适大小的子问题。一个个将子问题解决,大问题自然也就被解决了。

我是前端西瓜哥,最近在研究斗地主游戏,欢迎关注我,一起学前端。

本文首发于我的公众号:前端西瓜哥