Java面试
1-Java基础
基础篇要点:算法、数据结构、基础设计模式
1-1.二分查找
要求
- 能够用自己语言描述二分查找算法
- 能够手写二分查找代码
- 能够解答一些变化后的考法
算法描述
- 前提:有已排序数组 A(假设已经做好)
- 定义左边界 L、右边界 R,确定搜索范围,循环执行二分查找(3、4两步)
- 获取中间索引 M = Floor((L+R) /2)
- 中间索引的值 A[M] 与待搜索的值 T 进行比较
① A[M] == T 表示找到,返回中间索引
② A[M] > T,中间值右侧的其它元素都大于 T,无需比较,中间索引左边去找,M - 1 设置为右边界,重新查找
③ A[M] < T,中间值左侧的其它元素都小于 T,无需比较,中间索引右边去找, M + 1 设置为左边界,重新查找 - 当 L > R 时,表示没有找到,应结束循环
算法实现
/** * @author bobochang * @description 二分查找代码 * @created 2022/9/20-00:11 **/ public class BinarySearch { public static void main(String[] args) { // 1.定义有序的int类型数组 int[] array = {24, 33, 45, 56, 58, 62, 74, 89, 102}; // 2.定义需要查找的对象 int target = 74; // 3.调用二分查找方法函数 获得对象在数组中索引值 int index = binarySearch(array, target); // 4.输出结果 System.out.println("index = " + index); } private static int binarySearch(int[] array, int target) { // 1.定义左右边界和中间值 int left = 0, right = array.length - 1, m = 0; // 2.循环执行二分查找 while (left <= right) { // 避免int整数相加溢出 更换减法 left/2 + right/2 ==> // left + ( -left/2 + right/2 ) ==> left + ( right - left ) / 2 // m = (left + right) / 2; // m = left + (right - left) / 2; // 采用位运算右移1位则代表除以2 m = (left + right) >>> 1; // 3.判断查找对象和遍历的当前值 if (array[m] == target) { return m; } else if (array[m] > target) { right = m - 1; } else { left = m + 1; } } return -1; } }
选择题考法
- 有一个有序表为 1,5,8,11,19,22,31,35,40,45,48,49,50 当二分查找值为 48 的结点时,需要经过( 4 )次比较
- 使用二分法在序列 1,4,6,7,15,33,39,50,64,78,75,81,89,96 中查找元素 81 时,需要经过( 4 )次比较
对于前两个题目,记得一个简要判断口诀:奇数二分取中间,偶数二分取中间靠左。
- 在拥有128个元素的数组中二分查找一个数,需要比较的次数最多不超过多少次
1-2.冒泡排序
要求
- 能够用自己语言描述冒泡排序算法
- 能够手写冒泡排序代码
- 了解一些冒泡排序的优化手段
算法描述
- 依次比较数组中相邻两个元素大小,若 a[j] > a[j+1],则交换两个元素,两两都比较一遍称为一轮冒泡,结果是让最大的元素排至最后
- 重复以上步骤,直到整个数组有序
算法实现
/** * 通过判断当前是否发生变化从而优化算法 * * @param array */ private static void bubble(int[] array) { for (int i = 0; i < array.length - 1; i++) { boolean swapped = false; for (int j = 0; j < array.length - 1 - i; j++) { if (array[j] > array[j + 1]) { swap(array, j, j + 1); swapped = true; } } System.out.println("第" + (i + 1) + "轮数组 = " + Arrays.toString(array)); if (!swapped) { break; } } }
- 优化点1:每经过一轮冒泡,内层循环就可以减少一次
- 优化点2:如果某一轮冒泡没有发生交换,则表示所有数据有序,可以结束外层循环
优化算法实现
/** * 通过判断最后一次交换值是否发送改变 * 从而判断最后一次被交换的索引至最后一位已有序 进而优化算法 * * @param array */ private static void bubble(int[] array) { int swapIndex = array.length - 1; do { // 1.进入循环至0 int last = 0; for (int i = 0; i < swapIndex; i++) { if (array[i] > array[i + 1]) { swap(array, i, i + 1); // 2.记录最后一次交换值的索引 last = i; } } // 3.将最后一次交换的索引值赋值 作为下一次排序遍历的长度 swapIndex = last; // 4.判断当前是否已经交换完 形成有序数组 System.out.println("Arrays.toString(array) = " + Arrays.toString(array)); } while (swapIndex != 0); }
每轮冒泡时,最后一次交换索引可以作为下一轮冒泡的比较次数,如果这个值为零,表示整个数组有序,直接退出外层循环即可
1-3.选择排序
要求
- 能够用自己语言描述选择排序算法
- 能够比较选择排序与冒泡排序
- 理解非稳定排序与稳定排序
算法描述
- 将数组分为两个子集,排序的和未排序的,每一轮从未排序的子集中选出最小的元素,放入排序子集
- 重复以上步骤,直到整个数组有序
算法实现
/** * 选择排序-算法实现 * * @param array */ private static void selection(int[] array) { for (int i = 0; i < array.length - 1; i++) { // i代表每轮排序最小值需要交换到的具体索引 // selectIndex代表每轮已被选择的值当前在数据中的具体索引 int selectIndex = i; for (int j = selectIndex + 1; j < array.length; j++) { // 遍历(选择)剩余未被排序的其他数组 if (array[selectIndex] > array[j]) { // 记录当前该轮出现最小值 selectIndex = j; // 当争论执行完再进行交换 减少交换次数 //swap(array, i, j); } } if (selectIndex != i) { swap(array, selectIndex, i); } System.out.println("Arrays.toString(array) = " + Arrays.toString(array)); } }
与冒泡排序比较
- 二者平均时间复杂度都是 O(n2)O(n^2)O(n2)
- 选择排序一般要快于冒泡,因为其交换次数少
- 但如果集合有序度高,冒泡优于选择
- 冒泡属于稳定排序算法,而选择属于不稳定排序
- 稳定排序指,按对象中不同字段进行多次排序,不会打乱同值元素的顺序
- 不稳定排序则反之
1-4.插入排序
要求
- 能够用自己语言描述插入排序算法
- 能够比较插入排序与选择排序
算法描述
- 将数组分为两个区域,排序区域和未排序区域,每一轮从未排序区域中取出第一个元素,插入到排序区域(需保证顺序)
- 重复以上步骤,直到整个数组有序
算法实现
/** * 插入排序-算法实现 * @param array */ private static void insertSort(int[] array) { // 当前i表示需要进行插入的元素的索引值 for (int i = 1; i < array.length; i++) { // 通过临时变量存储需要向前插入元素的值 int temp = array[i]; // 通过i值找到与之前一位的索引值 int front = i - 1; while (front >= 0) { if (temp < array[front]) { // 若需要插入元素值小于前一位元素值 较大值后移 array[front + 1] = array[front]; } else { // 已找到需要插入的位置索引值 退出循环 break; } front--; } // 向比较索引值的后一位进行插入操作 array[front + 1] = temp; System.out.println("Arrays.toString(array) = " + Arrays.toString(array)); } }
与选择排序比较
- 二者平均时间复杂度都是 O(n2)O(n^2)O(n2)
- 大部分情况下,插入都略优于选择(选择略优于冒泡)
- 有序集合插入的时间复杂度为 O(n)O(n)O(n)
- 插入属于稳定排序算法,而选择属于不稳定排序
警示
插入排序易被轻视,其实它的地位非常重要。小数据量排序,都会优先选择插入排序
1-5.希尔排序
要求
- 能够用自己语言描述希尔排序算法
算法描述
- 首先选取一个间隙序列,如 (n/2,n/4 … 1),n 为数组长度
- 每一轮将间隙相等的元素视为一组,对组内元素进行插入排序,目的有二
① 少量元素插入排序速度很快
② 让组内值较大的元素更快地移动到后方 - 当间隙逐渐减少,直至为 1 时,即可完成排序
算法实现
private static void shell(int[] a) { int n = a.length; for (int gap = n / 2; gap > 0; gap /= 2) { // i 代表待插入元素的索引 for (int i = gap; i < n; i++) { int t = a[i]; // 代表待插入的元素值 int j = i; while (j >= gap) { // 每次与上一个间隙为 gap 的元素进行插入排序 if (t < a[j - gap]) { // j-gap 是上一个元素索引,如果 > t,后移 a[j] = a[j - gap]; j -= gap; } else { // 如果 j-1 已经 <= t, 则 j 就是插入位置 break; } } a[j] = t; System.out.println(Arrays.toString(a) + " gap:" + gap); } } }
1-6.快速排序
要求
- 能够用自己语言描述快速排序算法
- 掌握手写单边循环、双边循环代码之一
- 能够说明快排特点
- 了解洛穆托与霍尔两种分区方案的性能比较
算法描述
- 每一轮排序选择一个基准点(pivot)进行分区
- 让小于基准点的元素的进入一个分区,大于基准点的元素的进入另一个分区
- 当分区完成时,基准点元素的位置就是其最终位置
- 在子分区内重复以上过程,直至子分区元素个数少于等于 1,这体现的是分而治之的思想 (divide-and-conquer)
- 从以上描述可以看出,一个关键在于分区算法,常见的有洛穆托分区方案、双边循环分区方案、霍尔分区方案
单边循环快排(lomuto 洛穆托分区方案)
- 选择最右元素作为基准点元素
- j 指针负责找到比基准点小的元素,一旦找到则与 i 进行交换
- i 指针维护小于基准点元素的边界,也是每次交换的目标索引
- 最后基准点与 i 交换,i 即为分区位置
算法实现
/** * @author bobochang * @description 快速排序-单边循环 * @created 2022/9/20-14:23 **/ public class QuickSortPartition { public static void main(String[] args) { int[] array = {5, 3, 7, 2, 9, 8, 1, 4}; quick(array, 0, array.length - 1); } public static void quick(int[] array, int l, int h) { // 判断当前是否已经排序完成 if (l >= h) { return; } // 找出基准点的索引值 放入partition变量中 int partition = partition(array, l, h); // 左边界进行排序 quick(array, l, partition - 1); // 右边界进行排序 quick(array, partition + 1, h); } private static int partition(int[] array, int l, int h) { // 1.定义基准点(当前为默认最右侧元素为基准点) int pv = array[h]; // 2.定义初始指针 i j int i = l; // 3.循环对比查找比基准点小的值并进行交换 将其置于基准点左侧 for (int j = l; j < h; j++) { if (array[j] < pv) { // 比基准点小 将维护左边界的索引与值进行交换 // 当i和j不相等时候才进行交换 避免性能消耗浪费 if (j != i) { swap(array, i, j); } i++; } } // 基准点位置与j进行交换 // 当i和h不相等时候才进行交换 避免性能消耗浪费 if (i != h) { swap(array, i, h); } System.out.println("Arrays.toString(array) = " + Arrays.toString(array)); return i; } /** * 交换数组中指定的两个值 * * @param array * @param first * @param second */ private static void swap(int[] array, int first, int second) { int temp = array[first]; array[first] = array[second]; array[second] = temp; } }
双边循环快排(不完全等价于 hoare 霍尔分区方案)
- 选择最左元素作为基准点元素
- j 指针负责从右向左找比基准点小的元素,i 指针负责从左向右找比基准点大的元素,一旦找到二者交换,直至 i,j 相交
- 最后基准点与 i(此时 i 与 j 相等)交换,i 即为分区位置
要点
- 基准点在左边,并且要先 j 后 i(先进行右指针查找 再进行左指针的查找)
因为基准点元素在最左边, 而左边分区都是小于等于基准点元素
如果是i先动的话, 最后i=j停的位置一定是比基准点元素大的地方, 此时发生交换, 必然乱序
- while( i< j && a[j] > pv ) j--
- while ( i< j && a[i] <= pv ) i++
算法实现
/** * @author bobochang * @description 快速排序-双边循环 * @created 2022/9/20-14:23 **/ public class QuickSortPartition { public static void main(String[] args) { int[] array = {5, 3, 7, 2, 9, 8, 1, 4}; quick(array, 0, array.length - 1); } public static void quick(int[] array, int l, int h) { // 判断当前是否已经排序完成 if (l >= h) { return; } // 找出基准点的索引值 放入partition变量中 int partition = partition(array, l, h); // 左边界进行排序 quick(array, l, partition - 1); // 右边界进行排序 quick(array, partition + 1, h); } private static int partition(int[] array, int l, int h) { // 以最左侧的元素值作为基准值 int pv = array[l]; // 定义左边界起始指针i 右边界其实指针j int i = l; int j = h; // 循环遍历数组 当两个指针重合时停下 并将基准值与重合元素值进行交换 while (i < j) { // 右边界指针由右往左选择比基准值小的元素 while (i < j && array[j] > pv) { j--; } // 左边界指针由左往右选择比基准值大的元素 while (i < j && array[i] <= pv) { i++; } // 将左右边界找到的值进行交换 swap(array, i, j); } swap(array, l, i); System.out.println("Arrays.toString(array) = " + Arrays.toString(array)); return i; } /** * 交换数组中指定的两个值 * * @param array * @param first * @param second */ private static void swap(int[] array, int first, int second) { int temp = array[first]; array[first] = array[second]; array[second] = temp; } }
快排特点
- 平均时间复杂度是 O(nlog2n)O(nlog_2n )O(nlog2n),最坏时间复杂度 O(n2)O(n^2)O(n2)
快速排序时间复杂度总体小于其他排序(冒泡排序、选择排序、插入排序、希尔排序)
- 数据量较大时,优势非常明显
- 属于不稳定排序
1-7.集合ArrayList
要求
- 掌握 ArrayList 扩容规则
扩容规则
- ArrayList() 会使用长度为零的数组
- ArrayList(int initialCapacity) 会使用指定容量的数组
- public ArrayList(Collection<? extends E> c) 会使用集合的大小作为数组容量
- add(Object o) 首次扩容为 10,再次扩容为上次容量的 1.5 倍
- addAll(Collection c) 没有元素时,扩容为 Math.max(10, 实际元素个数),有元素时为 Math.max(原容量 1.5 倍, 实际元素个数)
Math.max(a,b) ==> 函数意义:从值ab中取最大值
1-8.集合LinkedList
要求
- 能够说清楚 LinkedList 对比 ArrayList 的区别,并重视纠正部分错误的认知
LinkedList
- 基于双向链表,无需连续内存
- 随机访问慢(要沿着链表遍历)
- 头尾插入删除性能高
- 占用内存多
ArrayList
- 基于数组,需要连续内存
- 随机访问快(指根据下标访问)
- 尾部插入、删除性能可以,其它部分插入、删除都会移动数据,因此性能会低
- 可以利用 cpu 缓存,局部性原理
内存连续存储 使得放入cache缓存中可存入多个数值 提高性能
1-9.Iterator
要求
- 掌握什么是 Fail-Fast、什么是 Fail-Safe
Fail-Fast 与 Fail-Safe
- ArrayList 是 fail-fast 的典型代表,遍历的同时不能修改,尽快失败
底层源码根据记录创建迭代器时的修改数值expeectedModCount
将遍历记录的expeectedModCount值与实时值modCount进行比较 不相等则表示遍历时发生修改
并进行抛出遍历时并发异常ConcurrentModificationException
- CopyOnWriteArrayList 是 fail-safe 的典型代表,遍历的同时可以修改,原理是读写分离
底层源码实现是通过创建迭代器时 将传入数组进行复制并扩容(传入数组长度+1)
并在遍历时 遍历对象为复制扩容后的数组 该操作便牺牲了数据的一致性
补充
List中迭代器实现-Vector(采用也是Fail-Fast)
Vector在添加前也会对比是否一致,if (modCount != expectedModCount)
不一致则throw new ConcurrentModificationException
1-10.HashMap
要求
- 掌握 HashMap 的基本数据结构
- 掌握树化
- 理解索引计算方法、二次 hash 的意义、容量对索引计算的影响
- 掌握 put 流程、扩容、扩容因子
- 理解并发使用 HashMap 可能导致的问题
- 理解 key 的设计
基本数据结构
- 1.7 数组 + 链表
- 1.8 数组 + (链表 | 红黑树)
选择红黑树原因:1.7中的链表过长影响性能
何时会树化:当链表长度超过树化阈值[链表长度] 8 时,先尝试扩容来减少链表长度,如果数组容量已经 >=64,才会进行树化
何时会退化为链表:
- 情况1:在扩容时如果拆分树时,树元素个数 <= 6 则会退化链表
- 情况2:remove 树节点时,若 root、root.left、root.right、root.left.left 有一个为 null ,也会退化为链表
树化意义
- 红黑树用来避免 DoS 攻击,防止链表超长时性能下降,树化应当是偶然情况,是保底策略
- hash 表的查找,更新的时间复杂度是 O(1)O(1)O(1),而红黑树的查找,更新的时间复杂度是 O(log2n)O(log_2n )O(log2n),TreeNode 占用空间也比普通 Node 的大,如非必要,尽量还是使用链表
- hash 值如果足够随机,则在 hash 表内按泊松分布,在负载因子 0.75 的情况下,长度超过 8 的链表出现概率是 0.00000006,树化阈值选择 8 就是为了让树化几率足够小
树化规则
- 当链表长度超过树化阈值 8 时,先尝试扩容来减少链表长度,如果数组容量已经 >=64,才会进行树化
退化规则
- 情况1:在扩容时如果拆分树时,树元素个数 <= 6 则会退化链表
- 情况2:remove 树节点时,若 root、root.left、root.right、root.left.left 有一个为 null ,也会退化为链表
索引计算方法
- 首先,计算对象的 hashCode()
- 再进行调用 HashMap 的 hash() 方法进行二次哈希
二次 hash() 是为了综合高位数据,让哈希分布更为均匀
- 最后 & (capacity – 1) 得到索引(或根据容量capacity求mod运算)
数组容量为何是 2 的 n 次幂
- 计算索引时效率更高:如果是 2 的 n 次幂可以使用位与运算代替取模
- 扩容时重新计算索引效率更高: hash & oldCap == 0 的元素留在原来位置 ,否则新位置 = 旧位置 + oldCap
注意
- 二次 hash 是为了配合 容量是 2 的 n 次幂 这一设计前提,如果 hash 表的容量不是 2 的 n 次幂,则不必二次 hash
- 容量是 2 的 n 次幂 这一设计计算索引效率更好,但 hash 的分散性就不好,需要二次 hash 来作为补偿,没有采用这一设计的典型例子是 Hashtable
put 流程
- HashMap 是懒惰创建数组的,首次使用才创建数组
- 计算索引(桶下标)
- 如果桶下标还没人占用,创建 Node 占位返回
- 如果桶下标已经有人占用
- 已经是 TreeNode 走红黑树的添加或更新逻辑
- 是普通 Node,走链表的添加或更新逻辑,如果链表长度超过树化阈值,走树化逻辑
- 返回前检查容量是否超过阈值,一旦超过进行扩容
1.7 与 1.8 的区别
- 链表插入节点时,1.7 是头插法,1.8 是尾插法
- 1.7 是大于等于阈值且没有空位时才扩容,而 1.8 是大于阈值就扩容
- 1.8 在扩容计算 Node 索引时,会优化
扩容(加载)因子为何默认是 0.75f
- 在空间占用与查询时间之间取得较好的权衡
- 大于这个值,空间节省了,但链表就会比较长影响性能
- 小于这个值,冲突减少了,但扩容就会更频繁,空间占用也更多
常见HashMap面试问题
- 多线程下会有什么问题
- 扩容死链(JDK1.7)
- 数据错乱(JDK1.8)
- HashMap中的key能否为null 作为key的对象(value)有什么要求
- HashMap的key可以为null 但Map接口的其他实现不可以
- 作为Key的对象 必须实现hashCode()和equels() 并且key的值不能发生改变(不可变)
若key发生变, 会导致再次查询时查询不到
- String对象的hashCode()如何实现 为什么每次乘的是31
- String对象进行hashCode()的目标是达到较为均匀的散列效果 使得每个字符串的 hashCode 足够独特
- 字符串中的每个字符都可以表现为一个数字,称为 SiS_iSi,其中 i 的范围是 0 ~ n - 1
- 散列公式为: S0∗31(n−1)+S1∗31(n−2)+…Si∗31(n−1−i)+…S(n−1)∗310S_0∗31^{(n-1)}+ S_1∗31^{(n-2)}+ … S_i ∗ 31^{(n-1-i)}+ …S_{(n-1)}∗31^0S0∗31(n−1)+S1∗31(n−2)+…Si∗31(n−1−i)+…S(n−1)∗310
- 31 代入公式有较好的散列特性,并且 31 * h 可以被优化为
- 即 32∗h−h32 ∗h -h 32∗h−h
- 即 25∗h−h2^5 ∗h -h25∗h−h
- 即 h≪5−hh≪5 -hh≪5−h
1-11.单例模式
要求
- 掌握五种单例模式的实现方式
- 理解为何 DCL 实现时要使用 volatile 修饰静态变量
- 了解 jdk 中用到单例的场景
饿汉式
public class Singleton1 implements Serializable { private Singleton1() { //预防反射破坏单例模式方法 if (INSTANCE != null) { throw new RuntimeException("单例对象不能重复创建"); } System.out.println("private Singleton1()"); } private static final Singleton1 INSTANCE = new Singleton1(); public static Singleton1 getInstance() { return INSTANCE; } public static void otherMethod() { System.out.println("otherMethod()"); } //预防反序列化破坏单例模式 方法名不能改变 public Object readResolve() { return INSTANCE; } }
- 构造方法抛出异常是防止反射破坏单例
readResolve()
是防止反序列化破坏单例- 无法预防通过unsafe方法破坏单例
枚举饿汉式
public enum Singleton2 { INSTANCE; private Singleton2() { System.out.println("private Singleton2()"); } @Override public String toString() { return getClass().getName() + "@" + Integer.toHexString(hashCode()); } public static Singleton2 getInstance() { return INSTANCE; } public static void otherMethod() { System.out.println("otherMethod()"); } }
- 枚举饿汉式能天然防止反射、反序列化破坏单例
懒汉式
public class Singleton3 implements Serializable { private Singleton3() { System.out.println("private Singleton3()"); } private static Singleton3 INSTANCE = null; // Singleton3.class public static synchronized Singleton3 getInstance() { if (INSTANCE == null) { INSTANCE = new Singleton3(); } return INSTANCE; } public static void otherMethod() { System.out.println("otherMethod()"); } }
- 其实只有首次创建单例对象时才需要同步,但该代码实际上每次调用都会同步
- 故在方法上加synchronized会导致性能下降,便有了双检锁懒汉式优化
双检锁懒汉式
csharp
复制代码
第一次检查目的:通过判断当前是否已经存在单例 减少性能消耗
第二次检查目的:通过判断释放锁的资源是否已经完成单例创建,避免资源浪费与单例的重复创建
为何必须加 volatile:
INSTANCE = new Singleton4()
不是原子的,分成 3 步:创建对象、调用构造、给静态变量赋值,其中后两步可能被指令重排序优化,变成先赋值、再调用构造- 如果线程1 先执行了赋值,线程2 执行到第一个
INSTANCE == null
时发现 INSTANCE 已经不为 null,此时就会返回一个未完全构造(属性值未进行初始化)的对象
内部类懒汉式
public class Singleton5 implements Serializable { private Singleton5() { System.out.println("private Singleton5()"); } private static class Holder { static Singleton5 INSTANCE = new Singleton5(); } public static Singleton5 getInstance() { return Holder.INSTANCE; } public static void otherMethod() { System.out.println("otherMethod()"); } }
- 避免了双检锁的缺点
- 内部类懒汉式解决了在静态代码块中完成单例创建,利用内部类在加载时才完成单例创建,通过JVM虚拟机确保线程安全解决破坏单例问题
JDK 中单例的体现
- Runtime 体现了饿汉式单例
- Console 体现了双检锁懒汉式单例
- Collections 中的 EmptyNavigableSet 内部类懒汉式单例
- ReverseComparator.REVERSE_ORDER 内部类懒汉式单例
- Comparators.NaturalOrderComparator.INSTANCE 枚举饿汉式单例