【软考】算法与数据结构复习指南

1、算法

根据考试大纲,本章要求考生掌握以下几个方面的知识点。
(1)数据结构设计:线性表、查找表、树、图的顺序存储结构和链表存储结构的设计和实现。
(2)算法设计:迭代、穷举搜索、递推、递归、回溯、贪心、动态规划、分治等算法设计。
从历年的考试情况来看,本章的考点主要集中以下方面。
在数据结构设计中,主要考查基本数据结构如栈,二叉树的常见操作代码实现。
在算法设计中,主要考查动态规划法、分治法、回溯法、递归法、贪心法。
  1. 算法特性
    算法有一些基本特性要求掌握,表14-1是对这些特性的总结
    在这里插入图片描述
  2. 算法复杂度分析
    算法复杂性包括两个方面,一个是算法效率的度量(时间复杂度),一个是算法运行所需要的计算机资源量的度量(空间复杂度),这也是评价算法优劣的重要依据。
    时间复杂度
    一个程序的时间复杂度是指程序运行从开始到结束所需要的时间。通常分析时间复杂度的方法是从算法中选取一种对于所研究的问题来说是基本运算的操作,以该操作重复执行的次数作为算法的时间度量。一般来说,算法中原操作重复执行的次数是规模n的某个函数T(n)。由于许多情况下要精确计算T(n)是困难的,因此引入了渐进时间复杂度在数量上估计一个算法的执行时间。我们通常使用“O()”来表示时间复杂度,其定义如下:
    在这里插入图片描述
    也就是说,随着n的增大,f(n)渐进地不大于g(n)。例如,一个程序的实际执行时间为
    T(n)=3n3+2n2+n,则T(n)=O(n3)。T(n)和n3的值随n的增长渐近地靠拢。常见的渐进时间复杂度
    有:O(1)<O(log2n)<O(n)<O(nlog2n)<O(n2)<O(n3)<O(2n)。
    下面以几个实例来说明具体程序段的时间复杂度。
    例1:求以下程序段的时间复杂度。
    temp=i;
    i=j;
    j=temp;
    分析该程序段会发现,程序的功能是将i与j的值交换。程序一共3条语句,每条语句的执行次数都为1。即:T(n)=1+1+1,所以整个程序段的时间复杂度为O(1)。
    例2:求以下程序段的时间复杂度。
    sum=0; //执行1次
    for(i=1;i<=n;i++) //执行n次
    for(j=1;j<=n;j++) //执行n2次
    sum++; //执行n2次
    本程序段的T(n)=2n2+n+1,时间复杂度应取指数级别最高的,所以为O(n2)。

空间复杂度
一个程序的空间复杂度是指程序运行从开始到结束所需的存储量。它通常包括固定部分和可变部分两个部分。
在算法的分析与设计中,经常会发现时间复杂度和空间复杂度之间有着微妙的关系,经常可以相互转换,也就是可以利用空间来换时间,也可以用时间来换空间。

渐近符号
前面讲到时间复杂度时,已经提到了“通常我们使用O()来表示时间复杂度”,但在考试时,有时会出现Θ符号,所以在此介绍一下常用的三种渐近符号在算法复杂度中所代表的含义。
O(f(n)),给出了算法运行时间的上界,一般用来表达最坏情况下的时间复杂度,这也是平时最常见的一种表达表式;
Ω(f(n)),给出了算法运行时间的下界,一般用来表达最好情况下的时间复杂度;
Θ(f(n)),给出了算法运行时间的上界和下界,其实并不是所有的算法都能求出Θ(f(n))的。

查找与排序
查找与排序是软件设计师考试中常考的知识点,不仅在上午综合知识部分考查,下午软件设计部分也会涉及。

查找与排序都会涉及到一些算法,例如查找,最笨的方法就是顺序查找,即从头开始,逐一对比,直到找到目标为止,这样如果要找的元素比较靠后,则需要消耗大量时间。如果用折半查找,则可以快速找到目标。折半的方式为:一开始就跟目标序列中的中部元素对比,如果要找的值小于中部元素,则说明要找的元素在前半个队列中,如此一来,一次对比,实排除了一半的元素,所以很高效。本节将详细介绍这些查找与排序的算法。

  1. 顺序查找
    顺序查找又称线性查找,它是最基本的查找技术,它的查找过程是:从表中的第一个记录开始,逐个进行记录的关键字和给定值比较,若某个记录的关键字和给定值相等,则查找成功,找到所查的记录;如果直到最后一个记录,都无匹配的,则查找失败。
    顺序查找方法既适用于线性表的顺序存储结构,也适用于线性表的链式存储结构。成功时的顺序查找的平均查找长度如下:
    在等概率情况下,pi=1/n(1≤i≤n),故成功的平均查找长度为(n+…+2+1)/n=(n+1)/2,即查找成功时的平均比较次数约为表长的一半。若k值不在表中,则需进行(n+1)次比较之后才能确定查找失败(所以表中元素过多时,不宜采用该方法进行查找)。
    该算法的时间复杂度为:O(n)。

  2. 二分查找法
    二分查找又称折半查找,优点是比较次数少,查找速度快,平均性能好;其缺点是要求待查表为有序表,且插入删除困难。因此,折半查找方法适用于不经常变动而查找频繁的有序列表。
    下面具体来看一看二分法查找的具体操作流程:(设R[low,…,high]是当前的查找区间)
    (1)确定该区间的中点位置:mid=[(low+high)/2](注意:此处会进行取整操作,在考试时,这些细节往往直接影响得分);
    (2)将待查的k值与R[mid].key比较,若相等,则查找成功并返回此位置,否则需确定新的查找区间,继续二分查找,具体方法如下。
    若R[mid].key>k,则由表的有序性可知R[mid,…,n].key均大于k,因此若表中存在关键字等于k的结点,则该结点必定是在位置mid左边的子表R[low,…,mid–1]中。因此,新的查找区间是左子表
    R[low,…,high],其中high=mid–1。
    若R[mid].key<k,则要查找的k必在mid的右子表R[mid+1,…,high]中,即新的查找区间是右子
    表R[low,…,high],其中low=mid+1。
    若R[mid].key=k,则查找成功,算法结束。
    (3)下一次查找是针对新的查找区间进行,重复步骤(1)和(2)。
    (4)在查找过程中,low逐步增加,而high逐步减少。如果high<low,则查找失败,算法结束。
    二分查找法采用了分治法的思想,后面将详细说明分治法的工作方式。

  3. 散列表
    散列技术 是在记录的存储位置和它的关键字之间建立一个确定的对应关系f ,使得每个关键字key对应一个存储位置f(key)。采用散列技术将记录存储在一块连续的存储空间中,这块连续存储空间称为散列表或哈希表。
    使用散列表技术 使得查询能在O(1)时间内完成查找,这是非常难得的。那么他是怎么做到的呢?
    简单一点讲就是:在存储时,我们会建立一个函数,利用函数算出存储空间,而取的时候,仍然用同样的方式来取出数据,这样就达到了目的。
    下面通过一个实例来理解这样的过程。
    例:将一个数列存储起来:52,37,28,41,79,85,93,64;以方便快速查询。我们可以定义函数:f(x)=x mod 10。即存储位置通过数据与10进行取余操作获取。所以,52应存放于2号空间,38存放于7号空间,依此类推。把所有数据存储好以后,若要查询85,只需要将85与10取余,得到5,再直接判断5号存储空间是否为85即可。若要查询99,则用99与10取余,得到9,然后判断9号空间是否为99,结果发现不是99而是79,所以查找失败,数列中没有我们需要的数据。
    同时值得我们注意的是,并不是每个数据,我们都可以一次查找到,因为散列函数可能存在冲突。即两个不同的关键字,由于散列函数值相同,因而被映射到同一表位置上。例如,我们选取的散列函数为:X MOD 10。需要存储的数列为:1,8,11,4,5,9。此时,我们发现,1与11对10取余,余数均为1,此时,产生冲突。产生冲突时,通常有两种解决方案:
    (1)线性探查法
    该方法的思路很简单,当前空间已被占据,则选下一个空闲空间来存储。还是以上面的数据为例,当存储完数列中的:1、8,需要存储11时,发现冲突产生,此时将11存储于1号空间的下一个空闲位置,即2号空间。
    (2)双散列函数法
    双散列函数法的思想是采用多个散列函数,当产生冲突时,利用第2个函数再次进行地址计算,得到存储位置。

  4. 插入排序
    插入排序的基本思想是每步将一个待排序的记录按其排序码值的大小,插到前面已经排好的文件中的适当位置,直到全部插入完为止。插入排序方法主要有直接插入排序和希尔排序。
    直接插入排序
    直接插入排序的基本思想非常简单:每次从无序表中取出第一个元素,把它插入到有序表的合适位置,使有序表仍然有序。
    第一趟比较前两个数,然后把这两个数按大小插入到有序表中;第二趟把第三个数据取出,然后依次与前两个数比较,把这个数按大小插入到有序表中;依次进行下去,进行了(n-1)趟以后就完成了整个排序过程。
    希尔排序
    希尔排序也称为缩小增量排序 ,它是在直接插入排序的基础上进行改进而得到的排序方法
    其基本思想是:每一趟都按照确定的间隔(增量)将元素分组 ,在每一组内进行直接插入排序 ,使得小元素可以跳跃式地向前移动 ,以后逐步缩小增量值,直到增量值为1为止,这时序列中的元素也已经基本有序,再进行直接插入排序时就可以很快,如图14-1所示。
    在这里插入图片描述
    增量通常首先取d1=n/2(n为待排序的数值的总个数),di+1=di/2,如果值为偶数,则加1,以保证di为奇数。

  5. 选择排序
    选择排序的基本思想是每一步都从待排序的记录中选出排序码最小的记录,顺序存放在已排序的记录序列的后面。常见的选择排序有直接选择排序和堆排序两种。
    直接选择排序
    直接选择排序的过程是,首先在所有记录中选出排序码最小的记录,把它与第1个记录交换,然后在其余的记录内选出排序码最小的记录,与第2个记录交换……依次类推,直到所有记录排完为止。
    无论文件初始状态如何,在第i趟排序中选出最小关键字的记录,需做n–i次比较,因此,总的比较次数为n(n–1)/2=O(n2)。当初始文件为正序时,移动次数为0;文件初态为反序时,每趟排序均要执行交换操作,总的移动次数取最大值3(n–1)。直接选择排序的平均时间复杂度为O(n2)。直接选择排序是不稳定的。

堆排序
堆排序是利用堆这一特殊的树形结构进行的选择排序,它有效地改进了直接选择排序,提高了算法的效率。堆排序的整个过程是:构造初始堆,将堆的根结点和最后一个结点交换,重新调整成堆,再交换,再调整,直到完成排序。
堆实际上就是一种特殊的完全二叉树,它采用顺序存储。如果从0开始对树的结点进行编号,编号的顺序按层进行,同层则按从左到右的次序;则编号为0~ 的结点为分支结点,编号大于的结点为叶结点,对于每个编号为i的分支结点,它的左子结点的编号为2i+1,右子结点的编号为2i+2。除编号为0的树根结点外,对于每个编号为i的结点,它的父结点的编号为。
假定结点i中存放记录的排序码为Si,则堆的各结点的排序码满足Si≥S2i+1且Si≥S2i+2(0≤i≤)。这种堆称为大顶堆(大根堆),而如果相反(即满足Si≤S2i+1且Si≤S2i+2),则称为小顶堆(小根堆)。
构成堆就是把待排序的元素集合,根据堆的定义整成堆。这个过程需从对应的完全二叉树中编号最大的分支结点(编号为 )起,至根结点(编号为0)止。依次对每个分支结点进行“渗透”运算,形成以该分支结点为根的堆,当最后对二叉树的根结点进行渗透运算后,整个二叉树就成为了堆。下面我们就以序列{42,13,24,91,23,16,05,88}为例,说明堆的构造过程(首先按层次遍历将序列生成对应的完全二叉树),如图14-2所示。
在这里插入图片描述

图14-2完成堆构造后,得到堆序列{91,88,24,42,23,16,5,13}。在历年的考试中,经常出现给出序列要求判断是否为堆。例如(10,50,80,30,60,20,15,18)是否为堆?我们应该从编号为 的结点开始,逐个检查是否能够满足“Si≥S2i+1且Si≥S2i+2”或“Si≤S2i+1且Si≤S2i+2”。上例中,共有8个元素,因此从第4个开始。显然,它并不满足定义,因此不是堆。

通过前面的论述,我们可以得知:构建初始堆需要花费比较长的时间,因此对于记录数较少的排序问题并不适合于应用堆排序。

  1. 交换排序
    交换排序的基本思想是:两两比较待排序记录的排序码,并交换不满足顺序要求的那些偶对,直到满足条件为止。交换排序的典型方法包括冒泡排序和快速排序。
    冒泡排序
    冒泡排序的基本思想是,通过相邻元素之间的比较和交换,将排序码较小的元素逐渐从底部移向顶部。由于整个排序的过程就像水底下的气泡一样逐渐向上冒,因此称为冒泡算法。整个冒泡排序过程如下所述:首先将A[n-1]和A[n-2]元素进行比较,如果A[n-2]>A[n-1],则交换位置,使小的元素上浮,大的元素下沉;当完成一趟排序后,A[0]就成为最小的元素;然后就从A[n-1]~A[1]之间进排序。下面就是一个实际的例子,如图14-4所示。
    在这里插入图片描述

  2. 快速排序
    快速排序采用的是分治法,其基本思想是将原问题分解成若干个规模更小但结构与原问题相似的子问题。通过递归地解决这些子问题,然后再将这些子问题的解组合成原问题的解。快速排序通
    常包括两个步骤:
    第一步,在待排序的n个记录中任取一个记录,以该记录的排序码为准,将所有记录都分成两组,第1组都小于该数,第2组都大于该数,如图14-5所示。
    第二步,采用相同的方法对左、右两组分别进行排序,直到所有记录都排到相应的位置为止。
    在这里插入图片描述

  3. 归并排序
    归并也称为合并,是将两个或两个以上的有序子表合并成一个新的有序表。若将两个有序表合并成一个有序表,则称为二路合并。合并的过程是:比较A[i]和A[j]的排序码大小,若A[i]的排序码小于等于A[j]的排序码,则将第一个有序表中的元素A[i]复制到R[k]中,并令i和k分别加1;如此循环下去,直到其中一个有序表比较和复制完,然后再将另一个有序表的剩余元素复制到R中。而归并排序就是使用合并操作完成排序的算法,如果利用二路合并操作,则称为二路合并排序,其过程如下:
    首先把待排序区间中的每个元素都看做一个有序表(则有n个有序表),通过两两合并,生成个长度为2(最后一个表的长度可能小于2)的有序表,这也称为一趟合并。
    然后再将这 个有序表进行两两合并,生成个长度为4的有序表。如此循环直到得到一个长度为n的有序表,通常需要 趟,如果该值为奇数,则为。
    基于磁盘进行的外排序经常使用归并排序的方法。其过程主要可以分为两个阶段。建立外排序所有的内存缓冲区:根据它们的大小将输入的文件划分为若干段,用某种有效的内排序方法,对各段进行排序,这些经过排序的段称为初始归并段,生成后就写到外存中去。使用归并树模式将第一个阶段生成的初始归并段加以归并,一趟趟地扩大归并段或减少归并段个数,直到最后归并成一个大的归并段为止。

在这里插入图片描述

使用k路平衡归并时,如果有m个初始归并段,则相应的归并树就有层,需要归并趟。例如,若对27个元素只进行三趟多路归并排序,则选取的归并路数是多少?这其实就是已知=3,求k值,很显然是3。通常只需增加归并路数k,或减少初始归并段个数m,都能够减少归并趟数S,以减少读写磁盘的次数d,达到提高外排序速度的目的。

  1. 基数排序
    基数排序是一种借助多关键字排序思想对单逻辑关键字进行排序 的方法。基数排序不是基于关键字比较的排序方法 ,它适合于元素很多而关键字较少的序列 。基数的选择和关键字的分解是根据关键字的类型来决定的,例如关键字是十进制数,则按个位、十位来分解
    例如,我们需对{135,242,192,93,345,11,24,19}进行排序,因为数据的最高位是百位,所以要分三趟进行分配和收集 ,如图14-7所示。
    在这里插入图片描述
    如图14-7所示,在匹配的过程中显然需要额外的辅助存储空间,通常采用链式存储分配的方式来存放中间结果。

  2. 排序算法的稳定性和复杂度(如表14-2所示)
    表14-2排序算法的稳定性和复杂度
    在这里插入图片描述
    注:基数排序的复杂度中,r代表关键字的基数,d代表长度,n代表关键字的个数。

1.迭代法
迭代法是用于解决数值计算问题中的非线性方程(组)求解或最优解(近似根) 的一种算法设计方法。它的主要思想是:从某个点出发,通过某种方式求出下一个点使得其离要求的点(方程的解)更近一步 ;当两者之差接近到可接受的精度范围时,就认为找到了问题的解。由于它是不断进行这样的过程,因此称为迭代法,同时从中也可以看出使用迭代法必须保证其收敛性。具体来说,迭代法包括简单迭代法、对分法、梯度法、牛顿法 等。对分法是指在某个解空间采用二分搜索,它的优点是只要在该解空间内有根,就能够快速地搜索到;梯度法则又称为最速下降法,它常用于工程问题的解决。
在使用的迭代法的过程中,应该注意两种异常情况。
如果方程无解,那么近似根序列将不会收敛,迭代过程会成为“死循环”,因此在使用时应先判断其是否有解,并应对迭代的次数进行限制;
方程虽然有解,但迭代公式选择不当,或迭代的初始近似根选择不合理,也会导致迭代失败。
迭代法总的来说是一种比较简单的求解方法,但是此类算法还存在两个不足:一是一次只能求方程的一个解,而且需要人工给出近似初值,如果初值选择不好就可能找不到解;二是不易保证程序的收敛性。
2. 穷举搜索法
穷举搜索法是穷举所有可能的情形,并从中找出符合要求的解,即对可能是解的众多候选解按某种顺序逐一枚举和检验,并从中找出那些符合要求的解作为问题的解。对于没有有效的解法的离散型问题,如果规模不大,穷举搜索法是很好的选择。
穷举搜索法通常需要使用多重循环来实现,对每个变量的每个值都进行测试,看其是否满足给定条件,如果满足则说明找到问题的一个解。
3. 递推法
递推法实际上首先需要抽象为一种递推关系,然后再按递推关系来求解。它通常表示为两种方式:
从简单推到一般,这常用于计算级数;将一个复杂问题逐步推到一个具有已知解的简单问题,它常与“回归”配合为递归法。递推法是一种简单有效的方法,通常可以编写出执行效率较高的程序。使用的关键是找出递推关系式,并确定初值。
任何用递推法可以解决的问题,都可以很方便地用递归法解决;但有很多可以使用递归法解决的问题,不一定可以使用递推法解决。但如果是既可以使用递归,又可以使用递推法来解决的问题,则应使用递推法,因为它的效率要高于递归法。
4. 递归法
递归是一种特别有用的工具,不仅在数学中广泛应用,还是设计和描述算法的一种有力工具。它经常用于分解复杂算法:将规模为N的问题分解成为规模较小的问题,然后从这些规模较小的问题的解中构造出大问题的解;而这些规模较小的问题采用同样的分解和综合的方法,分解成规模更小的问题;而特别的,当规模为1时,能够得到解。
从上面的描述中,我们可以看出递归算法包括“递推”和“回归”两个部分:递推是为了得到问题的解,将它推到比原问题简单的问题的求解;而回归则是当小问题得到解后,回归到原问题的解上来。
在使用递推时应该注意以下几点:
递推应该有终止点,终止条件便会使算法失效;“简单问题”表示离递推终止条件更为接近的问题。也就是说简单问题与原问题解的算法是一
致的,差别主要是参数。参数的变化将使问题递推到有明确解的问题。
在使用回归时应该注意:递归到原问题的解时,算法中所涉及的处理对象应是关于当前问题的,即递归算法所涉及的参数与局部处理对象是有层次的。当解一个问题时,有它的一套参数与局部处理对象。当递推进入一“简单问题”时,这套参数与局部对象便隐蔽起来,在解“简单问题”时,又有自己一套。但当回归时,原问题的一套参数与局部处理对象又活跃起来了。有时回归到原问题以得到问题解,回归并不引起其他动作。
采用递归方法定义的数据结构或问题最适合使用递归方法解答。当然,回到实际的开发中,递归的表现形式有两种:函数自己调用自己。两个函数之间相互调用。考试时以函数自己调用自己的方式居多。下面以两个程序实例说明该问题。例:利用递归程序计算n的阶乘。这是一个非常简单的计算问题,只要学过程序设计,都能用一个简单的循环来解决该问题。编写一个循环语句,实现:S=1234n即可。但在此,我们要求用递归来实现,这便要求我们找出阶乘中隐藏的推荐规则,通过总结可得出规律:也就是说:要求n的阶乘,需要分两种情况分析问题,当n=0时,阶乘的结果为1;当n不等于0时,n的阶乘等于n乘以(n-1)的阶乘。这样就产生了递推过程。下面是将这种思路进行程序实现:接下来看一个更为复杂的例题:编写计算斐波那契(Fibonacci)数列的函数,数列大小为n。无穷数列1,1,2,3,5,8,13,21,35,…,称为斐波那契数列。这种数列有一个规律,数列第1个与第2个元素的值均为1,从第3个值开始,每个数据是前两个数据之和。即,数列可以表示为:1,1,(1+1),(1+(1+1)),((1+1)+(1+(1+1)))…在此,我们可以把这种规律转成递推式:
有了递推式,再来写程序,也就很容易了,直接转化即可,该问题程序实现如下所示:使用递归法写出的程序非常简洁,但其执行过程却并不好理解。在理解这种方法的过程中,建议大家使用手动运行程序的方式来进行分析,先从最简单的程序开始尝试,逐步到复杂程序。递归法的用途非常广泛,图的深度优先搜索、二叉树的前序、中序和后序遍历等可采用递归实现。
5. 回溯法
回溯法(试探法)是一种选优搜索法,按选优条件向前搜索,以达到目标。但当探索到某一步时,发现原先选择并不优或达不到目标,就退回一步重新选择,这种走不通就退回再走的技术为回溯法,而满足回溯条件的某个状态的点称为“回溯点”。其工作机制如图14-8所示。
在使用回溯法时,必须知道以下几个关键的特性:采用回溯法可以求得问题的一个解或全部解,为了不重复搜索已找过的解,通常会使用栈(也可以用位置指针、值的排列顺序等)来记录已经找到的解。要注意的是,回溯法求问题的解时,找到的解不一定是最优解。
程序要注意记录中间每一个项的值,以便回溯;如果回溯到起始处,表示无解。
用回溯法求问题的全部解时,要注意在找到一组解时应及时输出并记录下来,然后马上改变当前项的值继续找下一组解,防止找到的解重复。下面我们通过一个经典的问题来研究回溯法的应用。例:使用回溯法解决迷宫问题,找到迷宫的出路。基本思路分析:进入一个迷宫之所以难以找到出路,是因为迷宫会有多个岔路口,形成多条路径,而成千上万条路径中,仅有1条(或几条)路径可走出迷宫。若采用回溯法,则在尝试走一条路径时,会把这些分岔都记录好,当一条路走不通时,原路返回到最近的一个分岔口,从这个分岔口找下一路进行尝试,这个过程与14-8所示的情况完全一致。接下来可以开始把这种思路转化为数据结构来表达了:设迷宫为m行n列,利用数组maze[m][n]来表示一个迷宫。maze[i][j]=0或1。其中0表示通路,1表示不通。当从某点向下试探时,中间的点有8个方向可以试探,而4个角点只有3个方向,而其他边缘点有5个方向。为使问题简单化,我们用maze[m+2][n+2]来表示迷宫,而迷宫的四周的值全部为1。这样做使问题简单了,每个点的试探方向全部为8,不用再判断当前点的试探方向有几个,同时与迷宫周围是墙壁这一实际问题相一致。如图14-9所示的迷宫是一个6×8的迷宫。入口坐标为(1,1),出口坐标为(6,8)。
在这里插入图片描述
迷宫的定义如下:
#define m 6 /
迷宫的实际行 /
#define n 8 /
迷宫的实际列 */
int maze [m+2][n+2] ;
在上述表示迷宫的情况下,每个点有8个方向可以试探。如当前点的坐标为(x,y),与其相邻的8个点的坐标都可根据与该点的相邻方位而得到。因为出口在(m,n),因此试探顺序规定为:从当前位置向前试探的方向为从正东沿顺时针方向进行。为了简化问题,方便地求出新点的坐标,将从正东开始沿顺时针方向进行的这8个方向的坐标增量放在一个结构数组move[8]中,在move 数组中,每个元素由两个域组成,x为横坐标增量,y为纵坐标增量。move数组如图14-10所示。

在这里插入图片描述

move数组定义如下:
typedef struct
{
int x,y
} item ;
item move[8] ;
这样对move的设计会很方便地求出从某点(x,y)按某一方向v (0≤v≤7) 到达的新点(i,j)的坐标。
可知,试探点的坐标(i,j)可表示为i=x+move[v].x ;j=y+move[v].y。到达了某点而无路可走时需返回前一点,再从前一点开始向下一个方向继续试探。因此,压入栈中的不仅是顺序到达的各点的坐标,而且还要有从前一点到达本点的方向。对于迷宫,依次入栈如下:

栈中每一组数据是所到达的每点的坐标及从该点沿哪个方向向下走的,对于如图14-9所示的迷
宫,走的路线为:(1,11→(2,21→(3,30→(3,40→(3,50→(3,60(下脚标表示方
向);当从点(3,6)沿方向0到达点(3,7)之后,无路可走,则应回溯,即退回到点(3,6),对应
的操作是出栈,沿下一个方向即方向1继续试探;方向12试探失败,在方向3上试探成功,因此将
(3,6,3)压入栈中,即到达了(4,5)点。
栈中元素是一个由行、列、方向组成的三元组,栈元素的设计如下:
typedef struct
{int x , y , d ; /* 横坐标和纵坐标及方向*/
}datatype ;
栈的设计如下:
#define MAXSIZE 1024 /*栈的最大深度*/
typedef struct
{datatype data[MAXSIZE];
int top;/*栈顶指针*/
}SeqStack
一种方法是另外设置一个标志数组mark[m][n],它的所有元素都初始化为0,一旦到达了某一点
(i,j)之后,使mark[i][j]1,下次再试探这个位置时就不能再走了。另一种方法是当到达某点
(i,j)后使maze[i][j]-1,以便区别未到达过的点,同样也能起到防止走重复点的目的。本书采用
后者方法,算法结束前可恢复原迷宫。
算法简单描述如下:
栈初始化;
将入口点坐标及到达该点的方向(设为-1)入栈
while (栈不空) {
栈顶元素=>(x , y , d)
出栈;
求出下一个要试探的方向d++;
while(还有剩余试探方向时){
if (d方向可走)
then { (x , y , d)入栈 ;
求新点坐标(i, j);
将新点(i , j)切换为当前点(x , y);
if ( (x ,y)= =(m,n) )结束 ;
else 重置d=0 ;
}
else d++ ;
}
}
该问题算法程序实现如下所示。
#include <stdio.h>
#define m 6 /* 迷宫的实际行 */
#define n 8 /* 迷宫的实际列 */
#define MAXSIZE 1024 /* 栈的最大深度 */
int maze[m+2][n+2]; /* 迷宫数组,初始时为0*/
typedef struct item{ /* 坐标增量数组 */
int x,y;
}item;
item move[8]; /* 方向数组 */
typedef struct datatype{ /* 栈结点数据结构 */
int x,y,d; /* 横坐标和纵坐标及方向 */
}datatype;
typedef struct SeqStack{ /* 栈结构 */
datatype data[MAXSIZE];
int top; /* 栈顶指针 */
}SeqStack;
SeqStack *s;
datatype temp;
int path(int maze[m+2][n+2],item move[8]){
int x,y,d,i,j;
temp.x=1;temp.y=1;temp.d=-1;
Push_SeqStack(s,temp); /* 辅助变量temp表示当前位置,将其入栈 */
while(!Empty_SeqStack(s))
{ Pop_SeqStack(s,&temp); /* 若栈非空,取栈顶元素送temp */
x=temp.x;y=temp.y;d=temp.d+1;
while(d<8) /* 判断当前位置的8个方向是否为通路 */
{ i=x+move[d].x;j=y+move[d].y;
if(maze[i][j]= =0)
{ temp.x=x;temp.y=y;temp.d=d;
Push_SeqStack(s,temp);
x=i;y=j;maze[x][y]=-1;
if(x==m&&y==n)return 1; /* 迷宫有路 */
else d=0;
}
else d++;
} /*while (d<8)*/
} /*while */
return 0 ; /* 迷宫无路 */
}
int Empty_SeqStack(SeqStack *s) /* 判断栈空函数 */
{ if (s->top= =-1)return 1;
else return 0;
}
int Push_SeqStack(SeqStack *s, datatype x) /* 入栈函数 */
{ if(s->top= =MAXSIZE-1)return 0; /* 栈满不能入栈 */
else {s->top++;
s->data[s->top]=x;
return 1;
}
}
int Pop_SeqStack(SeqStack *s,datatype *x) /* 出栈函数 */
{ if(Empty_SeqStack(s))return 0; /* 栈空不能出栈 */
else{*x=s->data[s->top];
s->top--;return 1; } /* 栈顶元素存入*x,返回 */
}
void main(){
int i,j,t;
move[0].x=0;move[0].y=1;
move[1].x=1;move[1].y=1;
move[2].x=1;move[2].y=0;
move[3].x=1;move[3].y=-1;
move[4].x=0;move[4].y=-1;
move[5].x=-1;move[5].y=-1;
move[6].x=-1;move[6].y=0;
move[7].x=-1;move[7].y=1;
for(i=0;i<=n+1;i++){
maze[0][i]=1;
maze[m+1][i]=1;
}
for(i=1;i<=m;i++){
maze[i][0]=1;
maze[i][n+1]=1;
}
printf("please input maze\n");
for(i=1;i<=m;i++)
for(j=1;j<=n;j++)scanf("%d",&maze[i][j]);
t=path(maze,move);
if(t==1){
printf("the track is :\n");
while(!Empty_SeqStack(s))
{ Pop_SeqStack(s,&temp); /*若栈非空,则打印输出 */
printf("%d,%d,%d\n",temp.x,temp.y,temp.d);
}
}
}
  1. 贪婪法
    贪婪法,也叫贪心法,它是一种重要的算法设计技术,它总是做出当前来说最好的选择,而并不从整体上加以考虑,它所做的每步选择只是当前步骤的局部最优,而不一定是整体最优。由于它并不必为了寻找最优解而穷尽所有可能解,因此其耗费时间少,一般可以快速得到满意解(即“不追求最优,但求满意”)。
    贪婪法只能够求问题的某个解,而不可能给出所有的解。经典的贪婪法算法包括背包问题、装箱问题、马踏棋盘问题、货郎担问题、哈夫曼编码问题。
    如此解释很抽象,应用到一个实例中来说明问题。如一个超市的收银系统,要计算出最佳的找零方案,可以采用贪心法。假设找零金额为38元,贪心法的基本思路是:由于人民币面值为:100,50,20,10,5,1。为了找零时,找出的零钞张数最少,所以优先考虑面额大的,先用1张20块的,然后还需要找零18,此时再用1张10块的,再用1张5块的,接下来用3张一块的,这样就完成任务了。我们可以发现这个过程是按既定的顺序一步一步走,形成解决方案,这个过程中不涉及回溯。通过这个实例,可以了解到贪心法的基本思想。
    下面将通过装箱问题来分析贪婪法的具体应用。
    例:有6种物品,它们的体积分别为:60、45、35、20、20和20单位体积,箱子的容积为100个单位体积。现在求需要几只箱子才能把这些物品都装起来。
    使用贪婪法求解该问题的基本思想是:先将物品的体积按从大到小的顺序排列。然后依次将物品放到它第一个能放进去的箱子中,若当前箱子装不下当前物品,则启用一个新的箱子装该物品,直到所有的物品都装入了箱子。如采用这种方式,我们发现,得到解决方案为:1、3号物品放一个箱子,2、4、5放第二个箱子,6放在第3个箱子,一共需要3个箱子。但由于此问题很简单,我们可以很容易用人工计算的方式得知最优解只要2个箱子。即:1、4、5和2、3、6。所以从此可以看出贪婪法求的是可行解,而非最优解。下面是该问题的算法与程序实现过程。
算法简单描述:
{输入箱子的容积;
输入物品种数n;
按体积从大到小顺序,输入各物品的体积;
预置已用箱子链为空;
预置已用箱子计数器box_count为0;
for (i=0;i<n;i++)
{从已用的第一只箱子开始顺序寻找能放入物品i 的箱子j;
if (已用箱子都不能再放物品i)
{新启用一个箱子,并将物品i放入该箱子;
box_count++;
}
else
将物品i放入箱子j;
}
}
上述算法一次就能求出需要的箱子数box_count,并能求出各箱子所装物品,但该算法不一定
能找到最优解。
该问题算法程序实现如下所示。
# include<stdio.h>
# include<stdlib.h>
typedef struct ele{/*物品结构的信息*/
int vno; /*物品号*/
struct ele *link; /*指向下一物品的指针*/
}ELE;
typedef struct hnode{/*箱子结构信息*/
int remainder; /*箱子的剩余空间*/
ELE *head; /*箱子内物品链的首元指针*/
struct hnode *next; /*箱子链的后继箱子指针*/

}HNODE;
void main()
{int n, i, box_count, box_volume, *a;
HNODE *box_h, *box_t, *j;
ELE *p, *q;
printf("输入箱子容积\n"); scanf("%d",&box_volume);
printf("输入物品种数\n"); scanf("%d",&n);
a=(int *)malloc(sizeof(int)*n);
printf("请按体积从大到小顺序输入各物品的体积:");
for (i=0;i<n;i++)scanf("%d",a+i); /*数组a按从大到小顺序存放各物品的体积信息*/
box_h=box_t=NULL;   /*box_h为箱子链的首元指针,box_t为当前箱子的指针,初始为
空*/
box_count=0; /*箱子计数器初始也为0*/
for (i=0;i<n;i++) /*物品i按下面各步开始装箱*/
{p=(ELE *)malloc(sizeof(ELE));
p->vno=i; /*指针p指向当前待装物品*/
/*从第一只箱子开始顺序寻找能放入物品i的箱子j*/
for (j=box_h;j!=NULL;j=j->next)
if (j->remainder>=a[i])break; /*找到可以装物品i的箱子,贪婪准则的体现*/
if (j= =NULL) { /*已使用的箱子都不能装下当前物品i*/
j=(HNODE *)malloc(sizeof(HNODE)); /*启用新箱子*/
j->remainder=box_volume-a[i]; /*将物品i放入新箱子j*/
j->head=NULL; /*新箱子内物品链首元指针初始为空*/
if (box_h= =NULL) box_h=box_t=j; /*新箱子为第一个箱子*/
elsebox_t=boix_t->next=j; /*新箱子不是第一个箱子*/
j->next=NULL;
box_count++;
}
elsej->remainder-=a[i]; /*将物品i放入已用过的箱子j*/
/*物品放入箱子后要修改物品指针链*/
for (q=j->head;q!=NULL&&q->link!=NULL;q=q->link);
if (q= =NULL) { /*新启用的箱子插入物品*/
p->link=j->head; j->head=p; /*p为指向当前物品的指针*/
}
else{/*已使用过的箱子插入物品*/
p->link=NULL; q->link=p; /*q为指向箱子内物品链顶端的物品*/
}
}
printf("共使用了%d只箱子", box_count);
printf("各箱子装物品情况如下:");
for (j=box_h,i=1;j!=NULL;j=j->next,i++) /*输出i只箱子的情况*/
{printf("第%2d只箱子,还剩余容积%4d,所装物品有;\n",i,j->remainder);
for (p=j->head;p!=NULL;p=p->link)
printf("%4d",p->vno+1);
printf("\n");
}
}
  1. 分治法
    分治法可能算得上是使用最广泛的一种算法设计方法,其基本思想是将大问题分解成一些较小
    的问题,然后由小问题的解方便地构造出大问题的解。
    采用分治法时,应该知道如果不能找到有效的将大问题分析为小问题的方法,那就可能无法得
    到问题的解;另外它也经常和递归法结合使用;在分治时要注意确保边界的清晰。分治法能够解决
    的问题通常具有以下特性:
    问题缩小到一定程度将很容易解决——通常都能够满足。
    问题可以分解为若干规模小的相同问题,这也称为最优子结构性质——前提条件。
    利用该问题分解出的子问题的解可以合并为该问题的解——关键,如果只满足前两个,可以考
    虑使用贪婪法或动态规划法。
    该问题所分解出的各个子问题是相互独立——和效率相关。
    分治法最简单好理解的实例就是使用二分查找法进行查找。其程序实现如下所示。
    function Binary_Search(L,a,b,x);
    { if(a>b)return(-1);
    else
    { m=(a+b)/2;
    if(x= =L[m])return(m);
    else if(x>L[m])
    return(Binary_Search(L,m+1,b,x)); /递归实现/
    else return(Binary_Search(L,a,m-1,x)); /递归实现/
    }
    }
    在以上算法中,L为排好序的线性表,x为需要查找的元素,b、a分别为x的位置的上下界,即如
    果x在L中,则x在L[a…b]中。每次我们用L中间的元素L[m]与x比较,从而确定x的位置范围,然后递归
    地缩小x的范围,直到找到x。

  2. 动态规划法
    动态规划法的基本思想与分治法类似,也是将复杂的问题分解成子问题来解决。但只是此处的子问题通常是重叠的,它们是将复杂问题的某些阶段,所以处理方式也有所不同。在此方法中,引入一个数组,不管子问题是否对最终解有用,都会存于该数组中,利用对数组的分析得到最优解。在求解问题中,对于每一步决策,列出各种可能的局部解,再依据某种判定条件,舍弃那些肯定不能得到最优解的局部解,在每一步都经过筛选,以每一步都是最优解来保证全局是最优解,这种求解方法称为动态规划法。一般来说,适合于用动态规划法求解的问题具有以下特点:
    可以划分成若干个阶段,问题的求解过程就是对若干个阶段的一系列决策过程。每个阶段有若干个可能状态。
    一个决策将你从一个阶段的一种状态带到下一个阶段的某种状态。
    在任一个阶段,最佳的决策序列和该阶段以前的决策无关。
    各阶段状态之间的转换有明确定义的费用,而且在选择最佳决策时有递推关系(即动态转移方程)。
    使用动态规划法求解问题的基本思路如图14-11所示。
    在这里插入图片描述
    下面我们用一个通俗的例子,更进一步理解动态规划法:
    假如我们要生产一批雪糕,在这个过程中要分好多环节:购买牛奶,对牛奶提纯处理,放入工厂加工,对加工后的商品进行包装,包装后就去销售……,这样每个环节就可以看做是一个阶段;产品在不同的时候有不同的状态,刚开始时只是白白的牛奶,进入生产后做成了各种造型,从冷冻库拿出来后就变成雪糕。每个形态就是一个状态,那从液态变成固态经过了冰冻这一操作,这个操作就是一个决策。一个状态经过一个决策变成了另外一个状态,这个过程就是状态转移。也就是说,在利用动态规划法解决问题时,我们会关注这些状态,以及状态的转移,对于中间结果,会予以保存,这样才能进行下一步的处理。
    回到计算机方面的问题,同样是计算斐波那契(Fibonacci)数列,动态规划法的处理方式与递归法有一些差异,动态规划法一般要求填充一个表,如图14-12所示。

这个表存储的内容,其实就是中间结果,每一个中间结果存储下来,对于后续要求的内容起到了直接的影响。如,数列的第一个元素是1,这被要求填入表中的第1项,第2个元素也为1,也将存储起来。当要求F(3)时,通过查表,得到F(1)与F(2)的值,将两者相加得到F(3),再将F(3)的值存储到表格第3项中。依此类推,当要求计算出F(9)的值时,可通过查表得到F(7)、F(8)之后相加得到F(9)。这便是动态规划法解决问题的方法。
通过上面的分析,相信大家对动态规划法的基本理念有一定的认知了,但现在要解决考试当中的问题,往往还很难。因为考试往往涉及到程序实现的问题,下面将通过实例描述动态规划法的实现过程。
例:使用动态规划法解决背包问题。有一个背包总容量为42,现有三个物品的价值/重量分别为:40/3,101/31,67/10,请求出背包应该装哪些物品。

使用动态规划法解决这种0-1背包问题的基本思路为:将原问题分解成一系列子问题,然后从这些子问题中求出原问题的解。对一个负重能力为m的背包,如果选择装入第i种物品,那么原背包问题就转化为一个子背包问题了。动态规划会利用空间换时间,将子问题和其结果记录下来,这样一步一步查询得到最终结果。本题的实现代码为:

步一步查询得到最终结果。本题的实现代码为:
#include<iostream>
int c[10][100];/*对应每种情况的最大价值*/
int knapsack(int m,int n)
{
int i,j,w[10],p[10];
for(i=1;i<n+1;i++)
scanf("%d,%d",& w[i],& p[i]);
for(i=0;i<10;i++)
for(j=0;j<100;j++)
c[i][j]=0;/*初始化数组*/
for(i=1;i<n+1;i++)
for(j=1;j<m+1;j++)
{
if(w[i]<=j) /*如果当前物品的容量小于背包容量*/
{
if(p[i]+c[i-1][j-w[i]]>c[i-1][j])
/*如果本物品的价值加上背包剩下的空间能放的物品的价值*/
/*大于上一次选择的最佳方案则更新c[i][j]*/
c[i][j]=p[i]+c[i-1][j-w[i]];
else
c[i][j]=c[i-1][j];
}
else c[i][j]=c[i-1][j];
}
printf("背包中放着重量如下的物品:"); /*确定选取了哪几个物品*/
i=n;j=m;
while((i>=0)&&(j>=0))
{
if((p[i]+c[i-1][j-w[i]]>c[i-1][j])&&(i-1>=0)&&(j-w[i]>=0)){
printf("%d ",w[i]);
j=j-w[i];
i=i-1;
}
else
i=i-1;
}
printf("\n");
return(c[n][m]);
}
void main()
{
int m,n,k;
printf("输入总背包容量:");scanf("%d",&m);
printf("\n");
printf("输入背包最多可放的物品个数:");scanf("%d",&n);
printf("\n");
printf("输入每一组数据:");
printf("\n");
k=knapsack(m,n);
printf("背包所能容纳的最大价值为:%d。",&k);
}

2、数据结构

数据结构是指数据对象及其相互关系和构造方法。在软件设计过程中,不同的数据结构的选
用,对系统最终效果的影响极大。所以该知识点是软件设计师核心考点,无论是上午综合知识部
分,还是下午的软件设计部分,考查分值都很高。
根据考试大纲,本章要求考生掌握数组、链表、队列和栈、树、图、杂凑相关知识,从历年的
考试情况来看,本章主要考查常见数据结构的逻辑结构特性及存储的相关内容。

一、数组与线性表
按数据的逻辑结构来划分,常见的数据结构包括:数组(静态数组、动态数组)、线性表(顺序表、链表、队列、栈)、树(二叉树、查找树、平衡树、线索树、堆)、图。本节将介绍数组与线性表的相关内容。

  1. 数组
    数组是一种常见的数据结构,根据数组下标的个数,可以把数组分为一维、二维、…、多维数组,如表4-1所示。维度是指下标的个数。一维数组只有一个下标;二维数组则有两个下标,第一个称为行下标,第二个称为列下标。根据数组的定义,计算存储地址是一个经常考查的知识点。
    在这里插入图片描述
    注:表4-1计算公式中的a为数组首地址,len为每个数据对象的长度,i与j的下标默认从0开始。

  2. 稀疏矩阵
    在计算机中存储一个矩阵时,可使用二维数组。例如,M×N阶矩阵可用一个数组a[M][N]来存储(可按照行优先或列优先的顺序)。如果一个矩阵的元素绝大部分为零,则称为稀疏矩阵。若直接用一个二维数组表示稀疏矩阵,则会因存储太多的零元素而浪费大量的内存空间。在稀疏矩阵中,有一种情况非常常见,即稀疏矩阵内部存在对称性。这样,我们可以采用一维数组来表示它们,这也常称为压缩存储,如表4-2所示。
    在这里插入图片描述

  3. 线性表
    线性表是用来表示数据对象之间的线性结构,通俗地说,线性结构就是指所有结点是按“一个接着一个排列”的方式相互关联而组成一个整体。
    线性结构是n个结点的有穷序列。通常表示为(a1,a2,…,an),a1称为起始结点,an称为结束结点,i称为ai在线性表中的序号或位置,线性表所含结点的个数称为线性表的长度,长度为0的线性表称为空表。
    线性表主要的存储结构有两种:顺序存储结构和链式存储结构。采用顺序存储结构,就称为顺序表(常用数组实现);采用链式存储结构则称为线性链表(即链表)。
    (1)顺序表
    顺序存储是最简单的存储方式,通常用一个数组,从数组的第一个元素开始,将线性表的结点依次存储在数组中,即线性表的第i个结点存储在数组的第i(0≤i≤n–1)个元素中,用数组元素的顺序存储来体现线性表中结点的先后次序关系。
    顺序存储线性表的最大优点就是能随机存取线性表中的任何一个结点,缺点主要有两个,一是数组的大小通常是固定的,不利于任意增加或减少线性表的结点个数;二是插入和删除线性表的结点时,要移动数组中的其他元素,操作复杂。
    (2)链表
    链表就是采用链式存储实现的线性表。它是动态分配链表结点,通过链接指针,将各个节点按逻辑顺序连接起来。根据其存储结构的不同,可以分为单链表、循环链表和双链表三种,软设目前主要考查前两种。
    单链表(如表4-3所示)
    在这里插入图片描述

循环链表
循环链表与单链表的区别仅仅在于其尾结点的指针域值不是null,而是指向头结点的指针。这样做的好处是,从表中的任一结点出发都能够通过后移操作扫描整个循环链表。
(3)顺序表与链表的比较
在实际应用中,应该如何在顺序实现和链式实现中进行选择呢?通常是从时间和空间性能的角度来进行判断,如表4-4所示。
在这里插入图片描述
(4)队列
队列也是一种特殊的线性表,只允许在一端进行插入,另一端进行删除运算。允许删除运算的那一端称为队首,允许插入运算的一端称为队尾。称队列的结点插入为进队,结点删除为出队。因最先进入队列的结点将最先出队,所以队列具有先进先出的特征。实现队列,可以使用顺序存储(如:数组方式)也可以用链表。
顺序存储
用顺序存储线性表来表示队列,为了指明当前执行出队运算的队首位置,需要一个指针变量head(称为头指针),为了指明当前执行进队运算的队尾位置,也需要一个指针变量tail(称为尾指针)。
若用有N个元素的数组表示队列,随着一系列进队和出队运算,队列的结点移向存放队列的数组的尾端,会出现数组的前端空着,而队列空间已用’完的情况。一种可行的解决办法是当发生这样的情况时,把队列中的结点移到数组的前端,修改头指针和尾指针。另一种更好的解决办法是采用循环队列。

循环队列就是将实现队列的数组a[N]的第一个元素a[0]与最后一个元素a[N–1]连接起来。队空的初态为 head=tail=0。在循环队列中,当tail 赶上head时,队列满。反之,当head赶上tail时,队列变为空。这样队空和队满的条件都同为head=tail,这会给程序判别队空或队满带来不便。因此,可采用当队列只剩下一个空闲结点的空间时,就认为队列已满的简单办法,以区别队空和队满。即队空的判别条件是head=tail,队满的判别条件是head=tail+1。
链式存储
队列也可以用链接存储线性表实现,用链表实现的队列称为链接队列。链表的第一个结点是队列首结点,链表的末尾结点是队列的队尾结点,队尾结点的链接指针值为NULL。队列的头指针head 指向链表的首结点,队列的尾指针tail指向链表的尾结点。当队列的头指针head值为NULL时,队列为空。
(5)栈
栈是另一种特殊的线性表,栈只允许在同一端进行插入和删除运算。允许插入和删除的一端称为栈顶,另一端为栈底。称栈的结点插入为进栈,结点删除为出栈。因为最后进栈的结点必定最先出栈,所以栈具有后进先出的特征。
顺序存储
采用顺序实现的栈中,初始化运算负责将栈顶变量top初始化为“􀀀1”,使栈为空;在进栈操作时,需要判断栈是否满(top􀀀N􀀀1说明栈满),如果未满,则将新元素插入栈,并将top的值加1;在出栈操作时,需要判断栈是否空(top􀀀􀀀1说明栈空),如果非空,则取出栈顶元素,将top的值减1。
顺序栈的缺点在于为了避免栈满时发生溢出,需预先为栈设立足够大的空间,但太大会造成空间浪费,太小又容易引发溢出。
链式存储
栈也可以用链表实现,用链表实现的栈称为链接栈。链表的第一个结点为顶结点,链表的首结点就是栈顶指针top,top为NULL的链表是空栈。
(6)字符串
字符串是由某字符集上的字符所组成的任何有限字符序列。当一个字符串不包含任何字符时,称它为空字符串。一个字符串所包含的有效字符个数称为这个字符串的长度。一个字符串中任一连续的子序列称为该字符串的子串。
字符串通常存于足够大的字符数组中,每个字符串的最后一个有效字符之后有一个字符串结束标志,记为“\0”。通常由系统提供的库函数形成的字符串的末尾会自动添加“\0”,但当由用户的应用程序来形成字符串时,必须由程序自行负责在最后一个有效字符之后添加“\0”,以形成字符串。
对字符串的操作通常有:
● 统计字符串中有效字符的个数;
● 把一个字符串的内容复制到另一个字符串中;
● 把一个字符串的内容连接到另一个足够大的字符串的末尾;
● 在一个字符串中查找另一个字符串或字符;
● 按字典顺序比较两个字符串的大小。

二、树的概念

  1. 树的概念
    树是一种典型的非线性数据结构,它能够很好地应用于描述分支和层次特性的数据集合。树是由一个或多个结点组成的有限集合T,它满足以下两个条件:
    (1)有一个特定的结点,称为根结点;
    (2)其余的结点分成m(m≥0)个互不相交的有限集合。其中每个集合又都是一棵树,称
    T1,T2,…,Tm–1为根结点的子树。
    显然,以上定义是递归的,即一棵树由子树构成,子树又由更小的子树构成。由条件(1)可知,一棵树至少有一个结点(根结点)。一个结点的子树数目称为该结点的度(次数),树中各结点的度的最大值称为树的度(树的次数)。度为0的结点称为叶子结点(树叶),除叶子结点外的所有结点称为分支结点,根以外的分支结点称为内部结点。例如,在图4-8所示的树中,根结点的度数为3,结点2的度数为4,结点4的度数为1,结点9的度数为2,其他结点的度数为0,该树的度数4。
    在这里插入图片描述
    在用图形表示的树中,对两个用线段连接的相关联的结点而言,称位于上端的结点是位于下端的结点的父结点或双亲结点,称位于下端的结点是位于上端的结点的(孩)子结点,称同一父结点的多个子结点为兄弟结点,称处于同一层次上、不同父结点的子结点为堂兄弟结点。例如在图4-8中,结点1是结点2,3,4的父结点。反之,结点2,3,4都是结点1的子结点。结点2,3,4是兄弟结点,而结点5,6,7,8,9是堂兄弟结点。
    定义一棵树的根结点所在的层次为1,其他结点所在的层次等于它的父结点所在的层次加1。树中各结点的层次的最大值称为树的层次。

  2. 树的遍历
    另外一个重点则是树的遍历问题,也就是根据某种顺序逐个获得树中全部结点的信息,常见的遍历方法有三种
    前序遍历:“根左右”,即先访问根结点,然后再从左到右按前序遍历各棵子树。以图4-8为例,前序遍历结果为:1,2,5,6,7,8,3,4,9,a,b。
    后序遍历:“左右根”,即从左到右遍历根结点的各棵子树,最后访问根结点。以图4-8为例,后序遍历结果为:5,6,7,8,2,3,a,b,9,4,1。
    层次遍历:首先访问处于0层的根结点,然后从左到右访问1层上的结点,以此类推,层层向下访问。以图4-8为例,层次遍历结果为:1,2,3,4,5,6,7,8,9,a,b

  3. 二叉树的概念
    二叉树是一个有限的结点集合,该集合或者为空,或者由一个根结点及其两棵互不相交的左、右二叉子树所组成。
    二叉树的结点中有两棵子二叉树,分别称为左子树和右子树。因为二叉树可以为空,所以二叉树中的结点可能没有子结点,也可能只有一个左子结点(右子结点),也可能同时有左右两个子结点。如图4-9所示是二叉树的4种可能形态(如果把空树计算在内,则共有5种形态)。
    在这里插入图片描述
    在二叉树中,有两种表现极为特殊,即满二叉树和完全二叉树,如图4-10所示。
    在这里插入图片描述
    在这里插入图片描述
    在这里插入图片描述
    在这里插入图片描述

  4. 二叉树的遍历
    树的遍历方法也同样适用于二叉树(如右图4-11所示),不过由于二叉树的自身特点,还有一种中序遍历法。
    前序遍历(根左右,先访问根结点,然后分别用前序分别遍历左、右子树,也称为前根遍历)。图4-11的前序遍历结果是:1,2,4,5,7,8,3,6。
    中序遍历(左根右,先按中序遍历左子树,再访问根结点,然后再按中序遍历右子树,也称为中根遍历)。图4-11的中序遍历的结果是:4,2,7,8,5,1,3,6。
    后序遍历(左右根,分别按后序遍历要左、右子树,然后再访问根结点,也称为后根遍历)。
    图4-11的后序遍历的结果是:4,8,7,5,2,6,3,1。层次遍历(首先访问处于0层的根结点,然后从左到右访问1层上的结点,以此类推,层层向下访问)。图4-11的层次遍历的结果是:1,2,3,4,5,6,7,8。
    在这里插入图片描述
    根据上面的定义和描述,我们可以发现遍历是递归定义的,最适合使用递归函数来实现。在学习遍历时,最重要的是结合其概念来灵活应用。

  5. 二叉查找树
    二叉查找树,它或者是一棵空树;或者是具有下列性质的二叉树:
    (1)若左子树不空,则左子树上所有结点的值均小于它的根结点的值;
    (2)若右子树不空,则右子树上所有结点的值均大于它的根结点的值;
    (3)左、右子树也分别为二叉查找树;
    当对这样的二叉树进行中序遍历,就可以得到一个排好序的结点序列,因此,二叉查找树也称为二叉排序树。二叉查找树主要考查其遍历。

  6. 平衡二叉树
    平衡二叉树又被称为AVL树,它具有以下性质:它的左右两个子树的高度差的绝对值不超过1 ,并且左右两个子树都是一棵平衡二叉树。满二叉树就是一种平衡二叉树。

  7. 线索二叉树
    二叉树在通常情况下是无法直接找到某结点在某种遍历序列中的前驱和后继结点的 。而线索二叉树则通过利用二叉树上空的指针域来存放这些“线索”信息 ,通常其采用以下做法:若前驱结点不为空,而且其右指针域为空,则将根结点的地址赋给前驱结点的右指针域,并将前驱结点的右线索标志置1;若根结点的左指针域为空,则把前驱结点的地址赋给根结点的左指针域,同时将根结点的左线索标志置1;将根结点地址赋给保存前驱结点指针的变量,以便当访问下一个结点时,此根结点成为前驱结点。

  8. 哈夫曼树
    在理解哈夫曼树之前,必须了解一些最基本的概念。
    树的路径长度 :是从树根到树中每一结点的路径长度之和 ,在结点数目相同的二叉树中,完全二叉树的路径长度最短;
    :在一些应用中会赋予树中结点一个有意义的实数 ,这个数字称为权;
    带权路径长度结点到树根之间的路径长度与该结点上权的乘积 ,称为结点的带权路径长度;
    树的带树路径长度 (树的代价):所有叶结点的带树路径长度之和

在这里插入图片描述而在权值相同的n个叶子结点构成的所有二叉树 中,带权路径长度最小的二叉树 称为最优二叉树,也称为哈夫曼树。构造哈夫曼树的过程如图4-12所示。
从图4-12中,我们可以发现每次都是选取最小权值的二叉树 进行合并,因此它使用的是贪婪法。而且,我们还可以发现,使用这种构造过程,哈夫曼树是不可能存在度为1的分支结点,而最初的n个节点将经过n-1次合并,生成n-1个新结点,因此哈夫曼树的总结点数是2n-1的结点 ,而叶子结点数正是n

三、图的概念

  1. 图的相关概念
    有向图:若一个图中的每条边都是有方向的,则称为有向图。在有向图中,<Vi,Vj>表示一条有向边,Vi是始点(起点),Vj是终点。<Vi,Vj>和<Vj,Vi>表示的是两条不同的边。有向边也称为弧,边的始点称为弧头,终点称为弧尾。
    无向图:若一个图中的每条边都是无方向的,则称为无向图。无向图的边是顶点的无序对,通常使用(Vi,Vj)来表示一条边,无向图的边没有起点和终点,(Vi,Vj)和(Vj,Vi)表示的是同一条边。
    无向完全图:如果限定任何一条边的两个顶点都不相同,则有n个顶点的无向图至多有n(n-1)/2条边,这样的无向图称为无向完全图。
    有向完全图:恰好有n(n-1)条边的有向图称为有向完全图。
    连通图:如果图中两个顶点间存在路径,则称它们是连通的;而如果图中任意两个顶点间都是连通的,则称该图为连通图。

  2. 图的存储结构
    图有两种主要的存储结构,它们是邻接矩阵表示法和邻接表表示法,如表4-5所示。
    在这里插入图片描述

  3. 图的遍历
    图的遍历也是从某个顶点出发,沿着某条搜索路径对图中每个顶点各做一次且仅做一次访问,常用的遍历算法包括以下深度优先和广度优先两种,如表4-6所示。
    在这里插入图片描述

  4. 最小生成树
    如果连通图G的一个子图是一棵包含G所有顶点的树,则该子图称为G的生成树。生成树是含有该连通图全部顶点的一个极小连通子图,它并不是唯一的,从不同的顶点出发可以得到不同的子树。含有n个顶点的连通图的生成树有n个顶点和n-1条边。要求一个连通图的生成树很简单,只需从任何一个顶点出发,作一次深度优先或广度优先的搜索,将所经过的n个顶点和n-1条边连接起来,就形成了极小连通子图,也就是一棵生成树。
    对一个带权的图,在一棵生成树中,各条边的权植之和为这棵生成树的代价,其中代价最小的生成树称为最小生成树。普里姆算法(Prim算法)和克鲁斯卡尔算法(Kruskal算法)是求连通的带权无向图的最小代价树的常用算法。
    注:带权的图是指每条边带上权值的图,常用于表示通路的代价。

  • 0
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

小哈里

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值