一. 初识算法
1.1 什么是算法?
定义
在数学和计算机科学领域,算法是一系列有限的严谨指令,通常用于解决一类特定问题或执行计算
In mathematics and computer science, an algorithm (/ˈælɡərɪðəm/) is a finite sequence of rigorous instructions, typically used to solve a class of specific problems or to perform a computation.[^1]
Introduction to Algorithm[^2]
不正式的说,算法就是任何定义优良的计算过程:接收一些值作为输入,在有限的时间内,产生一些值作为输出。
Informally, an algorithm is any well-defined computational procedure that takes some value, or set of values, as input and produces some value, or set of values, as output in a finite amount of time.
1.2 什么是数据结构?
定义
在计算机科学领域,数据结构是一种数据组织、管理和存储格式,通常被选择用来高效访问数据
In computer science, a data structure is a data organization, management, and storage format that is usually chosen for efficient access to data
Introduction to Algorithm[^2]
数据结构是一种存储和组织数据的方式,旨在便于访问和修改
A data structure is a way to store and organize data in order to facilitate access and modifications
可以说,程序 = 数据结构 + 算法,它们是每一位程序员的基本功,下来我们通过对一个非常著名的二分查找算法的讲解来认识一下算法
1.3 二分查找 [^3]
二分查找算法也称折半查找,是一种非常高效的工作于有序数组的查找算法。后续的课程中还会学习更多的查找算法,但在此之前,不妨用它作为入门。
1) 基础版
需求:在有序数组 A A A 内,查找值 t a r g e t target target
- 如果找到返回索引
- 如果找不到返回 − 1 -1 −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 |
1 | 设置 i = 0 i=0 i=0, j = n − 1 j=n-1 j=n−1 |
2 | 如果 i > j i \gt j i>j,结束查找,没找到 |
3 | 设置 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 的最小整数) |
4 | 如果 t a r g e t < A m target < A_{m} target<Am 设置 j = m − 1 j = m - 1 j=m−1,跳到第2步 |
5 | 如果 A m < t a r g e t A_{m} < target Am<target 设置 i = m + 1 i = m + 1 i=m+1,跳到第2步 |
6 | 如果 A m = t a r g e t A_{m} = target Am=target,结束查找,找到了 |
P.S.
- 对于一个算法来讲,都有较为严谨的描述,上面是一个例子
- 后续讲解时,以简明直白为目标,不会总以上面的方式来描述算法
java 实现
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;
}
-
i
,
j
i,j
i,j 对应着搜索区间
[
0
,
a
.
l
e
n
g
t
h
−
1
]
[0,a.length-1]
[0,a.length−1](注意是闭合的区间),
i
<
=
j
i<=j
i<=j 意味着搜索区间内还有未比较的元素,
i
,
j
i,j
i,j 指向的元素也可能是比较的目标
- 思考:如果不加 i = = j i==j i==j 行不行?
- 回答:不行,因为这意味着 i , j i,j i,j 指向的元素会漏过比较
- m m m 对应着中间位置,中间位置左边和右边的元素可能不相等(差一个),不会影响结果
- 如果某次未找到,那么缩小后的区间内不包含 m m m
-
2) 改变版
另一种写法
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;
}
-
i
,
j
i,j
i,j 对应着搜索区间
[
0
,
a
.
l
e
n
g
t
h
)
[0,a.length)
[0,a.length)(注意是左闭右开的区间),
i
<
j
i<j
i<j 意味着搜索区间内还有未比较的元素,
j
j
j 指向的一定不是查找目标
- 思考:为啥这次不加 i = = j i==j i==j 的条件了?
- 回答:这回 j j j 指向的不是查找目标,如果还加 i = = j i==j i==j 条件,就意味着 j j j 指向的还会再次比较,找不到时,会死循环
- 如果某次要缩小右边界,那么 j = m j=m j=m,因为此时的 m m m 已经不是查找目标了
1.4 衡量算法好坏
时间复杂度
下面的查找算法也能得出与之前二分查找一样的结果,那你能说出它差在哪里吗?
public static int search(int[] a, int k) {
for (
int i = 0;
i < a.length;
i++
) {
if (a[i] == k) {
return i;
}
}
return -1;
}
考虑最坏情况下(没找到)例如 [1,2,3,4]
查找 5
int i = 0
只执行一次i < a.length
受数组元素个数 n n n 的影响,比较 n + 1 n+1 n+1 次i++
受数组元素个数 n n n 的影响,自增 n n n 次a[i] == k
受元素个数 n n n 的影响,比较 n n n 次return -1
,执行一次
粗略认为每行代码执行时间是 t t t,假设 n = 4 n=4 n=4 那么
- 总执行时间是 ( 1 + 4 + 1 + 4 + 4 + 1 ) ∗ t = 15 t (1+4+1+4+4+1)*t = 15t (1+4+1+4+4+1)∗t=15t
- 可以推导出更一般地公式为, T = ( 3 ∗ n + 3 ) t T = (3*n+3)t T=(3∗n+3)t
如果套用二分查找算法,还是 [1,2,3,4]
查找 5
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;
}
-
int i = 0, j = a.length - 1
各执行 1 次 -
i <= j
比较 f l o o r ( log 2 ( n ) + 1 ) floor(\log_{2}(n)+1) floor(log2(n)+1) 再加 1 次 -
(i + j) >>> 1
计算 f l o o r ( log 2 ( n ) + 1 ) floor(\log_{2}(n)+1) floor(log2(n)+1) 次 -
接下来
if() else if() else
会执行 3 ∗ f l o o r ( log 2 ( n ) + 1 ) 3* floor(\log_{2}(n)+1) 3∗floor(log2(n)+1) 次,分别为- if 比较
- else if 比较
- else if 比较成立后的赋值语句
-
return -1
,执行一次
结果:
- 总执行时间为 ( 2 + ( 1 + 3 ) + 3 + 3 ∗ 3 + 1 ) ∗ t = 19 t (2 + (1+3) + 3 + 3 * 3 +1)*t = 19t (2+(1+3)+3+3∗3+1)∗t=19t
- 更一般地公式为 ( 4 + 5 ∗ f l o o r ( log 2 ( n ) + 1 ) ) ∗ t (4 + 5 * floor(\log_{2}(n)+1))*t (4+5∗floor(log2(n)+1))∗t
注意:
左侧未找到和右侧未找到结果不一样,这里不做分析
两个算法比较,可以看到 n n n 在较小的时候,二者花费的次数差不多
![image-20221108095747933](.\imgs\image-20221108095747933.png)
但随着 n n n 越来越大,比如说 n = 1000 n=1000 n=1000 时,用二分查找算法(红色)也就是 54 t 54t 54t,而蓝色算法则需要 3003 t 3003t 3003t
![image-20221108100014451](.\imgs\image-20221108100014451.png)
画图采用的是 Desmos | 图形计算器
计算机科学中,时间复杂度是用来衡量:一个算法的执行,随数据规模增大,而增长的时间成本
- 不依赖于环境因素
如何表示时间复杂度呢?
-
假设算法要处理的数据规模是 n n n,代码总的执行行数用函数 f ( n ) f(n) f(n) 来表示,例如:
- 线性查找算法的函数 f ( n ) = 3 ∗ n + 3 f(n) = 3*n + 3 f(n)=3∗n+3
- 二分查找算法的函数 f ( n ) = ( f l o o r ( l o g 2 ( n ) ) + 1 ) ∗ 5 + 4 f(n) = (floor(log_2(n)) + 1) * 5 + 4 f(n)=(floor(log2(n))+1)∗5+4
-
为了对 f ( n ) f(n) f(n) 进行化简,应当抓住主要矛盾,找到一个变化趋势与之相近的表示法
大 O O O 表示法[^4]
其中
- c , c 1 , c 2 c, c_1, c_2 c,c1,c2 都为一个常数
- f ( n ) f(n) f(n) 是实际执行代码行数与 n 的函数
- g ( n ) g(n) g(n) 是经过化简,变化趋势与 f ( n ) f(n) f(n) 一致的 n 的函数
渐进上界
渐进上界(asymptotic upper bound):从某个常数 n 0 n_0 n0开始, c ∗ g ( n ) c*g(n) c∗g(n) 总是位于 f ( n ) f(n) f(n) 上方,那么记作 O ( g ( n ) ) O(g(n)) O(g(n))
- 代表算法执行的最差情况
例1
- f ( n ) = 3 ∗ n + 3 f(n) = 3*n+3 f(n)=3∗n+3
- g ( n ) = n g(n) = n g(n)=n
- 取 c = 4 c=4 c=4,在 n 0 = 3 n_0=3 n0=3 之后, g ( n ) g(n) g(n) 可以作为 f ( n ) f(n) f(n) 的渐进上界,因此表示法写作 O ( n ) O(n) O(n)
例2
- f ( n ) = 5 ∗ f l o o r ( l o g 2 ( n ) ) + 9 f(n) = 5*floor(log_2(n)) + 9 f(n)=5∗floor(log2(n))+9
- g ( n ) = l o g 2 ( n ) g(n) = log_2(n) g(n)=log2(n)
- O ( l o g 2 ( n ) ) O(log_2(n)) O(log2(n))
已知 f ( n ) f(n) f(n) 来说,求 g ( n ) g(n) g(n)
- 表达式中相乘的常量,可以省略,如
- f ( n ) = 100 ∗ n 2 f(n) = 100*n^2 f(n)=100∗n2 中的 100 100 100
- 多项式中数量规模更小(低次项)的表达式,如
- f ( n ) = n 2 + n f(n)=n^2+n f(n)=n2+n 中的 n n n
- f ( n ) = n 3 + n 2 f(n) = n^3 + n^2 f(n)=n3+n2 中的 n 2 n^2 n2
- 不同底数的对数,渐进上界可以用一个对数函数
log
n
\log n
logn 表示
- 例如: l o g 2 ( n ) log_2(n) log2(n) 可以替换为 l o g 10 ( n ) log_{10}(n) log10(n),因为 l o g 2 ( n ) = l o g 10 ( n ) l o g 10 ( 2 ) log_2(n) = \frac{log_{10}(n)}{log_{10}(2)} log2(n)=log10(2)log10(n),相乘的常量 1 l o g 10 ( 2 ) \frac{1}{log_{10}(2)} log10(2)1 可以省略
- 类似的,对数的常数次幂可省略
- 如: l o g ( n c ) = c ∗ l o g ( n ) log(n^c) = c * log(n) log(nc)=c∗log(n)
常见大 O O O 表示法
按时间复杂度从低到高
- 黑色横线 O ( 1 ) O(1) O(1),常量时间,意味着算法时间并不随数据规模而变化
- 绿色 O ( l o g ( n ) ) O(log(n)) O(log(n)),对数时间
- 蓝色 O ( n ) O(n) O(n),线性时间,算法时间与数据规模成正比
- 橙色 O ( n ∗ l o g ( n ) ) O(n*log(n)) O(n∗log(n)),拟线性时间
- 红色 O ( n 2 ) O(n^2) O(n2) 平方时间
- 黑色朝上 O ( 2 n ) O(2^n) O(2n) 指数时间
- 没画出来的 O ( n ! ) O(n!) O(n!)
渐进下界
渐进下界(asymptotic lower bound):从某个常数 n 0 n_0 n0开始, c ∗ g ( n ) c*g(n) c∗g(n) 总是位于 f ( n ) f(n) f(n) 下方,那么记作 Ω ( g ( n ) ) \Omega(g(n)) Ω(g(n))
渐进紧界
渐进紧界(asymptotic tight bounds):从某个常数 n 0 n_0 n0开始, f ( n ) f(n) f(n) 总是在 c 1 ∗ g ( n ) c_1*g(n) c1∗g(n) 和 c 2 ∗ g ( n ) c_2*g(n) c2∗g(n) 之间,那么记作 Θ ( g ( n ) ) \Theta(g(n)) Θ(g(n))
空间复杂度
与时间复杂度类似,一般也使用大 O O O 表示法来衡量:一个算法执行随数据规模增大,而增长的额外空间成本
public static int binarySearchBasic(int[] a, int target) {
int i = 0, j = a.length - 1; // 设置指针和初值
while (i <= j) { // 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;
}
二分查找性能
下面分析二分查找算法的性能
时间复杂度
- 最坏情况: O ( log n ) O(\log n) O(logn)
- 最好情况:如果待查找元素恰好在数组中央,只需要循环一次 O ( 1 ) O(1) O(1)
空间复杂度
- 需要常数个指针 i , j , m i,j,m i,j,m,因此额外占用的空间是 O ( 1 ) O(1) O(1)
1.5 再看二分查找
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;
}
思想:
- 左闭右开的区间, i i i 指向的可能是目标,而 j j j 指向的不是目标
- 不奢望循环内通过
m
m
m 找出目标, 缩小区间直至剩 1 个, 剩下的这个可能就是要找的(通过
i
i
i)
- j − i > 1 j - i > 1 j−i>1 的含义是,在范围内待比较的元素个数 > 1
- 改变 i i i 边界时,它指向的可能是目标,因此不能 m + 1 m+1 m+1
- 循环内的平均比较次数减少了
- 时间复杂度 Θ ( l o g ( n ) ) \Theta(log(n)) Θ(log(n))
2) Java 版
private static int binarySearch0(long[] a, int fromIndex, int toIndex,
long key) {
int low = fromIndex;
int high = toIndex - 1;
while (low <= high) {
int mid = (low + high) >>> 1;
long 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.
}
- 例如
[
1
,
3
,
5
,
6
]
[1,3,5,6]
[1,3,5,6] 要插入
2
2
2 那么就是找到一个位置,这个位置左侧元素都比它小
- 等循环结束,若没找到,low 左侧元素肯定都比 target 小,因此 low 即插入点
- 插入点取负是为了与找到情况区分
- -1 是为了把索引 0 位置的插入点与找到的情况进行区分
3) Leftmost 与 Rightmost
有时我们希望返回的是最左侧的重复元素,如果用 Basic 二分查找
-
对于数组 [ 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;
}
- 大于等于中间值,都要向右找
几个名词
范围查询:
- 查询 x < 4 x \lt 4 x<4, 0.. l e f t m o s t ( 4 ) − 1 0 .. leftmost(4) - 1 0..leftmost(4)−1
- 查询 x ≤ 4 x \leq 4 x≤4, 0.. r i g h t m o s t ( 4 ) 0 .. rightmost(4) 0..rightmost(4)
- 查询 4 < x 4 \lt x 4<x,$rightmost(4) + 1 … \infty $
- 查询 4 ≤ x 4 \leq x 4≤x, l e f t m o s t ( 4 ) . . ∞ leftmost(4) .. \infty leftmost(4)..∞
- 查询 4 ≤ x ≤ 7 4 \leq x \leq 7 4≤x≤7, l e f t m o s t ( 4 ) . . r i g h t m o s t ( 7 ) leftmost(4) .. rightmost(7) leftmost(4)..rightmost(7)
- 查询 4 < x < 7 4 \lt x \lt 7 4<x<7, r i g h t m o s t ( 4 ) + 1.. l e f t m o s t ( 7 ) − 1 rightmost(4)+1 .. leftmost(7)-1 rightmost(4)+1..leftmost(7)−1
求排名: l e f t m o s t ( t a r g e t ) + 1 leftmost(target) + 1 leftmost(target)+1
- t a r g e t target target 可以不存在,如: l e f t m o s t ( 5 ) + 1 = 6 leftmost(5)+1 = 6 leftmost(5)+1=6
- t a r g e t target target 也可以存在,如: l e f t m o s t ( 4 ) + 1 = 3 leftmost(4)+1 = 3 leftmost(4)+1=3
求前任(predecessor): l e f t m o s t ( t a r g e t ) − 1 leftmost(target) - 1 leftmost(target)−1
- l e f t m o s t ( 3 ) − 1 = 1 leftmost(3) - 1 = 1 leftmost(3)−1=1,前任 a 1 = 2 a_1 = 2 a1=2
- l e f t m o s t ( 4 ) − 1 = 1 leftmost(4) - 1 = 1 leftmost(4)−1=1,前任 a 1 = 2 a_1 = 2 a1=2
求后任(successor): r i g h t m o s t ( t a r g e t ) + 1 rightmost(target)+1 rightmost(target)+1
- r i g h t m o s t ( 5 ) + 1 = 5 rightmost(5) + 1 = 5 rightmost(5)+1=5,后任 a 5 = 7 a_5 = 7 a5=7
- r i g h t m o s t ( 4 ) + 1 = 5 rightmost(4) + 1 = 5 rightmost(4)+1=5,后任 a 5 = 7 a_5 = 7 a5=7
求最近邻居:
- 前任和后任距离更近者
习题
1) 时间复杂度估算
用函数 f ( n ) f(n) f(n) 表示算法效率与数据规模的关系,假设每次解决问题需要 1 微秒( 1 0 − 6 10^{-6} 10−6 秒),进行估算:
- 如果 f ( n ) = n 2 f(n) = n^2 f(n)=n2 那么 1 秒能解决多少次问题?1 天呢?
- 如果 f ( n ) = l o g 2 ( n ) f(n) = log_2(n) f(n)=log2(n) 那么 1 秒能解决多少次问题?1 天呢?
- 如果 f ( n ) = n ! f(n) = n! f(n)=n! 那么 1 秒能解决多少次问题?1 天呢?
参考解答
- 1秒 1 0 6 = 1000 \sqrt{10^6} = 1000 106=1000 次,1 天 1 0 6 ∗ 3600 ∗ 24 ≈ 293938 \sqrt{10^6 * 3600 * 24} \approx 293938 106∗3600∗24≈293938 次
- 1秒 $2^{1,000,000} $ 次,一天 2 86 , 400 , 000 , 000 2^{86,400,000,000} 286,400,000,000
- 推算如下
- 10 ! = 3 , 628 , 800 10! = 3,628,800 10!=3,628,800 1秒能解决 1 , 000 , 000 1,000,000 1,000,000 次,因此次数为 9 次
- 14 ! = 87 , 178 , 291 , 200 14!=87,178,291,200 14!=87,178,291,200,一天能解决 86 , 400 , 000 , 000 86,400,000,000 86,400,000,000 次,因此次数为 13 次
2) 耗时估算
一台机器对200个单词进行排序花了200秒(使用冒泡排序),那么花费800秒,大概可以对多少个单词进行排序
a. 400
b. 600
c. 800
d. 1600
答案
- a
解释
- 冒泡排序时间复杂度是 O ( N 2 ) O(N^2) O(N2)
- 时间增长 4 倍,而因此能处理的数据量是原来的 4 = 2 \sqrt{4} = 2 4=2 倍
3) E01. 二分查找-Leetcode 704
要点:减而治之,可以用递归或非递归实现
给定一个 n 个元素有序的(升序)整型数组 nums 和一个目标值 target ,写一个函数搜索 nums 中的 target,如果目标值存在返回下标,否则返回 -1
例如
输入: nums = [-1,0,3,5,9,12], target = 9
输出: 4
解释: 9 出现在 nums 中并且下标为 4
输入: nums = [-1,0,3,5,9,12], target = 2
输出: -1
解释: 2 不存在 nums 中因此返回 -1
参考答案:略,可以用讲过的任意一种二分求解
4) E02. 搜索插入位置-Leetcode 35
要点:理解谁代表插入位置
给定一个排序数组和一个目标值
- 在数组中找到目标值,并返回其索引
- 如果目标值不存在于数组中,返回它将会被按顺序插入的位置
例如
输入: nums = [1,3,5,6], target = 5
输出: 2
输入: nums = [1,3,5,6], target = 2
输出: 1
输入: nums = [1,3,5,6], target = 7
输出: 4
参考答案1:用二分查找基础版代码改写,基础版中,找到返回 m,没找到 i 代表插入点,因此有
public int searchInsert(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 i; // 原始 return -1
}
参考答案2:用二分查找平衡版改写,平衡版中
- 如果 target == a[i] 返回 i 表示找到
- 如果 target < a[i],例如 target = 2,a[i] = 3,这时就应该在 i 位置插入 2
- 如果 a[i] < target,例如 a[i] = 3,target = 4,这时就应该在 i+1 位置插入 4
public static int searchInsert(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 (target <= a[i]) ? i : i + 1;
// 原始 (target == a[i]) ? i : -1;
}
参考答案3:用 leftmost 版本解,返回值即为插入位置(并能处理元素重复的情况)
public int searchInsert(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;
}
5) E03. 搜索开始结束位置-Leetcode 34
给你一个按照非递减顺序排列的整数数组 nums,和一个目标值 target。请你找出给定目标值在数组中的开始位置和结束位置。
如果数组中不存在目标值 target,返回 [-1, -1]。
你必须设计并实现时间复杂度为 O(log n) 的算法解决此问题
例如
输入:nums = [5,7,7,8,8,10], target = 8
输出:[3,4]
输入:nums = [5,7,7,8,8,10], target = 6
输出:[-1,-1]
输入:nums = [], target = 0
输出:[-1,-1]
参考答案
public static int left(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 right(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;
}
public static int[] searchRange(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)};
}
}
二. 基础数据结构
2.1 数组
1) 概述
定义
在计算机科学中,数组是由一组元素(值或变量)组成的数据结构,每个元素有至少一个索引或键来标识
In computer science, an array is a data structure consisting of a collection of elements (values or variables), each identified by at least one array index or key
因为数组内的元素是连续存储的,所以数组中元素的地址,可以通过其索引计算出来,例如:
int[] array = {1,2,3,4,5}
知道了数组的数据起始地址 B a s e A d d r e s s BaseAddress BaseAddress,就可以由公式 B a s e A d d r e s s + i ∗ s i z e BaseAddress + i * size BaseAddress+i∗size 计算出索引 i i i 元素的地址
- i i i 即索引,在 Java、C 等语言都是从 0 开始
- s i z e size size 是每个元素占用字节,例如 i n t int int 占 4 4 4, d o u b l e double double 占 8 8 8
小测试
byte[] array = {1,2,3,4,5}
已知 array 的数据的起始地址是 0x7138f94c8,那么元素 3 的地址是什么?
答:0x7138f94c8 + 2 * 1 = 0x7138f94ca
空间占用
Java 中数组结构为
- 8 字节 markword
- 4 字节 class 指针(压缩 class 指针的情况)
- 4 字节 数组大小(决定了数组最大容量是 2 32 2^{32} 232)
- 数组元素 + 对齐字节(java 中所有对象大小都是 8 字节的整数倍[^12],不足的要用对齐字节补足)
例如
int[] array = {1, 2, 3, 4, 5};
的大小为 40 个字节,组成如下
8 + 4 + 4 + 5*4 + 4(alignment)
随机访问性能
即根据索引查找元素,时间复杂度是 O ( 1 ) O(1) O(1)
2) 动态数组
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));
}
}
- 这些方法实现,都简化了 index 的有效性判断,假设输入的 index 都是合法的
插入或删除性能
头部位置,时间复杂度是 O ( n ) O(n) O(n)
中间位置,时间复杂度是 O ( n ) O(n) O(n)
尾部位置,时间复杂度是 O ( 1 ) O(1) O(1)(均摊来说)
3) 二维数组
int[][] array = {
{11, 12, 13, 14, 15},
{21, 22, 23, 24, 25},
{31, 32, 33, 34, 35},
};
内存图如下
![image-20221104114132056](.\imgs\image-20221104114132056.png)
-
二维数组占 32 个字节,其中 array[0],array[1],array[2] 三个元素分别保存了指向三个一维数组的引用
-
三个一维数组各占 40 个字节
-
它们在内层布局上是连续的
更一般的,对一个二维数组 A r r a y [ m ] [ n ] Array[m][n] Array[m][n]
- m m m 是外层数组的长度,可以看作 row 行
- n n n 是内层数组的长度,可以看作 column 列
- 当访问
A
r
r
a
y
[
i
]
[
j
]
Array[i][j]
Array[i][j],
0
≤
i
<
m
,
0
≤
j
<
n
0\leq i \lt m, 0\leq j \lt n
0≤i<m,0≤j<n时,就相当于
- 先找到第 i i i 个内层数组(行)
- 再找到此内层数组中第 j j j 个元素(列)
小测试
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
4) 局部性原理
这里只讨论空间局部性
- cpu 读取内存(速度慢)数据后,会将其放入高速缓存(速度快)当中,如果后来的计算再用到此数据,在缓存中能读到的话,就不必读内存了
- 缓存的最小存储单位是缓存行(cache line),一般是 64 bytes,一次读的数据少了不划算啊,因此最少读 64 bytes 填满一个缓存行,因此读入某个数据时也会读取其临近的数据,这就是所谓空间局部性
对效率的影响
比较下面 ij 和 ji 两个方法的执行效率
int rows = 1000000;
int columns = 14;
int[][] a = new int[rows][columns];
StopWatch sw = new StopWatch();
sw.start("ij");
ij(a, rows, columns);
sw.stop();
sw.start("ji");
ji(a, rows, columns);
sw.stop();
System.out.println(sw.prettyPrint());
ij 方法
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);
}
ji 方法
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);
}
执行结果
0
0
StopWatch '': running time = 96283300 ns
---------------------------------------------
ns % Task name
---------------------------------------------
016196200 017% ij
080087100 083% 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],如图所示
但很遗憾,第二次内循环要的是 [ 1 , 0 ] [1,0] [1,0] 这条数据,缓存中没有,于是再读入了下图的数据
这显然是一种浪费,因为 [ 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 读写时同样可以体现局部性原理
-
数组可以充分利用局部性原理,那么链表呢?
答:链表不行,因为链表的元素并非相邻存储
5) 越界检查
java 中对数组元素的读写都有越界检查,类似于下面的代码
bool is_within_bounds(int index) const
{
return 0 <= index && index < length();
}
- 代码位置:
openjdk\src\hotspot\share\oops\arrayOop.hpp
只不过此检查代码,不需要由程序员自己来调用,JVM 会帮我们调用