阿里二面凉了,难蹦。。。-二面八股

时间:2024-04-22 21:45:29

说说聚簇索引和非聚簇索引的区别?

  • 聚簇索引的叶子节点存放的是实际数据,所有完整的用户记录都存放在聚簇索引的叶子节点;

  • 非聚簇索引的叶子节点存放的是主键值,而不是实际数据。

如果某个查询语句使用了二级索引(非聚簇索引),但是查询的数据不是主键值,这时在二级索引找到主键值后,需要去聚簇索引中获得数据行,这个过程就叫作「回表」,也就是说要查两个 B+ 树才能查到数据。

不过,当查询的数据是主键值时,因为只在二级索引(非聚簇索引)就能查询到,不用再去聚簇索引查,这个过程就叫作「索引覆盖」,也就是只需要查一个 B+ 树就能找到数据。

说说B+树和B树的区别

  • 在B+树中,数据都存储在叶子节点上,而非叶子节点只存储索引信息;而B树的非叶子节点既存储索引信息也存储部分数据。

  • B+树的叶子节点使用链表相连,便于范围查询和顺序访问;B树的叶子节点没有链表连接。

  • B+树的查找性能更稳定,每次查找都需要查找到叶子节点;而B树的查找可能会在非叶子节点找到数据,性能相对不稳定。

说说B+树非叶子节点分裂流

InnoDB 创建主键索引默认为聚簇索引,数据被存放在了 B+Tree 的叶子节点上。也就是说,同一个叶子节点内的各个数据是按主键顺序存放的,因此,每当有一条新的数据插入时,数据库会根据主键将其插入到对应的叶子节点中。

如果我们使用自增主键,那么每次插入的新数据就会按顺序添加到当前索引节点的位置,不需要移动已有的数据,当页面写满,就会自动开辟一个新页面。因为每次插入一条新记录,都是追加操作,不需要重新移动数据,因此这种插入数据的方法效率非常高。

如果我们使用非自增主键,由于每次插入主键的索引值都是随机的,因此每次插入新的数据时,就可能会插入到现有数据页中间的某个位置,这将不得不移动其它数据来满足新数据的插入,甚至需要从一个页面复制数据到另外一个页面,我们通常将这种情况称为页分裂页分裂还有可能会造成大量的内存碎片,导致索引结构不紧凑,从而影响查询效率

举个例子,假设某个数据页中的数据是1、3、5、9,且数据页满了,现在准备插入一个数据7,则需要把数据页分割为两个数据页:

图片

出现页分裂时,需要将一个页的记录移动到另外一个页,性能会受到影响,同时页空间的利用率下降,造成存储空间的浪费。

而如果记录是顺序插入的,例如插入数据11,则只需开辟新的数据页,也就不会发生页分裂:

图片

因此,在使用 InnoDB 存储引擎时,如果没有特别的业务需求,建议使用自增字段作为主键。

select * from xxx where a = x  b= x  order by c   怎么建立索引

可以建立(a,b,c)联合索引,这样三个字段都能利用索引,并且 c 字段还能利用索引的有序性,避免了额外排序。

数据库事务隔离级别

  • 读未提交,指一个事务还没提交时,它做的变更就能被其他事务看到;

  • 读提交,指一个事务提交之后,它做的变更才能被其他事务看到;

  • 可重复读,指一个事务执行过程中看到的数据,一直跟这个事务启动时看到的数据是一致的,MySQL InnoDB 引擎的默认隔离级别

  • 串行化;会对记录加上读写锁,在多个事务对这条记录进行读写操作时,如果发生了读写冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行;

按隔离水平高低排序如下:

图片

针对不同的隔离级别,并发事务时可能发生的现象也会不同。

图片

说说幻读

在一个事务内多次查询某个符合查询条件的「记录数量」,如果出现前后两次查询到的记录数量不一样的情况,就意味着发生了「幻读」现象。

假设有 A 和 B 这两个事务同时在处理,事务 A 先开始从数据库查询账户余额大于 100 万的记录,发现共有 5 条,然后事务 B 也按相同的搜索条件也是查询出了 5 条记录。

图片

接下来,事务 A 插入了一条余额超过 100 万的账号,并提交了事务,此时数据库超过 100 万余额的账号个数就变为 6。

然后事务 B 再次查询账户余额大于 100 万的记录,此时查询到的记录数量有 6 条,发现和前一次读到的记录数量不一样了,就感觉发生了幻觉一样,这种现象就被称为幻读。

说说快排流程,时间复杂度

快速排序的流程如下:

  • 从数组中选择一个基准元素(通常是数组中间位置的元素)。

  • 将数组分成两部分,小于基准元素的放在左边,大于基准元素的放在右边。

  • 递归地对左右两部分进行快速排序。

快速排序的时间复杂度为O(n log n),其中n为数组的长度。最坏情况下时间复杂度为O(n^2),发生在每次选择的基准元素都是最大或最小值时。平均情况下时间复杂度为O(n log n),效率较高。

快排为什么时间复杂度最差是O(n^2)

主要是因为在每次划分时选择的基准元素不合适导致的。当每次选择的基准元素都是当前子数组中的最大或最小元素时,就会导致每次划分只能减少一个元素,而不是均匀地分成两部分,从而造成时间复杂度达到O(n^2)。

这种情况通常发生在数组已经有序或基本有序的情况下。为了避免最坏情况发生,可以通过随机选择基准元素或者使用三数取中法等策略来提高快速排序的性能。

快排这么强,那冒泡排序还有必要吗?

冒泡排序在一些特定场景下仍然有其优势,比如:

  • 对于小规模数据或基本有序的数据,冒泡排序可能比快速排序更简单、更直观。

  • 冒泡排序是稳定排序算法,相对于快速排序的不稳定性,在某些情况下可能更适合要求稳定性的场景。

  • 冒泡排序是原地排序算法,不需要额外的空间,适合空间复杂度要求严格的场景。

说说红黑树

红黑树是一种自平衡的二叉查找树,

图片

具有以下特点:

  1. 每个节点要么是红色,要么是黑色。

  2. 根节点是黑色。

  3. 每个叶子节点(NIL节点)是黑色。

  4. 如果一个节点是红色,则其子节点必须是黑色。

  5. 从任一节点到其每个叶子节点的所有路径都包含相同数目的黑色节点。

红黑树的自平衡性质可以保证在进行插入、删除等操作后,树的高度保持在O(log n)内,从而保持了较高的查找、插入和删除效率。

下面是红黑树插入节点的过程,这左旋右旋的操作,就是为了自平衡。

图片

说说红黑树怎么左旋右旋

左旋和右旋是两种基本的旋转操作,用于保持红黑树的平衡。下面简单描述一下左旋和右旋的操作步骤:

左旋操作:

  1. 将当前节点的右子节点作为新的根节点。

  2. 将新根节点的左子节点移动为当前节点的右子节点。

  3. 如果当前节点有父节点,将当前节点的父节点更新为新根节点的父节点;否则,将新根节点设置为树的根节点。

  4. 更新新根节点和其子节点的父节点关系。

接下来看下图片展示,首先断开节点PL与右子节点G的关系,同时将其右子节点的引用指向节点C2;然后断开节点G与左子节点C2的关系,同时将G的左子节点的应用指向节点PL。

图片

接下来再放下 gif 图,希望能帮助大家更好地理解左旋

图片

右旋操作与左旋操作对称,具体步骤如下:

  1. 将当前节点的左子节点作为新的根节点。

  2. 将新根节点的右子节点移动为当前节点的左子节点。

  3. 如果当前节点有父节点,将当前节点的父节点更新为新根节点的父节点;否则,将新根节点设置为树的根节点。

  4. 更新新根节点和其子节点的父节点关系。

首先断开节点G与左子节点PL的关系,同时将其左子节点的引用指向节点C2;然后断开节点PL与右子节点C2的关系,同时将PL的右子节点的应用指向节点G。

图片

右旋的gif展示(图片来自网络):

图片

说说hashmap扩容,说说负载因子

hashmap扩容

HashMap 扩容的目的是为了减少哈希冲突,Java中hashmap扩容过程如下图:

图片

当进行 put 操作导致整个哈希表的负载因子因此到达阈值后,会进入一个阻塞的扩容流程中,先通过复制一个两杯容量的entry数组,然后将原有entry中的元素进行遍历执行rehash,jdk1.8使用的是2次幂的扩展(指长度扩为原来2倍)。

所以,元素的位置要么是在原位置,要么是在原位置再移动2次幂的位置。因此在执行rehash的时候只需要看看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引+oldCap”。

负载因子

HashMap 负载因子 loadFactor 的默认值是 0.75,当 HashMap 中的元素个数超过了容量的 75% 时,就会进行扩容。

默认负载因子为 0.75,是因为它提供了空间和时间复杂度之间的良好平衡。

负载因子太低会导致大量的空桶浪费空间,负载因子太高会导致大量的碰撞,降低性能。0.75 的负载因子在这两个因素之间取得了良好的平衡。

说说扩容之后的退化问题

HashMap在发生扩容时,会重新计算元素的存放位置,可能导致原本分布均匀的元素在新的容量下发生“退化”,即多个元素映射到同一个桶(bucket)上,使得链表长度过长,影响了HashMap的性能。这种情况下,HashMap的查找、插入和删除操作的时间复杂度可能会由原本的O(1)上升到O(n),严重影响了HashMap的效率。

为了解决HashMap扩容后的退化问题,通常采用以下方法:

  1. 提高负载因子(load factor):在发生扩容之前,可以提前扩容,使得哈希表中的元素数量与桶的数量的比值在扩容后不会过高,减少退化的可能性。

  2. 使用红黑树:在JDK 8中,当链表长度过长时,会将链表转换为红黑树,以减少查找时间,提高性能。

说说ArrayList的扩容

ArrayList在添加元素时,如果当前元素个数已经达到了内部数组的容量上限,就会触发扩容操作。ArrayList的扩容操作主要包括以下几个步骤:

  • 计算新的容量:一般情况下,新的容量会扩大为原容量的1.5倍(在JDK 10之后,扩容策略做了调整),然后检查是否超过了最大容量限制。

  • 创建新的数组:根据计算得到的新容量,创建一个新的更大的数组。

  • 将元素复制:将原来数组中的元素逐个复制到新数组中。

  • 更新引用:将ArrayList内部指向原数组的引用指向新数组。

  • 完成扩容:扩容完成后,可以继续添加新元素。

ArrayList的扩容操作涉及到数组的复制和内存的重新分配,所以在频繁添加大量元素时,扩容操作可能会影响性能。为了减少扩容带来的性能损耗,可以在初始化ArrayList时预分配足够大的容量,避免频繁触发扩容操作。

为什么ArrayList扩容是1.5倍

1.5 可以充分利用移位操作,减少浮点数或者运算时间和运算次数。

// 新容量计算
int newCapacity = oldCapacity + (oldCapacity >> 1);