算法详解
1、二分查找
-
算法描述
给定一个内含 n n n 个元素的有序数组 A A A,满足 A 0 ≤ A 1 ≤ A 2 ≤ ⋯ ≤ A n − 1 A_{0}\leq A_{1}\leq A_{2}\leq \cdots \leq A_{n-1} A0≤A1≤A2≤⋯≤An−1,一个待查值 t a r g e t target target -
实现步骤
- 设置 i = 0 i=0 i=0, j = n − 1 j=n-1 j=n−1
- 如果 i > j i \gt j i>j,结束查找,没找到
- 设置 m = f l o o r ( i + j 2 ) m = floor(\frac {i+j}{2}) m=floor(2i+j) , m m m 为中间索引, f l o o r floor floor 是向下取整( ≤ i + j 2 \leq \frac {i+j}{2} ≤2i+j 的最小整数)
- 如果 t a r g e t < A m target < A_{m} target<Am 设置 j = m − 1 j = m - 1 j=m−1,跳到第2步
- 如果 A m < t a r g e t A_{m} < target Am<target 设置 i = m + 1 i = m + 1 i=m+1,跳到第2步
- 如果 A m = t a r g e t A_{m} = target Am=target,结束查找,找到了
-
代码实现(基础版)
public static int binarySearch(int[] a, int target) {
int i = 0, j = a.length - 1;
while (i <= j) {
int m = (i + j) >>> 1;
if (target < a[m]) { // 在左边
j = m - 1;
} else if (a[m] < target) { // 在右边
i = m + 1;
} else {
return m;
}
}
return -1;
}
- 改造版
public static int binarySearch(int[] a, int target) {
int i = 0, j = a.length;
while (i < j) {
int m = (i + j) >>> 1;
if (target < a[m]) { // 在左边
j = m;
} else if (a[m] < target) { // 在右边
i = m + 1;
} else {
return m;
}
}
return -1;
}
这样做的目的是让j位置的元素也参与运算。
-
思考1:为什么i<=j意味着区间内有未比较的元素,而不是i<j?
i==j意味着i,j他们指向的元素也会参与比较
j<j 只意味着m指向的元素产生于比较 -
思考2:(i + j) / 2 有没有问题?
换成(i + j) >>> 1,当j过大,可能出现负数,涉及二进制的计算,通过右移,高数位是0,不会改成负数。 -
思考3:都写成小于号有啥好处?
排序 -
性能分析
- 时间复杂度
最好: O ( 1 ) O(1) O(1),最坏: O ( l o g n ) O(log n) O(logn) - 空间复杂度
需要常数个指针 i , j , m i,j,m i,j,m,因此额外占用的空间是 O ( 1 ) O(1) O(1)
- 时间复杂度
1.1、二分查找平衡版
public static int binarySearchBalance(int[] a, int target) {
int i = 0, j = a.length;
while (1 < j - i) {
int m = (i + j) >>> 1;
if (target < a[m]) {
j = m;
} else {
i = m;
}
}
return (a[i] == target) ? i : -1;
}
减少循环内的比较次数
j-i>0 的含义:在范围内待比较的元素个数
时间复杂度
Θ
(
l
o
g
(
n
)
)
\Theta(log(n))
Θ(log(n))
改变
i
i
i 边界时,它指向的可能是目标,因此不能
m
+
1
m+1
m+1
1.2、Java版本的二分查找
private static int binarySearch0(int[] a, int fromIndex, int toIndex,
int key) {
int low = fromIndex;
int high = toIndex - 1;
while (low <= high) {
int mid = (low + high) >>> 1;
int midVal = a[mid];
if (midVal < key)
low = mid + 1;
else if (midVal > key)
high = mid - 1;
else
return mid; // key found
}
return -(low + 1); // key not found.
}
- 根据返回的索引将不存在的目标值新增到数组中
// 根据返回的索引将不存在的目标值新增到数组中
public static void main(String[] args) {
int[] a = {2,5,8};
int target = 4;
int i = Arrays.binarySearch(a, target);
System.out.println(i);
// low: 插入点
// -2 = -(low + 1)
if (i < 0) {
// 插入点索引
int insertIndex = Math.abs(i + 1);
// 创建新数组
int[] b = new int[a.length + 1];
// 拷贝插入点索引之前的数据
System.arraycopy(a, 0, b, 0, insertIndex);
b[insertIndex] = target;
// 拷贝原数组插入点之后的
System.arraycopy(a, insertIndex, b, insertIndex + 1, a.length - insertIndex);
System.out.println(Arrays.toString(b));
}
}
1.3、Leftmost 与 Rightmost
有时我们希望返回的是最左侧的重复元素
- 对于数组 [ 1 , 2 , 3 , 4 , 4 , 5 , 6 , 7 ] [1, 2, 3, 4, 4, 5, 6, 7] [1,2,3,4,4,5,6,7],查找元素4,结果是索引3
- 对于数组 [ 1 , 2 , 4 , 4 , 4 , 5 , 6 , 7 ] [1, 2, 4, 4, 4, 5, 6, 7] [1,2,4,4,4,5,6,7],查找元素4,结果也是索引3,并不是最左侧的元素
代码实现
public static int binarySearchLeftmost1(int[] a, int target) {
int i = 0, j = a.length - 1;
int candidate = -1;
while (i <= j) {
int m = (i + j) >>> 1;
if (target < a[m]) {
j = m - 1;
} else if (a[m] < target) {
i = m + 1;
} else {
candidate = m; // 记录候选位置
j = m - 1; // 继续向左
}
}
return candidate;
}
如果希望返回的是最右侧元素
public static int binarySearchRightmost1(int[] a, int target) {
int i = 0, j = a.length - 1;
int candidate = -1;
while (i <= j) {
int m = (i + j) >>> 1;
if (target < a[m]) {
j = m - 1;
} else if (a[m] < target) {
i = m + 1;
} else {
candidate = m; // 记录候选位置
i = m + 1; // 继续向右
}
}
return candidate;
}
应用
对于 Leftmost 与 Rightmost,可以返回一个比 -1 更有用的值
Leftmost 改为
public static int binarySearchLeftmost(int[] a, int target) {
int i = 0, j = a.length - 1;
while (i <= j) {
int m = (i + j) >>> 1;
if (target <= a[m]) {
j = m - 1;
} else {
i = m + 1;
}
}
return i;
}
- leftmost 返回值的另一层含义: < t a r g e t \lt target <target 的元素个数
- 小于等于中间值,都要向左找
Rightmost 改为
public static int binarySearchRightmost(int[] a, int target) {
int i = 0, j = a.length - 1;
while (i <= j) {
int m = (i + j) >>> 1;
if (target < a[m]) {
j = m - 1;
} else {
i = m + 1;
}
}
return i - 1;
}
- 大于等于中间值,都要向右找
1.4、算法题1
给定一个 n n n个元素的(升序)整型数组 n u m s nums nums和一个目标值 t a r g e t target target,写一个函数搜索 n u m s nums nums中的 t a r g e t target target,如果目标值存在返回下标,否则返回 − 1 -1 −1
示例 1 示例1 示例1:
输入:nums=[-1,0,3,5,9,12], target=9
输出:4
示例 2 示例2 示例2:
输入:nums=[-1,0,3,5,9,12], target=2
输出:-1
解析 解析 解析
class Solution {
public int search(int[] nums, int target) {
int i = 0, j = nums.length - 1;
while (i <= j) {
int mid = (i + j) >>> 1;
if (target < nums[mid]) {
j = mid - 1;
} else if (nums[mid] < target) {
i = mid + 1;
} else {
return mid;
}
}
return -1;
}
}
1.5、算法题2
给定一个排序数组和一个目标值,在数组中找到目标值,并返回其索引。如果目标值不存在于数组中,返回它将会被按顺序插入的位置。请必须使用时间复杂度未 O ( l o g n ) O(log_n) O(logn)的算法
示例 1 示例1 示例1:
输入:nums=[1,3,5,6], target=5
输出:2
示例 2 示例2 示例2:
输入:nums=[1,3,5,6], target=2
输出:1
示例 3 示例3 示例3:
输入:nums=[1,3,5,6], target=7
输出:4
解析 解析 解析
class Solution {
public static void main(String[] args) {
int[] a = {1,3,5,6};
int target = 7;
if (search(a, 2) < 0) {
// 插入位置
System.out.println(Math.abs(search(a, target) + 1));
} else {
System.out.println(search(a, target));
}
}
public static int search(int[] nums, int target) {
int i = 0, j = nums.length - 1;
while (i <= j) {
int mid = (i + j) >>> 1;
if (target < nums[mid]) {
j = mid - 1;
} else if (nums[mid] < target) {
i = mid + 1;
} else {
return mid;
}
}
return -(i + 1);
}
}
1.6、算法题3: 在排序数组中查找元素的第一个和最后一个位置
给你一个按照非递减顺序排列的整数数组 n u m s nums nums,和一个目标值 t a r g e t target target,请你找出给定目标值在数组中的开始位置和结束位置
如果数组中不存在目标值target,返回[-1, -1]
时间复杂度 O ( l o g n ) O(log_n) O(logn)
示例 1 示例1 示例1:
输入:nums=[5,7,7,8,8,10], target=8
输出:[3, 4]
示例 2 示例2 示例2:
输入:nums=[5,7,7,8,8,10], target=6
输出:[-1, -1]
示例 3 示例3 示例3:
输入:nums=[], target=0
输出:[-1, -1]
解析 解析 解析
class Solution {
public static void main(String[] args) {
int[] a = {5,7,7,8,8,10};
int target = 8;
System.out.println(Arrays.toString(search(a, target)));
}
public static int[] search(int[] nums, int target) {
int x = left(nums, target);
if (x == -1) {
return new int[]{-1,-1};
} else {
return new int[]{x, right(nums, target)};
}
}
public static int left (int[] nums, int target) {
int i = 0, j = nums.length - 1;
int candidate = -1;
while (i <= j) {
int mid = (i + j) >>> 1;
if (target < nums[mid]) {
j = mid - 1;
} else if (nums[mid] < target) {
i = mid + 1;
} else {
candidate = mid;
j = mid - 1;
}
}
return candidate;
}
public static int right (int[] nums, int target) {
int i = 0, j = nums.length - 1;
int candidate = -1;
while (i <= j) {
int mid = (i + j) >>> 1;
if (target < nums[mid]) {
j = mid - 1;
} else if (nums[mid] < target) {
i = mid + 1;
} else {
candidate = mid;
i = mid + 1;
}
}
return candidate;
}
}
2、线性查找
public static int search(int[] a, int k) {
for (int i = 0; i < a.length; i++) {
if (a[i] == k) {
return i;
}
}
return -1;
}
3、数组
数组内的元素是连续存储的
根据索引查找元素,时间复杂度是 O ( 1 ) O(1) O(1)
空间占用
Java 中数组结构为
- 8 字节 markword
- 4 字节 class 指针(压缩 class 指针的情况)
- 4 字节 数组大小(决定了数组最大容量是 2 32 2^{32} 232)
- 数组元素 + 对齐字节(java 中所有对象大小都是 8 字节的整数倍[^12],不足的要用对齐字节补足)
例如
int[] array = {1, 2, 3, 4, 5};
的大小为 40 个字节,组成如下
int 占4个字节
8 + 4 + 4 + 5*4 + 4(alignment)
3.1、动态数组
java 版本
public class DynamicArray implements Iterable<Integer> {
private int size = 0; // 逻辑大小
private int capacity = 8; // 容量
private int[] array = {};
/**
* 向最后位置 [size] 添加元素
*
* @param element 待添加元素
*/
public void addLast(int element) {
add(size, element);
}
/**
* 向 [0 .. size] 位置添加元素
*
* @param index 索引位置
* @param element 待添加元素
*/
public void add(int index, int element) {
checkAndGrow();
// 添加逻辑
if (index >= 0 && index < size) {
// 向后挪动, 空出待插入位置
System.arraycopy(array, index,
array, index + 1, size - index);
}
array[index] = element;
size++;
}
private void checkAndGrow() {
// 容量检查
if (size == 0) {
array = new int[capacity];
} else if (size == capacity) {
// 进行扩容, 1.5 1.618 2
capacity += capacity >> 1;
int[] newArray = new int[capacity];
System.arraycopy(array, 0,
newArray, 0, size);
array = newArray;
}
}
/**
* 从 [0 .. size) 范围删除元素
*
* @param index 索引位置
* @return 被删除元素
*/
public int remove(int index) { // [0..size)
int removed = array[index];
if (index < size - 1) {
// 向前挪动
System.arraycopy(array, index + 1,
array, index, size - index - 1);
}
size--;
return removed;
}
/**
* 查询元素
*
* @param index 索引位置, 在 [0..size) 区间内
* @return 该索引位置的元素
*/
public int get(int index) {
return array[index];
}
/**
* 遍历方法1
*
* @param consumer 遍历要执行的操作, 入参: 每个元素
*/
public void foreach(Consumer<Integer> consumer) {
for (int i = 0; i < size; i++) {
// 提供 array[i]
// 返回 void
consumer.accept(array[i]);
}
}
/**
* 遍历方法2 - 迭代器遍历
*/
@Override
public Iterator<Integer> iterator() {
return new Iterator<Integer>() {
int i = 0;
@Override
public boolean hasNext() { // 有没有下一个元素
return i < size;
}
@Override
public Integer next() { // 返回当前元素,并移动到下一个元素
return array[i++];
}
};
}
/**
* 遍历方法3 - stream 遍历
*
* @return stream 流
*/
public IntStream stream() {
return IntStream.of(Arrays.copyOfRange(array, 0, size));
}
}
插入或删除性能
头部位置,时间复杂度是 O ( n ) O(n) O(n)
中间位置,时间复杂度是 O ( n ) O(n) O(n)
尾部位置,时间复杂度是 O ( 1 ) O(1) O(1)
3.2、二维数组
int[][] array = {
{11, 12, 13, 14, 15},
{21, 22, 23, 24, 25},
{31, 32, 33, 34, 35},
};
小测试
Java 环境下(不考虑类指针和引用压缩,此为默认情况),有下面的二维数组
byte[][] array = {
{11, 12, 13, 14, 15},
{21, 22, 23, 24, 25},
{31, 32, 33, 34, 35},
};
已知 array 对象起始地址是 0x1000,那么 23 这个元素的地址是什么?
答:
- 起始地址 0x1000
- 外层数组大小:16字节对象头 + 3元素 * 每个引用4字节 + 4 对齐字节 = 32 = 0x20
- 第一个内层数组大小:16字节对象头 + 5元素 * 每个byte1字节 + 3 对齐字节 = 24 = 0x18
- 第二个内层数组,16字节对象头 = 0x10,待查找元素索引为 2
- 最后结果 = 0x1000 + 0x20 + 0x18 + 0x10 + 2*1 = 0x104a
3.3、局部性原理
这里只讨论空间局部性
- cpu 读取内存(速度慢)数据后,会将其放入高速缓存(速度快)当中,如果后来的计算再用到此数据,在缓存中能读到的话,就不必读内存了
- 缓存的最小存储单位是缓存行(cache line),一般是 64 bytes,一次读的数据少了不划算啊,因此最少读 64 bytes 填满一个缓存行,因此读入某个数据时也会读取其临近的数据,这就是所谓空间局部性
对效率的影响
比较下面 ij 和 ji 两个方法的执行效率
ij 方法
public class Application {
public static void main(String[] args) {
int rows = 1000000;
int columns = 14;
int[][] a = new int[rows][columns];
long start = System.currentTimeMillis();
System.out.println();
ij(a, rows, columns);
System.out.println(System.currentTimeMillis() - start);
long start1 = System.currentTimeMillis();
ji(a, rows, columns);
System.out.println(System.currentTimeMillis() - start1);
}
public static void ij(int[][] a, int rows, int columns) {
long sum = 0L;
for (int i = 0; i < rows; i++) {
for (int j = 0; j < columns; j++) {
sum += a[i][j];
}
}
// System.out.println(sum);
}
public static void ji(int[][] a, int rows, int columns) {
long sum = 0L;
for (int j = 0; j < columns; j++) {
for (int i = 0; i < rows; i++) {
sum += a[i][j];
}
}
// System.out.println(sum);
}
}
执行结果
16 ij
70 ji
可以看到 ij 的效率比 ji 快很多,为什么呢?
- 缓存是有限的,当新数据来了后,一些旧的缓存行数据就会被覆盖
- 如果不能充分利用缓存的数据,就会造成效率低下
以 ji 执行为例,第一次内循环要读入 [ 0 , 0 ] [0,0] [0,0] 这条数据,由于局部性原理,读入 [ 0 , 0 ] [0,0] [0,0] 的同时也读入了 [ 0 , 1 ] . . . [ 0 , 13 ] [0,1] ... [0,13] [0,1]...[0,13],
[0, 0] | [0, 1] | [0, 2] | [0, 3] | [0, 4] | [0, 5] | [0, 6] | [0, 7] | [0, 8] | [0, 9] | [0, 10] | [0, 11] | [0, 12] | [0, 13] | [0, 14] |
---|
但很遗憾,第二次内循环要的是 [ 1 , 0 ] [1,0] [1,0] 这条数据,缓存中没有,于是再读入了下图的数据
[0, 0] | [0, 1] | [0, 2] | [0, 3] | [0, 4] | [0, 5] | [0, 6] | [0, 7] | [0, 8] | [0, 9] | [0, 10] | [0, 11] | [0, 12] | [0, 13] | [0, 14] |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
[1, 0] | [1, 1] | [1, 2] | [1, 3] | [1, 4] | [1, 5] | [1, 6] | [1, 7] | [1, 8] | [1, 9] | [1, 10] | [1, 11] | [1, 12] | [1, 13] | [1, 14] |
这显然是一种浪费,因为 [ 0 , 1 ] . . . [ 0 , 13 ] [0,1] ... [0,13] [0,1]...[0,13] 包括 [ 1 , 1 ] . . . [ 1 , 13 ] [1,1] ... [1,13] [1,1]...[1,13] 这些数据虽然读入了缓存,却没有及时用上,而缓存的大小是有限的,等执行到第九次内循环时
缓存的第一行数据已经被新的数据 [ 8 , 0 ] . . . [ 8 , 13 ] [8,0] ... [8,13] [8,0]...[8,13] 覆盖掉了,以后如果再想读,比如 [ 0 , 1 ] [0,1] [0,1],又得到内存去读了
同理可以分析 ij 函数则能充分利用局部性原理加载到的缓存数据
举一反三
-
I/O 读写时同样可以体现局部性原理
-
数组可以充分利用局部性原理,那么链表呢?
答:链表不行,因为链表的元素并非相邻存储
4、链表
链表是数据元素的线性集合,其每个元素都指向下一个元素,元素存储上并不连续
- 单向链表,每个元素只知道其下一个元素是谁
- 双向链表,每个元素知道其上一个元素和下一个元素
- 循环链表,通常的链表尾节点 tail 指向的都是 null,而循环链表的 尾节点tail 指向的是头节点 head
随机访问性能
根据 index 查找,时间复杂度 O ( n ) O(n) O(n)
插入或删除性能
- 起始位置: O ( 1 ) O(1) O(1)
- 结束位置:如果已知 tail 尾节点是 O ( 1 ) O(1) O(1),不知道 tail 尾节点是 O ( n ) O(n) O(n)
- 中间位置:根据 index 查找时间 + O ( 1 ) O(1) O(1)
5、递归
比如单链表递归遍历的例子:
void f(Node node) {
if(node == null) {
return;
}
println("before:" + node.value)
f(node.next);
println("after:" + node.value)
}
说明:
- 自己调用自己,如果说每个函数对应着一种解决方案,自己调用自己意味着解决方案是一样的(有规律的)
- 每次调用,函数处理的数据会较上次缩减(子集),而且最后会缩减至无需继续递归
- 内层函数调用(子集处理)完成,外层函数才能算调用完成
原理
假设链表中有 3 个节点,value 分别为 1,2,3,以上代码的执行流程就类似于下面的伪码
// 1 -> 2 -> 3 -> null f(1)
void f(Node node = 1) {
println("before:" + node.value) // 1
void f(Node node = 2) {
println("before:" + node.value) // 2
void f(Node node = 3) {
println("before:" + node.value) // 3
void f(Node node = null) {
if(node == null) {
return;
}
}
println("after:" + node.value) // 3
}
println("after:" + node.value) // 2
}
println("after:" + node.value) // 1
}
思路
- 确定能否使用递归求解
- 推导出递推关系,即父问题与子问题的关系,以及递归的结束条件
例如之前遍历链表的递推关系为
f
(
n
)
=
{
停止
n
=
n
u
l
l
f
(
n
.
n
e
x
t
)
n
≠
n
u
l
l
f(n) = \begin{cases} 停止& n = null \\ f(n.next) & n \neq null \end{cases}
f(n)={停止f(n.next)n=nulln=null
- 深入到最里层叫做递
- 从最里层出来叫做归
- 在递的过程中,外层函数内的局部变量(以及方法参数)并未消失,归的时候还可以用到
5.1、单路递归
E01. 阶乘
用递归方法求阶乘
-
阶乘的定义 n ! = 1 ⋅ 2 ⋅ 3 ⋯ ( n − 2 ) ⋅ ( n − 1 ) ⋅ n n!= 1⋅2⋅3⋯(n-2)⋅(n-1)⋅n n!=1⋅2⋅3⋯(n−2)⋅(n−1)⋅n,其中 n n n 为自然数,当然 0 ! = 1 0! = 1 0!=1
-
递推关系
f ( n ) = { 1 n = 1 n ∗ f ( n − 1 ) n > 1 f(n) = \begin{cases} 1 & n = 1\\ n * f(n-1) & n > 1 \end{cases} f(n)={1n∗f(n−1)n=1n>1
代码
private static int f(int n) {
if (n == 1) {
return 1;
}
return n * f(n - 1);
}
拆解伪码如下,假设 n 初始值为 3
f(int n = 3) { // 解决不了,递
return 3 * f(int n = 2) { // 解决不了,继续递
return 2 * f(int n = 1) {
if (n == 1) { // 可以解决, 开始归
return 1;
}
}
}
}
E02. 反向打印字符串
用递归反向打印字符串,n 为字符在整个字符串 str 中的索引位置
- 递:n 从 0 开始,每次 n + 1,一直递到 n == str.length() - 1
- 归:从 n == str.length() 开始归,从归打印,自然是逆序的
递推关系
f
(
n
)
=
{
停止
n
=
s
t
r
.
l
e
n
g
t
h
(
)
f
(
n
+
1
)
0
≤
n
≤
s
t
r
.
l
e
n
g
t
h
(
)
−
1
f(n) = \begin{cases} 停止 & n = str.length() \\ f(n+1) & 0 \leq n \leq str.length() - 1 \end{cases}
f(n)={停止f(n+1)n=str.length()0≤n≤str.length()−1
代码为
public class Application {
public static void main(String[] args) {
String str = "123456789";
reversePrint(str, 0);
}
private static void reversePrint(String str, int i) {
if (i == str.length()) {
return;
}
reversePrint(str, i + 1);
System.out.println(str.charAt(i));
}
}
输出
9
8
7
6
5
4
3
2
1
拆解伪码如下,假设字符串为 “abc”
void reversePrint(String str, int index = 0) {
void reversePrint(String str, int index = 1) {
void reversePrint(String str, int index = 2) {
void reversePrint(String str, int index = 3) {
if (index == str.length()) {
return; // 开始归
}
}
System.out.println(str.charAt(index)); // 打印 c
}
System.out.println(str.charAt(index)); // 打印 b
}
System.out.println(str.charAt(index)); // 打印 a
}
E03. 通过递归实现二分查找
用递归实现二分查找,每次二分查找相同的四个参数 (数组、目标值、起始索引、结束索引)
代码实现
代码实现
代码实现
public static void main(String[] args) {
// 递归版本的二分查找
// 每次查找的四个参数 (数组、目标值、起始索引、结束索引)
int[] a = new int[]{1, 3, 4, 5, 6, 7, 8, 9};
System.out.println(f(a, 5, 0, a.length - 1));
}
private static int f(int[] a, int target, int i, int j) {
if (j < i) {
return -1;
}
int mid = (i + j) >>> 1;
if (target < a[mid]) {
return f(a, target, i, mid - 1);
} else if (a[mid] < target) {
return f(a, target, mid + 1, j);
} else {
return mid;
}
}
E04. 通过递归实现冒泡排序
将数组分成两部分[0, j] [j, a.length - 1]
左边[0, j]是未排序的部分
右边[j, a.length - 1]是已排序不分
未排序区间内,相邻的两个元素比较,如果前一个大于后一个,则交换位置
代码实现 代码实现 代码实现
public static void main(String[] args) {
int[] a = new int[]{5, 4, 6, 3, 2, 1};
f(a, a.length - 1);
System.out.println(Arrays.toString(a));
}
// j 代表未排序区域右边界
private static void f(int[] a, int j) {
if (j == 0) {
return;
}
// x:充当未排序和已排序的分界点,如果发生交换才会更改x的值
int x = 0;
for (int i = 0; i < j; i++) {
if (a[i] > a[i + 1]) {
int temp = a[i];
a[i] = a[i + 1];
a[i + 1] = temp;
x = i;
}
}
f(a, x);
}
E05. 通过递归实现插入排序
递归函数 将low位置的元素插入到[0, low-1]的已排序区域
代码实现 代码实现 代码实现
public static void main(String[] args) {
int[] ints = {5, 4, 3, 2, 1};
insertion(ints, 1);
System.out.println(Arrays.toString(ints));
}
/**
* 将low位置的元素插入到[0, low-1]的已排序区域
*
* @param ints
* @param low 未排序区域的左边界
*/
private static void insertion(int[] ints, int low) {
if (low == ints.length) {
return;
}
int t = ints[low];
int i = low - 1;
while (0 <= i && ints[i] > t){
// ji lu cha ru wei zhi
ints[i + 1] = ints[i];
i--;
}
ints[i + 1] = t;
insertion(ints, low + 1);
}
5.2、多路递归
- 之前的例子是每个递归函数只包含一个自身的调用,这称之为 single recursion
- 如果每个递归函数例包含多个自身调用,称之为 multi recursion
递推关系
f
(
n
)
=
{
0
n
=
0
1
n
=
1
f
(
n
−
1
)
+
f
(
n
−
2
)
n
>
1
f(n) = \begin{cases} 0 & n=0 \\ 1 & n=1 \\ f(n-1) + f(n-2) & n>1 \end{cases}
f(n)=⎩
⎨
⎧01f(n−1)+f(n−2)n=0n=1n>1
E01. 斐波那契数列
下面的表格列出了数列的前几项
F0 | F1 | F2 | F3 | F4 | F5 | F6 | F7 | F8 | F9 | F10 | F11 | F12 | F13 |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|
0 | 1 | 1 | 2 | 3 | 5 | 8 | 13 | 21 | 34 | 55 | 89 | 144 | 233 |
实现
public static int f(int n) {
if (n == 0) {
return 0;
}
if (n == 1) {
return 1;
}
return f(n - 1) + f(n - 2);
}
时间复杂度
- 递归的次数也符合斐波那契规律, 2 ∗ f ( n + 1 ) − 1 2 * f(n+1)-1 2∗f(n+1)−1
- 时间复杂度推导过程
- 斐波那契通项公式 f ( n ) = 1 5 ∗ ( 1 + 5 2 n − 1 − 5 2 n ) f(n) = \frac{1}{\sqrt{5}}*({\frac{1+\sqrt{5}}{2}}^n - {\frac{1-\sqrt{5}}{2}}^n) f(n)=51∗(21+5n−21−5n)
- 简化为: f ( n ) = 1 2.236 ∗ ( 1.618 n − ( − 0.618 ) n ) f(n) = \frac{1}{2.236}*({1.618}^n - {(-0.618)}^n) f(n)=2.2361∗(1.618n−(−0.618)n)
- 带入递归次数公式 2 ∗ 1 2.236 ∗ ( 1.618 n + 1 − ( − 0.618 ) n + 1 ) − 1 2*\frac{1}{2.236}*({1.618}^{n+1} - {(-0.618)}^{n+1})-1 2∗2.2361∗(1.618n+1−(−0.618)n+1)−1
- 时间复杂度为 Θ ( 1.61 8 n ) \Theta(1.618^n) Θ(1.618n)
变体1 - 兔子问题[^8]
- 第一个月,有一对未成熟的兔子(黑色,注意图中个头较小)
- 第二个月,它们成熟
- 第三个月,它们能产下一对新的小兔子(蓝色)
- 所有兔子遵循相同规律,求第 n n n 个月的兔子数
分析
兔子问题如何与斐波那契联系起来呢?设第 n 个月兔子数为 f ( n ) f(n) f(n)
- f ( n ) f(n) f(n) = 上个月兔子数 + 新生的小兔子数
- 而【新生的小兔子数】实际就是【上个月成熟的兔子数】
- 因为需要一个月兔子就成熟,所以【上个月成熟的兔子数】也就是【上上个月的兔子数】
- 上个月兔子数,即 f ( n − 1 ) f(n-1) f(n−1)
- 上上个月的兔子数,即 f ( n − 2 ) f(n-2) f(n−2)
因此本质还是斐波那契数列,只是从其第一项开始
变体2 - 青蛙爬楼梯
- 楼梯有 n n n 阶
- 青蛙要爬到楼顶,可以一次跳一阶,也可以一次跳两阶
- 只能向上跳,问有多少种跳法
分析
n | 跳法 | 规律 |
---|---|---|
1 | (1) | 暂时看不出 |
2 | (1,1) (2) | 暂时看不出 |
3 | (1,1,1) (1,2) (2,1) | 暂时看不出 |
4 | (1,1,1,1) (1,2,1) (2,1,1) (1,1,2) (2,2) | 最后一跳,跳一个台阶的,基于f(3) 最后一跳,跳两个台阶的,基于f(2) |
5 | … | … |
-
因此本质上还是斐波那契数列,只是从其第二项开始
-
对应 leetcode 题目 70. 爬楼梯 - 力扣(LeetCode)
优化斐波那契数列(记忆法)
通过一个数组,记录每次返回的结果,如果集合中对应的位置有值,不需要再次计算。
代码
代码
代码
public static void main(String[] args) {
int[] ints = new int[11];
Arrays.fill(ints, -1); // [-1,-1,-1,-1,-1,-1,-1,-1,-1,-1]
ints[0] = 0;
ints[1] = 1; // [0,1,-1,-1,-1,-1,-1,-1,-1,-1]
System.out.println(f(10, ints));
}
private static int f(int n, int[] a){
/*if (n == 0) {
return 0;
}
if (n == 1) {
return 1;
}*/
if (a[n] != -1) {
return a[n];
}
a[n] = f(n - 1, a) + f(n - 2, a);
return a[n];
}
递归求和n+n-1,…+1
递归算法
递归算法
递归算法
f
(
n
)
=
{
n
+
f
(
n
−
1
)
f(n) = \begin{cases} n + f(n-1) \end{cases}
f(n)={n+f(n−1)
调用次数太多的话,会导致栈内存溢出
E02. 汉诺塔
6、队列
计算机科学中,queue 是以顺序的方式维护的一组数据集合,在一端添加数据,从另一端移除数据。习惯来说,添加的一端称为尾,移除的一端称为头,就如同生活中的排队买商品
先定义一个简化的队列接口
public interface Queue<E> {
/**
* 向队列尾插入值
* @param value 待插入值
* @return 插入成功返回 true, 插入失败返回 false
*/
boolean offer(E value);
/**
* 从对列头获取值, 并移除
* @return 如果队列非空返回对头值, 否则返回 null
*/
E poll();
/**
* 从对列头获取值, 不移除
* @return 如果队列非空返回对头值, 否则返回 null
*/
E peek();
/**
* 检查队列是否为空
* @return 空返回 true, 否则返回 false
*/
boolean isEmpty();
/**
* 检查队列是否已满
* @return 满返回 true, 否则返回 false
*/
boolean isFull();
}
7、栈
计算机科学中,stack 是一种线性的数据结构,只能在其一端添加数据和移除数据。习惯来说,这一端称之为栈顶,另一端不能操作数据的称之为栈底,就如同生活中的一摞书
先提供一个栈接口
public interface Stack<E> {
/**
* 向栈顶压入元素
* @param value 待压入值
* @return 压入成功返回 true, 否则返回 false
*/
boolean push(E value);
/**
* 从栈顶弹出元素
* @return 栈非空返回栈顶元素, 栈为空返回 null
*/
E pop();
/**
* 返回栈顶元素, 不弹出
* @return 栈非空返回栈顶元素, 栈为空返回 null
*/
E peek();
/**
* 判断栈是否为空
* @return 空返回 true, 否则返回 false
*/
boolean isEmpty();
/**
* 判断栈是否已满
* @return 满返回 true, 否则返回 false
*/
boolean isFull();
}
7、常见查找算法
7.1、线性查找
线性查找也称为顺序查找,是一种最简单的查找算法。在这种算法中,我们从数据集的开头开始,逐个比较每个数据项,以寻找要查找的数据。如果我们找到了目标数据,查找过程就结束了。如果我们到达数据集的末尾,仍然找不到目标数据,则可以认为它不存在于数据集中。
线性查找的时间复杂度是O(n),其中n是数据集的大小。因此,它在大型数据集中可能会很慢。然而,在小型数据集中,它仍然是一种非常有用的算法。
7.2、二分查找
二分查找也称为折半查找,是一种更快速的查找算法。但前提是,数据集必须已经排序。在二分查找中,我们取数据集的中间值,然后将目标与中间值进行比较。如果目标小于中间值,则在左侧子集中继续查找;如果目标大于中间值,则在右侧子集中继续查找。每次比较都会缩小要搜索的数据集的大小。
二分查找的时间复杂度是O(log n),其中n是数据集的大小。这种算法在大型数据集中非常有效,但在小型数据集中可能并不是最快的选择。
7.3、哈希表查找
哈希表查找也称为散列表查找,是另一种常见的查找算法。它利用哈希函数将数据项映射到散列表中的位置。在查找过程中,我们只需通过哈希函数计算目标数据的位置,然后检查该位置是否包含目标数据。
哈希表查找的时间复杂度是O(1)。这使得它成为大型数据集中最快的查找算法之一。但是,哈希表查找的效率取决于哈希函数的质量。如果两个数据项映射到相同的位置,就会发生哈希冲突,这可能会导致性能下降。
小结
在编写程序时,我们需要选择适合数据集大小和其他要求的最佳查找算法。例如,如果数据集很小,则线性查找可能是最快的选择;如果数据集已经排序,则二分查找是非常有用的。然而,在大型数据集中,哈希表查找通常是最好的选择。了解不同类型的查找算法及其特点可以帮助我们在编写程序时做出明智的选择。
8、高效查找相关数据结构
8.1、二叉搜索树
二叉搜索树(也称二叉排序树)是符合下面特征的二叉树:
- 树节点增加 key 属性,用来比较谁大谁小,key 不可以重复
- 对于任意一个树节点,它的 key 比左子树的 key 都大,同时也比右子树的 key 都小,例如下图所示
轻易看出要查找 7 (从根开始)自然就可应用二分查找算法,只需三次比较
- 与 4 比,较之大,向右找
- 与 6 比,较之大,继续向右找
- 与 7 比,找到
查找的时间复杂度与树高相关,插入、删除也是如此。
- 如果这棵树长得还不赖(左右平衡)上图,那么时间复杂度均是 O ( log N ) O(\log{N}) O(logN)
- 当然,这棵树如果长得丑(左右高度相差过大,树的倾斜)下图,那么这时是最糟的情况,时间复杂度是 O ( N ) O(N) O(N)
注:
- 二叉搜索树 - 英文 binary search tree,简称 BST
- 二叉排序树 - 英文 binary ordered tree 或 binary sorted tree
小结
优点:
- 如果每个节点的左子树和右子树的大小差距不超过一,可以保证搜索操作的时间复杂度是 O(log n),效率高。
- 插入、删除结点等操作也比较容易实现,效率也比较高。
- 对于有序数据的查询和处理,二叉查找树非常适用,可以使用中序遍历得到有序序列。
缺点:
- 如果输入的数据是有序或者近似有序的,就会出现极度不平衡的情况,可能导致搜索效率下降,时间复杂度退化成O(n)。
- 对于频繁地插入、删除操作,需要维护平衡二叉查找树,例如红黑树、AVL 树等,否则搜索效率也会下降。
- 对于存在大量重复数据的情况,需要做相应的处理,否则会导致树的深度增加,搜索效率下降。
- 对于结点过多的情况,由于树的空间开销较大,可能导致内存消耗过大,不适合对内存要求高的场景。
8.2、AVL 树
历史 历史 历史
AVL 树是一种自平衡二叉搜索树,由托尔·哈斯特罗姆在 1960 年提出并在 1962 年发表。它的名字来源于发明者的名字:Adelson-Velsky 和 Landis,他们是苏联数学家,于 1962 年发表了一篇论文,详细介绍了 AVL 树的概念和性质。
在二叉搜索树中,如果插入的元素按照特定的顺序排列,可能会导致树变得非常不平衡,从而降低搜索、插入和删除的效率。为了解决这个问题,AVL 树通过在每个节点中维护一个平衡因子来确保树的平衡。平衡因子是左子树的高度减去右子树的高度。如果平衡因子的绝对值大于等于 2,则通过旋转操作来重新平衡树。
AVL 树是用于存储有序数据的一种重要数据结构,它是二叉搜索树的一种改进和扩展。它不仅能够提高搜索、插入和删除操作的效率,而且还能够确保树的深度始终保持在 O(log n) 的水平。随着计算机技术的不断发展,AVL 树已经成为了许多高效算法和系统中必不可少的一种基础数据结构。
前面介绍过,如果一棵二叉搜索树长的不平衡,那么查询的效率会受到影响,如下图
通过旋转可以让树重新变得平衡,并且不会改变二叉搜索树的性质(即左边仍然小,右边仍然大)
如何判断失衡?
如果一个节点的左右孩子,高度差超过 1,则此节点失衡,才需要旋转
失衡的四种情况
LL
- 失衡节点(图中 8 红色)的 bf > 1,即左边更高
- 失衡节点的左孩子(图中 6)的 bf >= 0 即左孩子这边也是左边更高或等高
LR
- 失衡节点(图中 8)的 bf > 1,即左边更高
- 失衡节点的左孩子(图中 6 红色)的 bf < 0 即左孩子这边是右边更高
RL
- 失衡节点(图中 3)的 bf <-1,即右边更高
- 失衡节点的右孩子(图中 6 红色)的 bf > 0,即右孩子这边左边更高
RR
- 失衡节点(图中 3)的 bf <-1,即右边更高
- 失衡节点的右孩子(图中 6 红色)的 bf <= 0,即右孩子这边右边更高或等高
解决失衡
失衡可以通过树的旋转解决。什么是树的旋转呢?它是在不干扰元素顺序的情况下更改结构,通常用来让树的高度变得平衡。
观察下面一棵二叉搜索树,可以看到,旋转后,并未改变树的左小右大特性,但根、父、孩子节点都发生了变化
4 2
/ \ 4 right / \
2 5 --------------------> 1 4
/ \ <-------------------- / \
1 3 2 left 3 5
小结
AVL树的优点:
- AVL树是一种自平衡树,保证了树的高度平衡,从而保证了树的查询和插入操作的时间复杂度均为O(logn)。
- 相比于一般二叉搜索树,AVL树对查询效率的提升更为显著,因为其左右子树高度的差值不会超过1,避免了二叉搜索树退化为链表的情况,使得整棵树的高度更低。
- AVL树的删除操作比较简单,只需要像插入一样旋转即可,在旋转过程中树的平衡性可以得到维护。
AVL树的缺点:
- AVL树每次插入或删除节点时需要进行旋转操作,这个操作比较耗时,因此在一些应用中不太适用。
- 在AVL树进行插入或删除操作时,为保持树的平衡需要不断进行旋转操作,在一些高并发环节和大数据量环境下,这可能会导致多余的写锁导致性能瓶颈。
- AVL树的旋转操作相对较多,因此在一些应用中可能会造成较大的空间浪费。
8.3、红黑树
历史
红黑树是一种自平衡二叉查找树,最早由一位名叫Rudolf Bayer的德国计算机科学家于1972年发明。然而,最初的树形结构不是现在的红黑树,而是一种称为B树的结构,它是一种多叉树,可用于在磁盘上存储大量数据。
在1980年代早期,计算机科学家Leonard Adleman和Daniel Sleator推广了红黑树,并证明了它的自平衡性和高效性。从那时起,红黑树成为了最流行的自平衡二叉查找树之一,并被广泛应用于许多领域,如编译器、操作系统、数据库等。
红黑树的名字来源于红色节点和黑色节点的交替出现,它们的颜色是用来维护树的平衡性的关键。它们的颜色具有特殊的意义,黑色节点代表普通节点,而红色节点代表一个新添加的节点,它们必须满足一些特定的规则才能维持树的平衡性。
红黑树也是一种自平衡的二叉搜索树,较之 AVL,插入和删除时旋转次数更少
红黑树特性
- 所有节点都有两种颜色:红🔴、黑⚫️
- 所有 null 视为黑色⚫️
- 红色🔴节点不能相邻
- 根节点是黑色⚫️
- 从根到任意一个叶子节点,路径中的黑色⚫️节点数一样
小结
维度 | 普通二叉搜索树 | AVL树 | 红黑树 |
---|---|---|---|
查询 | 平均O(logn),最坏O(n) | O(logn) | O(logn) |
插入 | 平均O(logn),最坏O(n) | O(logn) | O(logn) |
删除 | 平均O(logn),最坏O(n) | O(logn) | O(logn) |
平衡性 | 不平衡 | 严格平衡 | 近似平衡 |
结构 | 二叉树 | 自平衡的二叉树 | 具有红黑性质的自平衡二叉树 |
查找效率 | 低 | 高 | 高 |
插入删除效率 | 低 | 中等 | 高 |
普通二叉搜索树插入、删除、查询的时间复杂度与树的高度相关,因此在最坏情况下,时间复杂度为O(n),而且容易退化成链表,查找效率低。
AVL树是一种高度平衡的二叉搜索树,其左右子树的高度差不超过1。因此,它能够在logn的平均时间内完成插入、删除、查询操作,但是在维护平衡的过程中,需要频繁地进行旋转操作,导致插入删除效率较低。
红黑树是一种近似平衡的二叉搜索树,它在保持高度平衡的同时,又能够保持较高的插入删除效率。红黑树通过节点着色和旋转操作来维护平衡。红黑树在维护平衡的过程中,能够进行较少的节点旋转操作,因此插入删除效率较高,并且查询效率也较高。
综上所述,红黑树具有较高的综合性能,是一种广泛应用的数据结构。
8.4、B树
历史
B树(B-Tree)结构是一种高效存储和查询数据的方法,它的历史可以追溯到1970年代早期。B树的发明人Rudolf Bayer和Edward M. McCreight分别发表了一篇论文介绍了B树。这篇论文是1972年发表于《ACM Transactions on Database Systems》中的,题目为"Organization and Maintenance of Large Ordered Indexes"。
这篇论文提出了一种能够高效地维护大型有序索引的方法,这种方法的主要思想是将每个节点扩展成多个子节点,以减少查找所需的次数。B树结构非常适合应用于磁盘等大型存储器的高效操作,被广泛应用于关系数据库和文件系统中。
B树结构有很多变种和升级版,例如B+树,B*树和SB树等。这些变种和升级版本都基于B树的核心思想,通过调整B树的参数和结构,提高了B树在不同场景下的性能表现。
总的来说,B树结构是一个非常重要的数据结构,为高效存储和查询大量数据提供了可靠的方法。它的历史可以追溯到上个世纪70年代,而且在今天仍然被广泛应用于各种场景。
8.5、哈希表
思考
- 我们的代码里使用了尾插法,如果改成头插法呢?
- JDK 的 HashMap 中采用了将对象 hashCode 高低位相互异或的方式减少冲突,怎么理解
- 我们的 HashTable 中表格容量是 2 的 n 次方,很多优化都是基于这个前提,能否不用 2 的 n 次方作为表格容量?
- JDK 的 HashMap 在链表长度过长会转换成红黑树,对此你怎么看