数据结构与算法--线性表

线性表

​ 线性表数据排列像线一样,每个线性表的数据最多只有前和后两个方向。数组、链表、队列、栈等都是线性表结构。

数组

​ 数组是一种线性表数据结构。它用一组连续的内存空间,来存储一组具有相同类型的数据。

连续内存空间代表数组中的所有元素都存储在一起,相同类型的数据代表每个元素占用空间的大小相同。同时保证这两个条件,才可以做到随机访问(也就是在O(1)复杂度下访问数组中的任意值),例如:

int[] val = new int[100];

//此时假设val数组的首地址是100,int型占用4字节
//那么a[3]首地址为100+3*4=112,地址范围为112~115,因此我们可以通过地址直接访问

​ 这正是这两个因素,导致数组的很多操作变得非常低效。比如插入、删除操作,为保证连续性就需要进行大量的数据搬移。例如:当前数组有n个元素,我们想要在第2个位置插入一个元素,那么从位置2开始往后的所有数据都需要依次往后移动一位。

复杂度

  1. 插入:O(n)
  2. 删:O(n)
  3. 改:O(1)
  4. 随机访问:O(1)

优化思路

针对数组的插入操作

​ 如果我们无需保证数组有序,要在长度为n的数组inx位置处插入值val,那么可以将原本在inx位置处的值赋值到n位置,然后将val赋值到inx位置,此时插入复杂度降为O(1)

t2

针对数组的删除操作

​ 方法一:我们可以将多次删除操作集中在一起执行(类似于JVM的标记清除垃圾回收算法)。每次删除操作只是标记一下,当数组空间不足时才触发真正的删除操作,这样就大大减少了删除操作导致的数据搬移。看到一个网友形象的说法:就像垃圾桶的垃圾,它并没有消失只是被 ‘‘标记’’ 成垃圾,只有垃圾桶塞满时才会清理垃圾桶,然后再次存储其它垃圾

​ 方法二:同插入优化类似,将被删除位置处的值替换为n - 1位置处的值,维护数组已使用长度len=len-1。

注意事项

​ 一定要警惕数组越界!!!!

​ 在 C 语言中只要不是访问受限的内存,所有内存空间都可以自由访问。 因此访问越界的数组编译器不会报错,而是会出现一些莫名奇妙的逻辑错误,而这些错误debug的难度非常大。

​ 举个例子:

#include <stdio.h>

int main(){
    
    int i = 0;
    int arr[3] = {0};

    for(; i<=3; i++){
        arr[i] = 0;
        printf("%d\n", i);
    }

    return 0;
}

​ 这段代码会无限输出0、1、2

​ 因为数组范围为0~2,由于非法设置arr[3]的值为0,导致每循环到i=3时,arr[3]被重置为0,也就是i被重置为0。

​ 至于arr[3]为什么就是i,建议大家看一下C++内存模型

​ 在这里简略说一下:我们先声明并创建i,然后再声明创建arr数组,由于栈地址空间是由高地址到低地址扩展,因此i比arr内存地址高。由于编译器64位操作系统下默认进行8字节对齐,因此i和arr[2]地址空间连续。由于数组元素和i都是int型(都占4字节),因此arr[3]实质上就是i。

​ 至于有些系统下(如Mac),无法出现死循环的情况。是因为默认开启了堆栈保护,可通过编译命令行参数-fno-stack-protector取消堆栈保护来达到该效果。

扩展小作业

​ 数组除了上述特征外,还有个致命缺点:无法动态扩容,也就是说数组大小固定,无法根据场景动态伸缩。

​ STL中Vector实现了对应的功能,大家可以自己看一下源码实现一个简易的vector,比如:

1. 申请全局变量cap代表数组的容量,len代表数组当前使用量
2. 申请指向容量为cap数组的指针,int *a = new int[cap]
3. 当添加元素时, 如果len < cap则a[len++] = val
4. 当添加元素时,如果len >= cap且cap < 100000,则cap = cap * 2;
   如果len >= cap且cap >= 100000,则cap = cap + 100000;
   并申请新的数组地址,并将原数组的元素复制到新数组中,并让指针a指向新数组,然后释放原数组内存空间
5. 考虑申请新数组内存空间失败的情况

链表

​ 我们学了数组发现它有以下几个缺点:

1. 插入、删除时间复杂度O(n)效率比较低
2. 数组中让大家实现vector功能的小作业,其中让大家考虑新数组内存空间失败的情况。原因就在于数组需要一块**连续的内存空间**, 对内存的要求比较高

​ 而链表就解决了上述两个问题: 链表也是一种线性表数据结构,它通过“指针”将一组零散的内存块串联起来使用。

​ 因为不是连续的,所以链表的插入、删除操作都是O(1)的,也正是因为它不是连续的,因此无法像数组一样通过寻址公式计算对应元素的内存地址,导致随机访问、改操作复杂度为O(n)。所以,没有最完美的算法,只有在某些场景下最合适的算法~

​ 我们最常用的链表结构有:单链表双向链表循环链表

单链表:

t2.1

循环链表:

t2.2

​ 和单链表相比,循环链表的优点是从链尾到链头比较方便。当要处理的数据具有环型结构特点时,就特别适合采用循环链表。比如著名的约瑟夫问题

双向链表:

t2.3
双向链表要比单 相比单向链表,双向链表占用更多的内存空间(2个指针),但可以支持双向遍历,更具有灵活性

复杂度

  1. 插入:O(1)
  2. 删:O(1)
  3. 改:O(n)
  4. 随机访问:O(n)

优化思路

​ 针对链表的插入、删除操作,需要对插入第一个结点和删除最后一个结点的情况进行特殊处理,这样可能会因为考虑不全而出错。

​ 我们可以使用带头链表(不管链表是不是空,head 指针都会一直指向这个哨兵结点。哨兵结点是不存储数据的。因为哨兵结点一直存在,所以插入第一个结点和插入其他结点,删除最后一个结点和删除其他结点,都可以统一为相同的代码实现逻辑)

t2.4

注意事项

​ 手撕链表时一定要警惕指针丢失和内存泄漏!!!!

​ 我们在写的时候,一定注意不要弄丢了指针,在添加操作时,一定要注意操作的顺序 。在删除操作时,一定要手动释放内存空间

扩展小作业

​ 用单链表存储字符串(一个节点存储一个字符),判断该字符串是否为回文串的方法?

​ 采用快慢指针的方法时间复杂度为O(n),空间复杂度为O(1),具体方式:快指针一次2步(p = p->next->next),慢指针一次一步(p = p->next),当快指针到终点时,慢指针正好到中点。 在慢指针前进过程中同时修改其 next 指针,使得链表前半部分反序,最后比较中点两侧的链表是否相等即可。

​ 除此之外还可以看一下单链表翻转、链表检测是否成环、两个有序链表合并、删除倒数第n个节点、求链表中间节点等问题,对应leetcode题目编号为(206,141,21,19,876)上找找做一下~

​ 栈是一种 操作受限的线性表数据结构 。它的特点就是先进后出,后进先出

​ 栈主要包括两个操作:入栈和出栈,当然还有查询栈头元素、是否为空等操作。

​ 相比数组和链表,虽丧失了灵活性,但由于只对外暴露几种操作接口,因此对于特定场景更加可控(实质上栈就是由数组或者链表实现的~),比如用于函数调用栈。

t2.5

复杂度

​ 入栈:O(1)

​ 出栈:O(1)

​ 返回栈头:O(1)

​ 栈是否为空:O(1)

应用场景

​ 之前提到的函数调用栈,大家可以补一下操作系统中栈帧的相关知识(后续操作系统专题会带领大家学一下~)

​ 简单说一下:操作系统给每个线程分配了一块独立的内存空间,这块内存被组织成“栈”这种结构, 用来存储函数调用时的临时变量。

​ 发生函数调用的时候,系统会在当前栈帧顶部压入被调用函数的参数、返回地址等信息,然后继续创建下一个栈帧。函数返回的时候回收栈帧,返回到上一个栈帧。

t2.6

​ 除此之外,近期学了一下Go语言,其中的derfer也是利用了栈的特性。除此之外,逆波兰表达式、括号匹配等操作都可以利用栈来实现,详细大家可以查一下~

扩展小作业

​ 利用动态数组来写一个可以动态扩容的栈~

​ 关于栈,大家可以做Leetcode上的 20,155,232,844,224,682,496 题

队列

​ 队列是一种 操作受限的线性表数据结构 。它的特点就是先进先出,后进后出

​ 和栈类似,队列主要包括两个操作:入栈和出栈,本质上也是通过数组或者链表实现

​ 队列通过维护两个指针:head(指向队头)、tail(指向队尾)来实现

t2.7

复杂度

​ 入队:O(1)

​ 出队:O(1)

​ 返回队头:O(1)

​ 队列是否为空:O(1)

优化思路

​ 随着不停地入队、出队操作,head 和 tail 都会持续往后移动。当 tail 移动到最右边,即使数组中还有空闲空间,也无法继续往队列中添加数据了。

​ 对于上述情况,我们可以利用循环队列来解决~,一定要注意 确定好队空和队满的判定条件

队列满时:(tail+1) % n == head
队列空时:head == tail

应用场景

对于大部分资源有限的场景,当没有空闲资源时,基本上都可以通过“队列”这种数据结构来实现请求排队 ,比如缓存、消息队列、网络中路由器的等待队列等等, 它们在很多偏底层系统、框架、中间件的开发中,起着关键性的作用

​ 基于链表实现队列,可以实现一个支持无限排队的队列,但可能会导致过多的请求排队等待,适用于响应敏感度低的系统

​ 基于数组实现的队列有大小有限,当排队请求超过队列大小时会拒绝之后的请求,适用于响应敏感度高的系统。对于队列的大小也要进行合理的设置:太大导致等待请求太多影响响应时间,太小无法充分利用资源影响性能。

扩展小作业

​ 阻塞队列:在队列的基础上增加了阻塞操作: 队列为空,从队头取数据会被阻塞;队列已满,插入数据的操作就会被阻塞。

​ 并发队列:基于阻塞队列,通过增加消费者(取数据)的个数来提高处理效率。在多线程模型下需要注意线程安全问题,解决方法:

1. 上锁,实现简单但性能低
2. 基于循环队列,利用CAS原子操作实现无锁循环队列

​ 业务中接触的消息队列等都是应用了队列的原理,优点是解耦合、异步、削峰等就不详细赘述。

​ 大家可以试着实现一下线程安全的无锁循环队列(一写多读模型)~

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值