2024年1月

一、前言

在上一篇中我们使用全连接网络,来构建我们的手写数字图片识别应用,取得了很好的效果。但是值得注意的是,在实验的最后,最后我们无论把 LOSS 优化到如何低,似乎都无法在测试数据集 test data 中提高我们的识别准确度,你可以回头尝试全连接的网络连接,新增多几层 layer ,来尝试是否能把准确率提升至90%以上,而我自己本地尝试的结果就是识别的准确率只有83%。那我们能不能优化一下网络结构,来让准确度更高呢?有办法的,那就是CNN卷积神经网络。关于CNN卷积神经网络的学习,我打算分为两篇,本文主要是为了补充学习CNN所需要的前置知识,如果你了然于胸可以直接跳过。

二、前置知识

在整体介绍CNN之前,我发现要清晰,准确的理解整个CNN的过程,需要非常多的前置知识,卷积,欧拉公式,傅里叶变化,快速傅里叶变化等等,如果你能了解更底层的细节,估计你会对CNN有不一样的理解。

首先,我们先来思考一个问题,关于我们人脑是如何识别一张图片的?

我们之所以可以准确的分辨哪个是喵咪,哪个是狗狗,完全处于我们的“常识”。比如猫和狗的嘴巴不同,猫有胡须,猫爪和狗爪不一样等等,这些实际上就是相关的“特征”,我们人脑以一种我们现如今看来也不完全了解的形式,快速的将图片进行了“计算”,并提取了相关的“特征”,识别图片为猫咪 or 狗狗。

返回到我们的手写数字识别上来,我们之所以使用全连接的网络并不能完全处理好图片识别的原因,就是我们只是透过一个像素一个像素的去“看”,作为下一层网络的输入。完全没有考虑这个像素相关联的局部图像,从而缺失了识别相关联的种种特征,导致识别的准确度并没有提示。比如下面的手写图片数字 8:

这里的 8 字是在整个图片的正中央,现在如果将其倾斜一定角度,并且缩写数倍,将其放到图片的左下角位置:

这两个图片如果同时作为全连接的网络的输入,最后网络学习的超参是绝对不一样的,这里我想表达的是这两个图都是数字8,那么理应有相同(或者严谨的说是相近的)的特征向量,但是在全连接网络中单单从一个小小像素的角度来说,这会扰乱共同特征的学习,无法识别出 8 这两个团的共同之处。如果我们能通过放大区域作为输入,比如某个像素的周围区域,同时作为输入,如下:

以上就是CNN卷积神经网络的基本架构,利用局部特征来归纳学习,提高识别的准确度。

三、卷积

为了说明卷积是啥,这里举个例子。如果你经常会因为一些事情惹女朋友生气,那么女朋友的愤怒程度会根据你的刺激而呈现固定的规律。比如这样,刚开始刺激的时候,愤怒程度处于最高点,然后随着时间愤怒值衰减趋至0:

如果这个时候,每隔固定的时间比如 0.1 天就进行一次刺激,那么图像就长成这样了:

我们截取放大其中一个时间点,来看一下某一时刻的“愤怒值”要怎么求。比如下图中第四个尖峰的蓝点h处的高度,就是4个“刺激”导致的“愤怒值”在各自消退过程中的残留高度之和。为了方便理解,我们引入一个函数g(n),它代表的是一个刺激之后过了n时间单位后,女朋友的愤怒值。当 t=0.4 这个时间段的时候,那么女朋友的总愤怒值就是:h = h1 + h2 + h3 + h4

此时h1=g(0.3-0.3),h2=g(0.3-0.2),h3=g(0.3-0.1),h4=g(0.3-0),那么假设每个单位时间
Δ来一个刺激,那么对于 t 时刻,就有:(求和的过程就是“卷积”的“积”)

从数学角度来理解卷积,可以将其视为两个函数之间的运算,Δ值趋于无穷小的时候,通常表示:

四、傅里叶变换

在这里我们先从信号系统入手,通过卷积来理解接下来所要介绍的傅里叶变换。首先,我们需要记住卷积其中的最重要的一个性质:
时域的卷积等于频域相乘,频域的卷积等于时域相乘。

假设我们这里有两种信号,x(t) 和 y(t) 如果都是复杂函数的话,理论上都可以如下的多项式结构来进行表达,其实就是泰勒展开:

根据卷积的性质, x(t) 和 y(t) 两者的乘积可以表达为,x(t) 的多项式 a(n) 和 y(t) 的多项式 b(n) 的卷积,所以我们只要对多项式进行操作就必定能知道,x(t)乘以y(t)的新函数的表达式。比如上面的系数可以用如下方法进行求解:

上面的结论是不是很完美,但是有个问题,对于所有的函数确实可以泰勒展开来表示,但是我们在现实中操作确实非常困难的,因为我们很难恰当的找到对应的N阶多项式系数,换句话说时域名上很难算,所以我们需要把上面的多项式转变一下,变成如下的形式,这里转换指的是线性时不变系统,都可以看做是若干个 sin 和 cos 进行叠加,具体可以参考这个视频:
传送门

可能咋一看,你会发出疑惑:这是啥?? 如果我告诉你这其实就是欧拉公式,你会不会想起来什么?我刚开始看的时候,完全有种死去的高数正在袭击我的感觉,为了快速的理解欧拉公式,我找到了这个视频,看完你会有种豁然开朗的感觉:
传送门

接下来我让 x(t) 和 x(t) 自身相乘:

发现没有,当求 x(t) 的 2 次方的时候,非常容易表示,就只需要在实部中参数乘以2,和虚部中参数乘以2。同理可得:

因此,我们只要将上诉的多项式变成 e 的虚部次方的形式,那不就可以完美解决多项式相乘的求解系数的问题了吗?假设我们有两个信号 g(t) 和 f(t),表示为:

则两个函数的乘积为:

上诉的过程会计算一系列的三角函数,非常复杂,但是如果我们能够通过欧拉公式将 cos 和 sin 转变成 e 的虚部次方的形式,即:

我们把上面的函数写成如下的形式:

我们可以得到这两个函数的 f(t) 和 g(t) 的乘积如下:

这样我们就可以很快的利用卷积获取到此函数的多项式系数,然后再把 e 的虚部次方带入:

如上我们就完成了一次在时域信号复杂,难以相乘的情况下,通过将时域信号分解成频域信号进行卷积,就能得到我们想要的结果。

五、FFT快速傅里叶在CNN图像卷积上的使用

CNN中的卷积核使用里,就是通过“采样”过滤的方式,来通过频域上的“滤波”操作,然后反映成时域上的“特征”,所以卷积核也叫滤波器 Filter 之类的。

所以在 CNN 实现中,首先需要解决的是,我们如何快速的让原图片和卷积核进行“卷积”操作。如果单纯的像我们求解多项式系数一样,去求卷积的结果,那么起码需要 O(n^2) 的时间复杂度,就等于下图一样。每张图我们都需要让卷积核走动遍历去计算,但是如果是一张大图片,在比较庞大的训练数据量的时候,这计算量是非常恐怖的,那么这时 FFT 快速傅里叶就登场了,我们用这个算法来进行优化加速。

首先,我们需要确定的是,怎么把一张图片给变成一个频域上的函数?我们都知道图像每个像素都有可以用RBG值来进行表示,并且一张彩色的图片可以变成一个三维的矩阵,现在用一张灰色的图片来举例,也就是说它只有一维,那么这一张图片可以想象成如下的这样一个表达形式。

然后我们再分别以图像的x轴、y轴(或者说矩阵的第一维和第二维)为横轴,以灰度值为纵轴,这样就可以得到两个函数,分别表示灰度值在x轴和y轴的变化情况,如下图

根据傅里叶变换,时域上的信号可以变成频域上的若干个信号的叠加,那么图片在 y-z 面和 x-z 面上就可以分解成两个不同的频域信号了。如果你能在脑海中形成如下的图,且频域分别是 y-z 和 x-z 平面上,那么你应该很容易理解

比如我们使用一张图片,利用代码把它的傅里叶变换成频域上的表示就是这样:

图像的频谱图显示了图像在不同空间频率上的分布情况。这对于理解图像的纹理、边缘、模式等特征是很有帮助的,看频谱图可以从以下几个方面来看:

  • 低频成分
    : 位于频谱图中心的区域通常表示图像的低频成分。低频成分包含图像中较大且变化缓慢的部分,比如背景信息。
  • 高频成分
    : 位于频谱图边缘的区域表示图像的高频成分。高频成分对应图像中边缘、纹理等变化较快的部分。
  • 水平和垂直方向
    : 在频谱图中,水平方向表示图像的水平频率,垂直方向表示图像的垂直频率。这可以帮助分析图像中的水平和垂直特征。
  • 对角线
    : 对角线方向上的成分表示图像中具有斜向特征的部分。

比如如果图像在不同位置出现,他们的频谱图是不变的,如果旋转一定角度,在频谱上也能直观的发现:

所以,说了这么多,你大概能了解到傅里叶变换在图像处理中是非常重要的,也直观的清楚看到了一个图片进行傅里叶变换之后变成了什么样子。但是我们怎么获取到对应的频域函数的各个系数呢?也就是说我们怎么让上图中的图片,变成一个多项式相加的形式。然后让原图和卷积核的频域表达进行点值相乘。

假设我们取 n 个互不相同的值,x=(x0, x1, x2,...., xn-1) ,用这些点加上系数就能单独的代表某个图像的函数,比如我们称之为 A(x),我们可以通过一组点来唯一确定此函数,如果我们知道结果 A(x) ,并且找到 n 个不同的点我们就能倒推出这个函数的表达式了:

A(x) = { (x0, A(x0)), (x1, A(x1)), (x2, A(x2)), ... (xn-1, A(xn-1)) }

也就是,问题变成了求解 n 阶线性方程的问题:

如果是二维的图片那就是这样的形式,只是为了方便后续的推到,现在我们只讨论一维的情况:

使用 FFT 和 IFFT 就能快速的找到一组不相同的点,对应上方左边的矩阵,首先登场的是 n
次单位根

(这叫做“旋转因子”, k 是旋转因子的指数),其实这个值就是上面一节我们介绍的 e 的虚部次方,这样的数有 n 个:

k = 1,2,3....n,

也就是说带入旋转因子之后,我们上面的函数就可以写成如下的表达式:


表达式(1)

这里注意的是,利用旋转因子的两个性质:

1.

2. 如果 n 是偶数,那么:

我们把表达式(1)变成这样,分为奇数和偶数两个部分:

带入旋转因子:


表达式(2)

我们让 k 值变成 k+n/2 时:

根据旋转因子的性质一和性质二:

所以对于奇数和偶数项,我们就可以得出以下的表达式:

所以只要我们就得到了“前一半”的结果;只要将等式右边中间的符号改成减号,就可以得到“后一半”的结果。所以只要分治的对半处理,就能快速的获得这个函数的表达式了。下面我们再将旋转因子的 n 个点带入到点值矩阵里面去:


表达式(3)

那么已知这个图片的单单灰度值的输出,也就是一个二维的y矩阵,我们就能求出这个图片函数的系数对吧,如下图:

现在我告诉你这个矩阵的-1次方,非常容易获得,那就是乘以 1/n:


表达式(4)

发现了吗?表达式(3)和(4),使用的是几乎是同一个旋转因子矩阵,只需要在旋转因子上进行-1次方的变换,所以所以我们只要构造一个这样的矩阵,就能快速的完成 FFT 和 IFFT 的计算。

我发现当我看公式到这里,脑子已经完全变成浆糊了,我下面尝试一下将CNN中如果使用了FFT的话应该是怎么加速卷积计算的,水平有限,如果里面有错误,麻烦读者给予指正~

六、尝试手工推导CNN中使用FFT

现在有一张 1024x1024 的原图,并且使用 3x3 的一个卷积核,对其进行卷积操作。那么令 F(x) 为原图的函数表达,因为有 1024x1024 个像素点,所以我们是一个 1048576-1 阶的函数,令 G(x) 为卷积核的函数表达,因为是 3x3,所以这是个 9-1 阶的函数,如下:

F(X) = a0 + a1·X + a2·X^2 ..... + a1048576·X^1048575

G(X) = b0  + b1·X + b2·X^2.....+ b8·X^8

第二步,对输入的两个多项式 A(x) 和 B(x) 进行零填充: 将两个多项式的次数扩展到 2n-1,n 是足够大的整数,使得  n 大于等于 A 和 B 的次数之和。这可以通过在系数数组的末尾添加零来实现。比如这里 G(x) 就扩充变成:

G(X) = b0  + b1·X + b2·X^2.....+ b8·X^8 + 0·X^9 + .....0·X^1048575

第三步,把 F(x) 和 G(x) 变成点值表示,用复数旋转因子带入,然后使用 FFT,递归分治带入每一个输入值,然后就能得到 F(X)·G(X) 的值了。选择 2n-1 的单位复数根

,其中

,通常选择是

,其中 i 是虚数单位,这个选择是为了保证 2n-1 个根是不一样的。

F(X) = a0 + a1·Wn + a2·Wn ..... + a1048576·Wn

G(X) = b0  + b1·Wn + b2·Wn.....+ b8·Wn + 0·Wn + .....0·Wn

在伪代码中是这样子递归来求FFT的:

def fft_recursive(a, w_n):
n
=len(a)if n == 1:returna

# 分割为奇次和偶次部分
a_even
= a[0::2]
a_odd
= a[1::2]

# 递归计算
y_even
= fft_recursive(a_even, w_n**2)
y_odd
= fft_recursive(a_odd, w_n**2)

# 合并结果
y
= [0] *n
w
= 1 for k in range(n//2): t = w *y_odd[k]
y[k]
= y_even[k] +t
y[k
+ n//2] = y_even[k] - t w *=w_nreturn y

将 2n-1 个复数根 Wn 逐个带入函数,你会发现上面的式子用矩阵来表示就是上一节 表达式(3) 里面的表示,对于 F(x) 和 G(x)

上面的 y0 到 yn-1 实际上就是我们的图片也就是 1024*1024 个像素点,利用 表达式(4) 是可以反推出 a0 到 an-1 的系数的,然后我们这里需要计算 F(X)·G(X) 的值,也就是,当求出系数 a0~an-1 和 b0~bn-1 之后,我们带入到函数 F(X) 和 G(X) 就能针对每个点值进行单独的乘积了,比如对于第一个复数根:

Feature(W1) = F(W1) · G(W1)

一共选了 2n-1 个复数根,逐个带入就可以获得特征图的傅里叶变换函数,然后获取到 2n-1 个结果之后,实际上我们就获取到了这个图片的傅里叶输出了,然后再经过一次 IFFT 那就是 表达式(4),我们就能获取到对应的 Featrue 特征图函数的表达式了。详细的过程你可在脑子里过一遍第四节中的第一张图。

得到特征图的函数表达,也就意味着我们可以根据 LOSS 不断修正它的系数,从而筛选出最能符合我们分类要求的函数,这样整个CNN的逻辑就完全可以串联起来了。

七、总结

本篇花了非常多的时间,也查阅了很多的资料才大概把卷积的整个底层原理,用图文的方式展示出来,因为水平有限,如果里面有任何不对的地方,麻烦读者指正。另外非常感谢下面的各位up主的贡献,我只是小小的知识搬运工。

Reference

[1] 请问为什么fft可以加速卷积运算? - JieShi的回答 - 知乎
https://www.zhihu.com/question/394657296/answer/2329522108

[2] https://zhuanlan.zhihu.com/p/454090354

[3] https://blog.csdn.net/qq_37149421/article/details/103137332

[4] https://zhuanlan.zhihu.com/p/526705694

[5] 【【官方双语】那么……什么是卷积?】 https://www.bilibili.com/video/BV1Vd4y1e7pj/?share_source=copy_web&vd_source=4d1e9766ee0260272093180a125d35ee

[6] https://zhuanlan.zhihu.com/p/454090354

我在前面随笔《
在Winform系统开发中,对表格列表中的内容进行分组展示
》,介绍了Winform程序中对表格内容进行了分组的展示,在WPF应用中,同样也可以对表格的内容进行分组展示,不过处理方式和Winform有所差异,本篇随笔同样基于SqlSugar开发框架的基础上,实现在WPF应用中实现DataGrid的分组显示,以及嵌套明细展示效果。

1、回顾Winform的表格分组展示效果

对于常规的二维表格数据,如下所示。

我们根据其中一个字段对表格数据进行分组展示,这样更方便用户对特定数据的归类展示处理。

Winform的界面中,我们基于DevExpress的GridView控件对数据进行分组展示,其中代码如下所示。

//增加汇总字段和显示
var gridView1 = this.winGridViewPager1.gridView1;if(checkGroup.Checked)
{
this.winGridViewPager1.ShowLineNumber = false;
gridView1.IndicatorWidth
= 0;
gridView1.OptionsView.ShowGroupExpandCollapseButtons
= true;//显示折叠的分组 gridView1.OptionsView.AllowCellMerge = true; //允许合并字段 gridView1.OptionsView.GroupDrawMode =GroupDrawMode.Standard;

gridView1.GroupSummary.Clear();
gridView1.Columns[
"Category"].GroupIndex = 0;//对类别进行分组展示 var item = newGridGroupSummaryItem();
item.FieldName
= "Id";
item.DisplayFormat
= "(合计数量 = {0:n})";
item.SummaryType
= DevExpress.Data.SummaryItemType.Count;//Sum、Average等 gridView1.GroupSummary.Add(item);
gridView1.ExpandAllGroups();
}
else{
gridView1.GroupSummary.Clear();
this.winGridViewPager1.ShowLineNumber = true;
gridView1.OptionsView.AllowCellMerge
= false;
}

我们可以看到,只需要实现对 GroupSummary的添加处理即可实现汇总效果的展示。

2、在WPF应用中实现DataGrid的分组显示

在WPF应用中,数据的显示通过DataGrid控件进行展示,默认是没有分组效果的,如果需要分组效果,需要定制表格的分组样式才能达到效果。

我们同样基于SqlSugar开发框架的基础上,基于原料表的数据展示,实现在WPF应用中实现DataGrid的分组显示,实现的效果如下所示。

我们这里根据【类别】字段来进行分组统一,其中分组样式在XAML上定义如下所示。

<DataGrid.GroupStyle>
    <GroupStyle>
        <GroupStyle.ContainerStyle>
            <StyleTargetType="{x:Type GroupItem}">
                <SetterProperty="Template">
                    <Setter.Value>
                        <ControlTemplateTargetType="{x:Type GroupItem}">
                            <ExpanderIsExpanded="True">
                                <Expander.Header>
                                    <StackPanelOrientation="Horizontal">
                                        <TextBlockFontWeight="Bold"Text="类别:" />
                                        <TextBlockFontWeight="Bold"Text="{Binding Name}" />
                                        <TextBlockText="{Binding ItemCount, StringFormat='    (合计数量 = {0} )'}" />
                                    </StackPanel>
                                </Expander.Header>
                                <ItemsPresenter/>
                            </Expander>
                        </ControlTemplate>
                    </Setter.Value>
                </Setter>
            </Style>
        </GroupStyle.ContainerStyle>
        <GroupStyle.HeaderTemplate>
            <DataTemplate>
                <TextBlockFontWeight="Bold"Text="{Binding Name}" />
            </DataTemplate>
        </GroupStyle.HeaderTemplate>
    </GroupStyle>
</DataGrid.GroupStyle>

如果我们需要控件的虚拟化处理(提高显示性能),那么设置下虚拟化处理属性即可。

<DataGridx:Name="grid"Grid.Row="1"hc:DataGridAttach.ShowRowNumber="True"AutoGenerateColumns="False"HeadersVisibility="All"IsReadOnly="True"ItemsSource="{Binding ViewModel.Items}"MouseDoubleClick="DataGrid_MouseDoubleClick"RowHeaderWidth="60"SelectionChanged="DataGrid_SelectionChanged"SelectionMode="Extended"VerticalScrollBarVisibility="Auto"VirtualizingPanel.IsVirtualizing="True"
VirtualizingPanel.IsVirtualizingWhenGrouping="True"
>

表格的字段,如没有特殊处理,我们用常用的定义效果即可,如下所示。

<DataGrid.Columns>
    <DataGridTextColumnWidth="SizeToCells"MinWidth="100"Binding="{Binding Id}"Header="ID编号" />
    <DataGridTextColumnWidth="SizeToCells"MinWidth="100"Binding="{Binding Category}"Header="类别" />
    <DataGridTextColumnWidth="SizeToCells"MinWidth="100"Binding="{Binding Code}"Header="原料编码" />..............</DataGrid.Columns>

对于分组的效果处理,我们后台的C#代码也需要进行一定的适应处理,如下所示。

我们采用MVVM的模式处理查询操作,获得数据并转换为CollectionView后,设置分组信息即可,如下代码所示。

/// <summary>
///查询处理/// </summary>
[RelayCommand]private asyncTask Search()
{
//查询获得视图模型的数据 await this.ViewModel.SearchCommand.ExecuteAsync(null);//把数据集合转换为CollectionView,并设置其GroupDescriptions var viewSource = CollectionViewSource.GetDefaultView(this.ViewModel.Items);if (this.ViewModel.IsUseGroup)
{
viewSource.GroupDescriptions.Clear();
viewSource.GroupDescriptions.Add(
new PropertyGroupDescription("Category"));
}
this.grid.ItemsSource =viewSource;
}

这样就是写了数据的显示和分组处理了。

上面的SearchCommand就是视图模型基类函数的查询获得数据的处理方式。

    /// <summary>
    ///触发查询处理命令/// </summary>
    /// <returns></returns>
[RelayCommand]public virtual asyncTask Search()
{
//切换第一页 this.PagerInfo.CurrentPageIndex = 1;//查询更新 awaitGetData();
}

GetData也是视图模型基类函数的通用处理方式,通过分页和条件信息,获得对应的数据记录。

    /// <summary>
    ///根据分页和查询条件查询,请求数据/// </summary>
    /// <returns></returns>
    public virtual asyncTask GetData()
{
//转换下分页信息 ConvertPagingInfo();var result = await service.GetListAsync(this.PageDto);if (result != null)
{
this.Items = result.Items?.ToList();this.PagerInfo.RecordCount =result.TotalCount;
}
}

3、在WPF应用中实现嵌套明细展示效果

我们这里继续介绍另外一个DataGrid的效果,通过明细展示的方式显示其中一条记录相关联的表格信息,有时候也可以看成是主从关联信息。

单我们单击其中一条记录的时候,展示嵌套表格,展示详细的明细信息,如下效果所示。

这个效果主要是通过定义DataGrid.RowDetailsTemplate进行明细内容的处理的。例如我们定义明细的模板如下所示,其实也就是显示另外一个表格信息。

<DataGrid.RowDetailsTemplate>
    <DataTemplate>
        <DataGridMaxHeight="500"hc:DataGridAttach.ShowRowNumber="True"AlternatingRowBackground="LightBlue"AutoGenerateColumns="False"HeadersVisibility="Column"IsReadOnly="True"RowHeaderWidth="60"ScrollViewer.VerticalScrollBarVisibility="Auto"SelectionUnit="FullRow">
            <DataGrid.Columns>
                <DataGridTextColumnMinWidth="120"Binding="{Binding Name}"Header="显示名称" />
                <DataGridTemplateColumnWidth="80"Header="图标">
                    <DataGridTemplateColumn.CellTemplate>
                        <DataTemplate>
                            <ui:SymbolIconWidth="32"FontSize="32"Symbol="{Binding Icon}" />
                        </DataTemplate>
                    </DataGridTemplateColumn.CellTemplate>
                </DataGridTemplateColumn>
                <DataGridTextColumnWidth="80"Binding="{Binding Seq}"Header="排序" />
                <DataGridTextColumnWidth="100"Binding="{Binding FunctionId}"Header="功能ID" />
            </DataGrid.Columns>

        </DataGrid>
    </DataTemplate>
</DataGrid.RowDetailsTemplate>

Xaml的结构如下所示。

另外,我们在视图模型中除了定义一级的数据记录外,还定义一个嵌套记录集合对象,如下所示。

        /// <summary>
        ///编辑的数据列表/// </summary>
[ObservableProperty]private ObservableCollection<MenuInfo>?menuItems;/// <summary>
        ///指定父级的子级数据列表/// </summary>
[ObservableProperty]private ObservableCollection<MenuInfo>? detailItems;

这样我们在行选择变化的时候,重新获得明细的记录,然后绑定显示事件即可,如下代码所示。

   /// <summary>
   ///记录行变化的时候,触发明细记录的获取处理/// </summary>
   private async void DataGrid_SelectionChanged(objectsender, SelectionChangedEventArgs e)
{
var datagrid = sender asDataGrid;if (datagrid != null)
{
var item = datagrid!.SelectedItem asMenuInfo;if (item != null)
{
awaitViewModel.GetDetailList(item.Id);
}
}
}

而在页面初始化的时候,我们可以构造一个事件,用来绑定明细记录的绑定显示处理。

    //明细记录可见性变化的时候,触发数据的绑定处理事件
    this.grid.RowDetailsVisibilityChanged += (s, e) =>{var datagrid = s asDataGrid;if (datagrid != null)
{
var DetailsDataGrid = e.DetailsElement asDataGrid;if(DetailsDataGrid != null)
{
DetailsDataGrid.ItemsSource
=viewModel.DetailItems;
}
}
};

这样就可以正常的显示嵌套明细的记录了。

一:背景

1. 讲故事

前些天微信上有位朋友找到我,说他的程序偶发崩溃,分析了个把星期也没找到问题,耗费了不少人力物力,让我能不能帮他看一下,给我申请了经费,哈哈,遇到这样的朋友就是爽快,刚好周二晚上给调试训练营的朋友分享
GC标记阶段
相关知识,而这个dump所展示的问题是对这块知识的一个很好的巩固,接下来我们开始分析吧。

二:WinDbg分析

1. 为什么会崩溃

要想找到崩溃原因,还是用老命令
!analyze -v
,输出如下:


0:005> !analyze -v
CONTEXT:  (.ecxr)
eax=063ce258 ebx=07b90000 ecx=0063552e edx=0063552e esi=03070909 edi=03070909
eip=71954432 esp=063ce220 ebp=063ce23c iopl=0         nv up ei pl nz na pe nc
cs=0023  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00010206
clr!WKS::gc_heap::mark_object_simple+0x12:
71954432 8b0f            mov     ecx,dword ptr [edi]  ds:002b:03070909=????????
Resetting default scope

EXCEPTION_RECORD:  (.exr -1)
ExceptionAddress: 71954432 (clr!WKS::gc_heap::mark_object_simple+0x00000012)
   ExceptionCode: c0000005 (Access violation)
  ExceptionFlags: 00000001
NumberParameters: 2
   Parameter[0]: 00000000
   Parameter[1]: 03070909
Attempt to read from address 03070909

STACK_TEXT:  
063ce23c 719543fc     063ce258 0a76cc88 71954260 clr!WKS::gc_heap::mark_object_simple+0x12
063ce25c 71950b62     0a76cc88 063cec88 00000000 clr!WKS::GCHeap::Promote+0xa8
...
063cec28 71950fa3     71950da0 063cec40 00000500 clr!Thread::StackWalkFrames+0x9d
063cec4c 7195103e     063cec88 00000002 00000000 clr!standalone::ScanStackRoots+0x43
063cec68 71954038     0079cb88 063cec88 00080101 clr!GCToEEInterface::GcScanRoots+0xdb
063cecc0 71953225     00080101 00000000 00000001 clr!WKS::gc_heap::mark_phase+0x17e
063cece0 7195355b     71f75da0 00000000 00000001 clr!WKS::gc_heap::gc1+0xae
063cecf8 71953665     71f75fb4 71f75fb4 00000000 clr!WKS::gc_heap::garbage_collect+0x367
063ced18 7195376a     00000000 00000000 71f75fb4 clr!WKS::GCHeap::GarbageCollectGeneration+0x1bd
...

从卦中信息看,当前执行流处于GC标记阶段,并且是在各个线程栈上寻找用户根,在寻找的过程中踩到了坏内存,接下来需要捋一下是什么逻辑踩到的,可以用 u 反汇编一下。


0:005> u WKS::gc_heap::mark_object_simple
clr!WKS::gc_heap::mark_object_simple:
71954420 55              push    ebp
71954421 8bec            mov     ebp,esp
71954423 83ec18          sub     esp,18h
71954426 8b4508          mov     eax,dword ptr [ebp+8]
71954429 57              push    edi
7195442a 8b38            mov     edi,dword ptr [eax]
7195442c 89bde8ffffff    mov     dword ptr [ebp-18h],edi
71954432 8b0f            mov     ecx,dword ptr [edi]
...

从汇编逻辑看,这是将方法的第一个参数进行解引用,参考 coreclr 的源码。


void gc_heap::mark_object_simple(uint8_t** po THREAD_NUMBER_DCL)
{
	uint8_t* o = *po;

	if (gc_mark1(o))
	{
        ...
	}
}

结合C++代码,
edi=03070909
就是上面的o,也就是需要标记的托管对象,但现在这个 o 是一个坏对象,那为什么会坏掉呢?

2. 为什么 o 坏掉了

按照过往经验肯定是托管堆损坏了,可以用
!verifyheap
观察下。


0:005> !verifyheap
No heap corruption detected.

从卦中看,我去,托管堆居然是好的,过往经验在这个dump里被击的粉碎,接下来要往哪里突破呢? 可以观察下这个托管地址和当前的托管segment在空间距离上的特征,命令输出如下:


0:005> !address 03070909

Usage:                  <unknown>
Base Address:           02ca2000
End Address:            036f0000
Region Size:            00a4e000 (  10.305 MB)
State:                  00002000          MEM_RESERVE
Protect:                <info not present at the target>
Type:                   00020000          MEM_PRIVATE
Allocation Base:        026f0000
Allocation Protect:     00000004          PAGE_READWRITE

0:005> !eeheap -gc
Number of GC Heaps: 1
generation 0 starts at 0x06ca7a7c
generation 1 starts at 0x06b91000
generation 2 starts at 0x026f1000
ephemeral segment allocation context: none
 segment     begin  allocated      size
026f0000  026f1000  02c98f8c  0x5a7f8c(5930892)
06b90000  06b91000  0732b3d0  0x79a3d0(7971792)
Large object heap starts at 0x036f1000
 segment     begin  allocated      size
036f0000  036f1000  03c78da0  0x587da0(5799328)
Total Size:              Size: 0x12ca0fc (19702012) bytes.
------------------------------
GC Heap Size:    Size: 0x12ca0fc (19702012) bytes.

0:005> !address

  BaseAddr EndAddr+1 RgnSize     Type       State                 Protect             Usage
-----------------------------------------------------------------------------------------------
...
+  26f0000  2ca2000   5b2000 MEM_PRIVATE MEM_COMMIT  PAGE_READWRITE                     <unknown>  [..........o.....]
   2ca2000  36f0000   a4e000 MEM_PRIVATE MEM_RESERVE                                    <unknown>  
...

说实话,有经验的朋友看到这卦中信息马上就知道是怎么回事了,步骤大概是这样的。

  • 03070909 曾经实打实的分配在 SOH 上
  • GC 触发后,03070909 所在的 segment 被收缩,同时对象被移走。
  • 但不知为何,线程栈还保留了这个老地址 03070909,而不是新地址

出现这种情况的原因,大多是 C# 和 C++ 交互时没有把 03070909 给固定住(GCHandle.Alloc),导致GC触发对象移动之后,会存在两种情况的崩溃。

  1. C++ 层面的崩溃:因为此时的C++拿的地址不再有效了,导致在非托管层崩溃。

  2. CLR 层面的崩溃:线程如果在C++层面僵持,托管层GC触发时会误认为这个无效的地址还是一个有效的对象,进而在标记阶段导致程序崩溃。

有些朋友可能被我说懵了,画个简图如下:

由于这个dump属于第二种崩溃,即存在僵死的线程,接下来就是想办法找到这个线程。

3. 僵死的线程在哪里

如果你了解GC标记阶段的底层运作,我相信你很容易找出这个答案的,对,只需要找到 ScanStackRoots 函数的第一个参数即可,参考代码如下:


void GCToEEInterface::GcScanRoots(promote_func* fn, int condemned, int max_gen, ScanContext* sc)
{
	Thread* pThread = NULL;
	while ((pThread = ThreadStore::GetThreadList(pThread)) != NULL)
	{
		ScanStackRoots(pThread, fn, sc);
	}
}

接下来上 windbg 在崩溃的线程栈上实操一下。


0:005> kb 8
 # ChildEBP RetAddr      Args to Child              
00 063ce23c 719543fc     063ce258 0a76cc88 71954260 clr!WKS::gc_heap::mark_object_simple+0x12
01 063ce25c 71950b62     0a76cc88 063cec88 00000000 clr!WKS::GCHeap::Promote+0xa8
02 063ce274 71951a35     063cec40 0a76cc88 00000000 clr!GcEnumObject+0x37
03 063ce5d8 71950e6f     063ce920 063ce870 00000000 clr!EECodeManager::EnumGcRefs+0x72b
04 063ce628 717bfaa4     063ce650 063cec40 71950da0 clr!GcStackCrawlCallBack+0x139
05 063ce8f4 717bfbaa     063ce920 71950da0 063cec40 clr!Thread::StackWalkFramesEx+0x92
06 063cec28 71950fa3     71950da0 063cec40 00000500 clr!Thread::StackWalkFrames+0x9d
07 063cec4c 7195103e     063cec88 00000002 00000000 clr!standalone::ScanStackRoots+0x43

0:005> dp 063cec88 L1
063cec88  08debbf8

0:005> !t
ThreadCount:      30
UnstartedThread:  0
BackgroundThread: 29
PendingThread:    0
DeadThread:       0
Hosted Runtime:   no
                                                                         Lock  
       ID OSID ThreadOBJ    State GC Mode     GC Alloc Context  Domain   Count Apt Exception
       ...
       30   26 3e98 08debbf8     2b220 Preemptive  00000000:00000000 0079cb88 0     MTA 
       ...

从卦中看,30号线程就是我苦苦寻找的僵死线程,接下来赶紧切过去看看,果然发现了C++的函数
xxx.Driver.xxx
,由于私密性,我就模糊一下了哈。


0:030> ~30s
eax=00000000 ebx=08debbf8 ecx=00000000 edx=00000000 esi=00000000 edi=00000244
eip=77872aac esp=0a76c9fc ebp=0a76ca6c iopl=0         nv up ei pl nz na pe nc
cs=0023  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00000206
ntdll!NtWaitForSingleObject+0xc:
77872aac c20c00          ret     0Ch
0:030> !clrstack 
OS Thread Id: 0x3e98 (30)
Child SP       IP Call Site
0a76cc18 77872aac [InlinedCallFrame: 0a76cc18] 
0a76cc0c 00aa8047 DomainBoundILStubClass.IL_STUB_PInvoke(UInt32, xxx ByRef)
0a76cc18 00aa6c67 [InlinedCallFrame: 0a76cc18] xxx.Driver.xxx(UInt32, xxx ByRef)
0a76ccc0 00aa6c67 xxx.Driver.xxxFault(UInt32, System.String)
...

既然发现了C++方法,最后还剩一个疑问,就是此时的03070909真的在非托管层吗?这个可以通过搜索它的线程栈地址。


0:030> s-d poi(@$teb+0x8) poi(@$teb+0x4) 03070909
0a76cc88  03070909 728f5d01 68d8c642 5c654b42  .....].rB..hBKe\

从代码中可以看到确实是在
xxx.Driver.xxxFault
方法里传给了C++,有了这些信息接下来就是告诉朋友,重点关注下这个方法,捋一下逻辑。

三:总结

说实话这个dump分析起来还是有一定难度的,它考验着你对GC标记阶段玩法的底层理解,即使这位朋友是C#编程高手,分析了个把星期找不出问题是能够理解的,毕竟术业有专攻,很开心的是这位朋友因此加了.NET高级调试训练营,哈哈,以dump会友。

图片名称

Linux Shell 脚本入门教程

Linux Shell 脚本是一种强大的工具,它允许您自动化日常任务和复杂操作。在本教程中,我们将逐步介绍几个实用的 Shell 脚本示例。每个示例都将详细说明,以便即使是初学者也能轻松理解和应用。

1. 基础 Shell 脚本

示例 1: "Hello World"

每个编程学习之旅都从 "Hello World" 开始。创建一个名为
hello_world.sh
的文件,并输入以下内容:

#!/bin/bash
echo "Hello World"

运行脚本:

bash hello_world.sh

这个脚本非常简单,它使用
echo
命令来打印 "Hello World"。

示例 2: 读取用户输入

接下来,我们编写一个脚本来读取用户输入。

创建
user_input.sh
并输入以下内容:

#!/bin/bash
echo "What is your name?"
read name
echo "Hello, $name!"

运行并根据提示输入您的名字:

bash user_input.sh

此脚本使用
read
命令来获取用户输入,并将其存储在变量
name
中,然后打印出来。

2. 条件语句

示例 3: 简单的 If-Else

创建
check_number.sh

#!/bin/bash
echo "Enter a number:"
read number
if [ $number -gt 10 ]; then
    echo "The number is greater than 10."
else
    echo "The number is less than or equal to 10."
fi

这个脚本检查用户输入的数字是否大于 10。

3. 循环

示例 4: For 循环

创建
for_loop.sh

#!/bin/bash
for i in {1..5}; do
    echo "Looping ... number $i"
done

此脚本将打印数字 1 到 5。

示例 5: While 循环

创建
while_loop.sh

#!/bin/bash
counter=1
while [ $counter -le 5 ]; do
    echo "Counter: $counter"
    ((counter++))
done

这个脚本使用 while 循环,打印 1 到 5 的数字。

4. 函数

示例 6: 基础函数

创建
greeting_function.sh

#!/bin/bash
function greet {
    echo "Hello, $1!"
}

echo "Enter your name:"
read name
greet $name

这个脚本定义了一个函数
greet
,然后使用用户输入的名字调用它。

总结

Shell 脚本是一种强大且灵活的工具,可以帮助您自动化 Linux 系统上的许多任务。通过这些基础示例,您可以开始构建自己的脚本来简化日常工作。记住,实践是最好的学习方法,不要害怕尝试和犯错!

转载请注明 原文链接 :
https://www.cnblogs.com/Multya/p/17988499
repo:
https://github.com/Satar07/AI_GoBang_Public
欢迎来star

程序外观

主界面

image-20231226211238733

游戏界面

image-20231226211328343

胜利界面

image-20231226211544596

操作方式

  • 鼠标点按(符合直觉的)
  • 方向左键:撤回(隐藏功能 不提示用户)
  • 方向右键:电脑帮忙下一个子(在人机对战的情况下)(隐藏功能 不告诉用户)

设计

整个设计采用类来划分功能

image

  • Application
    实现了对整个应用程序的生命管理,包括对窗口初始化的执行和窗口关闭的类的销毁
  • Scene
    实现对整个场景的绘画任务,是程序运作的核心部位,我将仔细讲解:
    • 它首先接受来自Application的更新和绘画的任务
    • 然后在自身管理的对象池里有优先级的更新对象,同时收集event和实时添加对象
    • 遍历完后开始处理event,将控制权移交给eventManager,同时接受来自eventManager的修改对象的请求
    • 处理完event后就开始按顺序绘画
  • EventManager
    是一个处理event的函数,实现统一化管理决策,内部指令具有优先级保证功能的正确性
  • ChessManager
    是一个管理棋盘的的类,主要储存一系列棋盘的状态方便scene修改对象和帮助eventManager决策
  • ChessEngine
    是主要的游戏引擎,所有需要对棋子进行计算分数或者统计信息的操作都在这里完成
  • AC Engine
    是AC自动机算法的引擎,处理多模式字符串匹配加速估值
  • GameObject Pool
    里面的所有对象都继承自
    GameObject
    具有可绘画和可更新的特征,其中UI借用了第三方库RAYGUI完成UI的绘制
  • Benchmark
    是测试用类,用来统计每个函数的用时来优化程序结构

实现

框架的搭建

最初的想法和现在差不多,考虑游戏围绕update&draw运行,不过event的解耦始终非常困难,因为其中的决策又要用到很多棋盘的信息,不能只靠一个event触发就无脑运行一个功能(如悔棋)

于是一开始设计成Scene决策和绘画一体的模式

void Scene::Update() {
    if (!IsWindowsStatic) {//窗口能获取信息
        if (cnt % 2) {//到电脑下了
            ...
            return;
        }

        if (IsMouseButtonPressed(MOUSE_BUTTON_LEFT)) {
            ...

            if (SomeoneWin(roundVec)) {
                m_gameObjects.push_back(new Message(cnt % 2 ? "P1 Win!!" : "P2 Win!!"));
                IsWindowsStatic = true;
            }
            return;
        }

    }
}

很明显,非常混乱不利于debug,于是小修一下包装了功能

void Scene::Update() {
    if (IsWindowsStatic) return;
    if (isWantToCancel()) {
        cancelOneStep();
        if (haveComputerHere()) {
            cancelOneStep();
        }
    }
    if (isWantToSwitchPlayer()) {
        switchPlayer();
    }
    if (isComputerNow()) {
        computerDownOneStep();
    }
    if (isHunanNow() and isPlayerClickOnBoardAndValid()) {
        humanDownOneStep();
    }
    if (someoneWin()) {
        IsWindowsStatic = true;
        showWinMessage();
    }
}

这个在单单棋盘界面的时候是没有问题的,但是一旦加进来主菜单的时候就会非常混乱了

所以又把event请回来了,同时为了方便把eventManager、chessManager和Scene都弄成单例的模式

后来发现多此一举,又全部弄成静态的方法来管理

最后就变成了这样的:

void Scene::Update() {
    for (GameObject *obj: m_gameObjects) {
        if (obj->m_isActive) {
            EventManager::AddEvent(obj->Update());
        }
    }
    if (EventManager::IsGaming()) {
        EventManager::AddEvent(ChessManager::update());
    }
    EventManager::Update();
}

因为所有能够画出来的对象都有统一的接口,所以绘画非常的方便。

之后update就交给EventManager来决策了,那么信息如何传递呢,我用的是enum枚举:

enum Event {
    EVENT_NONE = 0,
    EVENT_IS_WANT_TO_RETURN_TO_MAIN_MENU = 1,
    
    ...
        
    EVENT_SET_GAME_MODE_AI_SECOND = 210,
    EVENT_SET_GAME_MODE_NO_AI = 211,
};

任何一个出现在游戏里的对象都能够通过update返回一个event来激活一个事件。而这个event最终会传递eventManager处理。eventManager把event统一暂存(优先队列)再统一处理。

eventManager的处理:

void EventManager::Update() {
    while (!m_eventQueue.empty()) {
        Event event = m_eventQueue.top();
        m_eventQueue.pop();
        switch (event) {
            case EVENT_IS_WANT_TO_CANCEL:
                cancelOneStep();
                if (!ChessManager::thereIsNoComputer()) {
                    cancelOneStep();//人机对战模式撤回两次
                }
                break;
                
            ...
                
            case EVENT_IS_WANT_TO_RETURN_TO_MAIN_MENU:
                ChessEngine::searchFloor = 3;
                ChessManager::computerIsPWhat = 1;
                IS_GAMING = false;
                IS_GAME_OVER = false;
                Scene::m_gameObjects.clear();
                Scene::AddObject(new UI);
                ChessManager::saveState();
                ChessManager::clear();
                break;
            case EVENT_NONE:
                break;
        }
    }
}

这样便很好的分离了决策和绘画的功能,并且加强了程序的扩展性。

核心算法模块 ChessEngine

一开始没有这个模块而是把它和chessManager放在一起,但是考虑到分离管理和计算两个任务,所以分了这个模块出来

ChessEngine对外的接口有4个:

  • InitMap
    用于获取棋盘的状态数组。元素必须为0,1,2中的一个
  • getMaxCoord
    用于获取当前棋盘分数最高的坐标位置(也就是最优解的坐标)
  • someOneWin
    用来确认当前是否有人已经赢了
  • searchFloor
    储存搜索层数,用来调节难度

image-20231225230602710

改进的过程

AI算法我是一步步改进到现在的ab剪枝的。一开始的最大值搜索采用排序:

iVector2 AI::DownCoord() {
    std::vector<DownStruct> possibleDown = getPossibleDown();

    for (auto nowDown: possibleDown) {
        nowDown.calcDownScore();
    }

    std::sort(possibleDown.begin(), possibleDown.end());

    return possibleDown[0].coord;
}

虽然很简洁明了,但是棋力不够

Leaf AI::AlphaBeta(int depth, Leaf alpha, Leaf beta, iVector2 nowDown) {
    Leaf ret{};
    if (depth == 0) {        //到达一定深度,评估棋局,返回分值
        return {nowDown, score_sum};
    }

    std::queue<iVector2> possibleStep = generalPossibleStep();

    while (!possibleStep.empty()) {
        //下棋
        nowDown = possibleStep.front();
        possibleStep.pop();
        DownUpdateScore(nowDown);

        ret = AlphaBeta(depth - 1, alpha, beta, nowDown);

        //撤销
        UpUpdateScore(nowDown);

        //剪枝
        if (depth % 2 == 0) {//MAX层
            if (ret > alpha) {
                alpha = {nowDown, ret.score};
            }
            if (alpha > beta) {
                return {nowDown, INT_MAX};//返回无效贡献值
            }
        } else {//MIN层
            if (beta > ret) {
                beta = {nowDown, ret.score};
            }
            if (alpha > beta) {
                return {nowDown, INT_MIN};
            }
        }
    }
    return depth % 2 ? beta : alpha;
}

然后采用ab剪枝,虽然可以搜到4层了,但是非常慢,剪枝几乎没有效果

这个时候我因为对scene也不够满意,所以直接把AI部分重写了一遍,然后把运算部分全部打包在chessengine里,再重写的过程中偶然发现了为什么剪枝效果非常差的原因:是因为在对一个点进行评估的是由没有考虑两边,而是单单考虑到了要下的子在这个点的分数。

于是考虑分数做差,这步棋由双方各下一次然后分数相减,差值越大说明越要占该点。

加上这个改进之后剪枝的效果大增,然后可以只考虑前面的几种可能(分数最大的)来减少搜索的事件,变相增加搜索的深度。

这样棋力又进一步增加,搜索深度可以到6层,单步小于10秒思考。

最终效果

最后的ab剪枝算法:

从生成的可能的步子里面进行深度搜索,然后排除一些不可能对结果产生贡献的操作。

ab剪枝的原理可以参考我写的博客:
Alpha-Beta剪枝的原理的深入理解 - 博客园

//意义:目前棋盘的最值评分 分数的正负取决于一开始的isBlackNow
int ChessEngine::abSearch(int floor, int alpha, int beta, bool isBlackNow, Coord &searchResult) {
    PROFILE_FUNCTION
    int tmpScore, moveCount = 0;
    Coord tempSearchResult{};
    std::vector<ScoreCoord> possibleMove = generatePossibleMove(isBlackNow);
    for (auto &now: possibleMove) {
        moveCount++;
        if (moveCount > 8) break; //只搜索前8个可能的落子点
        int x = now.coord.x, y = now.coord.y;
        m_map[x][y] = isBlackNow ? BLACK_CHESS : WHITE_CHESS;
        if (someoneWin({x, y})) {//如果有人赢了 必定是下这个子的人赢了
            searchResult = {x, y};
            tmpScore = evaluateAll(isBlackNow);//返回这个局面最高的得分,也就是赢局的分数
            m_map[x][y] = NO_CHESS;
            return tmpScore;
        }
        if (floor == 1) {//如果只看这一步子 那就是这一步子所有可能的得分中的最大值
            tmpScore = evaluateAll(isBlackNow);
            m_map[x][y] = NO_CHESS;
            if (tmpScore > alpha) {
                alpha = tmpScore;
                searchResult = {x, y};
            }
            continue;
        }
        tmpScore = -abSearch(floor - 1, -beta, -alpha, !isBlackNow, tempSearchResult);//不然得分就是我下了之后的对方的所能得到的最高分取负
        m_map[x][y] = NO_CHESS;
        if (tmpScore >= beta) {
            return beta;
        }
        if (tmpScore > alpha) {//取对方尽所有努力后得到最大值中的最小的一个 取负值后变成最大的一个
            alpha = tmpScore;
            searchResult = {x, y};
        }
    }
    return alpha;
}
std::vector<ScoreCoord> ChessEngine::generatePossibleMove(bool isBlackNow) {
    PROFILE_FUNCTION
    std::vector<ScoreCoord> ret;
    ret.reserve(225);
    for (int x = 1; x <= 15; ++x) {
        for (int y = 1; y <= 15; ++y) {
            if (thereIsNoChessNearby({x, y}))continue;
            if (m_map[x][y] != NO_CHESS)continue;
            int baseScore = evaluateOnePoint(isBlackNow, {x, y});//没有落子前的分数
            m_map[x][y] = isBlackNow ? BLACK_CHESS : WHITE_CHESS;
            int myScore = evaluateOnePoint(isBlackNow, {x, y});//我下这点我会得到的分数
            m_map[x][y] = isBlackNow ? WHITE_CHESS : BLACK_CHESS;
            int rivalScore = evaluateOnePoint(!isBlackNow, {x, y});//如果我不下这点则敌方会得到的分数
            m_map[x][y] = NO_CHESS;
            ret.push_back({(myScore - baseScore) + (rivalScore - (-baseScore)), {x, y}});//要让我获益最大 或者能让敌方获益最大的点下棋
        }
    }
    std::shuffle(ret.begin(), ret.end(), std::mt19937(std::random_device()()));
    std::sort(ret.begin(), ret.end(), [](const ScoreCoord &a, const ScoreCoord &b) {
        return a.score > b.score;
    });
    return ret;
}

生成器采用了随机洗牌,来产生每次不同的结果

估值函数的实现

估值函数是启发式搜索的最大难点,本文采用董红安2005年论文“
计算机五子棋博弈系统的研究与实现
”中的评分表

采用字符串结构来表示特征,如五个相连可以写成”66666611“

由于是多模式匹配,AC算法自然胜任这种工作,于是单独写了一个ACEngine来完成给字符串赋分的操作:

初始化:

ACEngine ChessEngine::whiteEngine = {
        {"22222",  50000},
        {"022220", 4320},
        {"022200", 720},
        {"002220", 720},
        {"022020", 720},
        {"020220", 720},
        {"22220",  720},
        {"02222",  720},
        {"22022",  720},
        {"20222",  720},
        {"22202",  720},
        {"002200", 120},
        {"002020", 120},
        {"020200", 120},
        {"000200", 20},
        {"002000", 20},};

提前建边后搜索便能以O(n)的复杂度完成匹配,提升了搜索的速度

GameObject抽象类

class GameObject {
public:
    virtual void Draw() = 0;

    virtual Event Update() = 0;

public:
    int m_id{};

    bool m_isActive = true;
};

所有的游戏内的对象都继承自GameObject,这样就可以通过多态实现在数组中统一调用和绘画,统一更新(获取用户的输入信息):

for (GameObject *obj: m_gameObjects) {
    if (obj->m_isActive) {
        obj->Draw();
    }
}
...
for (GameObject *obj: m_gameObjects) {
    if (obj->m_isActive) {
        EventManager::AddEvent(obj->Update());
    }
}

如单个棋子的对象的实现:

//单个棋子的对象
class Chess : public GameObject {
public:
    Chess(Coord coord, bool IsBlack);

    void Draw() override;

    Event Update() override;

public:
    Coord m_standardCoord{};
    Vector2 m_realCoord{};
    Color m_color{};
};

测试

性能测试

参考我过去写的博客,进行关键部位的性能测试(chessEngine)

关于BenchMark/c++11计时器/Chrome:tracing 的一些笔记 - Satar07 - 博客园 (cnblogs.com)

image

image

上面是搜索4层的情况,发现剪枝情况良好,一共评估全局了184次,全局评估大概净耗时大概是单点评估的两倍,优化的效果不错

对战

在线对战(真人)先手和后手没有输过几次,大部分都是很快结束战斗了:(典型对局)

image-20231226212226537

AI能够准确的创造三三局面快速结束比赛

不过和在线有难度的AI比起来还是偶尔会输,不过大部分是赢

image-20231226212633366

参考网站