数据结构一般泛指数据的逻辑结构和存储结构,独立于具体编程语言。例如,我们在浏览一个网页时,看到的页面布局结构。拿到一本书时,看到的书的目录结构。打开手机上的地图软件,看到的图结构等等都和我们所说的数据结构有关系。
逻辑结构:数据的逻辑结构描述的是数据元素之间的逻辑关系,与数据的存储无关,是独立于计算机的一种结构。数据的逻辑结构可以看作是从具体问题抽象出来的数学模型。具体可分为线性结构(栈,队列),非线性结构(集合,树形结构,图结构)。
存储结构:数据的存储结构是数据元素以及其关系在计算机存储器内的表示,是逻辑结构用计算机语言的实现。例如顺序存储结构(位置相邻)、链状存储结构(指针关联)。
在编程领域中,可以将程序理解为数据结构+算法。算法就是操作特定结构数据的方法或技巧,具备正确性,可行性,有穷性,输入,输出等特征。例如对数据的查找,排序,运算等都会涉及到具体算法的应用。
主要还是从算法所占用的「时间」和「空间」两个维度去考量。
时间维度:是指执行当前算法所消耗的时间,我们通常用「时间复杂度」来描述。
空间维度:是指执行当前算法需要占用多少内存空间,我们通常用「空间复杂度」来描述。
数组是一个有限的、类型相同的数据的集合,在内存中是一段连续的内存区域,这段内存区域一旦定义,其大小不改变。
数组的长度在定义时确定,数组中的元素的默认值由其数组类型决定。可通过数组下标访问数组中元素,下标的起始位置永远从0开始。
我们可以通过下标随机访问数组中任何一个元素,其原理是因为数组中元素的存储是连续的,所以我们可以通过数组内存空间的首地址加上元素的偏移量计算出某一个元素的内存地址,例如,array[n]的地址 = array数组内存空间的首地址 + 每个元素大小*n。
当我们通过下标去访问数组中的数据时,并不需要从头开始依次遍历数组,因此数组的访问时间复杂度是 O(1),当然这里需要注意,如果不是通过下标去访问,而是通过内容去查找数组中的元素,则时间复杂度不是O(1),极端的情况下需要遍历整个数组的元素,时间复杂度可能是O(n),当然通过不同的查找算法所需的时间复杂度是不一样的。
数组元素的连续性,导致数组在插入和删除元素的时候效率比较低。如果要在数组中间插入一个新元素,就必须要将要相邻的后面的元素全部往后移动一个位置,留出空位给这个新元素
数组插入时,首先要检测数组中是否有足够的空间可以存储新的元素,假如不足还需要对数组进行扩容。一般会重新申请一个相对原数组1.5倍大小的存储空间(因为数组在内存中是一块连续的内存空间,一旦定义其长度不可以再进行修改),并且把原来的数据拷贝到新申请的空间上。假如现在数组空间是足够的,新元素要插入在数组的最开头位置,那整个原始数组都需要向后移动一位,此时的时间复杂度为最坏情况即O(n),如果新元素要插入的位置是最末尾,则无需其它元素移动,则此时时间复杂度为最好情况即O(1),所以平均而言数组插入的时间复杂度是O(n)。
数组的删除与插入类似,假如不是删除最后一个有效元素,其它元素在删除以后,后续元素都要向前移动
数组删除时,如果删除数组末尾的数据,则最好情况时间复杂度为O(1)。如果删除开头的数据,则最坏情况时间复杂度为O(n)。
数组连续的内存空间,可以借助CPU的缓存机制,预读数组中的数据,所以数据也就具备了很好的“随机访问”的性能。但有利就有弊,这个限制也让数组的很多操作变得非常低效。例如,数组中删除、插入一个数据时,为了保证连续性,就需要做大量的数据移动操作。
链表是一种物理存储单元上并不要求连续的存储结构(当然也可以连续的,数组要求必须连续),链表中数据元素之间的逻辑顺序,是通过链表中的指针指向进行实现的(逻辑上相邻但物理上不一定相邻,数组中逻辑和物理上都相邻)
链表结构中,为了表示每个数据元素与其直接后继数据元素之间的逻辑关系,除了要存储其本身的数据之外,还需存储一个指针,用于指向其直接后继元素(其实是直接后继的存储位置),这两部分信息就是我们所说的一个结点元素。多个节点元素通过指针域建立关系并形成链表。
单向链表
最简单的一种链表,每一个节点(Node)除了存储数据之外,只有一个指针(后继指针,也称引用)指向后面一个节点(Node),这个链表称为单向链表
对于”单链表”而言,有两个节点比较特殊,分别是第一个节点和最后一个节点。我们一般习惯于将第一个节点称为“头节点”(head),最后一个节点称为“尾节点”(tail)。“头节点”一般用于记录链表的基地址,通过此地址获取链表中的节点对象。“尾节点”的指针域不指向任何节点,指针域的值为null,表示最后一个节点。
单向循环链表
循环链表就是一种特殊的单向链表,只不过在单向链表的基础上,将尾节点的指针指向了Head节点,使之首尾相连
单向循环链表相对于单向链表的优点是从链尾到链头比较方便。当要处理的数据具有环型结构特点时,就特别适合采用单向循环链表,这样可以很大程度上减少代码量。
双向链表
双向链表与单向链表的区别是前者是2个方向都有指针,后者只有1个方向的指针。双向链表的每一个节点都有2个指针,一个指向前驱节点,一个指向后继节点
双向链表相对于单向链表,需要额外的一个空间来存储前驱结点的地址。所以,如果存储同样多的数据,双向链表要比单链表占用更多的内存空间。但双向链表支持双向遍历,这样也带来了双向链表操作的灵活性。例如,单从结构上来看,双向链表可以支持O(1)时间复杂度的情况下找到前驱结点,正是这样的特点,也使双向链表在某些情况下的插入、删除等操作都要比单向链表更加简单和高效。
双向循环链表
双向循环链表只是在双向链表的基础上,添加了头节点与尾节点的双向引用
双向循环链表可以从任意节点开始向前或向后查找节点。
链表的优势并不在与访问,因为链表无法通过首地址和下标去计算出某一个节点的地址,所以链表中如果要查找某个节点,则需要一个节点一个节点的遍历,因此链表的访问时间复杂度为O(n),但对于双向链表而言,假如已经确定某个节点的位置,再去查找其上个节点的位置,可以直接通过前驱指针直接获取上一个点对象地址即可,这一些要比单向链表有优势。
我们知道在进行数组的插入、删除操作时,为了保持内存数据的连续性,需要做大量的数据移动,所以时间复杂度是O(n)。而在链表中插入或者删除一个数据,我们并不需要为了保持内存的连续性而移动节点,因为链表中节点的存储空间本身就不需要连续。所以,在链表中插入和删除一个节点时是非常快速的。我们只需要修改指针的指向即可。
对于链表而言,无论是执行插入还是删除操作,他们的时间复杂度可以理解为O(1)。但是在插入和删除之前,需要遍历查找的时间复杂度为O(n)。根据时间复杂度分析中的加法法则,插入或删除某个值的结点时,对应的总时间复杂度仍旧为O(n)。
链表本身没有大小的限制,内存无需连续,天然地支持动态扩容,所以插入或删除性能相对较好,尤其是双向链表。但链表的随机访问性能相对较差,因为链表中的数据并非连续存储的,所以无法像数组那样,根据首地址和下标,通过寻址公式就能直接计算出对应的内存地址,而是需要根据指针一个结点一个结点地依次遍历,直到找到相应的结点。
双向链表相对于单向链表最大的优势就是节点的查找速度。对于单链表而言删除某个结点,需要知道其前驱结点b,而单链表并不支持直接获取前驱结点,所以,为了找到前驱结点,我们还是要从头结点开始遍历链表,直到p->next=b,说明p是b的前驱结点。但是对于双向链表来说,这种情况就比较有优势了。因为双向链表中的结点已经保存了前驱结点的指针,不需要像单链表那样遍历。所以,单链表删除操作需要O(n)的时间复杂度,而双向链表只需要在O(1)的时间复杂度内就搞定了
因为单项列表相对于双向列表少存储一个上节点指针,所以相对节省点内存
栈(Stack)是一种先进后出(FILO-First In Last Out),操作上受限的线性表。其限制指的是,仅允许在表的一端进行插入和删除运算。这一端称为栈顶(top),相对地,把另一端称为栈底(bottom)。
应用场景
Java中虚拟机内部方法调用栈。
运算表达式的语法分析,词法分析。
浏览器内置的回退栈(back stack)。
手机中APP的回退栈(back stack)。
栈是一种重要的数据结构,而表达式求值是程序设计语言编译中的一个基本问题,编译系统通过栈对表达式进行语法分析、词法分析,最终获得正确的结果。例如,在使用栈进行表达式计算时,一般要设计两个栈,其中一个用来保存操作数,另一个用来保存运算符。我们从左向右遍历表达式,当遇到数字,我们就直接压入操作数栈;当遇到运算符,就与运算符栈的栈顶元素进行比较,若比运算符栈顶元素优先级高,就将当前运算符压入栈,若比运算符栈顶元素的优先级低或者相同,从运算符栈中取出栈顶运算符,从操作数栈顶取出2个操作数,然后进行计算,把计算完的结果压入操作数栈,继续比较。如图所示:
操作系统给每个线程分配了一块独立的内存空间,这块内存被组织成“栈”这种结构,用来存储函数调用时的临时变量。每进入一个函数,就会将其中的临时变量作为栈帧入栈,当被调用函数执行完成,返回之后,将这个函数对应的栈帧出栈。
在进行括号匹配的语法校验时,可以用栈保存匹配的左括号,从左到右一次扫描字符串,当扫描到左括号时,则将其压入栈中。当扫描到右括号时,从栈顶取出一个左括号,如果两个括号能匹配上,则继续扫描剩下的字符串。如果扫描过程中,遇到不能配对的右括号,或者栈中没有数据,则说明为非法格式。当所有的括号都扫描完成之后,如果栈为空,则说明字符串为合法格式;否则,说明未匹配的左括号为非法格式。
在android手机上我们每打开一个app都会创建一个回退栈,栈中存储每次打开的界面对象,新打开的UI界面会处于栈顶,当我们点击手机上的回退操作时,会移除栈顶元素,将后续元素作为栈顶,然后进行激活。
队列(Queue)这种结构非常好理解。你可以把它想象成超市中排队结账,排在前面的先结账出队,排在后面的后结账出队。后来的人只能站在末尾,不允许插队。
队列(Queue)跟栈(Stack)非常相似,也是操作受限的一种逻辑结构,最基本的操作也是两个:入队(enqueue),放一个数据到队列尾部;出队(dequeue),从队列头部取一个元素。
单端队列:只支持一端入队(enqueue),一端出队(dequeue)。
双端队列:支持队列的两端进行入队和出队操作。
循环队列:可提供更好的性能,降低时间复杂度。
阻塞队列:生产者和消费者应用模型中的一种容器,在队列空或满的时候进行阻塞。
优先级队列:支持按优先级操作的的队列结构(内部对元素进行排序)。
基于数组实现:每次出队的时候,数组的元素整体往左移动,这样队列出队的时间复杂度就为O(N),队列出队操作的时间复杂度高
基于链表实现:可以实现一个支持无限排队的无界队列,但是可能会导致过多的请求排队等待,请求处理的响应时间过长。所以,针对响应时间比较敏感的系统,基于链表实现是不合适的(例如线程池)。
循环队列是让队列形成一种环形结构,它以循环的方式去存储元素,但还是会按照队列的先进先出的原则去操作
基于数组方式实现的简易队列,我们发现一个问题,每次出队都会涉及到数组中元素的移动,时间的复杂度比较高,所以可以使用循环队列优化
例如有这么个操作,我们现在为队列添加两个变量,它们分别为head和tail,其初始值都为下标0,都指向数组中的第一个元素
现在我们向队列添加A,B,C,D四个元素,每添加一个元素tail的下标就向后移动一个位置,当四个元素添加完毕,此时tail移动到下标为4的位置
当从队列中出队一个元素,head的下标也会向后移动一个位置,假设现在出队A,B两个元素,此时head的位置
随着不停地进行入队、出队操作,head和tail都会持续往后移动。当tail移动到最右边,即使数组中还有空闲空间,也无法继续往队列中添加数据了.此时需要移动数据了
通过这样的设计可以适当减少元素移动次数,出队操作的时间复杂度仍然是O(1),但入队操作的时间复杂度不是O(1).那我们还能继续优化吗?此时可以借助”循环队列”,降低时间复杂度,减少队列中元素的移动,充分利用队列空间。
循环队列中随着不断入队操作的执行,tail指向了队尾的后一个位置,也就是新元素将要被插入的位置,如果该位置和head相等了,那么必然说明当前状态已经不能容纳一个元素入队(间接的说明队满)。因为这种情况是和队空(head==tail)的判断条件是一样的,所以我们选择舍弃一个节点位置,tail指向下一个元素的位置,我们使用tail+1判断下一个元素插入之后,是否还能再加入一个元素,如果不能了说明队列满,不能容纳当前元素入队(其实还剩下一个空位置),当然这是牺牲了一个节点位置来实现和判断队空的条件进行区分。
双端队列(Double-ended queue)是一种特殊的队列,简称为Deque。支持队列两端的入队和出队操作。同时具备了栈(Stack)和队列(Queue)特性
双端队列在很多场景都有应用,Java中ForkJoin模式下的工作窃取(允许其它线程从自己的线程队列尾部获取任务、执行任务)
阻塞(Blocking)式队列,顾名思义,首先它是一个队列(Queue),然后在这个队列中加入了阻塞(Blocking)式功能(例如去饭店吃饭,满员了可在等候区排队等待)
阻塞式队列(BlockingQueue)经常应用于生产者和消费者模式,生产者是往队列里添加元素的线程,消费者是从队列里拿元素的线程。当队列中没有数据的情况下,消费端的所有线程都会被自动阻塞(挂起),直到有数据放入队列。当队列中填满数据的情况下,生产端的所有线程都会被自动阻塞(挂起),直到队列中有空的位置,线程被自动唤醒
在Java的JUC包中,提供了很多基于阻塞方式实现的队列,BlockingQueue接口是一种阻塞式队列接口,基于此接口的实现类对象解决了高效、安全“传输”数据的问题。通过这些高效并且线程安全的队列类,为我们快速搭建高质量的多线程程序带来极大的便利
ArrayBlockingQueue 是一个有边界的阻塞队列,它的内部实现是一个数组。它的容量是有限的,我们必须在其初始化的时候指定它的容量大小,容量大小一旦指定,其大小不可改变。其内部的阻塞方式是通过重入锁 ReenterLock 和 Condition 条件队列实现的,但是队列中的锁是没有分离的,即添加操作和移除操作采用的同一个ReenterLock锁,这样就会导致入队和出队操作不能同时进行。
LinkedBlockingQueue采用的是一种基于单链表实现的阻塞式无界队列。此队列在添加一个元素时会创建一个新的Node对象。删除一个元素时要移除一个节点对象。频发的创建和销毁可能对GC操作有较大影响。但是,此队列中的锁(Lock)是分离的,其添加操作采用的是putLock,移除操作采用的则是takeLock,这样能大大提高队列的吞吐量,也意味着在高并发的情况下生产者和消费者可以并行地操作队列中的数据,以此来提高整个队列的并发性能。
ConcurrentLinkedQueue是一个基于单链表实现的、线程安全的、非阻塞式无界队列。此队列的设计也非常考验设计功底,其内部全程使用了cas操作,并且在边界控制方面也引入了哨兵机制。总之,设计复杂程度远远高于直接使用锁(Lock)对象方式的线程安全队列的实现。
散列表又称哈希表(Hash Table),是一种将键(key)映射到值的数据结构,是对数组应用的推广,它基于“散列设计算法”将关键码(Key)映射为数组下标,然后将关键码对应的数据存储在数组中。这个过程类似于字典设计(基于字典关键码找到对应的词条),其中,图中的buckets为桶数组(又称“散列表”-hash table),桶数组中基于桶(bucket)直接存储数据。
散列设计是一种设计思想,它通过一定的算法将key转换为散列表的下标。这种对算法的封装我们称之为“散列函数”,通过散列函数计算得出的值称之为“散列值”(或哈希值)
我们在设计散列算法时,通常要遵循几个基本原则,例如:
散列(Hash)计算得到的散列值应该是一个非负整数;(因为数组的下标从0开始)
如果 key1 = key2,那 hash(key1) == hash(key2);(相同key,得到的散列值也相同)
如果 key1 ≠ key2,那 hash(key1) ≠ hash(key2);(尽量做到不相同)
在进行散列设计时,对于key不同的计算,应尽量保证hash值也不相同,但这样的设计,可能要付出的更多的计算成本,时间成本。所以key不同,hash值相同的这种现象还是会存在的,我们把它称之为散列冲突
解决方案
开放寻址法(open addressing):当出现了散列冲突以后,开放寻址是要重新探测一个新的空闲位置,然后将其插入。那如何重新探测新的位置呢?常用的方法有线性探测(Linear Probing),二次探测(Quadratic probing)和双重散列(Double hashing)等,不管采用哪种探测方法,当散列表中空闲位置不多的时候,散列冲突的概率就会大大提高。
线性探测:每次探测的步长是 1,那它探测的下标序列依次是 hash(key)+0,hash(key)+1,hash(key)+2……。你可能会发现,此方法其实存在很大问题。当散列表中插入的数据越来越多时,散列冲突发生的可能性就会越来越大,空闲位置会越来越少,线性探测的时间就会越来越久。极端情况下, 我们可能需要探测整个散列表,所以最坏情况下的时间复杂度为 O(n)。
二次探测:跟线性探测很像,它每次探测的步长就变成了原来的“2次方”,其探测的下标序列就是 hash(key)+0,hash(key)+1^2 ,hash(key)+2^2 ……。
双重散列:意思就是不仅要使用一个散列函数。可能要使用一组散列函数 hash1(key),hash2(key),hash3(key)…。
优劣势
优势:查询速度快(数据都在数组中),序列化也方便。
劣势:数据量越大冲突的几率就越大,探测时间就会越长。
总之,当数据量比较小、装载因子小的时候,适合采用开放寻址法。
链表法(chaining):当出现了散列冲突以后,链表法相比开放寻址法,它要简单很多。也是一种更加常用的散列冲突解决办法。 在散列表中,每个“桶(bucket)”或者“槽(slot)”会对应一张链表,所有散列值相同的元素,我们都放到相同槽位对应的链表中,我们在散列表中进行数据插入的时,通过散列函数计算出对应的散列槽位,将其插入到对应链表中即可,所以插入的时间复杂度是 O(1)。当删除一个元素时,我们同样通过散列函数计算出对应的槽位, 然后遍历链表找到对应元素进行删除即可。其时间复杂度可能会大一些,例如O(K)。
优劣势
优势,内存利用率高,解决冲突的时间更快。
缺陷,桶中节点元素内存地址不连续,导致查询性能可能会降低。
总之,基于链表的散列冲突处理方法比较适合存储大对象(此时可忽略指针占用空间)、大数据量的散列表。而且,比起开放寻址法,它更加灵活,支持更多的优化策略,比如用红黑树代替链表。
初始大小设计:默认的初始大小是 16,当然这个默认值是可以设置的,如果事先知道大概的数据量有多大,可以通过修改默认初始大小,减少动态扩容(2的n次方)的次数,这样会大大提高 HashMap 的性能。数组长度保持2的次幂,length-1的低位都为1,会使得获得的数组索引index更加均匀,减少hash冲突。保证得到的新的数组索引和老数组索引一致(大大减少了之前已经散列良好的老数组的数据位置重新调换)
装载因子和动态扩容设计:最大装载因子默认是 0.75,当 HashMap 中元素个数超过 0.75*capacity(capacity表示散列表的容 量)的时候,就会启动扩容,每次扩容都会扩容为原来的两倍大小。
散列冲突及解决方案设计:HashMap 底层采用链表法来解决冲突。即使负载因子和散列函数设计得再合理,也免不了会出现 链表过长的情况,一旦出现链表过长,则会严重影响 HashMap 的性能。 于是,在 JDK1.8 版本中,为了对 HashMap 做进一步优化,官方引入了红黑树。而当链表长度太 长(默认超过 8)时,链表就转换为红黑树。我们可以利用红黑树快速增删改查的特点,提高 HashMap 的性能。当红黑树结点个数小于或等于6的时候,又会将红黑树转化为链表。因为在数据量 较小的情况下,红黑树要维护平衡,比起链表来,性能上的优势并不明显。
线程(thread)安全设计:HashMap本身并不是线程安全的对象,所以仅可以应用在线程安全的环境。在线程不安全的环境推荐使用ConcurrentHashMap,此map在JDK8中采用了CAS算法保证对map的操作是线程安全的;
1.7中采用数组+链表,1.8采用的是数组+链表/红黑树,即在1.8中链表长度超过一定长度后就改成红黑树存储。
1.7 的底层节点为Entry,1.8 为node ,但是本质一样,都是Map.Entry 的实现
1.7扩容时需要重新计算哈希值和索引位置,1.8并不重新计算哈希值,巧妙地采用和扩容后容量进行&操作来计算新的索引位置。
1.7是采用表头插入法插入链表,1.8采用的是尾部插入法。
ConcurrentHashmap(1.8)这个并发集合是线程安全的HashMap,在jdk1.7中是采用Segment + HashEntry + ReentrantLock的方式进行实现的,而1.8中放弃了Segment臃肿的设计,取而代之的是采用Node + CAS + Synchronized来保证并发安全进行实现。
JDK1.8的实现降低锁的粒度,JDK1.7版本锁的粒度是基于Segment的,包含多个HashEntry,而JDK1.8锁的粒度就是HashEntry(首节点)
JDK1.8版本的数据结构变得更加简单,使得操作也更加清晰流畅,因为已经使用synchronized来进行同步,所以不需要分段锁的概念,也就不需要Segment这种数据结构了,由于粒度的降低,实现的复杂度也增加了
JDK1.8使用红黑树来优化链表,基于长度很长的链表的遍历是一个很漫长的过程,而红黑树的遍历效率是很快的,代替一定阈值的链表,这样形成一个最佳拍档。
树是一种非线性的数据结构,它是由n(n>=0)个有限节点组成一个具有层次关系的集合。把它叫做树是因为它看起来像一棵倒挂的树,也就是说它是根朝上,而叶朝下的。它具有以下的特点:
每个节点有零个或多个子节点;
没有父节点的节点称为根节点;
每一个非根节点有且只有一个父节点;
除了根节点外,每个子节点可以分为多个不相交的子树
二叉树的每个节点最多有两个子节点
若它的左子树不为空,那么左子树上面的所有节点的值均小于它的根节点的值
若它的右子树不为空,那么右子树上面的所有的节点的值均大于它的根节点的值
它的左右的树叶分别为二叉排序树
AVL 树是平衡二叉查找树,增加和删除节点后通过树形旋转重新达到平衡。右旋是以某个节点为中心,将它沉入当前右子节点的位置,而让当前的左子节点作为新树的根节点,也称为顺时针旋转。同理左旋是以某个节点为中心,将它沉入当前左子节点的位置,而让当前的右子节点作为新树的根节点,也称为逆时针旋转。
AVL 树的时间复杂度是 O(logn),AVL 的左右树的高度差也叫平衡因子(平衡因子就是从某个节点开始,他的左右子树的节点数差),即平衡因子不大于 1。
AVL 树在插入数据的时候会不断地调整,因为高度相差不大于 1 真的太严格了。那这样在频繁插入的时候必然需要一直调整树的结构,让其保持平衡。
2-3树 是平衡树
2 叉节点,有两个分树,节点中有一个元素,左树元素更小,右树元素节点更大
3 叉节点,有三个子树,节点中有两个元素,左树元素更小,右树元素更大,中间树介于两个父元素之间
主要特征是在每个节点上增加一个属性表示节点颜色,可以红色或黑色。红黑树和 AVL 树 类似,都是在进行插入和删除时通过旋转保持自身平衡,从而获得较高的查找性能。与 AVL 树 相比,红黑树不追求所有递归子树的高度差不超过 1,保证从根节点到叶尾的最长路径不超过最短路径的 2 倍,所以最差时间复杂度是 O(logn)。红黑树通过重新着色和左右旋转,更加高效地完成了插入和删除之后的自平衡调整。
红黑树在本质上还是二叉查找树,它额外引入了几个约束条件:
根节点是【黑色】
每个节点要么是【黑色】要么是【红色】
每个【红色】节点的两个子节点一定都是【黑色】
每个叶子节点(NIL)都是【黑色】
任意一个节点的路径到叶子节点所包含的【黑色】节点的数量是相同的---这个也称之为【黑色完美平衡】
新插入的节点必须是【红色】->为什么?如果新插入的节点是【黑色】,那不管是在插入到那里,一定会破坏黑色完美平衡的,因为任意一个节点的路径到叶子节点的黑色节点的数量肯定不一样了(第 6 点我自己加的,实际特性的定义是前 5 个)
维持平衡
左旋:以某个节点作为固定支撑点(围绕该节点旋转),其右子节点变为旋转节点的父节点,右子节点的左子节点变为旋转节点的右子节点,左子节点保持不变
右旋:以某个节点作为固定支撑点(围绕该节点旋转),其左子节点变为旋转节点的父节点,左子节点的右子节点变为旋转节点的左子节点,右子节点保持不变
变色:节点的颜色由红色变成黑色,或者是由黑色变成红色。
当约束条件不满足的时候就通过左旋右旋来重新调整
红黑树的平衡性不如 AVL 树,它维持的只是一种大致的平衡,不严格保证左右子树的高度差不超过 1。这导致节点数相同的情况下,红黑树的高度可能更高,也就是说平均查找次数会高于相同情况的 AVL 树。在插入时,红黑树和 AVL 树都能在至多两次旋转内恢复平衡,在删除时由于红黑树只追求大致平衡,因此红黑树至多三次旋转可以恢复平衡,而 AVL 树最多需要 O(logn) 次。AVL 树在插入和删除时,将向上回溯确定是否需要旋转,这个回溯的时间成本最差为 O(logn),而红黑树每次向上回溯的步长为 2,回溯成本低。因此面对频繁地插入与删除红黑树更加合适。
B 树中每个节点同时存储 key 和 data,而 B+ 树中只有叶子节点才存储 data,非叶子节点只存储 key。InnoDB 对 B+ 树进行了优化,在每个叶子节点上增加了一个指向相邻叶子节点的链表指针,形成了带有顺序指针的 B+ 树,提高区间访问的性能。B+ 树的优点在于:
由于 B+ 树在非叶子节点上不含数据信息,因此在内存页中能够存放更多的 key,数据存放得更加紧密,具有更好的空间利用率,访问叶子节点上关联的数据也具有更好的缓存命中率。
B+树的叶子结点都是相连的,因此对整棵树的遍历只需要一次线性遍历叶子节点即可。而 B 树则需要进行每一层的递归遍历,相邻的元素可能在内存中不相邻,所以缓存命中性没有 B+树好。但是 B 树也有优点,由于每个节点都包含 key 和 value,因此经常访问的元素可能离根节点更近,访问也更迅速。
内部排序:在内存中进行的称为内部排序
比较排序
插入排序
直接插入排序
希尔排序
选择排序
直接选择排序
堆排序
交换归并排序
冒泡排序
快速排序
非比较排序
计数排序
基数排序
桶排序
外部排序:当数据量很大时无法全部拷贝到内存需要使用外存,称为外部排序
每一趟将一个待排序记录按其关键字的大小插入到已排好序的一组记录的适当位置上,直到所有待排序记录全部插入为止。它是稳定排序,平均/最差时间复杂度 O(n²),元素基本有序时最好时间复杂度 O(n),空间复杂度 O(1)。
把记录按下标的一定增量分组,对每组进行直接插入排序,每次排序后减小增量,当增量减至 1 时排序完毕,又称缩小增量排序,是对直接插入排序的改进,不稳定,平均时间复杂度 O(n^1.3^),最差时间复杂度 O(n²),最好时间复杂度 O(n),空间复杂度 O(1)。
每次在未排序序列中找到最小元素,和未排序序列的第一个元素交换位置,再在剩余未排序序列中重复该操作直到所有元素排序完毕。不稳定,时间复杂度 O(n²),空间复杂度 O(1)。
是对直接选择排序的改进,不稳定,时间复杂度 O(nlogn),空间复杂度 O(1)。将待排序记录看作完全二叉树,可以建立大根堆或小根堆,大根堆中每个节点的值都不小于它的子节点值,小根堆中每个节点的值都不大于它的子节点值。 以大根堆为例,在建堆时首先将最后一个节点作为当前节点,如果当前节点存在父节点且值大于父节点,就将当前节点和父节点交换。在移除时首先暂存根节点的值,然后用最后一个节点代替根节点并作为当前节点,如果当前节点存在子节点且值小于子节点,就将其与值较大的子节点进行交换,调整完堆后返回暂存的值。
稳定,平均/最坏时间复杂度 O(n²),元素基本有序时最好时间复杂度 O(n),空间复杂度 O(1)。比较相邻的元素,如果第一个比第二个大就进行交换,对每一对相邻元素做同样的工作,从开始第一对到结尾的最后一对, 每一轮排序后末尾元素都是有序的,针对 n 个元素重复以上步骤 n -1 次排序完毕。
是对冒泡排序的一种改进,不稳定,平均/最好时间复杂度 O(nlogn),元素基本有序时最坏时间复杂度 O(n²),空间复杂度 O(logn)。
首先选择一个基准元素,通过一趟排序将要排序的数据分割成独立的两部分,一部分全部小于等于基准元素,一部分全部大于等于基准元素,再按此方法递归对这两部分数据进行快速排序。
快速排序的一次划分从两头交替搜索,直到 low 和 high 指针重合,一趟时间复杂度 O(n),整个算法的时间复杂度与划分趟数有关。
最好情况是每次划分选择的中间数恰好将当前序列等分,经过 log(n) 趟划分便可得到长度为 1 的子表,这样时间复杂度 O(nlogn)。
最坏情况是每次所选中间数是当前序列中的最大或最小元素,这使每次划分所得子表其中一个为空表 ,这样长度为 n 的数据表需要 n 趟划分,整个排序时间复杂度 O(n²)。
归并排序基于归并操作,是一种稳定的排序算法,任何情况时间复杂度都为 O(nlogn),空间复杂度为 O(n)。
基本原理:应用分治法将待排序序列分成两部分,然后对两部分分别递归排序,最后进行合并,使用一个辅助空间并设定两个指针分别指向两个有序序列的起始元素,将指针对应的较小元素添加到辅助空间,重复该步骤到某一序列到达末尾,然后将另一序列剩余元素合并到辅助空间末尾。
适用场景:数据量大且对稳定性有要求的情况。
数据量规模较小,考虑直接插入或直接选择。当元素分布有序时直接插入将大大减少比较和移动记录的次数,如果不要求稳定性,可以使用直接选择,效率略高于直接插入。
数据量规模中等,选择希尔排序。
数据量规模较大,考虑堆排序(元素分布接近正序或逆序)、快速排序(元素分布随机)和归并排序(稳定性)。
一般不使用冒泡。