前言
程序来源于生活,程序改变生活,编程让生活变得更美好。
年关将至,相信很多同学已经结束了辛苦的一年的工作开启了假期模式,抵达家乡之后,很多亲朋好友聚在一起,大家可以打打麻将交流交流感情。不过,对于常年奔跑在工作中的我们来说,牌技着实拙计,别急,今天我们就编个程序帮忙算一算,哈哈哈,让我们这个春节都不吃亏,祝大家运气都旺旺旺。
成都麻将的规则
四川麻将的规则非常简单,各地有些许的不同,本文就以成都麻将的规则为例,编写一个算法根据手牌计算出听牌。
首先,成都麻将108张,分别是36张筒,36张条,36张万,每个玩家拿13张牌,作为起始游戏的玩家拿14张牌。
玩家需要至少定缺一门,比如筒
,条
,万
花色,玩家的手牌不能超过2门,超过则称之为花猪,不能听牌。
然后,玩家可以有多种胡牌方式,一种比较简单的胡牌方式叫做 7对
,就是手里面有6对对子,然后剩下的单的牌就是你听的牌。
剩下的,也就是最复杂的胡牌计算公式了,这个公式叫做DD + m*ABC + n*XXX
,其中,DD
在四川麻将中称为将,然后ABC
是公差为1的等差数列 ,XXX
就是完全一样的3个牌,成都麻将称之为坎
。
胡牌的时候,系数m
和n
可能为0。
因为碰
或者杠
的牌可以视为已经处理好的手牌,所以算法在做处理的时候,只需要考虑手牌是否能够满足公式即可。
下面,是几种听牌的方式:
七对: 单钓(对子胡): 清一色(一条龙): 普通对子胡: 平胡:
编写算法计算听牌
成都麻将有3种花色,因此可以用一个类型来表示:
ts
type EntityCategory = "筒" | "条" | "万";
设计麻将类
ts
class Entity {
/**
* 牌的类型,筒条万
*/
type: EntityCategory;
/**
* 牌的内容,
*/
size: number;
constructor({ type, size }: { size?: number; type?: EntityCategory } = {}) {
size && (this.size = size);
type && (this.type = type);
}
}
编写检测七对听牌方式的算法
七对胡牌类型的算法实现起来相对比较简单,因为我们只需要枚举手里面的牌即可。
七对必须要求玩家手里的牌张数为13,多少都不符合七对的类型。
如果玩家还是花猪,那么肯定是不能听牌的,玩家的牌,只有点数一样,花色一样,才能算的上对子,我们不关心这些对子,我们其实只关心那一张单的牌,因此,我的检测程序实现如下:
ts
/**
* 七对的胡牌类型,保证传入的参数是已经进行过排序的,找到7对的听牌
*/
export function sevenPairHandCalc(group: Entity[][]) {
// 暂未定缺,还不可以进行胡牌
if (group.length === 3) {
return -1;
}
// 将牌型进行合并
const list = group.reduce((l, c) => {
return l.concat(c);
});
// 如果牌的内容不为13,说明肯定是不是7对
if (list.length !== 13) {
return -1;
}
let targetVal = -1;
let i = 0;
while (i < list.length) {
// 说明当前是一对牌,花色一样,内容一样,可以直接跳过到下一对进行比较
if (
list[i + 1] &&
list[i].size === list[i + 1].size &&
list[i].type === list[i + 1].type
) {
// 跳到下一对进行比较
i += 2;
}
// 说明当前这个牌是单的,如果是符合预期的牌型的话,那么,这个就是最终听的牌,否则说明不是听牌
else if (
list[i + 1] &&
(list[i].size !== list[i + 1].size || list[i].type !== list[i + 1].type)
) {
if (targetVal === -1) {
targetVal = list[i].size;
i++;
} else {
return -1;
}
}
// 若最后一个牌才是单的,之前的都是双的,则最后一个也是可以胡的牌
else if (!list[i + 1] && targetVal === -1) {
return list[i].size;
}
}
// 返回找到的听牌
return targetVal;
}
编写检测一般胡牌方式的检测算法
检测一般胡牌方式的算法相对也复杂一些,用计算机的视角来说,就是不断的尝试。
首先,我们对玩家的手牌按照类别和点数进行排序,这个操作就可以对应实际操作的时候我们理牌的那个过程。
玩家的手牌必须是对3取余之后为1 ,手牌多了少了都是相公(实际打牌的过程中,我们可能会因为自己的疏忽,而导致手牌多了或手牌不足无法胡牌的场景十有八九)
然后,还是跟之前聊到过的规则一样,玩家手牌必须最多只能持有2种花色,花猪是不能听牌的。
很容易想到的一个点,玩家能胡的牌,只能是手牌里面持有的花色1-9,所以,我们这一步可以先生成玩家手牌的可胡的所有牌,最后把这个集合送进玩家的手牌依次进行尝试。
然后,就是开始不断的枚举了。我们需要尝试将一张牌加入到玩家的手牌中,计算玩家的手牌是否可以满足这个公式DD + m*ABC + n*XXX,系数m和n可以为0
。
枚举,该怎么样来进行枚举呢?之前我们在规则那一小节提出的将对,不知道同学们还有没有印象。我们首先先找出玩家手里面有两张牌以上的牌,然后取两张,把剩余的牌划分成一个集合。
玩家手里面,可能有很多对牌,所以这个时候找出来的组合也可能有很多种。
在得到了除了将对以外的所有组合集合以后,事情看起来就变得简单了。
我们只需要取出一个集合,每次尝试从这个组合里面抵消AAA
或ABC
(公差为1的等差数列)这样的组合,若经过不断的抵消之后,最终剩余集合的元素为0,则认为玩家是可以胡这张牌的。
因为玩家至多持有2种花色,因此必须两种花色都满足这种组合方式才算听牌。
在计算胡牌的过程中,每次抵消一组牌的组合时,必须要优先消除AAA
这样的牌,否则会造成错误的计算结果。
所以,这就是根据以上思路编写的算法:
ts
/**
* 普通胡牌的计算,保证传入的参数是已经进行过排序的
*/
export function normalHandCalc(group: Entity[][]) {
// 尚未打缺,无法胡牌
if (group.length === 3) {
return [];
}
// 将不同的牌型进行合并
const list = group.reduce((l, c) => {
return l.concat(c);
});
// 相公,即手牌不足或者手牌多了,都无法胡牌
if (list.length > 13 || list.length % 3 !== 1) {
return [];
}
// 找出当前手牌能够可以胡的牌
const selectTargetEntity: Entity[] = [];
// 如果玩家不是清一色,那么,玩家可以胡牌的类型就是持有两种类型牌的其中之一
const type1 = group[0][0].type;
selectTargetEntity.push(...createTryTarget(type1));
if (group.length === 2) {
const type2 = group[1][0].type;
selectTargetEntity.push(...createTryTarget(type2));
}
// 得到所有可以胡的牌的结果
return selectTargetEntity.filter((v) => {
return calcHand(list, v);
});
}
function clone(obj: unknown) {
return JSON.parse(JSON.stringify(obj));
}
/**
* 计算胡牌
* @param group 当前手牌
* @param target 是否可以胡这个牌
*/
function calcHand(list: Entity[], target: Entity) {
// 将手牌进行克隆,因为要计算很多次,不能直接操作玩家的手牌
const myList = clone(list) as Entity[];
// 将候选听牌加入手牌
myList.push(target);
myList.sort((a, b) => {
if (a.type != b.type) {
return String(a.type).charCodeAt(0) - String(b.type).charCodeAt(0);
} else {
return a.size - b.size;
}
});
// 从玩家的手牌里面选出一对,四川麻将称之为 将,然后剩余的牌,只要每3个能够组成AAA 或者ABC,ABC为等差数列,公差为1的组合,
// 这个牌就是玩家可听的候选项。
const map: Map<String, Entity[]> = new Map();
myList.forEach((entity) => {
// 以类型和内容作为Map的Key
const key = entity.size + "" + entity.type;
if (map.has(key)) {
map.get(key)!.push(entity);
} else {
map.set(key, [entity]);
}
});
// 过滤出大于2的牌,这些牌才有资格作为将对
const pairOptions = [...map.values()].filter((v) => {
return v.length >= 2;
});
// 计算出,去除将对之后的剩余的所有手牌的可能性
const filterPairResultGroupList = pairOptions.map((v) => {
// 仍然需要深克隆,因为需要多次处理
const onceList = clone(myList) as Entity[];
let counter = 0;
while (counter < 2) {
const idx = onceList.findIndex(
(t) => t.size === v[0].size && t.type === v[0].type
);
if (idx >= 0) {
onceList.splice(idx, 1);
counter++;
}
}
// 得到去除将对以后剩余的手牌
return onceList;
});
// 只要有一个能满足条件,则认为可以胡
return filterPairResultGroupList.some((list) => judge(list));
}
/**
* 对手牌进行分组,然后再进行判断
* @param list
*/
function judge(list: Entity[]) {
const map: Map<String, Entity[]> = new Map();
list.forEach((v) => {
if (map.has(v.type)) {
map.get(v.type)!.push(v);
} else {
map.set(v.type, [v]);
}
});
const group = [...map.values()];
// 分别对两组牌进行组合,如果都可以满足,则认为是可以胡牌的,玩家有可能只有一门花色的牌
return calcOnce(group[0] || []) && calcOnce(group[1] || []);
}
/**
* 计算出除了将对以外的牌,是否可以组成每3个能够组成AAA 或者 ABC,ABC为等差数列,公差为1的结果,只处理一种类型的牌型
* @param list
*/
function calcOnce(list: Entity[]) {
if (list.length === 0) {
return true;
}
while (list.length >= 3) {
let flag = true;
// 如果当前牌能够组成AAA的组合
if (
list.length >= 3 &&
list[0].size === list[1].size &&
list[1].size === list[2].size
) {
// 丢弃AAA,继续进行下一轮计算
let counter = 3;
while (counter > 0) {
list.shift();
counter--;
}
flag = false;
} else {
// 不能直接取前3进行比较,因为有可能出现122334这样的case,实际上可以组成123,234这样的组合
let a = list[0].size;
let b: number | null;
let offsetB = 1;
// 向后找到第一个比A大的数,B一定是可以找的到的
while (offsetB < list.length && a === list[offsetB].size) {
offsetB++;
}
if (offsetB >= list.length) {
return false;
}
b = list[offsetB].size;
let offsetC = offsetB + 1;
// 向后找到第一个比C大的数,但是C不一定能够找到,比如113这样的场景,就只能找到AB,无法确定C
while (offsetC < list.length && b === list[offsetC].size) {
offsetC++;
}
let c: number | null = offsetC < list.length ? list[offsetC].size : null;
// 如果ABC满足公差为1的等差数列,说明这三个牌也可以丢弃。
if (typeof c === "number" && b - a === 1 && c - b === 1) {
// 丢弃第一个牌
list.shift();
// 丢弃第二个牌,因为原来的牌少了一个,所以offset要减去1
list.splice(offsetB - 1, 1);
// 丢弃第三个牌,因为原来的牌少了两个个,所以offset要减去2
list.splice(offsetC - 2, 1);
} else {
return false;
}
flag = false;
}
if (!flag) {
list.sort((a, b) => {
return a.size - b.size;
});
} else {
// 说明无法组成AAA或者,ABC,公差为1的等差数列
return false;
}
}
// 如果不能消完,说明不能完全组成AAA,或者ABC这样的排列
return list.length === 0;
}
/**
* 根据牌型,生成可能听牌的候选项
* @param type
* @returns
*/
function createTryTarget(type: EntityCategory) {
const selectTargetEntity: Entity[] = [];
for (let i = 1; i <= 9; i++) {
selectTargetEntity.push(
new Entity({
type,
size: i,
})
);
}
return selectTargetEntity;
}
测试用例
把七对的胡牌类型和一般的胡牌类型的结果求并集再去重之后,就是玩家最终可以胡牌的结果。 以下是我编写的测试用例,感兴趣的同学可以贡献测试用例来校验我的算法是否正确,哈哈哈。
ts
import { normalHandCalc, sevenPairHandCalc } from "./Calculator";
import { Entity } from "./Entity";
import { groupBy } from "lodash";
function createHand(code: string) {
const list = code.split("").map((v) => {
const en = new Entity();
en.size = +v;
if (/\d/.test(v)) {
en.size = +v;
en.type = "万";
} else if (/[a-z]/.test(v)) {
en.size = v.charCodeAt(0) - 96;
en.type = "条";
} else {
en.size = v.charCodeAt(0) - 64;
en.type = "筒";
}
return en;
});
const group = Object.values(groupBy(list, (v) => v.type));
return group;
}
describe("calc seven pairs", () => {
it("1123344556677", () => {
const hand = createHand("1123344556677");
const target = sevenPairHandCalc(hand);
expect(target).toBe(2);
});
it("1223344556677", () => {
const hand = createHand("1223344556677");
const idx = sevenPairHandCalc(hand);
expect(idx).toBe(1);
});
it("1122334455667", () => {
const hand = createHand("1122334455667");
const idx = sevenPairHandCalc(hand);
expect(idx).toBe(7);
});
it("1111223344557", () => {
const hand = createHand("1111223344557");
const idx = sevenPairHandCalc(hand);
expect(idx).toBe(7);
});
it("1111222244557", () => {
const hand = createHand("1111222244557");
const idx = sevenPairHandCalc(hand);
expect(idx).toBe(7);
});
it("1233445567788", () => {
const list = "1233445567788".split("").map((v) => {
const en = new Entity();
en.size = +v;
return en;
});
const idx = sevenPairHandCalc([list]);
expect(idx).toBe(-1);
});
it("1A23344556677", () => {
const list = "1A23344556677".split("").map((v) => {
const en = new Entity();
if (/\d/.test(v)) {
en.size = +v;
en.type = "万";
} else {
en.size = v.charCodeAt(0) - 64;
en.type = "筒";
}
return en;
});
const idx = sevenPairHandCalc([list]);
expect(idx).toBe(-1);
});
});
describe("calc normal list", () => {
it("case where is 122334BBCDEFG", () => {
const hand = createHand("122334BBCDEFG");
const results = normalHandCalc(hand);
expect(results.length).toBe(3);
expect(results[0].type).toBe("筒");
expect(results[1].type).toBe("筒");
expect(results[2].type).toBe("筒");
expect(results[0].size).toBe(2);
expect(results[1].size).toBe(5);
expect(results[2].size).toBe(8);
});
it("case only 1", () => {
const hand = createHand("1");
const results = normalHandCalc(hand);
expect(results.length).toBe(1);
expect(results[0].type).toBe("万");
});
it("case normal case", () => {
const hand = createHand("1114567");
const results = normalHandCalc(hand);
expect(results.length).toBe(2);
expect(results[0].type).toBe("万");
expect(results[1].type).toBe("万");
expect(results[0].size).toBe(4);
expect(results[1].size).toBe(7);
});
it("case two pair", () => {
const hand = createHand("11AA");
const results = normalHandCalc(hand);
expect(results.length).toBe(2);
expect(results[0].type).toBe("万");
expect(results[1].type).toBe("筒");
expect(results[0].size).toBe(1);
expect(results[1].size).toBe(1);
});
it("case six target mahjong", () => {
const hand = createHand("1112345678");
const results = normalHandCalc(hand);
expect(results.length).toBe(6);
expect(results[0].type).toBe("万");
expect(results[1].type).toBe("万");
expect(results[2].type).toBe("万");
expect(results[3].type).toBe("万");
expect(results[4].type).toBe("万");
expect(results[5].type).toBe("万");
expect(results[0].size).toBe(2);
expect(results[1].size).toBe(3);
expect(results[2].size).toBe(5);
expect(results[3].size).toBe(6);
expect(results[4].size).toBe(8);
expect(results[5].size).toBe(9);
});
it("case test clear three pattern is correct", () => {
const hand = createHand("11123456AA");
const results = normalHandCalc(hand);
expect(results.length).toBe(4);
expect(results[0].type).toBe("万");
expect(results[1].type).toBe("万");
expect(results[2].type).toBe("万");
expect(results[3].type).toBe("筒");
expect(results[0].size).toBe(1);
expect(results[1].size).toBe(4);
expect(results[2].size).toBe(7);
expect(results[3].size).toBe(1);
});
it("case every target in some type", () => {
const hand = createHand("1112345678999");
const results = normalHandCalc(hand);
expect(results.length).toBe(9);
expect(results[0].type).toBe("万");
expect(results[1].type).toBe("万");
expect(results[2].type).toBe("万");
expect(results[3].type).toBe("万");
expect(results[4].type).toBe("万");
expect(results[5].type).toBe("万");
expect(results[6].type).toBe("万");
expect(results[7].type).toBe("万");
expect(results[8].type).toBe("万");
expect(results[0].size).toBe(1);
expect(results[1].size).toBe(2);
expect(results[2].size).toBe(3);
expect(results[3].size).toBe(4);
expect(results[4].size).toBe(5);
expect(results[5].size).toBe(6);
expect(results[6].size).toBe(7);
expect(results[7].size).toBe(8);
expect(results[8].size).toBe(9);
});
});
结语
实际上这个算法还有很多改进的空间,这个就交给有兴趣的同学去自行实现吧,哈哈哈。
其实在实际生活中有很多例子可以用计算机帮助我们解决问题,在遇到的时候大家可以充分的发挥自己的头脑。
最后说一些警惕的话,可以看出,计算机处理麻将的规则是很简单的,并且生成的牌序可以把它记录下来,分成4份,然后可以准确的知道每个玩家面前的牌堆的排列,在发牌之后进而就能根据顺序和初始化的拿牌的位置进而分析出玩家的起始手牌,然后就可以通过分析牌序得到之后可能出现的所有牌,如果其中有玩家碰或者杠影响了牌序,能够立刻进行重新计算。如果参与网络对战,一旦这些信息被第三方外挂抓取到那你的信息将会是被别人看的一览无余了,所以珍爱生命,远离网络赌博。
最后,祝大家新年假期里都能玩的开心,过的快乐!Happy New Year!
对于本文阐述的内容有任何疑问的同学可以在评论区留言或私信我。
如果大家喜欢我的文章,可以多多点赞收藏加关注,你们的认可是我最好的更新动力,😁。