百日算法修炼 · Day 09

百日算法修炼 · Day 09

主题:基础排序、二分查找、局部最小、异或运算

语言:Java | 建议用时:120~150 分钟


阅读方式

本文仍然分为两个区域:

  • 上半篇是练习区:只包含题目、示例、思考问题和可折叠提示;
  • 下半篇是答案区:包含优化后的代码、过程图解、复杂度、循环不变式、易错点与验证思路。

建议先独立完成练习,再点击每道题下面的"查看参考答案"。

今天看起来题目很多,但其实可以归成三条主线:

  1. 排序:如何把无序数据逐步变有序;
  2. 二分:如何利用"单调性 / 局部趋势"砍掉一半搜索空间;
  3. 异或:如何利用位运算性质消掉成对出现的数据。

今日任务地图

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)

思考问题

  1. i 轮结束后,数组的哪一部分已经确定?
  2. 为什么内层循环只需要扫描 [i + 1 ... N - 1]
  3. 一轮中应该记录"最小值"还是"最小值下标"?
  4. 如果最小值本来就在 i 位置,还有必要交换吗?
  5. 选择排序最好、最坏情况下的比较次数会明显变化吗?
  6. 选择排序为什么通常不稳定?

提示 1:每轮只解决一个位置

第 i 轮只负责给 i 位置找到正确元素:从 i 到数组末尾找最小值,把它放到 i。
提示 2:记录下标更方便

找到最小值以后最终需要交换元素,所以记录 minIndex 比只记录最小值更直接。

写完后再看:[查看练习 1 参考答案](#查看练习 1 参考答案)


练习 2:冒泡排序

给定整数数组 arr,使用冒泡排序将其按升序排列。

text 复制代码
输入:[5, 1, 4, 2, 8]
输出:[1, 2, 4, 5, 8]

思考问题

  1. 一轮完整冒泡后,哪个元素会到达最终位置?
  2. 为什么下一轮可以少比较一个位置?
  3. 如果某一轮一次交换都没有发生,说明了什么?
  4. 相等元素是否需要交换?
  5. 如何在已经有序的数组上把最好情况优化到 O(N)

提示 1:大的元素会不断向右"冒"

相邻元素逆序时交换,一轮结束后,当前未排序区中的最大值会来到最右端。
提示 2:记录本轮是否发生交换

如果整轮扫描没有任何逆序对,就说明当前数组已经整体有序,可以提前结束。

写完后再看:[查看练习 2 参考答案](#查看练习 2 参考答案)


练习 3:插入排序

给定整数数组 arr,使用插入排序将其按升序排列。

text 复制代码
输入:[5, 2, 4, 6, 1, 3]
输出:[1, 2, 3, 4, 5, 6]

思考问题

  1. i 轮开始前,哪一段已经有序?
  2. 当前元素为什么只需要向左寻找位置?
  3. 连续交换和"整体右移后插入"有什么区别?
  4. 数组接近有序时,插入排序为什么会很快?
  5. 相等时不移动,是否有助于保持稳定性?

提示 1:维护一个有序前缀

把 arr0 ... i - 1 想成已经排好的牌,第 i 张牌只需要插入到这个有序区域中的正确位置。
提示 2:可以少做很多交换

先保存当前值,把比它大的元素整体右移,最后只写入一次当前值,通常比一路两两交换更清晰。

写完后再看:[查看练习 3 参考答案](#查看练习 3 参考答案)


练习 4:二分查找------目标值是否存在

给定一个升序数组 arr 和整数 target,判断 target 是否存在。

text 复制代码
arr    = [1, 3, 5, 7, 9]
target = 7

返回:true

思考问题

  1. 为什么二分查找要求能够根据中点判断"答案只可能在左边还是右边"?
  2. 循环条件为什么通常写成 left <= right
  3. 为什么中点推荐写成 left + ((right - left) >> 1)
  4. arr[mid] < target 时,为什么 mid 可以被直接排除?
  5. 空数组应该返回什么?

提示:每次至少排除一半

如果中点值比 target 小,由于数组有序,那么 mid 以及 mid 左边都不可能再出现 target。

写完后再看:[查看练习 4 参考答案](#查看练习 4 参考答案)


练习 5:二分查找------最左侧等值位置

给定一个升序数组 arrtarget,如果 target 出现多次,返回它最左侧的下标 ;不存在返回 -1

text 复制代码
arr    = [1, 2, 2, 2, 4, 6]
target = 2

返回:1

思考问题

  1. 找到 arr[mid] == target 后,为什么不能直接返回?
  2. 命中一个答案后,下一步应该继续去哪一侧?
  3. 为什么需要变量 ans 暂存当前答案?
  4. 如果目标值只出现一次,这个写法还能正确工作吗?

提示:命中只是"候选答案"

看到 target 时先记录 mid,但左边仍可能存在更靠左的 target,所以继续令 right = mid - 1。

写完后再看:[查看练习 5 参考答案](#查看练习 5 参考答案)


练习 6:二分查找------最右侧等值位置

给定一个升序数组 arrtarget,如果 target 出现多次,返回它最右侧的下标 ;不存在返回 -1

text 复制代码
arr    = [1, 2, 2, 2, 4, 6]
target = 2

返回:3

思考问题

  1. 它和"最左侧位置"真正不同的只有哪一步?
  2. 命中后为什么应该令 left = mid + 1
  3. 最左与最右二分能否看成一组完全对称的问题?

提示:把练习 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]

请返回任意一个局部最小位置。

思考问题

  1. 数组明明没有整体有序,为什么还能二分?
  2. 如果 arr[0] < arr[1],为什么可以直接返回 0
  3. 排除左右端点后,为什么搜索区间应该从 1N - 2
  4. 如果 arr[mid] > arr[mid - 1],为什么左半边一定存在局部最小?
  5. 如果 arr[mid] > arr[mid + 1],为什么右半边一定存在局部最小?
  6. 为什么这道题验证答案时不能简单要求"和暴力法返回同一个下标"?

提示 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

思考问题

  1. a ^ a 等于什么?
  2. a ^ 0 等于什么?
  3. 异或是否满足交换律、结合律?
  4. 为什么偶数次出现的数最终都会被"消掉"?
  5. 为什么这个解法不需要排序,也不需要哈希表?

提示:把所有元素全部异或起来

相同的数出现偶数次时会两两抵消,最终只剩下那个出现奇数次的数。

写完后再看:[查看练习 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]

第一轮会把 12a 交换:

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 < rightleft <= right 混用,却没有同步调整更新逻辑;
  • 写成 left = midright = 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

完整代码

原实现的核心方向是对的,但搜索区间直接从 0N - 1,随后又访问 mid - 1mid + 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. 为什么局部最小的对数器不应该强制比较"下标完全相等"?

查看答案

同一个数组可能存在多个局部最小,不同正确算法可能返回不同合法下标。应验证返回位置是否满足局部最小定义,而不是要求答案唯一。


加餐挑战

  1. 把选择排序改成每轮同时选择最小值和最大值,分别放到左右两端;
  2. 统计冒泡排序提前终止前实际进行了多少次比较;
  3. 写一个"二分查找第一个 >= target 的位置";
  4. 写一个"二分查找最后一个 <= target 的位置";
  5. 尝试不用 ans 变量,写出 lower bound 风格的最左边界二分;
  6. 构造一个拥有多个局部最小的数组,观察二分可能返回哪一个;
  7. 证明:在相邻元素不相等且两个端点都不是局部最小时,中间一定至少存在一个局部最小;
  8. 扩展异或题:数组中有两种数出现奇数次,其他数出现偶数次,如何找到这两个数?

挑战 3 提示

当 arrmid >= target 时,mid 已经可能是答案,但左边还可能存在更早的合法位置,因此记录并继续向左。
挑战 8 提示

先把所有数异或得到 a ^ b,再取这个结果最右侧的一个 1。这个二进制位说明 a 和 b 在该位一定不同,可以据此把数组分成两组。


Day 09 复盘清单

  • 我能独立写出选择排序,并解释每轮为什么只确定一个最小值
  • 我知道选择排序为什么最好情况仍是 O(N²)
  • 我能给冒泡排序加上"本轮无交换则提前结束"的优化
  • 我知道冒泡排序相等元素不交换时可以保持稳定
  • 我能用"元素右移 + 最后插入"写出插入排序
  • 我知道为什么插入排序对接近有序的数据很友好
  • 我能写出标准的"是否存在"二分查找
  • 我能解释 left + ((right - left) >> 1) 的写法
  • 我能写出最左等值位置,并知道命中后为什么继续向左
  • 我能写出最右等值位置,并知道命中后为什么继续向右
  • 我理解"二分不要求整体有序,只要求能排除一半"
  • 我会先处理局部最小问题的两个端点,再搜索 [1 ... N - 2]
  • 我能根据 mid 和左右邻居的趋势判断局部最小应该去哪一侧找
  • 我知道多解问题的对数器应该验证"答案是否合法",而不是强制答案唯一
  • 我能解释 a ^ a = 0a ^ 0 = a
  • 我能用 O(N) 时间、O(1) 额外空间找出出现奇数次的数
  • 我会根据题型选择合适的基准方法构造对数器

一句话总结: Day 09 真正要修炼的不是八段代码,而是三种思维------排序时维护"已经确定的区域",二分时维护"答案一定还存在的搜索区间",异或时利用"成对抵消"的代数性质;只要不变式清楚,代码自然会变得短、稳、好证明。

相关推荐
DFT计算杂谈1 小时前
Janus单层Cr2SSe中的应变可调多压电效应与谷电子学
人工智能·算法·机器学习
今天AI了吗1 小时前
从 LLM 到 Agent Skill:把 AI 底层概念串起来
数据库·人工智能·sql·深度学习·神经网络·算法·机器学习
hanlin032 小时前
动态规划专练:力扣第1035、392题
算法·leetcode·动态规划
淡海水2 小时前
C#与常用数据结构源码剖析-全篇导览
开发语言·数据结构·unity·面试·职场和发展·c#
不正经学生2 小时前
C语言大小端字节序:内存里字节的排列顺序
c语言·开发语言·arm开发·c++·算法·c#
大模型探索者2 小时前
2026金融大模型训推平台选型指南:主流厂商横向对比与私有化落地
人工智能·算法·金融
依然鸣3 小时前
PTA团体程序设计天梯赛L2真题讲解L2-001-004
经验分享·学习·算法·深度优先·pat考试
一米阳光86613 小时前
软考(中级)软件设计师核心笔记(8)数据结构——图
数据结构·笔记·职场发展·软考·软件设计师·中级职称
杨航 AI5 小时前
** AI 面试算法 50 题清单**,不是单纯的 LeetCode 题号列表,而是按照“**考察概率 × 面试价值 × 你当前准备方向**”排序
算法·leetcode·面试