Data Structures[翻译]
2007-03-01 19:38 老博客哈 阅读(1308) 评论(4) 编辑 收藏 举报 Data Structures
【原文见:http://www.topcoder.com/tc?module=Static&d1=tutorials&d2=dataStructures】
作者 By timmac
TopCoder Member
翻译 农夫三拳@seu
drizzlecrj@gmail.com
即使计算机能够毫不夸张的每秒执行上百万次的数学运算,当一个问题变得庞大且复杂时,性能仍然是一个很重要的考虑方面。最至关紧要的关于快速解决问题的方面之一就是数据在内存中是如何存储的。
为了举例说明这点,可以试想你进入一个图书馆去查找某个学科的一本书。最有可能的是你能够使用一些电子参考或者在最坏情况下,有一个卡片目录来帮助你找到你想要的书的名称和作者。由于书籍都是按目录进行排放的并且在每一个目录中是按照作者的姓名排序的,因此这是一个直接并且轻松的过程,那么然后你就可以在书架上找到你想要的书了。
现在,假定你去图书馆找一本特定的书,然而这里没有排放好的书架,只有在房间角落有一些排成行的袋子,里面放满了可能相关可能不相关的书。这样就可能需要数个小时甚至数天来找到你需要的书了,这是一个对比性强的道理。这就是数据在没有存储为与应用相关的格式时软件运行的情况。
简单的数据结构(Simple Data Structures)
最简单的数据结构是原生的变量。他们存放单个值,并且使用中受限。当许多值需要存储时,可以使用一个数组。假定这篇文章的读者对变量和数组有一个很好的理解。
一个稍微复杂的同样是原声类型的概念是指针。指针理论上存储的是内存中一个指向某个有用的值的地址而不是一个实际的值。大多数经验丰富的C++程序员都能够很好的理解如何使用指针和多种申请功能,而新手程序员也许被更加高级的那些隐式使用指针的时髦“托管”语言或好或坏的困扰着。
理解指针是“指向”内存中的某处而不是存储实际的值就足够了。
一个稍微不抽象点的方法来思考指针可以想想人们是怎么记住(或者记不住)一些特定的事情的。一个好的工程师可能记许多次也记不住一个特定的公式/常数/方程式,但是当他们被问到时,他们能够很准确的告诉你可以在哪边查阅。
数组(Arrays)
数组是一个简单的数据结构并且可以考虑成具有固定长度的列表。数组很实用因为它比较简单并且在数据量已知(或者可以在程序中决定)的情况下很适合。假定你需要一小段代码来计算几个数的平均数。数组则是一个存放单个数据的完美的数据结构,因为他们没有指定的顺序并且所需的计算除了遍历所有的值就没有其他特别的处理了。数组的另外一个大的好处在于它们支持使用索引进行随机访问。例如,如果你有一个包含教室里在座的一系列学生名字的列表,每一个座位的编号从1到n,那么studentName[i]则是读取座位号i的学生姓名。
数组也可以看成一个预先装订好的纸张。它有着固定数量的页数,每页都存储信息并且处在预先定义好的位置,这些位置将永不改变。
链表(Linked Lists)
一个链表是一个能够存储任意数量数据的数据结构,并且可以轻松的增加和删除项。链表最简单的形式是一个指向数据节点的指针。每一个数据节点包含数据的组合(有可能一个是一个有着多个数据的记录),和一个指向下一个节点的指针。在链表的尾端,指针被设置为null。
这种设计是的链表对于存储一个大小未知或者经常改变的数据项是非常好的。尽管如此,它没有提供一个访问链表中任意一个元素的方法,只有从开头遍历到结尾直到遇到的节点是你想要的。对于在指定位置插入一个新的节点也是一个同样的道理。这个效率应该不难看出了。
一个典型的链表实现将会有定义一个节点的代码,就像这样:
String data;
ListNode nextNode;
}
ListNode firstNode;
你可以写一个方法通过在链表开头插入元素来增加新的节点:
NewNode.nextNode = firstNode;
firstNode = newNode;
Iterating through all of the items in the list is a simple task:
ListNode curNode = firstNode;
while (curNode != null) {
ProcessData(curNode);
curNode = curNode.nextNode;
}
相关联的数据结构是双端链表,它稍微解决了这个问题。它与典型的链表之间的区别在于它的根数据结构存储了一个指向第一个和最后一个节点的指针。每一个节点有一个指向链表中前一个和后一个的节点。这样创建的数据结构更加灵活,它使得从不同方向进行遍历成为可能。但是这样仍然的操作仍然有限制。
队列(Queues)
一个队列是用“先进先出”描述在好不过的数据结构了。现实生活中的一个例子是在银行排队。每一个人进入银行,他或她都在队伍的尾部“入列”。当一个出纳员窗口可用时,他们从队列的前端“出列”。
也许在TopCoder问题中最常使用队列的地方就是实现广度有限搜索了(BFS)。BFS指的是首先在第一步到达所有能够达到的状态 ,然后到达需要二步的所有状态,等等。由于队列能够存储所有访问过的状态空间,因此能够帮助实现这个解决方案。
一个常见类型的题目是在迷宫中找最短路径。从原始的点出发,找出所有在一步之内能够到达的地点然后将它们加入到队列中。然后,从队列中取出一个位置,然后找到所有能够在多一步能够到达的地点,然后在队列中加入这些新的地点。重复这个过程直到要么找到最短路径要么队列为空(在这个情况下不存在路径)。不管什么时候要求“最短路径”或者“最少移动次数”,通过使用队列来实现BFS都将是得到正确解的一个好的机会。
大多数标准库,像Java API和.Net framework,提供了一个Queue类来提供在队列中添加和移除元素的基本接口。
BFS类型的问题在比赛中经常遇到;在一些问题中,可以很快且简单的发现BFS,而有时候则不是那么明显。
一个队列的实现可以简单到一个数组和一个指向当前数组位置的指针。例如,如果你试图在50*50的网格上从点A走到点B,并且直到你面对的方向(或者其他一些细节)与问题无关,那么你知道有2500个“状态”要去访问。因此,你可以像下面一样写出你的队列:
int xPos;
int yPos;
int moveCount;
}
class MyQueue {
StateNode[] queueData = new StateNode[2500];
int queueFront = 0;
int queueBack = 0;
void Enqueue(StateNode node) {
queueData[queueBack] = node;
queueBack++;
}
StateNode Dequeue() {
StateNode returnValue = null;
if (queueBack > queueFront) {
returnValue = queueData[queueFront];
QueueFront++;
}
return returnValue;
}
boolean isNotEmpty() {
return (queueBack > queueFront);
}
}
然后,你的解决方案中的主程序可以像下面这样。(注意如果你的队列访问了所有的状态而仍然没有到达我们的目的地,那么肯定不能到达那里,因此我们返回值“-1”)
queue.Enqueue(initialState);
while (queue.isNotEmpty()) {
StateNode curState = queue.Dequeue();
if (curState == destState)
return curState.moveCount;
for (int dir = 0; dir < 3; dir++) {
if (CanMove(curState, dir))
queue.Enqueue(MoveState(curState, dir));
}
}
栈(Stacks)
栈,从某种意义上来说,是队列的反面,因为它们被描述成“后进先出”。一个经典的例子就是小卖部里面堆叠的碟子。服务员们可以不断的在上面摆放干净的碟子,但是每次,一个客人将会从顶部拿走碟子,而那个碟子是最后一个放上去的。
然后栈似乎很少显式的实现,深入理解它们是如何工作以及它们如何隐式的被使用是非常值得学习的。那些已经编过一段时间程的人可能对于每次在一个程序中呼叫一个子程序里的栈使用非常亲切。任何参数,通常是局部变量,都在栈上分配了空间。然后,在子程序结束之后,局部变量被移除了,返回值从栈中“弹出”,这样程序才可以才刚刚停下呼叫子程序的地方继续往下执行了。
理解一个函数调用其他函数而被调用的函数继续调用其他函数意味着什么是非常重要的。每个函数调用都会增加“嵌套层数”,(函数调用的深度,如果你愿意这么叫),并且会增加栈上的空间使用。首要的一个例子就是递归函数。当一个递归函数不断的调用自身,栈空间随着递归深度的不断增加飞快的被占据。几乎每一个经验丰富的程序员都犯过写一个递归函数没有正确返回的错误,那样的递归函数不断的调用自身直到系统抛出“栈空间不足”类型的错误。
虽然如此,这些关于递归深度的讨论还是很重要的,因为栈,即使在不显式使用的情况下,都是深度优先搜索的核心。一个深度优先搜索很典型的应用是遍历一棵树,例如在XML文档中查找某一个特定的结点。栈在某种意义上负责维护一条到达当前节点路径,因此程序中能够“回溯”(例如,递归函数调用中没有找到想要找的节点)并且继续执行相邻的下一个节点。
Soma(SRM198)是使用这种方法的一个经典问题。
树(Trees)
树是一个具有一个或者多个数据节点的数据结构。第一个节点被称作“根”并且每一个节点有0个或者多个“子节点”。单个节点的最大子节点数目和子节点的最大深度在某些情况下都是受到树所表示的具体数据而限制的。
一个关于树的最常见的例子就是XML文档了。最顶端的文档元素是根节点,它下面的每一个标签都是一个子节点。那些标签也都可以继续有子节点。在每一个节点中,标签的类型以及任何属性组成了该节点的数据。在这样一棵树中,节点的层次和顺序都是定义好的并且是数据本身很重要的一个部分。另外一个树的例子就是书面大纲。整个大纲本身就是一个包含每一个顶级要点的根节点,每一个这样的要点用可能
包含一个或者多个子要点,等等。在大多数磁盘上的文件存储系统就是树结构。
机关的结构也使得它们称为了一个树的结构.在一个典型的管理阶层中,一个董事长下面可能有一个或者多个副董事长,而每一个副董事长
都管理这几个经理,而每一个经理负责着多个员工。
PermissionTree (SRM 218) 是一个平常的文件系统中不平常的问题。
bloggoDocStructure (SRM 214) 是另外一个使用树的好的例子
二叉树(Binary Trees)
二叉树是树的一个特例。二叉树是一种存储和读取一系列记录的高效方式之一,通过使用某种方法可以使用键值进行索引。一个二叉树的思想在于它的每一个节点最多有两个孩子。
在最典型的实现之中,左节点的键值小于它的父亲节点,而右节点的键值大于它的父亲节点。因此,存储在二叉树中的数据可以使用键值进行索引。当遍历一棵二叉树来查找指定键值时,可以很容易的决定遍历哪个子节点。
有人可能会问为什么选择使用二叉树而不是一个排好序的数组呢。在查找一个值的情况下(通过遍历二叉树,或者在排好序的数组中进行二份查找)所需要的时间复杂度均为O(log n)。尽管如此,向二叉树中增加一个新的项则是一个简单的操作。相反,在排好序的数组中任意插入一个元素则需要花时间在重新组织数据的顺序上。
如果你曾经用尝试过用辨识手册来辨认你在野外找到的树叶,那么这是一个用来理解怎样在二叉树中查找数据的好方法。使用辨识手册的时候,你从头开始回答一系列诸如“叶子是带齿的,还是光滑的?”之类只有两种回答可能的问题。根据你得到的答案,你缩小细节范围, 你可能知道了名字或许更多关于树叶的信息。如果某人是这个辨识手册的修订者,那么新的种类的生物能够像通过问这些问题一样这样加入到其中。最后向其中插入一个新的问题,它得到的树叶不同于其他相似的树叶。在电脑中,问一个节点的问题就可以简单成“你是小于还是大于x啊?”
优先队列(Priority Queues)
在一个经典的广度有限搜索(BFS)算法当中,一个简单的队列可以很好的保存所有被访问的状态。由于每一个新的状态都比当前状态要多一步操作,所以在队列末端加入新的位置对于找到最快的路径已经足够了。然后,这里假设了从一个状态到一个状态只要一步。
让我们考虑另外一个例子,当你正在开着一辆车并且希望尽快的到达目的地。一个典型的问题可能告诉你你可以在一分钟之内向上/下/左/右移动一个街区。在这样的情况下,一个简单的BFS能够很好的工作,并且可以保证一个正确的结果。
但是如果我们告诉你你在两分钟之内才能向前移动一个街区,并且需要3分钟来掉转车头并向前移动一个街区(这个方向指的是与车原来驶向的方向相反的方向)。依照我们所执行的移动操作,新状态将不是简单的比现有状态多一步,并且一个简单队列的“有序性”就丢失了。
这就是为什么有了优先级队列。简单点说,优先级队列接受状态并且在内部使用一个方法来存储它们,其目的是要能快速的在队首得到最少的花费值(由于“最少时间/最短路径”问题的本性,我们通常最先想要的是最少的花费。)
现实世界中的一个优先队列的例子就是等待上飞机的时候。来的早的人趋向于坐的靠近门一点,这样他们就能够在一被叫到就能到队伍中。尽管如此,那些拥有“金卡”或者乘坐第一航班的人不管什么时候来的则总是先被叫到。
优先队列一个非常简单的实现是仅仅使用一个数组来(一个一个)查找包含最小费用的状态,并且在尾部添加新的元素。这样的实现在插入时有一个很好的时间复杂度,但是对于取出状态实在是慢的可怕。
一个特殊类型的叫做堆的二叉树被用来实现优先级队列。在一个堆中,根节点总是小于(或者大于,依赖于你是怎么实现“优先级” 的)它的孩子节点们。更进一步的,这个树是一个“完全树”。关于完全树的一个简单点的定义是没有一个分支深度为n+1直到所有其他分支的深度深度为n层的树。也就是说,它左侧的节点总是最先被填充。
想要从堆中取出一个值,只要取出根节点的值(它具有最小的消耗或者最高的优先级)就可以了。深度最大,最靠右的叶子成为了新的根节点。如果这个新的节点大于它的左孩子,则将根节点与左孩子进行交换,其目的是维护根节点的值总是比它的子女要小的属性。这个有必要的话就一直沿着左分支下去。向堆中添加一个元素恰恰相反。新的值作为下一个叶子被添加,然后有必要的话就不断的向上进行交换来维护堆的属性。
完全树的一个方便的属性在于它们可以非常有效的存储在一个数组中。一般来说,数组的0号元素是根,并且k+1和k+2是元素k的孩子节点。所带来的影响就是在添加下一个叶子的时候可以直接在数组尾部加。
散列表(Hash Tables)
散列表是一个独一无二的数据结构,它通常用来实现一个“字典”接口,靠的是一系列键对应一个相关联的值。键被用来作为一个索引来照到联系的值。这就像一个字典,可以通过照到一个指定词(键)的定义(值)。
不幸的是,并不是每种类型的数据都能够很容易的像一个简单的字典单词那样排好序,这就是为什么有“散列”这个玩意了。散列是从数据的片段生成一个值(在这种情况下,通常是32位或者64位的整数)的过程。这个散列值将会成为组织数据和进行排序的一个基础。散列值可能是数据的前n位,后n位,值的模数或者某些情况下有一个复杂函数计算得到。使用这些散列值,不同的“散列桶位”都可以设置用来存储数据。如果散列值均等的分布(这是理想情况下散列算法的情况),那么散列桶位将趋向于均等的填充,并且在很多情况下,大多数的桶位里只有不多于1个对象或者只有少数的一些对象。这使得查找过程非常迅速。
一个散列桶位包含多于1个的值成为“冲突”。冲突处理的实现是特定的并且对散列表的性能至关重要。最简单的方法是在散列桶位置中实现一个像链表一样的结构,那样的话,具有相同散列值的元素就可以在正确的位置链接起来了。其他的更加复杂的方案包括利用相邻位,表中没有使用的位置,或者重新计算散列来得到一个新的值。总之,任何方法都有好的和坏的地方(考虑时间,空间大小和复杂度)
另外一个关于散列表的例子是在很多图书馆里使用的Dewey decimal system。每本书都根据主题指派了一个数字。例如500的都是自然科学书籍,700的都是艺术书籍,等等。和一个真实的散列表很像,某人查找一本给定书的速度是由散列桶被均分的程度决定的。在一个都是科学资料的图书馆里查找青蛙的书籍要比在一个大部分书都是经典文学的图书馆里查找要花费更长的时间。
在应用开发中,散列表是一个用来存储参考数据的好地方,像将一个州的简称链接到全名。在解决问题当中,散列表在使用分治来解决背包问题上很有用。我们有38根管道,现在要求找出最少数量的管道来铺设一个给定长度的单线管道。通过将这个问题分为两个19的集合,计算每个集合中可能得到的长度,我们可以创建将管道的长度和最少需要使用的管道数量相关联的散列表。那么,对于一个集合中可以铺设的管道长度,我们可以很容易的判断是否能够在另外一个集合中照到对应的长度,这两个部分连接起来来得到想要的完整管道的长度。
总结(Conclusion)
从所有的这些可以看到数据结构是另外一个应该放在资深程序员工具箱中的工具。当前大多数程序设计语言中可用的库和框架免去了全面理解怎样实现这些工具的必要。这个带来的结果是开发者可以很快的借助强有力的思想来完成高质量的解决方案。挑战存在于该选择哪一个。
尽管如此,对这些工具如何工作使得这个选择更加的容易。并且,当有需要的时候,它能够让程序员想到一个新问题的新的解决方案。或许是你在为一个客户进行工作的时候,或许是在下一个45分钟的SRM的编码阶段沉思1000分问题的时候。