一. 初识算法
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]
说白了,算法就是任何定义优良的计算过程:接收一些值作为输入,在有限的时间内,产生一些值作为输出。
2 什么是数据结构?
定义
在计算机科学领域,数据结构是一种数据组织、管理和存储格式,通常被选择用来高效访问数据
In computer science, a data structure is a data organization, management, and storage format that is usually chosen for efficient access to data
说白了,数据结构是一种存储和组织数据的方式,旨在便于访问和修改
接下来我们通过对一个非常著名的二分查找算法的讲解来认识一下算法
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,结束查找,找到了 |
java 代码实现:
public static int binarySearchBasic(int[] a, int target) {
//设置指针和初值
int i = 0, j = a.length - 1;
//i~j范围内有东西
while (i <= j) {
int m = (i + j) / 2;
//int m = (i + j) >>> 1;
/*如果目标在左边,j就移到m的左边,如果目标在右边,i就移到m的右边,
如果刚好在中间,就返回索引m,如果都找不到就返回-1*/
if (target < a[m]) {
j = m - 1;
} else if (a[m] < target) {
i = m + 1;
} else {
return m;
}
}
return -1;
}
测试:
@Test
@DisplayName("测试 binarySearchBasic")
public void test1() {
int[] a = {7, 13, 21, 30, 38, 44, 52, 53};
assertEquals(0, binarySearchBasic(a, 7));
assertEquals(1, binarySearchBasic(a, 13));
assertEquals(2, binarySearchBasic(a, 21));
assertEquals(3, binarySearchBasic(a, 30));
assertEquals(4, binarySearchBasic(a, 38));
assertEquals(5, binarySearchBasic(a, 44));
assertEquals(6, binarySearchBasic(a, 52));
assertEquals(7, binarySearchBasic(a, 53));
System.out.println("找到了");
assertEquals(-1, binarySearchBasic(a, 0));
assertEquals(-1, binarySearchBasic(a, 15));
assertEquals(-1, binarySearchBasic(a, 60));
System.out.println("没找到");
}
- 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 指向的元素也可能是比较的目标
- m m m 对应着中间位置,中间位置左边和右边的元素可能不相等(差一个),不会影响结果
- 如果某次未找到,那么缩小后的区间内不包含 m m m
思考:
问题1: 为什么是 i<=j , 如果不加 i = = j i==j i==j 行不行?
不行,因为这意味着 i,j 指向的元素会漏过比较 , i<j 只意味着 m 指向的元素参与比较, i和j相等的情况也可能是我们查找的目标。
问题2: (i + j) / 2 有没有问题?
有,当j的数值特别大的时候,会发生索引变成负数的情况,原因是超过了正整数能表示的范围,就会由正变负。例如:当我把int j = Integer.MAX_VALUE - 1时,第二次求中间索引m就会为负数。
原因:
同一个二进制数 1011_1111_1111_1111_1111_1111_1111_1110
不把最高位视为符号位, 代表 3221225470
把最高位视为符号位, 代表 -1073741826
java中总是把最高位视为符号位!
解决:
为了避免出现负数,采用无符号右移运算符,相当于整除2结果为正。
注:>>>与>>>的区别
>>是带符号,就是根据之前的数字补齐0和1,>>>不带符号直接补0,也就是结果一定是正数。
如下图:
问题测试:
@Test
@DisplayName("测试右移运算")
public void test2() {
int i = 0;
int j = Integer.MAX_VALUE - 1;
// 模拟第一次求中间索引
int m = (i + j) / 2;
System.out.println(m);
// 模拟第二次求中间索引, target 在右侧
i = m + 1;
assertEquals(1073741824, i);
assertEquals(2147483646, j);
assertEquals(-1073741826, i + j);
m = (i + j) / 2;
// 发现索引变成负数了,第一层的m值为1073741823,
// 第二次的m整型的最大值-1,超过了正整数能表示的范围,就会由正变负。
System.out.println(i);
System.out.println(j);
System.out.println(i+j);
System.out.println(m);
assertEquals(-536870913, m);
//为了避免出现负数,采用无符号右移运算符,相当于/2结果为正。适用于更多语言,在javaScript中可视为整数运算。
m = (i + j) >>> 1; // 改正后的情况
assertEquals(1610612735, m);
System.out.println(m);
}
问题3: 都写成小于号有啥好处?
因为是数组是升序的,写小于符号更好理解,接近人类语言。
3.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 指向的一定不是查找目标
- 如果某次要缩小右边界,那么 j = m j=m j=m,因为此时的 m m m 已经不是查找目标了
思考:为啥这次不加 i = = j i==j i==j 的条件了?
因为这回 j j j 指向的不是查找目标,如果还加 i = = j i==j i==j 条件,就意味着 j j j 指向的还会再次比较,找不到时,会死循环
3.3衡量算法好坏
时间复杂度
下面的查找算法也能得出与之前二分查找一样的结果,那你能说出它差在哪里吗?
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 在较小的时候,二者花费的次数差不多
但随着
n
n
n 越来越大,比如说
n
=
1000
n=1000
n=1000 时,用二分查找算法(红色)也就是
54
t
54t
54t,而蓝色算法则需要
3003
t
3003t
3003t
画图采用的是 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 表示法
按时间复杂度从低到高
- 黑色横线 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!)
空间复杂度
与时间复杂度类似,一般也使用大 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)
3.4二分查找平衡版
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))
思考:为什么需要平衡版?
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;
}
}
如上,当元素在最左边时,只需要执行第一个if,N次执行。
当元素在最右边时,需要先执行if,再执行else if,这样就需要执行N+N次。执行次数不平衡。
3.5二分查找 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.
}
测试:
@Test
@DisplayName("测试 binarySearch java 版")
public void test5() {
/*
⬇
[2, 5, 8] a
[2, 0, 0, 0] b
[2, 4, 0, 0] b
[2, 4, 5, 8] b
*/
// i = -插入点 - 1 因此有 插入点 = abs(i+1)
int[] a = {2, 5, 8};
int target = 4;
int i = Arrays.binarySearch(a, target);
System.out.println(i);
//可用这个替代if(i<0)
//assertTrue(i < 0);
if (i<0){
int inserIndex = Math.abs(i+1);
//将b数组扩大1位
int[] b = new int[a.length+1];
//插入点之前copy到新数组
System.arraycopy(a,0,b,0,inserIndex);
//插入目标值
b[inserIndex] = target;
//copy插入点后面的值
System.arraycopy(a,inserIndex,b,inserIndex+1,a.length-inserIndex);
System.out.println(b);
//assertArrayEquals(new int[]{2, 4, 5, 8}, b);
}
}
思考:为什么返回的是-(low+1)?
- 例如
[
1
,
3
,
5
,
6
]
[1,3,5,6]
[1,3,5,6] 要插入
2
2
2 那么就是找到一个位置,这个位置左侧元素都比它小
- 等循环结束,若没找到,low 左侧元素肯定都比 target 小,因此 low 即插入点
- 插入点取负是为了与找到情况区分
3.6Leftmost 与 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,并不是最左侧的元素
此时,我们就需要用到想办法每次查找的都是最左的重复元素。这里我们引用了一个变量candidate来记录候选位置就可以解决了。
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 更有用的值i,无论找到找不到都返回最靠左的相同索引位置。
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;
}
-
返回i的含义:>=target的最靠左索引。
-
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;
}
- 返回i-1的含义:>=target的最靠右索引。
- 大于等于中间值,都要向右找
3.7应用
几个名词
通过Leftmost,Rightmost二分查找,我们可以解决很多问题,比如范围查询问题、求排名问题、求前(后)任问题、求最近邻居问题等,以下是解决相应问题的思路。
范围查询:
- 查询 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.二分查找LeetCode题
E01. 二分查找-力扣 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
参考答案:
class Solution {
public int search(int[] nums, int target) {
int i = 0, j = nums.length;
while (1 < j - i) {
int m = (i + j) >>> 1;
if (target < nums[m]) {
j = m;
} else {
i = m;
}
}
return (target == nums[i]) ? i : -1;
}
}
E02. 搜索插入位置-力扣 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 版本解,返回值即为插入位置(并能处理元素重复的情况)
class Solution {
public int searchInsert(int[] nums, int target) {
int i = 0, j = nums.length - 1;
while(i <= j) {
int m = (i + j) >>> 1;
if(target <= nums[m]) {
j = m - 1;
} else {
i = m + 1;
}
}
return i;
}
}
E03. 搜索开始结束位置-力扣 34 题
34. 在排序数组中查找元素的第一个和最后一个位置 - 力扣(LeetCode)
给你一个按照非递减顺序排列的整数数组 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]
参考答案
class Solution {
public 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)};
}
}
public static int left(int[] nums,int target){
int i = 0;
int j = nums.length-1;
int candidate = -1;
while(i<=j){
int m =(i+j)>>>1;
if(target<nums[m]){
j=m-1;
}else if(target>nums[m]){
i=m+1;
}else{
candidate = m;
j=m-1;
}
}
return candidate;
}
public static int right(int[] nums,int target){
int i = 0;
int j = nums.length-1;
int candidate = -1;
while(i<=j){
int m =(i+j)>>>1;
if(target<nums[m]){
j=m-1;
}else if(target>nums[m]){
i=m+1;
}else{
candidate = m;
i=m+1;
}
}
return candidate;
}
}
E04.x的平方根-力扣69题
**要求:**不允许使用任何内置指数函数和算符,例如 pow(x, 0.5) 或者 x ** 0.5 。
实现int sqrt(int x)函数
给你一个非负整数 x ,计算并返回 x 的 算术平方根 。
由于返回类型是整数,结果只保留整数部分 ,小数部分将被 舍去 。
示例 1:
输入:x = 4
输出:2
示例 2:
输入:x = 8
输出:2
解释:8 的算术平方根是 2.82842..., 由于返回类型是整数,小数部分将被舍去。
提示:
0 <= x <= 231 - 1
思路:
本题是一道常见的面试题,面试官一般会要求面试者在不使用
平方根函数的情况下,得到平方根的整数部分。一般的思路会有以下几种:
-
通过其它的数学函数代替平方根函数得到精确结果,取整数部分作为答案;
-
通过数学方法得到近似结果,直接作为答案。
方法一:袖珍计算器算法
「袖珍计算器算法」是一种用指数函数 exp 和对数函数 ln代替平方根函数的方法。我们通过有限的可以使用的数学函数,得到我们想要计算的结果。
注意: 由于计算机无法存储浮点数的精确值(浮点数的存储方法可以参考 IEEE 754,这里不再赘述),而指数函数和对数函数的参数和返回值均为浮点数,因此运算过程中会存在误差。
因此在得到结果的整数部分 ans后,我们应当找出ans与ans+1中哪一个是真正的答案。
class Solution {
public int mySqrt(int x) {
if (x == 0) {
return 0;
}
int ans = (int) Math.exp(0.5 * Math.log(x));
return (long) (ans + 1) * (ans + 1) <= x ? ans + 1 : ans;
}
}
- 首先判断输入的
x
是否为 0,如果是,则直接返回 0,因为平方根为 0。 - 使用近似求解的方法来计算平方根:
- 首先,使用
Math.log
函数取x
的自然对数。 - 接着,将得到的自然对数乘以 0.5。
- 然后,使用
Math.exp
函数计算乘积的指数,得到平方根的近似值。 - 最后,将近似值转换为整数,并赋给
ans
变量。
- 首先,使用
- 返回最终结果,通过判断
(ans + 1) * (ans + 1)
是否小于等于x
,如果是,则返回ans + 1
,否则返回ans
。
测试结果:
时间复杂度:O(1)
空间复杂度:O(1)
方法二:二分查找
参考答案:
class Solution {
public int mySqrt(int x) {
int l = 0, r = x, ans = -1;
while (l <= r) {
int mid = l + (r - l) / 2;
if ((long) mid * mid <= x) {
ans = mid;
l = mid + 1;
} else {
r = mid - 1;
}
}
return ans;
}
}
代码的逻辑如下:
- 初始化左边界
l
为 0,右边界r
为x
,以及结果变量ans
为 -1。 - 使用二分查找的方法来逼近平方根的整数部分:
- 在每一轮循环中,计算中间值
mid
,通过取左边界l
和右边界r
的平均值,可以防止整数溢出。 - 判断
mid
的平方是否小于等于x
,如果是,则将mid
赋给ans
,并将左边界l
更新为mid + 1
,以继续搜索更大的值。 - 如果
mid
的平方大于x
,则将右边界r
更新为mid - 1
,以继续搜索更小的值。 - 这样,在每一轮循环中,通过不断缩小搜索范围,最终可以找到最接近平方根的整数值。
- 在每一轮循环中,计算中间值
- 当左边界
l
大于右边界r
时,退出循环。 - 返回结果变量
ans
,即找到的平方根的整数部分。
该方法通过不断将搜索范围缩小一半,具有很高的效率。时间复杂度为 O(logN),其中 N 为输入的非负整数 x
的大小。
测试结果:
时间复杂度:O(logx)
空间复杂度:O(1)。
方法三:牛顿迭代
参考答案:
class Solution {
public int mySqrt(int x) {
if (x == 0) {
return 0;
}
double C = x, x0 = x;
while (true) {
double xi = 0.5 * (x0 + C / x0);
if (Math.abs(x0 - xi) < 1e-7) {
break;
}
x0 = xi;
}
return (int) x0;
}
}
代码的逻辑如下:
- 首先判断输入的
x
是否为 0,如果是,则直接返回 0,因为平方根为 0。 - 定义变量
C
并初始化为x
,表示需要求解平方根的数。 - 定义变量
x0
并初始化为x
,作为初始近似值。 - 使用牛顿迭代法来逼近平方根的整数部分:
- 在每一轮迭代中,通过计算
xi
的值,将x0
更新为xi
。 xi
的计算公式为0.5 * (x0 + C / x0)
,即将x0
和C / x0
的平均值作为新的近似值。- 重复上述步骤直到满足停止条件,即
Math.abs(x0 - xi) < 1e-7
,表示近似值x0
和xi
的差的绝对值小于给定的精度阈值 1e-7。
- 在每一轮迭代中,通过计算
- 当满足停止条件时,退出循环。
- 将最终的近似值
x0
转换为整数,并返回作为结果。
牛顿迭代法是一种通过不断逼近函数零点的方法,可以高效地求解方程的根。在这个问题中,我们需要求解的是方程 f(x) = x^2 - C = 0
的根,即平方根。该方法的时间复杂度较低,通常能够在较少的迭代次数内得到精确的结果。
测试:
时间复杂度:O(logx),此方法是二次收敛的,相较于二分查找更快。
空间复杂度:O(1)。
算法初识就到这了,今天以二分查找为例子来让大家深刻了解一下算法,后续我将从算法初级到进阶再到高阶,结合LeedCode给大家系统讲解数据结构与算法。