Blog

C# 二分查找:不只是查找元素

在有序数组里找一个值,是二分查找最没用的用法。真正重要的模式搜索的是一个从没被构建出来的答案空间,根本不需要有序数组。

几乎人人都会在有序数组上写二分查找。可题目只要不提数组,几乎没人会想到用它。

本篇讲的就是这个落差。这里的五个模式里,有四个根本不在集合里查找。

下面每个程序都是完整的,都在 .NET 10 上跑过,输出直接从运行结果里粘贴。

模式 16 — Array.BinarySearch 能给你什么,不能给你什么

BCL 里已经有现成的,而它在没找到时的返回值,是整个类里最被浪费的功能。

int[] a = [10, 20, 20, 20, 30, 40];

// The BCL search. On a miss it returns the bitwise complement of where the
// value WOULD go — which is the insertion point, not an error.
foreach (int want in new[] { 30, 25, 5, 50 })
{
    int r = Array.BinarySearch(a, want);
    Console.WriteLine(r >= 0
        ? $"BinarySearch({want,2}) = {r,2}   found at index {r}"
        : $"BinarySearch({want,2}) = {r,2}   not found; ~{r} = {~r} is where it would go");
}

// With duplicates, BinarySearch promises nothing about WHICH match you get.
// These two do.
static int LowerBound(int[] a, int x)      // first index with a[i] >= x
{
    int lo = 0, hi = a.Length;
    while (lo < hi)
    {
        int mid = lo + (hi - lo) / 2;
        if (a[mid] < x) lo = mid + 1; else hi = mid;
    }
    return lo;
}

static int UpperBound(int[] a, int x)      // first index with a[i] > x
{
    int lo = 0, hi = a.Length;
    while (lo < hi)
    {
        int mid = lo + (hi - lo) / 2;
        if (a[mid] <= x) lo = mid + 1; else hi = mid;
    }
    return lo;
}

Console.WriteLine();
Console.WriteLine($"a = [{string.Join(", ", a)}]");
foreach (int x in new[] { 20, 25 })
{
    int lb = LowerBound(a, x), ub = UpperBound(a, x);
    Console.WriteLine($"x={x,2}  lower={lb}  upper={ub}  count={ub - lb}");
}

输出:

BinarySearch(30) =  4   found at index 4
BinarySearch(25) = -5   not found; ~-5 = 4 is where it would go
BinarySearch( 5) = -1   not found; ~-1 = 0 is where it would go
BinarySearch(50) = -7   not found; ~-7 = 6 is where it would go

a = [10, 20, 20, 20, 30, 40]
x=20  lower=1  upper=4  count=3
x=25  lower=4  upper=4  count=0

负数结果不是错误码。它是 ~insertionPoint,也就是这个值应该插入的位置按位取反。~(-5)4,所以 25 应该放在索引 4。很多题里,这一行就省掉了第二次查找。

Array.BinarySearch 不会告诉你找到的是哪一个重复值。数组里有三个 20,文档只保证你拿到其中一个。所以值得自己手写这两个边界函数,而且两个配合起来能回答的问题,比单独任何一个都多。

0 1 2 3 4 5 x=20 10 20 20 20 30 40 lower upper count = upper − lower = 3 x=25 10 20 20 20 30 40 lower upper 不存在:lower = upper,count = 0

lower 是第一个不小于 x 的索引,upper 是第一个大于 x 的索引。两者之差就是副本个数;两者相等时,这个值不存在,而这个位置也恰好是它应该插入的地方。

注意两个函数都是 while (lo < hi),而且 hia.Length 开始,不是 a.Length - 1。这是故意的:答案完全可能是“越过末尾”,查 50 时就是这样。

代价: O(log n)。

适用场景: 你需要统计相等值的个数、找插入位置,或者找第一个不小于某个界限的元素。upper - lower 就是个数,不用再单独扫一遍。

模式 17 — 二分答案

真正重要的是这一个。

包裹必须按顺序在 D 天内运完。求能按时运完所有包裹的最小每日运力。

这里没有数组可查。但看看这个问题的形状。如果运力 20 可行,那 21 也可行,22 也可行,往上全都可行。如果 14 不行,13 也不行,往下全都不行。所以把答案按顺序排开,是这个样子:

capacity:  10  11  12  13  14  15  16  17  18  19  20
works?      F   F   F   F   F   T   T   T   T   T   T

它恰好翻转一次。而找出只翻转一次的那个位置,这就是二分查找本身。有序数组从来不是前提,它只是得到这个性质的一种方式。

0 1 2 3 4 5 6 7 8 9 10 运力 10 11 12 13 14 15 16 17 18 19 20 5 天能运完? F F F F F T T T T T T 答案

二分查找不需要有序数组,它需要的是一个答案恰好翻转一次的问题。这里从来没有构建数组:谓词按需计算,查找要找的是最后一个 F 和第一个 T 之间的边界。

int[] weights = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
int days = 5;

// Can every package be shipped within `days` at this capacity? Packages must
// go in order, so this is a simple greedy pass.
static bool Feasible(int[] w, int days, int cap)
{
    int used = 1, load = 0;
    foreach (int x in w)
    {
        if (x > cap) return false;
        if (load + x > cap) { used++; load = 0; }
        load += x;
    }
    return used <= days;
}

int lo = weights.Max();          // cannot be less than the heaviest single item
int hi = weights.Sum();          // one day is always enough

Console.WriteLine($"searching capacities {lo}..{hi}\n");

while (lo < hi)
{
    int mid = lo + (hi - lo) / 2;
    bool ok = Feasible(weights, days, mid);
    Console.WriteLine($"lo={lo,2} hi={hi,2}  try {mid,2}  ->  {(ok ? "fits, so nothing bigger is needed: hi = mid" : "too small: lo = mid + 1")}");
    if (ok) hi = mid; else lo = mid + 1;
}

Console.WriteLine($"\nsmallest capacity that works: {lo}");
Console.WriteLine($"check {lo}: {Feasible(weights, days, lo)}   check {lo - 1}: {Feasible(weights, days, lo - 1)}");

输出:

searching capacities 10..55

lo=10 hi=55  try 32  ->  fits, so nothing bigger is needed: hi = mid
lo=10 hi=32  try 21  ->  fits, so nothing bigger is needed: hi = mid
lo=10 hi=21  try 15  ->  fits, so nothing bigger is needed: hi = mid
lo=10 hi=15  try 12  ->  too small: lo = mid + 1
lo=13 hi=15  try 14  ->  too small: lo = mid + 1

smallest capacity that works: 15
check 15: True   check 14: False

这个方法靠三样东西成立,它们也是这类问题的检查清单:

  1. 可行性判定。 Feasible 对一个候选值回答“行”或“不行”。它只是一遍普通的贪心,慢一点也没关系,因为只会运行 O(log 范围) 次。
  2. 单调性。 一旦可行,往上就一直可行。如果这一点不成立,整个方法就无效。写任何代码之前,先检查的就是它。
  3. 显然正确的边界。 lo 取最重的单个包裹,因为比它小的运力永远运不走这个包裹。hi 取总重量,因为这样一天总能运完。两个边界都不需要紧,只需要对。

最后打印的两行是值得保留的习惯:断言答案可行,而比它小 1 的值不可行。差一错误马上就能发现。

代价: O(可行性判定 × log 范围)。

适用场景: 题目说最小化最大值最大化最小值,或者满足条件的最小 X。看到这种说法,基本就能确定。

模式 18 — 旋转数组

一个有序数组在某个未知位置被旋转过。要在 O(log n) 内找到目标值。

直觉是先找旋转点,再查找。这样能行,但要查两次。下面的做法只查一次。

在任何一个切分点,旋转造成的断点只能落在其中一半,因为断点只有一个。所以另一半是正常有序的,可以照常推理。

45 67 01 2 01 23 45 6 lo mid hi a[lo] ≤ a[mid],所以这一半有序 旋转只有一个断点,所以断点只能落在其中一半。 找到有序的那一半,判断目标值是否在它的范围内,无论结果如何都能丢掉一边。

比较 a[lo] ≤ a[mid] 就是全部的诀窍。它不检查目标值,而是确定哪一半可以照常推理。

int[] a = [4, 5, 6, 7, 0, 1, 2];

static int Search(int[] a, int target)
{
    int lo = 0, hi = a.Length - 1;
    while (lo <= hi)
    {
        int mid = lo + (hi - lo) / 2;
        if (a[mid] == target) return mid;

        // Exactly one half is guaranteed to be sorted. Find it, then ask
        // whether the target lies inside it.
        if (a[lo] <= a[mid])
        {
            Console.WriteLine($"  lo={lo} mid={mid} hi={hi}  left half [{a[lo]}..{a[mid]}] is sorted");
            if (a[lo] <= target && target < a[mid]) hi = mid - 1; else lo = mid + 1;
        }
        else
        {
            Console.WriteLine($"  lo={lo} mid={mid} hi={hi}  right half [{a[mid]}..{a[hi]}] is sorted");
            if (a[mid] < target && target <= a[hi]) lo = mid + 1; else hi = mid - 1;
        }
    }
    return -1;
}

Console.WriteLine($"a = [{string.Join(", ", a)}]\n");
foreach (int t in new[] { 0, 6, 3 })
{
    Console.WriteLine($"target {t}:");
    Console.WriteLine($"  -> {Search(a, t)}\n");
}

输出:

a = [4, 5, 6, 7, 0, 1, 2]

target 0:
  lo=0 mid=3 hi=6  left half [4..7] is sorted
  lo=4 mid=5 hi=6  left half [0..1] is sorted
  -> 4

target 6:
  lo=0 mid=3 hi=6  left half [4..7] is sorted
  lo=0 mid=1 hi=2  left half [4..5] is sorted
  -> 2

target 3:
  lo=0 mid=3 hi=6  left half [4..7] is sorted
  lo=4 mid=5 hi=6  left half [0..1] is sorted
  lo=6 mid=6 hi=6  left half [2..2] is sorted
  -> -1

比较 a[lo] <= a[mid] 跟目标值完全无关。它问的是哪一半完好无损。确定之后,才拿目标值和那一半已知的范围比较。

target 3 这次运行:它什么也没找到,但每一步仍然把查找范围减半,最后只比较了三次,而不是七次。

代价: O(log n)。

适用场景: 数据有序但被平移过:旋转数组、环形缓冲区、写满后从头覆盖的日志文件。

模式 19 — 实数上的二分查找

思路相同,只是定义域变成连续的。没有“下一个”值可以跳过去,所以循环不能靠 lo == hi 结束。

错误的修法是 while (hi - lo > 1e-9)。接近双精度的极限时,(lo + hi) / 2 可能恰好等于 lo,区间不再缩小,循环永远不会结束。你自己写的测试全都能过,一交到评测机上就卡死。

正确的修法是不再数精度,改数迭代次数

// A FIXED iteration count, not an epsilon. 100 halvings takes any starting
// interval below 2^-100, which is far under what a double can represent — so
// this cannot spin forever, and it needs no tolerance argument.
static double Root(double x)
{
    double lo = 0, hi = Math.Max(1.0, x);
    for (int it = 0; it < 100; it++)
    {
        double mid = (lo + hi) / 2;
        if (mid * mid < x) lo = mid; else hi = mid;
    }
    return lo;
}

foreach (double x in new[] { 2.0, 10.0, 0.25, 1e6 })
    Console.WriteLine($"root({x,9}) = {Root(x):F10}   Math.Sqrt = {Math.Sqrt(x):F10}");

Console.WriteLine();
Console.WriteLine($"{"iterations",11}  {"interval width",18}");
double w = 1.0;
foreach (int n in new[] { 10, 30, 50, 100 })
{
    w = Math.Pow(2, -n);
    Console.WriteLine($"{n,11}  {w,18:E3}");
}

输出:

root(        2) = 1.4142135624   Math.Sqrt = 1.4142135624
root(       10) = 3.1622776602   Math.Sqrt = 3.1622776602
root(     0.25) = 0.5000000000   Math.Sqrt = 0.5000000000
root(  1000000) = 1000.0000000000   Math.Sqrt = 1000.0000000000

 iterations      interval width
         10          9.766E-004
         30          9.313E-010
         50          8.882E-016
        100          7.889E-031

减半一百次,任何初始区间都会缩小到原来的 2⁻¹⁰⁰,大约是 7.9 × 10⁻³¹,远小于 double 能表示的任何精度。所以一百次迭代总是够用,几乎不花时间,也不可能死循环。通常五十次就绰绰有余。直接用一百次,不用再多想。

代价: O(迭代次数),一个固定常数。

适用场景: 答案是实数,比如速率、比值、距离、时间。

模式 20 — 三分查找,答案不单调的时候

二分查找需要“是/否”的答案只翻转一次。有些问题给不了这个条件。一个先降后升的函数没有翻转点,它有的是一个最小值,而且在最小值两侧,函数的走向都是错的。

探测一个点,分不清你在最小值的哪一侧。探测两个点就可以。

lo m1 m2 hi 舍弃 f(m1) < f(m2),所以最小值不可能在 m2 右侧。hi = m2。

二分查找需要“是/否”问题的答案只翻转一次。三分查找要求更少:只要函数先降后升。两次探测就能告诉你,哪一侧外面的三分之一不可能包含最小值。

// Unimodal: falls, then rises. Binary search needs monotonic, which this is
// not — but the minimum can still be bracketed, by comparing two interior
// points instead of one.
static double F(double x) => (x - 2.5) * (x - 2.5) + 1;

double lo = 0, hi = 10;
for (int it = 0; it < 200; it++)
{
    double m1 = lo + (hi - lo) / 3;
    double m2 = hi - (hi - lo) / 3;
    if (F(m1) < F(m2)) hi = m2; else lo = m1;
    if (it < 4)
        Console.WriteLine($"it={it}  m1={m1:F4} f={F(m1):F4}   m2={m2:F4} f={F(m2):F4}   -> [{lo:F4}, {hi:F4}]");
}

double x = (lo + hi) / 2;
Console.WriteLine($"\nminimum at x = {x:F8}, f(x) = {F(x):F8}");

输出:

it=0  m1=3.3333 f=1.6944   m2=6.6667 f=18.3611   -> [0.0000, 6.6667]
it=1  m1=2.2222 f=1.0772   m2=4.4444 f=4.7809   -> [0.0000, 4.4444]
it=2  m1=1.4815 f=2.0374   m2=2.9630 f=1.2143   -> [1.4815, 4.4444]
it=3  m1=2.4691 f=1.0010   m2=3.4568 f=1.9154   -> [1.4815, 3.4568]

minimum at x = 2.50000001, f(x) = 1.00000000

仔细看这个答案:x = 2.50000001,但 f(x) = 1.00000000

函数值精确到十六位,位置却只精确到八位。这不是循环的 bug,多迭代几次也修不好。光滑函数在最小值附近是平的,一大段 x 算出来的值,double 根本分不出差别。位置最多只能精确到机器精度(machine epsilon)的平方根左右。

如果题目要的是最小,三分查找是精确的。如果问的是最小值在哪里,你只能拿到一半的有效数字。

代价: O(迭代次数)。每一步保留区间的三分之二,所以收敛比二分查找慢,但仍然是几何级数收敛。

适用场景: 所求的量明显是先降后升的。单峰是硬性要求:在有两个低谷的函数上,它会稳稳地收敛到错误的那一个。

要点

  • Array.BinarySearch 返回负数时,结果是 ~insertionPoint 不是错误。取一次 ~,就得到它该放的位置。
  • lowerupper 边界能回答 BCL 查找回答不了的问题。 upper - lower 是副本个数,两者相等说明不存在。
  • 二分查找不需要有序数组。 它需要的是一个恰好翻转一次的“是/否”问题。这个要求弱得多,所以这个模式也能用在根本没有集合的问题上。
  • 写任何代码之前,先检查单调性。 如果“20 可行”推不出“21 可行”,代码写得再仔细,查找也是错的。
  • 边界松没关系,错了才有关系。 最重的单件和总重量都显然正确,而 O(log) 让多出来的余量几乎不花代价。
  • 在实数上,数迭代次数,不要数精度。 一百次总是够用,永远不会卡死。用 epsilon 做条件就可能卡死。
  • 三分查找给出的最小值位置,有效数字只有最小值本身的一半。 原因是最小值附近函数平坦,不是代码写错了。

第 5 篇从查找转向容器:栈、队列,以及一遍扫描就能回答“下一个更大的是什么”的单调结构。

这篇文章对你有帮助吗?

点一颗爱心来评分!

平均评分 0 / 5. 投票总数: 0

还没有人投票。来做第一个评分的人吧。