游戏编程之数据结构和算法

486 阅读18分钟

highlight: atelier-seaside-light

作者:老九—技术大黍

社交:知乎

公众号:老九学堂(新人有惊喜)

特别声明:原创不易,未经授权不得转载或抄袭,如需转载可联系笔者授权

前言

image-20210420152910010.png

我来参考翻译一下:

楼越高,它需要的基础越深。

备注:Thomas Kempis是罗马帝国的作家。

类型、结构体和类

编程语言最初始只是被认为支持相关数据类型集的操作 (The first programming languages only supported operations on a closed set of data types)。比如,我们可操作整数和浮点数(值),以及其它这两种类型的其它类型。原因很简单,它是简化汇编语言级别的编程难度,这些数据类型只是一种变量。直到今天,所有的数据类型都是基于这些数据类型来定义和处理的,因为当今计算速度非常快,所以开发人员不会感觉到需要操作都是基于整型和浮点型数据类型。

实际上,在过去许多计算并不运行浮点数的值运算,这需要在软件层面来解决。一些开发人员会感觉使用编程语言表现力比较有限,所以导致使用这些有限的工具不能创建大型的软件应用,于是,随着时间的流逝,结构化的编程语言出现了”structured programming language” (C语言, Pascal语言等),并且得以广泛流行。在这些语言中,用户定义的数据类型被引入,而作为一种开发人员的抽象,这种抽象让开发人员很容易编码现实世界数据(encode real-world data)。

如果在我们的程序员使用点,那么我们可以决定在三个变量中存贮该点的数据,如下:

float x, y, z;

当允许我们抽象时,我们可以把上面的三个变量看一个类型point3D:

typedef struct{
		float x, y, z;
} Point3D;

这种编程方式允许开发人员书写代码会更加简洁,比如我们可以这样访问变量的属性如下:

Point3D p;
p.x = 1;

它的好处是:这个类型可以不断创建,所以复杂的类型可以使这些类型,从而降低了复杂度,有点类似洋葱的层—一层包裹一层—结果是形成了类型的层级 (type hierarchies),这种层级对于我们使用复杂的关系来编码实现世界的是非常有帮助的。显然,用户定义类型增加了编程语言的表现力,并且开启了一个现代软件开发的时代。

然而,有一个巨大的类型层级之后,又有新的问题出现了,作为开发人员那么要求我们必须熟悉这个巨大的代码块以及数据结构。在前面的Point3D类型中,我们还需要添加操作如加、减等,以及计算点和线等行为。如果我们只有用户定义类型,那么需要如下的行为:

  • point pointAdd(point, point);
  • point pointSubtract(point, point);
  • float pointDotProduct(point, point);
  • point pointCrossProduct(point, point);

当我们数据结构增长时,伴随它的是代码也会增另外,我们可能会遇到命名问题,并且我们的代码可能会变成了到处都是#include语句的蜘蛛网,因此我们程序的代码数量和复杂也因为语言的特点而变理突显出来。因为这种缺陷,于量出现第二次的编程语言的革命,它的动机是有这样一个idea:它允许大的程序中有少量的混乱现象。因为用户自定义类型总体上说是好的思想,在此概念基础上允许每个类型包含源代码,以及自己可被访问的操作行为,因此,类型变成了一种自省 (self-sufficient)代码和数据模型两部分组成。这个模型被叫做classes (类)。

另外,规范类之间的关系之后可以用来模拟现实的世界!类可以被继承,类成员变量可以重命名(标准操作和操作会重载)等,这时该点的数据如下:

class Point3D{
private: 
    float x, y, z;
public: 
    point(float, float, float);
	~point();
	point operator+(point);
	point operator-(point);
};

二种是通过修改这些基于团队的代码(类)可以让团队成员之间交换和共享资源。最近几年编程方法得以改进,比如可视化语言的设计模式,以及标准模板库(STL)等。但是这些改变并没有影响OOP和现有用户自定义类型的使用,它们让开发人员可以比较容易的进行工作。就游戏开发而言,设计模式和STL是非常有用,因此本章节最后一部分会详细讨论设计模式。下面我们开始关注在游戏开发中常用的数据结构,以及算法。其中有一些非常简单,一些结构比较复杂。

数据结构—静态数组

游戏很少是使用一个数据的实例来完成工作的,大多数情况下,我们需要保存相似类型元素为一个列表:网格中的三角关系,一个游戏级别是的敌人数等等。多大数结构可以用来完成这些任务。其中最简单的方式就是静态数组(static array),该数组允许开发者保存元素列表,该列表游戏中不会改变。如果我们需要改变数据(比如,从动态敌人池中添加/删除敌人),这时候就需要一个解决方案(比如链表)可能是最好的解决方案。但是大多数情况下,我们是以线性数组形式保存通用的数组定义如下:

class ArrayOfType{
    type *data;
    int size;
    ArrayOfType(int);
    ~ArrayOfType();
}

构造方法需要一个数组大小的初始值

ArrayOfType:: ArrayOfType(int psize){
    size = psize;
    data = new type[size];
	//do something
}

析构方法如下:

ArrayOfType:~ ArrayOfType(){
    if(data != NULL)
        delete[] data;
}

使用静态数组另外一个好处是获取执行的速度。如果我们把数组元素的位置看成是一个主键(数据库中的术语),那么我们可以通过get O(1)来即时访问。如果我们保证按主键的值来排序(比如以字母顺序,或者以阿拉伯数字的升序来排序)。这样我们可以使用get O(log2 # of elements)查询,该方式比较线性效率高,因为我们需要在循环中需要最小成本来实现相同的功能。其中”log2 # of elements”是logarithmic cost的意思。为达到这个效果,我们需要简化,但是有效的算法—叫做binary (二叉查找)或者dychotomic search (折半查找)算法。下面是核心算法的代码:

typedef struct{
    int passportID;
    char* name;
} person;

class People{
    person* data;
    int size;
    person* seek(int);
};

其中seek方法把一个person结构的指针,如果没有那么返回NULL值。代码如下:

person* People::seek(int passid){
    int top = size – 1;
    int button = 0;
	//如果查找范围只有一个元素,那么停止循环
    while(top – button > 1){
        //计算中间点	
        int mid = (top - bottom) / 2;
         //检查查找的范围,以便进一步扫描
        if(passid >= data[mid].passwordID)
        bottom = mid;
         else
        top = mid;
    }
    if(data[bottom].passwordID == passid)
        return (&data[bottom]);
    else
        return NULL;
}

二叉查找(折半查找)算法

image-20210420160037303.png

构造方法中传入的初始值与折半之后数组元素[mid]的值比对;如果大,那么把数组的bottom改为mid值;否则把数组的top改为mid值。如此循环直到找到值希望的值,否则返回NULL值。

数据结构—链表

Linked list是一个静态数组的扩展。像静态数据一样,它是保存相同类型元素的序列,但是链表有另外的好处:这个序列可动态的增长收缩,收缩的时间为结构行生命周期之中。实现这个功能是因为使用动态内存给每个独立元素分配了空间,然后使用指针把下一个元素链接起来。核心代码如下:

typedef struct{
    //定义元素的数据
    element* next;
} element;

class LinkedList{
    element* first;
    element* current;
};

image-20210420160230275.png

注意:我们指定了两个指针,一个是指向链表中第一个元素(我们不移动该指针),另外一个指向当前的元素,该指针用来在列表中实现查找和扫描功能。显然,这种方式不可能随机访问给定位置的元素,除非我们循环中找到指定的目标,因此链表特别适合顺序遍历,但不适合随机访问元素链表是数组的超类,因为它们允许我们按需要来resize数据结构,另外,它们有两个潜在的问题。第一,它需要小心处理内存分配的内部,确保内存不会泄漏;第二,一些循环(查询和随机访问)效率比起数组低。因为在数组中,我们直接可以使用主键位置来访问元素,但是在链表中,我们只能使用指针来顺序访问元素,因为,该查询是线性的,而不是logarithmic的形式。

同样对于链表中元素的删除也比较复杂,因为我们需要把指针指向前一个元素,以解决删除元素之后的顺序问题上面所述是Figure 3.1链表(单向链表)的数据结构,它的缺陷需要使用双向链表来解决:

typedef struct{
    //here the data for the element
    element* next;
    element* prev;
} element;
class LinkedList{
    element* first;
    element* current;
}

image-20210420160411495.png

数据结构—队列

双向链表可以很容易实现新增和删除元素,因为我们可以向前指向前一个元素和向后指向下一个元素来实现新增与删除动作,所以双向链表的好处是新增和删除元素的效率比数组高。但是,我们需要按固定顺序新增元素怎么办?

队列 (Queue)—可以解决这个问题。它总是在列表的尾部添加元素,而访问元素总是从列表的开始处。这有点像超市排队的现象。顾客自觉的加入到期望的队列尾部,而只有出口开始处的顾客可以付现金或者刷卡结帐。

typedef struct{
    //here the data for the element
    element* next;
} elements;
class Queue{
    element* first;
    element* last;
    void insertBack(element*);
    element* getFirst(element*);
}	

image-20210420160537907.png

队列对于各种应用都是非常有用的,因为它可以接时间来进行排序列表:通过网络连接获取消息、命令到一个实时策略游戏的单元中等。我们客户端代码可以的这个队列的尾部新增元素,然后在队列的开始处抽取这个队列的中的元素,这样就保证了按照时间的顺序来排序队列中的元素。另外队列可以是固定和可变的形式,如果是固定队列,那么通常使用循环队列来实现,循环队列是使用N个最近使用的元素为一个列表,当有新的元素出现时,该新出现的元素覆盖旧的元素循环队列会在3D游戏中大量使用。

比如,我们需要绘制行走的脚步,或者绘制在墙上的子弹,我们不可能使用太多的元素来绘制这些场景,因为这样会阻塞我们的呈现引擎,所以,我们只绘制最近N个元素。显然,这种场合非常适合使用队列来解决该问题。当队列被填充时,旧元素会被删除掉,所以只有最新的脚步会被绘制在屏幕中。

数据结构—栈

栈是链表的另外一种变种,它是元素添加和开始的地方是同一处,我们叫LIFO原则。它有点类似装订一本书,最后装订的页面是目录,它会被读者第一个翻开。

typedef struct{
    //here the data for the element
    element * next;
} element;
class Stack{
    element* first;
    void push(elements*);
    element* pop();
}

栈结构对于访问几何类型元素时非常有用:盘点货物,然后把物品项被放到地上等。同时栈也可以用于在缓存中,让新消息替换旧消息的情况。LIFO原则让源新元素拥有了优先级别。

image-20210420160855825.png

数据结构—双向队列

当我们同时使用栈和队列时会比较浪费代码,特殊这两种结构非常类似(除了指针的新增与删除之外)。一种通用的做法就是把这两种结构封装到一个对象中,该对象同时拥有队列和栈的结构—这种结构叫做双向队列(dequeue—double-ended queue)。这种数据结构允许我们在列表的开始端和结束端都可以压栈和出栈行为。

image-20210420160947687.png

typedef strut{
    //here the data for the element
    element* next;
    element* prev;
} element;
class Deque{
    element* first;
    element* last;
    void pushFront(element*);
    element* popFront();
    void pushBack(element*);
    element* popBack();
};

表格(Tables)-哈希表

表格是一种顺序结构,这种结构是使用一个唯一key值标识一个关联的数组元素。比如,我们使用护照编号来保存个人的姓名,这时我们使用一个表格。表格可以有很多条目(register—有时使用row来表示),并且第个条止可以有多个字段(fields—有时候使用columns表示)。这种数据主要应用于数据库管理系统中,当然也可以用在游戏中以表格形式保存武器类型和它们的特性。表格可以多种形式表示,最简单的形式是静态数组,但是它有长度限制—key的值的限制。

但是大多数情况下表格都比较复杂,从表格开始之后,很少能确定的结束key键。比如,我们有一个顾客护照号的列表,但是不可能列出所有顾客的护照编号。所以,我们把这个问题分成两部分,分别使用不同的解决方案。主数据大多数是静态不变的,所以不会有新增和删除的情况(或者说有少量这些行为),这时表格的顺序结构需要改变吗?在静态情况下,使用key来排序,把数据以静态数组的升序来保存是非常好的策略—此时我们可以二叉查找法。但是如是需要新增元素,并且新增之后需要保持原来的顺序;如果删除了元素,也需要保持原来的顺序,这时我们需要另外的数据结构来解决了—它是就是Hash table (哈希表)。这种数据结构可以解决静态和动态数据(不会耗尽的key值)问题。它提供快速访问元素、新增和删除元素。

哈希表由一个数据repository来保存表格的数据,该repository耦合一个指向该处的指针(键)来查询表格中数据。哈希算法是一个“scatter (离散)”的键值,该值表示一个保存表格的repository位置。这样就提供了一种数据的缩略表示。

Class HashTale{
    DlinkedList data[100];
    void create();
    element* seek(int key);
    void insert(element* el);
    void delete(int key);
    DlinkedList * hashFunction(int key);
};

image-20210420161324389.png

在上面示例中,哈希表是使用一个双向链表实现的。哈希函数只是把输入键值转换成一个指向该列表的开始处,也就是元素实际被存贮的地方,哈希函数源代码如下:

DlinkedList* HashTable::hashFunction(int key){
    int pos = (key % 100);
    return &data[pos];
}; 

哈希表在主流应用被大量用在巨型数据银行中执行查询功能,虽然该数据结构非常耗费内存资源,但是它可以大降低访问时间。比如,保存1千万个政府发放的护照编号,那么我们可以这样处理:使用1万个数组和1万个护照数据,列表平均长度是1千元素。这时我们循环一万次,平均每个列表又循环一千次来解决一千个护照的比较问题。其中,我们可以使用不同数组来完成对这个巨型的查询、新增与删除等功能。另外,如果我们想可以调整该数据结构来控制查询的速度。比如,我们准备购买大内存,因为我们需要这些巨型数组需要大量的指针。

假设我们使用32位指针,1万个数组平均长度1千个元素组成,那么这种双向链表需要的存贮形式:1千个元素每个都有两个指针,外加一定数量的变量(比如100个字节),那么每个链表所需的字节数为:108,000字节。那么1万个链表和它们的第一个元素需要1万个指针,所以总体空间如下:

10,000 * (108,000 + 4) = 1,080,040,000 = 约1GB

的空间。现在一个静态数组就保存了整个数据库,但是没有哈希表结构,如果加上哈希表结构,那么我们需要1千万 x 100的字节空间,所以它们现所需要的空间至少是1亿个字节。

显然,该数据结构保存了大量的记录,这样会因为指针的数量而导致内存过载,因此会让系统运行速度变量;换句话说,轻量级的结构不适用于哈希表,因列表的指针在严重影响内存的使用。

数据结构-树

一颗树是一由连续的结点组成的一种数据结构 (A tree is a data structure consisting of a series of nodes)。每个结点有一些关联信息,也就是指向其它结点的指针。一颗开始于一个根结点,每个结点有一个指针指向它的子结点(descendant)。对于子结点的约束就是它是否有一个直接的父亲(father除了根结点之外),这样就会产生一个树结构。一颗树一般这是样的形式:根(root)、分支(branch)和叶(leave)等。

但是我们一般很难解释树可以被用来做什么。我们可把树看成一条路,它被分成了几个分支,而每个路的最终目标只有一个单一的路径—没有循环,没有交叉,就是一个分支型的路。显然,我们这时会发现树可以大量信息的类型。如果我们找到恰当的分类的键,那么我们可以使用分支来确保我们可以快速找到目标值—查找路径算法。最好的应用就是Window的注册表应用,它就是树的最佳应用说明。下面我们来看树的类型和使用: 树分为两大类,一个是简单的二叉树,然后是N叉树。

二叉树

一个二叉树是明显有一个结点有子结点的数据结构,我们一般使用两个指针表示左右结点。叶结点(没有子结点的结点)也有两个指针,但是我们使用NULL来初始化这两个指针,从而不能再演化出子结点。树中的数据中是以每个结点形式来保存,虽然有些树类型只有叶子、或者没有叶子的结点等。

typedef struct{
    Tree* left;
    Tree* right;
    //here goes the real data from the node
}Tree;

二叉树被广泛用于各种问题场合,它的分支非常自然符合古典的logarithmic search (对数查找法)。每个分支又被同样分为两个分支,它大大的提高了查找速度。比如一个二叉树(Binary Search Tree --BST)它的数据结构是为了加速查找速度,于是需要添加如下规则:

在树中的每个结点,在左边结点中的所有元素的键值,必须小于右边子结点的所有元素的键值。(For each node in the tree, the key value of all elements in the left subnode must be smaller than the key value of all elements in the right subnode.)

以上二叉树的可以非常快的扫描,并且这颗树可以动态添加的。但是BST的不足是,这颗树必须是左右平衡的,如果有些的叶了离根结点很远,那么不是所有的分支都有相同的高度。参见下图:

image-20210420162503368.png

平衡二叉树

为解决以上问题,我们引用一种新的二叉类型—平衡二叉树数据结构(AVL-tree). 一个平衡二叉树就是在BST的基础上添加了给每个结点的约束—相同级别深度,这个深度是左边子树必须是这样的情况:最多有一个单元的深度比右边子树的不同!(An AVL tree is a BST with the additional restriction that for every node in the tree, the depth (in levels) of the left subtree must differ at most by one unit from the depth of the right substree).

总结

在游戏中使用的数据结构其实没有很多,所以,我们在大学时不应该害怕学习《数据结构》这门课程的。

最后

记得给大黍❤️关注+点赞+收藏+评论+转发❤️

作者:老九学堂—技术大黍

著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。