面试记录-数据结构

159 阅读16分钟

一、数组

(一)什么是数组

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

1、线性表,分为顺序表和链表,除了数组链表,队列,栈都是线性表结构。

顺序表:存储方式(连续的存储单元),时间复杂度(根据下表O(1)查询,插入和删除是O(n)),空间性能(初始化时,就要确定长度,大了浪费,小了发生上溢,不支持扩容)。

链表:存储方式(采用链式存储,一组任意的存储单元存储数据),时间复杂度(查找O(n),插入和删除O(1)),空间性能(初始化不需要确定长度,按实际需要,支持扩容)。

线性表(数组,队列,链表,栈)。与之相对应的是非线性表,比如二叉树,堆,图等,非线性表中的数据 之间并不是简单的前后关系。

2、连续的内存空间和相同类型的数据,数组的连续性使得数组具有随机访问的特性,但是新增和插入需要大量的搬移工作。

(二)数组是如何实现根据下表访问数组元素的

计算机会给每个内存单元分配一个地址,计算机通过地址来访问内存中的数据。但计算机需要随机访问数组中的某个元素时,他会首先通过寻址公示,获得存储地址。

a[i]_address = base_address + i * data_type_size

其中data_type_size表示数组中每个元素的大小,data_type_size是数组中存储的数据自己字节大小。

数组根据下表访问的时间复杂度为O(1),查找时间复杂度是O(lgn)。

(三)为什么大多数编程语言都是从0开始编号,而不是开始的?

从数组存储的内存模型上看,[下标]最确切的定义应该是[偏移(Offset)]。如果用数组a来表示数组的首地址,a[0]就是偏移为0的位置,也就是首地址,a[k]就是偏移k个type_size

的位置,所以计算a[k]的内存地址只需要用下面这个公式

a[k]_address = base_address + k * type_size

但是,如果从1开始计数,那我们计算数据元素a[k]的内存地址就会变为

a[k]_address = base_address + (k-1)*type_size

对比这两个公式可以发现,从1开始,每次随机访问都多了一次减法运算,对cpu来说,就是多了一次减法指令。数组作为非常基础的数据结构,通过下标随机访问数组元素又是非常基础的,效率优化当然也要极致。

所以为了减少一次减法操作,选择从0开始。

(四)低效率的插入和删除

为了保证数据的连续性,会到导致效率低下。

插入操作

如果在数组的末尾插入元素,就不需要移动数据了,最好的时间复杂度为O(1);如果在数组开头插入元素,那所有元素都要依次往后移动一位,最坏的时间复杂度是O(n)。因为数组中的每个位置插入元素的

概率都是一样的,所以平局情况时间复杂度为O(n)。如果数组中的数据是有序的,在某个位置插入一个新的元素,就必须按照刚才的方法搬移K之后的数据。如果数组中的数据没有任何规律,数组只被当做一个存储

数据的集合,那么在数组第K个位置插入某个数据,为了避免大规模的搬移,可以直接将第K为的数据搬移到数组的最后,把新的元素放在第ke个位置。利用这种技巧,在特定的场景下,在第K位置插入一个元素的

时间复杂度将变为O(1)。

删除操作

和插入类似,如果删除数据末尾的数据,则最好的时间复杂度为O(1);如果删除首地址的数据则时间复杂度为O(n),平均时间复杂度为O(n)。在某些特殊场景下,并一定非得追求数据总数据的连续性。

可以将数据多次删除操作集中在一起执行,删除的效率会提高。

这其实JVM标记-清除算法的核心思想。

首先标记所有需要回收的对象

在标记完成后统一回收所有被标记的对象,其中判断对象是否需要回收用的是可达性分析算法,基本思路是通过一系列的“GC Roots”的对象作为起始点,从这些节点开始向下搜素,搜索走过的路径

就是引用链,当一个GC Roots对象没有任何引用链时,则证明此对象不可用,JVM会将这些对象回收。

在java中可作为GC Roots的对象包括下面几种:

虚拟机栈(栈帧中的本地变量)中的引用的对象

方法区中类静态属性引用的对象

方法区中常量引用的对象

本地房发展中JNI(Native)引用的对象。

(五)数组的访问越界问题

Java语言做了越界检查,会抛出异常,java.lang.ArrayIndexOutOfBoundsException。并终止方法运行。不然会出现死循环。

二、栈

(一)什么是栈

先进后出(FILO),典型的栈结构。

从栈的操作性上看,栈是一种操作受限的线性表,只允许在一端进行插入和删除操作。

(二)如何实现一个栈

栈主要包括,入栈和出栈,也就是在栈顶插入和删除数据。实际上,栈既可以用数组来实现,也可以用链表来实现。数组实现的栈叫做顺序栈;链表实现的栈,叫做链式栈。

数组实现(入栈下标++,出栈--下标)

链表实现(入栈进入链表头,出栈从列表头出)

(三)实现一个特殊的栈,在基本功能的基础上,在实现返回栈中最小元素功能

pop,push,getMin操作的时间复杂度都是O(1)

设计栈类型可以使用现成的栈结构

1、第一种

压入数据规则,假设当前数据为value,先将其压如stackData。然后判断stackMin是否为空。如果为空,则value也压入stackMin。如果不为空,则比较value和stackMin的栈顶元素中哪个更小

如果value<=this.getMin(),则value压入stackMin。反之不做任何操作。

2、第二种

压入数据规则,假设当前数据为 value,先将其压入 stackData。然后判断 stackMin 是否为空:如果为空,则 value 也压入 stackMin。如果不为空,则比较 value 和 stackMin 的栈顶元素

中哪一个更小:如果 value <= this.getMin(),则 value 压入 stackMin;反之,则把 stackMin 栈顶元素重复压入 stackMin,即在栈顶元素上再压入一个栈顶元素。

三、队列

(一)什么是队列

队列是数据结构中比较重要的一种类型,它支持先进先出(FIFO),尾部添加,头部删除。和栈一样也是操作受限的线性表

(二)队列的种类

单队列,单队列就是常见的队列,每次队尾添加,队头删除,存在“假溢出”问题,也就是明明有位置却不能添加。

循环队列(避免“假溢出”)

(三)如何实现一个队列

1、顺序队列

队列需要两个指针,一个head指针,指向对头,一个tail指针,指向队尾。

当tail移动到最右边的时候,即时数组有空闲空间,也无法继续往队列中添加数据。当head=0,tail移动最右边时,数组无可用空间,其他清空,都有。如果出现这种情况,只需要在入队的时候

集中进行一次搬移操作。

2、链式队列

基于链表的实现,我们同样需要连个指针:head和tail。它们分别指向链表的第一个结点和最后一个结点。如图所示:入队时,tail.next = new Node(); tail = tail.next;出队时,head = head.next;。

3、循环队列

如果顺序队列将数据组收尾相连,形成一个环,就构成了循环队列。

四、链表

(一)什么是链表

相对于数组,链表是一种稍微复杂的数据结构,和数组同级。比如ArrayList其实原理就是数组。而LinkedList实现原理就是链表,链表在循环遍历时效率不高,但插入删除优势明显。

(二)几种常见的链表

单链表

双向链表

循环链表

五、散列表

(一)什么是散列表

散列表(Hash table 也叫哈希表)是一种查找算法,与链表,树等算法不同的是,散列算法在查找时不需要进行一系列和键值(关键字是数据元素中某个数据项的值)的比较操作。

散列算法希望能尽量做到不经过任何比较,通过一次存取就能得到所查找的数据元素。因而要在数据元素的存储位置和它的Key之间建立一个确定的对应关系,是每个Key和散列中一个唯一

存储位置相对应。因此在查找时,只要根据这个对应关系找到给定Key在散列中的位置即可。

(二)散列函数

直接定制法:取Key或Key的某个线性函数值为散列地址。即:h(key)=key或h(key)=a*key+b;

数据分析法:

平方取值发:取Key平方后的中间几位散列地址

折叠法:将Key分成位数相同几部分,然后去这几部分的叠加和作为散列地址。

除留余数法:取Key被某个不大于散列表表长M的数p除后所得的余数为散列地址,即h(Key)=key Mod p p<=m

随机数发:选择随机数,取Key的随机函数值为它的散列地址。即h(Key)=random(Key)。

(三)散列冲突

再好的散列函数也无法避免冲突,常用的冲突解决方法有两种,开发寻址和链表法。

1、开放寻址法:核心思想是如果出现了散列冲突,就重新探测一个空闲位置将其插入。如何探测新的位置有线性探测,二次探测和双重探测。

线性探测,如果位置已经被占用,则从当前位置开始,依次往后查找,直到找到空闲位置为止。

二次探测,二次探测和线性探测很像,线性探测每次探测的步长为1,而二次探测的步长就变成了原来的二次发。

双重探测,使用两个散列函数,如果第一个被占用,则用第二个在散列一次。

2、链表法:

链表法是一种更加常用的散列冲突解决方案,他要简单的多。在散列表中,每个[桶]或者[槽]会对应一条链表,所有散列值相同的元素都会放在相同槽位对应链表中。

六、排序二叉树

(一)什么是排序二叉树

首先如果普通二叉树每个节点满足:左子树所有节点小于它的根节点,且有字数所有节点大于它的根节点,则这样的二叉树为排序二叉树。

(二)操作

插入操作:首先要从根节点可爱是往下插座自己要插入的位置;具体流程是:新节点与当前节点比较,如果相同则表示已经存在且不能再重复插入;如果小于当前节点,则在左子树中寻找,如果左子树为空则当前节点为要找的

父节点,新节点插入到当前节点的左子树即可;如果大于当前节点,则到右子树中寻找,如果右子树为空则当前节点为要找的父节点,新节点插入到当前节点的右子树即可。

删除操作:删除操作分三种情况,即要删除的节点无子节点,要删除的节点只有一个子节点,要删除的节点有两个子节点。1.对于要删除的节点无子节点可以直接删除,即让其父节点将孩子节点置空。2.对于要删除的节点有

一个子节点,则替换要删除的节点为其子节点。3.对于要删除的节点有两个子节点,则首先找该节点的替换节点(即右子树中最小节点),接着替换要删除的节点为替换节点,然后删除替换节点。

查询操作:查询操作主要流程为:先和根节点比较,如果相同就返回,如果小于根节点则到左子树中递归查找,如果大于根节点则到右子树中递归查找。因此排序二叉树中可以很容易获取到最大(最优最深子节点)最小

(最左最深子节点)值。

七、红黑树

(一)什么是红黑树

R-B Tree,它是一种特殊的二叉树,平衡二叉树。红黑树的每个节点上都有存储位标识节点颜色,可以是红或黑。

(二)红黑树特性

每个节点或者黑色或者红色。根节点是黑色。每个为空的叶子节点是黑色。如果一个节点是红色的,则它的子节点必须是黑色的。从任一节点到其每个叶子节点的所有路径包含相同数目的黑色节点。从根节点到叶子节点

的最长路径不会超过最短路径的2倍。

左旋:对X进行左旋,意味着,将“x的右孩子”设为“x的父亲节点”;即,将x变成了一个左节点(x成了为z的左孩子)。因此左旋中的“左”意味着“被旋转的节点将变成一个左节点”

右旋:对x进行右旋,意味着,将“x的左孩子”设为“x的父亲节点”;即,将x变成一个右节点。因此右旋中“右”意味着“被旋转的节点将变成一个右节点”。

插入:

1、将红黑树当做一颗二叉查找树,将节点插入。

2、将插入的节点着色为“红色”。

3、通过一系列旋转或着色,使之重新成为红黑树。

删除:

1、将红黑树当做二叉树,将节点删除

2、通过一系列旋转或着色,使之重新成为红黑树。

红黑树的应用,TreeMap,TreeSet以及jdk1.8HashMap底层。

八、B-Tree与B+Tree

(一)B-Tree(文件系统)

B-Tree又叫做平衡多路查找树。一棵m阶的B-Tree特性如下

1.树中每个节点至多有m-1个子节点。

2.除根节点和叶子节点外,其他每个几点至少有ceil(向上取整)(m/2)-1个子节点。

3.若根节点不是叶子节点,则至少有2个子节点。

4.所有叶子节点都出现在同一层。

5.每个节点中的元素key从小到大排列,元素key的左节点的所有元素key值都小于等于元素Key,右节点的所有元素key值都大于等于元素key。

6.插入数据,向兄弟节点借,兄弟节点不够则向父节点借。

(二)B+Tree(Mysql索引)

B+Tree是B-Tree的优化,使其更适合实现外存储索引结构。等同数量的数据存储到B+Tree,树的高度更低,查询效率更高。

1.所有非叶子节点只存储key信息,不存储data(数据信息)

2.所有data都存储在叶子节点上

3.所有叶子节点之间都有一个链指针。

4.所有叶子节点包含了全部元素(key+data)的信息。

(三)一棵m阶的B+Tree与B-Tree的差异

1.同等树高时,B-Tree查询效率比B+Tree高。由于B-Tree节点中存储了Key+data的整个信息,找到Key就等于获取了数据。而B+Tree的data只存储在叶子节点,因此要遍寻到叶子节点才能获取到data信息。

2.同等数量是,B+Tree查询效率比B-Tree高。由于B+Tree的非叶子几点只存储Key信息,而B-Tree的节点存储了Key+data,而每个页(一个节点)的大小是固定的,所以B+Tree的树高会更低一下。数据量越大,B+Tree的层高优势越明显。

3.查找大于或者等于某个key(关键字)的数据时,B+Tree的效率远高于B-Tree,由于B+Tree的叶子节点存储了所有key+data信息,而且叶子节点之间有链指针,所有查询的时候B+Tree更快。

九、LSM树

B+Tree最大的性能问题是会产生大量的磁盘随机IO,大量的磁盘随机IO会严重影响索引建立的速度。

对于那些索引数据大的情况,插入速度是对性能影响的重要指标,而读取相对来说就比较少。譬如在一个无缓存的情况下,B-Tree首先要进行一次磁盘读写将数据读取到内存中,然后进行修改,最后在进行一次IO写会到磁盘中。

为了克服B+Tree的弱点Hbase引入了LSM树。LSM Tree采用读写分离的策略,会优先保真写操作的性能;其数据首先存储内存中,而后需要定期flush到磁盘中。Lsm-Tree通过内存插入与磁盘的顺序写,来达到最优的写性能,因为这会大大

降级磁盘的寻道次数,一次磁盘IO可以写入很多个索引块,Hbase等都是基于LSM-Tree来构建索引的数据库;一般而言LSM-Treed的写更加高效(追加顺序写),B-Tree的读更加高效。L

LSM-Tree的三个组件SSTable(保存在磁盘上的树节点),Memtable(保存在内存中的树节点),Write-Ahead-log(防止内存刷到磁盘数据丢失,在磁盘上维护了一个只追加写的log文件)

十、位图

位图的原理就是用一个bit来标识一个数字是否存在,采用一个bit来存储一个数据,这样可以大大节省空间。bitmap是很常用的数据结构,比如用于bloom filter中;用于无重复整数的排序等等。bitmap通常基于数组来实现,数组中每个元素可以

看成一系列二进制数,所有元素组成更大的二进制集合。

十一、BFS与DFS

BFS(广度优先遍历,Brandth First Search)与DFS(深度优先遍历,Depth First Search)是遍历树或图的两种常用方法。

DFS是以深度,不断去查找是否有下级节点,如果有就继续递归向下查找,否则回到上级,再由未遍历的下级节点进入。

BFS是以广度,从一个节点,查找出它的所有子节点,再依次从所有子节点中向下查找所有子节点。