摘要:极客时间《数据结构与算法之美》基础篇笔记,数组、链表、栈、队列、递归
数组
概念
数组是(Array)是一种线性表数据结构,它用一组连续的内存空间,来存储一组具有相同类型的数据。数值支持随机访问,根据下表随机访问的时间复杂度为 O(1)
计算机通过地址来访问内存中的数据,数组寻址公式: a[i]_address = base_address + i * data_type_size
,base_address 是内存块的首地址,data_type_size 表示数组中每个元素的大小。根据寻址公式可以看出,如果数组编号从 1 开始,那么访问地址就要变成 a[i]_address = base_address + (i - 1) * data_type_size
,这样的话就多了一步减法操作。而且 Java、JavaScript 都效仿了 C 语言:从 0 开始计数数组下标。
插入与删除
假设数组长度为 n,现在将一个数据插入到数组的第 k 个位置,那么第 k~n 这部分的元素都顺序地往后挪一位。如果是在数组末尾插入,则不需要挪移数据,最好时间复杂度为 O(1)。如果是在数组开头插入,则所有的元素都要向后挪一位,最坏时间复杂度为 O(n)。插入各个位置的概率是一样的,则平均情况时间复杂度为 (1+2+…+n)/n=O(n)。有时为了避免大规模的数据搬移,可以直接将第 k 位的数据搬移到数据的末尾,然后将新元素直接放入第 k 个位置,这样的插入的时间复杂度变为 O(1)
删除元素后,为了保持内存的连续性,也要搬移数据。删除末尾数据时为最好情况时间复杂度 O(1);删除开头数据时为最坏情况复杂度 O(n);平均情况时间复杂度为 O(n)。有时在特殊场景下,我们一定非得追求数组中数据的连续性:可以先记录下已经删除的数据,当数组没有更多空间存储数据时再触发执行一次真正的删除操作,这样就大大减少了数据搬移的工作
链表
概念
与数组不同,链表是通过“指针”将一组零散的内存块串联起来使用,常见的有单链表、双向链表和循环链表。这些内存块被称为“结点”,记录下个结点地址的指针叫做后继指针 next
单链表
单链表的第一个结点叫做头结点,最后一个几点叫做尾结点。其中,头结点用来记录链表的基地址。尾结点的指针指向一个空地址 NULL
链表的插入和删除操作只需要考虑相邻结点的指针改变,所以对应的时间复杂度为 O(1)。但是单链表的查询只能遍历结点,需要 O(n)的时间复杂度
js 实现单链表(js 代码引用自 《学习JavaScript数据结构与算法》第五章)
1 | function LinkedList() { |
1 | class Node { |
双向链表
双向链表的每个结点不知有一个后继指针 next 指向后面的结点,还有一个前驱指针 prev 指向前面的结点。这样使双向链表需要比单链表更多的空间来存储后继结点的地址,同时也带来了更多的灵活性。双向链表可以支持 O(1) 时间复杂度的情况下找到前去结点,而不需要遍历所有结点。
双向链表中的删除:
1、删除结点中“值等于某个给定值”的结点
2、删除给定指针指向的结点
第1种情况,无论单双链表都需要遍历找到对应的结点后再删除。遍历的时间复杂度为 O(n),纯删除操作为 O(1),结合加法法则,删除值等于给定值的结点对应的链表操作的总时间复杂度为 O(n)
第2种情况,已知要删除的结点,那么就知道其前驱结点的指针,不需要遍历获得前驱结点,则时间复杂度为 O(1)
插入:如果在链表的某个指定结点前面插入一个结点,双向链表可以获得节点的前驱结点,时间复杂度为 O(1)
如果双向链表和循环列表整合则成为双向循环链表
js 实现双向链表(js 代码引用自 《学习JavaScript数据结构与算法》第五章)
1 | function DoublyLinkedList() { |
循环链表
循环链表是一种特殊的单链表,其尾结点的指针(tail.next)指向链表的头结点(head)。双向循环列表则是尾结点后继指针(tail.next)指向头结点(head),头结点前驱指针(head.prev)指向尾结点(tail)。当要处理的数据具有环型结构特点时,就特别适合采用循环链表。
数组与链表对比
数组是使用的连续内存空间,如果声明的空间过大,则可能导致“内存不足”,过小则可能会不够用。不够用时会申请一块更大的内存空间,把原数组拷贝进去,十分费时。
链表本身没有大小限制,天然支持动态扩容
基于链表实现 LRU 缓存淘汰算法
思路:维护一个有序单链表,越靠近链表尾部的结点是越早访问的。当有一个新数据被访问时,从头开始顺序遍历链表。
如果链表中已缓存该数据,遍历得到该数据对应的结点,将其从原有位置上删除,然后在添加到链表头部。
如果链表中没有该数据,同时缓存未满,则直接将新数据结点插入到链表头部;如果缓存已满,则将链表尾结点删除后将新数据结点插入链表头部
链表编写技巧
技巧一:理解指针或引用的含义
存储所指对象的内存地址。将某个变量赋值给指针,实际上就是将这个变量的地址赋值给指针。指针中存储了这个变量的内存地址,指向了这个变量,通过指针就能找到这个变量
技巧二:警惕指针丢失和内存泄漏
插入结点时,一定要注意操作的顺序。删除链表结点时,也一定要记得手动释放内存空间,自动管理内存的编程语言除外(例如:java、js)
技巧三:利用哨兵简化实现难度
针对链表的插入、删除操作,需要对插入第一个结点和删除最后一个结点的情况进行特殊处理。使用哨兵解决“便捷问题”,不直接参与业务逻辑。引入哨兵结点后,在任何时候,不管链表是不是空,head 指针都会一直指向这个哨兵结点。有哨兵结点的链表叫“带头链表”,没有的则称为“不带头链表”。示例:在一个元素不重复的数组中,匹配指定元素并返回其所在的位置
1 | // 在数组a中,查找key,返回key所在的位置 |
利用哨兵可以减少执行时间,但是这样可读性太差,一般不需要如此追求极致的性能
技巧四:重点留意边界条件处理
经常用来检查链表代码是否正确的边界条件有4个:
1、如果链表为空时,代码是否能正常工作?
2、如果链表只包含一个结点时,代码是否能正常工作?
3、如果链表只包含两个结点时,代码是否能正常工作?
4、代码逻辑在处理头结点和尾结点的时候,是否能正常工作?
就是链表在结点个数为0、1、2时能否工作,代码在头尾结点能否正确执行
技巧五:举例画图,辅助思考
画在纸上可以更清晰
技巧六:多写多练,没有捷径
练习 5 个常见的链表操作(详细见”单链表”一节代码):
1、单链表反转
2、链表中环的检测
3、两个有序的链表合并
4、删除链表倒数第 n 个结点
5、求链表的中间结点
用空间换时间
用空间换时间的设计思想:当内存空间充足,追求代码执行速度,可以选择空间复杂的相对较高但时间复杂度相对较低的算法或者数据结构。反之,在手机或单片机上则选择用时间换空间的思想
栈
概念
栈:限定仅在表尾进行插入和删除操作的线性表,有先进后出,后进先出的特性(迟到早退),是一种操作受限制的线性表。
站只有两个基本操作:入栈 push()、出栈 pop()
用数组实现的栈叫顺序栈,用链表实现的栈叫链式栈。入栈和出栈的空间复杂度都为 O(1),这里的空间复杂度是指除了原本的数据存储空间外,算法运行还需要额外的存储空间
实现一个栈(js 代码引用自 《学习JavaScript数据结构与算法》第三章)
1 | // java 基于数组实现的顺序栈 |
基于链表实现的栈StackBasedOnLinkedList:
1 | class Node { |
支持动态扩容的顺序栈
当栈不满的时候,入栈操作时间复杂度为 O(1);栈空间不够时,需要重新申请空间并迁移数据,时间复杂度为 O(n)。即入栈最好情况时间复杂度为 O(1),最坏情况时间复杂度为 O(n)。
现在假设条件:
1、栈空间不够时,我们重新申请一个是原来大小两倍的数组
2、仅分析入栈操作,忽略出栈操作
3、不涉及内存搬移的入栈操作为 simple-push 操作,时间复杂度为 O(1)
在这些条件下,一个大小为 K 的空栈,前 K 次入栈的时间复杂度为 O(1)。第 K+1 次入栈需要申请 2K 空间并迁移前 K 个元素和执行 K+1 次入栈操作,其时间复杂度为 O(n)。之后又是 K-1 次 simple-push 入栈操作,时间复杂度为 O(1)。使用摊还分析法,入栈的均摊时间复杂度为 O(1)
栈常见应用
1、函数调用栈。比如调用 A 函数时就将 A 函数压入栈顶,如果 A 函数中调用另一个函数 B,就将 B 函数再压入栈顶,执行完后 B 函数后 B 函数出栈,然后再继续执行 A 函数
2、表达式求值。编译器通过两个栈来实现求值功能:一个栈保存操作的数,另一个保存运算符。从左向右遍历表达式,如果是数字,直接压入操作数栈。如果是运算符,就与运算符栈的栈顶元素进行比较。如果比栈顶元素优先级高,就把当前运算符压入栈顶;如果比栈顶元素优先级低或者相等,从运算符栈中取出一个运算符(一次出栈),从操作数栈的栈顶取出两个数(两次出栈),然后进行运算,再将运算的结果存入操作数栈,继续遍历操作
3、括号匹配。假设现有表达式中有三种括号:圆括号()
、方括号[]
、花括号{}
,三者可以随意嵌套,怎样判断这个表达式中括号都是成对出现合法的呢?用栈来保存未匹配的左括号,从左向右扫描表达式:遇到左括号时,将其压入栈顶,遇到右括号并且能与当前栈顶的左括号匹配,则继续扫描;如果右括号不能与栈顶的左括号匹配,或者栈顶没有数据,说明字符串非法。如果扫描完后栈为空,说明合法;否则,有未匹配的左括号,字符串格式非法
4、浏览器的前进、后退功能。使用两个栈,X 和 Y,把首次访问的页面依次压入 X 的栈顶,点击后退时,再依次从 X 中出栈,再依次放入 Y 栈。当点击前进时,则依次从 Y 中取出,再依次放入 X 栈。当 X 或 Y 没有数据,说明没有前进或后退的页面了SampleBrowser
1 | class SampleBrowser { |
队列
概念
队列:只允许在表的前端进行删除操作,在表的后端进行插入操作,有先进先出的特性,也是一种操作受限制的线性表
队列也只有两种基本操作:入队 enqueue(),放一个数据到队尾,出队 dequeue(),从队头取出一个元素。队列需要两个指针:head 指针指向队头,tail 指针指向队尾
用数组实现的队列叫作顺序队列,用链表实现的队列叫作链式队列
实现一个顺序队列(js 代码引用自 《学习JavaScript数据结构与算法》第四章)
1 | // java |
入队时,如果没有空闲空间了才集中触发一次数据搬移(入队方法2),这样使入队的时间复杂度为 O(1)
链式队列QueueBasedOnLinkedList.js:
1 | class Node { |
循环队列
循环队列可以避免数据搬移,但要确定好队空和队满的条件。数组实现的非循环队列中,tail == n 时队满,head == tail 时队空。循环队列 head == tail 时队空,满足 (tail+1)%n=head 时队满。当队满时 tail 指向的位置没有保存数据,也就是说循环队列会浪费一个数组的存储空间
1 | public class CircularQueue { |
CircularQueueBasedOnLinkedList.js链式循环队列:
1 | class Node { |
阻塞队列
阻塞队列是在队列的基础上增加了阻塞操作:在队列为空的时候,从队列头部获取数据会被阻塞,指导队列中有了数据才能返回;如果队列已满,那么插入操作就会被阻塞,直到队列中有空闲位置后再插入数据,然后再返回。可以使用阻塞队列轻松实现一个“生产者-消费者模型”。当资源有限的情况下,比如线程池,线程池没有空闲线程时,新的任务请求线程资源一般有两种处理方式:第一种是非阻塞的处理方式,直接拒绝任务;另一种是阻塞的处理方式,将请求排队,等到有空闲线程时取出进行处理。基于数组或链表可以实现两种队列,基于链表的队列,可以无线排队。但排队太多会导致响应时间过长,对于对响应时间敏感的系统不适合。基于数组实现的有限队列,队列大小有限,如果队列已满,新的请求会被直接拒绝,这样对响应时间敏感的系统相对合理
并发队列
在多线程的情况下,会有多个线程同时操作队列,这时候就会有线程安全问题。线程安全的队列叫做并发队列。最简单的实现方式是直接在 enqueue()、dequeue() 方法上加锁,同一时刻只允许一个存或者取操作。