软件设计师-3.数据结构与算法基础

结构:结构是指元素之间的关系。

逻辑结构:元素之间的相互关系称为数据的逻辑结构,可划分为线性结构非线性结构

  • 常用的线性结构有:线性表,栈队列、数组和串。

  • 常见的非线性结构有:二维数组,多维数组,广义表,树(二叉树),图。

存储结构:数据元素及元素之间的存储形式称为存储结构,可分为顺序存储链接存储两种基本方式。

  • 顺序存储时,相邻数据元素的存放地址相邻(逻辑与物理统一);要求内存中可用存储单元的地址必须是连续的。
  • 链接存储时,相邻数据元素可随意存放,但所占存储空间分两部分,一部分存放节点信息,另一部分存放表示节点间关系的指针。

3.1 线性结构

3.1.1线性表

3.1.1.1 顺序表

存储结构:顺序存储结构(顺序表)

存储方式:内存是连续分配的,并且是静态分配的,在使用前需要分配固定大小的空间。

操作:

  1. 访问第i个元素:根据下标,可以直接访问对应的元素。

  2. 查找是否含有某值:依次比对每个元素的值,直到找到。

  3. 删除第i个元素

    例如删除 a4,删除后,需要将 a5、a6、a7依次向前移动一个位置。

    含有n个元素的线性表采用顺序存储,等概率删除其中任一个元素,平均需要移动(n-1)/2个元素。

  4. 第i元素前插入某值

    例如在 a4 前插入元素,则a4、a5、a6、a7依次向后移动一个位置,然后再插入新元素。

    含有n个元素的线性表采用顺序存储,等概率插入一个元素,平均需要移动n/2个元素。

3.1.1.2 链表

存储结构:链式存储结构(链表)。

链表(linked-list)存储方式:链表的内存是不连续的,前一个元素存储地址的下一个地址中存储的不一定是下一个元素。访问某结点应找上一结点提供的地址,每一节点有一指针变量存放下一结点的地址。数据元素的逻辑顺序是通过链表中的指针链接次序实现的。

操作:

  1. 访问第i个元素:通过head开始查找,根据地址指针找到下一个元素,依次找到第i个元素

  2. 查找是否含有某值:依次比对每个元素的值,直到找到。

  3. 删除第i个元素:

    例如删除B结点,删除后,A的下一个地址需要指向C结点,即1021

  4. 第i元素前插入某值:

    例如在B结点前插入,插入元素后,A结点的下一个地址为新插入结点的地址;新插入结点的下一个地址为 B。

其他概念

  • 尾结点:最后一个有效结点。

  • 首结点:第一个有效结点。

  • 头结点:第一个有效结点之前的那个结点,存放链表首地址。

  • 头指针:指向头结点的指针变量。

  • 尾指针:指向尾结点的指针变量。

特点:

  1. n个节点离散分布,彼此通过指针相联系。
  2. 除头结点和尾结点外,每个结点只有一个前驱结点和一个后续结点。头结点没有前驱结点,尾结点没有后继节点。
  3. 头结点不存放有效数据,只存放链表首地址。其中数据类型通首节点类型一样。
  4. 头结点的目的是为了方便对链表的操作,比如在链表头进行节点的删除和插入。

以上讲解的是单链表,除了单链表外,链表该有循环链表、双向链表,如下图所示

数据结构中的逻辑结构是指数据对象中元素之间的相互关系。按逻辑结构可将数据结构分为()。

A.静态结构和动态结构

B.线性结构和非线性结构

C.散列结构和索引结构

D.顺序结构和链表结构

答案 B

若某线性表长度为n且采用顺序存储方式,则运算速度最快的操作是()。

A.查找与给定值相匹配的元素的位置

B.查找并返回第i个元素的值(1≤i≤n)

C.删除第i个元素(1≤i≤n)

D.在第i个元素(1≤i≤n )之前插入一个新元素

答案 B

以下关于单链表存储结构特征的叙述中,不正确的是()。

A. 表中结点所占用存储空间的地址不必是连续的

B. 在表中任意位置进行插入和删除操作都不用移动元素

C. 所需空间与结点个数成正比

D. 可随机访问表中的任一结点

答案 D

3.1.2栈和队列

队列:先进先出(FIFO——first in first out)

  • 队尾(rear)进行插入工作;
  • 队头(front)进行读取(删除)操作。

栈:先进后出(FILO——first in last out);

  • 栈顶(top):进行插入和读取(删除)操作的一端称为栈顶;
  • 栈底(bottom):固定不变,不可进行插入和删除操作;
  • 进栈: Push the stack
  • 出栈:Pop the stack

题1:元素按照 a、b、c的次序入栈,请尝试写出其所有可能的出栈序列。

这类题的解法主要是先将abc三个字符的素有排列给写出来,然后看看哪个是不可能的。

列出所有的排列: abc、acb、bac、bca、cab、cba

然后看看哪个不符合要求:cab

所以答案是 abc、acb、bac、bca、cba

3.1.3串

由字符(数组、字母、下划线等)构成的一维数组。

概念:

  • 空串:无任何字符的字符串。
  • 空白串:由空白符号(空格、制表符等)构成的字符串。
  • 子串:串中任一个连续的字符组成的子序列称为该串的子串。
  • 非平凡子串:非空且不等同意字符串本身。
  • 串的模式匹配:模式串在主串中首次出现的位置。
  • 字符串比较:从左至右按取ASCII码值进行比较。

设S 是一个长度为n的非空字符串,其中的字符各不相同,则其互异的非平凡子串(非空且不同于S本身)个数为( )。

A.2n-1

B.n²

C.n(n+1)/2

D.(n+2) (n-1)/2

答案 选D

分析:我们可以使用带入法来对本地进行分析,不需要真的推导公式。

比如,当长度为1时,它的非平凡子串的个数是0,看看哪个公式符合即可;若还是不行,则当长度为2时,它的非平凡子串的个数是2,再看看哪个公式符合。

3.2 数组、矩阵和广义表

3.2.1 数组

数组是由n个相同的元素所组成的序列。

注意:

  1. 空间连续,统一划分。
  2. 元素类型相同,每个元素占用存储单元相同。
  3. 下标有序,n个元素,下标是 0~n-1。

3.2.1.1 一维数组

A[i]的存储地址为:a+i*len。 首地址为a,len表示单个元素所占用的存储单元。

以存放首地址为100,每个元素占用3个存储单元为例。

A[i] 地址 = 100 + i*3

3.2.1.2 二维数组

3*4的二维数组

假设有n*m的二维数组,下标从0开始,按行存储,则

  • A[i][j]的偏移量为 (i*n+j) * len
  • A[i][j]的存储地址为 (i*n+j) * len + 数组首地址

3.2.2 矩阵

矩阵是很多科学与工程计算领域研究的数学对象。在数据结构中,主要讨论如何在节省存储空间的情况下使矩阵的各种运算能高效的进行。

3.2.2.1 特殊矩阵

在一些矩阵中,存在很多相同的元素或者是0的元素。为了节省空间,可以对这类矩阵进行压缩存储,即多个值相同的元素只分配一个存储单元,对0不分配存储单元。假如值相同的元素或0元素在矩阵中的分布有一定的规律,则称此类矩阵为特殊矩阵,否则为稀疏矩阵。

对称矩阵:若每一对元素(Aij、Aji)仅占用一个存储单元,则可将n²个元素压缩存储到能存放 n(n+1)/2 个元素的存储空间中。假设一维数组 B[n(n+1)/2]作为n阶对称矩阵A中元素的存储空间,则 B[k] 与 矩阵元素 Aij 之间存在一一对应的关系,如下所示 $$ k = i(i-1)/2 + j k = j(j-1)/2 + i $$ 对角矩阵:若以行为主序将n阶三角矩阵的非0元素近占用一个存储单元,则可将n²个元素压缩存储到能存放 3n-2 个元素的存储空间中。假设一维数组B[3n-2]z作为n阶对角矩阵A中元素的存储空间,则B[k]与 矩阵元素 Aij 之间存在一一对应的关系,如下所示 $$ k=3×(i-1) + j-i+1+1 = 2i +j-2 $$

3.2.2.2 非特殊矩阵(稀疏矩阵)

稀疏矩阵:非0元素的个数远远少于0元素的个数,且非0元素的分布没有规律。

对于稀疏矩阵,存储非0元素时必须同时存储器位置(即行号和列号),所以三元组(i,j,aᵢⱼ) 可唯一确定矩阵中的一个元素。

三元组表为 (1,2,12),(1,4,9),(2,4,7),(3,1,1),(4,1,2),(4,4,1)

3.2.2.3 矩阵的乘法

矩阵的乘法运算:设A为m*p的矩阵,B为p*n的矩阵,那么m*n的矩阵C为矩阵A与B的乘积,记作 C = AB,其中C中的第i行第j列元素可以表示为:

如下所示:

A的行与B的列依次相乘

f(1)=1,f(2)=1,n>2时f(n)=f(n-1)+f(n-2) 据此可以导出,n>1时,有向量的递推关系式:

(f(n+1),f(n))=(f(n),f(n-1))A

其中A是2x2矩阵( )。从而,(f(n+1),f(n)=(f(2),f(1))*( )

解析:

首先我们知道f(n)=f(n-1)+f(n-2),即n位置的数是前两个数字之和。 f(n+1)=f(n)+f(n-1) 是成立的, f(n-1)=f(n-2)+f(n-3)也是成立的。

选项1:

  • 这是一个矩阵乘法,我们可以使用代入的方式来进行计算。
  • (f(n),f(n-1))与A选项矩阵进行相乘
    • 结果是 ( f(n)x0+f(n-1)x1, f(n)x1+f(n-1)x1 ) = ( f(n-1), f(n) + f(n-1) )
    • 而 f(n+1)=f(n)+f(n-1) ,对上面的结果进行替换, ( f(n-1), f(n+1) )
    • 算出的结果不等于 (f(n+1),f(n))
  • (f(n),f(n-1))与B选项矩阵进行相乘
    • 结果是 ( f(n)x1+f(n-1)x1, f(n)x0+f(n-1)x1 ) = ( f(n) + f(n-1), f(n-1) )
    • 替换后 ( f(n+1), f(n-1) ) 结果也是错误的
  • (f(n),f(n-1))与C选项矩阵进行相乘
    • ( f(n)x1+f(n-1)x0, f(n)x1+f(n-1)x1 ) = ( f(n), f(n) + f(n-1) )
    • 替换后 ( f(n), f(n+1) ) 结果也是错误的
  • (f(n),f(n-1))与D选项矩阵进行相乘
    • ( f(n)x1+f(n-1)x1, f(n)x1+f(n-1)x0 ) = ( f(n) + f(n-1), f(n) )
    • 替换后 ( f(n+1), f(n) ),结果正确
  • 所以选项1的答案是 D

选项2:

  • 由选项1可知 ( f(n+1),f(n) )=( f(n),f(n-1) )A
  • 对n进行降维,即令n=n-1后, ( f(n),f(n-1) )=( f(n-2),f(n-3) )A
  • 继续降维 ( f(n-1),f(n-2) )=( f(n-3),f(n-4) )A
  • ...
  • 最终 ( f(3),f(4) ) = ( f(2),f(1) )A
  • 再次回到 ( f(n+1),f(n) )=( f(n),f(n-1) )A ,将 f(n),f(n-1)替换成 ( f(n-2),f(n-3) )A ,则 ( f(n+1),f(n) )= ( f(n+1),f(n) )=( f(n-2),f(n-3) ) A²
  • 继续替换 ( f(n-2),f(n-3) ),则 ( f(n+1),f(n) )=( f(n-3),f(n-4) ) A³
  • 最终发现由 ( f(n+1),f(n) ) 替换到 ( f(2),f(1) ) A,一共经过了n-1次替换。(也可以发现规律,就是后一个参数与A的次方之后等于n,则 ( f(2),f(1) ) 情况下,应该是A的n-1次方)
  • 所以答案选择 A

3.2.3 广义表

广义表(Lists,又称列表)是一种非连续性的数据结构,是线性表的一种推广。即广义表中放松对表元素的原子限制,容许它们具有其自身结构。

广义表通常记作: $$ Ls=( a1,a2,…,an) $$ ai即可以是一个元素,也可以是一个广义表,分别称为原子和子表。

广义表的长度是指广义表中元素的个数。广义表的深度是指广义表展开后所含的括号的最大层数。

在此,只讨论广义表的两个重要的基本运算:取表头head(Ls)和取表尾tail(Ls)。

  • 取表头head(Ls):非空广义表LS的第一个元素为表头,它可以是一个单元素,也可以是一个子表。
  • 取表尾tail(Ls):在非空广义表中,出表头元素外,由其余元素所构成的表称为表尾。非空广义表的表尾必定是一个表。

特点:

  • 广义表可以是多层次结构,因为广义表的元素可以是子表,而子表的元素还可以是子表。
  • 广义表的元素可以是已经定义的广义表的名字,所以一个广义表可以被其他广义表所共享。
  • 广义表可以是一个递归表,即广义表的元素也可以是本广义表的名字

存储结构,采用链式存储。

由于广义表中可同时存储原子和子表两种形式的数据,因此链表节点的结构也有两种,如图所示:

表示原子的节点由两部分构成,分别是 tag 标记位和原子的值,表示子表的节点由三部分构成,分别是 tag 标记位、hp 指针和 tp 指针。

tag 标记位用于区分此节点是原子还是子表,通常原子的 tag 值为 0,子表的 tag 值为 1。子表节点中的 hp 指针用于连接本子表中存储的原子或子表,tp 指针用于连接广义表中下一个原子或子表。

例如,广义表 {a,{b,c,d}} 是由一个原子 a 和子表 {b,c,d} 构成,而子表 {b,c,d} 又是由原子 b、c 和 d 构成,用链表存储该广义表如图 2 所示:

3.3 树

3.3.1 树与二叉树的定义

▶树的基本概念

父结点:例如结点1结点2的父节点

子结点:结点2结点1的子节点

兄弟结点:拥有同一父结点的两个子结点为兄弟结点。例如 结点2结点3是兄弟节点

叶子结点:无子结点的结点。如 结点4是叶子节点。

结点度:一个结点拥有几个子节点,它的度就是几。例如结点2的度为2

树的度:所有结点中,度最大的那个结点的度就是树的度。如上图,所有的结点中,每个节点的度最大不超过2,所以树的度为2.

树的度为2的树就是二叉树。s

层(深度、高度):一共有多少层。如上图,层是4层。

结点1是整个树的根节点。

3.3.2 二叉树的性质与存储结构

▶二叉树的划分

满二叉树:每一层都不能再插入一个结点。

完全二叉树:1~倒数第二层是满的,最后一层叶子节点是从左到右依次排序(左全右空)。

非完全二叉树:不是满二叉树、完全二叉树的树就是非完全二叉树。

▶二叉树的特性

  1. 在二叉树的第i层上最多有2 ⁱ ⁻¹个结点(i≥1)

  2. 深度为k的二叉树最多有2ᵏ-1个结点(k≥1)

  3. 叶子节点数为n。度为2的节点数为m,则n=m+1。

  4. 如果对一颗有n个结点的完全二叉树的结点按序编号(从第一层到log₂n+1层,每层从左到右),则对任一结点i(1≤i≤n),有:

    • 如果i=1,则结点i无父结点,是二叉树的根;如果i>1,则父结点是 i/2。

    • 如果 2i>n,则结点 i 为叶子结点,无左子结点;否则,其左子结点是结点2i。

      例如下图 9,2i=18 > 17,无左孩子结点

    • 如果 2i+1>n,则结点i无右子结点,否则其右子结点是结点 2i+1。

      例如下图 9,2i+1=19 > 17,无右孩子结点

    • 以上规律可以结合下图来看

提问:一个二叉树如果共有65个节点,问至少有多少层?最多有多少层?

最少有7层,可以套用公式深度为k的二叉树最多有2ᵏ-1个结点,当有6层是最多有63个结点,放不下65,所以最少有7层。

最多有65层,每层放一个结点。

▶二叉树的存储结构

  1. 显然对于完全二叉树和满二叉树采用顺序存储结构即简单又节省空间,对于一般的二叉树则不宜采用顺序存储结构。因为一般的二叉树也必须按照完全二叉树的形式存储,也就是要添加上一些实际不存在的虚点。

    如下图所示

  2. 对于普通的二叉树采用链式存储结构

    由于二叉树包含数据元素、左子树的根、右子树的根及双亲信息,因此可以用三叉链表或二叉链表来存储二叉树,链表的头指针指向二叉树的根结点。

题1:对下图所示的二叉树进行顺序存储(根结点编号为1,对于编号为i的结点,其左孩子结点为2i,右孩子结点为2i+1)并用一维数组BT来表示。已知结点X、E和D在数组BT中的下标为分别为1、2、3,可推出结点G、K和H在数组BT中的下标分别为( )。

A.10、11、12

B.12、24、25

C.11、12、13

D.11、22、23

答案 D

分析:由题可知 E下标2,它的右子节点F的下标为 2E+1 = 5;

​ G的坐标为 2F+1=11

​ K的坐标为 2G = 22

​ H的坐标为 2G+1=23

题2:完全二叉树的特点是叶子结点分布在最后两层,且除最后一层之外,其他层的结点数都达到最大值,那么25个结点的完全二叉树的高度(即层数)为()。

A.3

B.4

C.5

D.6

答案 5

3.3.3二叉树的遍历

  • 前序/先序遍历:先遍历根结点,然后遍历左子树,最后遍历右子树。

    上图前序遍历的结果是: 1 2 4 5 7 8 3 6

  • 中序遍历:先遍历左子树,然后遍历根结点,最后遍历右子树

    上图前序遍历的结果是:4 2 7 8 5 1 3 6

  • 后序遍历:先遍历左子树,然后遍历右子树,最后遍历根结点

    上图前序遍历的结果是:4 8 7 5 2 6 3 1

  • 层序遍历:从上往下逐层遍历

    上图前序遍历的结果是:1 2 3 4 5 6 7 8

提问:由前序为 ABHFDECG;中序序列为 HBEDFAGC 构造的二叉树。

由前序序列找到根结点,再有中序序列来确认哪个是左子结点,哪个是右子结点。

  1. 由前序序列,知道 A 为根结点。

  2. 由中序序列,知道左子树为 HBEDF ,右子树为 GC。

  3. 再回到前序序列,根据步骤2知道,BHFDE 是左子树,CG为右子树。

  4. 根据步骤3, C 是右子树的根结点。

  5. 步骤2可知,中序遍历是 GC 是右子树,可知道 G 是 C 的左子节点。

  6. 由步骤3 知道左子树的前序序列为 BHFDE,可知B是根结点

  7. 由步骤2 HBEDF ,可知H 为左节点,EDF 为右子树

  8. 由前序 FDE 可知, F是根结点

  9. 由中序EDF,可知,ED为F的左子树

  10. 由前序 DE 可知,D是根结点

  11. 由中序 ED,可知 E 是 D 的左子结点

3.3.4线索二叉树

二叉树的遍历实质上是一个非线性结构进行线性化的过程,它使得每个节点(除第一个和最后一个)在这些线性序列中有且仅有一个直接前驱和直接后继。但在二叉链表存储结构中只能找打一个节点的左、右孩子,不能直接得到结点在任一遍历序列中的前驱和后继,这些信息只能在遍历的动态过程中才能得到,因此引入线索二叉树来保存这些动态过程得到的信息。

为了保持前驱和后继信息,可考虑在每个节点中增加两个指针域来存放遍历时得到的前驱和后继信息,这样就可以为以后的访问带来方便。但增加指针信息会降低存储空间的利用率,因此可考虑采用其他方法。

若n个节点的二叉树采用二叉链表做存储结构,则链表中必然有n+1个空指针域,可以使用这些空指针域来存放结点的前驱和后继信息。为此,需要在节点中增加ltag和rtag,以区分孩子指针的指向,如下所示: $$ |ltag|lchild|data|rchild|rtag| $$ 其中:

ltag:0-lchild域指示结点的左孩子;1-lchild域指示结点的直接前驱。

rtag:0-rchild域指示结点的右孩子;1-rchild域指示结点的直接后继。

若二叉树的二叉链表采用以上所示的结构,则响应的链表称为线索链表,其中指向节点前驱、后继的指针称为线索。加上线索的二叉树称为线索二叉树。对二叉树以某种次序遍历使其称为线索二叉树的过程称为线索化。中序线索二叉树及其存储结构如图所示。

如何进行线索化呢?

实质上是在遍历过程中用线索取代空指针。因此,设指针p执行正在访问的结点,则遍历时设立一个指针 pre,使其时钟执行刚刚访问过的节点(即p所示结点的前驱结点),这样就记下了遍历过程中结点被访问的先后关系。

  1. 若p执行的节点有空指针域,则将响应的标志域置为1
  2. 若pre!=null且pre所指节点的rtag等于1,则灵 pre->rchild=p
  3. 若p所指向节点的ltag=1,则p->lchild = pre;
  4. 使pre执行刚刚访问过的节点,即令pre=p;

需要说明的是,用这种方法得到的线索二叉树,其线索并不完整。也就是说部分节点的前驱或后继信息还需要通过进一步运算来得到。

如何在线索二叉树中查找结点的前驱和后继呢?以中序线索二叉树为例,令p执行树种的某个结点,查找p所指结点的后继节点的方法:

  1. 若p-rtag==1,则p->rchild指向其后继结点。
  2. 若p-rtag==0,则p所指节点的中序后继必然是其右子树中进行中序遍历得到的第一个节点。也就是说,从p所指节点的右子树的根触发,沿着左孩子指针链向下查找,直找到一个没有左孩子的节点位置,这个节点就是就是p所指节点的直接后继节点,也称其为p的右子树的“最左下”的节点。

3.3.5特殊二叉树

3.3.5.1 二叉查找树

左子树小于根,右子树大于根

特点:

  1. 二叉查找树的中序遍历序列为从小到大排列的序列。
  2. 值最小的结点无左子树,值最大的结点无右子树。
  3. 每一层从左到右进行遍历的序列为从小到大排列的序列。

上图插入结点:序列(89,48,56,48,20,112,51)

  1. 若查找二叉树为空树,则以新结点为查找二叉树。
  2. 将要插入结点值域父节点值比较,确定放左子树还是右子树,若子树为空,则作为子树的根结点插入。
  3. 若该键值结点已存在,则不再插入。

3.3.5.2 哈夫曼树(最优二叉树)

需要了解的基本概念:

  • 树的路径长度:从树根到树中每一结点的路径长度之和。

    如下图左边的树,结点2的路径长度为2,结点4的长度为3,结点8的长度为3,结点1的长度为1

  • 权:在一些应用中,赋予树中结点的一个有某种意义的实数。

  • 带权路径长度:结点到树根之间的路径长度与该结点上权的乘积。

    如下图左边的树,结点2的带权路径长度为2x2=4,结点4的长度为3x4=12,结点8的长度为3x8=24,结点1的长度为1*1=1

  • 树的带权路径长度(树的代价):树中所有叶结点的带权路径长度之和。

    如下图左边的树,树的代价 = 4+12+24+1=41

哈夫曼树就是树的代价最小的那颗树。

假设有一组权值 50,20,30,40,10,请尝试构造哈夫曼树。

  1. 最小的两个构造一颗子树,10和20 ,并让他们的父节点为子节点之和即30

  2. 从上面剔除10,20,并将30加入即【50,30,30,40】,再找到最小的两个值 30,30,再次按照步骤1构造树

  3. 再次按照步骤2得到数组【50,60,40】,选择最小的进行树的构造

  4. 再次按照步骤2得到数组【90,60】,选择最小的进行树的构造

3.3.6树和森林

3.3.6.1树的存储结构

  1. 树的双亲表示法(双亲结点就是父结点)

    该表示法用一组地址连续的单元存储树的结点,并在每个结点中附设一个指示器,指出其双亲在该存储结构中的位置(即父结点所在元素的下标)。

     

    双亲表示法求指定结点的双亲和父结点都十分方便,但是求一个结点的孩子结点的时候就比较麻烦。

  2. 树的孩子表示法

    孩子表示法就是把每个节点的孩子节点存到一个单链表中,这个链表成为“孩子链表”,每个节点都对应一个孩子链表,没有孩子的结点,对应的孩子链表为空,结点的数据和孩子的头指针,我们还是用一个顺序表来存储。

    孩子表示法在找节点的孩子的时候十分方便,但是在找他的双亲的时候又有些麻烦,于是我们可以在这个顺序表的每个结点加上一个双亲域形成带双亲的孩子表示法。

  3. 孩子兄弟表示法

    孩子兄弟表示法是三种存储方式中最好操作的,它的本质是二叉树,只不过,它的右指针从右孩子变成了兄弟,其他与二叉树相同,如下图。

    如果想找到某个节点的第n个孩子,可以先通过他的指针找到第一个孩子,然后通过第一个孩子的兄弟结点遍历n-1次,此时得到的结点就是它的第n个孩子。

3.3.6.2 树和森林的遍历

由于树的每个结点可以有多个子树,因此遍历树的方法有两种,即先根遍历和后根遍历。

  1. 先根遍历

    先访问树的根结点,然后依次先根遍历各课子树。对树的先根遍历等同于对转换的二叉树进行先序遍历。

  2. 后根遍历

    先依次后边遍历树根的各课子树,然后访问树的根结点。对树的后根遍历等同于对转换的二叉树进行中序遍历。

森林的遍历

  1. 先序遍历

    若森林非空,首先访问森林中第一棵树的根结点,然后先序遍历第一课树根结点的子树森林,最后先序遍历出第一课树之外剩余的树所构成的森林。

  2. 中序遍历

    若森林非空,首先访问森林中第一棵树的子树森林,然后先序遍历第一课树的根结点,最后中序遍历出第一课树之外剩余的树所构成的森林。

3.3.6.3 树、森林和二叉树之间的转化

二叉树与树是一 一对应的关系,给定一棵树有其对应的唯一的二叉树,同理,给定一个二叉树,也有唯一对应的树(或森林)与之对应。

二叉树与树转化的实质就是,拿右指针为其兄弟,左指针为其孩子的二叉树解释成为一棵树,本质是与二叉树差不多的,只是他的右指针不再是他的右孩子,而是他的兄弟了,所以我们在解释的时候一定要注意他的指向含义。

根据我们对二叉树的定义,我们知道,任何一棵树对应的二叉链表的根节点的是没有兄弟的,那么如果我们遇到了,根节点有兄弟的我们应该如何理解呢?

其实,这个时候,我们可以将森林中的各个树的根节点,视为兄弟,这样子,这个树我们就可以解释了,其实他是一个森林对应的二叉树。他的根节点和他的第一个孩子视为是这个森林中的第一个树,右节点则是森林中的其他的树,这样子我们对根节点有兄弟的二叉树也可以做解释。

  1. 树转二叉树

  2. 森林转二叉树

  3. 二叉树转森林

    相当于2的逆过程,对于每一个右节点已经不再是右孩子,而是该结点对应的兄弟,除此之外其余正常

3.4 图

3.4.1图的定义和存储

3.4.1.1 图的定义

完全图:

  • 在无向图中,若每对顶点之间都有一条边相连,则称该图为完全图。
  • 在有向图中,若每对顶点之间都有2条有向边相连,则称该图为完全图。

问题:n个顶点的无向图和有向图的完全图的边的个数为多少?

答案:无向图的完全图有n(n-1)/2个边,有向图的完全图有n(n-1)个边。

连通图:指图中任意两个顶点之间都有一个路径相连

注意:

  1. 并不是两个点之间直接相连,而是经过n个顶点后,A可以与B相连,则为连通。
  2. 图中的一个顶点,与剩下的所有顶点都有路径可以连通。

针对有向图而言,有强连通(带上方向是连通图)和弱连通(带上方向不是连通图,去掉方向后是连通图)。

3.4.1.2 图的转换-无向图转邻接矩阵

用一个n阶方阵R来存放图中各结点的关联信息,其矩阵元素Rᵢⱼ定义为:

无向图转换的邻接矩阵为对称矩阵。

矩阵列和行分别表示顶点的序号,Rᵢⱼ

  • i对应的行序号,j对应的列序号。例如下图,圈出的表示 R₂₃=1,R₂₄=0

  • 针对出发,标记与其他列顶点的关系。例如,从第2行,标注的是顶点2与其他顶点的关系。

  • 针对出发,标记与其他行顶点的关系。例如,从第2列,标注的是顶点2与其他顶点的关系。

3.4.1.3 图的转换-有向图转邻接矩阵

与无向图的转换方式相同,当路径上有权值的时候,在矩阵中就填入权值,否则就填入0、1。

  • 该顶点为起点的有向边个数,叫做出度。其实就是从改行的顶点出发的所有路径。

  • 该顶点为终点的有向边个数,,叫做入度。其实就是所有指向达该顶点的路径。

  • 出入与入度之和叫做度。

3.4.1.4 图的转换-有向图转邻接链表

首先把每个顶点的邻接顶点用链表表示,然后用一个一维数组来顺序存储上每个链表的表头指针。

3.5 算法特性与复杂度

3.5.1 算法特性

基本特性

  • 有穷性:执行有穷步之后结束。

  • 确定性:算法中每一条指令都必须有确切的含义,不能含糊不清。

  • 输入输出数目约定:输入(>=0)。输出(>=1)。

  • 有效性(可行性):算法的每个步骤都有效执行并能得到确定的结果。

    例如 a=0,b/a就是无效的。

算法评价指标:

  • 正确性:正确实现算法功能,最重要的指标。
  • 友好性:具有良好的使用性。
  • 可读性:可读、可以理解的,方便分析、修改和移植。
  • 健壮性:对不合理的数据或非法的操作能进行检查、纠正。
  • 效率:对计算机资源的消耗,包括计算机内存和运行时间的消耗。

3.5.2 算法复杂度

时间复杂度

程序运行从开始到结束所需要的时间。通常分析时间复杂度的方法是从算法中选取一种对于所研究的问题来说是基本运算的操作,以该操作重复执行的次数为算法的时间度量。一般来说,算法中原操作重复执行的次数是规模n的某个函数T(n)。由于许多情况下要精确计算T(n)是困难的,因此引入了渐进时间复杂度在数量上估计一个算法的执行时间。其定义如下:

如果存在两个常数c和m,对于所有的n,当n>=m时由 f(n) <= g(n),则有f(n)=O(g(n))。也就是说,随着n的增大, f(n) 逐渐也不大于 g(n) 。例如,一个程序的实际执行时间常见的对算法执行所需时间的度量: $$ O(1)<O(log₂(n))<O(n)<O(nlog₂(n))<O(n²)<O(n³)<O(2ⁿ) $$

O(1) :一次可以执行完,没有循环、递归

O(log₂(n)):对半拆分、对半拆分。二分查找

O(n):依次循环,例如输出数组元素

O(nlog₂(n)):归并排序、快速排序

O(n²):插入排序、选择排序

考试一般会考,对应的查找、排序算法,它的时间复杂度。

空间复杂度

是指对一个算法在运行过程中临时占用存储空间大小的度量。一个算法的空间复杂度值考虑在运行过程中为局部变量分配的存储空间的大小。

3.6 查找

3.6.1顺序查找

顺序查找:将待查找的关键字跟表中的数据从头至尾按顺序进行比较。

平均查找长度(等概率情况):

3.6.2二分查找

二分法查找(折半查找)的基本思想是:(设R[low,...,high]是当前的查找区)

  1. 确定该区间的中点位置:mid = (low+high)/2。 (向下取整,例如6.5 就是6)
  2. 将待查的k值与R[mid]比较,若相等,则查找成功并返回此位置,否则需要确定新的查找区间,继续二分查找,具体方法如下(假设是从小到大):
    • 若 R[mid] > k,则由表的有序性可知,R[mid,...,high]均大于k,因此若表中存在关键字等于k的结点,则该结点必定是在 mid 左边的子表R[low,...,mid-1]中。因此,新的查找区间是左子表R[low,...,high],其中high=mid-1。
    • 若 R[mid] < k,则要查找的k必定在 mid 右边的子表R[mid+1,...,high]中。因此,新的查找区间是左子表R[low,...,high],其中low=mid+1。
    • 若 R[mid] = k,则查找成功,算法结束。
  3. 下次查找是针对新的查找区间进行,重复步骤1和2。
  4. 在查找过程中,low逐步增加,而high逐步减少。如果 high < low ,则查找失败,算法结束。

例子,请给出在含有12个元素的有序表{1,4,10,16,17,18,23,29,33,40,50,51} 中二分查找关键字17的过程。

二分查找在查找成功时关键字的比较次数最多为 log₂(n) + 1 次

二分查找的时间复杂度为 O(log₂(n)) 次

二分查找仅适用于元素有序的顺序表

二分查找(循环法)

int bigSearch(int r[], int low, int high, int key){
    //r[low,...,high]中的元素按非递减顺序,用二分查找法在数组r中查找与key相同的元素,若找到则返回该元素在数组的下标,否则返回-1
    int mid;
    while (low<=high) {
        mid = (low+high)/2;
        if(key == r[mid]) return mid;
        else if(key < r[mid]) high = mid-1;
        else low = mid+1;
    }
    return -1;
}

非递减即递增的!

二分查找(递归法)

int bigSearch(int r[], int low, int high, int key){
    //r[low,...,high]中的元素按非递减顺序,用二分查找法在数组r中查找与key相同的元素,若找到则返回该元素在数组的下标,否则返回-1
    int mid;
    if(low<=high) {
        mid = (low+high)/2;
        if(key == r[mid]) return mid;
        else if(key < r[mid]) bigSearch(r,low,mid-1,key) ;
        else bigSearch(r,mid+1,high,key);
    }
    return -1;
}

3.6.3散列表查找

3.6.3.1 散列表查找-线性探查法

散列表查找的基本思想是:已知关键字集合U,最大关键字为m,设计一个函数Hash,它以关键字为自变量,关键字的存储地址为因变量,将关键字映射到一个有限的、地址连续的区间 T[0,...,n-1] (n<<m) 中,这个区间就称为散列表,散列表查找中使用的转换函数称为散列函数。

关键码为(3,18,26,29,17),存储空间为10,散列函数 h = key % 7

  1. 存储3, 3%7=3,存放在3的位置。
  2. 存储18, 18%7=4,存放在4的位置。
  3. 存储26, 26%7=5,存放在5的位置。
  4. 存储29, 29%7=1,存放在1的位置。
  5. 存储17, 17%7=3,本该存放在3的位置,但是3的位置已经有值,产生了冲突,所以从3的后面位置(位置4)开始查找,直到有空闲的地方,即存放在位置6。

3.6.3.2 散列表查找-拉链法

关键码为(3,18,26,29,17),散列函数 h = key % 7,用链地址法。

3.7 排序

稳定排序与不稳定排序

稳定排序通俗地讲就是能保证排序前2个相等的数其在序列的前后位置顺序和排序后它们两个的前后位置顺序相同。在简单形式化一下,如果Ai = Aj,Ai原来在位置前,排序后Ai还是要在Aj位置前。与之相反就是不稳定排序

内排序与外排序

内排序是被排序的数据元素全部存放在计算机内存中的排序算法。若待排序记录的数量庞大,在排序的过程中需要使用到外部存储介质如磁盘等,这种涉及内外存储器数据交换的排序过程称为外部排序,又称为外排序

排序方法分类:

  • 插入类排序

    直接插入排序 、希尔排序

  • 交换类排序

    冒泡排序、快速排序

  • 选择类排序

    简单选择排序、堆排序

  • 归并排序

  • 基数排序

3.7.1 插入类排序-直接插入排序

即当插入第i个记录时,R₁,R₂,...,Rᵢ₋₁ 均已排好序,因此,将第i个记录 Rᵢ 依次与 Rᵢ₋₁,...,R₂,R₁ 进行比较,找到合适的位置插入。它简单明了,但速度很慢。

元 素 :16 3 25 11 17

第一趟:16 3 25 11 17

第二趟:3 16 25 11 17

第三趟:3 16 25 11 17

第四趟:3 11 16 25 17

结 果 :3 11 16 17 25

void insertSort(int data[], int n) {
    /**用直接插入排序法将data[0]~data[n-1]中的n个整数进行升序排列*/
    int i,j; int tmp;
    for(i=1; i<n; i++) {
        if(data[i] < data[i-1]) { // 将data[i]插入有序子序列data[0]~data[i-1]
            tmp = data[i]; // 备份待插入的元素
            data[i] = data[i-1];
            for(j=i-2; j>=0&&data[j]>tmp; j--)//查找插入位置并将元素后移
                data[j+1]=data[j];
            data[j+1]=tmp;//插入正确位置
        }
    }
}

时间复杂度 O(n²)

稳定性稳定的

空间复杂度 O(1)

使用的元素基本有序且与算法的排序方向一致,它会非常快速。

3.7.2 插入类排序-希尔排序

先取一个小于n的整数 d₁ 作为第一个增量,把文件的全部记录分成 d₁ 个组。所有距离 d₁ 的倍数的记录放在同一个组中。先在各组内进行直接插入排序;然后,取第二个增量 d₂ < d₁ 重复上述的分组和排序,直至所取的增量 dₜ = 1(dₜ <dₜ₋₁ <O<d₂ < d₁),即所有记录放在同一组中进行直接插入排序为止。该方法实质行是一种分组插入方法

在此我们选择增量gap=length/2,缩小增量继续以gap = gap/2的方式,这种增量选择我们可以用一个序列来表示,{n/2,(n/2)/2...1},称为增量序列。希尔排序的增量序列的选择与证明是个数学难题,我们选择的这个增量序列是比较常用的,也是希尔建议的增量,称为希尔增量,但其实这个增量序列不是最优的。此处我们做示例使用希尔增量。

void hearSort(int data[], int len) {
    int i,j,k,tmp,gap;//gap为希尔增量
    for(gap=len/2; gap>0; gap/=2) {
        for(i=0;i<gap;i++) {// 变量 i 为每次分组的第一个元素下标
            for(j=i+gap; j<len; j+=gap) { //对步长为gap的元素进行直插排序,当gap为1时,就是直插排序
                tmp = data[j];// 备份 data[j] 的值
                for(k=j-gap; k>=0&&data[k]>tmp;k-=gap) {
                    data[k+gap]=data[k];
                }
                data[k+gap]=tmp;//将其插入正确的位置
            }
        }
    }
}

时间复杂度:O(n²)

稳定性:不稳定

空间复杂度:O(1)

3.7.3 选择类排序-直接选择排序

过程:首先在所有记录中选出排序码最小的记录,把它与第1个记录交换,然后再其余的记录内选出排序码最小的记录,与第二个记录交换......依次类推,直到所有记录排完为止。

void selectSort(int data[], int n) {
    int i,j,k;
    int temp;
    for(i=0; i<n-1; i++) {
        for(k=i,j=i+1; j<n;j++) { //k表示data[i]~data[n-1]中最小元素的下标
            if(data[j]<data[k]) k=j;
        }
        if(k!=i) { //将本趟找出的最小元素与data[i]交换
            temp = data[i];
            data[i] = data[k];
            data[k] = temp;
        }
    }
}

时间复杂度:O(n²)

稳定性:不稳定

空间复杂度:O(1)

3.7.4 选择类排序-堆排序

堆是具有以下性质的完全二叉树:每个结点的值都大于或等于其左右孩子结点的值,称为大顶堆;或者每个结点的值都小于或等于其左右孩子结点的值,称为小顶堆。如下图:

同时,我们对堆中的结点按层进行编号,将这种逻辑结构映射到数组中就是下面这个样子

该数组从逻辑上讲就是一个堆结构,我们用简单的公式来描述一下堆的定义就是:

大顶堆:arr[i] >= arr[2i+1] && arr[i] >= arr[2i+2]

小顶堆:arr[i] <= arr[2i+1] && arr[i] <= arr[2i+2]

堆排序的基本思想是:将待排序序列构造成一个大顶堆,此时,整个序列的最大值就是堆顶的根节点。将其与末尾元素进行交换,此时末尾就为最大值。然后将剩余n-1个元素重新构造成一个堆,这样会得到n个元素的次小值。如此反复执行,便能得到一个有序序列了。

步骤:

  1. 构造初始堆。将给定无序序列构造程一个大顶堆(一般升序采用大顶堆,降序采用小顶堆)

    假设给定无序序列结构如下

    1. 此时我们从最后一个非叶子结点开始(叶结点自然不用调整,第一个非叶子结点 arr.length/2-1=5/2-1=1,也就是下面的6结点),从左至右,从下至上进行调整。

    2. 找到第二个非叶节点4,由于[4,9,8]中9元素最大,4和9交换。

    3. 这时,交换导致了子根[4,5,6]结构混乱,继续调整,[4,5,6]中6最大,交换4和6。

    4. 此时,我们就将一个无需序列构造成了一个大顶堆

  2. 将堆顶元素与末尾元素进行交换,使末尾元素最大。然后继续调整堆,再将堆顶元素与末尾元素交换,得到第二大元素。如此反复进行交换、重建、交换。

    1. 将堆顶元素9和末尾元素4进行交换

    2. 重新调整结构,使其继续满足堆定义

    3. 再将堆顶元素8与末尾元素5进行交换,得到第二大元素8.

    4. 后续过程,继续进行调整,交换,如此反复进行,最终使得整个序列有序

    void swap(int arr[], int a, int b) {
        int tmp = arr[a];
        arr[a] = arr[b];
        arr[b] = tmp;
    }

    /**
     * 调整大顶堆(仅是调整过程,建立在大顶堆已构建的基础上)
     */
    void adjustHeap(int []arr,int i,int length){
        int temp = arr[i];//先取出当前元素i
        for(int k=i*2+1;k<length;k=k*2+1){//从i结点的左子结点开始,也就是2i+1处开始
            if(k+1<length && arr[k]<arr[k+1]){//如果左子结点小于右子结点,k指向右子结点
                k++;
            }
            if(arr[k] >temp){//如果子节点大于父节点,将子节点值赋给父节点(不用进行交换)
                arr[i] = arr[k]; i = k;
            }else{
                break;
            }
        }
        arr[i] = temp;//将temp值放到最终的位置
    }

    void heapSort(int[] arr, int length){
        //1.构建大顶堆
        for(int i=length/2-1;i>=0;i--){
            adjustHeap(arr,i,length);//从第一个非叶子结点从下至上,从右至左调整结构
        }
        //2.调整堆结构+交换堆顶元素与末尾元素
        for(int j=length-1;j>0;j--){
            swap(arr,0,j);//将堆顶元素与末尾元素进行交换
            adjustHeap(arr,0,j);//重新对堆进行调整
        }
    }

堆排序的时间复杂度为:O(nlog₂(n))

稳定性:不稳定

空间复杂度:O(1)

3.7.5 交换类排序-冒泡排序

冒泡排序的基本思想是,通过相邻元素之间的比较和交换,将排序较小的元素逐渐从底部移向顶部。由于整个排序的过程就像水底下的气泡一样逐渐向上冒,因此称为冒泡算法。

void bubbleSort(int arr[], int n) { //冒泡排序
    int temp;
    int i,j;
    for(i=0;i<n-1;i++) { //外循环排序为排序趟数,n个数进行n-1趟
        for(j=0;j<n-1-i;j++) { //内循环为每趟比较的次数,第趟比较 n-i 次
            if(arr[j] > arr[j+1]) { //相邻元素比较,若逆序则交换
                temp = arr[j];
                arr[j]=arr[j+1];
                arr[j+1]=temp;
            }
        }
    }
}

时间复杂度:O(n²)

稳定性:稳定

空间复杂度:O(1)

3.7.6 交换类排序-快速排序

快速排序采用的是分治法,其基本思想是将原问题分解成若干个规模更小但结构与其原问题相似的子问题。通过递归解决这些子问题,然后再将这些子问题的解 组合成原问题的解。

一般情况下它的排序速度很快,只有当数据基本有序的时候速度是最慢的。

快速排序包括两个步骤:

  1. 在待排序的n个记录中任取一个记录,以该记录的排序码为准,将所有记录都分成两组,第一组都小于该数,第二组都大于该数。
  2. 采用相同的方法对左、右两组分别进行排序,直到所有记录都排到相应的位置为止。

以下例子是模拟第一遍分组的过程:

初始数组 57 68 59 52 72 28 96 33 24 19

  1. 首先找到基准数 57,此时l=0,h=9

  2. 从右侧开始, 19小于57,l(0)与h(9)处数据进行交换(数组为 19 68 59 52 72 28 96 33 24 57) 。

  3. 从左侧开始(l=l+1=1),68大于57停止移动,l(1)与h(9)处数据进行交换(数组为 19 57 59 52 72 28 96 33 24 68

  4. 从右侧开始(h=h-1=8),24小于57停止移动,l(1)与h(8)处数据进行交换(数组为19 24 59 52 72 28 96 33 57 68)

  5. 从左侧开始(l=l+1=2),59大于57停止移动,l(2)与h(8)处数据进行交换(数组为19 24 57 52 72 28 96 33 59 68)

  6. 从右侧开始(h=h-1=7),33小于57停止移动,l(2)与h(7)处数据进行交换(数组为19 24 33 52 72 28 96 57 59 68)

  7. 从左侧开始(l=l+1=3),52小于57继续移动(l=l+1=4),72大于57停止移动,l(4)与h(7)处数据进行交换

    (数组为19 24 33 52 57 28 96 72 59 68)

  8. 从右侧开始(h=h-1=6),96大于57继续移动(h=h-1=5),28小于57停止移动,l(4)与h(5)处数据进行交换

    (数组为19 24 33 52 28 57 96 72 59 68)

  9. 从左侧开始(l=l+1=5),此时发现l=h=5,第一次排序过程结束,数组为 19 24 33 52 28 57 96 72 59 68,57的左边部分小于它,右边部分大于它。接下来讲左边部分【19 24 33 52 28】和右边部分【96 72 59 68】继续进行排序,直到最后排序结束为止。

void quickSort(int a[],int n) {//快速排序
    int l,h;
    int pivot=a[0];//设置基准值
    l=0;h=n-1;
    while(l<h) {
        while(l<h && a[h]>pivot) h--;//从右向左移动,大于基准值者保持原位
        if(l<h) {a[l]=a[h]; l++;}
        while(l<h && a[l]<=pivot) l++;//从左向右移动,小于基准值者保持原位
        if(l<h) {a[h]=a[l]; h--;}
    }
    a[l]=pivot;//基准元素归位
    if(l>1) quickSort(a,l); //递归地对左子序列进行快速排序
    if(n-l-1>1) quickSort(a+l+1,n-l-1); //递归地对右子序列进行快速排序
}

时间复杂度:O(n²)

稳定性:不稳定

空间复杂度:O(nlog₂(n))

3.7.7归并排序

归并排序(MERGE-SORT)是利用归并的思想实现的排序方法,该算法采用经典的分治(divide-and-conquer)策略(分治法将问题(divide)成一些小的问题然后递归求解,而**治(conquer)**的阶段则将分的阶段得到的各答案"修补"在一起,即分而治之)。

分而治之

可以看到这种结构很像一棵完全二叉树,本文的归并排序我们采用递归去实现(也可采用迭代的方式去实现)。阶段可以理解为就是递归拆分子序列的过程,递归深度为log2n。

再来看看阶段,我们需要将两个已经有序的子序列合并成一个有序序列,比如上图中的最后一次合并,要将[4,5,7,8]和[1,2,3,6]两个已经有序的子序列,合并为最终序列[1,2,3,4,5,6,7,8],来看下实现步骤。

    /**
     * 归并排序
     * @param arr 数组
     * @param left 开始下标
     * @param right 结束下标
     * @param temp 临时数组
     */
    void mergeSort(int[] arr,int left,int right,int[] temp) {
        if (left<right) {//当还能继续分解数组时递归
            int mid=(left+right)/2;
            //向左进行分解-递归
            mergeSort(arr,left,mid,temp);
            //向右进行分解-递归
            mergeSort(arr,mid+1,right,temp);
            //每次分解都进行合并
            merge(arr,left,mid,right,temp);
        }
    }

    /**
     * 归并排序的合并过程
     * @param left 左侧下标
     * @param mid 中间下标
     * @param right 右侧下标
     * @param temp 临时数组
     */
    void merge(int[] arr,int left,int mid,int right,int[] temp) {
        int i=left;  //左边数组的初始索引值
        int j=mid+1; //右边数组的初始索引值
        int t=0; //临时数组的索引值
        int tempLeft;
        //1、通过比较左右两边的数组将数值有序放入临时数组
        while (i<=mid && j<=right) {
            if (arr[i]<=arr[j]) {
                temp[t++]=arr[i++];
            } else {
                temp[t++]=arr[j++];
            }
        }
        //2、将左边数组和右边数组剩余的元素迁移到
        while (i<=mid) { temp[t++]=arr[i++]; }
        while (j<=right) { temp[t++]=arr[j++]; }
        //3、最后将临时数组迁移到arr中
        t=0; tempLeft=left;
        while (tempLeft<=right) {
            arr[tempLeft++]=temp[t++];
        }
    }

3.7.8基数排序

基数排序介绍

  • 基数排序属于"分配式排序",又称"桶子发",顾名思义,它是通过键值的各个位的值,将要排序的元素分配到某些桶中,达到排序的作用
  • 基数排序法是属于稳定性的排序,基数排序法是效率高的稳定性 排序法
  • 基数排序的实现方式是:将整数按位数切割成不同的数字,然后按照每个位数分别比较

基数排序的思想

将所有待比较数值统一为同样的数位长度,数位较短的数前面补零.然后,从最低位开始,依次进行一次排序.这样从最低位排序一直到最高位排序完成以后,数列就变成一个有序序列.

基数排序图文说明

将数组{53,3,524,748,14,214} 使用基数排序,进行升序排序

    void radixSort(int[] array, int length)
    {
        int i,j,l,tmp;
        int maxNumLength=1;//记录数据的最大位数为几位,比如25 2位,130 3位
        int n=1;//代表位数对应的数:1,10,100...
        int k=0;//保存每一位排序后的结果用于下一位的排序输入
        int[][] bucket=new int[10][length];//排序桶用于保存每次排序后的结果,这一位上排序结果相同的数字放在同一个桶里
        int[] order=new int[10];//用于保存每个桶里有多少个数字
        int max;//最大值
        //获取到待排序数中的最大值的位数
        max = array[0];
        for (i = 0; i < length; i++) {
            if (array[i] > max) { max = array[i]; }
        }
        while(max/10>0) {
            maxNumLength++;
            max = max/10;
        }
        //基数排序
        n=1;
        for(l=0;l<maxNumLength;l++) {
            //装桶
            for(i=0;i<length;i++) {
                tmp=(array[i]/n)%10;
                bucket[tmp][order[tmp]]=array[i];
                order[tmp]++;
            }
            n*=10;
            //从桶中取出数据
            for(i=0;i<10;i++) {
                if(order[i]>0) {
                    for(j=0;j<order[i];j++) {
                        array[k++]=bucket[i][j];
                    }
                }
                order[i]=0;//将桶里计数器置0,用于下一次位排序
            }
            k=0;//将k置0,用于下一轮保存位排序结果
        }
    }

3.7.9 排序总结

3.8 错误习题集

题2

给定一个有n个元素的有序线性表。若采用顺序存储结构,则在等概率前提下,删除其中的一个元素平均需要移动()个元素。

A.(n+1)/2

B.n/2

C.(n-1)/2

D.1

答案 C

题目要求计算进行删除时平均移动元素个数,如 a、b、c、d、e、f ,若要删除f,则无需一定任何元素,直接删除即可;若要删除e,则需移动一个元素,即把f移至e位置;若要删除d,则需移动2个元素,把把e移至d位置,f移至e位置;依次类推,要删除第一个元素,则要移动n-1个元素。

y偶遇每个元素被删除的概率是相等的,所以平均需要移动的元素个数为 ( 0+(n-1) )/2 = (n-1)/2。

(最小数+最大数)/2为平均数。

题3

下来叙述中,不正确的是()。

A.线性表在链式存储时,查找第i个元素的时间与i的值成正比。

B.线性表在链式存储时,查找第i个元素的时间与i的值有关。

C.线性表在顺序存储时,查找第i个元素的时间与i的值成正比。

D.线性表在顺序存储时,查找第i个元素的时间与i的值无关。

答案 C

顺序存储结构的特点是“顺序存储,随机存取”,也就是说线性表在顺序存储时,查找第i个元素的时间与i的值无关。

链式存储结构的特点是“随机存储,顺序存取”,也就是说链式存储结构的数据元素可以随机地存储在内存单元,但访问其任意一个数据元素时,都必须从其头指针开始逐个进行访问。

题8

在一颗度为4的树T中,若有20个度为4的结点,10个度为3的结点,1个度为2的结点,10个度为1的结点,则树T的叶子节点的个数是()。

A.41

B.82

C.113

D.122

答案B

在树中,除根节点外,其余所有结点都是由其双亲节点引出的。一个度为n的节点表示由该结点引出n个孩子节点,因此树T的结点个为20*4+10*3+1*2+10*1+1= 123 ,其中最后的1位根节点,则叶子结点的个数为 123-(20-10-1-10)=82个。

题11

在查找算法中,可用平均查找长度(记为ASL)来衡量一个查找算法的优劣,其定义为:

此处Pᵢ为查找表中第i个记录的概率,Cᵢ为查找第i个记录时同关键字比较次数,n为表中记录数。

以下叙述中均假定每个记录被查找的概率相等,即 pᵢ=1/n (i=1,2,...,n)。当表中的记录连续有序存储在一个一维数组中时,采用顺序查找与折半查找方法查找的ASL值分别是()。

A.O(n),O(n)

B.O(n),O(lbn)

C.O(lbn),O(n)

D.O(lbn),O(lbn)

答案 B

顺序查找的基本思想是从表的一端开始,顺序扫描线性表,依次将扫描到的节点关键字和给定值k相比较。若当前扫描到的节点关键字与k相等,则查找成功;若扫描结束后,仍未找到关键字等于k的结点,则查找失败。顺序查找方法既适用于线性表的顺序存储结构,也适用于线性表的链式存储结构。

成功的顺序查找的平均查找长度如下: $$ ASL=np₁ + (n-1)p₂ + ... + 2pₙ₋₁ + pₙ $$ 在等概率情况下,Pᵢ=1/n(1≤i≤n),故成功的平均查找长度为(n+...+2+1)= (n+1)/2,即查找成功时的平均比较次数为表长的一半。若k值不在表中,则需要进行n+1次比较之后才能确定查找失败。查找的时间复杂度的为 O(n)。

若事先知道表中个结点的查找概率不相等,以及它们的分布情况,则应将表中结点查找概率由小到大的顺序存放,以便提高顺序查找的效率。

顺序查找的优点是算法简单,且对表的结构无任何要求,无论是用向量还是用链表来存放节点,也无论节点之间是否按关键字有序,它都同样使用。其缺点是查找效率低,因此当n较大时不宜采用顺序查找。

二分查找又称这版查找,是一种效率较高的查找方法。二分查找要求线性表是有序表,即表中结点按关键字有序,并且要求用向量作为表的存储结构。

二分查找的基本思想是(设R[low,...,high])是当前的查找区间:

  1. 确定该区间的中点位置:mid=(low+high)/2
  2. 将待查的k值与 R[mid].key 比较,若相等,则查找成功并返回次位置,否则需要确定新的查找区间,继续二分查找,具体方法如下:
    • 若R[mid].key > k,则表的有序性可知 R[mid,...,high]均大于k,因此若表中存在关键字等于k的节点,则该在R[low,...,mid-1]中。因此,新的查找区间是在左子表R[low,...,high],其中,high=mid-1
    • 若R[mid].key < k,则k在R[mid+1,...,high]中,即新的查找区间是在左子表R[low,...,high],其中,low=mid+1
    • 若R[mid].key = k,则查找成功,算法结束。
  3. 下一次查找针对新的查找区间进行,重复 1、2步骤
  4. 在查找过程中,low逐步增加,high逐步减少。如果 high<low,则查找失败,算法结束。

因此,从初始的查找区间R[1,...,n]开始,每经过一次与当前查找区间中点位置上结点关键字的比较,就可确定是否成功,不成功则当前的区间就缩小一半。重复这一过程,知道找到关键字为k的节点,或直至当前的查找区间为空时为止。查找的时间复杂度为:O(log₂n).

因此,答案是 B

题12★

根据使用频率,为5个字符设计哈夫曼编码不可能是()。

A.111,110,10,01,00

B.000,001,010,011,1

C.001,000,10,01,11

D.110,100,101,11,1

答案 D

哈夫曼编码属于前缀编码,根据前缀编码的定义,任一字符的编码都不是另一字符编码的前缀。而在选项D中,1是前面4个字符的前缀,明显违反了这一原则,所以不属于哈夫曼编码。

题13

二叉树在线索化后,仍不能有效解决的问题是()。

A.先序线索二叉树中求先序后继

B.中序线索二叉树中求中序后继

C.中序线索二叉树中求中序前驱

D.后序线索二叉树中求后序后继

答案 D

在中序线索二叉树中,查找结点P的中序后继分为以下两种情况。

  1. 若结点P的右子树为空,则直接得到中序后继。
  2. 若结点P的右子树非空,则中序后继是P的右子树中最左下的结点。

在中序线索二叉树中,查找结点p的前驱结点也有两种情况

  1. 若结点P的左子树为空,则直接得到中序前驱。
  2. 若结点P的左子树非空,则中序前驱是P的左子树中最右下的结点。

因此,在中序线索二叉树中,查找中序前驱中序后继都可以有效的解决。

先序线索二叉树中,查找结点先序后继很简单,仅从P出发就可以找到,但是找其先序前驱必须要知道 P 的双亲结点

后序线索二叉树中,查找结点后序前驱很简单,仅从P出发就可以找到,但是找其后序后继必须要知道 P 的双亲结点

题14

由元素序列(27,16,75,38,51)构造平衡二叉树,则首次出现的最小不平衡子树的根(即离插入结点最近且平衡因子的绝对值为2的结点)为()。

A.27

B.38

C.51

D.75

答案 D

平衡二叉树的构造过程如图:

根据题目要求,首次出现最小不平衡子树的根是 75.

题15

若 G 是一个具有36条边的非连通无向图(不含自回路和多重边),则图G至少有()个顶点。

A.11

B.10

C.9

D.8

答案 B

因G为非连通图,所以G中至少含有两个连通子图,而且该图不含有回路和多重边。题目问的是至少有多少个顶点,因此一个连通图可以看成是只有1个顶点,另一个连通图可以看成是一个完全图(因为完全图在最小顶点的情况下能得到的边数最多),这样该题就转化为“36条边的完全图有多少个顶点”,因为具有n个顶点的无向完全图的边条数为 n*(n-1)/2,可以算出 n=9 满足条件。在加上一个连通图(只有一个顶点),则图G至少有10个顶点。

题16

有向图 的所有拓扑排序序列有 () 个。

A.2

B.4

C.6

D.7

答案 A

拓扑排序是将AOV网中所有顶点排成一个线性序列的过程,并且该列满足:若在AOV网中从顶点vᵢ 到vⱼ有一条路径,则在该线性序列中,顶点vᵢ必在顶点vⱼ之前。

对AOV网进行拓扑排序的方法如下:

  1. 在AOV网中选择一个入度为0的顶点,且输出它
  2. 从网中删除该顶点即该顶点有关的所有弧。
  3. 重复上述两步,知道网中不存在入度为0的顶点为止。

本题中 A必须是第一个元素,E必须是最后一个元素,D必须是倒数第二个元素,即序列 A**DE,其中*为B或C,所以共两种拓扑排序序列。

2中情况的拓扑排序过程如下:

题19

用插入排序和归并排序算法对数组<3,1,4,1,5,9,6,5>进行从小到大排序,则分别需要进行()次数组元素之间的比较。

A.12,14

B.10,14

C.12,16

D.10,16

答案 A

插入排序是逐个将待排序元素插入到已排序的有序表中。用插入排序算法对数组<3,1,4,1,5,9,6,5>进行排序的过程:

  • 原元素序列:监视哨(3),1,4,1,5,9,6,5
  • 第一趟排序:3 (1,3),4,1,5,9,6,5 3插入时与1比较1次
  • 第二趟排序:4 (1,3,4),1,5,9,6,5 4插入时与3比较1次
  • 第三趟排序:1 (1,1,3,4),5,9,6,5 1插入时比较3次
  • 第四趟排序:5 (1,1,3,4,5),9,6,5 5插入时与4比较1次
  • 第五趟排序:9 (1,1,3,4,5,9),6,5 9插入时与5比较1次
  • 第六趟排序:6 (1,1,3,4,5,6,9),5 6插入时比较2次
  • 第七趟排序:5 (1,1,3,4,5,5,6,9) 5插入时比较3次

整个排序的比较次数 1+1+3+1+1+2+3 = 12

归并排序的思想是将两个相邻的有序子序列归并为一个序列,然后再将新产生的相邻序列进行归并,当只剩下一个有序序列时算法结束。那么用归并排序算法对数组<3,1,4,1,5,9,6,5>进行排序的过程:

  • 原元素序列:3,1,4,1,5,9,6,5
  • 第一趟排序:[1,3] [1,4] [5,9] [5,6] 比较4次
  • 第二趟排序:[1,1,3,4] [5,5,6,9] 前半部分比较3次,后半部分比较3次
  • 第三趟排序:[1,1,3,4,5,5,6,9] 5分别与 1、2、3、4比较一次

整个排序过程需要比较的次数为 4+3+3+4 = 14

题20

递归算法的执行过程,一般来说,可先后分成()两个阶段。

A.试探和回归

B.递推和回归

C.试探和返回

D.递推和返回

答案 B

递归算法的执行过程分为递推回归两个阶段。在递推阶段,把较复杂的问题(规模为n)的求解推到比原问题简单一些的问题(规模小于n)的求解。

在回归阶段,当获得最简单的情况后,逐级返回,依次得到稍复杂问题的解。

下面列举一个经典的递归算法的例子——菲波那切数列问题来说明这一过程。

菲波那切数列为:0,1,1,2,3,...,即

fib(0)=0;

fib(1)=1;

fib(n)=fib(n-1) + fib(n-2) (当n>1时)

写成递归函数有:

int fib(int n) {
    if(n==0) return 0;
    if(n==1) return 1;
    if(n>1) return fib(n-1) + fib(n-2);
}

这个例子的递推过程为:求解 fib(n) ,把它分解到 fib(n-1) + fib(n-2) 。也就是说,为计算 f(n),必须先计算 fib(n-1) 和 fib(n-2) ,而计算 fib(n-1) 和 fib(n-2) 又必须先计算 fib(n-3) 和 fib(n-4)。依次类推,直至计算 fib(1) 和 fib(0),分别能立即得到结果 1 和 0。在递推阶段,必须要有终止递归的条件。例如在 fib(n) 中,当 n 为 1和0的情况。

回归过程:得到 fib(1) 和 fib(0) 后,返回得到 fib(2) 的结果......在得到了 fib(n-1) 和 fib(n-2) 的结果后,返回得到 fib(n) 的结果。

题23

若循环队列以数组 Q[0,...,m-1]作为其存储结构,变量rear表示循环队列中队尾元素的实际位置,其移动按 read = (rear+1) mod m 进行,变量 length 表示当前循环队列中元素的个数,则循环队列的队首元素的实际位置是()。

A. rear - length

B.(rear - length + m) mod m

C.(1 + rear + m - length) mod m

D.m - length

答案 C

其实这种题目在考场上最好的解题方法是找一个实际的例子,往里面一套便知道了。下面理解以下原理。因为 rear 表示的是队尾元素的实际位置(注意,不是队尾指针)。而且题中有“移动按 read = (rear+1) mod m 进行”,这说明:队列存放元素的顺序为 Q[1],Q[2],...,Q[0]。所以在理想情况下 rear - length +1 能算出队首元素的位置,即当 m=8, rear=5, length=2 时, rear - length +1 = 4,4就是正确的队首元素实际位置。但 rear - length +1 有一种情况无法处理,即当 m=8, read=1, length=5 时无法计算出。

所以在 rear + 1 -length 的基础上加上 m 再与 m 求模,以此方法来计算。

题25

若广义表 L=( (a,b,c),e ),则L的长度和深度分别为()。

A.2和1

B.2和2

C.4和2

D.4和1

答案 B

广义表记作 LS=(a1,a2,...,an)其中LS是广义表名,n是它的长度,所以本表的长度为2.而广义表中嵌套括号的层数为其深度,所以L的深度为2.

题27

已知一个线性表(38,25,74,63,52,48),假定采用散列函数 h(key) = key % 7 计算散列地址,并散列存储在散列表 A[0,...,6] 中,若采用线性探测方法来解决冲突,则在该散列表上进行等概率成功查找的平均查找长度为()。

A.1.5

B.1.7

C.2.0

D.2.3

答案 C

要计算散列表上的平均查找长度,首先必须知道再建立散列表时,每个数据存储时进行了几次散列。这样就知道哪一个元素的查找长度是多少。散列表的填表过程如下:

  1. 首先存入第一个元素 38,由于 h(38) = 38 % 7 = 3,又因为 3号单元现在没有数据,所以把38存入3号单元

    0123456
    38
  2. 接着存入25,由于 h(25) = 25 % 7 = 4,又因为 4号单元现在没有数据,所以把25存入4号单元

    0123456
    3825
  3. 接着存入 74,由于 h(74) = 74 % 7 = 4,此时4号单元已被25占据,所以进行线性再散列,线性再散列公式为 Hᵢ = (h(key)+dᵢ ) % m,其中 dᵢ 为 1,2,3,4,...,所以 H₁ = (4 + 1) % 7 = 5,此时 单元5没有数据,所以把74存入到5号单元

    0123456
    382574
  4. 接着存入 63,由于 h(63) = 63 % 7 = 0,又因为 0号单元现在没有数据,所以把63存入0号单元

    0123456
    63382574
  5. 接着存入 52,由于 h(52) = 52 % 7 = 3,此时3号单元已被38占据,所以进行线性散列 H₁ = (3+1)%7 = 4,但4号单元也被占据了,所以再次散列 H₂ = (3+2)%7 = 5,但5号单元也被占据了,所以再次散列 H₃ = (3+3)%7 = 6,6号单元为空,所以把52存入6号单元

    0123456
    6338257452
  6. 接着存入 48,由于 h(48) = 48 % 7 = 6,此时6号单元已被占据,所以进行线性再散列 H₁ = (6+1)%7 = 0,但0号单元也被占据了,所以再次散列 H₂ = (6+2)%7 = 1,1号单元为空,所以把48存入1号单元

    0123456
    634838257452

如果一个元素进行了N次散列,相应的查找次数也是N,所以 38,25,63 这三个元素查找长度为1,74查找长度为2,48查找长度为3,52查找长度为4,平均查找长度为 (1+1+1+2+3+4)/6 = 2。

题28

设某算法的计算时间可用递推关系式 T(n) = 2T(n/2) + n 表示,则该算法的时间复杂度为()。

A. O(lgn)

B. O(nlgn)

C. O(n)

D. O(n²)

答案 B

递推关系式 T(n) = 2T(n/2) + n 其实是在给n个元素进行快速排序时最好情况(每次分割都恰好将记录分为两个长度相等的子序列)下的时间递推关系式,其中T(n/2) 是一个子表需要的处理时间,n为当次分割需要的时间。注意,这里实际上是用比较次数来度量时间。可以对此表达式进行变形得

​ T(n)/n - (2/n)*T(n/2) = T(n)/n - T(n/2)/(n/2) = 1

用 n/2 代替上式中的n可得

​ T(n/2)/(n/2) - T(n/4)/(n/4) = 1

继续用 n/2 代替上式中的n可得

​ T(n/4)/(n/4) - T(n/8)/(n/8) = 1

​ ...

​ T(2)/2 - T(1)/1 = 1

算法共需要进行 log₂n 次分割,将上述 log₂n 个式子相加,删除互相抵消的部分,得

​ T(n)/n - T(1)/1 = log₂n,而T(1) = 1

那么上式可转化为

​ T(n)/n = log₂n + 1 => T(n) = nlog₂n + n

而在求时间复杂度时关注“大头”,那么

​ T(n) = O(n log₂n) = O(n lgn)

题29

()算法策略与递归技术的联系最弱。

A.分治

B.动态规划

C.贪心

D.回溯

答案 C

  • 分治法:对于一个规模为n的问题,若该问题可以容易地解决(如说规模n较小)则直接解决;否则将其分解为k个规模较小的子问题,这些子问题互相独立且与原问题形式相同,递归地解这些子问题,然后将各个子问题的解藕饼到原问题的解。
  • 动态规划法:这种算法也用到了分治思想,它的做法是将问题实例分解为更小、相似的子问题,并存储子问题的解而避免计算重复的子问题。
  • 贪心算法:它是一种不追求最优解,只希望得到较为满意解的方法。贪心算法一般可以快速得到满意的解,因为它省去了为找到最优解而穷尽所有可能所必须耗费的大量时间。贪心算法常以当前情况为基础做最优选择,而不考虑各种可能的整体情况,所以贪心法不要回溯。
  • 回溯算法(试探法):它是一种系统地搜索问题的解的方法。回溯算法的基本思想是:从一条路往前走,能进则进,不能进则退回来,换一条路再试。其实现一般要用到递归和堆栈。

以上算法中的分治法和动态规划法通常要用到回溯算法,而回溯算法又一般要用到递归,所以只有贪心算法与递归技术联系最弱。

题30★

对于具有 n 个元素的一个数据序列,若只需要得到其中第k个元素之前的部分排序,最好采用()。

A.直接插入排序

B.希尔排序

C.快速排序

D.堆排序

答案 D

此题考察的是场景的内部排序算法。

  • 直接插入排序的基本思想:每步将一个待排序的记录按其排序码值的大小,插入到前面已经拍好的文件中的适当位置,直到全部插入为止。
  • 希尔排序的基本思想:先取一个小n的整数d1作为第一个增量,把文件的全部记录分成 d1 个组,所有距离为 d1 的倍数记录放在同一个组中。先在各组内进行直接插入排序;然后取第二个增量d2 < d1,重复上述的分组和排序,直至所有的增量 dt=1(dt < dt-1 < O < d2 < d1),即所有记录放在同一组中进行直接插入排序为止。该方法实质上是一种分组插入方法。
  • 直接选择排序:首先在所有记录中选出排序码最小的记录,把它与第一个记录交换,然后在其余的记录内选出排序码最小的记录,与第2个记录交换......依次类推,直到所有记录排完为止。
  • 堆排序:堆排序是一种树形选择排序,是对直接选择排序的有效改进。它通过建立初始堆和不断地重建堆,逐个将排序关键字按顺序输出,从而达到排序的目的。(从小到大排序:大顶推,每次取出根元素放在数组最后)
  • 冒泡排序:被排序的记录数组R[1,...,n]垂直排列,每个记录R[i]看做是重量为 ki 的气泡。根据轻气泡不能在重气泡之下的原则,从下往上扫码数组 R ,凡扫描到违反本原则的轻气泡,就使其向上“漂浮”。如此反复进行,知道最后任何两个气泡都是在轻者在上,重者在下为止。
  • 快速排序:采用了一种分治的策略,将原问题分解为若干个规模更小但结构与原问题相似的子问题。递归地解这些子问题,然后将这些子问题的解组合为原问题的解。
  • 归并排序:将两个或两个以上的有序子表合并程一个新的有序表,初始时,把含有n个结点的待排序序列看作由n个长度都为1的有序子表所组成,将他们依次两两归并得到长度为2的若干有序子表,再对他们两两合并,知道得到长度为n的有序表为止,排序结束。
  • 基数排序:从低位到高位依次对待排序的关键码进行分配和收集,经过d趟分配和收集,就可以得到一个有序序列。

了解这些算法思想后,解题就容易了。现在看题目具体要求,题目中“若只需得到其中第k个元素之前的部分排序”有歧义。例如,现在待排序列(15,8,9,2,23,69,5)。现在要求得到其中第三个元素之前的部分排序。

第一种理解:得到 (15,8,9)的排序

第二种理解:得到排序之后的序列(2,5,8,9,15,23,69)的(2,5,8,9);得到排序后第三个元素之前的部分排序,即(2,5,8)。

但综合题义,第一种理解可以排除。对于第二种理解,只有堆排序合适,因为希尔排序、直接插入排序和快速排序都不能实现部分排序。若要达到题目要求,只能把所有元素排序完成,在从结果集中把需要的数据列截取出来,这样效率远远不及堆排序。所以本体选择D.

  • 1
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值