免费获取学习方案
ARTICLE DETAIL

资讯详情

深耕编程基础知识与建站技术分享的一线实战洞察。

C++ STL二分查找算法在自定义结构体中的实战应用

C++ STL二分查找算法在自定义结构体中的实战应用 1. 从一次“找茬”需求说起为什么需要二分查找结构体最近在重构一个老项目的用户管理模块遇到了一个很实际的问题。系统里有一个std::vectorUser里面存放了上万个用户信息User是一个结构体包含了id、name、score等字段。现在我需要根据用户的id快速定位到某个用户或者找到所有score在某个区间内的用户。最直接的想法是遍历但数据量一大性能就成了瓶颈。这时二分查找Binary Search这个经典算法就浮现在脑海。它的时间复杂度是 O(log n)对于有序数据查找效率极高。C 标准模板库STL贴心地为我们提供了std::lower_bound和std::upper_bound这两个函数它们就是基于二分查找实现的。但问题来了当容器里存放的不是int、double这些基本类型而是我们自定义的结构体时这两个函数还能直接“开箱即用”吗答案是能但需要你告诉它们“怎么比”。这就是今天要深入探讨的核心如何让lower_bound和upper_bound在结构体数组或任何自定义类型的容器中正确、高效地工作。这不仅仅是调用一个函数那么简单它涉及到对 STL 算法设计哲学的理解、对比较规则的精确控制以及在实战中如何避开那些看似合理实则致命的“坑”。2.lower_bound与upper_bound不只是查找更是定位在深入结构体之前我们必须先彻底理解这两个函数在做什么。很多人对它们的认知停留在“二分查找”这其实不够精确。它们真正的威力在于“定位”一个可插入位置。假设我们有一个已排序的升序序列[10, 20, 20, 30, 40]。2.1std::lower_bound第一个“不小于”值的位置std::lower_bound(first, last, value)返回一个迭代器指向在[first, last)区间内第一个不小于value的元素。查找 20序列是[10, 20, 20, 30, 40]。第一个不小于20的元素就是第一个20所以返回指向第一个20的迭代器。查找 25序列中没有25。第一个不小于25的元素是30所以返回指向30的迭代器。查找 5第一个不小于5的元素是10返回指向10的迭代器。查找 50所有元素都小于50函数返回last即尾后迭代器。它的核心思想是如果我要把value插入到这个有序序列中并且保持序列仍然有序lower_bound返回的位置就是value可以插入的第一个位置如果value已存在则插入到所有相等元素的最前面。2.2std::upper_bound第一个“大于”值的位置std::upper_bound(first, last, value)返回一个迭代器指向在[first, last)区间内第一个大于value的元素。查找 20序列是[10, 20, 20, 30, 40]。第一个大于20的元素是30所以返回指向30的迭代器。查找 25第一个大于25的元素也是30。查找 40没有元素大于40返回last。它的核心思想是如果我要把value插入到这个有序序列中upper_bound返回的位置是value可以插入的最后一个位置之后如果value已存在则插入到所有相等元素的最后面。2.3 两者的配合确定一个值的范围这两个函数常常结对使用来快速定位有序序列中所有等于value的元素范围。std::vectorint v {10, 20, 20, 30, 40}; int value 20; auto lower std::lower_bound(v.begin(), v.end(), value); // 指向第一个20 auto upper std::upper_bound(v.begin(), v.end(), value); // 指向30 // [lower, upper) 这个左闭右开区间包含了所有等于20的元素 for (auto it lower; it ! upper; it) { std::cout *it ; // 输出20 20 }这个特性在统计、区间查询等场景下非常高效。理解了它们对内置类型的行为我们才能更好地驾驭自定义类型。3. 结构体的挑战如何定义“小于”现在进入正题。我们的容器里存放的是struct或class。对于std::lower_bound和std::upper_bound它们内部在进行二分比较时本质上是执行这样的操作if (element value)或if (value element)。因此序列必须按照这个“”操作所定义的顺序进行排序函数才能正确工作。对于结构体编译器并不知道如何比较两个对象。比如User a, b;a b这个表达式是无效的。我们必须明确告诉 STL我的结构体对象究竟怎样才算“小于”另一个对象。3.1 方法一重载小于运算符 (operator)这是最自然、最推荐的方式。在你的结构体内部定义比较规则。struct User { int id; std::string name; double score; // 重载小于运算符 // 定义先按id升序排序如果id相同再按score降序排序 bool operator(const User other) const { if (id ! other.id) { return id other.id; // id小的排在前面 } // id相同的情况下score大的排在前面降序 return score other.score; // 注意这里是 实现了同id下的score降序 } };关键点解析const成员函数比较操作不应修改对象本身所以声明为const。const引用参数避免不必要的拷贝。定义严格的弱序这是最重要的要求。你的比较规则必须满足非自反性a a必须为false。不对称性如果a b为true则b a必须为false。可传递性如果a b且b c则a c必须为true。等价的可传递性如果!(a b) !(b a)即a和b等价并且!(b c) !(c b)那么必须有!(a c) !(c a)。 简单来说你的比较规则必须能无歧义地确定任意两个对象的先后顺序。上面先id后score的规则就符合要求。定义好operator后你可以直接使用std::sort对vectorUser排序也可以直接使用lower_bound和upper_bound。std::vectorUser users {{3, Alice, 85.5}, {1, Bob, 90.0}, {2, Charlie, 88.0}, {1, David, 92.0}}; // 排序按照我们定义的operator规则 std::sort(users.begin(), users.end()); // 排序后{id:1,score:92}, {id:1,score:90}, {id:2,score:88}, {id:3,score:85.5} // 查找id为2的用户 User target{2, , 0}; // 我们只关心id其他字段可以随意 auto it std::lower_bound(users.begin(), users.end(), target); if (it ! users.end() it-id target.id) { std::cout Found: it-name std::endl; }实操心得重载operator时务必确保比较逻辑与你的排序需求完全一致。一个常见的坑是你按(id, score)排序却只用id去调用lower_bound。由于operator比较了全部字段这可能导致查找失败。上面的例子中我们创建了一个“不完整”的target对象但因为operator只依赖id和score而target.score0在比较id相同的{1,92}和{1,0}时92 0为false0 92也为false根据我们的规则score降序所以92排在0前面{1,92} {1,0}为false{1,0} {1,92}也为false两者等价所以查找逻辑依然是正确的但这种方式有风险。更稳健的做法是使用方法二。3.2 方法二使用自定义比较函数或函数对象仿函数这是更灵活、更清晰的方式尤其是当你的查找逻辑和排序逻辑可能不同时。lower_bound和upper_bound的第四个参数允许你传入一个自定义的比较准则。使用 Lambda 表达式C11 之后推荐struct User { int id; std::string name; double score; // 不再重载 operator }; std::vectorUser users {{3, Alice, 85.5}, {1, Bob, 90.0}, {2, Charlie, 88.0}, {1, David, 92.0}}; // 排序按id升序 std::sort(users.begin(), users.end(), [](const User a, const User b) { return a.id b.id; }); // 查找同样只按id比较 User target{2, , 0}; auto it std::lower_bound(users.begin(), users.end(), target, [](const User a, const User b) { return a.id b.id; }); // 注意比较规则必须和排序时一致 if (it ! users.end() it-id target.id) { // 找到了 }使用函数对象仿函数如果比较逻辑复杂或需要复用可以定义一个仿函数类。struct CompareUserById { bool operator()(const User a, const User b) const { return a.id b.id; } }; // 使用 std::sort(users.begin(), users.end(), CompareUserById()); auto it std::lower_bound(users.begin(), users.end(), target, CompareUserById());重要警告传递给std::lower_bound的自定义比较函数其语义必须与对容器进行排序时所使用的比较函数完全一致。否则函数的行为是未定义的UB你可能会得到错误的结果或者程序崩溃。这是使用二分查找函数时最容易踩的坑。std::sort和std::lower_bound都依赖同一个“小于”概念来定义顺序。3.3 方法三使用透明比较器C14在 C14 中std::lower_bound等函数支持“异构查找”。这意味着你不需要构造一个完整的User对象作为target可以直接用int id去查找。这需要比较器支持“透明”操作即定义了is_transparent类型。通常我们可以直接使用标准库提供的std::less空尖括号作为透明比较器。#include functional // for std::less struct User { int id; std::string name; // 需要定义与int比较的运算符或者... }; // 方案A为User定义与int的比较不常见 bool operator(const User u, int id) { return u.id id; } bool operator(int id, const User u) { return id u.id; } // 方案B更通用使用自定义透明比较器 struct UserIdTransparentComparator { using is_transparent void; // 关键声明为透明比较器 bool operator()(const User u, int id) const { return u.id id; } bool operator()(int id, const User u) const { return id u.id; } bool operator()(const User a, const User b) const { return a.id b.id; } }; int main() { std::vectorUser users {{1, Bob}, {2, Charlie}, {3, Alice}}; std::sort(users.begin(), users.end(), UserIdTransparentComparator()); // 透明查找直接传递id无需构造User对象 auto it std::lower_bound(users.begin(), users.end(), 2, UserIdTransparentComparator()); if (it ! users.end() it-id 2) { std::cout Found by transparent search: it-name std::endl; } return 0; }透明比较器避免了临时对象的构造在性能敏感的场景下很有用但实现稍显复杂。对于大多数应用方法二Lambda已经足够清晰和高效。4. 实战进阶处理多字段排序与查找的复杂场景现实情况往往更复杂。我们可能需要对结构体按多个字段进行排序并且查找时也可能基于不同的字段组合。4.1 场景先按部门排序再按工资排序查找特定部门的人假设我们有Employee结构体。struct Employee { int departmentId; double salary; std::string name; }; std::vectorEmployee employees; // ... 填充数据需求1将员工按departmentId升序排列同一部门内按salary降序排列工资高的在前。需求2快速找到某个部门比如部门5的所有员工。实现步骤定义排序和查找的比较规则。由于查找时我们只关心departmentId但排序是两字段的我们需要确保查找规则是排序规则的“前缀”或与之兼容。一个稳妥的方法是排序和查找使用相同的多字段比较规则。// 统一的比较规则先部门后工资降序 auto cmp [](const Employee a, const Employee b) { if (a.departmentId ! b.departmentId) { return a.departmentId b.departmentId; } // 部门相同工资降序 return a.salary b.salary; // 注意这里是 实现降序 }; // 排序 std::sort(employees.begin(), employees.end(), cmp); // 查找部门5的第一个员工 // 我们需要构造一个“最小”的部门5员工作为target Employee targetDept5; targetDept5.departmentId 5; targetDept5.salary std::numeric_limitsdouble::max(); // 关键因为我们是降序所以用最大工资值 auto lower std::lower_bound(employees.begin(), employees.end(), targetDept5, cmp);为什么targetDept5.salary要设为最大值因为我们的排序规则是部门5内工资从高到低排。lower_bound找的是第一个“不小于”targetDept5的位置。targetDept5的部门是5工资是“正无穷大”。在比较时对于部门5的员工规则会比较工资。由于salary other.salary降序targetDept5的“正无穷大”工资在降序规则下是“最小”的因为任何实际工资都小于正无穷大所以在降序序列里实际工资大的排在前面比“正无穷大”这个“最小”值要“大”。因此lower_bound会返回部门5的第一个员工即工资最高的那个。upper_bound同理可以找到部门5之后第一个部门不是5的员工。查找部门5的所有员工。结合lower_bound和upper_bound。// 构造一个“最大”的部门5员工作为upper_bound的target Employee targetDept5Max; targetDept5Max.departmentId 5; targetDept5Max.salary -std::numeric_limitsdouble::max(); // 负无穷大在降序规则下是“最大”的 auto upper std::upper_bound(employees.begin(), employees.end(), targetDept5Max, cmp); // 现在[lower, upper) 区间包含了所有部门5的员工 for (auto it lower; it ! upper; it) { std::cout it-name : it-salary std::endl; }踩坑实录处理降序字段时的target构造是最大的难点。核心原则是lower_bound的target应该设置为你要查找的区间的“理论最小值”upper_bound的target应该设置为“理论最大值”这里的“最小”和“最大”是根据你的比较规则来定义的。对于升序字段最小值就是很小的数如INT_MIN最大值就是很大的数如INT_MAX。对于降序字段则恰恰相反。如果不理解这一点很容易得到错误的区间。4.2 场景使用std::pair或std::tuple简化多字段比较如果你的编译器支持 C11 或更高版本并且你的比较逻辑是简单的字段依次比较可以使用std::tie来生成一个std::tuple进行比较这能让代码更简洁且不易出错。struct Employee { int departmentId; double salary; std::string name; // 重载operator实现先部门升序后工资降序 bool operator(const Employee other) const { // 注意std::tie 只能处理升序。要降序需要对字段取反或使用std::make_tuple手动处理。 // 更清晰的方式是使用自定义比较器。 } }; // 使用自定义比较器配合 std::tie (仅适用于全升序) auto cmp_ascending [](const Employee a, const Employee b) { // 先部门id升序再工资升序再名字升序 return std::tie(a.departmentId, a.salary, a.name) std::tie(b.departmentId, b.salary, b.name); }; // 对于混合排序有升有降建议还是老老实实写if-else逻辑更清晰可控。std::tie在字段全部需要升序时是神器它能自动生成正确的字典序比较。但对于混合排序其可读性可能不如显式的if-else链。5. 性能考量与最佳实践5.1 确保容器已排序这是lower_bound和upper_bound正确工作的绝对前提。如果容器未排序函数的行为是未定义的结果不可预测。一个良好的实践是将排序和查找的逻辑封装在一起或者使用std::set/std::map这类始终有序的容器。// 不好的做法 std::vectorData data getData(); auto it std::lower_bound(data.begin(), data.end(), value); // 危险data可能未排序 // 好的做法 std::vectorData data getData(); std::sort(data.begin(), data.end(), compareRule); // 明确排序 auto it std::lower_bound(data.begin(), data.end(), value, compareRule); // 使用相同规则查找5.2 选择正确的数据结构std::vector如果数据一次性加载之后频繁查找但很少插入/删除那么先排序再使用lower_bound是高效的。因为vector内存连续缓存友好二分查找速度快。std::set/std::map如果数据需要动态插入、删除并始终保持有序那么应该直接使用这些关联容器。它们内部是红黑树插入删除和查找的时间复杂度都是 O(log n)。它们有自己的lower_bound成员函数用法类似。std::deque双端队列。它也支持随机访问迭代器所以可以用std::sort和std::lower_bound。但它的内存不是完全连续的缓存局部性比vector稍差。如果需要在头部和尾部频繁插入删除同时又要进行二分查找deque是一个折中的选择。5.3 自定义比较器的性能Lambda 表达式通常会被编译器内联性能损失极小。复杂的仿函数也可能被内联。但如果比较操作本身非常耗时例如需要字符串比较、深拷贝等它将成为二分查找的瓶颈。在设计结构体时尽量让作为排序键的字段是轻量级的如整型、枚举或者存储其哈希值用于比较。5.4 错误处理lower_bound和upper_bound返回的是迭代器。在使用返回的迭代器前必须检查它是否有效不等于end()并且指向的元素是否确实是你要找的。auto it std::lower_bound(vec.begin(), vec.end(), target, cmp); if (it ! vec.end() !cmp(target, *it) !cmp(*it, target)) { // 找到了确切相等的元素。当 !(ab) !(ba) 时a和b等价在排序意义上相等。 // 对于简单比较这通常意味着 it-key target.key。 } else { // 未找到。it 指向第一个不小于target的位置可能是插入点。 }对于结构体判断“相等”可能比基本类型复杂。如果排序只依赖部分字段如id那么“等价”可能只意味着这些字段相等其他字段可能不同。你需要根据业务逻辑来决定这是否算“找到”。6. 一个综合案例学生成绩管理系统让我们用一个完整的例子来串联所有知识点。系统需要管理学生信息支持按学号快速查找以及按班级和成绩进行区间查询。#include iostream #include vector #include algorithm #include string #include cassert struct Student { int studentId; // 学号 int classId; // 班级 std::string name; float score; // 成绩 // 为了方便打印 friend std::ostream operator(std::ostream os, const Student s) { os ID: s.studentId , Class: s.classId , Name: s.name , Score: s.score; return os; } }; int main() { std::vectorStudent students { {1001, 1, 张三, 88.5}, {1003, 2, 李四, 92.0}, {1002, 1, 王五, 76.0}, {1005, 2, 赵六, 92.0}, // 同班同分 {1004, 1, 钱七, 88.5}, // 同班同分 }; // 场景1按学号查找学号唯一 // 排序规则按学号升序 auto cmpById [](const Student a, const Student b) { return a.studentId b.studentId; }; std::sort(students.begin(), students.end(), cmpById); std::cout 按学号排序后: std::endl; for (const auto s : students) std::cout s std::endl; int searchId 1003; Student dummyTarget{searchId, 0, , 0.0f}; auto itById std::lower_bound(students.begin(), students.end(), dummyTarget, cmpById); if (itById ! students.end() itById-studentId searchId) { std::cout \n找到学号为 searchId 的学生: *itById std::endl; } else { std::cout \n未找到学号为 searchId 的学生。 std::endl; } // 场景2按班级和成绩查询班级内按成绩降序 // 排序规则先班级升序再成绩降序 auto cmpByClassAndScore [](const Student a, const Student b) { if (a.classId ! b.classId) return a.classId b.classId; return a.score b.score; // 成绩降序 }; std::sort(students.begin(), students.end(), cmpByClassAndScore); std::cout \n按班级和成绩排序后: std::endl; for (const auto s : students) std::cout s std::endl; // 查找班级1中成绩 85.0 的学生即成绩低于85分的最高分学生之后的位置 // 我们需要找到第一个成绩 85.0 的学生不我们需要理解需求。 // 假设我们想找班级1里成绩“不高于”85分的学生区间。 // 序列是班级1内成绩降序[88.5, 88.5, 76.0] // “不高于85分”即 score 85.0。 // lower_bound找第一个“不小于”target的位置。我们需要构造一个target使得“不小于”对应“85.0”。 // 由于是降序“不小于”意味着“成绩大于等于”。我们的target成绩应该设为85.0。 // 对于降序序列lower_bound(..., score85.0) 会返回第一个成绩 85.0 的位置我们来分析 // 比较 a.score b.score (降序)。target b.score85.0。 // 对于元素a(88.5): 88.5 85.0 为 true所以 a b 为 false? 等等这里容易混淆。 // 让我们直接使用查找“第一个成绩小于等于85.0”的位置。这其实是 upper_bound 的语义。 // 在降序序列中找“第一个X”的位置用 upper_bound 配合一个特定的比较逻辑更清晰。 // 更稳妥的方法是先找到班级1的起始然后手动线性查找或使用反向迭代器。 // 但为了演示 lower_bound/upper_bound我们换一个需求查找班级1中成绩“大于等于80分”的学生区间。 int searchClass 1; float minScore 80.0f; // 构造target代表“刚好达到条件”的虚拟学生。 // 我们要找 score 80.0。 // 在降序序列中第一个“不满足 score 80.0”的位置就是 upper_bound。 // 我们需要一个比较函数它能区分“满足条件”和“不满足条件”。 // 定义如果一个学生“小于”target意味着他应该排在target之前即他的条件更好或相等。 // 这很绕。对于这种复杂区间查询如果性能要求不是极端高一个更易懂的方法是 // 1. 用 lower_bound 找到班级1的起始。 // 2. 用 upper_bound 找到班级1的结束。 // 3. 在这个班级1的区间内用 std::find_if 进行线性查找。因为班级内数据量通常不大。 // 查找班级1的区间 Student classLowerTarget{searchClass, 0, , std::numeric_limitsfloat::max()}; // 最小位置 Student classUpperTarget{searchClass, 0, , -std::numeric_limitsfloat::max()}; // 最大位置 auto classBegin std::lower_bound(students.begin(), students.end(), classLowerTarget, cmpByClassAndScore); auto classEnd std::upper_bound(students.begin(), students.end(), classUpperTarget, cmpByClassAndScore); std::cout \n班级 searchClass 的学生列表 (成绩降序): std::endl; for (auto it classBegin; it ! classEnd; it) { std::cout *it std::endl; } // 在班级1的区间内线性查找成绩80的学生 std::cout \n班级 searchClass 中成绩 minScore 的学生: std::endl; for (auto it classBegin; it ! classEnd; it) { if (it-score minScore) { std::cout *it std::endl; } else { break; // 因为成绩是降序一旦遇到小于80的后面的肯定都小于 } } return 0; }这个案例展示了多种排序规则根据不同需求按学号、按班级和成绩对同一数据集进行排序。查找唯一键使用lower_bound和自定义比较器查找学号。查找非唯一键区间使用lower_bound和upper_bound配合定位特定班级的所有学生。混合查询的局限对于“班级内成绩大于等于X”这类复合条件在已按(班级, 成绩)排序的序列上虽然可以用二分找到班级头尾但成绩过滤可能需要线性扫描。如果这种查询极其频繁可以考虑为每个班级单独维护一个有序vector或者使用更高级的数据结构如std::mapint, std::setStudent。7. 总结与核心要点回顾std::lower_bound和std::upper_bound是 STL 中强大而灵活的二分查找工具但要驾驭它们处理自定义结构体需要透彻理解其工作原理和约束。有序是前提调用这两个函数前必须确保容器区间已按照你将要使用的同一个比较规则排序完毕。定义明确的比较规则通过重载operator或提供自定义比较函数/仿函数明确结构体对象之间的“小于”关系。规则必须满足严格的弱序。比较规则的一致性排序时用的比较规则必须和lower_bound/upper_bound调用时传入的规则完全一致。这是导致未定义行为的最常见原因。理解“位置”语义lower_bound返回第一个“不小于”给定值的位置可插入位置的前端upper_bound返回第一个“大于”给定值的位置可插入位置的后端。两者结合可以高效定位所有等价元素。处理复杂排序键当排序依据多个字段且排序方式升序/降序混合时构造用于查找的target对象需要格外小心要依据比较规则确定其字段的“理论最小值”或“理论最大值”。权衡性能与复杂度对于简单的、查找键明确的场景lower_bound在已排序的vector上效率极高。对于动态数据集或更复杂的多维度区间查询可能需要考虑std::set、std::map甚至是将数据按主查询键分组存储等策略。最后也是最重要的经验在编写完比较和查找代码后务必用包含边界情况如空容器、查找值小于最小值、查找值大于最大值、查找值等于某个存在的值、查找值不存在但位于中间的测试数据来验证你的逻辑是否正确。二分查找的 bug 往往隐蔽只有全面的测试才能保证其可靠性。
返回列表