线性表,指数据排成像一条线一样的结果,每个线性表的数据最多只有前后两个方向。
对应的非线性表,则没有明显的数据前后关系。
目录
数组
数组是用一组连续的内存空间,来存储一组具有相同类型的数据。通过上述两点(连续内存,相同类型),可以实现(基于索引的)随机访问。因为内存地址是连续的,可以根据每个数据类型的存储大小乘以自己希望访问元素的索引位置,通过数组首地址偏移的方式来计算访问元素的内存地址,进行直接读取。不会像链表那样(经常拿来和数组做对比),只能从头元素开始,通过next指针依次跳转才能访问读取。
而这也就是大多数编程语言的数组下标从0开始的原因,首元素对应索引值为0,对应偏移量也是0。(实际上首元素应该就是数组开始的内存地址,不存在偏移)但如果改为从1开始计数,那么访问过程中就多出了1次减法。而作为基础数据类型,这方面的优化尤其需要做到极致。
相同的特点,除了优点,也会带来缺点(balance,没有最优,只有最适合)。因为要求数组内存为连续,插入和删除操作都需要迁移数据。插入要求插入点后数据后移一位,不能让插入点属于单独的一块内存。优化,如果数组本身保存的并不是有序,而是作为数据集合,可以将插入数原本位置的元素简单放置到数组末尾。删除要求删除点后数据前移一位(不能让数组中间有空位,不然随机访问会出错)。优化,如果并不追求数组数据的连续性(个人理解就是随机方法特性),可以在删除操作发生时仅做预标记,与其他元素区分开来。等到多个删除操作发生后再合并为一次统一进行数据迁移。
数组越界,在没有边界检查的语言中要时刻检查数组越界,比如C。因为内存实质是有存储数据的,只是用于其他变量或者其他数据类型。这样越界访问后往往就得到莫名的错误,也难以进行调试检查。所以使用数组最重要的一点就是检查是否发生越界。
鸡汤:底层数据结构对于我们理解别人的算法是有帮助的,虽然从业务的角度出发,别人开发写好的容器工具是有用的就行。但程序员不能止步于码农。
链表
链表不像数组使用连续的内存块,其是通过后继指针来指明下一个节点对应的内存位置,所以可以利用零散分开的内存来进行保存。同样基于这种指针式的结构,在链表中间增加或者删除节点都不会需要做数据迁移,只是修改前一个节点的后续指针就能够实现。虽然链表在添加和删除操作上方便简单,但如果需要读取或查找特定的元素节点,则只能从头节点开始,依次读取后继节点来遍历。
除了常见的单链表,还有特殊的双向链表和循环链表。双向链表是除了后继节点外,还会额外保存一份前驱节点的指针来引用前一个节点的内存地址。双向链表在删除/增加特定节点的时候,可以方便的获取前驱节点来修改指针,而不是找到目标节点后再次遍历来获取前一节点。所以虽然双向链表使用更多内存,实际应用场景中还是比单向链表更受欢迎。循环链表是在尾节点的后继节点指向头节点,而不像单链表指向null,适合某些特殊的问题。
链表代码的技巧:
1、理解指针或者引用的含义。将某个变量赋值给指针,实际上就是将这个变量的地址赋值给指针。链表的next结点就是个指针,指向下一个结点的内存地址,通过地址来读取数据。
2、警惕指针丢失和内存泄漏。链表插入操作,类似于交换数值,有时需要借助中间变量,先保存会发生改变的结点,然后再通过中间变量来读取原来的数值。
3、利用哨兵简化实现难度。因为头结点以及尾结点的存在,通常需要特殊处理,可以在头结点之前再加一个哨兵结点,将所有结点的操作统一起来。
4、重点留意边界条件处理。比如空链表,单个结点链表,2个结点链表以及头尾结点是否处理正确。
5、举例画图,辅助思考。画图可以帮助我们理清思路,而不是全部都在脑海中构想。很重要,不要特意考验我们的心算能力。
栈
栈是一种操作受限的线性表,只能在结构的一端进行插入和删除操作。符合先进后出的特点,有些类似于碟架,先放进去的就被压在下面。我们常用的函数调用就类似这种,在进入调用函数的时候,就把外层函数的变量和参数保持起来压栈,等到调用函数执行完毕后,再把这些外层函数的执行状态出栈,继续执行。
用数组实现的栈,我们叫作顺序栈,用链表实现的栈,我们叫作链式栈。如果是顺序栈需要支持动态扩容,那么底层只要采用支持动态扩容的数组就行。在原数组存储满后,申请一个更大的数组,然后将原数组的元素复制到新数组中。入栈操作的最坏情况时间复杂度是O(n),但均摊时间复杂度还是O(1),因为大多数时间下并不需要做数据复制。
栈的应用-表达式求值,编译器就是通过两个栈来实现,其中一个栈保存操作数,另一个栈保存运算符。我们从左向右遍历表达式,当遇到数字,我们就直接压入操作数栈;当遇到运算符,就与运算符栈的栈顶元素进行比较。如果比运算符栈顶元素的优先级高,就将当前运算符压入栈;如果比运算符栈顶元素的优先级低或者相同,从运算符栈中取栈顶运算符,从操作数栈的栈顶取 2 个操作数,然后进行计算,再把计算完的结果压入操作数栈,继续比较。
下图示例说明:当到第4位乘法操作符时,优先级比加号大,将乘号压入栈中。当处理第6位减号时,因为比操作符栈栈顶元素乘号优先级低,所以将乘号和两个操作数出栈进行运算,得到40,压入操作数栈。继续比较,因为减号与加号的操作数优先级相同,又继续将加号和两个操作数出栈得到43,压入操作数栈。
栈的应用-检测括号匹配,利用栈先进后出的特点,对检测到的括号字符入栈出栈,与括号优先和最近的相应括号匹配相吻合。从左到右依次扫描字符串,当扫描到左括号时,将其压入栈中;当扫描到右括号时,从栈顶取出一个左括号。如果能够匹配,比如“(”跟“)”匹配,“[”跟“]”匹配,“{”跟“}”匹配,则继续扫描剩下的字符串。当所有的括号都扫描完成之后,栈为空,则说明字符串为合法格式;否则,说明有未匹配的左括号,为非法格式。
队列
此数据结构类似我们日常生活中的排队,先来先到,队尾插入,在队头弹出。
跟栈一样,用数组实现的队列叫作顺序队列,用链表实现的队列叫作链式队列。基于链表的实现方式,可以实现一个支持无限排队的无界队列(unbounded queue),但是可能会导致过多的请求排队等待,请求处理的响应时间过长。而基于数组实现的有界队列(bounded queue),队列的大小有限,所以线程池中排队的请求超过队列大小时,接下来的请求就会被拒绝,这种方式对响应时间敏感的系统来说,就相对更加合理。但顺序队列需要设置一个合理的大小,太长导致等待的请求太多,太短导致无法充分利用系统资源、发挥最大性能。
顺序队列在最开始的时候,头指针和尾指针都是在第一个元素,随着入队操作,尾指针不断后移。而执行出队操作时,头指针也在后移。这样在尾指针到最后一个元素后,即使数组空间因为出队而空出了之前的一些元素也没办法继续后移,不然就是数组越界,所以入队操作在某些情况下需要做数据迁移,将数组中还有的元素进行整体搬迁。
循环队列,通过将队列首尾相连来省去数据迁移的工作,这样即使尾指针已经到最后一个元素,但还可以继续移动到第一个元素处。循环队列主要在于判断队空与队满两种情况,队空与非循环队列一样,head == tail,头指针和尾指针指向同一个元素代表此时队列为空。而队满是(tail+1)%n == head,tail指针比head小1,如果是临界点(head是0),则是加1后再对队列长度求余。另外因为要和队空条件区分,队满时tail所指向的元素是空的,不能存放元素,如果存放元素,那么会移动一次尾指针,导致尾指针与头指针重合。
实际上,对于大部分资源有限的场景,当没有空闲资源时,基本上都可以通过“队列”这种数据结构来实现请求排队。