二分查找要求数据有序,这一点不会因为用指针还是下标而改变。用指针实现时,最常见的思路是维护一个左指针和一个右指针,左指针初始指向数组首元素,右指针初始指向数组尾元素的下一个位置,形成一个左闭右开的区间。每次计算中间位置时,不能简单地把两个指针相加再除以二,因为两个指针相加没有定义,必须借助数组首地址的偏移量来计算。例如中间指针可以用数组首地址加上左右偏移量差值的一半得到,这样既满足指针运算规则,又能准确定位到中间元素。

边界处理是二分查找最容易出错的地方。采用左闭右开区间时,只要左指针小于右指针,区间内就至少有一个元素,循环条件写成 left < right 即可。当目标值大于中间元素时,左指针移动到中间指针的下一个位置;当目标值小于中间元素时,右指针直接移动到中间指针的位置;当目标值等于中间元素时,立即返回中间指针。这个移动规则保证了每次迭代后区间长度严格缩小,不会出现死循环,也避免了漏掉第一个或最后一个元素的情况。
指针版本的代码实现与逐步解析
下面给出一段完整的C语言实现,数组假设为升序排列。函数接收数组首地址、数组长度和目标值,返回指向目标值所在位置的指针,如果没有找到则返回NULL。
#include <stdio.h>
int *binary_search_pointer(int *arr, int n, int target) {
int *left = arr;
int *right = arr + n; /* 左闭右开区间,right指向末尾元素之后 */
while (left < right) {
int *mid = arr + ((right - left) / 2); /* 借助首地址计算中间指针 */
if (*mid == target) {
return mid;
} else if (*mid < target) {
left = mid + 1; /* 目标在右半部分 */
} else {
right = mid; /* 目标在左半部分 */
}
}
return NULL;
}
int main(void) {
int a[] = {2, 5, 8, 12, 19, 23, 37, 45, 58};
int n = sizeof(a) / sizeof(a[0]);
int target = 19;
int *pos = binary_search_pointer(a, n, target);
if (pos != NULL) {
printf("找到了,索引为: %ld\n", pos - a);
} else {
printf("未找到\n");
}
return 0;
}
代码中 right = arr + n 是合法的,因为数组元素个数为 n,最后一个元素的地址是 arr + n - 1,arr + n 刚好是尾后地址,只用于比较,不进行解引用。计算中间指针时使用 arr + ((right - left) / 2),而不是 (left + right) / 2,因为两个指针相加属于未定义行为,必须先算出偏移量再和首地址相加。偏移量 (right - left) / 2 是整数除法,结果向下取整,保证了中间指针不会超出右边界。
当数组长度为奇数或偶数时,这个中间位置的取法略有不同。比如长度5,初始 left 指向索引0,right 指向索引5,偏移量差为5,除以2得2,arr + 2 即索引2。长度6时,偏移量差为6,除以2得3,arr + 3 即索引3。无论哪种情况,中间位置都落在区间内部,不会等于 right,因此解引用是安全的。循环内部每轮都会根据比较结果调整 left 或 right,并且调整幅度至少为1个元素,循环变量单调变化,保证了终止性。
指针版本与下标版本的差异与易错点
下标版本的二分查找通常用整型变量 left 和 right 记录边界,中间位置写成 mid = left + (right - left) / 2。指针版本只是把这些整型下标替换成了地址运算,逻辑完全一致,但是更容易出现两个问题:一是在计算中间指针时写成 (left + right) / 2,直接导致编译错误或未定义行为;二是在移动左指针时写成 left = mid + 1 没有问题,但如果写成 left = mid++ 就会出错,因为 mid++ 返回原值再自增,赋值之后 left 仍然指向原来的 mid,造成区间几乎不缩小,可能死循环。
另一个常见错误是把循环条件写成 left <= right,同时又采用左闭右开区间。这样当区间为空时,left 可能等于 right,需要额外判断是否越界,容易造成解引用无效地址。左闭右闭区间是另一种写法,right 初始指向最后一个元素,循环条件写成 left <= right 也可以,但移动规则要相应调整。本文采用的左闭右开写法在C++标准库中的 std::lower_bound 等函数中被广泛使用,理解和掌握这种边界约定能减少出错。
从性能角度看,指针版本和下标版本在编译优化后几乎没有差别,因为下标访问在底层也是通过基址加偏移量实现的。指针版本的优势在于更贴近内存模型,方便在函数中直接返回元素地址,调用者可以通过返回的指针计算出索引,也可以直接修改指向的元素。在需要操作数组片段或者处理动态内存时,指针版本往往能让代码更简洁。但指针版本的可读性稍差一些,尤其是对指针运算不熟悉的读者,需要多花时间理解每一步地址变化。
边界条件验证与测试用例设计
为了验证指针版二分查找的正确性,应当覆盖多种边界情况:空数组、单元素数组、目标值位于首元素、目标值位于末元素、目标值不在数组中但落在中间区间、目标值小于所有元素、目标值大于所有元素、数组长度为偶数和奇数。空数组时 n 为0,right = arr + 0,left = arr,循环条件 left < right 不成立,直接返回 NULL,不会发生任何解引用。单元素数组长度为1,right = arr + 1,区间内只有一个地址,mid 就是那个元素,比较后立即返回或者区间清空。
可以使用以下测试框架进行验证。这里单独写一段测试代码,不依赖任何第三方库,把每个测试用例作为一次函数调用,打印输出实际结果和期望结果。
#include <stdio.h>
#include <assert.h>
/* 复用上面的 binary_search_pointer 函数 */
int *binary_search_pointer(int *arr, int n, int target);
void test_case(int *arr, int n, int target, int expected) {
int *p = binary_search_pointer(arr, n, target);
int actual = (p == NULL) ? -1 : (int)(p - arr);
printf("target=%d, expected=%d, actual=%d\n", target, expected, actual);
assert(actual == expected);
}
int main(void) {
int a[] = {1, 3, 5, 7, 9, 11};
int n = sizeof(a) / sizeof(a[0]);
test_case(a, n, 1, 0); /* 首元素 */
test_case(a, n, 11, 5); /* 末元素 */
test_case(a, n, 5, 2); /* 中间元素 */
test_case(a, n, 4, -1); /* 不在数组中 */
test_case(a, n, 0, -1); /* 小于所有 */
test_case(a, n, 15, -1); /* 大于所有 */
test_case(NULL, 0, 3, -1); /* 空数组,注意n为0时不能解引用,函数内部arr可能为NULL但不会使用,因为循环不进入 */
return 0;
}
注意测试空数组时传递 NULL 作为首地址,函数内部在 n 为0时不会解引用任何地址,因此安全。但为了严谨,实际工程中如果允许 NULL 传入,函数开头可以增加 if (arr == NULL && n > 0) return NULL; 的判断。上述测试代码引入了 assert 宏,需要包含 assert.h,可以在调试阶段快速发现错误。
通过边界测试可以确认指针移动没有越界,尤其是最后一次迭代中 left 和 right 相邻的情况。例如数组 {1,3},查找目标2时,初始 left 指向1,right 指向尾后地址,mid 指向1,目标大于1,left 移动为 mid + 1 即指向3,此时 left < right,区间只剩元素3,mid 再次指向3,目标小于3,right 移动为 mid,此时 left == right,循环结束,返回 NULL,整个过程没有解引用 right 指向的尾后地址。这种逐步推演对理解指针版本很有帮助。
指针二分查找在其他数据场景中的迁移
指针版本二分查找的底层思想可以迁移到其他随机访问结构上,比如 vector 的迭代器、自定义数组类或者内存池中的连续块。只要能够通过首地址加偏移量得到中间地址,并且支持解引用和比较操作,就可以套用相同的代码框架。例如C++中可以直接使用迭代器实现,代码结构几乎一样,只是类型从 int* 换成 std::vector<int>::iterator,计算中间迭代器时不能用首地址加偏移量,因为迭代器没有全局首地址,但可以用 left + (right - left) / 2 来计算,因为随机访问迭代器的加法和减法都是合法的。
对于链表这类非连续存储的结构,指针版本的传统二分查找无法直接使用,因为无法在 O(1) 时间内通过两个指针的差值计算中间位置。此时需要先遍历链表得到长度,再从头节点移动一半步数到达中间节点,整体时间复杂度不变但常数因子变大。不过链表通常利用跳表或者自平衡二叉搜索树来实现高效查找,普通二分查找并不适用。理解了数组指针版本的限制之后,就能清楚地区分哪些数据结构适合二分查找,哪些不适合。
在嵌入式开发中,数组可能存储在 flash 或特定内存区域,指针版本可以直接操作内存地址,省去计算索引的额外开销,但需要注意指针类型的大小和地址对齐要求。如果数组元素是结构体,指针运算会按照结构体大小步进,计算中间指针时同样用首地址加上偏移量乘以类型大小,C语言会自动处理 sizeof 的乘除,无需人工干预。这也是指针版本比手动索引更不易出错的一个原因。
总结来说,用指针实现数组二分查找的关键在于三点:第一,坚持左闭右开区间,循环条件用左指针小于右指针;第二,中间指针通过首地址加偏移量差值的一半计算,不能直接相加两个指针;第三,根据比较结果只移动一侧指针,每次移动确保跳过已比较的元素。掌握这些细节后,指针版二分查找可以成为编写底层算法库时的标准工具之一。