《Windows游戏编程大师技巧》(第二版)第11章

该博客聚焦游戏编程,介绍了数据结构和多线程技术。数据结构方面,讲解了静态结构、数组和链表的使用场景与操作方法;多线程技术方面,阐述了线程创建、终止、等待等操作,以及在游戏和DirectX编程中的应用,还提及了资源管理和可重入性问题。


第三部分:核心游戏编程
  
第11章 算法、数据结构、内存管理和多线程
  
第12章 人工智能
  
第13章 游戏物理
  
第14章 文字时代
  
第15章 综合运用:编写游戏!

第11章 算法、数据结构、内存管理和多线程
"You think I can get a hug after this?"
—Bear, Armageddon(电影《世界末日》)
本章将讨论在其他游戏编程参考书常常会中疏漏的细节问题。我们将涉及编写可保存进度的游戏、演示的制作、优化理论等所有内容。本章将帮您掌握这些必需的编程细节。这样,当我们在下一章讨论人工智能的时候,你已很好地掌握了一些游戏编程的一般概念,甚至连3D运算都不再能难倒你!

本章主要内容如下:
? 数据结构
? 算法分析
? 优化理论
? 数学运算技巧
? 混合语言编程
? 游戏的保存
? 多人游戏的实现
? 多线程编程技术
 
数据结构
 “游戏所应当采用哪种数据结构?”,这几乎是我最常被问到的问题。答案是:最快速最有效率的数据结构。然而,在大多数情况下,你并不需要采用那些所谓最先进也最复杂的数据结构。正相反,你应该尽可能将其简化。在速度比内存更重要的今天,我们宁可先牺牲内存!

记住这一点,我们先来看几个最常用于游戏的数据结构,并给你何时以及如何使用这些数据结构的建议。

静态结构和数组

数据结构最基本的形式,当然就是一个数据项单独出现的形式,如一个结构或类。如下所示:
typedef struct PLAYER_TYP // tag for forward references
        {
        int state; // state of player
        int x,y; // position of player
        // ...
        }  PLAYER, *PLAYER_PTR;
C++
在C++中,你不必使用typedef来定义一个结构类型;当你使用到关键字struct时,编译器会自动为之创建一个类型。此外,C++的struct甚至可以包含方法、公有和私有部分。

PLAYER player_1, player_2; // create a couple players
在这个例子中,一个带有两个静态定义的记录的数据结构就解决问题了。另一方面,如果游戏玩家多于三个,那么较好的做法是采用如下所示的数组:
PLAYER players[MAX_PLAYERS]; // the players of the game
这样,你便可以用一个简单的循环来处理所有的游戏玩家了。Okay,非常好,但是如果在游戏运行以前你不知道会有多少玩家或记录参数,那又该如何呢?
当出现这种情况时,我们应计算出数组所可能具有的元素个数的最大值。如果数目较小,如256或更小,并且每个数组元素也相当的小(少于256字节),我通常会采用静态分配内存,并使用一个计数器来计算某个时刻已激活的元素的数目。
你也许觉得这对于内存而言是一种浪费。但是它比遍历一个链表或动态结构要容易和快速得多。关键在于,如果你在游戏运行前知道数组元素的数目,并且数目不是太大,那么就在游戏启动时通过调用函数malloc()或new()来静态地预先分配内存。
警告
不要沉迷于静态数组!例如,假如你有一个大小为4KB的结构,而且可能有1到256个该结构类型的数组。为防止某些时候数组元素的个数达到256而产生溢出错误,采用静态分配内存方法时必须为之分配1MB的内存。这时,你显然需要更好的方法——采用链表或动态分配的数组来分配内存,以避免浪费。

链表
对于那些在程序启动或编译时可以预估的、简单的数据结构,数组是最合适的处理方法。但对于那些在运行时可能增大或缩小的数据结构而言,应当使用链表(linked list)这类形式的数据结构处理方法。图11-1表示了一个标准的、抽象的链表结构。一个链表由许多节点构成。每个节点都包含信息和指向表中下一个节点的指针。

图11-1:一条链表
 
链表用起来很酷,因为你可以将一个节点插入到链表的任意位置,同样也可以删除任意位置的节点。图11-2示意了节点插入链表的情形。由于在运行时可以插入或删除带有信息的节点,使得作为游戏程序的数据结构,链表很具吸引力。

图11-2:往链表中插入节点
 
链表惟一的缺点是你必须一个接一个地遍历节点来寻找你的目标节点(除非创建第二个数据结构来帮助查询)。例如,假定你要定位一个数组中的第15个元素,你只需这样便可以访问它:
players[15]
但对于链表,你需要一个遍历算法以访问链表的节点来定位目标节点。这意味着在最坏的情况下,查询的重复次数与链表的长度相等。这就是O(n)。O记号说明在有n个元素的情况下要进行与n同阶次数的操作。当然,我们可以采用优化算法和附加的包含排序索引表的数据结构,来达到与访问数组几乎同样快的速度。

创建链表
现在来看一看如何创建一个简单的链表、增加一个节点、删除一个节点,以及搜索带有给定关键字的数据项。下面是一个基本节点的定义:
typedef struct NODE_TYP
   {
   int id;         // id number of this object
   int age;        // age of person
   char name[32];  // name of person
   NODE_TYP *next; // this is the link to the next node
                   // more fields go here
   } NODE, *NODE_PTR;
为了访问一个链表,需要一个head指针和一个tail指针分别指向链表的头节点和尾节点。开始时链表是空的,因而头尾指针均指向NULL:
NODE_PTR     head = NULL,
           tail = NULL;
注意
Some programmers like to start off a linked list with a dummy node that's always empty. This is mostly a choice of taste. However, this changes some of the initial conditions of the creation, insertion, and deletion algorithms, so you might want to try it.
有些程序员喜欢以一个总是为空的哑元节点(即不表示实际数据的节点)作为一个链表的开始节点。这通常是个人习惯问题。但这会影响链表节点创建、插入和删除的算法中的初始条件,你不妨试一试。


遍历链表
出人意料的是,遍历链表是所有链表操作中最容易实现的。

1. 从head指针处开始。
2. 访问节点。
3. 链接到下一节点。
4. 如果指针非NULL,则重复第2、3步。
下面是源代码:
void Traverse_List(NODE_PTR head)
{
// this function traverses the linked list and prints out
// each node

// test if head is null
if (head==NULL)
   {
   printf("/nLinked List is empty!");
   return;
   } // end if

// traverse while nodes
while (head!=NULL)
      {
      // visit the node, print it out, or whatever...
      printf("/nNode Data: id=%d", head->id);
      printf("/nage=%d,head->age);
      printf("/nname=%s/n",head->name);

      // advance to next node (simple!)
      head = head->next;
      }  // end while

print("/n");

} // end Traverse_List
很酷是不是?下一步,让我们看一看如何在链表中插入一个节点。

插入节点
插入节点的第一步是创建该节点。创建节点有两种方法:你可以将新的数据元素传递给插入函数,由该函数来构造一个新节点;或者先构造一个新节点,然后将它传递给插入函数。这两种方法在本质上是相同的。
此外,还有许多方法可以实现节点插入链表的操作。蛮横的做法是将要插入的节点插在链表的开头或结尾。如果你不关心链表中节点的顺序,这倒不失为一个便捷的方法。但如果想保持链表原来的排序,你就应当采用更聪明的插入算法,这样可以保证插入节点后的链表仍然保持升序或降序的顺序。这也可以让以后进行搜索时速度更快。
为简明起见,我举一个最简单的节点插入方法的例子,也就是将节点插在链表的末尾。其实按顺序的节点插入算法并不太复杂。首先要扫描整个链表,找出新节点所要插入的位置,然后将其插入。惟一的问题就是保证不要丢失任何指针和信息。
下面的源代码将一个新节点插入链表的尾部(比插入链表头部难度稍大)。注意一下特殊的情况,即空链表和只有一个元素的链表:
// access the global head and tail to make code easier
// in real life, you might want to use ** pointers and
// modify head and tail in the function ???

NODE_PTR Insert_Node(int id, int age, char *name)
{
// this function inserts a node at the end of the list
NODE_PTR new_node = NULL;

// step 1: create the new node
new_node = (NODE_PTR)malloc(sizeof(NODE)); // in C++ use new operator

// fill in fields
new_node->id  = id;
new_node->age = age;
strcpy(new_node->name,name); // memory must be copied!
new_node->next = NULL; // good practice

// step 2: what is the current state of the linked list?

if (head==NULL) // case 1
   {
   // empty list, simplest case
   head = tail = new_node;

   // return new node
   return(new_node);
   }  // end if
else
if ((head != NULL) && (head==tail)) // case 2
   {
   // there is exactly one element, just a little
   // finesse...
   head->next = new_node;
   tail = new_node;

   // return new node
   return(new_node);
   }  // end if
else // case 3
   {
   // there are 2 or more elements in list
   // simply move to end of the list and add
   // the new node
   tail->next = new_node;
   tail = new_node;

   // return the new node
   return(new_node);
   }  // end else

} // end Insert_Node
As you can see, the code is rather simple. But it's easy to mess up because you're dealing with pointers, so be careful! Also, the astute programmer will very quickly realize that with a little thought, cases two and three can be combined, but the code here is easier to follow. Now let's remove a node.
你可能觉得代码比较简单。但实际上它却很容易造成混乱,因为你处理的是指针,所以要小心谨慎!聪明的程序员脑筋一转便会很快地意识到case 2和case 3可以合二为一,但这里的代码易读性更好。下面我们来看一看节点的删除。

删除节点
删除节点比插入节点复杂,因为指针和内存都要重新定位和分配。大多数情况下,只需删除指定的一个节点。但这个节点的位置可能是头部、尾部或中间,因此你必须编写一个通用的算法来处理所有可能的情况。如果你没有将所有的情况都考虑进去并进行测试,那后果将是非常糟糕的!
一般而言,这个算法必须能够按所给定的关键字搜索链表、删除节点并释放其占用的内存。此外,该算法还必须能够修复指向该被删除节点的指针和该节点所指向的下一个节点的指针。看一看图11-3便会一目了然。

图11-3:从链表中删除节点
 
下面这段代码可以基于关键字ID,完成删除任意节点的操作:
// again this function will modify the globals
// head and tail (possibly)

int Delete_Node(int id) // node to delete
{
// this function deletes a node from
// the linked list given its id
NODE_PTR curr_ptr = head, // used to search the list
         prev_ptr = head; // previous record

// test if there is a linked list to delete from
if (!head)
    return(-1);

// traverse the list and find node to delete
while(curr_ptr->id != id && curr_ptr)
     {
     // save this position
     prev_ptr = curr_ptr;
     curr_ptr = curr_ptr->next;
     }  // end while

// at this point we have found the node
// or the end of the list
if (curr_ptr == NULL)
    return(-1); // couldn't find record
// record was found, so delete it, but be careful,
// need to test cases
// case 1: one element
if (head==tail)
   {
   // delete node
   free(head);

   // fix up pointers
   head=tail=NULL;

   // return id of deleted node
   return(id);
   }  // end if
else // case 2: front of list
if (curr_ptr == head)
   {
   // move head to next node
   head=head->next;

   // delete the node
   free(curr_ptr);

   // return id of deleted node
   return(id);

   } // end if
else // case 3: end of list
if (curr_ptr == tail)
   {
   // fix up previous pointer to point to null
   prev_ptr->next = NULL;

   // delete the last node
   free(curr_ptr);

   // point tail to previous node
   tail = prev_ptr;

   // return id of deleted node
   return(id);

   } // end if
else // case 4: node is in middle of list
   {
   // connect the previous node to the next node
   prev_ptr->next = curr_ptr->next;

   // now delete the current node
   free(curr_ptr);

   // return id of deleted node
   return(id);
   } // end else

} // end Delete_Node
注意代码中包括了许多特殊的情况。尽管每一种情况的处理都很简单,但我还是希望提醒读者,一定要考虑周全,不放过每一种可能的情况。
最后,你或许已经注意到删除链表内部节点的操作极富戏剧性。这是因为这个节点一旦被删除,就无法恢复。我们不得不跟踪前一个NODE_PTR以跟踪末尾的节点。可以使用如图11-4所示的双向链表来解决这个问题及其他类似的问题。
图11-4:双向链表
 
双向链表的优点在于你可以在任何位置从两个方向遍历链表节点,可以非常容易地实现节点的插入和删除。数据结构上,惟一的改变就是添加了一个链接字段,如下所示:
typedef struct NODE_TYP
   {
   int id;        // id number of this object
   int age;       // age of person
   char name[32]; // name of person
   // more fields go here
   NODE_TYP *next; // link to the next node
   NODE_TYP *prev; // link to previous node

   } NODE, *NODE_PTR;
应用双向链表,你可以从任一个节点向前或向后搜索,所以和节点插入和删除有关的跟踪节点操作大大简化。控制台程序DEMO11_1.CPP|EXE便是一个简单的链表操作程序。它可以实现插入节点、删除节点和遍历链表。
注意
DEMO11_1.CPP是一个控制台程序而非标准的Windows .EXE程序。所以在编译前应将编译器设定为控制台应用程序。当然,这里没用到DirectX,因此也不必加载任何DirectX的.LIB文件。

算法分析
算法设计与分析通常是高级的计算机科学知识。但我们至少要掌握一些常识般的技巧和思想,以利于我们编写比较复杂的算法。
首先,要知道一个好的算法比所有的汇编语言或优化器都更好。例如,在前面说过,调整一下数据的顺序便能够减少按元素的幅度搜索数据所花费的时间。因此所应掌握的原则是:选择一个可靠的、适合问题和数据的算法,而与此同时,还要挑选一种易于该算法访问和处理的数据结构。
例如,假如你总是使用线性的数组,你就不要指望能够进行优于线性搜索时间的搜索(除非你使用第二个数据结构)。但如果使用排序的数组,搜索时间就会缩短成对数级别。
编写一个好算法的第一步是掌握一些算法分析知识。算法分析技术又叫渐近分析(Asymptotic Analysis),通常是基于微积分的。我不想过多地深入,所以只介绍一些概念。
分析一个算法的基本思想是看一看n个元素时主循环的执行次数。这里n可代表任何数据结构的元素数目。这是算法分析最重要的思想。当然,有了好的算法后,每次的执行时间、初始化的系统开销也同样重要,但我们从循环执行的次数开始。让我们看一看下面两个例子:
for (int index=0; index    {
    // do work, 50 cycles
    }  // end for index
在这个例子中,程序执行n (=50)次循环,因此执行时间就是n阶即O(n)。O(n)称为大O记法,它是一个上限值,也是对执行时间的一个很粗糙的上限估计。假如要更精确一点,你知道其内部的计算需要50次循环,所以整个执行时间就是:
n*50 cycles
对吗?错了!如果要计算循环时间,你应当将循环自身花费的时间包括进去。这些时间包括变量的初始化、比较、增量和循环的跳转。将这些时间加进去,如下式所示:
Cyclesinitialization+(50+Cyclesinc+Cyclescomp+Cyclesjump)*n
上式是一个较好的估计。这里,Cyclesinc、Cyclescomp和Cyclesjump分别代表增量、比较和跳转所需的周数。在奔腾级的处理器上,其值约为1~2周。因此,在这种情况下,循环本身所花费的时间和程序内部工作循环所需用的时间一样多。这一点是非常重要的。
比如,许多游戏程序员在处理有关像素绘图的问题时,将其编写成函数而非一个宏或内联代码。因为像素绘图函数是如此简单,以至于调用这个函数比直接画图所需时间还要多!所以在设计循环时必须确保循环内部有足够的工作,而且循环运行所需时间要远大于循环自身所花费的时间。

现在让我们来看一看另一个例子——它的时间复杂度高于n:
// outer loop
for (i=0; i    {
    // inner loop
    for (j=1; j<2*n; j++)
     {
         // do work
        } // end for j
     } // end for i
在这个例子中,我们假定循环中工作部分执行所需时间远大于实现循环机制所花费的时间,这样我们就不考虑循环自身花费时间而只考虑循环执行的次数。这个程序的外部循环次数为n次,内部循环的次数为2n-1次,所以内部代码总的执行次数为:
n*(2*n-1) = 2*n2-n
上式由两项构成。2n2是主项,其值要远大于后一项,并且随n取值的增加,两项的差值也增大。如图11-5所示。
.
图11-5:2n2-n的增长速率
 
当n较小比如n=2时,上式的值为:
2*(2)2 - 2 = 6
在该情况下,n这项减去总花费时间的25%。但当n的值增大时,比如n=1000:
2*(1000)2 - 1000 = 1999000
这时,n这项只减去总花费时间的0.5%,可以忽略不计。现在读者该明白了为什么2*n2项是主项或更简单地说n2是主项。所以,这时的算法复杂度是O(n2),这是非常糟糕的,以n2运算的算法是不能令人满意的,因此当你提出这样一个算法时,你最好从头再来!
上述都是为渐近分析作准备的。最低要求是:你必须能够粗略地估计你的游戏程序中循环的执行时间。这将有助于你挑选算法和所需编码空间。

递归
我们下一个所要探讨的主题是递归(Recursion)。递归是一种应用归纳的方法求解问题的技术。递归的基本含义是把许多问题连续分解成同一形式的简单问题,直到能够真正的求解为止。然后将这些小问题进行归纳、组合进而使整个问题得到解决。听起来是不是很美妙?

在计算机编程中。我们通常使用递归算法来实现搜索、排序和一些数学计算。递归的前提非常简单:编写一个函数,该函数具有调用自己来求解问题的能力。是不是听起来不可思议?其关键在于该函数调用自己时,就在堆栈里创建一组新的局部变量,所以相当于一个新的函数被调用。你惟一需要注意的是函数不能溢出堆栈,并且要有终止条件,程序结束时还要有结束处理代码以保证堆栈通过return()释放空间。让我们看一个标准的实例:阶乘的计算。一个数的阶乘写作n!,其含义如下:
n! = n*(n-1)*(n-2)*(n-3)*...(2)*(1)
并有0! = 1! = 1
Thus, 5! is 5*4*3*2*1.
这样,5!就是5*4*3*2*1。
以下是用通常的方法编写的计算代码:
// assume integers
int Factorial(int n)
{
int sum = 1; // hold result

// accumulate product
while(n >= 1)
     {
     sum*=n;

     // decrement n
     n--;

     } // end while

// return the result
return(sum);

} // end Factorial
看上去非常简单。如果输入0或1,则计算结果为1。若输入3,则其计算顺序如下:
sum = sum * 3 = 1 * 3 = 3
sum = sum * 2 = 3 * 2 = 6
sum = sum * 1 = 6 * 1 = 6

显然,计算结果正确无误。因为3! = 3*2*1。

以下是采用递归方法编写的程序:
int Factorial_Rec(int n)
{
// test for terminal cases
if (n==0 || n==1) return(1);
else
   return(n*Factorial_Rec(n-1));

} // end Factorial_Rec
这个程序并不怎么复杂。我们看看当n分别为0和1时,程序是如何运行的。在这两种情况下,第一个if语句为TRUE,就返回值1并退出程序。但当n>1时,奇妙的事情就发生了。这时,执行else语句并返回该函数调用自身(n-1)次后的值。这就是递归过程。
当前函数变量的取值仍在堆栈里保存,下次调用该函数就相当于调用一个以一组新变量为参数的函数。代码中第一个return语句在进行下一个调用前不能执行完毕,就这样一直循环下去直到执行结束语句为止。
让我们来看一看n=3时,每次迭代时变量n的实际数值。
1. 

第一次调用函数Factorial_Rec(3),
函数开始执行return语句:
return(3*Factorial_Rec(2));
2. 

第二次调用函数Factorial_Rec(2),
函数又开始执行return语句:
return(2*Factorial_Rec(1));
3. 

第三次调用函数Factorial_Rec(1),
这次函数执行结束语句并返回值1:
return(1);
现在奇妙的是,1被返回给第二次调用的函数Factorial_Rec(),如下所示:
return(2*Factorial_Rec(1));

这也就计算出了
return(2*1);</

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值