温馨提示:阅读这篇博客前请先阅读上一篇博客一种基于位运算的斗地主超高性能算法——出牌规则检验和获取出牌类型-优快云博客)
源码地址
github:https://github.com/Avalon712/ProkerUtils-For-FightingTheLandlord.git
gitee:https://gitee.com/gloaming712/ProkerUtils-For-FightingTheLandlord.git
自动模拟测试
为了测试算法的正确性和算法的性能,自己写了一个自动测试脚本,代码如下:(由于主要想看算法内部是否会产生GC,因此一些必要的内存分配没有写在方法里面,而是通过静态构造函数来进行的分配。)注意如果你使用Benchmark测试的时候记得把打印语句关闭。
下面是两次测试结果,这个结果意味着计算机模拟斗地主玩一局游戏只要75纳秒!!!足以可见算法的性能有多高效!
Method | Mean | Error | StdDev | Allocated |
TestPlay | 75.00 ns | 1.525 ns | 2.462 ns | - |
TestPlay | 72.35 ns | 1.444 ns | 1.483 ns | - |
namespace PokerUtils
{
//[MemoryDiagnoser]
public sealed class AutoPlayTest
{
private static readonly List<PokerCard>[] results;
private static readonly List<PokerCard> lastOutCards;
private static readonly List<PokerCard> tipCards;
static AutoPlayTest()
{
PokerCard[] remaining;
results = PokerHelper.NoShuffle(out remaining, 20);
//默认第一个玩家位地主
results[0].AddRange(remaining);
lastOutCards = new List<PokerCard>(10);
tipCards = new List<PokerCard>(10);
}
//static void Main(string[] args)
//{
// BenchmarkRunner.Run<Program>();
// Console.ReadLine();
//}
/*
Benchmark测试两次的结果
| Method | Mean | Error | StdDev | Allocated |
|--------- |---------:|---------:|---------:|----------:|
| TestPlay | 75.00 ns | 1.525 ns | 2.462 ns | - |
|--------- |---------:|---------:|---------:|----------:|
| TestPlay | 72.35 ns | 1.444 ns | 1.483 ns | - |
*/
//[Benchmark]
public void TestPlay()
{
int current = 0; //当前出牌玩家
PokerType lastOutType = PokerType.None; //上家出牌的类型
int last = 0;//上次玩牌的玩家
// int round = 1; //当前回合数
//int count = 3; //统计是否到了下一个回合
while (true)
{
//if(count == 3) { Console.WriteLine($"第{round}回合"); round++; }
// results[current].Sort((p1, p2) => p1 - p2); //方便查看
//如果上次没有人要,则继续该当前玩家出牌
if (last == current)
{
lastOutCards.Clear();
}
//Console.WriteLine($"玩家{current}{(current == 0 ? "(地主)" : "农民")}回合阶段");
if (lastOutCards.Count == 0)
{
Console.WriteLine("出牌前的手牌: " + DebugHelper.GetPokerString(results[current]));
lastOutType = PokerHelper.GetTipCards(results[current], tipCards);
lastOutCards.AddRange(tipCards);
results[current].RemoveAll(p => tipCards.Contains(p));
//Console.WriteLine($"当前出牌为({lastOutType}): " + GetPokerString(tipCards));
//Console.WriteLine("出牌后的手牌: " + GetPokerString(results[current]));
tipCards.Clear();
last = current;
}
else
{
//Console.WriteLine("出牌前的手牌: " + GetPokerString(results[current]));
PokerType currentType;
PokerHelper.GetTipCards(results[current], lastOutCards, lastOutType, tipCards, out currentType);
bool r = PokerHelper.FastCheck(tipCards, lastOutCards, lastOutType, out currentType);
if (currentType != PokerType.None)
{
lastOutCards.Clear();
lastOutCards.AddRange(tipCards);
results[current].RemoveAll(p => tipCards.Contains(p)); //从玩家的手牌中移除手牌
last = current;
lastOutType = currentType;
}
//Console.WriteLine($"当前出牌为({currentType})[{(r ? "√" : "×")}]: " + GetPokerString(tipCards));
//Console.WriteLine("出牌后的手牌: " + GetPokerString(results[current]));
tipCards.Clear();
}
//count--;
//if(count == 0) { count = 3; Console.WriteLine(); }
current = (current + 1) % 3; //下一家出牌
for (int i = 0; i < results.Length; i++)
{
if (results[i].Count == 0)
{
//Console.WriteLine($"对局结束,玩家{i}{(i == 0 ? "(地主)" : "农民")}胜利");
return;
}
}
}
}
public static string GetPokerString(List<PokerCard> cards)
{
return "[ " + string.Join(", ", cards) + " ]";
}
}
}
不洗牌算法
不洗牌算法我的实现思路是先将炸弹牌(即每四张牌)进行随机交换,交换次数是随机的;然后再单张牌进行随机交换,交换次数也是随机的,最后再一次性每人发17张牌,而不是一张一张的发。
分析当前玩家的手牌组成
首先要将当前玩家的手牌组成情况分析出来,即:单牌、顺子、对子、连对、三顺、飞机、炸弹、王炸。这些牌型,由于54张牌用位来进行记录也就54位就够了,因此只需要一个long类型(64位就可以分析出玩家的所有牌型)。先看源码,再讲解算法。
/// <summary>
/// 分析玩家的手牌构成
/// </summary>
/// <returns>(单牌、顺子、对子、连对、三张、飞机、炸弹)</returns>
private static ValueTuple<long, long, long, long, long, long, long> AnalysisCards(List<PokerCard> cards, ref bool existKingBomb)
{
long codes = 0;//玩家的手牌信息
for (int i = 0; i < cards.Count; i++)
codes |= 1L << (int)cards[i];
//1. 分析出玩家手牌的构成
// 76561193665298432 = 1L << 52 | 1L << 56;
existKingBomb = (codes & 76561193665298432) == 76561193665298432;//玩家手中是否存在王炸
long bombs = 0; //玩家手牌中的所有炸弹 --> 不含有王炸
long shunZi = 0; //玩家手牌中的所有顺子
long doubles = 0; //玩家手牌中的所有对子
long singles = 0; //玩家手牌中的单牌
long lianDui = 0; //玩家手牌中的连对
long feiJi = 0; //玩家手牌中的飞机
long threes = 0; //玩家手牌中的所有三张相同的牌
long bomb = 15; //64位才能记下所有炸弹 //从最小的炸弹四个3开始 ==> 4个3转为位信息为 1111 => 15
int code = 3; // 3、4、5、6...J、Q、K、A、2 、小王、大王 => 牌码
long temp; int count = 0;
while (code <= 17)
{
temp = (codes & bomb);
CountOne(temp, ref count);
switch (count)
{
case 1:
singles |= temp;
if (code < 15) { shunZi |= temp; }
break;
case 2:
doubles |= temp;
if (code < 15) { lianDui |= temp; };
break;
case 3:
threes |= temp;
if (code < 15) { feiJi |= temp; }
break;
case 4: bombs |= temp; break;
}
bomb <<= 4; //左移4位得到下一个炸弹
code++;
count = 0;
}
//获得顺子牌
ExcludeContinuous(ref shunZi, 5);
//获取连对
ExcludeContinuous(ref lianDui, 3);
//获取飞机
ExcludeContinuous(ref feiJi, 2);
singles ^= shunZi;
doubles ^= lianDui;
threes ^= feiJi;
if (existKingBomb) { singles ^= 76561193665298432; }
return (singles, shunZi, doubles, lianDui, threes, feiJi, bombs);
}
首先将玩家的牌都记录到一个long类型的codes信息中去(左移每个牌的枚举值位),之后将这个值与一个long类型的bomb进行与操作,这个bomb是炸弹,即如果是4个3就是1111,转为十进行就是15,如果将这个值左移4位就能得到11110000,这个值刚好是4个4组成的位信息。现在要统计每种类型的牌有多少张,只需要将codes与bomb作与操作得到一个long类型的temp值,再统计这个temp值中1的次数,就知道这种类型的牌有多少张了,比如:codes=1110_0101_0000(玩家手牌为黑桃4,方块4、以及3给5,没有黑桃5),如果将这个值与bomb=0000_1111_0000(即4个4)相与,即temp=codes & bomb=0000_0101_0000,再统计temp中位为1的数量,即2个1,说明当前玩家的手牌中有两张4。因此后面只要1的数量为1就记录到单牌中(如果这个牌码值还小于15就要记录到顺子中)、为2就记录到对子中(如果这个牌码值还小于15就要记录到连对中)、为3就记录到三顺和飞机中,为4就记录到炸弹中。由于王炸比较特殊,即第52位和第56位为1就是王炸,因此只要判断codes的第52位和第56位是否都为1就可以指定玩家手牌中是否有王炸。
有了上面的记录后,还要单独将飞机、顺子、连对拆出来,因为单牌中也记录了顺子的牌,飞机中还记录了三顺的牌、对子中还记录了连对的牌。要拆出来也和简单。先看下面的拆解算法:
/// <summary>
/// 将exclude中连续的部分去掉
/// </summary>
/// <param name="exclude"></param>
/// <param name="continuous">要连续几次才不进行去掉</param>
private static void ExcludeContinuous(ref long exclude, int continuous)
{
int code = 3; long bomb = 15; int count = 0; long bomb2, temp, temp2;
while (code <= 14)
{
temp = exclude & bomb;
//找到右边不为0的那个
if (temp != 0)
{
count++;
bomb2 = bomb;
//从当前不为零的位置开始统计后面连续的次数
while (true)
{
bomb2 <<= 4; //左移4位得到下一个炸弹
temp = exclude & bomb2;
if (temp != 0) { count++; }
else { break; }
}
temp2 = bomb2;
//不是顺子,将shunZi的bomb到bomb2之间的位全部置为0
if (count < continuous)
{
temp = bomb2 | bomb;
while (bomb2 != bomb)
{
bomb2 >>= 4;
temp |= bomb2;
}
exclude &= ~temp;
}
bomb = temp2; //更新到最后那个截止位置
//更新code到最新值,减一的原因是最后还会加1
code += count - 1;
}
bomb <<= 4; //左移4位得到下一个炸弹
code++;
count = 0;
}
首先确定要连续几次才进行拆除,比如:顺子要5次,飞机要2次、连对要3次。举例说明这个算法的思想:
现在给的单牌codes=1000_0100_0000_0010_0100_1000_0100_0010(从右往左依次为红桃3、梅花4、方块5、梅花6、红桃7,梅花9和方块10),要从这里面把不能组成顺子的牌去掉(即梅花9和方块10),将codes与4个3组成的bomb值相遇得到temp值,如果temp值大于0,说明当前codes中有3这个牌,此时count值加1,那么从此次开始依次像后面进行统计,再将codes与4个4相与,还是大于0,则count值再加1,继续。直到等于0,当等于0后,如果count值大于等于5,说明这个区域的牌组成的是顺子。按此法,当count值小于5时,说明这个区域的牌不是顺子,则将这个区域的位全部置为0。按此法就能从单牌中得出顺子的牌了。得到顺子的牌后,再将这个顺子的牌与原来的单牌进行异或,异或的结果就是不包含顺子的牌的单牌了。
对于飞机和连对都是同样的道理,只是count值不一样而已。
下面是如何统计出一个long值中位为1的数量的算法:
/// <summary>
/// 统计temp中位为1的数量
/// </summary>
/// <param name="temp">要统计的值</param>
/// <returns>统计出的数量</returns>
private static void CountOne(long temp, ref int count)
{
//这个算法很简单只需要每次将这个temp值的最右边的1置为0即可
while (temp > 0)
{
temp &= temp - 1;
count++;
}
}
提示算法
有了上面的分析后就可以愉快的进行下一步了。
上家有人出牌的情况
从当前玩家的手牌获取到提示出的牌,可以先分为两张情况进行讨论:①当前玩家的手牌数少于上家的出牌数;②当前玩家的手牌数多于或等于上家的出牌数。从这两种情况中可以先进行优先出与上家同一类型的牌,如果没有那么则当前玩家只有可能出炸弹。
当前玩家的手牌数少于上家的出牌数
直接看源码吧,源码的注释很全。
//2.1 如果当前玩家手牌少于上家出的牌,则只可能出炸弹了
if (cards.Count < outCards.Count)
{
//2.1.1 如果上家出的也是炸弹则只能出王炸
if (outCardsType == PokerType.Bomb && existKingBomb)
{
tipType = PokerType.KingBomb;
tipCards.Add(PokerCard.Black_Joker);
tipCards.Add(PokerCard.Red_Joker);
return;
}
//2.1.2 如果上家出的不是炸弹则只能出炸弹,取最小的炸弹
else if (result.Item7 != 0)
{
tipType = PokerType.Bomb;
int code = GetGreaterCode(ref result.Item7, 0);
tipCards.Add((PokerCard)((code - 3) * 4));
tipCards.Add((PokerCard)((code - 3) * 4 + 1));
tipCards.Add((PokerCard)((code - 3) * 4 + 2));
tipCards.Add((PokerCard)((code - 3) * 4 + 3));
return;
}
}
当前玩家的手牌数多于或等于上家的出牌数
优先出与上家牌型一致的牌。
将算法前先看几个封装的工具方法。
/// <summary>
/// 从玩家指定的牌型中中找到一个大于指定牌码的牌码
/// </summary>
/// <returns>小于0等于0都表示不存在</returns>
private static int GetGreaterCode(ref long codes, int compare)
{
int code = 3; //3、4、5、6...J、Q、K、A、2、小王、大王
long temp = 15;
while (code <= 17)
{
if ((codes & temp) > 0 && code > compare)
{
return code;
}
temp <<= 4;
code++;
}
return 0;
}
从long类型记录的牌的信息中读取到PokerCard的值的工具方法。
/// <summary>
/// 从codes中从指定的牌码数开始读取指定几个PokerCard枚举值,从指定的startCode开始读取
/// </summary>
/// <param name="skipRead">指定是否为跳读模式,跳读模式则每次只会在一种类型的牌中读一张</param>
private static void ReadPokerCard(int startCode, ref long codes, int readNum, List<PokerCard> cards, bool skipRead = false)
{
if (codes > 0 && readNum > 0)
{
long temp = 15L << (startCode - 3) * 4;
long r = codes & temp;
for (int i = startCode; i < 18; i++)
{
if (r > 0)
{
int pokerCard = (i - 3) * 4;
for (int j = 0; j < 4 && readNum > 0; j++)
{
if (((r >> pokerCard) & 1) == 1)
{
readNum--;
cards.Add((PokerCard)pokerCard);
if (skipRead) { break; }
}
pokerCard++;
}
}
temp <<= 4;
r = codes & temp;
}
}
}
/// <summary>
/// 从codes中读取指定牌码的PokerCard枚举值
/// </summary>
private static void ReadPokerCard(ref int code, ref long codes, List<PokerCard> cards)
{
//(code - 3) * 4 等于左移位数
int pokerCard = (code - 3) * 4;
long temp = 15L << pokerCard;
long r = codes & temp;
if (r > 0)
{
for (int j = 0; j < 4; j++)
{
if (((r >> pokerCard) & 1) == 1)
{
cards.Add((PokerCard)pokerCard);
}
pokerCard++;
}
}
}
/// <summary>
/// 提取出指定范围的牌码中的所有的PokerCard [startCode,endCode]
/// </summary>
private static void ReadPokerCard(int startCode, int endCode, ref long codes, List<PokerCard> cards)
{
for (int code = startCode; code <= endCode; code++)
{
ReadPokerCard(ref code, ref codes, cards);
}
}
获取连续的牌中的最小牌码和最大牌码,如:3456这个四个连续的牌中获取最小的牌码为3最大为6。334455最小为3最大为5。(这个函数会在顺子、连对、飞机的提示算法中用到)
/// <summary>
/// 从指定的连续牌中获取最小和最大值
/// </summary>
private static ValueTuple<int, int> GetContinusMinMax(int startCode, ref long codes)
{
int min = 0, max = 0;
if (codes > 0)
{
for (int code = startCode; code < 18; code++)
{
//(code - 3) * 4 等于左移位数
int pokerCard = (code - 3) * 4;
long temp = 15L << pokerCard;
long r = codes & temp;
//找到第一个数
if (r > 0)
{
min = code;
while (r > 0)
{
code++;
pokerCard = (code - 3) * 4;
temp = 15L << pokerCard;
r = codes & temp;
}
max = code - 1;
break;
}
}
}
return (min, max);
}
获取3334中的3这个牌码,444423中4这个牌码、即获取x带y或x带y对中x的值的工具函数。(会在三带、四带中用到)
/// <summary>
/// 获取三带、四带中三或四的那张牌的牌码,如:3334,则返回3; 444422,则返回4
/// </summary>
private static int GetWithCode(List<PokerCard> cards)
{
int result = 0;
int r = 0, n = 0, p, k, tmp, code, count = 0;
for (int i = 0; i < cards.Count; i++)
{
code = 3 + ((int)cards[i]) / 4;
tmp = 1 << code;
p = (r & tmp) >> code; //判断第code位上是否为1
k = (n & tmp) >> code;
count += p + k;
if (p == 1) { n |= tmp; }
if (k == 1) { n &= ~tmp; }
r |= tmp;
if (count >= 3) { result = code; break; }
}
return result;
}
单牌
只要从当前玩家的单牌的信息中找到一个比上家出的单牌的牌码值大即可。
对子
只要从当前玩家的对子牌的信息中找到一个比上家出的对子牌的牌码值大即可。
顺子
只要从当前玩家的顺子牌的信息中找到一个与上家的顺子牌的牌数一样多同时当前玩家的顺子的最小的牌的牌码值比上家的大即可满足。顺子比较有三张情况:交叉、包含、完全不相交。如:34567和3456789属于包含关系,56789和678910JQ属于交叉关系,34567和8910JQKA属于完全不相交的关系。
连对
提示原理和顺子是一样的。
飞机
飞机由于要判断带翅膀和不带翅膀的情况,如果带翅膀则要先判断当前玩家的手牌是否能够组成与上家的飞机一致的翅膀,如:上家飞机带的是3张单牌,那么当前玩家也要带3张单牌才行。带翅膀的情况比较复杂,这儿讲解感觉很麻烦,如果你感兴趣建议读源码吧。就是凑翅膀的过程要综合考虑很多情况。
不带翅膀的比较原理和顺子一致的;对于带翅膀的,先判断翅膀能否凑足,翅膀凑足后再判断飞机不带翅膀的,之后再组合在一起。
炸弹
炸弹的提示原理和单牌、对子都是一样的。
如果找不到与上家一致的牌型出,那么当前玩家只能出炸弹了。
上家没人出牌的情况
一种较为简单的提示出牌逻辑为:单牌>顺子>连对>对子>飞机>三顺>炸弹>王炸。因此先分析出当前玩家的牌的组成情况,再进行按顺序判断。(至于x带y、x带y对、飞机等等这些比较复杂的提示出牌这儿没有实现,emmmmm,源码已经有1600多行了,写累了。只不过源码很清晰的,几乎注释就写了300多行)。
/// <summary>
/// 该当前玩家出牌且上家没有人出牌时提示玩家出牌
/// </summary>
/// <remarks>注意:不会从玩家的手牌中移除提示的牌,这可能需要你自己完成这一步</remarks>
/// <param name="cards">玩家的手牌</param>
/// <param name="tipCards">存储提示要出的牌</param>
/// <returns>出牌类型</returns>
public static PokerType GetTipCards(List<PokerCard> cards, List<PokerCard> tipCards)
{
bool exitKingBomb = false;
//(1单牌、2顺子、3对子、4连对、5三张、6飞机、7炸弹)
var result = AnalysisCards(cards, ref exitKingBomb);
//出单牌
if (result.Item1 > 0)
{
ReadPokerCard(3, ref result.Item1, 1, tipCards);
return PokerType.Single;
}
//出顺子
else if (result.Item2 > 0)
{
var shunZi = GetContinusMinMax(3, ref result.Item2);
ReadPokerCard(shunZi.Item1, shunZi.Item2, ref result.Item2, tipCards);
return PokerType.ShunZi;
}
//出连对
else if (result.Item4 > 0)
{
var lianDui = GetContinusMinMax(3, ref result.Item4);
ReadPokerCard(lianDui.Item1, lianDui.Item2, ref result.Item4, tipCards);
return PokerType.LianDui;
}
//出对子
else if (result.Item3 > 0)
{
ReadPokerCard(3, ref result.Item3, 2, tipCards);
return PokerType.Double;
}
//出飞机
else if (result.Item6 > 0)
{
var feiJi = GetContinusMinMax(3, ref result.Item4);
ReadPokerCard(feiJi.Item1, feiJi.Item2, ref result.Item4, tipCards);
return PokerType.AeroplaneWithNone;
}
//出三张
else if (result.Item5 > 0)
{
ReadPokerCard(3, ref result.Item5, 3, tipCards);
return PokerType.ThreeWithNone;
}
//出炸弹
else if (result.Item7 > 0)
{
ReadPokerCard(3, ref result.Item7, 4, tipCards);
return PokerType.Bomb;
}
//出王炸
else if (exitKingBomb)
{
tipCards.Add(PokerCard.Black_Joker);
tipCards.Add(PokerCard.Red_Joker);
return PokerType.KingBomb;
}
return PokerType.None;
}