算法描述
前提:有已排序数组 a(假设已经做好)
定义左边界 l、右边界 r,确定搜索范围,循环执行二分查找(3、4两步)
获取中间索引 m= (l+r) /2
中间索引的值 a[m] 与待搜索的值 T 进行比较
① a[m] == T 表示找到,返回中间索引
② a[m] > T,中间值右侧的其它元素都大于 T,无需比较,中间索引左边去找,m- 1 设置为右边界,重新查找
③ a[m] < T,中间值左侧的其它元素都小于 T,无需比较,中间索引右边去找, m+ 1 设置为左边界,重新查找
当 l > r
时,表示没有找到,应结束循环
算法实现
public class BinarySearch {
public static void main(String[] args) {
int[] array = {1, 5, 8, 11, 19, 22, 31, 35, 40, 45, 48, 49, 50};
int target = 47;
int idx = binarySearch(array, target);
System.out.println(idx);
}
// 二分查找, 找到返回元素索引,找不到返回 -1
public static int binarySearch(int[] a, int t) {
int l = 0;
int r = a.length - 1;
int m;
while (l <= r) {
m = (l + r) / 2;
if (a[m] == t) {
return m;
} else if (a[m] > t) {
//目标值小于中间值
r = m - 1;
} else {
l = m + 1;
}
}
return -1;
}
}
解决整数溢出问题
当 l 和 r 都较大时,l + r
有可能超过整数范围,造成运算错误,解决方法有两种:
第一种:
int m = l + (r - l) / 2;
推导:
m = (l + r) / 2 => l/2 + r/2 => l - l/2 + r/2 => l + (r - l)/2
第二种:位运算,(l+r)若是正数,等价于第一种
int m = (l + r) >>> 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个元素的数组中二分查找一个数,需要比较的次数最多不超过多少次
问题转换为log_2 128 , 用 log_10 128 / log_10 2
算法描述
算法实现
public static void bubble(int[] a) {
for (int j = 0; j < a.length - 1; j++) {
// 一轮冒泡
boolean swapped = false; // 优化点2,是否发生了交换
for (int i = 0; i < a.length - 1 - j; i++) {//优化点1
System.out.println("比较次数" + i);
if (a[i] > a[i + 1]) {
Utils.swap(a, i, i + 1);
swapped = true;
}
}
System.out.println("第" + j + "轮冒泡" + Arrays.toString(a));
if (!swapped) {
break;
}
}
}
进一步优化
public static void bubble_v2(int[] a) {
int n = a.length - 1;//循环需要比较的次数
while (true) {
int last = 0; // 表示最后一次交换索引的位置
for (int i = 0; i < n; i++) {
System.out.println("比较次数" + i);
if (a[i] > a[i + 1]) {
Utils.swap(a, i, i + 1);
last = i;
}
}
n = last;
System.out.println("第轮冒泡"+ Arrays.toString(a));
if (n == 0) {
break;
}
}
}
最后一次交换索引可以作为下一轮冒泡的比较次数
,如果这个值为零,表示整个数组有序,直接退出外层循环即可算法描述
算法实现
private static void selection(int[] a) {
for (int i = 0; i < a.length - 1; i++) {
// i 代表每轮选择最小元素要交换到的目标索引
int s = i; // 代表最小元素的索引
for (int j = s + 1; j < a.length; j++) {
if (a[s] > a[j]) { // j 元素比 s 元素还要小, 更新 s
s = j;
}
}
if (s != i) {
swap(a, s, i);
}
System.out.println(Arrays.toString(a));
}
}
优化点:为减少交换次数,每一轮可以先找最小的索引,在每轮最后再交换元素
与冒泡排序比较
二者平均时间复杂度都是 O(n^2)
选择排序一般要快于冒泡,因为其交换次数少
但如果集合有序度高,冒泡优于选择
冒泡属于稳定排序算法,而选择属于不稳定排序
稳定排序与不稳定排序
System.out.println("=================不稳定================");
Card[] cards = getStaticCards();
System.out.println(Arrays.toString(cards));
selection(cards, Comparator.comparingInt((Card a) -> a.sharpOrder).reversed());
System.out.println(Arrays.toString(cards));
selection(cards, Comparator.comparingInt((Card a) -> a.numberOrder).reversed());
System.out.println(Arrays.toString(cards));
System.out.println("=================稳定=================");
cards = getStaticCards();
System.out.println(Arrays.toString(cards));
bubble(cards, Comparator.comparingInt((Card a) -> a.sharpOrder).reversed());
System.out.println(Arrays.toString(cards));
bubble(cards, Comparator.comparingInt((Card a) -> a.numberOrder).reversed());
System.out.println(Arrays.toString(cards));
都是先按照花色排序(♠♥♣♦),再按照数字排序(AKQJ…)
不稳定排序算法按数字排序时,会打乱原本同值的花色顺序
[[♠7], [♠2], [♠4], [♠5], [♥2], [♥5]]
[[♠7], [♠5], [♥5], [♠4], [♥2], [♠2]]
原来 ♠2 在前 ♥2 在后,按数字再排后,他俩的位置变了
稳定排序算法按数字排序时,会保留原本同值的花色顺序,如下所示 ♠2 与 ♥2 的相对位置不变
[[♠7], [♠2], [♠4], [♠5], [♥2], [♥5]]
[[♠7], [♠5], [♥5], [♠4], [♠2], [♥2]]
面试题
使用直接选择排序算法对序列18,23,19,9,23,15进行排序,第三趟排序后的结果为 9,15,18,19,23,23
算法描述
将数组分为两个区域,排序区域和未排序区域,每一轮从未排序区域中取出第一个元素,插入到排序区域(需保证顺序)
重复以上步骤,直到整个数组有序
算法实现
public static void insert(int[] a) {
// i 代表待插入元素的索引
for (int i = 1; i < a.length; i++) {
int t = a[i]; // 代表待插入的元素值
int j = i-1; //代表已排序区域的元素索引
//System.out.println(j);
while (j >= 0) {
if (t < a[j]) {
a[j + 1] = a[j];
j--;
} else {
break; //退出循环,减少比较次数
}
}
//将待插入的值插入
a[j + 1] = t;
System.out.println(Arrays.toString(a) + " " + j);
}
}
与选择排序比较
二者平均时间复杂度都是 O(n^2)
大部分情况下,插入都略优于选择
有序集合
插入的时间复杂度为 O(n)
插入属于稳定排序算法,而选择属于不稳定排序
提示
插入排序通常被轻视,其实它的地位非常重要。小数据量排序,都会优先选择插入排序
面试题:
使用直接插入排序算法对序列18,23,19,9,23,15进行排序,第三趟排序后的结果为 9,18,19,23,23,15
插入排序有个缺陷,要是大的元素全都集中在前面,那交换的次数会很多,为了解决这个问题,引入了希尔排序。
算法描述
首先选取一个间隙序列,如 (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);
}
}
}
算法描述
每一轮排序选择一个基准点(pivot)进行分区
在子分区内重复以上过程,直至子分区元素个数少于等于 1,这体现的是分而治之的思想 (divide-and-conquer)
从以上描述可以看出,一个关键在于分区算法,常见的有洛穆托分区方案、双边循环分区方案、霍尔分区方案
单边循环快排(lomuto 洛穆托分区方案)
选择最右元素作为基准点元素s
j 指针负责找到比基准点小的元素,一旦找到则与 i 进行交换
i 指针维护小于基准点元素的边界,也是每次交换的目标索引
最后基准点与 i 交换,i 即为分区位置
//递归
public static void quick(int[] a, int l, int h) {
if (l >= h) {
return;
}
int p = partition(a, l, h); // p 索引值
quick(a, l, p - 1); // 左边分区的范围确定
quick(a, p + 1, h); // 右边分区的范围确定
}
//分区
private static int partition(int[] a, int l, int h) {
int pv = a[h]; // 选择最右侧元素作为基准点元素
int i = l;
for (int j = l; j < h; j++) {
if (a[j] < pv) {
if (i != j) {//优化点
swap(a, i, j);
}
i++;
}
}
//将基准点元素与i进行交换
if (i != h) {//优化点
swap(a, h, i);
}
System.out.println(Arrays.toString(a) + " i=" + i);
// 返回值代表了基准点元素所在的正确索引,用它确定下一轮分区的边界
return i;
}
双边循环快排(不完全等价于 hoare 霍尔分区方案)
要点
基准点在左边,并且要先 j 后 i
while(i < j
&& a[j] > pv ) j - -
while ( i < j
&& a[i] <=
pv ) i++
private static void quick(int[] a, int l, int h) {
if (l >= h) {
return;
}
int p = partition(a, l, h);
quick(a, l, p - 1);
quick(a, p + 1, h);
}
private static int partition(int[] a, int l, int h) {
int pv = a[l];//最左边元素作为基准点元素
int i = l;
int j = h;
while (i < j) {
//先进行j的查找,然后进行i的查找;
// j 从右找小的,j与基准点元素作比较
//在内层循环为啥还要加i
//必须加
while (i < j && a[j] > pv) {
j--;
}
// i 从左找大的,i与基准点元素作比较
//为什么是<=呢?
//因为刚开始基准点元素和i元素相等,a[i] < pv不满足条件,也就不会进入循环,后面进行交换的时候就将基准点元素交换走了,肯定不对呀
while (i < j && a[i] <= pv) {
i++;
}
swap(a, i, j);
}
//交换基准点元素与j或者i,此时i==j
swap(a, l, j);
System.out.println(Arrays.toString(a) + " j=" + j);
return j;
}
快排特点
平均时间复杂度是 O(nlog_2n ),最坏时间复杂度 O(n^2)
数据量较大时,优势非常明显
属于不稳定排序
洛穆托分区方案 vs 霍尔分区方案
扩容规则
长度为零
的数组指定容量
的数组c 的大小
作为数组容量add(Object o) 首次扩容为 10,再次扩容为上次容量的 1.5 倍
代码演示
我们先来看一下add(Object)方法的扩容规则
public class TestArrayList {
public static void main(String[] args) {
System.out.println(arrayListGrowRule(30));
}
private static List<Integer> arrayListGrowRule(int n) {
List<Integer> list = new ArrayList<>();
int init = 0;
list.add(init);
if (n >= 1) {
init = 10;
list.add(init);
}
for (int i = 1; i < n; i++) {
init += (init) >> 1;
list.add(init);
}
return list;
}
}
这里有小伙伴就有疑问了,第三次扩容是15没问题,那第四次扩容不应该是15*1.5等于22.5,怎么会等于22呢?其实啊,查看代码我们会发现扩容1.5倍并不是乘以1.5,而是15右移一位
(整数的右移1相当于除2)等于7,加上之前的15就等于22了。
接下来看一下addAll(Collection c) 方法的扩容规则
先来看集合里面没有元素时的情况
private static void testAddAllGrowEmpty() {
ArrayList<Integer> list = new ArrayList<>();
list.addAll(List.of(1, 2, 3));
//list.addAll(List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11));
System.out.println(length(list));//10
}
此时list的长度为10
那我们换一下,list里加11个元素呢,此时list的长度会是15吗
private static void testAddAllGrowEmpty() {
ArrayList<Integer> list = new ArrayList<>();
list.addAll(List.of(1, 2, 3));
//list.addAll(List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11));
System.out.println(length(list));//11
}
list的长度其实是11,为什么呢?因为addAll扩容规则是下次扩容的容量跟实际的元素个数取一个较大值 作为下次扩容的容量
我们再来看一下集合里面有元素的情况
private static void testAddAllGrowNotEmpty() {
ArrayList<Integer> list = new ArrayList<>();
for (int i = 0; i < 10; i++) {
list.add(i);
}
list.addAll(List.of(1, 2, 3));
//list.addAll(List.of(1, 2, 3, 4, 5, 6));
System.out.println(length(list));//15
}
private static void testAddAllGrowNotEmpty() {
ArrayList<Integer> list = new ArrayList<>();
for (int i = 0; i < 10; i++) {
list.add(i);
}
//list.addAll(List.of(1, 2, 3));
list.addAll(List.of(1, 2, 3, 4, 5, 6));
System.out.println(length(list));//16
}
补充
通过反射机制获取到当前集合的长度
public static int length(ArrayList<Integer> list) {
try {
Field field = ArrayList.class.getDeclaredField("elementData");
field.setAccessible(true);
return ((Object[]) field.get(list)).length;
} catch (Exception e) {
e.printStackTrace();
return 0;
}
}
注意,示例中用反射方式来更直观地反映 ArrayList 的扩容特征,但从 JDK 9 由于模块化的影响,对反射做了较多限制,需要在运行测试代码时添加 VM 参数 --add-opens java.base/java.util=ALL-UNNAMED
方能运行通过,后面的例子都有相同问题
要求
Fail-Fast 与 Fail-Safe
ArrayList 是 fail-fast 的典型代表,遍历的同时不能修改
,尽快失败
CopyOnWriteArrayList 是 fail-safe 的典型代表,遍历的同时可以修改,原理是读写分离
源码分析
看下ArrayList的源码,增强for循环会调用迭代器对象
expectedModCount是迭代器的成员变量,记录了迭代器在刚开始迭代时的修改次数
modCount是List集合的成员变量,记录list 集合被修改了多少次
继续往下看有个next 方法
执行该方法时先执行checkForComodification(); 点进去看看
接下来看看CopyOnWriteArrayList 的源码
进入COWIterator
再看一下add方法,点进源码
LinkedList
双向链表
,无需连续内存头尾插入元素删除性能高
,中间插入元素的性能还不如ArrayListArrayList
数组
,需要连续内存随机访问快
(指根据下标访问)尾部插入、删除性能可以
,其它部分插入、删除都会移动数据,因此性能会低CPU缓存:第一次读取数据的时候将数据存到缓存中,之后再去读取数据就不会内存中读取了,直接从缓存中读取,效率更高
局部性原理:读取数据的时候,相邻的数据有很大的几率被访问到,因此读取的时候一次性的全都读到缓存中
底层数据结构,1.7和1.8有什么不同?
hashmap何时会扩容呢?
为何要用红黑树,为何一上来不树化,数化阈值为何是8,何时会树化,何时会退化为链表?
索引如何计算,hashCode都有了,为何还要提供hash方法,数组容量为何是2的n次幂?
首先,计算对象的 hashCode(),再调用 HashMap 的 hash() 方法进行二次哈希,最后二次哈希值跟数组容量模运算
,就得到桶下标,也就是索引
等价于二次哈希值 & (capacity – 1)
得到索引;等价运算前提,除数必须是2的n次幂
二次 hash() 是为了综合高位数据,让哈希分布更为均匀,不会造成链表过长的情况
计算索引时,如果是2的n次幂可以使用位与运算代替取模,效率更高;扩容时重新计算索引效率更高:二次 hash值 & 原始容量 == 0 的元素留在原来位置 ,否则新位置 = 旧位置 + oldCap(批量移动)。
但前三点都是为了配合容量为2的n次幂时的优化手段,例如HashTable的容量就不是2的n次幂,设计者综合考虑了各种因素,最终选择了2的n次幂作为容量。
注意:
容量是 2 的 n 次幂
这一设计前提,如果 hash 表的容量不是 2 的 n 次幂,则不必二次 hash容量是 2 的 n 次幂
这一设计计算索引效率更好,但 hash 的分散性就不好,需要二次 hash 来作为补偿,没有采用这一设计的典型例子是 Hashtableput 流程
1.7 与 1.8 的区别
链表插入节点时,1.7 是头插法,1.8 是尾插法
1.7 是大于等于阈值且没有空位时才扩容,而 1.8 是大于阈值就扩容
1.8 在扩容计算 Node 索引时,会优化
扩容(加载)因子为何默认是 0.75f
多线程下会有啥问题?
扩容死链(1.7 会存在)
1.7 源码如下:
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
数据错乱(1.7,1.8 都会存在)
key能否为null,作为key的对象有什么要求?
String对象的hashCode() 如何设计的,为啥每次乘的都是31?
确保一个类只有一个实例,并提供该实例的全局访问点。
单例模式可以让程序仅在内存中创建一个对象,让所有需要调用的地方都调用这个单例对象,防止了频繁地创建对象使得内存飙升。
使用对象
时才去创建该单例对象类加载
时已经创建好该单例对象饿汉式
class Singleton1 implements Serializable {
//无参构造私有,对象只能在本类被创建
private Singleton1() {
//防止反射破坏单例
if (INSTANCE != null) {
throw new RuntimeException("单例对象不能重复创建");
}
System.out.println("Singleton1()");
}
//静态成员变量
//静态变量的赋值是在静态代码块中执行的,由jvm保证其线程安全
private static final Singleton1 INSTANCE = new Singleton1();
//公共的静态方法,供外部调用
public static Singleton1 getInstance() {
return INSTANCE;
}
//防止反序列化破坏单例
public Object readResolve() {
return INSTANCE;
}
//其他方法
public static void otherMethod() {
System.out.println("otherMethod()");
}
}
public class AppTest {
public static void main(String[] args) {
Singleton1.otherMethod();
System.out.println("--------------------");
System.out.println(Singleton1.getInstance());
System.out.println(Singleton1.getInstance());
}
/**
* Singleton1()
* otherMethod()
* --------------------
* com.hh.demo.designpattern.e.Singleton1@135fbaa4
* com.hh.demo.designpattern.e.Singleton1@135fbaa4
*/
}
readResolve()
是防止反序列化破坏单例枚举饿汉式(重要)
package com.hh.demo.designpattern.e;
enum Singleton2 {
INSTANCE;
//公共的静态方法
public static Singleton2 getInstance() {
return INSTANCE;
}
@Override
public String toString() {
return getClass().getName() + "@" + Integer.toHexString(hashCode());
}
//其他方法
public static void otherMethod() {
System.out.println("otherMethod()");
}
}
public class AppTest {
public static void main(String[] args) {
Singleton2.otherMethod();
System.out.println("--------------------");
System.out.println(Singleton2.getInstance());
System.out.println(Singleton2.getInstance());
}
/**
* Singleton2()
* otherMethod()
* -------------------------
* com.hh.demo.designpattern.e.Singleton2@135fbaa4
* com.hh.demo.designpattern.e.Singleton2@135fbaa4
*
* 进程已结束,退出代码0
*/
}
懒汉式
package com.hh.demo.designpattern.e;
class Singleton3 implements Serializable {
//构造方法私有
private Singleton3() {
System.out.println("Singleton3()");
}
//私有的静态变量
private static Singleton3 INSTANCE = null;
//公共的静态方法,加锁保证线程安全
public static synchronized Singleton3 getInstance() {
if (INSTANCE == null) {
INSTANCE = new Singleton3();
}
return INSTANCE;
}
//其他方法
public static void otherMethod() {
System.out.println("otherMethod()");
}
}
public class AppTest {
public static void main(String[] args) {
Singleton3.otherMethod();
System.out.println("--------------------");
System.out.println(Singleton3.getInstance());
System.out.println(Singleton3.getInstance());
}
/**
* otherMethod()
* --------------------
* Singleton3()
* com.hh.demo.designpattern.e.Singleton3@135fbaa4
* com.hh.demo.designpattern.e.Singleton3@135fbaa4
*
* 进程已结束,退出代码0
*/
}
上面代码每次去获取对象都需要先获取锁,并发性能非常差;
其实只有首次创建单例对象时才需要同步,但该代码实际上每次调用都会同步;
接下来要做的就是优化性能,如果没有实例化对象则加锁创建,如果已经实例化了,则不需要加锁,直接获取实例
因此有了下面的双检锁改进
双检锁懒汉式
package com.hh.demo.designpattern.e;
class Singleton4 implements Serializable {
//无参构造私有
private Singleton4() {
System.out.println("private Singleton4()");
}
//volatile保证了变量的可见性,有序性,禁止指令重排
private static volatile Singleton4 INSTANCE = null;
public static Singleton4 getInstance() {
if (INSTANCE == null) {// 线程A和线程B同时看到 INSTANCE = null,如果不为null,则直接返回 INSTANCE
synchronized (Singleton4.class) {//线程A或线程B获得该锁进行初始化
if (INSTANCE == null) {// 其中一个线程进入该分支,另外一个线程则不会进入该分支
INSTANCE = new Singleton4();
}
}
}
return INSTANCE;
}
//其他方法
public static void otherMethod() {
System.out.println("otherMethod()");
}
}
public class AppTest {
public static void main(String[] args) {
Singleton4.otherMethod();
System.out.println("--------------------");
System.out.println(Singleton4.getInstance());
System.out.println(Singleton4.getInstance());
}
/**
* otherMethod()
* --------------------
* private Singleton4()
* com.hh.demo.designpattern.e.Singleton4@135fbaa4
* com.hh.demo.designpattern.e.Singleton4@135fbaa4
*
* 进程已结束,退出代码0
*/
}
为何必须加 volatile:
INSTANCE = new Singleton4()
不是原子的,分成 3 步:创建对象、调用构造、给静态变量赋值
,其中后两步可能被指令重排序优化,变成先赋值、再调用构造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()");
}
}
JDK 中单例的体现