数据结构
数据结构笔记
第一章 绪论
1.1 数据结构的基本概念
数据间的分类和关系
1 | 数据(Data) |
2、数据结构包含三个要素:逻辑结构、存储结构和数据的运算。算法的设计或定义取决于逻辑结构,而算法的实现取决于存储结构。
1 | 逻辑结构与存储结构无关,分为线性结构和非线性结构。 |
1.2 算法和算法评价
1、算法的五个特性:有穷性、确定性、可行性、输入和输出。
2、优秀算法的四个要求:正确性、可读性、健壮性、高效率和低存储量要求。
3、算法效率的度量是通过时间复杂度和空间复杂度来描述的。
1 | 时间复杂度指算法中基本运算的执行次数的数量级,有最坏时间复杂度、平均时间复杂度和最好时间复杂度。 |
常见的时间复杂度排序如下
$$
O(1)<O(log_2n)<O(n)<O(nlog_2n)<O(n^2)\<O(n^3)<O(2^n)<O(n!)<O(n^n)
$$
第二章 线性表
2.1 线性表的定义和基本操作
1、线性表是具有相同数据类型的n个数据元素的有限序列。
1 | 需要注意线性表是有限的,且线性表中的每个元素都是相同的类型。 |
2、线性表是一种逻辑结构,顺序表和链表才是其存储结构。
2.2 线性表的顺序表示
1、顺序表是线性表的顺序存储。
1 | 在读题时注意区分线性表和顺序表。 |
2、顺序表的特点是元素的逻辑顺序和存储的物理顺序相同。
1 | 注意在线性表中位序从1开始,但是在数组中存储中元素的下标是从0开始的。 |
3、顺序表的存储空间可以通过静态分配或者动态分配。
1 | 静态分配是固定大小,如果溢出就会导致程序错误终止。 |
4、顺序表存储的优点是可以随机访问,且存储密度高,但是在进行需要对元素进行移动的操作时速度较慢,且需要连续的存储空间,存储模式不够灵活。
5、插入操作,在插入时需要将后续元素后移。
1 | 在第i个位置插入时,需要将后续的n-i+1个元素后移。 |
6、删除操作,在删除时需要将后续元素前移。
1 | 在第i个位置插入时,需要将后续的n-i个元素前移。 |
7、按值查找,遍历
1 | 按值查找算法的平均查找次数为(n+1)/2,时间复杂度为O(n) |
按序号查找的时间复杂度为O(1),因为顺序表是可以随机存取的。
2.3 线性表的链式表示
1、链表是线性表的链式存储形式。
1 | 链表不要求逻辑结构中的相邻元素在物理存储结构上也相邻。 |
2、通常用头结点来标识一个链表,头结点的数据域可以设置数据或者不设置数据。
1 | 头结点的存在便利了对链表第一个元素的特殊判断,简化了算法。 |
3、求表长操作,遍历一遍,时间复杂度为O(n)。
4、按序号查找结点,遍历一遍,时间复杂度为O(n)。
1 | 如果是顺序表的话,时间复杂度为O(1)。 |
5、按值查找结点,遍历一遍,时间复杂度为O(n)。
6、插入结点,时间复杂度为O(n)。
1 | 在插入结点时为了防止断链,通常需要先将要插入的结点与后继结点连接之后,再将前驱结点与后继结点断开连接。 |
7、删除结点,时间复杂度为O(n)。
1 | 与插入结点类似,体现时间复杂度的主要操作是查找结点。 |
8、建立链表,建立链表可以使用头插法或者尾插法,时间复杂度为O(n)
1 | 使用头插法时,每次插入的元素都作为第一个元素,因此读入元素的顺序和生成的链表中的元素顺序是相反的,也就是逆序,可以用来实现链表的逆置。 |
9、双链表的插入结点操作,时间复杂度为O(n)
1 | 与单链表不同的是,双链表的插入需要对四个指针域进行修改,同样也需要保证在插入的过程中不断链。 |
10、双链表的删除结点操作,时间复杂度为O(n)。
11、循环链表,分为循环单链表和循环双链表。
1 | 循环链表与非循环链表的区别在于循环链表中的最后一个结点的指针会指向链表的头结点而不是NULL。 |
12、静态链表是通过数组模拟链表的存储结构实现。在结点中有数据域和指针域,但是此处的指针是结点在数组中的数组下标(相对地址),也称游标。
1 | 静态链表无法实现随机存取,但是需要连续的存储空间,一般以指针域为-1作为表尾结束标志。 |
13、题目小知识
1 | 链式存储结点内的元素存储地址是连续的(注意辨别是结点内还是相邻结点间)。 |
第三章 栈、队列和数组
3.1 栈
1、栈是只允许在一端进行插入或删除的线性表。
1 | 可以注意到栈是线性表,栈的主要结构有栈顶和栈底,操作时只能从栈顶入栈和出栈,是后进先出的模式(LIFO)。 |
2、栈的基本操作有初始化栈、栈空、栈满、出栈、入栈、销毁栈、读栈顶元素等。
1 | 当n个不同元素入栈时,出栈元素不同排列的个数符合卡特兰数。 |
$$
卡特兰数 = \frac{1}{n+1}C_{2n}^n
$$
3、栈的顺序实现称为顺序栈。顺序栈的存储结构是连续的,包含栈中元素和指向栈顶的指针。
1 | 指向栈顶的指针可能指向栈顶元素,也可能指向栈顶元素的下一个元素,都是常见的写法,做题时应当注意,在数据结构的所有时刻,都应当维持该数据结构的规则,对数据结构进行维护。 |
4、入栈时栈顶指针加一,出栈时栈顶指针减一,出栈时需要判断栈是否为空,入栈时需要判断栈是否为满。
5、共享栈,利用栈底位置相对不变的特性,可以让两个顺序栈共享一个一维数组空间。
1 | 共享栈就是两个栈共用一片空间,栈底分别在左右两侧,栈顶向中间增长。 |
6、栈的链式实现称为链栈。
1 | 链栈的优点是不存在栈满上溢的情况。(除非内存满了) |
7、题目小知识
1 | 在遇到计算出栈序列和进栈序列时,一般不用将所有的情况都列举出来,只需要对选项进行验证即可。 |
3.2 队列
1、队列是操作受限的线性表,只允许在表的一端进行插入,另一端进行删除。
1 | 注意到队列也是线性表,队列的主要结构为队内元素,队首(队头)指针和队尾指针。 |
2、队列的基本操作有初始化队列、队列空、入队、出队、读队首元素等。
1 | 栈和队列是操作受限的线性表,无法对栈或队列中的某个数据进行随机读取。 |
3、队列的顺序存储称为顺序队列。
1 | 顺序实现是分配的一块连续的存储单元,队首指针front指向队首元素,队尾指针rear指向队尾元素的下一个位置。 |
4、在顺序队列中,入队时队尾指针rear加一,出队时队首指针front加一。
1 | 当front == rear时,判定队列为空。 |
5、为了解决顺序队列的假溢出,可以使用循环队列。
1 | 循环队列也是顺序存储的。 |
6、队列的链式存储称为链式队列。
1 | 链式队列实际上是一个同时有队首指针和队尾指针的单链表。 |
7、双端队列是指允许两端都可以进行插入和删除的线性表。
1 | 双端对列包含几个种类。 |
8、题目小知识
1 | 链式存储尽管在存储空间上更加自由,通常不会溢出,但是还是受内存大小的限制。 |
3.3 栈和队列的应用
1、栈可以用于括号的匹配。
1 | 设置一个空栈,将序列进行遍历,若为左括号,则入栈,若为右括号,则查看栈顶是否为匹配的左括号,如果是则出栈,否则终止程序,匹配失败。若遍历完成之后,栈内还有左括号,匹配失败。 |
2、栈可以用于表达式的求值。
1 | 包括将中缀表达式转化为后缀表达式,以及对后缀表达式进行求值。 |
3、栈可以用于递归。
1 | 程序调用时变量存储在栈中,出口和参数也是通过栈结构进行存储。 |
4、队列可以用于层次遍历。
1 | 例如树的层次遍历或者bfs,这种对广度进行求解的情况。 |
5、队列还能解决主机与外设运行速度不匹配和解决多用户引起的资源竞争问题。
1 | 主机与外设不匹配时需要设置数据缓冲区,数据缓冲区是先进先出的,符合队列的规则。 |
6、题目小知识
1 | 对中缀表达式进行转换时只能得到唯一的后缀表达式 |
3.4 数组和特殊矩阵
1、数组是有限个相同类型元素组成的序列。
1 | 可以注意到跟线性表的定义类似。 |
2、数组的存储结构是一段连续的存储空间。
3、二维数组存在两种存储形式,一种是行优先存储,一种是列优先存储。
1 | 行优先存储就是先存储第一行再存储第二行,横向优先。 |
4、对于特殊矩阵,可以对其进行压缩存储。
1 | 压缩存储并不是对不存在的元素进行压缩,而是对相同的元素进行压缩,对零元素不分配空间。 |
5、对于对称矩阵
1 | 对于矩阵a,a(i,j) = a(j,i),这就是对称矩阵。 |
6、对于三角矩阵
1 | 三角矩阵分为上三角矩阵和下三角矩阵,存储的方式跟对称矩阵差不多。 |
7、对于三对角矩阵
1 | 对角矩阵也称带状矩阵,三对角矩阵的定义是,对于矩阵中的任意一个元素a(i,j),当|i-j|>1时,a(i,j) = 0,也就是说只有对角线附近的元素不为0。 |
8、对于稀疏矩阵
1 | 矩阵中非零元素相对于整个矩阵的元素来说非常少,通常达到了一百倍的差距,将这种矩阵称为稀疏矩阵。 |
第四章 串
4.1 串的定义和实现
1、串是由零个或多个字符组成的有限序列。
1 | 串中任意多个连续字符组成的子序列称为该串的子串,包含子串的串称为主串。串的逻辑结构和线性表类似,但是串的数据对象只能是字符集。 |
2、串的赋值、串的比较、求串的长度、串的拼接、求子串五种操作构成串类型的最小操作子集,其他操作都可以通过这五种操作得到。
1 | 除了串清除和串销毁 |
3、串的定长顺序存储表示,用一组地址连续的存储单元来存储串值的字符序列。
4、串的堆分配存储表示,仍然使用一组地址连续的存储单元存储,但是不是在程序执行前固定分配的,而是在程序执行的过程中动态分配的。
5、串的块链存储,类似线性表的链式存储结构,每个结点可以存储一个或多个字符,结点称为块。
4.2 串的模式匹配算法
1、模式匹配是指在主串中找到与模式串相同的子串,并返回其所在的位置。
2、暴力匹配算法:主串长度为n,模式串长度为m。
1 | 从主串的第一个字符开始,对模式串进行匹配。 |
在暴力匹配算法中,最多需要进行n-m+1次匹配,每次匹配最多需要进行m次比较,最坏时间复杂度为O(nm)。
3、串的模式匹配算法——KMP算法
在暴力算法中,每次失配都要从主串的下一个字符和模式串的第一个字符开始。但是如果已经匹配相等的前缀序列中有某个后缀正好是模式串的前缀,则可将模式串向右滑动到与这些相等字符对齐的位置。注意在KMP算法中,主串指针一直递增,向右滑动的是模式串的指针。
1 | 例如abcabcacab和abcac比较, |
模式串向右滑动的位数只和模式串本身相关,与主串无关。
4、KMP算法的原理
从上文可以看到,在KMP算法中,最重要的是在已经匹配相等的前缀序列中有某个后缀正好是模式串的前缀。而已经匹配相等的前缀序列其实就是模式串的前缀序列。所以其实本质上是指在已经匹配的模式串部分是否存在前后缀相等的情况。
1 | 对于模式串ababa |
1 | 此处再以主串abcabcacab和模式串abcac为例。 |
5、next数组手算方法
在KMP算法中,主串指针只会一直递增,而当每次匹配失败时,模式串会不断向右滑动。
由于实际算法中,模式串是固定的,不会一直滑动,所以表现出来的形式是指向模式串的指针变化。也就是说模式串向右滑动跟模式串的指针变化是等价的。
为此可以定义一个next数组,next[j]的含义是当模式串的第j个字符匹配失败时,模式串跳转到next[j]的位置继续匹配。
1 | 以abcac为例。 |
6、next数组的定义公式
$$
设主串为s_1s_2…s_n,模式串为p_1p_2…p_n。
$$
当主串的第i个字符与模式串的第j个字符失配时,可能存在三种情况。
第一种,j=1,此时主串应该和模式串的第零个字符匹配,next[j]=0。
第二种,j不等于1且已匹配的字符串中不存在最长相等前后缀,那么此时让主串的第i个字符和模式串的第1个字符匹配。
即next[j] = 1。
第三种,j大于等于2且已匹配的字符串中存在最长相等前后缀,假设最长相等前后缀的长度为k-1,即
$$
在已匹配子串p_1p_2…p_{j-1}中,存在k,使得p_1p_2…p_{k-1} = p_{j-k+1}p_{j-k+2}…p_{j-1}
$$
则next[j] = k,右滑位数为j-k。注意此处的最长这个限定词,在已匹配的字符串中可能有多个相等前后缀。
综上所述,next的定义如下
$$
\begin{equation}
next[j] =
\begin{cases}
0, & j = 1 \
max{k|1<k<j且p_1p_2…p_{k-1} = p_{j-k+1}p_{j-k+2}…p_{j-1}}, & 当此集合不为空时 \
1, & 其他情况
\end{cases}
\end{equation}
$$
7、next数组的递推
由于next数组的第一个和第二个元素都是已知的,且next[j] = k也存在递推的性质,因此可以尝试通过之前的next值得到之后的next值。
对于next[j] = k,其相当于
$$
p_1p_2…p_{k-1} = p_{j-k+1}p_{j-k+2}…p_{j-1}
$$
想要求出next[j+1],易得
$$
如果p_k = p_j,则p_1p_2…p_{k-1}p_{k} = p_{j-k+1}p_{j-k+2}…p_{j-1}p_{j}
$$
那么next[j+1] = k+1。
但是如果不相等呢,那么
$$
在已匹配子串p_1p_2…p_j中,需要找出k’,使得p_1p_2…p_{k’} =p_{j-k’+1}p_{j-k’+2}…p_{j-1}p_{j}
$$
已知k’<k,这是因为k已经是最长相等前后缀了
$$
\begin{array}{l}假设存在k’,在p_1p_2…p_{k-1}中,有\
p_1p_2…p_{k’-1} = p_{k-k’+1}p_{k-k’+2}…p_{k-1},又\
p_1p_2…p_{k-1} = p_{j-k+1}p_{j-k+2}…p_{j-1},可得\
p_1p_2…p_{k’-1} = p_{j-k’+1}p_{j-k’+2}…p_{j-1},则若\
p_{k’} = p_{j},则\
p_1p_2…p_{k’} =p_{j-k’+1}p_{j-k’+2}…p_{j-1}p_{j}。\
由于p_1p_2…p_{k’-1} = p_{k-k’+1}p_{k-k’+2}…p_{k-1},\
根据next[j]的定义可得,k’ = next[k]。\
证毕
\end{array}
$$
如果不匹配,则递推使用next[k],直到k’ = 0
$$
\begin{array}{l}
对于模式串abaabcaba,通过递推得到next数组\
next[1] = 0\
对于next[2],已知next[1]=0,j=1,k=0,则next[2] = 1。\
对于next[3],已知next[2]=1,j=2,k=1,p_j=b,p_k=a,则k’ = next[1]=0,根据next数组的定义,next[3] =1 \
对于next[4],已知next[3] = 1,即j=3,k=1,p_j = p_k = a,则next[4] = next[3] + 1 = 2。\
对于next[5],已知next[4] = 2,p_j = a,p_k = b,则k’ = next[2] = 1,p_{k’} = p_j = a,则next[5] = next[2] + 1 = 2。\
对于next[6],已知next[5] = 2,p_j = p_k = b,则next[6] = next[5]+1 = 3。\
对于next[7],已知next[6] =3,p_j = c,p_k = a,则k’ = next[3] = 1,p_{k’} = a,p_j=c,则k’’ = next[1] = 0,根据next数组的定义,next[7] =1。\
对于next[8],已知next[7] = 1,p_j=a,p_k=a,则next[8] = next[7] + 1=2。\
对于next[9],已知next[8] = 2,p_j=b,p_k=b,则next[9] = next[8] + 1 = 3。\
可得abaabcaba的next数组序列为011223123
\end{array}
$$
8、KMP算法的实现
先对模式串进行处理,求出next数组,代码不止下面一种写法,这是按王道的写的,说实话我觉得不太符合我的思考逻辑。
1 | # 参数为模式串和空的next数组 |
通过next数组对主串进行匹配
1 | # 参数为主串,模式串,next数组,返回值为主串和模式串匹配的第一个字符的位置 |
9、KMP算法的优化
之前定义的next数组在某些情况下存在缺陷。
$$
\begin{array}{l}
设主串为s,模式串为p\
在进行模式串和主串的匹配时,若主串和模式串在第i位处失配,证明s_i!=p_i。\
此时模式串的指针指向next[i]处,对s_i和p_{next[i]}进行比较。\
若p_i = p_{next[i]},那么此时就会造成重复的匹配。\
为了避免出现这种情况,在计算next数组时,\
如果出现p_i = p_{next[i]}的情况,则继续对i进行递推,\
令i’ = next[i],直到p_i’!=p_{next[i’]}为止,此时next[i’]为最优值。
\end{array}
$$
1 | 对于模式串aaaab和主串aaaba进行分析。 |
由于计算next的时候是通过递推得到的,即通过之前的next值得到之后的next值,那么为了实现优化,应当在计算之后的next值时对之前的next进行修改,不可能先得到之后的next值再得到之前的next值。
$$
\begin{array}{l}优化后的算法,对于模式串aaaab\
next[1] = 0
对于next[2],已知next[1]=0,j=1,k=0\
按照next值的计算方式,此时k=0,则next[2]应该等于1。\
但是p_{next[2]} = p_1 = a,则令next[2] = next[1]=0\
此时符合nextval的条件,next得到最优值。\
对于next[5],已知next[4]=3,j=4,k=3,p_k = p_j = a,则next[5] = 4\
对于不需要修正的next值,nextval=next。
\end{array}
$$
优化后的算法表示
1 | # 参数为模式串和空的nextval数组 |
说实话,代码写得太高级了(换作是我就先算整个next数组再算nextval数组,不然逻辑太难厘清了)
10、题目小知识
1 | 向右滑动的距离等于j-next[j] |