课程设计报告
1 需求和规格说明
1.1 问题描述
细胞自动机是一个时间和空间都离散的动态系统,每个细胞自动机都是由细胞单元(cell)组成的规则网格,每个细胞元都有k 个可能的状态,其当前状态由其本身及周围细胞元的前一状态共同决定。
例如:The Game of Life
- 细胞元形成二维数组。
- 每个细胞元有两个状态:
living
和dead
。 - t+1 时刻的状态由 t 时刻的状态决定。
- 状态变迁规则1:一个
living
的细胞元依旧保持存活,如果其周围存在 2 或 3 个living
细胞元,否则死亡。 - 状态变迁规则2:一个
dead
的细胞元依旧保持死亡,除非其周围恰好 3 个living
细胞元。
一个演化图例如下:
细胞自动机从某种程度上反映了生物群落的演化,也可应用到许多计算机问题中。
1.2 课程设计目的
了解细胞自动机的基本原理,并能实现对细胞自动机的模拟以及在此基础上做有价值的分析。
1.3 基本要求
- 编写 Gossip 模型的模拟(最好有图形界面,可用 C 的图形库)。
- 该模型是一个二维模型,并按如下条件处理边界问题:右边界的右邻居是左边界,同理,左边界、上边界、下边界也一样,
- 该模型的基本规则为:如果你的邻居中有人得知某消息,那么你将有5%的概率从其中获知消息的一个邻居那里获得该消息(即如果只有一个邻居有消息,得知概率为 5%,有两个邻居就是 10%,依此类推);如果你已经得知该消息,那么你将保存。
- 依据上述模拟得出 Gossip 模型较为全面的实验结果:消息覆盖率随时间的变化;不同概率值的影响等。
2 设计
2.1 设计思路
在 Gossip 模型中,计算节点通过随机选择的方式与其他节点通信,交换自身的状态信息或计算结果。本次课程设计基于细胞自动机的原理实现 Gossip 模型的模拟,先设计单个细胞的各种属性,再需要设计一个细胞自动机的二维网格结构,另外,还需要设计细胞之间随时间不断影响变化的概率算法,当然,要设计一个保存数据的算法,同时,还得设计算法使之能够以可视化动画的形式呈现。
2.2 数据结构设计
2.2.1 细胞的状态
自然地,由 Gossip 模型我们可以知道一个细胞可以只有两个基本状态:未收到消息和已收到消息。但是为了后续在单位时间内更新细胞状态的离散性,我们将其定义为三种状态:
未收到消息
刚收到消息
可以散播消息
// 定义每个细胞的状态
enum CellState {
EMPTY, //未收到消息
GETMESSAGE, //刚收到消息
MESSAGE //可以散播消息
};
2.2.2 细胞自动机网格结构
由给出的细胞自动机引例可知,细胞自动机网格可以选择由细胞状态所组成的二维数组为基本结构。
static CellState grid[ROWS][COLS]; //细胞组成的网格
另外,为了之后更方便的打印文字,我们选择同样大小的二维字符数组为缓存。
static char gridBuffer[ROWS][COLS];
2.2.3 单位时间内的数据存储
由于我们只需要连续地增添数据,故而采用 C++ STL 中的 vector
容器,其本质上是以顺序表的形式进行存储,提供了在尾部快速插入和删除元素的能力,相较于普通的数组,它初始时具有一定的容量,当需要插入更多元素时,它会自动调整数组的大小。由于顺序表的连续存储特性,vector
支持常数时间复杂度的随机访问操作,并且使用方法与数组类似,对于此模型来说比较方便。
// 定义一个容器用以存储单位时间模拟的数据(覆盖率 * 100并转化为整型变量)
static vector<int> coverageData;
// 定义一个容器用以存储变化率(覆盖率之差 * 1000并转化为整型变量)
static vector<int> gradientData;
2.2.4 其它数据
对于其它数据,我们以全局静态存储,方便随时调用。
// 定义细胞自动机的大小
const int ROWS = 100;
const int COLS = 100;
static int newROWS;
static int newCOLS;
//双缓冲处理显示
static char gridBuffer[ROWS][COLS];
HANDLE hOutput, hOutBuf;//控制台屏幕缓冲区句柄
COORD coord = {0, 0}; //设置输出坐标为控制台的左上角
DWORD bytes = 0;
static int cellNum; //细胞总数量
static int cellMessageNum = 0; //初始时得到消息的细胞数量为0
static double coverage = 0; //初始时消息覆盖率为0
2.3 算法设计
2.3.1 概率实现
- 【设计思路】
最初,我尝试调用ctime
中的time()
函数返回一个种子来生成随机数1,但模拟的过程中发现,该方法与时间的相关性太大,对于连续的某一段时间来说,生成的随机数近似,这就导致一段时间消息根本不传播,一段时间消息传播的次数猛增。
unsigned seed; // Random generator seed
// Use the time function to get a "seed" value for srand
seed = time(0);
srand(seed);
后来我了解到 C++ 有一个随机数引擎 mt19937
2(基于Mersenne Twister算法),只需将它设置为全局引擎,初始时接收一个种子,我们总体上调用的随机数分布就比较均匀了。
- 【算法实现】
//概率的实现
// 全局随机数引擎
mt19937 rng(random_device{}());
static int proValue = 5; //概率值
int getRandomNumber(int min, int max) {
uniform_int_distribution<int> uni(min, max); // 均匀分布
return uni(rng);
}
bool randomJudge(){
int randomNumber = getRandomNumber(1, 100);
if (randomNumber <= proValue)
return true; //proValue%的概率返回true
else
return false;
}
2.3.2 更新细胞状态
- 【设计思路】
细胞每一轮更新时的状态变化都与它的左、右、上、下邻居有关,我们不妨把前者的列下标分别设置为Left
、Right
,后者的行下标分别设置为Up
、Down
,自然地想,它们与细胞的关系应该分别是col – 1
、col + 1
、row + 1
、row – 1
的关系,但是,由于题目中提到右边界的右邻居是左边界,同理,左边界、上边界、下边界也一样,我们就必须考虑边界问题,这时候,我们可以巧妙运用取模运算实现边界间的“连接”,例如,一个细胞的Left
为(col - 1 + newCOLS) % newCOLS
。
另外,细胞是否会得到消息还与概率有关,且这种概率是累加的,即如果该细胞有两个邻居得到消息,初始概率为 5%,那么它得到消息的概率即为 10%,我们可以循环调用概率函数来模拟概率的累加。 - 【算法实现】
// 更新细胞的状态
static int Left, Right, Up, Down;
void updateCell(int row, int col) {
// 邻居知道消息的数量
int neighborMessageNum = 0;
// 检查邻居状态
if(grid[row][col] == GETMESSAGE)
grid[row][col] = MESSAGE;
if(grid[row][col] == EMPTY){ //只判断没有收到消息的细胞
Left = (col - 1 + newCOLS) % newCOLS;
Right = (col + 1) % newCOLS;
Up = (row - 1 + newROWS) % newROWS;
Down = (row + 1) % newROWS;
if(grid[row][Left] == MESSAGE) //判断左邻居
neighborMessageNum++;
if(grid[row][Right] == MESSAGE) //判断右邻居
neighborMessageNum++;
if(grid[Up][col] == MESSAGE) //判断上邻居
neighborMessageNum++;
if(grid[Down][col] == MESSAGE) //判断下邻居
neighborMessageNum++;
}
// 根据邻居情况更新细胞状态
for(int i = 0; i < neighborMessageNum; i++){ //用循环模拟概率的累加
if(randomJudge()){ //一定概率返回true
grid[row][col] = GETMESSAGE; //该细胞得到消息
cellMessageNum++; //得到消息的细胞数+1
break; //不需要继续判断邻居
}
}
}
// 进行一次模拟更新
void simulateOneStep() {
// 遍历细胞自动机中的每个细胞
for (int i = 0; i < newROWS; i++) {
for (int j = 0; j < newCOLS; j++)
updateCell(i, j);
}
}
2.3.3 更新数据容器
- 【设计思路】
为方便之后的可视化处理,我将数据转化为整形变量,其中,消息覆盖率需要 x100,而变化率需要 x1000(这是因为通过大量模拟,大多数情况下单位时间变化率都是很小的值,需要用千分率来表示)。 - 【算法实现】
//数据处理
coverageData.push_back((int)(coverage * 100));
gradientData.push_back((int)((coverage - preCoverage) * 1000));
2.3.4 可视化消息传播
- 【设计思路】
原先,我设想通过遍历网格,判断细胞的状态来打印字符,最后发现当数据量过大时,控制台会发生不可避免的闪烁。为解决这一问题,我了解到控制台的缓存处理技术3,即在一次循环中,我们可以先把网格遍历,转化为不同的字符输入到一个相同大小的二维字符数组中,再将其呈现到控制台上。 - 【算法实现】
//打印网格
void displayGrid(int& time){
int i, j;
//细胞机模拟
simulateOneStep();
for (i = 0; i < newROWS; i++){
for (j = 0; j < newCOLS; j++){
if(grid[i][j] == EMPTY)
gridBuffer[i][j] = ' ';
else if(grid[i][j] == GETMESSAGE)
gridBuffer[i][j] = '~';
else
gridBuffer[i][j] = '*';
}
}
for (i = 0; i < newROWS; i++){
coord.Y = i;
WriteConsoleOutputCharacterA(hOutBuf, gridBuffer[i], COLS, coord, &bytes);
}
//更改备注
//时间与概率值
i++;
coord.Y = i;
string strBackground = "Time: " + to_string(time) + " Probability value: " + to_string(proValue) + '%';
strncpy(gridBuffer[i], strBackground.c_str(), COLS - 1);
WriteConsoleOutputCharacterA(hOutBuf, gridBuffer[i], COLS, coord, &bytes);
//数量
i++;
coord.Y = i;
string strNumber = "Number of messageCell: " + to_string(cellMessageNum) + " Number of cell: " + to_string(cellNum);
strncpy(gridBuffer[i], strNumber.c_str(), COLS - 1);
WriteConsoleOutputCharacterA(hOutBuf, gridBuffer[i], COLS, coord, &bytes);
//覆盖率
i++;
coord.Y = i;
double preCoverage = coverage;
coverage = (double)cellMessageNum / cellNum;
string strCoverage = "Coverage of message: " + to_string(coverage);
strncpy(gridBuffer[i], strCoverage.c_str(), COLS - 1);
WriteConsoleOutputCharacterA(hOutBuf, gridBuffer[i], COLS, coord, &bytes);
//数据处理
coverageData.push_back((int)(coverage * 100));
gradientData.push_back((int)((coverage - preCoverage) * 1000));
//设置新的缓冲区为活动显示缓冲
SetConsoleActiveScreenBuffer(hOutBuf);
Sleep(400);
}
2.3.5 可视化数据呈现
- 【设计思路】
由于在一次完整的模拟过后,我们已经把数据都存储在了两个vector
容器中,所以,我们只需要每次循环中取到相应时间下标的元素大小,再循环打印匹配数量的 ■ 字符即可。 - 【算法实现】
//数据呈现
void displayData(){
int iCoverage;
cout << "Coverage of message" << endl;
for(int i = 0; i < coverageData.size(); i++){
cout << "time\t" << i << '\t';
iCoverage = coverageData[i];
for(int j = 0; j < iCoverage; j++){
cout << "■";
}
cout << coverageData[i] << '%' <<endl;
}
int iGradient;
cout << "Coverage of message" << endl;
for(int i = 0; i < gradientData.size(); i++){
cout << "time\t" << i << '\t';
iGradient = gradientData[i];
for(int j = 0; j < iGradient; j++){
cout << "■";
}
cout << gradientData[i] << "‰" <<endl;
}
}
3 用户手册
- 自动细胞机的网格从上到下为 0行、1行、2行……从左到右为 0列、1列、2列……
- 程序运行时,首先输入单个细胞传播消息的概率值(不小于 1,不大于 100)
- 然后分别输入网格高度和网格宽度(均不小于 1,不大于 50)
- 再输入初始消息细胞的行标和下标(不超出你所划定的网格范围)
- 得到细胞被初始化的信息,等待 2 秒后观察 Gossip 模型
- Gossip 模型运行完后,可观察模拟得到的数据柱状图
4 模型模拟与数据分析
4.1 消息覆盖率随时间的变化
20x20 消息覆盖率柱状图(5%概率)
可以观察到,消息覆盖率在初始的一段时间增长缓慢,然后增长速度逐渐加快,到了末段时间,增长速度又趋于平缓,通过观察下面的变化率柱状图可以更好地验证这一点。
20x20 消息覆盖变化率柱状图(5%概率,部分)
观察得知消息覆盖率的变化率与时间的关系大致呈中间高两边低的正态分布,当概率值偏大时,这一性质会更加明显。
30x30 消息覆盖变化率柱状图(50%概率,部分)
推测这是由细胞自动机整体形态变化导致的。一开始,得到消息的细胞随时间大致呈圆形扩散,能够散播消息的细胞即为圆的圆周,消息传播变化率自然而然地随之增大;然而,受细胞自动机大小的限制,得到消息的细胞相互之间阻碍、影响,这时候细胞自动机的形态呈现为一个由得到消息的细胞围成的空穴,能够散播消息的细胞即为空穴的边界,消息传播变化率自然而然地随之减小。当初始消息细胞位于二维数组边界时,可以更好地观察到这个变化过程(实际上由于细胞自动机边界的互通性,该模型实际上可以大致抽象为三维的球体外表面):
4.2 不同概率值的影响
30x30 情况下 10%,20%,30%,40%,50%对迭代次数、变化率峰值影响
概率 | 迭代次数 | 变化率峰值 |
---|---|---|
10% | 124 | 21‰ |
20% | 77 | 33‰ |
30% | 59 | 42‰ |
40% | 48 | 47‰ |
50% | 43 | 50‰ |
60% | 40 | 51‰ |
70% | 38 | 54‰ |
80% | 37 | 56‰ |
(注: 每个概率值模拟三次,取平均值)
观察发现,随着单个细胞传播消息的概率增加,整体上的迭代次数是减少的,整体的变化率峰值时上升的。但是,随着概率的增加,迭代次数与变化率的峰值趋于不变。
推测这是由细胞之间的消息传播机制导致的,一个细胞在一轮模拟中是否得到消息,不仅取决于概率值的大小,邻居的多少也占一大部分比重,特别是消息传播概率比较小的情况下,概率只要上升一点,宏观上细胞得到消息的概率就会大大增加。然而,当消息传播概率较大时,宏观上细胞得到消息的概率会趋于 100%,这样,迭代次数与变化率峰值就取决于细胞自动机的大小(空间)了。
5 进一步改进
目前程序对Gossip模型的分析还比较浅显,缺少对一些数据的进一步发掘(如细胞自动机的空间形态对迭代次数的影响),分析已知数据时较少运用深层次的数理方法,缺乏理性分析。后续程序可以设计数据自动分析归纳的模块,减少多次运行的时间成本。
另外,该程序对数据可视化的方法还不够成熟,且稳定性不高。后续可以利用 C++ 的图形库来解决数据可视化问题。
6 心得体会
在计算机领域中,Gossip 模型是一种分布式计算模型,它通过节点之间的信息交换和传播来实现协作计算。该模型的灵感来自于人际间的流言传播,因此得名。
在 Gossip 模型中,计算节点通过随机选择的方式与其他节点通信,交换自身的状态信息或计算结果。这种通信通常是基于点对点的方式,即每个节点只与一个或少数几个节点进行通信。每次通信,节点之间都会交换信息并更新自身的状态。通过多次随机通信和信息传播,节点之间逐渐达成一致或共享全局状态。
Gossip 模型在分布式系统中具有广泛的应用,如数据复制、资源分配、分布式计算和协议设计等。它的优点之一是它的去中心化特性,节点之间相互协作,无需中央控制节点。此外,Gossip 模型具有容错性,因为节点之间的通信是随机选择的,即使某些节点失效或无法访问,信息仍然可以通过其他路径进行传播。
本次课程设计中,我通过对问题的各种分析加强了编程能力。例如,在更新细胞状态的算法中,我一开始只考虑了两种状态:未得到消息和得到消息,但是,在模拟的过程中,我发现程序是可以正常运行的,但是细胞自动机在不断变化的过程中,并没有像预想的一样呈圆扩散,而是大致朝右下角延申,当概率调大时,这一现象愈发明显,甚至出现了“越级”扩散消息的情况。后来发现在一轮模拟的过程中,总是边遍历边更新状态,这就导致前面遍历更新完的细胞会有概率影响到后面遍历的细胞,这就导致“时间”不是离散的,会影响接下来的模型分析。故而我构造了三种状态,使之一次遍历的途中,细胞可以不受此轮已遍历过的细胞的影响。
通过数据分析的过程,我得知了在 Gossip 模型下,单个细胞传播消息的概率并不是越大越好,当消息传播概率较大时,提升消息传播的概率并不会使消息传播的速度得到质的改变,也就是说该模型在实际运用过程中有速度上的限制。理论上,该模型也应该有一个最佳的概率,使之有速度和效率上的平衡。
7 源码
#include <iostream>
#include <random>
#include <ctime>
#include <windows.h>
#include <cstring>
#include <vector>
using namespace std;
// 定义细胞自动机的大小
const int ROWS = 100;
const int COLS = 100;
static int newROWS;
static int newCOLS;
//双缓冲处理显示
static char gridBuffer[ROWS][COLS];
HANDLE hOutput, hOutBuf;//控制台屏幕缓冲区句柄
COORD coord = {0, 0}; //设置输出坐标为控制台的左上角
DWORD bytes = 0;
// 定义每个细胞的状态
enum CellState {
EMPTY, //未收到消息
GETMESSAGE, //刚收到消息
MESSAGE //可以散播消息
};
// 定义细胞自动机的状态
static CellState grid[ROWS][COLS]; //细胞组成的网格
static int cellNum; //细胞总数量
static int cellMessageNum = 0; //初始时得到消息的细胞数量为0
static double coverage = 0; //初始时消息覆盖率为0
// 定义一个容器用以存储单位时间模拟的数据(覆盖率 * 100并转化为整型变量)
static vector<int> coverageData;
// 定义一个容器用以存储变化率(覆盖率之差 * 1000并转化为整型变量)
static vector<int> gradientData;
//概率的实现
// 全局随机数引擎
mt19937 rng(random_device{}());
static int proValue = 5; //概率值
int getRandomNumber(int min, int max) {
uniform_int_distribution<int> uni(min, max); // 均匀分布
return uni(rng);
}
bool randomJudge(){
int randomNumber = getRandomNumber(1, 100);
if (randomNumber <= proValue)
return true; //proValue%的概率返回true
else
return false;
}
// 初始化细胞自动机的状态(全部为EMPTY)
void initializeGrid(){
for (int i = 0; i < newROWS; i++) {
for (int j = 0; j < newCOLS; j++)
grid[i][j] = EMPTY;
}
}
// 更新细胞的状态
static int Left, Right, Up, Down;
void updateCell(int row, int col) {
// 邻居知道消息的数量
int neighborMessageNum = 0;
// 检查邻居状态
if(grid[row][col] == GETMESSAGE)
grid[row][col] = MESSAGE;
if(grid[row][col] == EMPTY){ //只判断没有收到消息的细胞
Left = (col - 1 + newCOLS) % newCOLS;
Right = (col + 1) % newCOLS;
Up = (row - 1 + newROWS) % newROWS;
Down = (row + 1) % newROWS;
if(grid[row][Left] == MESSAGE) //判断左邻居
neighborMessageNum++;
if(grid[row][Right] == MESSAGE) //判断右邻居
neighborMessageNum++;
if(grid[Up][col] == MESSAGE) //判断上邻居
neighborMessageNum++;
if(grid[Down][col] == MESSAGE) //判断下邻居
neighborMessageNum++;
}
// 根据邻居情况更新细胞状态
for(int i = 0; i < neighborMessageNum; i++){ //用循环模拟概率的累加
if(randomJudge()){ //一定概率返回true
grid[row][col] = GETMESSAGE; //该细胞得到消息
cellMessageNum++; //得到消息的细胞数+1
break; //不需要继续判断邻居
}
}
}
// 进行一次模拟更新
void simulateOneStep() {
// 遍历细胞自动机中的每个细胞
for (int i = 0; i < newROWS; i++) {
for (int j = 0; j < newCOLS; j++)
updateCell(i, j);
}
}
//打印网格
void displayGrid(int& time){
int i, j;
//细胞机模拟
simulateOneStep();
for (i = 0; i < newROWS; i++){
for (j = 0; j < newCOLS; j++){
if(grid[i][j] == EMPTY)
gridBuffer[i][j] = ' ';
else if(grid[i][j] == GETMESSAGE)
gridBuffer[i][j] = '~';
else
gridBuffer[i][j] = '*';
}
}
for (i = 0; i < newROWS; i++){
coord.Y = i;
WriteConsoleOutputCharacterA(hOutBuf, gridBuffer[i], COLS, coord, &bytes);
}
//更改备注
//时间与概率值
i++;
coord.Y = i;
string strBackground = "Time: " + to_string(time) + " Probability value: " + to_string(proValue) + '%';
strncpy(gridBuffer[i], strBackground.c_str(), COLS - 1);
WriteConsoleOutputCharacterA(hOutBuf, gridBuffer[i], COLS, coord, &bytes);
//数量
i++;
coord.Y = i;
string strNumber = "Number of messageCell: " + to_string(cellMessageNum) + " Number of cell: " + to_string(cellNum);
strncpy(gridBuffer[i], strNumber.c_str(), COLS - 1);
WriteConsoleOutputCharacterA(hOutBuf, gridBuffer[i], COLS, coord, &bytes);
//覆盖率
i++;
coord.Y = i;
double preCoverage = coverage;
coverage = (double)cellMessageNum / cellNum;
string strCoverage = "Coverage of message: " + to_string(coverage);
strncpy(gridBuffer[i], strCoverage.c_str(), COLS - 1);
WriteConsoleOutputCharacterA(hOutBuf, gridBuffer[i], COLS, coord, &bytes);
//数据处理
coverageData.push_back((int)(coverage * 100));
gradientData.push_back((int)((coverage - preCoverage) * 1000));
//设置新的缓冲区为活动显示缓冲
SetConsoleActiveScreenBuffer(hOutBuf);
Sleep(200);
}
// 进行完整的模拟
void simulateGossipModel() {
// 初始化细胞自动机状态
initializeGrid();
int row, col;
cout << "输入行标:";cin >>row;
cout << "输入列标:";cin >>col;
grid[row][col] = MESSAGE;
cellMessageNum++;
cout << "cell " << row << ' ' << col << " 已被初始化" << endl;
Sleep(2000);
system("cls");
//创建新的控制台缓冲区
hOutBuf = CreateConsoleScreenBuffer(
GENERIC_WRITE,//定义进程可以往缓冲区写数据
FILE_SHARE_WRITE,//定义缓冲区可共享写权限
NULL,
CONSOLE_TEXTMODE_BUFFER,
NULL
);
//隐藏两个缓冲区的光标
CONSOLE_CURSOR_INFO cci;
cci.bVisible = 0;
cci.dwSize = 1;
//SetConsoleCursorInfo(hOutput, &cci);
SetConsoleCursorInfo(hOutBuf, &cci);
// 进行模拟更新,并记录消息覆盖率随时间的变化
for(int i = 0; i < 500; i++){
//打印输出
displayGrid(i);
if(coverage == 1){
break;
}
}
// 关闭句柄
CloseHandle(hOutBuf);
system("cls");
}
//数据呈现
void displayData(){
int iCoverage;
cout << "Coverage of message" << endl;
for(int i = 0; i < coverageData.size(); i++){
cout << "time\t" << i << '\t';
iCoverage = coverageData[i];
for(int j = 0; j < iCoverage; j++){
cout << "■";
}
cout << coverageData[i] << '%' <<endl;
}
int iGradient;
cout << "Coverage of message" << endl;
for(int i = 0; i < gradientData.size(); i++){
cout << "time\t" << i << '\t';
iGradient = gradientData[i];
for(int j = 0; j < iGradient; j++){
cout << "■";
}
cout << gradientData[i] << "‰" <<endl;
}
}
int main() {
// 设置初始值
cout << "选择概率值(%):";cin >> proValue;
cout << "选择网格高度:";cin >> newROWS;
cout << "选择网格宽度:";cin >> newCOLS;
cellNum = newROWS * newCOLS;
// 调用模拟函数,开始模拟Gossip模型
simulateGossipModel();
//呈现数据
displayData();
system("pause");
return 0;
}
本人拙作一篇,欢迎各位指正!