程序员如何实现财富自由系列之:利用程序员技能成为游戏开发者
作者:禅与计算机程序设计艺术
1.背景介绍
近年来,创造性的游戏开发模式受到了越来越多人的青睐。游戏具有美学价值、艺术价值和动力源泉。其中,游戏开发作为一个创新活跃的领域,也是创作者最先期的契机,给予创作者极大的想象空间和活动空间,也因此成为一种吸引力十足的职业。然而,对于程序员来说,游戏开发又是一个新的世界。掌握了游戏编程知识并能够编写符合游戏引擎API规定的游戏代码,无疑成为进入游戏行业的一把钥匙。可以说,游戏编程不但要有极高的能力和理解能力,更需要对生活充满激情和自信。 作为一名游戏开发者,首先需要具备良好的编码习惯和学习能力。否则将无法摆脱低级错误,导致程序不能按时交付。其次,熟练掌握游戏编程中的常用数据结构和算法,对提升编程速度、减少调试难度、降低出现问题的风险有着至关重要的作用。此外,还需具备较强的沟通能力和团队合作精神,能够及时跟进项目进展和处理工作中的困难问题。最后,还要懂得怎样做好个人发展计划和拓展职业发展空间,最大程度地满足个人需求和追求,这是成功的关键。
2.核心概念与联系
在游戏编程中,经常会涉及到一些基础概念或工具,比如数据结构(比如队列和栈)、算法(如贪婪法、回溯法等)、面向对象编程(OOP)、软件工程(比如版本管理、单元测试、设计模式等)、计算机图形学(比如数学变换、光照计算等)。这些概念是如何运用的,是非常有意义的。这里,我把游戏编程所涉及到的基本概念和工具整理如下表:
| 名称 | 英文名 | 描述 |
|---|---|---|
| 数据结构 | Data Structure | 是计算机存储、组织、运算的数据集合 |
| 队列 | Queue | 先进先出的数据结构 |
| 栈 | Stack | 后进先出的数据结构 |
| 算法 | Algorithm | 用来解决特定问题的一套指令集 |
| 贪婪法 | Greedy | 没有考虑全局最优,每次只找局部最优 |
| 回溯法 | Backtrack | 从前往后尝试所有可能情况,找出目标 |
| OOP | Object-Oriented Programming | 把数据和功能组织成对象的编程方法 |
| 类 | Class | OOP 的基本单元,用于描述具有相同特征和行为的对象 |
| 对象 | Instance | 在内存中创建的类的具体表示 |
| 方法 | Method | 类中定义的函数 |
| 软件工程 | Software Engineering | 应用于软件开发生命周期各个阶段的研究、过程和方法 |
| 版本控制 | Version Control | 用来记录文件修订历史、维护不同版本的软件的方法 |
| 分支 | Branch | 对软件进行改进或者添加新功能的临时的工作区 |
| 主干 | Mainline | 最新、最全、最重要的分支 |
| 发布 | Release | 将分支上的修改作为一个整体并交付用户使用的过程 |
| Bug | Bug | 程序中的缺陷、错误 |
| 测试 | Test | 为了发现和防止程序错误而执行的自动化的软件过程 |
| TDD | Test Driven Development | 以测试驱动开发的方式开发软件 |
| 函数式编程 | Functional programming | 以数学函数为核心的编程范式 |
| 命令式编程 | Imperative programming | 以命令序列为中心的编程范式 |
| 数组 | Array | 一组按一定顺序排列的数据元素 |
| 指针 | Pointer | 指向计算机内存位置的变量 |
| 链表 | Linked List | 由节点相互连接的各种数据的线性集合 |
| 文件 | File | 用于存储、组织数据的文件 |
| API | Application Programming Interface | 应用程序与操作系统之间的接口 |
| 线程 | Thread | 轻量级进程,可独立运行的程序执行单元 |
| GPU | Graphics Processing Unit | 专门用于渲染三维图像和雷达图案的处理芯片 |
这些基本概念和工具有助于我们了解游戏编程的方方面面,也会对我们今后面试和学习有所帮助。
3.核心算法原理和具体操作步骤以及数学模型公式详细讲解
在游戏编程中,有许多算法和数据结构被频繁使用。例如,贪婪法、回溯法、排序算法等都是常用的算法。下面,我就从贪婪法、回溯法和排序算法三个角度,分别讲解一下它们的原理和操作步骤。
贪婪法
贪婪法是一种在每一步选择中都采取在当前状态下最优选择的算法策略。贪婪法没有考虑全局最优,只能找到局部最优解。其基本思路是:自顶向下,从左到右,一次性选择最优子结构。贪婪法可以简单地描述为:
- 初始化一个问题的最优解的值;
- 根据当前的局部最优解构造问题的一个实例;
- 重复第2步直到得到问题的一个解;
下面是一个实际例子:
假设有四个房间,每个房间都有两种颜色的衣服。要在这四个房间里选取若干房间,使得在任意两室内,每种颜色的衣服出现的概率之和最大。
贪婪法算法流程:
-
建立一个数组
dp[i][j],dp[i][j]表示在前 i 个房间选 j 个房间的时候,每种颜色的衣服出现的最大次数。 -
初始化
dp[i][0] = dp[i][1] = i,因为只有一个房间,所以衣服的总数只能是 i 或 (n - i)。 -
使用循环依次枚举前 i 个房间的数量
k,从 0 到 n - k 即选 k 个房间。
a. 更新 dp[i+1][0] 和 dp[i+1][1] 的值。
if k > 0:
for c in range(len(colors)):
dp[i+1][c] = max(dp[i+1][c], dp[i][0]*p_color[c]) # 在第一个房间中选 c 件衣服
dp[i+1][c] = max(dp[i+1][c], dp[i][1]*p_color[(c + 1) % len(colors)]) # 在第二个房间中选 c 件衣服
else:
for c in range(len(colors)):
dp[i+1][c] = dp[i][0]*p_color[c] # 空集情况
dp[i+1][(c+1)%len(colors)] += p_same[c] * (n-(i+1))
代码解读
上面的代码片段的含义是:如果前 i 个房间中有 k 个房间,那么,在第 i+1 个房间中,第一个房间可以选择颜色 c,第二个房间可以选择颜色 c+1,并且两个房间有相同的概率 p_same。其他情况下,仅考虑第一个房间选择颜色 c 的概率即可。
b. 更新 dp[i+1][(c+1)%len(colors)]。dp[i+1][(c+1)%len(colors)] 表示在第 i+1 个房间中,第二个房间可以选择颜色 c+1。如果某个房间已经满了,则可以认为它的颜色已经确定,无法再改变。因此,在更新 dp[i+1][(c+1)%len(colors)] 时,需要除去第 i 个房间的选色结果。
c. 判断是否终止条件。当遍历完所有的 k 时,判断是否有一种方式可以确保每种颜色的衣服出现的次数都最多。假设有 m 种颜色,则需要保证 dp[i][j] <= floor((m+2)*j/2),其中 floor() 是取小整数的函数。
- 返回
dp[n][0]和dp[n][1]中的最大值。
这个问题可以使用贪婪法求解。它的时间复杂度是 O(nm)。但是它还有改进版——二分搜索法,时间复杂度可以降到 O(\log nm)。
回溯法
回溯法是一种非常古老的算法,它主要用于组合搜索。其基本思想是:穷尽所有可能的路径,直到找到一条失败的路径为止。回溯法通过递归实现,在搜索过程中遇到失败就退回到上一步,重新选择另一条路径继续探索。
回溯法适用于“多目标”问题,即存在多个不同的目标。回溯法要求程序以深度优先搜索的方式查找问题的所有解。具体的搜索策略是:
- 指定解空间树的根结点;
- 在当前结点的所有子结点中寻找一个子结点,该结点要满足一个约束条件,并满足该结点为“扩展”状态;
- 如果找到了一个可行的子结点,则进入该子结点,继续搜索;
- 如果没有找到可行的子结点,则回溯到父结点;
- 当到达树的根结点时,如果找到了一个正确的解,则输出并结束搜索;
- 如果到达树的根结点仍然没有找到正确解,则说明不存在正确解。
下面是一个棋盘问题的示例:
给定一个 9x9 的棋盘,其中每个格子可以放置皇后。求解最少需要多少步可以将所有的皇后放到棋盘上。
回溯法算法流程:
- 初始化棋盘为空。
- 调用
placeQueen()函数,传入坐标(0, 0),尝试放置第一颗皇后。 placeQueen()函数尝试在当前坐标(r, c)处放置一颗皇后。- 如果放置成功,则继续尝试放置下一颗皇后,直到尝试完所有可能的位置。
- 如果放置失败,则回溯到之前保存的状态。
- 当放置最后一颗皇后时,输出 “找到了一组解”。
def placeQueens(board):
# 检查是否可以放置皇后
def isValid(row, col):
for r in range(row):
if board[r] == col or abs(col - board[r]) == row - r:
return False
return True
n = len(board)
col = 0
while col < n:
row = len(list(filter(lambda x : x!= None, board)))
if not isValid(row, col):
break
board[row] = col
placeQueens(board)
del board[-1]
print(board)
if __name__ == '__main__':
board = [None]
placeQueens(board)
代码解读
回溯法时间复杂度:O(n!) 。原因是因为在放置第 k 颗皇后的同时,还存在 n-k 个格子可以放置皇后,每放置一颗皇后都会产生 n-k-1 个新的子问题,故有 C_{n}^{k} 种组合。
排序算法
排序算法是指对一串数据进行排序的一些算法。主要的排序算法有插入排序、选择排序、冒泡排序、快速排序、堆排序、计数排序、桶排序等。
插入排序
插入排序是一种简单的排序算法,它的思想是在一个已排序的序列中找到适当的位置将一个元素插入,使得序列依然保持有序。
插入排序算法流程:
- 从第二个元素开始,每个元素都与前面各个元素比较,找到相应的位置并移动到该位置。
- 如果该位置已有元素,则替换该元素。
- 重复步骤2。
下面是一个示例:
Input array: {5, 2, 7, 3}
Output sorted array: {2, 3, 5, 7}
Step 1: Insertion sort on the second element of the input array
{2, **5**, 7, 3}
{2, **2**, 7, 3} // This step is skipped because the previous element is smaller than the current one
{**2**, 5, 7, 3}
{2, **3**, 7, 5}
Step 2: Insertion sort on the third element of the input array
{2, 3, **7**, 5}
{2, 3, **5**, 7} // This step swaps with the first element to maintain ascending order
{2, 3, **5**, 7}
Sorted array after completing both steps: {2, 3, 5, 7}
代码解读
插入排序的平均时间复杂度为 O(n^2) ,且效率低下。由于它的效率太低,一般不会直接采用插入排序,而是采用其改进版——希尔排序。
选择排序
选择排序是一种简单且易于实现的排序算法。它的思想是通过遍历数组找到最小/最大的元素,并将它与数组的第一个元素交换,然后再次遍历数组,找到第二个最小/第二个最大的元素,以此类推,直到整个数组排序完成。
选择排序算法流程:
- 遍历数组,找到最小元素,将其与第一个元素交换。
- 遍历剩余的元素,找到最小元素,将其与第二个元素交换。
- 重复步骤2。
- 直到遍历完整个数组。
下面是一个示例:
Input array: {5, 2, 7, 3}
Output sorted array: {2, 3, 5, 7}
First pass: Find the smallest value and swap it with the leftmost value, leaving {2, 5, 7, 3}.
Second pass: Find the smallest value that's greater than 2 and swap it with 5, resulting in {2, 3, 7, 5}.
Third pass: The remaining values are already sorted, so we stop here. We end up with the final sorted array: {2, 3, 5, 7}.
代码解读
选择排序的平均时间复杂度为 O(n^2) ,且效率低下。
冒泡排序
冒泡排序是一种稳定排序算法。它的思想是通过两两比较来交换元素,使得逐渐减少逆序元素的个数。
冒泡排序算法流程:
- 比较相邻的元素,如果第一个比第二个大,则交换他们的位置。
- 对每一对相邻元素,重复该步骤,除了最后一个。
- 持续这一过程,直到没有任何一对相邻元素需要比较。
下面是一个示例:
Input array: {5, 2, 7, 3}
Output sorted array: {2, 3, 5, 7}
Pass 1: {5, 2, 7, 3} // Compare 5 and 2, swap them since 5 > 2
{2, 5, 7, 3}
Pass 2: {2, 5, 7, 3} // Compare 5 and 7, no swap needed
{2, 5, 7, 3}
Pass 3: {2, 5, 3, 7} // Compare 5 and 3, swap them since 5 < 3
{2, 3, 5, 7}
Pass 4: {2, 3, 5, 7} // The list is now fully sorted
{2, 3, 5, 7}
代码解读
冒泡排序的平均时间复杂度为 O(n^2) ,且效率不错。
快速排序
快速排序是一种基于分治法的排序算法。它的基本思想是通过选取一个“基准值”,然后将比该值小的元素放在其左边,将比该值大的元素放在其右边。然后,在两个子列表中重复以上过程,直到子列表变成单个元素。
快速排序算法流程:
- 从数组中选取一个元素,称为 “基准值”(pivot)。
- 将数组划分为两个子列表,左边的子列表包含所有比 “基准值” 小的元素,右边的子列表包含所有比 “基准值” 大的元素。
- 递归地排序左右两个子列表。
- 最终返回排序好的数组。
下面是一个示例:
Input array: {5, 2, 7, 3}
Output sorted array: {2, 3, 5, 7}
Choose pivot as 3
{2, 5, 7}, 3, {3}
Recursively apply quicksort algorithm to the two sublists {2, 5, 7} and {} until they become single elements.
Sublist {2, 5, 7}: Choose pivot as 5
{}, 5, {2, 7}
Recursive call to {2, 7}: base case reached. Swap 5 and 7 to get {}, 7, {2}. Sublist becomes {2}.
Sublist {}: Base case reached. Swap 2 and {} to get {2}, 7, {}. Sorted sublist remains empty.
The final sorted array is {2, 3, 5, 7}.
代码解读
快速排序的平均时间复杂度为 O(n \times log(n)) ,且效率较高。
堆排序
堆排序是另一种基于堆的数据结构,特别适合于实现优先队列。它的基本思想是建立一个大顶堆(堆的根节点是所有节点中最大的),然后把根节点与最后一个节点交换,并调整剩下的元素使得剩下的元素构成一个新的大顶堆。重复这一过程,直到所有元素都排列到位。
堆排序算法流程:
- 创建一个最大堆。
- 删除最大元素,并将其放入结果数组。
- 减小堆的大小,继续步骤2。
下面是一个示例:
Input array: {5, 2, 7, 3}
Output sorted array: {2, 3, 5, 7}
Build heap from array {5, 2, 7, 3}: {5}, {2, 7}, {2, 3, 7}
Heapify the root node of each heap, resulting in {5, 7}, {3, 7, 2}
Swap the last two elements of each heap, resulting in {5, 7}, {2, 3, 7}
Add the largest element back into its original position, resulting in {5, 2, 7, 3}
Finally, the result array is {2, 3, 5, 7}.
代码解读
堆排序的平均时间复杂度为 O(n\times log(n)) ,且效率较高。
计数排序
计数排序是一种非比较排序算法。它的基本思想是统计数组中每个元素的频率,然后根据频率来构建输出数组。
计数排序算法流程:
- 找到数组的最大值和最小值,并根据范围创建一个长度为 (max - min + 1) 的计数数组。
- 对输入数组中的元素进行计数,将计数器累加到对应的计数位置。
- 生成一个输出数组,其元素按照输入数组元素的索引来排列。
下面是一个示例:
Input array: {5, 2, 7, 3, 5, 2, 7}
Output sorted array: {2, 2, 3, 5, 5, 7, 7}
Count occurrences of each number in the input array:
{5, 2, 7, 3} => {0, 1, 2, 1}
Create an output array by using this count information: {2, 1, 3, 0, 0, 2, 1}
Apply decreasing order counting method to get the final result: {2, 2, 3, 5, 5, 7, 7}
代码解读
计数排序的平均时间复杂度为 O(n + k) ,其中 k 为数组中元素的范围,且 k 可以比 n 小很多。
桶排序
桶排序是一种非比较排序算法。它的基本思想是将待排序元素划分到有限数量的桶里,然后对每个桶中的元素进行排序。
桶排序算法流程:
- 设置桶的个数,并创建相应的桶。
- 将元素分配到各自的桶。
- 对每个桶内的元素进行排序。
- 将各个桶中的元素合并成一个数组。
下面是一个示例:
Input array: {5, 2, 7, 3, 5, 2, 7}
Output sorted array: {2, 2, 3, 5, 5, 7, 7}
Set numBuckets equal to sqrt(|inputArray|)
{5, 2, 7, 3, 5, 2, 7} -> {5, 2, 7, 3, }, {, }
Sort elements within each bucket using any comparison-based sorting algorithm such as insertion sort
{5}, {2, 7}, {3}
Merge all buckets together to produce the final output: {2, 2, 3, 5, 5, 7, 7}
代码解读
桶排序的平均时间复杂度为 O(nk + n \times log(k)) ,其中 k 为桶的个数,且 k 可比 n 小很多。
