关于数据结构

关于数组(array)

数组是由有限个相同类型的变量所组成的有序集合,它的物理存储方式是顺序存储,访问方式是随机访问。利用下标查找、更新数组元素的时间复杂度是O(1),中间插入、删除数组元素的时间复杂度是O(n)。

数组是最为简单、最为常用的数据结构。

数组的另一个特点是顺序存储,即数组中的每一个元素,都存储在小小的内存单元中,并且元素之间紧密排列,即不能打乱元素的存储顺序,也不能跳过某个存储单元进行存储。

数组的基本操作       

1.读取元素,根据数组下标进行读取,叫做随机读取。

2.更新元素,利用数组下标就可以把新值赋给该元素。

3.插入元素,即数组的实际元素小于数组的长度时,进行插入,可分为尾部插入、中间插入、超范围插入。尾部插入,直接把插入的元素放在数组的尾部空闲位置即可,等同于更新元素。                                                 中间插入,把要插入位置后面的元素从最后一个开始以此往后面移动一格,空出该位置后进行插入。               超范围插入,即数组的实际元素等于数组长度时,想插入新元素时,要进行扩容。即创建一个新数组,长度是旧数组的两倍,再把旧数组的元素统统复制过去,实现数组的扩容,再进行插入操作。

4.删除元素,删除元素与插入元素过程相反,若被删除的元素位于中间,后面的元素从前开始以此往前面移动一位,实现删除操作。当数组元素没有顺序要求时,可以将最后一个元素复制到所要删除元素处,再将最后一个元素删除即可,这将时间复杂度降为O(1)。

数组适用于读操作多,写操作少的场景。

关于链表(linked list)

链表是一种链式数据结构,由若干节点(node)组成,每个节点包含指向下一节点的指针。链表的物理存储方式是随机存储,访问方式是顺序访问。查找链表节点的时间复杂度是O(n),中间插入、删除、更新节点的时间复杂度是O(1)。

单向链表,每一个节点包含两部分,一部分是存放数据的变量data,另一部分是指向下一个节点的指针next。链表的第一个节点被称为头节点,最后一个节点被称为尾节点,尾节点的next指针指向空。对于单向链表,只能根据节点A找到下一个节点B,一级一级,单向传递。

双向链表,每一个节点除了拥有data和next指针,还拥有指向前置节点的prev指针。

链表的随机存储,即链表的每一个节点分布在内存的不同位置,依靠next指针关联起来,这样可以灵活有效地利用零散的碎片空间。

链表的基本操作

1.查找节点,只能从头节点开始,向后一个一个节点逐一查找。

2.更新节点,如果不考虑查找节点的过程,链表的更新过程会像数组那样简单,直接把旧数据换成新数据。

3.插入节点,可分为尾部插入,头部插入,中间插入。                                                                                                    尾部插入,把最后一个节点的next指针指向新插入的节点即可。                                                                                            头部插入,把新节点的next指针指向原先的头节点,把新节点变为链表的头节点。                                                 中间插入,把新节点的next指针,指向插入位置的节点,把插入位置前置节点的next指针,指向新节点。            只要内存空间允许,能够插入链表的元素是无穷无尽的,不需要像数组那样考虑扩容的问题。

4.删除元素,可分为尾部删除,头部删除,中间删除。                                                                                                 尾部删除,把倒数第二个节点的指针指向空即可。头部删除,把链表的头节点设为原先头节点的next指针即可。中间删除,把要删除节点的前置节点的next指针,指向要删除元素的下一个节点即可。                           许多高级语言,如Java,拥有自动化的垃圾回收机制,所以我们不用刻意去释放被删除的节点,只要没有外部引用指向它们,被删除的节点会被自动回收。

链表适用于读操作少,写操作多的场景。

物理结构和逻辑结构
1.如果把物质层面的人体比作数据存储的物理结构,那么精神层面的人格则是数据存储的逻辑结构。
逻辑结构是抽象的概念,它依赖于物理结构而存在。
2.逻辑结构分为线性结构(如顺序表、栈、队列)和非线性结构(如树、图);
物理结构分为顺序存储结构(如数组)和链式存储结构(如链表)。
栈(stack)
1.一种线性数据结构,像一个圆筒容器,栈中的元素只能先入后出(First In Last Out,简称FILO)。
最早进入的元素存放的位置叫作栈底(bottom),最后进入的元素存放位置叫作栈顶(top).
2.栈这种数据结构既可以用数组实现,也可以用链表实现。
3.栈的基本操作
(1)入栈(push),就是把新元素放入栈中,只允许从栈顶一侧放入元素,新元素的位置将会成为新的栈顶。
(2)出栈(pop),就是把元素从栈中弹出,只有栈顶元素才允许出栈,出栈元素的前一个元素将会成为新的栈顶。
4.入栈和出栈只会影响到最后一个元素,不涉及其他元素的整体移动,所以无论是以数组还是以链表实现,入栈和出栈的时间复杂度都是O(1)。
队列(queue)
1.一种线性数据结构,它的特征和行驶车辆的单行隧道很相似。不同于栈的先入后出,
队列中的元素只能先入先出(First In First Out,简称FIFO)。
队列的出口端叫作队头(front),队列的入口端叫作队尾(rear)。
2.与栈类似,队列这种数据结构既可以用数组实现,也可以用链表实现。
用数组实现时,为了入队操作的方便,把队尾位置规定为最后入队元素的下一个位置。
3.队列的基本操作
(1)入队(enqueue),就是把新元素放入队列中,只允许在队尾的位置放入元素,新元素的下一个位置将会成为新的队尾。
(2)出队(dequeue),就是把元素移出队列,只允许在队头一侧移出元素,出队元素的后一个元素将会成为新的队头。
4.用数组实现的队列可以采用循环队列的方式来维持队列容量的恒定。利用已出队元素留下的空间,让队尾指针重新指回
数组的首位。这样一来,整个队列的元素就“循环”起来了。在物理存储上,队尾的位置也可以在队头之前。当再有元素
入队时,将其放入数组的首位,队尾指针继续后移即可。一直到(队尾下标+1)%数组长度=队头下标时,代表此队列真
的已经满了。需要注意的是,队尾指针指向的位置永远空出1位,所以队列最大容量比数组长度小1。
栈和队列的应用
1.栈的应用
栈的输出顺序和输入顺序相反,所以栈通常用于对“历史”的回溯,也就是逆流而上追溯“历史”。
例如实现递归的逻辑、面包屑导航
2.队列的应用
队列的输出顺序和输入顺序相同,所以队列通常用于对“历史”的回放,也就是按照“历史”顺序,吧“历史”重演一遍。
例如在多线程中,争夺公平锁的等待队列,就是按照访问顺序来决定线程在队列中的次序的。
再如网络爬虫实现网站抓取时,也是把待抓取的网站URL存入队列中,再按照存入队列的顺序来依次抓取和解析的。
3.双端队列(deque)
综合了栈和队列的优点,从队头和队尾都可以进行入队或出队,可以先入先出,也可以先入后出。
4.优先队列
谁的优先级最高,谁先出队。不属于线性数据结构的范畴,它是基于二叉堆来实现的。
散列表(哈希表)(hash table)
1.这种数据结构提供了键(Key)和值(Value)的映射关系。
只要给出一个Key,就可以高效查找到它所匹配的Value,时间复杂度接近于O(1)。
2.哈希函数,通过某种方式,把Key和数组下标进行转换。
在Java及大多数面向对象的语言中,每一个对象都有属于自己的hashcode,这个hashcode
是区分不同对象的重要标识。无论对象自身的类型是什么,它们的hashcode都是一个整型变量。
index=Hashcode(Key)%Array.length
3.散列表的读写操作
(1)写操作(put)
即在散列表中插入新的键值对(在JDK中叫作Entry)。
哈希冲突:由于数组的长度是有限的,当插入的Entry越来越多时,不同的Key通过哈希函数
获得的下标有可能是相同的。
解决哈希冲突的方法:
开放寻址法,当一个Key通过哈希函数获得对应的数组下标已被占用时,我们可以“另谋高就”,寻找下一个空挡位置。
链表法:HashMap数组的每一个元素不仅是一个Entry对象,还是一个链表的头节点。
每一个Entry对象通过next指针指向它的下一个Entry节点。当新来的Entry映射到与之冲突的数组位置时,只需要插入到对应的链表中即可。
(2)读操作(get)
通过给定的Key,在散列表中查找对应的Value。
(3)扩容(resize)
当经过多次元素插入,散列表达到一定饱和度时,Key映射位置发生冲突的概率会逐渐提高,这是需要进行扩容。
对于JDK中的散列表实现类HashMap来说,影响其扩容的因素有两个:
Capacity,即HashMap的当前长度;
LoadFactor,即HashMap的负载因子,默认值为0.75f;
衡量HashMap需要扩容的条件:HashMap.Size>=Capacity*LoadFactor
扩容的步骤:
扩容,创建一个新的Entry空数组,长度是原数组的两倍
重新Hash,遍历原Entry数组,把所有的Entry重新Hash到新数组中。
重新Hash的原因,因为长度扩大以后,Hash的规则也随之改变。

树(tree)
树是n(n>=0)个节点的有限集。当n=0时,成为空树。在任意一个非空树中,有如下特点:
有且仅有一个特定的称为根的节点 
当n>1时,其余节点可分为m(m>0)个互不相交的有限集,每一个集合本身又是一个树,并称为根的子树。

根节点(root):树的最顶端的节点。(根节点只有一个)
叶子节点(leaf):自己下面不再连接有节点的节点(即末端),称为叶子节点(又称为终端结点)。度为0
子树:只要包含了⼀个结点,就得包含这个结点下的所有节点
子结构:可以是原树的任意一个部分
父节点(parent):某节点的上一级节点
孩子节点(child):从某节点衍生出来的节点
兄弟节点(sibling):与某节点同级,由同一个父节点衍生出来的节点
树的高度或深度:指树的最大层级数

二叉树(binary tree)
这种树的每个节点最多有两个孩子节点。两个孩子节点分别称为左孩子(left child)和右孩子(right child)。
这两个孩子节点的顺序是固定的,就像人的左右手,不能够颠倒或混淆。
1.二叉树的两种特殊形式
(1)满二叉树:一个二叉树的所有非叶子结点都存在左右孩子,并且所有叶子节点都在
同一层级上,那么这个树就是满二叉树。即每一个分支都是满的
(2)完全二叉树:对一个有n个节点的二叉树,按层级顺序编号,则所有节点的编号为从1到n。如果这个树所有节点
和同样深度的满二叉树的编号为从1到n的节点位置相同,则这个二叉树为完全二叉树。即最后一个节点之前的节点都齐全即可。
2.二叉树的存储
(1)链式存储结构,
链表是一对一的存储方式,每一个链表节点拥有data变量和一个指向下一个节点的next指针。
而二叉树稍微复杂一些,一个节点最多可以指向左右两个孩子节点,所以二叉树的每一个节点
包含三部分,存储数据的data变量,指向左孩子的left指针,指向右孩子的right指针
(2)数组
使用数组存储时,会按照层级顺序把二叉树的节点放到数组中对应的位置上。
如果某一个节点的左孩子或右孩子空缺,则数组的相应位置也空出来。
这样可以更方便地在数组中定位二叉树的孩子节点和父节点。
如已知父节点下标parent,则左孩子下标就是2*parent+1,右孩子下标就是2*parent+2。
反过来已知左孩子下标leftchild,则父节点下标为(leftchild-1)/2。
数组不适用于稀疏的二叉树,因为会浪费空间;二叉堆用于数组存储。

3.二叉树的应用
二叉树包含许多特殊的形式,每一种形式都有自己的作用,但其最主要的应用还在于
查找操作和维持相对顺序两方面
(1)查找:二叉树的树形结构使它很适合扮演索引的角色
二叉查找树(binary search tree):在二叉树的基础上增加了以下条件
如果左子树不为空,则左子树上所有节点的值均小于根节点的值;
如果右子树不为空,则右子树上所有节点的值均大于根节点的值;
左、右子树也都是二叉查找树
依靠比较大小来逐步查找,与二分查找算法相似
对于一个节点分布相对均衡的二叉查找树来说,如果节点总数是n,那么搜索节点的
时间复杂度就是O(logn),和树的深度是一样的。
(2)维持相对顺序:二叉查找树要求左子树小于父节点,右子树大于父节点,正是
这样保持了二叉树的有序性。所以二叉查找树又叫二叉排序树(binary sort tree)

二叉树的遍历
二叉树的遍历分为四种
1.前序遍历:输出顺序是根节点、左子树、右子树
2.中序遍历:输出顺序是左子树、根节点、右子树
3.后序遍历:输出顺序是左子树、右子树、根节点
4.层序遍历:输出顺序是一层一层横向遍历各个节点
其中,1、2、3属于深度优先遍历,4属于广度优先遍历

二叉堆
本质上是一种完全二叉树,可分为最大堆和最小堆两种类型。
最大堆:任何一个父节点的值,都大于或等于它左、右孩子节点的值。
最小堆:任何一个父节点的值,都小于或等于它左、右孩子节点的值。
二叉堆的根节点叫作堆顶。
最大堆的堆顶是整个堆中的最大元素,最小堆的堆顶是整个堆中的最小元素。

二叉堆的操作
1.插入节点:插入位置是完全二叉树的最后一个位置,经过与其父节点的比较决定
                   其是否“上浮”。
2.删除节点:删除的位置是处于堆顶的节点,将堆的最后一个节点补到原本堆顶的
                   位置,使它与其左右孩子比较,决定其是否“下沉”。
3.构建二叉堆:就是把一个无序的完全二叉树调整为二叉堆,本质就是让所有非叶子
                    节点“下沉”。由下及上的比较。
这几种操作都基于堆的自我调整。所谓堆的自我调整,就是把一个不符合堆性质的
完全二叉树,调整成一个堆。

二叉堆的代码实现
二叉堆虽然是一个完全二叉树,但它的存储方式并不是链式存储,而是顺序存储。
即二叉堆的所有节点都存储在数组中。
在数组中,假设父节点的下标是parent,那么它的左孩子下标就是2*parent+1;
右孩子下标就是2*parent+2。
二叉堆是实现堆排序及优先队列的基础。

优先队列
优先队列不再遵循先入先出的原则,而是分为两种情况:
(1)最大优先队列,无论入队顺序如何,都是当前最大的元素优先出队
(2)最小优先队列,无论入队顺序如何,都是当前最小的元素优先出队

优先队列的实现
利用二叉堆的特性,可以用最大堆来实现最大优先队列,这样的话,每一次入队操作就是
堆的插入操作,每一次出队操作就是删除堆顶节点。
二叉堆节点“上浮”和“下沉”的时间复杂度都是O(logn),所以优先队列入队和出队
的时间复杂度也是O(logn)

第四章  排序算法
根据时间复杂度的不同,主流的排序算法可以分为三大类。

1,时间复杂度为O(n*n)的排序算法

冒泡排序

选择排序

插入排序

希尔排序(它的性能略优于O(n*n),但又比不上O(nlogn),姑且把它归为本类)

2,时间复杂度为O(nlogn)的排序算法

快速排序

归并排序

堆排序

3,时间复杂度为线性的排序算法

计数排序

桶排序

基数排序

 

排序算法还可以根据其稳定性,划分为稳定排序和不稳定排序

即如果值相同的元素在排序后仍然保持着排序前的顺序,则这样的排序算法是稳定排序,否则,则是不稳定排序。

 

冒泡排序

把相邻的元素两两比较,当一个元素大于右侧相邻元素时,交换它们的位置;当一个元素小于或等于右侧相邻元素时,位置不变。

冒泡排序是一种稳定排序。

代码实现:

冒泡排序第一版,使用双循环进行排序,外部循环控制所有的回合,内部循环实现每一轮的冒泡处理,先进行元素比较,再进行元素交换。

冒泡排序第二版,利用布尔变量isSorted作为标记。如果在本轮排序中,元素有交换,则说明数列无序;如果没有元素交换,则说明元素有序,然后直接跳出大循环。

冒泡排序第三版,我们可以在每一轮排序后,记录下来最后一次元素交换的位置,该位置即为无序数列的边界,再往后就是有序区,有序区的元素不用再比较。

鸡尾酒排序,基于冒泡排序的一种升级排序法

鸡尾酒排序的元素比较和交换过程是双向的。

代码实现,外层的大循环控制着所有排序回合,大循环内包含两个小循环,第一个小循环从左向右比较交换元素,第二个小循环从右向左比较并交换元素。

优点,能够在特定的条件下,减少排序的回合数;

缺点,代码量几乎增加了一倍

适用场景,大部分元素已经有序

 

快速排序

与冒泡排序一样,都属于交换排序,通过元素之间的比较和交换位置来达到排序的目的。

不同的是,快速排序则在每一轮挑选一个基准元素,并让其他比它打得元素移动到数列一边,比它小的元素移动到数列的另一边,从而把数列拆解成两部分。

这种思路叫作分治法。

在分治法的思想下,原数列在每一轮都被拆分成两部分,每一部分在下一轮又分别被拆分成两部分,直到不可再分为止。

每一轮的比较和交换,需要把数组全部元素都遍历一遍,时间复杂度是O(n)。这样的遍历一共是多少轮呢?假如元素个数是n,那么平均情况下需要logn 轮,因此快速排序算法的平均时间复杂度是O(nlogn)。

基准元素(pivot)的选择

最简单的方式是选择数列的第一个元素,也可以随机选择一个元素作为基准元素,并且让基准元素和数列首元素交换位置。如果选到数列的最大值或最小值,会影响分治的效果。所以,虽然快速排序的平均时间复杂度是O(nlogn),但最坏情况下的时间复杂度是O(n*n)。

元素的交换

1.双边循环法

首先,选定基准元素pivot,并且设置两个指针left和right,指向数列的最左和最右两个元素。

接下来进行第一次循环,从right指针开始,让指针所指向的元素和基准元素做比较。如果大于或等于pivot,则指针向左移动,如果小于pivot,则指针停止移动,切换到left指针。

轮到left指针行动,让指针所指向的元素何基准元素做比较。如果小于等于pivot,则指针向右移动,如果大于pivot,则left指针停止移动。

2.单边循环法

首先选定基准元素pivot,同时设置一个mark指针指向数列起始位置,这个mark指针代表小于基准元素的区域边界。

接下来,从基准元素的下一个位置开始经历数组。

如果遍历到元素大于基准元素,就继续往后遍历数组。

如果遍历到的元素小于基准元素,则需要做两件事情:第一,把mark指针后移一位,因为小于pivot的区域边界增大了一,第二,让最新遍历到的元素和mark指针所在位置的元素交换位置,因为最新遍历的元素归属小于pivot的区域。

3.非递归实现

非递归方式代码的变动只发生在quickSort方法中,该方法引入了一个存储Map类型元素的栈,用于存储类型元素的栈,用于存储每一次交换时的起始下标和结束下标。

每一次循环,都会让栈顶元素出栈,通过partition方法进行分治,并且按照基准元素的位置分成左右两部分,左右两部分再分别入栈。当栈为空时,说明排序已经完毕,退出循环。

快速排序是很重要的算法,与傅立叶变换等算法并称为二十世纪十大算法。

 

堆排序

二叉堆的节点“下沉”调整(downAdjust 方法)是堆排序算法的基础,这个调节操作本身的时间复杂度是O(logn)。

堆排序算法的步骤

1.把无序数组构建成二叉堆。需要从小到大排序,则构建成最大堆;需要从大到小排序,则构建成最小堆。这一步的时间复杂度是O(n)

2.循环删除堆顶元素,替换到二叉堆的末尾,调整堆产生新的堆顶。需要进行n-1次循环,每次循环调用一次downAdjust方法,所以第二步的计算规模是(n-1)*logn,时间复杂度是O(nlogn)

两个步骤是并列关系,所以整体的时间复杂度是O(nlogn),空间复杂度是O(1),因为没有开辟额外的集合空间。

 

从宏观上看,堆排序与快速排序之间的区别和联系

相同点,平均时间复杂度都是O(nlogn),并且都是不稳定排序。

不同点,快速排序的最坏时间复杂度是O(n*n),而堆排序的最坏时间复杂度稳定在O(nlogn);快速排序递归和非递归方法的平均空间复杂度都是O(logn),而堆排序的空间复杂度是O(1)。

 

基于元素之间的比较来进行排序的算法:冒泡排序,快速排序

利用数组下标来确定元素的正确位置的算法:计数排序

 

计数排序

假设数组中有20个随机整数,取值范围为0~10,要求用最快的速度把这20个整数从小到大进行排序。所以,建立一个长度为11的数组,数组下标从0到10,元素初始值全为0,然后开始遍历这个随机数组,每一个整数按照其值对号入座,同时,对应数组下标的元素进行加一,这样,该数组中每一个下标位置的值代表数列中对应整数出现的次数。显然,现在输出的数列已经是有序的,输出数组元素的下标值,元素的值是几,就输出几次。

这就是计数排序的基本过程,它适用于一定范围内的整数排序。在取值范围不是很大的情况下,它的性能甚至快过那些时间复杂度为O(nlogn)的排序。

如果原始数组的规模是n,最大和最小整数的差值是m,则计数排序的时间复杂度是O(n+m),如果不考虑结果数组,只考虑统计数组大小的话,空间复杂度是O(m)。

计数排序的局限性

1.当数列最大和最小值差距过大时,并不适合用计数排序。

2.当数列元素不是整数时,也不适合用计数排序。

对于这些局限性,另一种线性时间排序算法做出了弥补,这种排序算法叫作桶排序

 

桶排序

同样是一种线性时间的排序算法,类似于计数排序所创建的统计数组,桶排序需要创建若干个桶来协助排序。每一个桶(bucket)代表一个区间范围,里面可以承载一个或多个元素。

工作原理:

1.创建这些桶,并确定每一个桶的区间范围。

具体需要建立多少个桶,如何确定桶的区间范围,有很多种不同的方式。这里创建的桶数量等于原始数列的元素数量,除最后一个桶只包含数列的最大值外,前面各个桶的区间按照比例来确定。

区间跨度=(最大值-最小值)/(桶的数量-1)

2.遍历原始数列,把元素对号入座放入各个桶中。

3.对每个桶内部的元素分别进行排序。

4.遍历所有的桶,输出所有元素。

假设原始数列有n个元素,分成n个桶,那么桶排序的总体时间复杂度和空间复杂度都为O(n)。桶排序的性能并非绝对稳定,如果元素的分布极不平衡,在极端情况下,第一个桶中有n-1个元素,最后一个桶中有1个元素。此时的时间复杂度将退化为O(nlogn),而且还白白创建了许多空桶。

 

 

 

 

 

 

 

 

 

 

 

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值