大家好,我是前端西瓜哥。
最近在试图做一个在线斗地主的游戏,为此需要实现一个洗牌算法,最后是给它实现了。一起来看看我是怎么将它实现的吧。
思路其实也不复杂,就两步:
- 拿到完整的一副牌(这里我们需要设计一下牌的数据存储方式)
- 洗牌
getShuffledCards
我们先从顶层的算法出发,将上面的两个流程抽为两个子函数。
function getShuffledCards() { const cards = getCards(); shuffle(cards); return cards; }
getCards 获取扑克牌数组
下面我们先看看 getCards 子算法。该算法的作用是返回一个完整扑克牌数组。
我们用字符串来表示一张牌。
对于牌的大小:
// 牌的大小 const nums = [3, 4, 5, 6, 7, 8, 9, 10, 'J', 'Q', 'K', 'A', 2];
至于扑克牌花色,我们用 0 到 3 表示。也可以用它们英文的首字母来表示:S、H、C、D,都可以。
// 黑桃、红心、梅花、方块 const types = [0, 1, 2, 3];
然后对它们做组合,就能表示一张特定的卡牌:
3_0 // 黑桃 3 J_3 // 方块 J
这里还缺两张比较特殊的大小王。因为他们花色的概念,所以要做特殊处理,随意找两个字符来表示。
大小王的英文为 Joker,可以考虑 J(Joker)或 K(王),但它们都被占用了。最后我就随意找两个连续字母 M 和 N 来表示了。你看这个两个字母是不是很像小丑的帽子,其实还挺像的。
const getCards = (() => { let cacheCards; return () => { if (cacheCards) return [...cacheCards]; const nums = [3, 4, 5, 6, 7, 8, 9, 'A', 2, 'J', 'Q', 'K']; // 牌的大小 const types = [0, 1, 2, 3]; // 黑桃、红心... 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(0, 3); // 地主的额外三张牌 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 方法。
首先明确平时我们平时打牌时的排序规则。
- 大的牌在左边
- 同样大的两张牌,花色为黑桃的在最左边,方块在最右边。
实现思路就是用 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 = [3, 4, 5, 6, 7, 8, 9, 'A', 2, 'J', 'Q', 'K']; // 牌的大小 const types = [0, 1, 2, 3]; // 黑桃、红心... 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(0, 3); // 地主的额外三张牌 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();
结尾
真正地给扑克牌洗牌,然后将它们发给玩家,再帮他们拍好牌,你们学会了吗?
我们看到,实现扑克牌洗牌的算法其实并没有想象中的那么简单,当然也不难。因为我们可以使用工程化的思维,将一个大问题不断地拆分,拆分成合适大小的子问题。一个个将子问题解决,大问题自然也就被解决了。
我是前端西瓜哥,最近在研究斗地主游戏,欢迎关注我,一起学前端。