一、栈
1.栈的基本概念
栈是一种特殊的线性表。在线性表中,我们可以在线性表中的任意一个位置插入或删除一个元素,但是在栈这种特殊的线性表下,我们只能在栈顶进行插入或删除的操作。
我们可以把栈想象成一批叠起来的盘子,这一叠盘子的最上边的盘子就是栈顶,最下面的就是栈底,也就是,如果我们要在这一叠盘子中再放一个盘子,那么只能放在顶上,而不能放在其他位置。那么如果这片空间中没有盘子呢?那么就形成了空栈。那么如果我们想要删除一个数据,也只能从栈顶删除,也就是栈的性质:后进来的先出去。(LIFO)
其实在生活中还有其他栈的例子,例如我们在玩原神的过程中,我们先打开抽奖页面,再打开抽奖页面中的其中一个奖池页面,那么我们新打开的奖池页面就存放在了栈顶(只能在栈顶进行出入操作),而抽奖页面就被奖池页面压在了栈底,如果我们想要退出这些页面的话,那么就要先退出奖池页面,再退出抽奖页面,也就与栈的性质不谋而合了,后进来的先出去(LIFO)。
2.栈的顺序存储实现
2.1初始化栈
首先我们创建一个struct结构体,结构体的数据项包括一个指向栈顶的指针top,以及我们所创建的静态数组,用来存放栈中元素。然后我们就可以通过创建结构体变量的方式来访问栈中各个元素以及栈顶指针。在初始化的过程中,栈中没有存放任何一个元素,那么我们就可以让top=-1(这里的top其实就是数组中元素的下标,栈底元素的下标为0)。
2.2进栈操作
首先我们利用结构体变量来访问栈中的top指针,令top+=1,然后再通过结构体变量来访问结构体中的数组的下标(top的值)的方式来找到我们要入栈的位置进行入栈。这样一来top就永远指向栈顶元素,那么我们对栈进行的操作也只能对栈顶元素进行。(这里要注意,一定要先让top的值+1然后再进栈,不然会出现对-1的位置进行入栈,程序也就会崩溃)。
访问代码为S.data[S.top],这里的S为结构体变量,data为数组中的数据,top为数组下标(栈顶指针)。
我们知道了top的作用,那么如果我们想判断栈满或者栈空,只需要看top的值即可,top为-1即为栈空,top=MaxSize-1即为栈满。
2.3出栈操作
我们知道了进栈操作的执行过程后,出栈操作也类似于进栈操作。但是需要注意的是,在出栈的时候,我们需要先利用结构体变量访问结构体中的数据(通过访问数据下标的方式)进行删除数据的操作,再令top-=1。这个过程正好与入栈操作相反。
3.栈的链式存储实现
我们在前面讲过,带头结点的单链表的创建有头插法,尾插法;带头结点的单链表的删除有对结点的后删操作以及前删操作。栈既然是一种特殊的线性表,那么链栈也就是一种特殊的单链表。所以我们根据栈的性质,不难猜出,链栈的创建(进栈)只能是头插法,因为只能对栈顶元素进行操作。那么链栈的删除也就对应着对头结点进行后删操作。
这里讲述一下头插法是如何插入的。首先创建一个结构体,结构体中包含一个数据域,以及一个指向下一个结构体的指针域(结构体的自引用)。然后给这个结构体用typedef函数取一个名字暂且称之为LNode。然后我们利用结构体的名字以及malloc函数创建一个结点,头指针L来指向该结点:LNode* L=(LNode* ) malloc (sizeof (LNode) )。然后我们再创建一个指针p也指向这个头结点。然后我们想好要插入多少个元素,利用for循环来解决。然后在循环中再次利用malloc函数创建一个要插入的结点q,利用scanf函数来输入结点的数据x,然后令q->data=x; q->next=p->next; p->next=q; p=q; 然后继续循环下去,直到链表结点足够为止。
这里要注意,再循环结束后,记得要将p复位到头指针L的位置。
头结点的后删操作:首先创建一个新的结点,指针f指向这个新的结点,再创建一个指针q指向将要被删除的结点,然后我们将第一个结点的数据域复制到这个新的结点上,然后再将头结点的指针域指向q指针指向的位置,然后free(f)即可。
二、队列
1.队列的基本概念
队列同栈一样是一种特殊的线性表,队列是一种先进先出的线性表,只能在一端插入,从另一端删除。队列我们要知道队头,队尾以及空队列。空队列和空栈一样,一个队列中没有任何数据元素,那么就是空队列。如果我们想在队列中插入和删除一个元素,那么只能从队尾进入,从对头进行删除。这就像我们生活中的排队一样,我们入队只能从队尾入,不能插队,然后当我们到了对头时就可以出队了。这也就符合了队的性质,先进先出(FIFO)。
2.队列的顺序存储
首先我们想象一下队列的性质,一个队列首先要有队列元素,然后需要在队尾以及队首进行相关操作。那么基于以上想法我们就可以创建一个struct结构体,结构体中包含储存队列元素的静态数组data[MaxSize],以及指向队首的指针 front 和指向队尾的指针 rear 。
注:这里的指针不是真正意义上的指针,而是代表着队中元素下标的整数。
结构体创建完毕后,我们就可以对这个队列进行入队以及出队,判断队是否已满或已空。在我们创建的结构体中队头指针 front 指向队首元素也就是初始下标为0,队尾指针指向队尾元素的下一个位置,也就是即将入队的位置。
基于对 front 以及 rear 的这种初始化形式,那么我们可以在队列的开始就将 front 和 rear 指向队首位置(此时队首为空),那么我们就可以通过判断 front与 rear是否相等的方式来判断一个队列是否为空。那么说到这里,就会出现一个问题:为什么不通过 rear 是否等于 MaxSize 来判断一个队列是否为空呢?
此时,如果我们用 rear 是否等于 MaxSize 的方式来判断队列是否为空的话。会出现,如果我们要删除一个队列中的元素,我们的 front 指针所指向的位置就会出队,然后 front 会指向被删位置的下一个位置,那么此时的 rear 等于 MaxSize 但是队列却不为空。如果我们继续出队,直到 front = rear,那么此时队列就为空。
2.1循环队列
在队列的顺序存储中,我们发现,一个元素入队指针 rear 就要后移,直到 rear == MaxSize 而出队时指针 front 也会后移,直到 front 与 rear 相遇,队列为空为止。但是这个过程中有一个问题,那就是如果 rear ==MaxSize 之后,便不能再进行入队操作,而进行出队操作后,又会留下空位而且还不能使用,这就造成了空间浪费。此时循环队列就可以解决这个问题。我们只需要令 rear == ( rear+1 ) % MaxSize。那么此时向后移位的过程中且队列中有一些元素出队 ( 此时的front != 0 ),只要rear+1=MaxSize。就会令rear等于0(这里用到的%是取模操作符,任何数对自己取模都等于0)。那么就可以重新在 rear 指向的位置插入元素。注:这里的rear是下标,所以要+1。
这种可以让 rear 到达 MaxSize 之后重新变为 0 的操作就是循环队列。但是这样一来就又会出现一个问题,我们队列判空的条件为 rear == front 那么如果 rear 可以循环,则队满的状态下也满足 rear == front ,这就造成了歧义。那么我们就只能舍弃一个单元的内存空间,让判断队满的条件为( rear+1 ) % MaxSize == front。这样就解决了这个问题。
那么有没有一种办法可以不舍弃这一个内存空间呢?
我们只需要引入第三变量就可以完美的解决这个问题,例如我们在创建结构体的时候就加入第三个变量来计算此时队中元素的个数,入队一次就+1,出队一次就-1。那么空队列的时候,这个参数就为0。而队满的时候这个参数就为MaxSize。或者我们引入一个变量 tag ,当我们插入入队时 tag=1 ,我们出队时 tag = 0。这样一来,队空时 tag==0;队满时 tag ==1 。这样一来,即便判断队满与队空的条件是一样的,但是由于引入了第三变量,我们也可以做到判断队满或队空。
3.队列的链式存储
首先我们创建第一个 struct 结构体变量用来存放我们队列的结点,以及指向下一个结点的指针LNode* next,然后再创建另一个 struct 结构体用来存放我们的头指针和尾指针(因为我们入队要从队尾入队,如果没有尾指针,就要从头指针遍历队列,时间复杂度很高)。
3.1入队
创建完结构体后我们用 malloc 函数创建一个结点然后让 front 和 rear 都指向这个结点,将这个结点当成头结点。然后利用尾插法来入队。如果是不带头结点的话 front 和 rear 都指向NULL,第一个元素入队的时候就需要特殊处理。我们需要令 front 和 rear 都指向这个新入队的结点。然后再用尾插法来创建一个队列。
这里说明一下尾插法:首先创建一个结构体,结构体中包含一个数据域,以及一个指向下一个结构体的指针域(结构体的自引用)。然后给这个结构体用typedef函数取一个名字暂且称之为LNode。然后我们利用结构体的名字以及malloc函数创建一个结点,头指针 front 和尾指针 rear 都来指向该结点:LNode* front = LNode* rear =(LNode* ) malloc (sizeof (LNode) ) 令 之后我们让front->next=NULL ,然后选择我们要插入的结点 s 。首先我们输入一个数据 x 复制给 s 的数据域,然后令s -> next = NULL ; rear -> next = s ; rear = s ; 然后可以继续按照这种方法入队。
3.2出队
在出队的时候,我们需要找到头指针 front 。然后对头指针进行后删操作。这里需要注意,如果我们要删除的元素是队列中的最后一个元素,那么尾指针指向这个元素被删除后,需要将尾指针移到头结点上。
4.双端队列(考研真题曾考过性质,很重要,需要重点复习)
4.1双端队列的基本概念
双端队列是一种只能在两端进行插入和删除的线性表。
4.2双端队列的变种
第一种是只能从某一段进行插入和删除,那么此时的双端队列就退化成了栈。第二种是只能从一段插入,从另一端删除,那么此时的队列就退化成了队列,第三种是输入受限,即只能在一端输入,但是可以在两端输出。第四种是输出受限,即只能在一段输出,但是可以从两端输入。
三、栈的应用
1.栈在括号匹配中的应用
我们在写代码的过程中,可能会写出一些带有括号的表达式,而如果我们写出的带有括号的表达式中,某一端缺少了左括号或者右括号,编译器就会直接报错,而编译器报错的原理就是栈在括号匹配中的应用。
当编译器遇到一个带有括号的表达式后,会将这个表达式从左到右进行扫描,当扫描到一个左括号 "(" "{" "[" 时编译器会将这个左括号放入栈中,继续扫描遇到左括号就放入栈中,当编译器将最后一个左括号压入栈中后,再向后扫描的第一个右括号如果与最后一个左括号不匹配,编译器就会报错,如果匹配那么就会将这个左括号弹出栈。并继续扫描下一个右括号,直到将所有右括号用完。此时如果栈中还有残留的左括号,那么编译器就会报错。如果在扫描右括号的过程中栈中的左括号已经用完,而表达式中还有右括号残留,那么编译器也会报错。
而左括号的入栈与出栈过程,就是栈在括号匹配中的应用。
2.栈在表达式求值中的应用
2.1后缀表达式
这里需要了解什么是中缀表达式,后缀表达式,前缀表达式。中缀表达式就是我们平时所常见的表达式。例如a+b。而后缀表达式就是将a和b之间的操作符后置,变为ab+,前缀表达式就是将a和b之间的操作符前置,变为+ab。CPU在处理表达式的时候使用后缀表达式。
2.2后缀表达式的计算
众所周知,我们电脑中的CPU不能像我们人脑一样去思考一个表达式应该先算什么位置,后算什么位置。CPU只能从左向右依次扫描这个表达式来进行计算。例如我们可以很好的判断出这个表达式:a+b*c。中应该先算b*c,但是CPU没有这个能力,那么我们就需要将这个表达式转化为后缀表达式:abc*+。那么CPU就可以对这个后缀表达式进行如下的处理:
CPU会从左到右扫描这个表达式,首先遇到一个操作数就将这个操作数压入栈中,这里的a,b,c相继入栈,直到CPU扫描到了第一个操作符*,那么就会从栈中弹出两个操作数,先弹出的操作数为右操作数,后弹出的操作数为左操作数,也就是这样 b*c ,然后将 b*c ,压入栈中(此时的b*c 是一个操作数),继续扫描,又扫描到了+,那么继续按照这个方法弹出两个操作数,就形成了(a)+(b*c),这里用()代表从栈中弹出的两个操作数。
这里还需要说明一下a+b*c是如何变为abc*+的:
首先将a+b*c从左到右扫描,当我们扫描到一个操作数时,直接弹出。而当我们扫描到了一个操作符时需要将其压入栈中。这里首先扫描到的是a,直接弹出,然后是+(这里需要观察,栈中没有任何操作符,所以可以直接压入栈中),压入栈中,然后是b直接弹出,然后是*压入栈中(这里需要观察栈中有一个+操作符,优先级小于*,故也要将*压入栈中,这里需要注意的是,如果栈中是 / 操作符优先级和*一样,那么就要将 / 弹出,再将*压入栈中),然后是c直接弹出。扫描完成后将栈中的操作符依次弹出,依次放在表达式的末尾。
2.3带括号的中缀表达式转化为后缀表达式
前面我们讲的中缀表达式不带括号,所以只需要根据运算符的优先级来写出后缀表达式即可,但是如果这个中缀表达式中带有括号比如 a + b - a * ( (c + d ) / e - f ) + g。在这个表达式中,我们既可以先算 c+d ,又可以先算a+b,那么此时我们就要左优先原则先算a+b。
那么我们来描述一下,如何将这个中缀表达式来转化为后缀表达式。
从左到右扫描,首先扫描到第一个操作数 a ,直接弹出,然后扫描到+,由于此时栈为空,那么直接将 + 压入栈中,然后扫描到 b 直接弹出,然后扫描到 - ,此时栈中有和 - 相同优先级的 +,那么此时就需要将 + 弹出栈,再将 - 压入栈中,继续扫描到 a 直接弹出,然后扫描到 * ,此时栈中为 - ,优先级低于 * ,那么就不用弹出 - ,直接将 * 放到栈顶。继续扫描到第一个 ( 压入栈中,第二个 ( 也压入栈中,然后扫描到 c 直接弹出,然后扫描到 + ,此时栈中虽然有优先级大于 + 的 * 运算符,也有优先级等于 + 的 - 运算符,但是由于他们都被 ( 压在栈下,故无法弹出,继续扫描。d 弹出,然后扫描到 ) 那么此时栈顶是 + ,然后就是 ( 那么此时,( 与 ) 匹配,故弹出所有栈中 ( 前的运算符。所以这里 + 直接被弹出。然后扫描到 / ,此时我们栈外弹出的是 ab+ acd+ ,此时栈中的栈顶元素为 ( ,继续扫描到 / 压入栈中,继续扫描到 e 直接弹出,那我们发现,栈外的 cd+ 单独形成一个操作数,而 / 为运算符,e 又为一个操作数,那么就可以将 / 弹出 ,然后扫描到 - 压入栈中,然后扫描到 f 直接弹出,继续扫描到下一个 ) ,那么这个 ) 就与栈中的 ( 匹配,此时就可以依次弹出栈中所有运算符,那么此时的 - 也被弹出。那么此时栈外:ab+ acd+e/f- ,此时我们发现cd+e/f- 形成一个单独的操作数,a 也是一个操作数,此时栈顶元素为 * ,那么就可以弹出 * 。弹出 * 之后我们又会发现 acd+e/f-*再次形成一个单独的操作数,且前面的ab+也是一个单独的操作数,那么此时就可以弹出栈中的 - ,栈外形成 ab+acd+e/f-*- ,继续扫描到 + 压入栈中,然后扫描到 g直接弹出,然后与前面那 ab+acd+e/f-*- 运算符相加写成后缀表达式,就形成了 ab+acd+e/f-*-g+。
3.栈在递归的应用
首先我们要知道递归的含义,假如当我们写一个函数merge的时候,在这个函数内部达成某种条件的时候再次调用这个函数merge。直到达成另一种条件的时候,我们就返回一个特定的值。那么在这个过程中,我们调用函数的过程就是函数入栈的过程;而返回一个特定的值的过程,就是出栈的过程。也就是 “递” 的过程就是入栈的过程,“归” 的过程就是出栈的过程。这里我们不再举例说明,自行体会这个过程。
四、队列的应用
1.树的层次遍历。2.图的广度优先遍历。
这里关于图和树,我还没学。学了再补充。
五、特殊矩阵的压缩存储
说到矩阵,就要先讲一下二维数组。
1.二维数组
二维数组也是一个数组,他们也存放在一整片连续的空间中,第一行的第一个元素时首元素,也是这个二维数组的地址。而二维数组在内存中的存放方式有两种,第一种是行优先方式,另一种是列优先方式。行优先存放是指先在开辟的内存空间中存放第一行元素,然后紧接着存放第二行元素;列优先存放是指先在开辟的内存空间中存放第一列元素,然后紧接着存放第二列元素。那么如果给定一个二维数组中的某一个元素a [ i ] [ j ],那么如何直到他的地址呢?
如果按照行优先原则,这个元素在第 i 行。由于数组下标是从 0 开始的,那么这个元素前面 i 行一共有 i * j 个元素。那么 a 是第 i + 1 行的 第 j + 1 个元素。所以,a 前面的所有元素加在一起的大小就是 ( i * j + j) * sizeof(Elemtype)。那么再加上首元素地址,就是 a 的地址。那么如果我们要存储一个普通的矩阵,就需要二维数组。
2.将特殊矩阵存储在一维数组中
2.1将对称矩阵储存在一维数组中
对称矩阵由于矩阵中的元素都相对于主对角线形成对称。那么我们只需要在矩阵中存放主对角线以及主对角线下方的元素即可。这样一来就可以节省内存空间。到时候如果我们想要使用矩阵中的某个元素,只需要将数组中的对应元素映射到矩阵中的某个位置即可。这里的映射函数我就不做赘述。(如果要访问上半区,只需要将矩阵的元素下标交换一下,aij变为aji即可。
考点*
那么接下来有一个考研的重要考点,给定一个特殊矩阵,再给一个一维数组。给出一维数组中某个元素的下标a [ k ],那么这个给定下标的元素在矩阵的什么位置?
我们假设a [ i ] [ j ],就是我们 a [ k ],所对应的矩阵中的位置。那么我们知道a [ i ] [ j ]在一维数组中的什么位置,不就可以反推出 a [ k ] 在矩阵中的什么位置了吗?那么我们来判断:由于给定矩阵为对称矩阵,那么存储在一维数组中的元素只有主对角线以及主对角线下方的元素。那么如果算出a [ i ] [ j ]前一共有多少个元素,不就知道a [ i ] [ j ]在一维数组中的下标是多少了吗?那么我们来算一下,矩阵的第一行存放1个元素,第二行存放2个,那么第 i - 1 行就存放 i - 1 个元素。那么如此一来,我们就算出了 a [ i ] [ j ]所在位置(不包括a [ i ] [ j ]所在行)前的所有元素。接下来,我们算a [ i ] [ j ] 所在行a [ i ] [ j ]前面的元素个数为 j 。那么二者相加,就可以算出a [ i ] [ j ]前的所有元素个数,那么这个数组就对应着 a 的下标 k 。如果规定这个数组的下标从 0 开始,还需要对这个数进行 - 1 的操作。
2.2三角矩阵储的压缩存储
我三角矩阵与对称矩阵类似,只不过三角矩阵的上半区或者下半区全都为常量,那么我们可以参考对称矩阵的方式来对三角矩阵进行压缩存储以及使用。我们只需要在一维数组中的最后一个位置多加入一个元素来储存三角矩阵中的常量即可。
2.3三对角矩阵(又称带状)矩阵的压缩存储
带状矩阵是一种只有主对角线中元素的左右两端元素可以不为 0 ,其他位置全为 0 的矩阵。那么在压缩存储的过程中只需要将主对角线以及主对角线左右两端的元素存储下来即可。那么其他的寻找数组元素在矩阵中所在位置与对称矩阵类似,可以自行推导。
2.4稀疏矩阵的压缩存储
稀疏矩阵是指一个矩阵中大多数元素都为 0 ,只有少数元素不为 0 ,那么此时我们只需要存储这些不为 0 的元素即可。那我们可以创建三个数组,每个数组的下标一一对应,那么我们可以在第一个数组中存放行号,第二个存放列号,第三个存放值即可。
稀疏矩阵还有一种存放方法叫做十字链表法。
首先我们创建一个结构体,结构体中存放着矩阵中非 0 元素的行,列,值,以及指向该行的下一个非零元素的指针,以及指向该列的下一个非零元素的指针。结点创建完毕后,我们将矩阵的列号称为指向该列的第一个非零元素的指针。每一个行号所形成的数组称为指针数组,用来存放指向每一行的第一个非零元素的指针。(王道课程中有图解)