栈和队列

C语言实现栈和队列

栈和队列

栈的基本概念

 

栈是一种常用的数据结构,它具有特定的数据处理方式:后进先出(Last In, First Out,简称LIFO)。这意味着最后被添加到栈上的元素将是第一个被移除的元素。可以将栈想象成一摞盘子,你总是只能从顶部添加或移除盘子。

压栈:栈的插入操作叫做进栈/压栈/入栈,入数据在栈顶。

出栈:栈的删除操作叫做出栈。出数据也在栈顶。

image-20240127000929511

 

栈的主要操作包括:

  1. Push:将一个元素放置到栈顶。

  2. Pop:移除并返回栈顶元素。

  3. Peek:查看栈顶元素而不移除它。

  4. IsEmpty:检查栈是否为空。

栈的用途非常广泛,包括:

  • 函数调用:在大多数编程语言中,函数调用的执行是通过栈来管理的。每当调用一个函数时,其返回地址和相关参数都会被推送到一个调用栈上,函数返回时,则从栈上弹出。

  • 表达式求值:在算术和逻辑表达式的求值中,栈用于存储操作数和操作符。这是编译器设计中的一个重要应用。

  • 回溯算法:在需要回溯的算法(如深度优先搜索)中,栈用于存储之前的状态。

  • 历史记录:在许多应用程序中,如Web浏览器的后退按钮,栈用于维护历史记录。

C语言实现栈

我们将定义一个栈的结构,实现基本操作:push(入栈)、pop(出栈)、peek(查看栈顶元素)和 isEmpty(检查栈是否为空)。

在实现栈之前简单分析一下需要定义哪些元素,以及使用什么样的数据结构

  • 首先既然是存放和读取数据肯定需要设置一个或多个变量存储我们需要的数据,在这个示例里我选择一个int类型存储数据,同时为了维护方便,使用typedefint类型取一个新的名字StackDataType,这样假使以后需要更换int为其它类型,只需要修改typedef后面的类型就可以了

  • 其次得考虑选择顺序表还是链表来实现栈,先简单介绍下顺序表链表各自的优缺点吧:

    • 链表

      • 优点:

        1. 任意位置插入删除,时间复杂度O(1);

        2. 按需申请释放空间;

      • 缺点:

        1. 不支持下标的随机访问;

        2. CPU高速缓存命中率会低(文末有关于CPU高速缓存的补充说明)

    • 顺序表

      • 优点:

        1. 尾插尾删效率不错

        2. 下标的随机访问

        3. CPU高速缓存命中率会更高(文末有关于CPU高速缓存的补充说明)

      • 缺点:

        1. 前面部分插入数据,效率是O(N),需要挪动数据

        2. 如果空间不够,需要扩容.而扩容又分以下种情况, A、数组是物理连续的,扩容会继续向后开辟空间,如果后面空间足够则原地扩容,并返回原来的指针。这是最理想的情况,因为它不涉及数据的复制。而如果没有足够的连续空间,则会异地扩容,异地扩容就是从内存中其他地方找到足够的空间,然后将原内存块的数据复制到新内存块中,释放原内存块,并返回新内存块的指针。这种情况下,会涉及到数据的复制,会有一定的性能开销。B内存分配失败:如果无法分配足够的内存,realloc 将返回 NULL。需要注意,在这种情况下,原始内存块不会被释放,因此应该保留原始指针以避免内存泄漏。

        3. 扩容空间一般还会带来空间的浪费,因为扩容空间基本都是按原空间的二倍容量扩容.

    • 分析完优缺点后,对顺序表和链表优缺点有了一个基本的认识. 最终根据栈的后进先出特性考虑,使用顺序表来实现栈的操作,因为顺序表尾插和尾删的效率都非常高,以及它有CPU高速缓存命中率更高的优势, 所以它比链表更适合实现栈。

  • 除了定义数据类型和顺序表外,还需要定义两个整型,分别记录栈的结构中栈顶位置top以及容量capacity,

首先定义栈的头文件

stack.h:

然后实现函数的定义

stack.c:

初始化栈(栈需要自己手动创建)

销毁栈

移除栈顶元素

入栈函数

查看栈顶元素,返回栈顶元素的值,然后栈顶元素出栈

判断是否为空栈

返回栈的元素个数

 

测试函数(展示)

test.c

image-20240127103946268

栈的应用示例

leetcode题:有效的括号 原题链接

题目描述:

给定一个只包括 '('')''{''}''['']' 的字符串 s ,判断字符串是否有效。

有效字符串需满足:

  1. 左括号必须用相同类型的右括号闭合。

  2. 左括号必须以正确的顺序闭合。

  3. 每个右括号都有一个对应的相同类型的左括号。

示例 1:

示例 2:

示例 3:

提示:

  • 1 <= s.length <= 104

  • s 仅由括号 '()[]{}' 组成

这道题可以很好地使用栈解决,思路如下:

  1. 遍历字符串,如果是左括号就入栈

  2. 如果是右括号就和栈顶元素比较

    比较的过程很类似于消消乐游戏,先分析逻辑:

    • 遍历到当前的字符是右括号时,如果栈顶是同样类型的右括号,就出栈然后继续往下比较;

    • 如果是不同类型的右括号或者栈为空,则直接返回false,因为已经确定最左边的都是左括号,右括号只会跟最近的左括号相匹配,一旦不匹配或者空栈就能肯定整个字符串的括号是不闭合的

    • 还有一种情况是遍历完字符串所有元素,过程中的括号都恰巧匹配,但也不能忘记判断栈是否为空,如果不为空说明左括号数量是多于右括号数量的,这样也是返回false,只有在判断完成后栈为空的情况下才能确定是闭合的,返回true

接下来开始使用C语言代码实现这样一个思路

使用C实现会比C++麻烦很多,因为C不像C++有STL库的支持.

使用C需要自己写一个栈出来完成函数的调用,也就是常说的造轮子.

但是这个过程可以帮助更深入地理解这些数据结构和算法的内部工作原理。

编写代码:

用例测试通过

image-20240127130032856

提交通过

image-20240127130531934

当然,做这道题其实也用不着这么麻烦,用变长数组加条件判断就可以解决问题,只不过需要定义一个比较大的数组,空间上会有一些浪费

大概如下:

 

 

队列的基本概念

队列:只允许在一端进行插入数据操作,在另一端进行删除数据操作的特殊线性表,队列具有先进先出FIFO(First In First Out) 特性。

这意味着在队列中,元素是按照它们被添加的顺序来被移除的。可以将队列想象成排队等候做核酸的人群,最先来的人将是最先享受捅嗓子并离开队伍的。

入队列:进行插入操作的一端称为队尾

出队列:进行删除操作的一端称为队头

image-20240127131953429

队列也可以数组和链表的结构实现,使用链表的结构实现更优一些,因为如果使用数组的结构,出队列在数组头上出数据,效率会比较低。

使用链表实现的话,优先选择头删尾插的方式, 因为假如使用头插尾删的方式,在尾删的时候不仅要记录尾结点,同时还要记录尾结点的前一个结点

img

队列的主要操作包括:

  1. Enqueue(入队):在队列的尾部添加一个元素。

  2. Dequeue(出队):移除队列头部的元素,并返回它。

  3. PeekFront:查看队列头部的元素,但不移除它。

  4. IsEmpty:检查队列是否为空。

队列的用途

  • 任务调度和管理:在操作系统中,队列用于任务调度,管理等待处理的任务。

  • 缓冲处理:在数据处理和网络通信中,队列作为缓冲(buffer)来管理数据流,平衡不同处理速率的任务。

  • 异步数据传输:队列可以用于异步数据传输,其中数据的生产和消费速度不一致。

  • 用户界面事件处理:在许多应用程序中,用户生成的事件(如鼠标点击、键盘事件等)会排队等待处理。

  • 打印队列管理:在打印任务中,打印机使用队列来管理打印作业的顺序。

 

C语言实现队列

队列的创建和栈很相似, 但是链列需要定义个tail变量用来存储尾结点地址,所以在这里定义两个结构体

Queue.h:

然后实现队列函数的定义

Queue.c:

初始化队列

出队并返回值

入队

销毁队列

判断队列是否为空

查看队首

查看队尾

查看队列长度

测试函数(展示)

test.c

image-20240127141746072

队列的应用示例

leetcode题: 225.用队列实现栈

题目描述:

请你仅使用两个队列实现一个后入先出(LIFO)的栈,并支持普通栈的全部四种操作(pushtoppopempty)。

实现 MyStack 类:

  • void push(int x) 将元素 x 压入栈顶。

  • int pop() 移除并返回栈顶元素。

  • int top() 返回栈顶元素。

  • boolean empty() 如果栈是空的,返回 true ;否则,返回 false

注意:

  • 你只能使用队列的基本操作 —— 也就是 push to backpeek/pop from frontsizeis empty 这些操作。

  • 你所使用的语言也许不支持队列。 你可以使用 list (列表)或者 deque(双端队列)来模拟一个队列 , 只要是标准的队列操作即可。

示例:

提示:

  • 1 <= x <= 9

  • 最多调用100pushpoptopempty

  • 每次调用 poptop 都保证栈不为空

这道题本身没有什么实际意义,因为生产环境中不会有这种奇葩的代码需求,但是通过这道题可以帮助初学者加深了解栈和队列之间的关系,下面开始分析如何实现:

最基本的想法是使用两个队列(我们可以称之为 queueAqueueB)来模拟栈的行为:

  1. Push 操作(入栈)

    • 将新元素加入到空闲的队列(假设初始时为 queueA)。

  2. Pop 操作(出栈)

    • 将除最后一个元素外的所有元素从 queueA 移到 queueB

    • 出队 queueA 的最后一个元素(这就是栈顶元素)。

    • 交换 queueAqueueB 的角色(现在 queueB 是用来入队新元素的队列)。

  3. Top 操作(获取栈顶元素)

    • 类似于 Pop 操作,但是在移动元素到另一个队列后,将栈顶元素保存并返回,而不是删除它。当然也可以返回尾结点值的方式直接完成

  4. Empty 操作(判断栈是否为空)

    • 如果两个队列都为空,则栈为空。

  5. Free 操作(释放栈) :

    • 不光要释放栈本身,同时也要释放queueAqueueB

image-20240128111623493

image-20240128111659695

image-20240128111719065

编写代码实现

测试用例通过

image-20240128112214805

提交通过

image-20240128112233270

做完这道题之后其实还有一道姐妹题,使用栈实现队列,为了巩固对栈和队列这种数据结构的理解,顺便也做一下:

leetcode题:232.用栈实现队列

题目描述:

请你仅使用两个栈实现先入先出队列。队列应当支持一般队列支持的所有操作(pushpoppeekempty):

实现 MyQueue 类:

  • void push(int x) 将元素 x 推到队列的末尾

  • int pop() 从队列的开头移除并返回元素

  • int peek() 返回队列开头的元素

  • boolean empty() 如果队列为空,返回 true ;否则,返回 false

说明:

  • 只能 使用标准的栈操作 —— 也就是只有 push to top, peek/pop from top, size, 和 is empty 操作是合法的。

  • 你所使用的语言也许不支持栈。你可以使用 list 或者 deque(双端队列)来模拟一个栈,只要是标准的栈操作即可。

示例 1:

提示:

  • 1 <= x <= 9

  • 最多调用 100pushpoppeekempty

  • 假设所有操作都是有效的 (例如,一个空的队列不会调用 pop 或者 peek 操作)

这道题使用同样的思路会有些小问题, 假设两个栈分别是StackA StackB,按照顺序压栈1,2,3,4StackA, 第一次出队的时候把数据挪动到了StackB,然后移除栈顶元素,这时完成了一次出队的操作,但是如下一次出队呢?是否就不需要再倒一遍给StackA了?那么如何判断每次出队是否需要导数据? 想省去判断的步骤也可以,但是每次pop步骤可能得来回倒两遍,时间上会有一定程度的浪费.

  • 所以在这里可以换一种思路,仔细观察不难发现,给stackA压栈后,下一步执行出队操作时, 先把所有数据导给StackB,然后StackB正常出栈就是出队的操作,那么之后再有入队操作就把需要入队的数据压栈给StackA,出队操作就让StackB出栈,如果StackB为空栈,就再执行一遍从StackA导出所有数据给StackB的操作,如此一来就实现了两个栈的合理运用实现队列的效果

  • 所以两个栈都有各自负责的功能,重新命名为StackPushStackPop似乎更为合适

编写代码实现:

用例通过

image-20240128150536488

提交通过

image-20240128150556911

leetcode题: 239. 滑动窗口最大值

题目描述:

给你一个整数数组 nums,有一个大小为 k 的滑动窗口从数组的最左侧移动到数组的最右侧。你只可以看到在滑动窗口内的 k 个数字。滑动窗口每次只向右移动一位。

返回 滑动窗口中的最大值

示例 1:

示例 2:

提示:

  • 1 <= nums.length <= 105

  • -104 <= nums[i] <= 104

  • 1 <= k <= nums.length

这道题使用双端队列(deque)来解题是完全可行的,当然在解题前先捋一下思路:

解题步骤

  1. 初始化

    • 创建一个双端队列(存储索引)和一个数组(存储结果)。

    • 定义两个指针 frontrear 来表示队列的头部和尾部

  2. 遍历数组

    • 对数组中的每个元素执行以下操作:

    • 移除旧的元素索引:如果队列头部的元素(最大值)不在当前窗口内(即其索引小于当前索引减去窗口大小),则将其从队列头部移除。

    • 维持队列递减:从队列尾部开始,移除所有小于当前元素值的索引,因为这些元素不可能成为当前窗口的最大值。

    • 添加新元素索引:将当前元素的索引添加到队列尾部。

  3. 记录结果

    • 当窗口形成(即遍历到的元素索引大于等于窗口大小减1时),将队列头部元素对应的值加入结果数组,因为队列头部的元素是当前窗口的最大值。

假设数组为 [1,3,-1,-3,5,3,6,7],窗口大小为 3

  • 当处理前三个元素 [1,3,-1] 后,队列存储的索引为 [1, 2],因为 3 > -1,所以 下标1对应的值是这个窗口的最大值。

  • 接下来滑动到 [3,-1,-3],由于索引 0(对应元素 1)已经不在窗口内,我们将其从队列头部移除。队列变为 [1, 2, 3]

  • 以此类推,我们继续遍历数组,不断更新队列和结果数组。

需要注意的是:

  • 队列始终保持递减,确保队列头部是当前窗口的最大值。

  • 队列存储的是元素的索引,而不是元素的值,这样可以知道队列头部的元素是否仍在当前窗口内。

  • 使用双端队列是因为我们需要从两端进行操作:头部移除旧的最大值,尾部添加新的潜在最大值。

编写代码实现:

 

补充说明:CPU缓存命中

我们知道现在CPU核心数越来越多,使用的新一代CPU基本上都已经有了三级缓存(L1, L2, L3)

image-20240129152234007

  • L1缓存分成两种,一种是指令缓存,一种是数据缓存。L2缓存和L3缓存不分指令和数据。

  • L1L2缓存在每一个CPU核中,L3则是所有CPU核心共享的内存。

  • L1、L2、L3的缓存容量越离CPU近就越小,速度也越快,越离CPU远,速度也越慢,容量也越大。

再往后面就是内存,内存的后面就是硬盘。

我们的数据就从内存开始,先加载到到L3,再到L2,再到L1,最后到寄存器进行CPU计算。

当CPU需要访问内存中的数据时,它首先检查这些数据是否已经在缓存中(缓存命中)。如果在缓存中找到数据,就不需要访问较慢的主内存,从而加快了数据处理速度。如果数据不在缓存中(缓存未命中),则必须从主内存中加载数据到缓存中。

缓存基本上来说就是CPU把内存上的数据加载到离自己近的地方,为了提高效率每次都会加载一块连续的内存空间,有个专业术语叫“Cache Line”,市面上主流的CPU的Cache Line 是 64 Bytes(也有的CPU用32 Bytes和128 Bytes)64 Bytes也就是16个32位的整型,这就是CPU从内存中一次性加载上来的数据单位。

拿我的CPU AMD Ryzen 5950X来举例, 网页链接techpowerup 该网页记录了近几年来发布的CPU处理器的性能参数,可以查询到我的CPUL164KB

image-20240129164524849

比如:Cache Line单位是(64 Bytes),所以先把Cache分布多个Cache Line, L1有64KB,那么,64KB/64B = 1024 个 Cache Line。

顺序表

  • 顺序表是一种线性数据结构,元素在内存中是连续存储的。访问顺序表中的一个元素时,相邻的元素往往也会被加载到CPU缓存中。这是因为现代CPU缓存通常采用了预取(prefetching)机制,会自动加载临近的内存区域到缓存中。

  • 缓存友好性:顺序表因其连续的内存布局而具有较高的缓存友好性。连续的内存布局意味着更高的空间局部性(spatial locality),即一旦访问了一个数据项,接下来访问其相邻数据项的概率较高。

链表

链表是由一系列节点组成,每个节点包含数据和指向下一个节点的指针。链表的元素在内存中通常是非连续的。

  • 缓存不友好性:链表的这种非连续存储方式导致其缓存不友好。每次访问链表的一个节点时,下一个节点可能并没有被预先加载到缓存中,因为它们在内存中的位置可能相距甚远,降低了空间局部性。

  • 缓存污染: 每次读取链表中的元素也同样是按照块的大小从内存加载进缓存, 就很有可能导致每次加载的这个块中的其他数据是无法利用的,如果 CPU 缓存有限,频繁的缓存未命中会导致缓存中的数据被频繁替换,这可能会污染缓存,因为原本可能还会被访问的数据被新加载的(但可能用不到的)数据替换掉了。这种缓存污染可以导致 CPU 的效率降低,因为它不得不频繁地在缓存和主内存之间传输数据,而不能有效地利用缓存。在需要频繁遍历的场景中,链表相比于如数组这样的连续存储结构,在性能上会有显著的不足。

CPU高速缓存命中率的关系

  • 顺序表和高命中率:顺序表的连续内存布局有助于提高CPU缓存的命中率。当顺序遍历顺序表时,由于高空间局部性,缓存命中的概率更高。

  • 链表和低命中率:链表的非连续存储方式通常导致较低的缓存命中率。当遍历链表时,由于每个元素的位置可能分散在内存中的不同地方,每次访问节点都可能导致缓存未命中,从而需要从主内存中加载数据。

评论