百日算法修炼 · Day 09
主题:基础排序、二分查找、局部最小、异或运算
语言:Java | 建议用时:120~150 分钟
阅读方式
本文仍然分为两个区域:
- 上半篇是练习区:只包含题目、示例、思考问题和可折叠提示;
- 下半篇是答案区:包含优化后的代码、过程图解、复杂度、循环不变式、易错点与验证思路。
建议先独立完成练习,再点击每道题下面的"查看参考答案"。
今天看起来题目很多,但其实可以归成三条主线:
- 排序:如何把无序数据逐步变有序;
- 二分:如何利用"单调性 / 局部趋势"砍掉一半搜索空间;
- 异或:如何利用位运算性质消掉成对出现的数据。
今日任务地图
text
Day 09 学习路线
开始
↓
选择排序
↓
冒泡排序
↓
插入排序
↓
二分:是否存在
↓
二分:最左位置
↓
二分:最右位置
↓
局部最小
↓
异或找奇数次元素
↓
对数器验证与复盘
| 题号 | 主题 | 核心能力 |
|---|---|---|
| 1 | 选择排序 | 维护未排序区最小值 |
| 2 | 冒泡排序 | 相邻交换、提前终止 |
| 3 | 插入排序 | 有序前缀、元素搬移 |
| 4 | 二分查找:是否存在 | 搜索区间与单调性 |
| 5 | 二分查找:最左等值位置 | 命中后继续向左 |
| 6 | 二分查找:最右等值位置 | 命中后继续向右 |
| 7 | 局部最小 | 不要求整体有序的二分 |
| 8 | 奇数次元素 | 异或的交换律与自反性 |
第一部分:独立练习区
先不要急着背模板。今天每道题都先问自己:我维护的"已确定区域"是什么?剩余答案为什么一定还在我保留的区间里?
练习 1:选择排序
给定整数数组 arr,使用选择排序将其按升序排列。
例如:
text
输入:[5, 3, 1, 4, 2]
输出:[1, 2, 3, 4, 5]
要求你自己完成:
java
public static void selectionSort(int[] arr)
思考问题
- 第
i轮结束后,数组的哪一部分已经确定? - 为什么内层循环只需要扫描
[i + 1 ... N - 1]? - 一轮中应该记录"最小值"还是"最小值下标"?
- 如果最小值本来就在
i位置,还有必要交换吗? - 选择排序最好、最坏情况下的比较次数会明显变化吗?
- 选择排序为什么通常不稳定?
提示 1:每轮只解决一个位置
第 i 轮只负责给 i 位置找到正确元素:从 i 到数组末尾找最小值,把它放到 i。
提示 2:记录下标更方便
找到最小值以后最终需要交换元素,所以记录 minIndex 比只记录最小值更直接。
写完后再看:[查看练习 1 参考答案](#查看练习 1 参考答案)
练习 2:冒泡排序
给定整数数组 arr,使用冒泡排序将其按升序排列。
text
输入:[5, 1, 4, 2, 8]
输出:[1, 2, 4, 5, 8]
思考问题
- 一轮完整冒泡后,哪个元素会到达最终位置?
- 为什么下一轮可以少比较一个位置?
- 如果某一轮一次交换都没有发生,说明了什么?
- 相等元素是否需要交换?
- 如何在已经有序的数组上把最好情况优化到
O(N)?
提示 1:大的元素会不断向右"冒"
相邻元素逆序时交换,一轮结束后,当前未排序区中的最大值会来到最右端。
提示 2:记录本轮是否发生交换
如果整轮扫描没有任何逆序对,就说明当前数组已经整体有序,可以提前结束。
写完后再看:[查看练习 2 参考答案](#查看练习 2 参考答案)
练习 3:插入排序
给定整数数组 arr,使用插入排序将其按升序排列。
text
输入:[5, 2, 4, 6, 1, 3]
输出:[1, 2, 3, 4, 5, 6]
思考问题
- 第
i轮开始前,哪一段已经有序? - 当前元素为什么只需要向左寻找位置?
- 连续交换和"整体右移后插入"有什么区别?
- 数组接近有序时,插入排序为什么会很快?
- 相等时不移动,是否有助于保持稳定性?
提示 1:维护一个有序前缀
把 arr0 ... i - 1 想成已经排好的牌,第 i 张牌只需要插入到这个有序区域中的正确位置。
提示 2:可以少做很多交换
先保存当前值,把比它大的元素整体右移,最后只写入一次当前值,通常比一路两两交换更清晰。
写完后再看:[查看练习 3 参考答案](#查看练习 3 参考答案)
练习 4:二分查找------目标值是否存在
给定一个升序数组 arr 和整数 target,判断 target 是否存在。
text
arr = [1, 3, 5, 7, 9]
target = 7
返回:true
思考问题
- 为什么二分查找要求能够根据中点判断"答案只可能在左边还是右边"?
- 循环条件为什么通常写成
left <= right? - 为什么中点推荐写成
left + ((right - left) >> 1)? - 当
arr[mid] < target时,为什么mid可以被直接排除? - 空数组应该返回什么?
提示:每次至少排除一半
如果中点值比 target 小,由于数组有序,那么 mid 以及 mid 左边都不可能再出现 target。
写完后再看:[查看练习 4 参考答案](#查看练习 4 参考答案)
练习 5:二分查找------最左侧等值位置
给定一个升序数组 arr 和 target,如果 target 出现多次,返回它最左侧的下标 ;不存在返回 -1。
text
arr = [1, 2, 2, 2, 4, 6]
target = 2
返回:1
思考问题
- 找到
arr[mid] == target后,为什么不能直接返回? - 命中一个答案后,下一步应该继续去哪一侧?
- 为什么需要变量
ans暂存当前答案? - 如果目标值只出现一次,这个写法还能正确工作吗?
提示:命中只是"候选答案"
看到 target 时先记录 mid,但左边仍可能存在更靠左的 target,所以继续令 right = mid - 1。
写完后再看:[查看练习 5 参考答案](#查看练习 5 参考答案)
练习 6:二分查找------最右侧等值位置
给定一个升序数组 arr 和 target,如果 target 出现多次,返回它最右侧的下标 ;不存在返回 -1。
text
arr = [1, 2, 2, 2, 4, 6]
target = 2
返回:3
思考问题
- 它和"最左侧位置"真正不同的只有哪一步?
- 命中后为什么应该令
left = mid + 1? - 最左与最右二分能否看成一组完全对称的问题?
提示:把练习 5 左右镜像
找到 target 后先记录 mid,但右边仍可能有更靠右的 target,所以继续搜索右半区。
写完后再看:[查看练习 6 参考答案](#查看练习 6 参考答案)
练习 7:无序数组中的局部最小
给定一个无序数组,满足:
text
任意相邻两个元素都不相等
定义下标 i 为局部最小,当且仅当:
i == 0时:arr[0] < arr[1]i == N - 1时:arr[N - 1] < arr[N - 2]- 中间位置:
arr[i] < arr[i - 1] && arr[i] < arr[i + 1]
请返回任意一个局部最小位置。
思考问题
- 数组明明没有整体有序,为什么还能二分?
- 如果
arr[0] < arr[1],为什么可以直接返回0? - 排除左右端点后,为什么搜索区间应该从
1到N - 2? - 如果
arr[mid] > arr[mid - 1],为什么左半边一定存在局部最小? - 如果
arr[mid] > arr[mid + 1],为什么右半边一定存在局部最小? - 为什么这道题验证答案时不能简单要求"和暴力法返回同一个下标"?
提示 1:二分不等于"数组必须整体有序"
二分真正需要的是:看到中点信息后,可以证明至少有一半区域一定包含答案。
提示 2:看趋势,不看具体值
如果中点左边更低,就沿着"向左下降"的方向找;如果右边更低,就沿着"向右下降"的方向找。
写完后再看:[查看练习 7 参考答案](#查看练习 7 参考答案)
练习 8:异或找出出现奇数次的数
一个数组中:
- 恰好有一种数出现了奇数次;
- 其他所有数都出现了偶数次。
请在 O(N) 时间、O(1) 额外空间内找出这个数。
例如:
text
arr = [3, 5, 3, 7, 5, 7, 7]
3 出现 2 次
5 出现 2 次
7 出现 3 次
返回:7
思考问题
a ^ a等于什么?a ^ 0等于什么?- 异或是否满足交换律、结合律?
- 为什么偶数次出现的数最终都会被"消掉"?
- 为什么这个解法不需要排序,也不需要哈希表?
提示:把所有元素全部异或起来
相同的数出现偶数次时会两两抵消,最终只剩下那个出现奇数次的数。
写完后再看:[查看练习 8 参考答案](#查看练习 8 参考答案)
第二部分:参考答案与图解
到这里再对照答案。建议先比较"思路"和"边界",最后才比较代码写法。
答案 1:选择排序
[返回练习 1](#返回练习 1)
问题回顾
选择排序要解决的是:
在每一轮中,从"还没有排好"的区域找出最小值,把它放到当前应该确定的位置。
核心过程
以:
text
[5, 3, 1, 4, 2]
为例。
第一轮确定 0 号位置:
text
未排序区:
[5, 3, 1, 4, 2]
↑
最小值 1
交换后:
[1 | 3, 5, 4, 2]
↑
已确定
第二轮只看右边:
text
[1 | 3, 5, 4, 2]
↑
最小值 2
[1, 2 | 5, 4, 3]
因此第 i 轮始终维护:
text
[0 ... i - 1] 已经排好
[i ... N - 1] 仍待处理
text
选择排序流程
固定当前位置 i
↓
minIndex = i
↓
扫描 [i + 1 ... N - 1]
↓
不断更新最小值下标
↓
minIndex == i ?
┌───────┴───────┐
│ │
是 否
│ │
无需交换 交换 arr[i] 与 arr[minIndex]
│ │
└───────┬───────┘
↓
进入下一轮
完整代码
java
/**
* 选择排序:每轮从未排序区中选出最小值,放到当前左边界。
*/
public static void selectionSort(int[] arr) {
if (arr == null || arr.length < 2) {
return;
}
for (int i = 0; i < arr.length - 1; i++) {
int minIndex = i;
// 在 [i + 1 ... N - 1] 中寻找更小元素。
for (int j = i + 1; j < arr.length; j++) {
if (arr[j] < arr[minIndex]) {
minIndex = j;
}
}
// 最小值已经在正确位置时,不做无意义交换。
if (minIndex != i) {
swap(arr, i, minIndex);
}
}
}
private static void swap(int[] arr, int i, int j) {
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
为什么这里很难优化到更好的时间复杂度?
无论原数组是:
text
[1, 2, 3, 4, 5]
还是:
text
[5, 4, 3, 2, 1]
第 i 轮都要把未排序区基本扫描一遍,才能确认最小值。
因此:
text
比较次数 ≈ (N - 1) + (N - 2) + ... + 1
= O(N²)
| 指标 | 复杂度 |
|---|---|
| 最好时间 | O(N²) |
| 最坏时间 | O(N²) |
| 额外空间 | O(1) |
| 稳定性 | 不稳定 |
为什么通常不稳定?
例如:
text
[2a, 2b, 1]
第一轮会把 1 和 2a 交换:
text
[1, 2b, 2a]
两个值同为 2 的元素相对顺序改变了。
易错点
- 内层循环错误地从
0开始; - 保存最小值却忘记保存最小值下标;
- 每发现一个更小值就立即交换,导致交换次数明显增加;
- 误以为"有序输入"能让选择排序提前结束。
答案 2:冒泡排序
[返回练习 2](#返回练习 2)
问题回顾
冒泡排序的核心是:
反复比较相邻元素,把当前未排序区中的最大值逐步交换到最右侧。
手推一轮
text
初始:
[5, 1, 4, 2, 8]
5 和 1:
[1, 5, 4, 2, 8]
5 和 4:
[1, 4, 5, 2, 8]
5 和 2:
[1, 4, 2, 5, 8]
5 和 8:
[1, 4, 2, 5, 8]
一轮结束后,当前最大值已经到达右侧最终位置。
text
[未排序区 | 已确定最大值区域]
一个很实用的优化:提前终止
如果某一轮:
text
一次交换都没有发生
说明整段已经不存在相邻逆序对,也就意味着数组已经有序。
text
冒泡排序流程
开始一轮
↓
swapped = false
↓
比较相邻元素
↓
是否逆序?
┌───┴───┐
│ │
否 是
│ │
继续 交换,并令 swapped = true
│ │
└───┬───┘
↓
本轮扫描结束?
┌───┴───┐
│ │
否 是
│ │
继续比较 检查 swapped
↓
swapped == false ?
┌────┴────┐
│ │
是 否
│ │
提前结束 进入下一轮
完整代码
java
/**
* 冒泡排序:相邻元素逆序时交换。
* 若某轮没有发生交换,则数组已经有序,可提前结束。
*/
public static void bubbleSort(int[] arr) {
if (arr == null || arr.length < 2) {
return;
}
// end 表示当前未排序区的右边界。
for (int end = arr.length - 1; end > 0; end--) {
boolean swapped = false;
for (int i = 0; i < end; i++) {
if (arr[i] > arr[i + 1]) {
swap(arr, i, i + 1);
swapped = true;
}
}
// 一整轮都没有交换,说明数组已经完全有序。
if (!swapped) {
break;
}
}
}
private static void swap(int[] arr, int i, int j) {
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
为什么冒泡排序可以稳定?
代码只在:
java
arr[i] > arr[i + 1]
时交换。
当两个元素相等时不交换,因此相等元素不会因为冒泡过程互相跨越。
| 指标 | 复杂度 |
|---|---|
| 最好时间 | O(N) |
| 平均时间 | O(N²) |
| 最坏时间 | O(N²) |
| 额外空间 | O(1) |
| 稳定性 | 稳定 |
易错点
- 内层循环访问到
arr[i + 1]越界; - 下一轮仍然扫描已经确定的最右区域;
- 忘记在每轮开始前重置
swapped = false; - 相等元素也交换,从而破坏稳定性。
答案 3:插入排序
[返回练习 3](#返回练习 3)
问题回顾
插入排序可以想成打扑克牌:
左手里的牌已经有序,每次从右边拿一张新牌,插入左侧有序区域的正确位置。
有序前缀
以:
text
[5, 2, 4, 6, 1, 3]
为例。
开始:
text
[5 | 2, 4, 6, 1, 3]
↑
天然有序
把 2 插入:
text
[2, 5 | 4, 6, 1, 3]
把 4 插入:
text
[2, 4, 5 | 6, 1, 3]
因此不变式是:
text
每轮开始前:
arr[0 ... i - 1] 已经有序
为什么用"搬移"代替连续交换?
原始写法可以一路交换:
text
5 2
↓
2 5
但当移动距离很长时,会产生很多三次赋值的交换。
更直接的做法是:
text
先保存 current
比 current 大的元素整体向右移动
最后把 current 写到空出来的位置
例如:
text
有序区:[2, 4, 5, 8]
插入:3
保存 current = 3
[2, 4, 5, 8, _]
8 →
5 →
4 →
[2, _, 4, 5, 8]
写入 3:
[2, 3, 4, 5, 8]
完整代码
java
/**
* 插入排序:维护有序前缀,把当前元素插入正确位置。
* 使用"元素右移"代替连续 swap,可减少赋值操作。
*/
public static void insertionSort(int[] arr) {
if (arr == null || arr.length < 2) {
return;
}
for (int i = 1; i < arr.length; i++) {
int current = arr[i];
int j = i - 1;
// 把所有比 current 大的元素向右移动一格。
// 使用 > 而不是 >=,可以保持相等元素的相对顺序。
while (j >= 0 && arr[j] > current) {
arr[j + 1] = arr[j];
j--;
}
// 把 current 放入最终位置。
arr[j + 1] = current;
}
}
为什么接近有序时很快?
如果数组已经接近有序:
text
[1, 2, 3, 5, 4, 6, 7]
大部分元素的 while 几乎不会进入。
所以插入排序的实际工作量和"逆序程度"高度相关。
| 指标 | 复杂度 |
|---|---|
| 最好时间 | O(N) |
| 平均时间 | O(N²) |
| 最坏时间 | O(N²) |
| 额外空间 | O(1) |
| 稳定性 | 稳定 |
易错点
- 外层从
0开始却做无意义工作; j >= 0的边界漏掉;- 覆盖了
arr[i]却没有提前保存current; - 使用
>=搬移相等元素,破坏稳定性。
答案 4:二分查找------目标值是否存在
[返回练习 4](#返回练习 4)
问题回顾
二分查找真正依赖的不是"会算 mid",而是:
根据
arr[mid]和target的关系,能够证明一半区间一定不可能有答案。
搜索区间不变式
始终维护:
text
如果 target 存在,
那么它一定在 [left ... right] 中。
例如:
text
arr = [1, 3, 5, 7, 9, 11, 13]
target = 11
left right
↓ ↓
[1, 3, 5, 7, 9, 11, 13]
↑
mid = 7
因为:
text
7 < 11
所以:
text
[1, 3, 5, 7]
都可以排除。
下一轮只搜索:
text
[9, 11, 13]
text
二分查找:是否存在
[left ... right] 非空
↓
计算 mid
↓
比较 arr[mid] 和 target
┌──────┼──────┐
│ │ │
小于 等于 大于
│ │ │
left= 返回 right=
mid+1 true mid-1
│ │
└──────┬──────┘
↓
继续二分
区间为空 → 返回 false
完整代码
java
/**
* 在升序数组中判断 target 是否存在。
*/
public static boolean binarySearchExists(int[] arr, int target) {
if (arr == null || arr.length == 0) {
return false;
}
int left = 0;
int right = arr.length - 1;
while (left <= right) {
// 避免直接计算 (left + right) 可能产生的整数溢出。
int mid = left + ((right - left) >> 1);
if (arr[mid] == target) {
return true;
}
if (arr[mid] < target) {
// mid 以及左侧都不可能是 target。
left = mid + 1;
} else {
// mid 以及右侧都不可能是 target。
right = mid - 1;
}
}
return false;
}
复杂度
每次把搜索规模近似减半:
text
N
N / 2
N / 4
N / 8
...
1
因此:
text
时间复杂度:O(log N)
额外空间:O(1)
易错点
left < right和left <= right混用,却没有同步调整更新逻辑;- 写成
left = mid或right = mid导致死循环; - 忘记题目前提是数组有序;
- 空数组边界处理不完整。
答案 5:二分查找------最左侧等值位置
[返回练习 5](#返回练习 5)
问题回顾
普通二分在命中时可以直接返回。
但"最左侧"要求:
找到一个 target 以后,它只是候选答案;左侧还可能存在更靠左的 target。
例如:
text
arr = [1, 2, 2, 2, 4, 6]
↑
命中 mid
此时不能结束,因为:
text
左边还有一个 2
核心动作
text
命中:
ans = mid
right = mid - 1
text
二分查找:最左等值位置
计算 mid
↓
arr[mid] 与 target 比较
┌────────┼────────┐
│ │ │
小于 等于 大于
│ │ │
left= ans=mid right=
mid+1 │ mid-1
↓
right = mid - 1
↓
继续向左逼近
完整代码
java
/**
* 在升序数组中查找 target 最左侧出现的位置。
*
* @return 最左侧下标;不存在时返回 -1
*/
public static int binarySearchLeftmost(int[] arr, int target) {
if (arr == null || arr.length == 0) {
return -1;
}
int left = 0;
int right = arr.length - 1;
int ans = -1;
while (left <= right) {
int mid = left + ((right - left) >> 1);
if (arr[mid] == target) {
// 当前 mid 是一个合法答案,但左侧还可能有更早的 target。
ans = mid;
right = mid - 1;
} else if (arr[mid] < target) {
left = mid + 1;
} else {
right = mid - 1;
}
}
return ans;
}
一句话记忆
text
找最左:
命中以后仍然向左逼近
复杂度
text
时间复杂度:O(log N)
额外空间:O(1)
易错点
- 一命中就
return mid; - 记录
ans后忘记继续收缩; - 命中后错误地向右搜索;
- 不存在时默认返回
0。
答案 6:二分查找------最右侧等值位置
[返回练习 6](#返回练习 6)
问题回顾
它和"最左侧"完全对称:
text
找最左:命中后继续向左
找最右:命中后继续向右
例如:
text
arr = [1, 2, 2, 2, 4, 6]
↑
命中 mid
如果右边还可能存在 2:
text
ans = mid
left = mid + 1
完整代码
java
/**
* 在升序数组中查找 target 最右侧出现的位置。
*
* @return 最右侧下标;不存在时返回 -1
*/
public static int binarySearchRightmost(int[] arr, int target) {
if (arr == null || arr.length == 0) {
return -1;
}
int left = 0;
int right = arr.length - 1;
int ans = -1;
while (left <= right) {
int mid = left + ((right - left) >> 1);
if (arr[mid] == target) {
// 当前 mid 是合法答案,但右侧还可能有更晚的 target。
ans = mid;
left = mid + 1;
} else if (arr[mid] < target) {
left = mid + 1;
} else {
right = mid - 1;
}
}
return ans;
}
最左 / 最右放在一起看
| 情况 | 最左位置 | 最右位置 |
|---|---|---|
arr[mid] < target |
left = mid + 1 |
left = mid + 1 |
arr[mid] > target |
right = mid - 1 |
right = mid - 1 |
arr[mid] == target |
记录后继续向左 | 记录后继续向右 |
| 最终答案 | 最小合法下标 | 最大合法下标 |
易错点
最容易错的地方只有一个:
text
命中以后,到底继续搜索哪边?
把这一点记清楚,比死背完整代码更可靠。
答案 7:无序数组中的局部最小
[返回练习 7](#返回练习 7)
问题回顾
这道题是今天最值得认真理解的一道二分题。
它告诉我们:
二分查找并不要求整个数组有序。
只要中点信息能保证某一侧一定存在答案,就可以二分。
先处理两个端点
如果:
text
arr[0] < arr[1]
那么 0 就是局部最小。
如果:
text
arr[N - 1] < arr[N - 2]
那么 N - 1 就是局部最小。
只有当两边都不是答案时,才进入中间区域。
此时一定满足一种"从左端先下降、右端向左也先下降"的趋势:
text
arr[0] > arr[1] arr[N - 2] < arr[N - 1]
\ /
\ /
\____ 某处谷底 ____/
为什么可以二分?
观察中点 mid。
情况 1:左边更低
text
arr[mid] > arr[mid - 1]
说明从 mid 向左至少先走了一步下降趋势。
结合左侧边界条件,可以证明左半边一定存在一个局部最小,因此:
text
right = mid - 1
情况 2:右边更低
text
arr[mid] > arr[mid + 1]
同理,右半边一定存在局部最小:
text
left = mid + 1
情况 3:两边都比 mid 大
text
arr[mid] < arr[mid - 1]
arr[mid] < arr[mid + 1]
直接返回 mid。
text
局部最小二分
先处理:
- 空数组
- 长度为 1
- 左端点
- 右端点
↓
在 [1 ... N - 2] 中二分
↓
比较 mid 与左右邻居
┌──────────┼──────────┐
│ │ │
左邻居更小 右邻居更小 两边都更大
│ │ │
向左搜索 向右搜索 返回 mid
完整代码
原实现的核心方向是对的,但搜索区间直接从 0 到 N - 1,随后又访问 mid - 1 和 mid + 1,边界推理不够干净。
把两个端点提前处理后,中间二分区间固定为:
text
[1 ... N - 2]
代码会更安全、更容易证明。
java
/**
* 在相邻元素均不相等的数组中,返回任意一个局部最小下标。
*/
public static int findLocalMinimum(int[] arr) {
if (arr == null || arr.length == 0) {
return -1;
}
int n = arr.length;
if (n == 1) {
return 0;
}
// 左端点本身就是局部最小。
if (arr[0] < arr[1]) {
return 0;
}
// 右端点本身就是局部最小。
if (arr[n - 1] < arr[n - 2]) {
return n - 1;
}
// 两个端点已经排除,只搜索中间,避免 mid-1 / mid+1 越界。
int left = 1;
int right = n - 2;
while (left <= right) {
int mid = left + ((right - left) >> 1);
if (arr[mid] > arr[mid - 1]) {
// 左邻居更小,左半边一定存在局部最小。
right = mid - 1;
} else if (arr[mid] > arr[mid + 1]) {
// 右邻居更小,右半边一定存在局部最小。
left = mid + 1;
} else {
// 相邻元素不相等,因此走到这里时 mid 同时小于左右邻居。
return mid;
}
}
// 按题目条件理论上不会走到这里。
return -1;
}
为什么验证时不能要求和暴力法返回同一个下标?
因为一个数组可能有多个局部最小:
text
[5, 1, 4, 2, 6]
↑ ↑
都合法
二分算法返回 1,暴力法返回 3,两者都可能正确。
因此验证逻辑应该是:
text
检查返回位置是不是"任意一个合法局部最小"
而不是:
text
必须和另一个算法返回完全相同的下标
复杂度
| 指标 | 复杂度 |
|---|---|
| 时间复杂度 | O(log N) |
| 额外空间 | O(1) |
易错点
- 没先处理空数组;
- 直接在
[0 ... N - 1]二分,却访问mid - 1/mid + 1; - 误以为无序数组绝对不能二分;
- 用"固定唯一答案"的方式验证一个可能存在多解的问题。
答案 8:异或找出出现奇数次的数
[返回练习 8](#返回练习 8)
问题回顾
这道题利用四条性质:
text
0 ^ a = a
a ^ a = 0
a ^ b = b ^ a
(a ^ b) ^ c = a ^ (b ^ c)
因此异或的顺序无所谓。
假设:
text
arr = [3, 5, 3, 7, 5, 7, 7]
全部异或:
text
3 ^ 5 ^ 3 ^ 7 ^ 5 ^ 7 ^ 7
利用交换律重新排列:
text
(3 ^ 3) ^ (5 ^ 5) ^ (7 ^ 7) ^ 7
得到:
text
0 ^ 0 ^ 0 ^ 7
= 7
偶数次出现的数全部抵消,最终只剩奇数次元素。
text
异或流程
ans = 0
↓
依次执行:
ans ^= arr[i]
↓
偶数次出现的元素
通过 x ^ x = 0 两两抵消
↓
最终只剩
出现奇数次的元素
完整代码
java
/**
* 数组中恰好有一种数出现奇数次,其余数都出现偶数次。
*
* @return 出现奇数次的那个数
*/
public static int findOddTimesNumber(int[] arr) {
if (arr == null || arr.length == 0) {
throw new IllegalArgumentException("arr must not be null or empty");
}
int ans = 0;
// 偶数次出现的数会通过 x ^ x = 0 两两抵消。
for (int num : arr) {
ans ^= num;
}
return ans;
}
为什么空间是 O(1)?
整个过程只维护:
java
int ans
不需要:
- 哈希表;
- 排序辅助结构;
- 额外数组。
因此:
| 指标 | 复杂度 |
|---|---|
| 时间复杂度 | O(N) |
| 额外空间 | O(1) |
对随机测试代码的一个优化
原测试数据生成过程中反复创建随机数对象,可统一使用 ThreadLocalRandom,并使用 Fisher-Yates 洗牌。
java
import java.util.concurrent.ThreadLocalRandom;
/**
* 生成"恰好一种数出现奇数次,其余数出现偶数次"的随机数组。
*/
public static int[] generateOddTimesArray(int maxLength, int maxValue) {
if (maxLength < 1 || maxValue < 1) {
throw new IllegalArgumentException();
}
ThreadLocalRandom random = ThreadLocalRandom.current();
// 数组总长度必须是奇数:
// 奇数次 + 若干偶数次 = 奇数。
int length = random.nextInt(1, maxLength + 1);
if ((length & 1) == 0) {
length = (length == maxLength) ? length - 1 : length + 1;
}
int oddValue = random.nextInt(-maxValue, maxValue + 1);
// oddTimes 取 [1, length] 内的奇数。
int oddTimes = random.nextInt(1, length + 1);
if ((oddTimes & 1) == 0) {
oddTimes--;
}
int[] arr = new int[length];
int index = 0;
// 先放入奇数次目标值。
for (int i = 0; i < oddTimes; i++) {
arr[index++] = oddValue;
}
// 剩余位置一定是偶数个,成对填充其他值。
while (index < length) {
int value;
do {
value = random.nextInt(-maxValue, maxValue + 1);
} while (value == oddValue);
arr[index++] = value;
arr[index++] = value;
}
// Fisher-Yates 洗牌。
for (int i = arr.length - 1; i > 0; i--) {
int j = random.nextInt(i + 1);
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
return arr;
}
易错点
- 把异或
^和逻辑运算混淆; - 忘记题目保证"只有一种数出现奇数次";
- 用哈希表虽然能做,但没有利用题目给出的位运算结构;
- 随机测试生成器没有保证其他数均出现偶数次。
第三部分:三种 O(N²) 排序放在一起理解
它们的时间复杂度都可能是 O(N²),但"每轮在确定什么"完全不同。
1. 选择排序:选出最小值
text
[已确定最小区域 | 未排序区域]
每轮:
text
从未排序区找最小值
2. 冒泡排序:冒出最大值
text
[未排序区域 | 已确定最大区域]
每轮:
text
通过相邻交换把最大值推到右端
3. 插入排序:扩大有序前缀
text
[有序前缀 | 待插入元素 | 未处理区域]
每轮:
text
把一个新元素插入已有序前缀
text
三种 O(N²) 排序的关注点
选择排序
└─ 每轮选择一个最小值,放到左侧确定区
冒泡排序
└─ 每轮把一个最大值"冒"到右侧确定区
插入排序
└─ 每轮把一个新元素插入左侧有序前缀
对比表
| 对比项 | 选择排序 | 冒泡排序 | 插入排序 |
|---|---|---|---|
| 核心动作 | 选最小值 | 相邻交换 | 向有序区插入 |
| 最好时间 | O(N²) |
O(N)(提前终止) |
O(N) |
| 最坏时间 | O(N²) |
O(N²) |
O(N²) |
| 额外空间 | O(1) |
O(1) |
O(1) |
| 稳定性 | 不稳定 | 稳定 | 稳定 |
| 接近有序时表现 | 一般 | 较好 | 很好 |
| 主要数据移动 | 每轮最多一次交换 | 大量相邻交换 | 连续右移 |
第四部分:四类二分题统一理解
今天的四道二分题其实是在练四种"答案方向"。
1. 是否存在
中点命中后:
text
直接结束
2. 最左等值位置
中点命中后:
text
记录答案
继续向左
3. 最右等值位置
中点命中后:
text
记录答案
继续向右
4. 局部最小
中点不需要和某个 target 比较。
它比较的是:
text
mid 和左右邻居的趋势
只要能证明某一侧必有答案,就砍掉另一半。
一张表记住
| 问题 | 命中 / 判断依据 | 下一步 |
|---|---|---|
| 是否存在 | arr[mid] == target |
直接返回 |
| 最左等值 | arr[mid] == target |
记录后向左 |
| 最右等值 | arr[mid] == target |
记录后向右 |
| 局部最小 | 和左右邻居比较 | 沿"更低的一侧"继续 |
真正需要记住的不是二分模板,而是:为什么被丢掉的那一半一定没有必要再看。
第五部分:今天最重要的循环不变式
1. 选择排序
每轮开始时:
text
arr[0 ... i - 1]
已经是整个数组中最小的 i 个元素,并且位置正确。
2. 冒泡排序
每轮结束后:
text
右侧又多一个已经到达最终位置的最大元素。
3. 插入排序
每轮开始时:
text
arr[0 ... i - 1]
已经有序。
4. 普通二分
任意时刻:
text
如果答案存在,
答案一定仍在 [left ... right] 中。
5. 最左 / 最右边界
ans 表示:
text
目前已经找到的合法候选答案。
搜索区间继续寻找:
text
是否存在更优的下标。
6. 局部最小
每次根据中点和邻居的下降趋势,始终保留:
text
至少包含一个局部最小的那一侧。
7. 异或
扫描到任意位置时:
text
ans
=
已经扫描过的所有元素的异或结果。
第六部分:统一对数器验证
今天非常适合继续使用"对数器"思维,但不同题型应该选择不同的验证方式。
1. 三种排序:和 Arrays.sort 比较
java
import java.util.Arrays;
import java.util.concurrent.ThreadLocalRandom;
import java.util.function.Consumer;
public class SortVerifier {
public static void verify(Consumer<int[]> sorter) {
final int testTimes = 10_000;
for (int test = 0; test < testTimes; test++) {
int[] original = randomArray(100, 1_000);
int[] actual = Arrays.copyOf(original, original.length);
int[] expected = Arrays.copyOf(original, original.length);
sorter.accept(actual);
Arrays.sort(expected);
if (!Arrays.equals(actual, expected)) {
throw new AssertionError(
"排序失败"
+ "\n原数组:" + Arrays.toString(original)
+ "\n期望:" + Arrays.toString(expected)
+ "\n实际:" + Arrays.toString(actual));
}
}
System.out.println("Accepted: " + testTimes + " tests");
}
private static int[] randomArray(int maxLength, int maxValue) {
ThreadLocalRandom random = ThreadLocalRandom.current();
int length = random.nextInt(maxLength + 1);
int[] arr = new int[length];
for (int i = 0; i < arr.length; i++) {
arr[i] = random.nextInt(-maxValue, maxValue + 1);
}
return arr;
}
}
调用:
java
SortVerifier.verify(YourClass::selectionSort);
SortVerifier.verify(YourClass::bubbleSort);
SortVerifier.verify(YourClass::insertionSort);
2. 二分存在:和线性扫描比较
java
public static boolean linearExists(int[] arr, int target) {
if (arr == null) {
return false;
}
for (int num : arr) {
if (num == target) {
return true;
}
}
return false;
}
验证时:
text
先生成随机数组
↓
Arrays.sort
↓
随机生成 target
↓
二分结果 vs 线性扫描结果
3. 最左 / 最右:分别从两端线性扫描
最左基准方法
java
public static int linearLeftmost(int[] arr, int target) {
if (arr == null) {
return -1;
}
for (int i = 0; i < arr.length; i++) {
if (arr[i] == target) {
return i;
}
}
return -1;
}
最右基准方法
java
public static int linearRightmost(int[] arr, int target) {
if (arr == null) {
return -1;
}
for (int i = arr.length - 1; i >= 0; i--) {
if (arr[i] == target) {
return i;
}
}
return -1;
}
4. 局部最小:直接验证"是否合法"
不要强行要求二分答案和某个暴力答案相同。
更好的验证函数:
java
public static boolean isLocalMinimum(int[] arr, int index) {
if (arr == null || arr.length == 0) {
return index == -1;
}
if (index < 0 || index >= arr.length) {
return false;
}
boolean leftOk = index == 0 || arr[index] < arr[index - 1];
boolean rightOk = index == arr.length - 1 || arr[index] < arr[index + 1];
return leftOk && rightOk;
}
测试时:
java
int index = findLocalMinimum(arr);
if (!isLocalMinimum(arr, index)) {
throw new AssertionError(...);
}
这种方式更符合"答案可能不唯一"的题型。
5. 异或题:用哈希表做可信基准
异或解法追求:
text
O(N) 时间 + O(1) 空间
而对数器不需要追求最优,只需要简单、可信。
java
import java.util.HashMap;
import java.util.Map;
public static int findOddTimesByMap(int[] arr) {
Map<Integer, Integer> count = new HashMap<>();
for (int num : arr) {
count.put(num, count.getOrDefault(num, 0) + 1);
}
for (Map.Entry<Integer, Integer> entry : count.entrySet()) {
if ((entry.getValue() & 1) == 1) {
return entry.getKey();
}
}
throw new IllegalArgumentException("input does not satisfy the condition");
}
验证:
text
异或解法结果
vs
哈希表统计结果
第七部分:容易混淆的几个点
1. O(N²) 不代表三个排序"完全一样"
虽然选择、冒泡、插入最坏情况都是:
text
O(N²)
但实际运行特征不同。
尤其数组接近有序时:
text
插入排序
往往会明显更占优势。
2. 二分查找不等于"只能处理有序数组"
更准确的说法是:
二分需要一种能够排除一半搜索空间的结构。
普通二分利用:
text
整体有序
局部最小利用:
text
局部下降趋势 + 必然存在性
3. 边界题的关键不是"找到答案",而是"继续逼近"
普通存在性二分:
text
找到就结束
最左 / 最右:
text
找到只是开始收集候选答案
4. 对数器的"正确答案"未必必须唯一
排序结果唯一,因此适合:
text
Arrays.equals
局部最小可能有多个,因此适合:
text
验证返回值是否满足性质
这是测试算法时非常重要的区别。
今日自测
1. 选择排序第 i 轮结束后,什么东西被确定了?
查看答案
下标 i 位置放好了当前未排序区中的最小值,因此 arr0 ... i 都已经处于最终正确位置。
2. 冒泡排序为什么可以通过 swapped 提前结束?
查看答案
如果一整轮相邻比较都没有发生交换,说明不存在相邻逆序对,数组已经整体有序,后续轮次没有必要继续。
3. 插入排序为什么适合接近有序的数据?
查看答案
接近有序时,每个新元素通常只需要向左移动很短距离,while 循环执行次数少,因此实际操作量会接近线性。
4. 普通二分命中 target 为什么可以直接返回?
查看答案
题目只问"是否存在",只要发现任意一个等值位置就已经完成目标,不需要继续寻找边界。
5. 最左位置为什么命中后还要继续向左?
查看答案
当前 mid 只是一个合法候选,mid 左侧仍可能存在相同 target;记录当前答案后继续搜索左半边,才能得到最小下标。
6. 最右位置的命中逻辑是什么?
查看答案
先记录 ans = mid,再令 left = mid + 1 继续搜索右半边,寻找更靠右的合法位置。
7. 为什么局部最小问题可以在无序数组上二分?
查看答案
因为通过 mid 与左右邻居的大小关系,可以判断某一侧必然存在局部最小。二分依赖的是"能排除一半",而不是必须整体有序。
8. 局部最小为什么建议把二分区间写成 [1 ... N - 2]?
查看答案
左右端点已经提前单独检查。中间区间保证 mid-1 和 mid+1 始终合法,使代码避免边界越界,也让证明更清晰。
9. 为什么所有元素异或后只剩奇数次元素?
查看答案
异或满足交换律和结合律,相同元素可以重排到一起。偶数次元素可以两两通过 x ^ x = 0 抵消,最终只剩出现奇数次的元素。
10. 为什么局部最小的对数器不应该强制比较"下标完全相等"?
查看答案
同一个数组可能存在多个局部最小,不同正确算法可能返回不同合法下标。应验证返回位置是否满足局部最小定义,而不是要求答案唯一。
加餐挑战
- 把选择排序改成每轮同时选择最小值和最大值,分别放到左右两端;
- 统计冒泡排序提前终止前实际进行了多少次比较;
- 写一个"二分查找第一个
>= target的位置"; - 写一个"二分查找最后一个
<= target的位置"; - 尝试不用
ans变量,写出 lower bound 风格的最左边界二分; - 构造一个拥有多个局部最小的数组,观察二分可能返回哪一个;
- 证明:在相邻元素不相等且两个端点都不是局部最小时,中间一定至少存在一个局部最小;
- 扩展异或题:数组中有两种数出现奇数次,其他数出现偶数次,如何找到这两个数?
挑战 3 提示
当 arrmid >= target 时,mid 已经可能是答案,但左边还可能存在更早的合法位置,因此记录并继续向左。
挑战 8 提示
先把所有数异或得到 a ^ b,再取这个结果最右侧的一个 1。这个二进制位说明 a 和 b 在该位一定不同,可以据此把数组分成两组。
Day 09 复盘清单
- 我能独立写出选择排序,并解释每轮为什么只确定一个最小值
- 我知道选择排序为什么最好情况仍是
O(N²) - 我能给冒泡排序加上"本轮无交换则提前结束"的优化
- 我知道冒泡排序相等元素不交换时可以保持稳定
- 我能用"元素右移 + 最后插入"写出插入排序
- 我知道为什么插入排序对接近有序的数据很友好
- 我能写出标准的"是否存在"二分查找
- 我能解释
left + ((right - left) >> 1)的写法 - 我能写出最左等值位置,并知道命中后为什么继续向左
- 我能写出最右等值位置,并知道命中后为什么继续向右
- 我理解"二分不要求整体有序,只要求能排除一半"
- 我会先处理局部最小问题的两个端点,再搜索
[1 ... N - 2] - 我能根据 mid 和左右邻居的趋势判断局部最小应该去哪一侧找
- 我知道多解问题的对数器应该验证"答案是否合法",而不是强制答案唯一
- 我能解释
a ^ a = 0、a ^ 0 = a - 我能用
O(N)时间、O(1)额外空间找出出现奇数次的数 - 我会根据题型选择合适的基准方法构造对数器
一句话总结: Day 09 真正要修炼的不是八段代码,而是三种思维------排序时维护"已经确定的区域",二分时维护"答案一定还存在的搜索区间",异或时利用"成对抵消"的代数性质;只要不变式清楚,代码自然会变得短、稳、好证明。