跳转至

复杂度与二分搜索

1. 什么是 \(O(n)\)\(O(\log n)\)

先看一段最简单的 Python 代码:

for i in range(x):
    # do something

这段代码的意思是: 把同一件事情重复做 x 次。

所以如果:

  • x = 10 → 做 10 次
  • x = 100 → 做 100 次
  • x = 1,000,000 → 做 1,000,000 次

你可以这样理解:

输入变大多少倍,工作量就变大多少倍。

这种增长方式,我们叫做:

\[ O(n) \]

更严谨一点的说法

这里的 n 并不是代码里的某个变量名,而是:输入的规模(input size)

比如:

nums = [3, 1, 5, 2]

for i in range(len(nums)):
    # 做一件事情

这段代码会:

  • 有 4 个元素 → 做 4 次
  • 有 100 个元素 → 做 100 次

所以:操作次数 \(\propto\) 输入大小,这就是 \(O(n)\)


2. 那什么是 \(O(\log n)\)

现在换一个思路。

不要一个一个检查,而是每一步都直接排除一半的可能性

举个非常直观的例子:假设你在猜一个 1 到 100 之间的数字,你可以这样猜:

  • 第一次猜 50 → 一下排掉一半
  • 如果答案更小 → 只剩 1–49
  • 再猜 25 → 又排掉一半
  • 再猜 12 → 再排一半
  • 再猜 6 → 再排一半
  • 再猜 3 → 再排一半
  • 再猜 1

整个过程是:

100 → 50 → 25 → 12 → 6 → 3 → 1

你只用了 大约 7 步。如果你用最笨的方法:一个一个试 → 最多要试 100 次;如果你每次砍一半:只需要 7 次左右。再看更大的数据,如果输入变大:

  • n = 10 → 大约 4 步
  • n = 100 → 大约 7 步
  • n = 1,000,000 → 大约 20 步

注意这里很关键的一点:

输入变成 10000 倍,步骤只多了一点点。

这种增长方式,我们叫:

\[ O(\log n) \]

刚才“猜数字”的过程,其实就是一种算法,叫做:

Binary Search(二分查找)

核心思想只有一句话:每一步都把可能的范围缩小一半。用更通俗的话说,不是“找答案”,而是不断缩小范围,直到只剩一个答案。

4. 我只是想知道这道题该怎么做,能不能不要讲这么多?

这题的核心其实非常短:

把最小值看成一个“谷底”,然后用二分去判断这个谷底在左边还是右边。

题目说,一个序列是 unimodal,意思是:

  • 前半段一直在下降
  • 后半段一直在上升

所以它的形状大概像这样:

9 7 5 3 4 6 8
      ^
     最小值

也就是说,这个数组一定像一个 V 字形,或者至少像“先往下走,再往上走”。

注意题目还说了:

decreasing part 或 increasing part 可以是空的。

这句话很重要,因为它说明:

  • [1,2,3] 也合法(只有上升,没有下降)
  • [4,3,2,1] 也合法(只有下降,没有上升)

所以你不能写一个只适用于“标准 V 字形”的奇怪特判代码,最好写一个统一处理所有情况的方法。

5. 为什么这题可以二分?

关键在于:我们并不需要“检查整个数组”,而是只需要看中间附近的趋势。

设中间位置是 mid,那么只看:

nums[mid]
nums[mid + 1]

就够了。

为什么?因为如果:

  • nums[mid] > nums[mid + 1]

说明你现在还在往下走,也就是说最小值一定在 右边

例如:

9 7 5 3 4 6 8
    ^
    mid = 5

这里 5 > 3,说明还没走到谷底,所以应该往右找。

反过来,如果:

  • nums[mid] < nums[mid + 1]

说明你现在已经开始往上走了,那最小值一定在 mid 或它左边

所以我们就得到了二分规则:

  • 如果 nums[mid] > nums[mid + 1],令 left = mid + 1
  • 否则,令 right = mid

每次都能丢掉一半区间,这就是标准的 \(O(\log n)\)

6. 这个规则为什么不会错?

你可以把数组想成一条山谷曲线。

在谷底左边,数组是下降的:

8 6 4 2

所以会出现:

nums[mid] > nums[mid + 1]

在谷底右边,数组是上升的:

2 3 5 7

所以会出现:

nums[mid] < nums[mid + 1]

也就是说,比较 midmid + 1,其实在问:

你现在是在谷底左边,还是谷底右边?

一旦这个问题能回答,我们就能把不可能包含答案的一半直接删掉。这就是为什么这题适合用 binary search。

7. 边界情况怎么办?

其实这个写法很自然地就把边界情况都处理掉了。

情况 1:整个数组一直上升

比如:

1 2 3 4 5

每次都会满足:

nums[mid] < nums[mid + 1]

所以 right 会不断往左收缩,最后停在 0,答案就是第一个元素。

情况 2:整个数组一直下降

比如:

5 4 3 2 1

每次都会满足:

nums[mid] > nums[mid + 1]

所以 left 会不断往右收缩,最后停在最后一个位置,答案就是最后一个元素。

8. 用样例模拟一遍

输入是:

9 7 5 3 4 6 8

一开始:

  • left = 0
  • right = 6

第一次

  • mid = 3
  • nums[mid] = 3
  • nums[mid + 1] = 4

因为:

3 < 4

说明已经在上升段,所以最小值在 mid 或左边。

于是:

  • right = 3

第二次

  • left = 0
  • right = 3
  • mid = 1
  • nums[mid] = 7
  • nums[mid + 1] = 5

因为:

7 > 5

说明还在下降段,所以最小值在右边。

于是:

  • left = 2

第三次

  • left = 2
  • right = 3
  • mid = 2
  • nums[mid] = 5
  • nums[mid + 1] = 3

因为:

5 > 3

所以:

  • left = 3

现在:

left == right == 3

循环结束,答案就是:

nums[3] = 3

9. 总结

这题虽然表面上是在找“最小值”,但真正利用的不是“最小值”本身,而是这个数组的形状:先下降,再上升,所以一定存在一个谷底。而 binary search 做的事情就是:每次看一眼中间附近是在往下走还是往上走,然后决定谷底在哪一边,最后就能快速找到答案。

评论