返回

PHP数组区间查找:3种方法快速定位数字范围

php

在数组中快速定位数字所属的范围

哥们儿,你是不是也碰到过这种事儿:手头有个数组,里面存了一堆数字,像 [0, 32, 64, 96, 128, ...] 这样。这些数字呢,定义了一段段的区间。然后,你又有另外一个数字,比如 44,想知道这个 44 究竟落在了哪个区间里?具体点说,就像题目里的,44 大于 32 但小于等于 64,所以它属于 64 这个“上限”所代表的范围。

这事儿吧,听起来绕,其实不难。下面咱就掰扯掰扯。

问题来了:怎么知道我的数字在哪一段?

咱们先把场景摆出来,就像你问的那样:

$ar = array(0, 32, 64, 96, 128, 160, 192, 224);
$num = 44;

目标就是要找出 $num (也就是 44) 应该归到哪个“档位”。看一眼就能明白,44 它比 32 大,又没超过 64(或者说小于等于64),所以我们希望得到的结果是 64。如果 $num32,那它应该也算在 32 这个档的末尾,即 (0, 32] 这个区间,上限是 32

为什么会碰到这个问题?

这种需求其实挺常见的。比如说:

  • 你要根据用户的消费金额给他划分会员等级,消费 0-100 是普通会员,101-500 是银牌,501-1000 是金牌。这里的 [0, 100, 500, 1000] 就是你的范围数组。
  • 处理数据分桶,把连续的数据点归到离散的桶里,方便统计分析。
  • 游戏里根据分数、经验值等确定玩家的段位或解锁的成就。

基本上,只要你想把一个数值映射到一个预设的、有序的等级或者类别,就可能用到这个逻辑。

解决方案:庖丁解牛

针对这个问题,有几种路子可以走,各有各的妙处。

方法一:简单直接的循环遍历

这是最直观,也最容易想到的法子。既然数组里的数是排好序的(从小到大),咱们就从头到尾扫一遍。

原理和作用

我们挨个儿比较数组里的元素。对于数组 $ar 中的任意两个相邻元素 $ar[$i-1]$ar[$i],如果我们的目标数字 $num 正好满足 $ar[$i-1] < $num <= $ar[$i],那 $ar[$i] 就是我们要找的那个范围的上限。

特别地,如果 $num 小于或等于数组的第一个元素 $ar[0],那么它就属于由 $ar[0] 定义的第一个范围。

代码示例 (PHP)

<?php
$ar = array(0, 32, 64, 96, 128, 160, 192, 224);
$num = 44;

function findRangeUpperBound_loop($arr, $number) {
    if (empty($arr)) {
        return null; // 空数组,啥也干不了
    }

    // 先处理特殊情况:如果数字小于等于数组的第一个元素
    // (假设第一个元素就是第一个区间的上限)
    if ($number <= $arr[0]) {
        return $arr[0];
    }

    // 遍历数组,从第二个元素开始,因为它需要和前一个元素构成区间
    for ($i = 1; $i < count($arr); $i++) {
        // 我们要找的是 $arr[$i-1] < $number <= $arr[$i]
        if ($number > $arr[$i-1] && $number <= $arr[$i]) {
            return $arr[$i];
        }
    }

    // 如果数字比数组里所有元素都大
    // 这种情况下,题目没明确说怎么办。可以返回null,或者最后一个元素,或者抛异常
    // 这里我们返回null,表示超出现有定义的最大范围
    // 或者,如果业务逻辑是“只要比最后一个小元素的都归到最后一个区间”,则可以返回 $arr[count($arr)-1]
    // 但按照题目例子44落在(32,64],返回64,则下面这样更合适:
    // 若 $num > $arr[count($arr)-1],表示没有合适的区间上限
    if ($number > $arr[count($arr)-1]) {
         // 这里可以根据实际业务定义,比如返回数组最后一个元素作为“无限大”区间的象征,
         // 或者严格按(L,R]则返回null。
         // 若允许等于最后一个元素,那么前面的循环在 $number <= $arr[count($arr)-1] 时会处理。
         // 如果是严格大于,那确实就是没找到。
        return null; // 或者你可以定义成抛出错误,或者返回一个特殊值
    }
    
    return null; // 默认没找到 (理论上如果$num不大于最大值,且数组有序,前面总能找到)
}

$upperBound = findRangeUpperBound_loop($ar, $num);

if ($upperBound !== null) {
    echo "数字 $num 属于上限为 $upperBound 的范围。\n"; // 输出: 数字 44 属于上限为 64 的范围。
} else {
    echo "数字 $num 未能在数组定义的范围内找到合适的上限。\n";
}

// 测试其他几个数
$num1 = 32;
$upperBound1 = findRangeUpperBound_loop($ar, $num1); // 应该得到 32
echo "数字 $num1 属于上限为 $upperBound1 的范围。\n";

$num2 = 0;
$upperBound2 = findRangeUpperBound_loop($ar, $num2); // 应该得到 0
echo "数字 $num2 属于上限为 $upperBound2 的范围。\n";

$num3 = 250;
$upperBound3 = findRangeUpperBound_loop($ar, $num3); // 应该得到 null (或根据业务调整)
echo "数字 $num3 ";
if ($upperBound3 !== null) {
    echo "属于上限为 $upperBound3 的范围。\n";
} else {
    echo "未能在数组定义的范围内找到合适的上限。\n";
}

$num4 = -5;
$upperBound4 = findRangeUpperBound_loop($ar, $num4); // 应该得到 0
echo "数字 $num4 属于上限为 $upperBound4 的范围。\n";
?>

安全建议

这种方法本身没啥安全风险,就是个纯粹的数值比较。顶多注意下输入的 $arr 是不是个数组,$number 是不是个数字,别因为类型不对搞出些 PHP 的 WarningNotice

进阶使用技巧

  1. 处理未排序数组 :如果你的 $ar 没准儿不是排好序的,那在用这个方法之前,你得先给它排个序:sort($ar, SORT_NUMERIC);。不然结果肯定不对。
  2. 空数组或单一元素数组 :上面的代码里简单处理了空数组。如果数组只有一个元素,比如 $ar = [64];,那么任何小于等于 64 的数都应该归到 64 这个范围。代码里的 if ($number <= $arr[0]) 就能处理好。循环 for ($i = 1; ...) 则不会执行。
  3. 数字超出所有范围 :如果 $num 比数组里最大的数还大,比如 $ar 最大是 224$num300。这种情况下,函数返回 null。你可以根据业务需求决定是返回 null、最后一个元素、还是抛出一个异常,告诉调用方“这数太大,没坑给它了!”。

方法二:更高效的二分查找法

如果你的数组 $ar 特别长,几千上万个元素,那挨个儿循环就有点慢了。这时候,二分查找(Binary Search)就能派上用场。

原理和作用

二分查找的前提是数组必须是有序的 。它的核心思想是不断地把查找范围缩小一半。

  1. 先看数组中间那个元素。
  2. 如果你的 $num 正好卡在这个中间元素定义的范围,那运气不错,找到了。
  3. 如果 $num 比中间元素小(或者应该在更左边的区间),那就在数组的左半边继续找。
  4. 如果 $num 比中间元素大(或者应该在更右边的区间),那就在数组的右半边继续找。
  5. 重复这个过程,直到找到目标,或者范围小到不能再分了。

我们要找的不是 $num 是否存在于数组中,而是它所属的那个 ($L, $R] 区间的 $R。换句话说,我们要找到数组中第一个大于等于 $num 的元素。这个元素就是我们要的上限 $R

代码示例 (PHP)

<?php
$ar = array(0, 32, 64, 96, 128, 160, 192, 224);
$num = 44;

function findRangeUpperBound_binarySearch($arr, $number) {
    if (empty($arr)) {
        return null;
    }
    
    // 数组必须是有序的。如果不能保证,先排序。
    // sort($arr, SORT_NUMERIC); // 如果需要的话

    // 特殊情况:如果数字小于等于数组的第一个元素
    if ($number <= $arr[0]) {
        return $arr[0];
    }

    // 特殊情况:如果数字大于数组的最后一个元素
    if ($number > $arr[count($arr) - 1]) {
        return null; // 超出最大范围
    }

    $low = 0;
    $high = count($arr) - 1;
    $ans = null; // 用来存储可能的答案

    // 目标是找到最小的 $arr[$mid] 使得 $arr[$mid] >= $number
    // 并且,如果 $arr[$mid] 是这个答案,那么 $arr[$mid-1] < $number (除非 $mid=0)
    // 实际上我们是寻找 $arr[i-1] < $number <= $arr[i] 中的 $arr[i]

    while ($low <= $high) {
        $mid = $low + floor(($high - $low) / 2);

        if ($arr[$mid] >= $number) {
            // $arr[$mid] 是一个潜在的上限
            // 我们需要确认它是不是最小的那个符合条件的上限
            // 同时,我们要确保它前一个元素(如果存在)是小于 $numberif ($mid == 0 || $arr[$mid-1] < $number) {
                 // 如果 $arr[$mid] >= $number
                 // 并且 ($mid是第一个元素 OR $arr[$mid-1] < $number)
                 // 这就意味着 $number 在 ($arr[$mid-1], $arr[$mid]] 这个区间里
                 // (注意 $number <= $arr[mid] 是隐含在 $arr[mid] >= $number 里的)
                $ans = $arr[$mid];
                break; // 直接找到了,可以跳出
            } else {
                // $arr[$mid] >= $number 但是 $arr[$mid-1] >= $number 也成立
                // 说明真正的上限还在左边
                $ans = $arr[$mid]; // 记录一个可能的答案
                $high = $mid - 1; // 继续往左边找更小的上限
            }
        } else { // $arr[$mid] < $number
            // 当前中间值太小了,目标在右半边
            $low = $mid + 1;
        }
    }
    // 上面的循环找到的是 $arr[i-1] < $number <= $arr[i] 中的 $arr[i]
    // 但一种更简洁的二分查找变体是直接找到第一个 >= number 的元素
    // 我们来改写一下以找到那个点:

    $low = 0;
    $high = count($arr) - 1;
    $result_candidate_index = -1;

    while ($low <= $high) {
        $mid_idx = $low + floor(($high - $low) / 2);
        if ($arr[$mid_idx] >= $number) {
            // $arr[$mid_idx] 是一个候选的上限值
            // 因为我们要找第一个 (最小的) 大于等于 $number 的值
            // 所以记录下来,然后尝试在左边找有没有更小的
            $result_candidate_index = $mid_idx;
            $high = $mid_idx - 1; 
        } else { // $arr[$mid_idx] < $number
            // 中间值太小了,目标上限肯定在右边
            $low = $mid_idx + 1;
        }
    }
    
    if ($result_candidate_index != -1) {
        // 我们找到了第一个 $arr[index] >= $number
        // 现在要确认 $number > $arr[index-1] (如果 index > 0)
        // 或者 $number <= $arr[0] (如果 index == 0)
        $found_upper_bound = $arr[$result_candidate_index];

        if ($result_candidate_index == 0) { // 找到了,且是数组第一个元素
            return $found_upper_bound; // e.g. num = -5, ar=[0,32..] -> returns 0
                                     // e.g. num = 0, ar=[0,32..] -> returns 0
        } else {
            // 如果 $number <= $arr[$result_candidate_index - 1],这意味着 $number 也小于等于前一个区间的上限
            // 这不符合我们 $L < $num <= $R 的定义。
            // 举例: $ar = [0, 32, 64], $num = 30. 
            //  Binary search for >=30 finds 32 (index 1). $arr[index-1] = $arr[0] = 0. 0 < 30. Correct.
            // 举例: $ar = [0, 32, 64], $num = 32.
            //  Binary search for >=32 finds 32 (index 1). $arr[index-1] = $arr[0] = 0. 0 < 32. Correct.
            // 所以,只要 $result_candidate_index 有效,且这个元素符合 >= $number,它就是上限。
            //  $number > $arr[$result_candidate_index - 1] 这个条件是必须的。
            if ($number > $arr[$result_candidate_index - 1]) {
                return $found_upper_bound;
            } else {
                // 这种情况比较tricky,比如 $arr = [0,32,32,64], $num = 32
                // "第一个大于等于32的"可能是 index 1 (value 32)。 $arr[0]=0 < 32.  OK, return $arr[1]=32.
                // 如果数组是严格递增的, $arr[idx-1] < $arr[idx] ,那么 $arr[idx-1] < $number 肯定成立(除非num非常小,已被开头处理)
                // 如果 $arr = [0, 10, 20, 20, 30] and $num = 20. 
                // BS might find $arr[2]=20. $arr[1]=10 < 20. Returns $arr[2]=20. This is one correct range.
                // The problem implicitly assumes $ar does not have duplicates for defining distinct ranges.
                // If $ar = [0, 32, 64], and $num = 32.
                // Our search for first $x >= 32 gives $x=32 at index 1.
                // $ar[1-1] = $ar[0] = 0. Is $num > $ar[0]? $32 > 0$, yes. So return $ar[1]=32.
                // This is fine. The binary search correctly identified the smallest element that acts as an upper bound.
                return $found_upper_bound; // Fallback from more complex logic to simple "first >= val" logic
            }
        }
    }
    
    return null; // Should be covered by initial checks if $number is too large
}

$upperBound = findRangeUpperBound_binarySearch($ar, $num);
if ($upperBound !== null) {
    echo "数字 $num 属于上限为 $upperBound 的范围 (二分查找)。\n"; // 输出: 数字 44 属于上限为 64 的范围 (二分查找)。
} else {
    echo "数字 $num 未能在数组定义的范围内找到合适的上限 (二分查找)。\n";
}

// 测试其他几个数
$num1 = 32;
$upperBound1 = findRangeUpperBound_binarySearch($ar, $num1); 
echo "数字 $num1 属于上限为 $upperBound1 的范围 (二分查找)。\n"; // 32

$num2 = 0;
$upperBound2 = findRangeUpperBound_binarySearch($ar, $num2); 
echo "数字 $num2 属于上限为 $upperBound2 的范围 (二分查找)。\n"; // 0

$num3 = 250;
$upperBound3 = findRangeUpperBound_binarySearch($ar, $num3);
echo "数字 $num3 ";
if ($upperBound3 !== null) {
    echo "属于上限为 $upperBound3 的范围 (二分查找)。\n";
} else {
    echo "未能在数组定义的范围内找到合适的上限 (二分查找)。\n"; // null
}

$num4 = -5;
$upperBound4 = findRangeUpperBound_binarySearch($ar, $num4);
echo "数字 $num4 属于上限为 $upperBound4 的范围 (二分查找)。\n"; // 0
?>

安全建议

跟循环遍历一样,主要还是数据类型校验。二分查找本身是安全的。

进阶使用技巧

  1. 确保数组有序 :再次强调,二分查找的命根子就是数组有序。如果拿到的数据不能保证这一点,用之前务必 sort()
  2. 处理重复值 :如果范围数组 $ar 里有重复值,比如 [0, 32, 32, 64],那么 32 这个值就可能出现在多个位置。上面的二分查找版本(第二个实现)设计为寻找第一个大于或等于 $number 的元素,这对于定义 (L, R] 区间的 R 是合适的。例如,如果 $num = 32,它会找到第一个 32 作为上限,其对应的下限是 0,所以区间是 (0, 32]
  3. 理解边界 :二分查找的边界条件 ($low <= $high 还是 $low < $high,以及 $high = $mid - 1 还是 $mid) 写起来容易犯错。多测试几个边界值(比如 $num 等于数组元素、小于第一个、大于最后一个)能帮你逮住虫子。我提供的第二个二分查找实现更标准地用于寻找 "lower_bound" (C++ STL 的概念),即第一个不小于目标值的元素。

方法三:巧用 PHP 内置函数组合 (针对 PHP)

PHP 自带了不少好用的数组函数,组合一下也能解决问题。

原理和作用

思路是:

  1. 筛选出数组 $ar 中所有大于等于 $num 的元素。
  2. 从这些筛选出来的元素中,找到最小的那个。这个最小的元素就是我们要的上限 $R
  3. 需要额外判断一下,筛选出的这个最小元素 $R_found,它在原数组中的前一个元素 $L_candidate 是否真的小于 $num,这才构成 $L_candidate < $num <= R_found

代码示例 (PHP)

<?php
$ar = array(0, 32, 64, 96, 128, 160, 192, 224);
$num = 44;

function findRangeUpperBound_phpBuiltin($arr, $number) {
    if (empty($arr)) {
        return null;
    }

    // 先处理 $number <= $arr[0] 的情况
    if ($number <= $arr[0]) {
        return $arr[0];
    }
    
    // 筛选出所有大于等于 $number 的元素
    $greaterOrEqualElements = array_filter($arr, function($value) use ($number) {
        return $value >= $number;
    });

    if (empty($greaterOrEqualElements)) {
        // 没有元素大于等于 $number,说明 $number 比数组中所有数都大
        return null; // 超出最大范围
    }

    // 从筛选结果中找到最小的那个,这个就是潜在的上限 $R
    $potentialUpperBound = min($greaterOrEqualElements);

    // 现在验证这个 $potentialUpperBound 是否真的构成 $L < $num <= $potentialUpperBound
    // 我们需要找到 $potentialUpperBound 在原数组 $arr 中的位置,然后看它前一个元素
    $upperBoundKey = array_search($potentialUpperBound, $arr);

    // array_search 可能返回 false 如果元素不存在 (理论上这里一定存在)
    // 或者返回 0 如果它是第一个元素 (已被开头的if处理)
    if ($upperBoundKey !== false && $upperBoundKey > 0) {
        $lowerBoundCandidate = $arr[$upperBoundKey - 1];
        if ($number > $lowerBoundCandidate) {
            return $potentialUpperBound;
        } else {
            // 这种情况例如: $ar = [0, 32, 64], $num = 32.
            // $greaterOrEqualElements = [32, 64]. $potentialUpperBound = 32. $upperBoundKey = 1.
            // $lowerBoundCandidate = $ar[0] = 0. $number (32) > $lowerBoundCandidate (0). return 32.  Correct.
            //
            // 如果 $arr = [0, 30, 32, 64], $num = 31
            // $greaterOrEqualElements = [32, 64]. $potentialUpperBound = 32. $upperBoundKey = 2.
            // $lowerBoundCandidate = $ar[1] = 30. $number (31) > $lowerBoundCandidate (30). return 32. Correct.

            // 如果 $num 正好等于某个数组元素 $arr[k],且 $arr[k-1] == $arr[k] (有重复)
            // 比如 $arr = [0, 32, 32, 64], $num = 32
            // $greaterOrEqualElements = [32, 32, 64]. $potentialUpperBound = 32.
            // array_search for 32 might return key 1. $arr[0]=0. 32 > 0. Returns 32. (Correct: range (0,32])
            // This logic generally holds.
            return $potentialUpperBound;
        }
    } elseif ($upperBoundKey === 0) { // $potentialUpperBound 是数组第一个元素
        return $potentialUpperBound; // 这已经被 $number <= $arr[0] cover了,但作为完整性。
    }
    
    return null; // 理论上不应该到这里如果之前的逻辑都对
}

$upperBound = findRangeUpperBound_phpBuiltin($ar, $num);
if ($upperBound !== null) {
    echo "数字 $num 属于上限为 $upperBound 的范围 (内置函数)。\n"; // 输出: 数字 44 属于上限为 64 的范围 (内置函数)。
} else {
    echo "数字 $num 未能在数组定义的范围内找到合适的上限 (内置函数)。\n";
}

// 测试其他几个数
$num1 = 32;
$upperBound1 = findRangeUpperBound_phpBuiltin($ar, $num1);
echo "数字 $num1 属于上限为 $upperBound1 的范围 (内置函数)。\n"; // 32

$num2 = 0;
$upperBound2 = findRangeUpperBound_phpBuiltin($ar, $num2);
echo "数字 $num2 属于上限为 $upperBound2 的范围 (内置函数)。\n"; // 0

$num3 = 250;
$upperBound3 = findRangeUpperBound_phpBuiltin($ar, $num3);
echo "数字 $num3 ";
if ($upperBound3 !== null) {
    echo "属于上限为 $upperBound3 的范围 (内置函数)。\n";
} else {
    echo "未能在数组定义的范围内找到合适的上限 (内置函数)。\n"; // null
}

$num4 = -5;
$upperBound4 = findRangeUpperBound_phpBuiltin($ar, $num4);
echo "数字 $num4 属于上限为 $upperBound4 的范围 (内置函数)。\n"; // 0

?>

安全建议

还是老样子,注意输入类型。array_filtermin 对非数字或非数组输入会报警。

进阶使用技巧

  1. 性能考量array_filter 会遍历整个数组创建一个新数组,然后 min 再遍历这个新数组。如果原数组非常大,这个性能可能不如二分查找。但对于中小型数组,代码写起来简洁明了。
  2. 数组键的保留array_filter 默认会保留原数组的键。如果你后面需要用键做些文章,这是个优点。
  3. 替代方案 array_reduce? :也可以用 array_reduce 实现类似逻辑,但可能会更绕一点,可读性上不一定有优势。

选哪个方案?小结一下

到底用哪种方法,得看你的具体情况:

  • 数组不大,代码想简单点? —— 用 循环遍历法 。好懂,不容易错。
  • 数组巨大,追求极致性能? —— 用 二分查找法 。前提是数组有序,稍微复杂点,但快。
  • 写 PHP,想用现成的轮子,数组大小适中? —— 用 PHP 内置函数组合 。代码相对简洁,但也得注意性能。

总的来说,没有万能钥匙,挑个顺手的、能解决你问题的就行。关键是理解每种方法背后的逻辑和它的适用场景。搞定了这些,下次再碰到类似问题,你就能胸有成竹地选出最合适的家伙什儿了。