Q55- code34- 在排序数组中查找元素的第一个和最后一个位置
实现思路
1 方法1:二分查找
1 实现思路
1.1 第1个位置:>=t的最小值 (minGt); 最后1个位置: <=t的最大值 (maxLt)
1.2 所有大小关系的整数查找,都可以转化为 minGt
- maxLt,可以转化为 >= (t + 1)的最小值 - 1,即 minGt(t + 1) - 1
-
t的 最小值,可以转化为 >= (t + 1)的最小值,即 minGt(t + 1)
- < t的 最大值,可以转化为 >= t的最小值 - 1,即 minGt(t) - 1
2.1 二分法里的区间,其含义是
- 区间内的,表示的是 还未处理的的元素
- 区间外的,表示的是 已经确定处理过的元素
- 所以开闭区间,表示的就是【当前还未处理的元素】,是否 不包括/包括 这个idx的值
2.2 区间维护的本质
-
二分查找的核心就是不断缩小"待查找区间"
-
left, right\] 区间内的元素代表"还需要继续判断的范围"
参考文档
代码实现
1.1 方法1.1- 双开区间
- 时间复杂度:O(logn)
- 空间复杂度:O(1)
ts
function searchRange(nums: number[], target: number): number[] {
const lt = minGt(nums, target);
// 易错点1: 如果lt 超出范围,或者lt 不等于target,说明不存在
if (lt === nums.length || nums[lt] !== target) return [-1, -1];
// <=t的最大值 (maxLt)相当于 minGt(t+1) - 1
const gt = minGt(nums, target + 1) - 1;
return [lt, gt];
}
// 获取 >=t的 最小值
function minGt(nums: number[], target: number): number {
// 左右都是开区间,从而保证整个内部区间都是未处理过的
// 开区间说明了未处理的元素,不包括-1 和 len
// 即 [-Infinity, -1]都 < t, [len, Infinity]都 >= t
let l = -1, r = nums.length;
// 如果l + 1 === r,说明此时l和r都被处理过了,此时区间内没有待处理元素
while (l + 1 < r) {
const mid = l + ((r - l) >> 1);
if (nums[mid] < target) {
l = mid;
} else {
r = mid;
}
}
// 到此时,l必然是 < t的最后一个元素,r必然是 >= t的第1个元素
return r;
}
1.2 方法1.2- 左闭右开区间
- 时间复杂度:O(logn)
- 空间复杂度:O(1)
ts
function searchRange(nums: number[], target: number): number[] {
const lt = minGt(nums, target);
// 易错点1: 如果lt 超出范围,或者lt 不等于target,说明不存在
if (lt === nums.length || nums[lt] !== target) return [-1, -1];
// <=t的最大值 (maxLt)相当于 minGt(t+1) - 1
const gt = minGt(nums, target + 1) - 1;
return [lt, gt];
}
// minGt: >=t的最小值- 左闭右开区间实现
function minGt(nums: number[], target: number): number {
// 表示 未处理的元素- 包括l; 不包括r
let l = 0, r = nums.length;
// 包括l,不包括r, 所有l === r时(如 [4,4)时),说明没有元素需要处理了
// 所以循环条件是 l < r
while (l < r) {
const mid = (l + r) >> 1;
if (nums[mid] < target) {
l = mid + 1;
} else {
r = mid;
}
}
// 任意返回l或者r都可,因为循环结束时 l === r
return r;
}
Q56- code33- 搜索旋转排序数组
实现思路
1 方法1:开区间折纸法
1.1 原始数组的特点:
- 一开始是完全升序的数组,比如 [1,2,3,4,5,6,7]
- 数组中的元素互不相同(题目保证)
1.2 旋转的本质:
- 旋转操作实际上 就是把数组从某个点切开
- 然后把左边的部分移到右边
- 比如在4处切开:[1,2,3,4|5,6,7] → [5,6,7|1,2,3,4]
- 关键是:只切了一刀!
1.3 为什么一定有一半有序:
- 旋转点(那一刀)只可能 存在于一边
- 没有旋转点的那边,保持了原来的升序性质
- 有旋转点的那边,被切断了升序性质
- 所以任意时刻,把数组从中间分开:
- 旋转点要么在左边
- 要么在右边
- 不可能同时在两边(因为只切了一刀)
1.4 这就导致了一个重要推论:
- 对于任意一个二分点:
- 如果旋转点在左边 → 右边一定有序
- 如果旋转点在右边 → 左边一定有序
这就像是折一张纸:
- 原始数组就像一张平整的纸(完全有序)
- 旋转就像在某处折一下
- 无论你在哪里横着切开这张折纸
- 切口的一边一定是平整的(有序的)
- 另一边可能包含折痕(旋转点)
2.1 关键思想
- 发现了"一半必有序"的性质
- 更妙的是发现:无序的那半边其实是一个小号的原问题
- 所以同样的解法可以一直用下去
方法2:last比较法
1.1 本质上是把"位置关系"转化为"值关系":
- 不需要找到分割点
- 只需要知道 x 和 target 分别在哪一段 (相对位置关系)
- l1段 必然都大于 l2段
- 如果x 和 target 在不同段,就能直接确定相对位置
- 如果在同一段,就按普通二分处理
参考文档
代码实现
1 方法1- 开区间折纸法
- 时间复杂度:O(logn)
- 空间复杂度:O(1)
ts
function search(nums: number[], target: number): number {
let l = -1, r = nums.length, len = nums.length;
while (l + 1 < r) {
const mid = (l + r) >> 1;
const x = nums[mid];
if (x === target) return mid;
// [6,7,8,9, 2,3,4]
// m t = 5
// Vmid < Vl,说明此时旋转点在左边,右侧是升序的
// 易错点1:这里需要用nums[0]作为固定参考点 判断旋转情况
if (x < nums[0]) {
// 若满足条件,说明 target 一定属于右侧升序区间内
// 否则 target 一定属于左侧区间
target > x && target <= nums[len - 1] ? (l = mid) : (r = mid);
} else {
// 说明此时旋转点在右边,左侧是升序的
// 若满足条件,说明 target 一定属于左侧升序区间内
// 否则 target 一定属于右侧区间
target < x && target >= nums[0] ? (r = mid) : (l = mid);
}
}
return -1;
}
2.1 方法2: last比较法
- 时间复杂度:O(logn)
- 空间复杂度:O(1)
ts
function search(nums: number[], target: number): number {
// last是天然的l1 和 l2 的 分割点
// l1的每个值 > l2的每个值
const last = nums.at(-1);
let l = -1, r = nums.length;
while (l + 1 < r) {
const mid = l + ((r - l) >> 1);
const x = nums[mid];
if (x === target) return mid;
// 易错点1:需要判断等于last的情况,不然当x或者t正好为last时,会误判断所属区间
// x属于l2; t属于l1
if (x <= last && target > last) {
r = mid;
} else if (x > last && target <= last) {
// x属于l1; t属于l2
l = mid;
} else {
// x 和 taget 属于同一段,比较这2个值进行二分即可
x < target ? (l = mid) : (r = mid);
}
}
return -1;
}