
1. 项目概述从“会用”到“精通”的算法进阶之路在C的世界里摸爬滚打几年后很多开发者会陷入一个瓶颈期语法早已烂熟于心STL容器和算法也能信手拈来但面对一些稍复杂的业务逻辑或性能要求苛刻的场景时写出的代码总感觉差那么点意思——要么运行效率不尽如人意要么代码结构臃肿难以维护。我自己也经历过这个阶段直到我开始系统性地审视和重构自己的“算法工具箱”才真正体会到从“会用算法”到“精通算法”的巨大鸿沟。这个所谓的“算法提升十四”并非指十四个具体的算法而是一个隐喻代表着一系列超越基础语法和标准库使用的、能够实质性提升代码质量与解决问题能力的进阶思维与技巧。它关乎如何更高效地组织数据、更优雅地设计流程、更精准地分析复杂度最终写出既快又好的工业级C代码。无论你是正在准备技术面试还是希望在日常开发中提升代码水准这套思维体系的建立都至关重要。2. 核心思维跃迁从实现功能到设计算法2.1 理解算法效率的“真实成本”很多初学者学习排序算法能默写冒泡、选择和插入排序的代码也知道快速排序和归并排序更快。但这远远不够。进阶的第一步是建立对算法效率更立体、更贴近实际的认知。大O记号Big O notation是起点但绝不是终点。例如我们都知道快速排序的平均时间复杂度是O(n log n)最坏是O(n²)。但在实际应用中什么是最坏情况对于基本快排当输入数组已经有序或逆序时如果总是选择第一个或最后一个元素作为基准pivot就会退化成O(n²)。这不仅仅是理论风险。我曾在处理一个近乎有序的时间序列数据时使用了std::sort通常采用内省排序IntroSort是快排、堆排和插入排序的混合发现性能依然不理想。后来意识到对于近乎有序的数据插入排序的O(n²)只是理论上的其实际常数因子极小局部性极好在数据量不大或部分有序时可能更快。std::sort的实现已经考虑了这种优化但如果我们自己实现排序或者需要自定义比较器时就必须思考基准的选择策略如三数取中法甚至根据数据特征混合使用不同算法。注意时间复杂度忽略了常数因子和低阶项但在数据规模确定、且常数因子差异巨大时比如O(n)的算法如果常数是100O(n²)的算法常数是1在n100时后者更快盲目相信大O会导致错误选择。实际分析时必须结合数据规模、内存访问模式缓存友好性一起考量。2.2 掌握空间换时间的权衡艺术这是算法设计中永恒的主题。哈希表std::unordered_map是典型的用空间换时间提供平均O(1)的查找但需要额外内存并可能发生哈希冲突。动态规划DP也常常需要一张表来存储子问题的解避免重复计算。一个经典的例子是判断一个链表是否有环。最直观的方法是使用哈希表记录访问过的节点空间复杂度O(n)。但更进阶的做法是Floyd判圈算法龟兔赛跑算法它只用两个指针空间复杂度O(1)。这就是在特定问题约束下通过巧妙的算法设计规避了空间开销。再比如计算斐波那契数列第n项。递归实现简洁但时间复杂度O(2^n)存在大量重复计算。使用带备忘录的递归记忆化搜索或迭代动态规划可以将时间复杂度降至O(n)但需要O(n)的数组存储中间结果。更进一步利用矩阵快速幂算法可以在O(log n)的时间内得到结果这需要理解数学原理并实现矩阵乘法是用“思维复杂度”换取了时间和空间的双重优化。在实际工程中我们需要根据n的大小、调用频率以及对内存的敏感度来决定使用哪种方案。2.3 培养问题分解与抽象的能力面对一个复杂问题直接寻找解决方案往往无从下手。进阶的算法能力体现在能否将陌生问题分解或映射到已知的经典模型上。例如LeetCode上有一道“任务调度器”问题给定一组用大写字母表示的任务相同任务之间必须有长度为n的冷却时间求最短完成时间。初看可能觉得需要复杂的模拟。但将其抽象后可以发现核心在于安排出现次数最多的任务。我们可以将其建模为“桶”模型建立一个宽度为(n1)的桶将频率最高的任务作为框架其他任务填充空隙。最终时间由任务的最大频率和具有该频率的任务数量共同决定。这种将具体调度问题抽象为数学模型的能力是算法思维进阶的关键。另一个例子是图论中的问题。很多看似与图无关的问题如状态转换、依赖关系、网络流等都可以抽象成图节点和边来求解。比如“单词接龙”问题将单词看作节点如果两个单词只有一个字母不同则它们之间有一条边问题就转化为了图中两个节点的最短路径问题可以用BFS解决。3. STL算法的深度运用与超越3.1 超越for循环理解STL算法的精髓C标准库提供了超过100个泛型算法但很多人只停留在使用sort、find、copy这几个。进阶的使用要求我们理解它们的语义、迭代器要求和性能保证。以std::remove和std::erase的搭配为例这是删除容器中特定元素的惯用法Erase–remove idiom。std::remove并不会真正删除元素它只是将不需要删除的元素移动到范围的前部并返回一个新的“逻辑终点”迭代器。真正的删除需要结合容器的erase方法。std::vectorint vec {1, 2, 3, 2, 5, 2}; // 错误这不会改变vec的大小只是将非2的元素前移尾部留下不确定的值 std::remove(vec.begin(), vec.end(), 2); // 正确Erase-remove惯用法 vec.erase(std::remove(vec.begin(), vec.end(), 2), vec.end()); // 现在vec是 {1, 3, 5}理解这个原理就能明白为什么std::remove叫“remove”而不是“erase”因为它操作的是迭代器范围不关心容器的具体内存管理。3.2 自定义函数对象与Lambda表达式的威力STL算法的强大之处在于其可定制性。通过传入自定义的比较函数、谓词或操作可以实现极其灵活的功能。例如std::sort默认使用运算符升序排序。但我们可以轻松实现降序、按自定义对象特定成员排序甚至实现复杂的多级排序。struct Person { std::string name; int age; double salary; }; std::vectorPerson people { /* ... */ }; // 按年龄升序排序 std::sort(people.begin(), people.end(), [](const Person a, const Person b) { return a.age b.age; }); // 按薪资降序排序若薪资相同则按年龄升序排序 std::sort(people.begin(), people.end(), [](const Person a, const Person b) { if (a.salary ! b.salary) return a.salary b.salary; // 降序 return a.age b.age; // 升序 });Lambda表达式让这种定制变得非常简洁。更进一步当操作需要复用或更复杂时可以定义完整的函数对象Functor即重载了operator()的类它可以拥有状态比普通函数指针功能更强。3.3 算法组合与管道化操作单个STL算法功能有限但将它们组合起来可以形成强大的数据处理管道。这类似于函数式编程中的概念。例如我们有一个整数向量想要得到其中所有偶数的平方并复制到一个新容器中。std::vectorint src {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}; std::vectorint dst; // 方法1传统循环清晰但稍显冗长 for (int num : src) { if (num % 2 0) { dst.push_back(num * num); } } // 方法2STL算法组合更声明式易于并行化等扩展 dst.clear(); std::copy_if(src.begin(), src.end(), std::back_inserter(dst), [](int n) { return n % 2 0; }); std::transform(dst.begin(), dst.end(), dst.begin(), [](int n) { return n * n; }); // 注意这里copy_if和transform是顺序执行中间结果存在dst中。 // 更理想的“管道化”需要C20 Ranges或手动编写视图View // C20 写法如果编译器支持 // auto result src | std::views::filter([](int n){return n%20;}) // | std::views::transform([](int n){return n*n;}); // dst.assign_range(result); // C23虽然纯STL算法在C20之前难以实现真正的惰性求值管道但组合使用的思想非常重要。C20引入的Ranges库正是为了更优雅地支持这种操作。4. 关键数据结构的选择与定制化4.1 深入理解容器的迭代器失效规则这是C面试中的经典问题也是实际开发中容易踩坑的地方。不同容器在插入、删除操作后迭代器、指针和引用的有效性规则不同。容器插入操作后的迭代器有效性删除操作后的迭代器有效性vector/string若引起重分配则全部失效否则插入点之后的迭代器失效。被删元素及之后的所有迭代器失效。deque在首尾插入迭代器失效除指向插入元素的迭代器在中间插入全部失效。在首尾删除只有被删元素的迭代器失效在中间删除全部失效。list/forward_list/set/map等节点式容器所有迭代器有效除了被删除元素的迭代器。只有指向被删除元素的迭代器失效。实操心得在遍历容器并可能修改它时要格外小心。例如删除vector中所有满足条件的元素如果使用基于迭代器的循环并在循环体内删除会导致迭代器失效和未定义行为。正确的做法是使用前面提到的erase-remove惯用法或者使用while循环并仔细更新迭代器。// 错误示例删除vector中所有奇数 std::vectorint vec {1, 2, 3, 4, 5}; for (auto it vec.begin(); it ! vec.end(); it) { if (*it % 2 1) { vec.erase(it); // 删除后it失效后续it行为未定义 } } // 正确示例1erase-remove vec.erase(std::remove_if(vec.begin(), vec.end(), [](int n) { return n % 2 1; }), vec.end()); // 正确示例2利用erase返回值返回被删元素之后元素的新迭代器 for (auto it vec.begin(); it ! vec.end(); ) { if (*it % 2 1) { it vec.erase(it); // 关键接收erase的返回值 } else { it; } }4.2 为自定义类型设计作为容器的键当我们想将自定义类型作为std::set的成员或std::map的键时容器需要一种方式来比较这些对象。有两种主要方式在自定义类型内重载运算符这是最常用的方法。需要保证比较满足严格弱序Strict Weak Ordering即非自反性comp(a, a)必须为false。非对称性若comp(a, b)为true则comp(b, a)必须为false。可传递性若comp(a, b)和comp(b, c)都为true则comp(a, c)必须为true。等价传递性如果!comp(a,b) !comp(b,a)即a和b等价那么它们与任何其他元素c的比较结果应该一致。struct MyKey { int id; std::string name; // 重载 运算符 bool operator(const MyKey other) const { // 通常先比较主要成员再比较次要成员 if (id ! other.id) return id other.id; return name other.name; } }; std::setMyKey mySet; // 可以直接使用提供自定义的比较函数对象当无法修改自定义类型比如来自第三方库或者需要多种不同的排序方式时使用。struct CompareById { bool operator()(const MyKey a, const MyKey b) const { return a.id b.id; } }; std::setMyKey, CompareById mySetById; // 或者使用Lambda但Lambda类型需要decltype或模板推导直接定义set时稍麻烦 auto cmp [](const MyKey a, const MyKey b) { return a.id b.id; }; // 按id降序 std::setMyKey, decltype(cmp) mySetDesc(cmp);对于std::unordered_set和std::unordered_map则需要提供哈希函数std::hash的特化或自定义函数对象和相等比较函数默认operator或自定义。4.3 实现自定义的轻量级数据结构有时标准库容器不能满足特定性能或语义需求需要自己实现简单的数据结构。例如实现一个固定大小的环形缓冲区Circular Buffer用于生产者-消费者模型下的数据缓冲。template typename T, size_t N class CircularBuffer { public: bool push(const T item) { if (full()) return false; buffer_[tail_] item; tail_ (tail_ 1) % N; size_; return true; } bool pop(T item) { if (empty()) return false; item buffer_[head_]; head_ (head_ 1) % N; --size_; return true; } bool empty() const { return size_ 0; } bool full() const { return size_ N; } size_t size() const { return size_; } private: T buffer_[N]; size_t head_ 0; size_t tail_ 0; size_t size_ 0; };这个实现避免了动态内存分配读写操作都是O(1)在实时系统或嵌入式环境中非常有用。关键在于理解头尾指针的模运算回绕以及用size_变量来清晰地区分“满”和“空”的状态避免head_ tail_的歧义。5. 动态规划与状态设计实战5.1 识别动态规划问题的特征动态规划是解决最优化问题的利器。一个问题是否适合用DP解决通常有两大特征最优子结构一个问题的最优解包含其子问题的最优解。比如最短路径问题从A到C的最短路径如果经过B那么这条路径中A到B、B到C的部分也必定是各自对应的最短路径。重叠子问题在递归求解过程中相同的子问题会被反复计算多次。比如斐波那契数列F(5)的计算需要F(4)和F(3)而F(4)的计算又需要F(3)和F(2)这里F(3)就被计算了两次。一个经典的DP入门问题是“爬楼梯”每次可以爬1或2个台阶到第n阶有多少种方法。令dp[i]表示到第i阶的方法数那么dp[i] dp[i-1] dp[i-2]这就是状态转移方程它清晰地体现了最优子结构到i阶的方法数由到i-1和i-2阶的方法数决定和重叠子问题。5.2 设计状态与状态转移方程这是DP最核心也最困难的一步。状态设计需要能够完整描述问题的某个阶段并且易于推导。以“最长公共子序列”LCS问题为例。给定两个字符串s1和s2求它们的最长公共子序列长度。状态定义dp[i][j]表示s1的前i个字符和s2的前j个字符的LCS长度。这里i和j就是描述“阶段”的状态变量。状态转移方程如果s1[i-1] s2[j-1]注意下标偏移那么这个字符一定在LCS中所以dp[i][j] dp[i-1][j-1] 1。如果s1[i-1] ! s2[j-1]那么LCS要么来自s1的前i-1和s2的前j个字符要么来自s1的前i和s2的前j-1个字符取最大值dp[i][j] max(dp[i-1][j], dp[i][j-1])。初始化dp[0][j] 0和dp[i][0] 0表示一个空字符串与任何字符串的LCS长度为0。这个二维DP表就是状态空间填表的过程就是自底向上解决问题的过程。5.3 空间优化与实现细节直接使用二维数组的空间复杂度是O(m*n)。观察状态转移方程可以发现dp[i][j]只依赖于上一行(i-1)和当前行的左边(j-1)。因此我们可以将空间优化到O(min(m, n))只保留两行或一行数组滚动数组。int longestCommonSubsequence(const std::string s1, const std::string s2) { int m s1.length(), n s2.length(); // 使用一维数组并额外变量保存左上角的值 std::vectorint dp(n 1, 0); for (int i 1; i m; i) { int prev 0; // 代表 dp[i-1][j-1] for (int j 1; j n; j) { int temp dp[j]; // 在更新dp[j]前保存作为下一轮的“左上角” if (s1[i-1] s2[j-1]) { dp[j] prev 1; } else { dp[j] std::max(dp[j], dp[j-1]); // dp[j]是上一行的dp[j-1]是当前行左边的 } prev temp; // 更新“左上角”的值 } } return dp[n]; }这种优化在面试和竞赛中常考在实际工程中如果数据规模极大也能有效减少内存占用提升缓存命中率。6. 图论算法在工程中的映射6.1 图的表示方法选择图论算法听起来学术但在工程中应用广泛如社交网络好友关系、路由规划、状态机、依赖分析等。首先面临的是图的表示问题主要有两种邻接矩阵用一个V x V的二维数组vectorvectorint表示G[i][j]表示顶点i到j的边权或是否存在边。适合稠密图可以快速查询任意两点间边但空间复杂度O(V²)对于稀疏图浪费严重。邻接表为每个顶点维护一个列表通常用vectorvectorpairint, int存储该顶点出发的边及其目标顶点和权重。适合稀疏图空间复杂度O(VE)但查询两点间是否有边需要遍历列表。选择建议绝大多数实际问题中的图都是稀疏的比如社交网络每个人认识的人有限因此邻接表是更通用的选择。在C中可以用vectorvectorEdge或者vectorlistEdge来表示。6.2 广度优先搜索与最短路径BFS是解决无权图最短路径问题的天然工具。它按照距离起点的层次逐层遍历第一次访问到某个节点时经过的路径就是最短路径。一个典型应用是“单词接龙”最短转换序列问题。将单词看作节点如果两个单词可以相互转换只有一个字母不同则连一条边。从起始单词开始BFS直到找到目标单词此时的层数就是最短转换序列长度。int ladderLength(const std::string beginWord, const std::string endWord, const std::vectorstd::string wordList) { std::unordered_setstd::string dict(wordList.begin(), wordList.end()); if (!dict.count(endWord)) return 0; std::queuestd::string q; q.push(beginWord); int steps 1; // 包含起点 while (!q.empty()) { int size q.size(); for (int i 0; i size; i) { // 处理当前层的所有节点 std::string word q.front(); q.pop(); if (word endWord) return steps; // 尝试变换单词的每一个字母 for (int j 0; j word.length(); j) { char original word[j]; for (char c a; c z; c) { if (c original) continue; word[j] c; if (dict.count(word)) { q.push(word); dict.erase(word); // 关键访问后从字典删除避免重复访问和环路 } } word[j] original; // 恢复原单词 } } steps; // 一层处理完步数加1 } return 0; // 未找到 }这里的dict同时充当了已访问集合visited的角色通过erase防止走回头路是BFS在图搜索中的常见技巧。6.3 深度优先搜索与回溯剪枝DFS常用于遍历所有可能解的情况如排列、组合、棋盘类问题。其核心是递归与回溯。单纯的DFS可能是指数级复杂度必须结合剪枝Pruning来提前终止不可能产生最优解的分支。以“N皇后”问题为例在N×N的棋盘上放置N个皇后使得它们互不攻击。DFS可以逐行放置皇后在每一行尝试每一列的位置如果当前位置与之前放置的皇后冲突同列、同对角线则剪枝不再继续向下搜索。void solveNQueens(int n, int row, vectorint cols, vectorvectorstring results) { if (row n) { // 所有行都成功放置了皇后找到一个解 results.push_back(generateBoard(cols, n)); return; } for (int col 0; col n; col) { // 尝试当前行的每一列 if (isValid(cols, row, col)) { // 检查是否冲突 cols[row] col; // 放置皇后 solveNQueens(n, row 1, cols, results); // 递归放置下一行 // 回溯cols[row]的值会被下一次循环覆盖无需显式“撤销” } } } bool isValid(const vectorint cols, int row, int col) { for (int r 0; r row; r) { int c cols[r]; // 检查是否同列或同对角线|row - r| |col - c| if (c col || abs(row - r) abs(col - c)) { return false; } } return true; }isValid函数就是剪枝条件。通过提前判断避免了大量无效的递归调用这是DFS高效解决组合问题的关键。7. 搜索与排序的进阶优化策略7.1 二分查找的变体与边界处理二分查找不仅用于在有序数组中找特定值更常用于解决“寻找边界”、“最小化最大值”一类问题。其核心在于循环不变式的维护和边界条件的精确处理。一个常见变体是在一个有重复元素的升序数组中找到目标值的第一个和最后一个出现位置即上下界。// 寻找左边界第一个 target 的位置 int lower_bound(const std::vectorint nums, int target) { int left 0, right nums.size(); // 注意右边界是size()不是size()-1 while (left right) { int mid left (right - left) / 2; // 防止溢出 if (nums[mid] target) { right mid; // 目标在左半部分包括mid } else { left mid 1; // 目标在右半部分 } } return left; // left right且是第一个target的位置 } // 寻找右边界第一个 target 的位置 int upper_bound(const std::vectorint nums, int target) { int left 0, right nums.size(); while (left right) { int mid left (right - left) / 2; if (nums[mid] target) { right mid; } else { left mid 1; } } return left; // left是第一个target的位置 }lower_bound返回的位置i满足所有j i的元素都 target所有j i的元素都 target。upper_bound返回的位置i满足所有j i的元素都 target所有j i的元素都 target。因此target的个数就是upper_bound - lower_bound。避坑指南二分查找最易错的是循环条件left right还是left right和边界更新right mid还是right mid - 1。坚持使用一种写法如上面的左闭右开[left, right)并理解其不变式能减少错误。7.2 复杂条件下的排序与比较器设计当排序规则不是简单的数值大小时比较器的设计就变得关键。比较器必须满足严格弱序否则在std::sort等算法中会导致未定义行为通常表现为程序崩溃或排序结果错乱。例如对一个自定义的“会议”结构体按开始时间排序如果开始时间相同则按结束时间早的优先。struct Meeting { int start; int end; }; bool compareMeeting(const Meeting a, const Meeting b) { if (a.start ! b.start) return a.start b.start; return a.end b.end; } std::vectorMeeting meetings; std::sort(meetings.begin(), meetings.end(), compareMeeting);这个比较器是满足严格弱序的。但考虑一个错误示例想按会议时长排序。// 错误示例按会议时长排序 bool compareByDuration(const Meeting a, const Meeting b) { int durA a.end - a.start; int durB b.end - b.start; return durA durB; // 违反了非自反性和非对称性当durA durB时compare(a,a)为true且compare(a,b)和compare(b,a)同时为true。 } // 正确写法 bool compareByDuration(const Meeting a, const Meeting b) { int durA a.end - a.start; int durB b.end - b.start; return durA durB; // 必须用 而不是 }这个细微差别是很多bug的来源。记住比较函数应该模拟运算符的行为而不是。7.3 非比较排序的应用场景当数据有特殊限制时非比较排序如计数排序、基数排序、桶排序可以在O(n)时间内完成排序远超基于比较的排序算法的O(n log n)下限。计数排序适用于数据范围不大例如人的年龄0-150考试成绩0-100的整数排序。它统计每个值出现的次数然后按顺序输出。void countingSort(std::vectorint arr) { if (arr.empty()) return; int minVal *std::min_element(arr.begin(), arr.end()); int maxVal *std::max_element(arr.begin(), arr.end()); int range maxVal - minVal 1; std::vectorint count(range, 0); std::vectorint output(arr.size()); // 统计频率 for (int num : arr) count[num - minVal]; // 将频率转换为前缀和此时count[i]表示小于等于(iminVal)的元素个数 for (int i 1; i range; i) count[i] count[i-1]; // 从后往前遍历原数组保证稳定性相同元素的相对顺序不变 for (int i arr.size() - 1; i 0; --i) { int idx arr[i] - minVal; output[count[idx] - 1] arr[i]; count[idx]--; } arr std::move(output); }计数排序是稳定的且时间复杂度为O(nk)k是数据范围。当kO(n)时效率极高。基数排序针对整数或字符串从最低位到最高位或反之依次进行稳定排序通常用计数排序作为子程序。它可以将对大规模整数的排序分解为多轮对小范围整数的排序。 这些算法在特定场景如数据库索引、大数据处理下非常高效了解它们可以拓宽解决问题的思路。8. 实战问题剖析与代码优化8.1 案例分析高效处理海量数据中的Top-K问题Top-K问题非常常见例如从十亿个搜索查询日志中找出频率最高的100个词。无法将所有数据载入内存排序。解决方案哈希统计遍历所有数据用一个哈希表unordered_mapstring, int记录每个词的出现频率。时间复杂度O(n)空间复杂度O(unique_keys)。维护一个大小为K的最小堆遍历哈希表将每个词频对(freq, word)放入堆中。如果堆大小小于K直接插入。如果堆大小等于K比较当前词频与堆顶堆中最小频率如果当前词频更大则弹出堆顶插入当前元素。否则跳过。 遍历完哈希表后堆中剩下的就是频率最高的K个词。using FreqPair std::pairint, std::string; std::vectorstd::string topKFrequent(const std::vectorstd::string words, int k) { // 1. 统计频率 std::unordered_mapstd::string, int freqMap; for (const auto word : words) { freqMap[word]; } // 2. 定义最小堆的比较器比较频率频率相同按字典序这里为了找最大K个用频率升序 auto cmp [](const FreqPair a, const FreqPair b) { if (a.first ! b.first) return a.first b.first; // 最小堆所以用 比较频率 return a.second b.second; // 频率相同字典序大的在下后续会逆序输出 }; std::priority_queueFreqPair, std::vectorFreqPair, decltype(cmp) minHeap(cmp); // 3. 维护大小为K的堆 for (const auto entry : freqMap) { minHeap.push({entry.second, entry.first}); if (minHeap.size() k) { minHeap.pop(); // 弹出频率最小的 } } // 4. 提取结果堆顶是最小的所以需要逆序 std::vectorstd::string result; while (!minHeap.empty()) { result.push_back(minHeap.top().second); minHeap.pop(); } std::reverse(result.begin(), result.end()); return result; }优化点如果内存中连哈希表都放不下唯一键太多可以使用“外部排序”或“MapReduce”分治思想将数据分割到多个文件中分别求Top-K再合并结果。堆的大小K通常远小于数据总量因此内存消耗可控。8.2 性能瓶颈分析与优化实例假设我们有一个函数需要频繁判断一个点是否在一个复杂多边形内。多边形由数千个顶点组成需要每秒进行数百万次判断。初始方案使用射线法。从点发出一条射线计算与多边形边的交点个数奇数在内偶数在外。每次判断需要遍历所有边O(N)复杂度N是边数。在百万次调用下性能堪忧。进阶优化空间换时间-预计算如果多边形不变可以预先计算其轴对齐包围盒AABB。判断点是否在矩形内是O(1)的如果点在矩形外直接返回false避免昂贵的射线法计算。更快的算法对于凸多边形可以使用叉积法判断点是否在所有边的同一侧复杂度也是O(N)但常数更小。或者将多边形三角剖分判断点是否在某个三角形内使用重心坐标法结合空间索引如BVH树可以将平均复杂度降至O(log N)。近似与量化如果允许一定误差可以将空间网格化像素化。预先计算一个二维布尔数组表示每个网格单元是否在多边形内。判断点时只需将其坐标量化到网格索引然后查表复杂度O(1)。这本质上是牺牲精度和内存换取速度。并行化如果判断的点集是独立的可以使用多线程并行计算。这个例子说明算法优化不仅仅是选择不同的算法还包括预处理、利用问题特性、近似计算和并行化等多层次手段。8.3 内存访问优化与缓存友好性现代CPU的缓存速度远快于内存。编写缓存友好的代码能极大提升性能尤其是对于数据密集型的算法。一个经典例子是遍历二维数组。在C中数组是按行存储的。const int N 10000; int arr[N][N]; int sum 0; // 缓存友好按行遍历 for (int i 0; i N; i) { for (int j 0; j N; j) { sum arr[i][j]; // 访问 arr[i][j], arr[i][j1]... 地址连续 } } // 缓存不友好按列遍历 for (int j 0; j N; j) { for (int i 0; i N; i) { sum arr[i][j]; // 访问 arr[0][j], arr[1][j]... 每次跳跃N个int } }按列遍历会导致大量的缓存缺失Cache Miss因为每次访问的内存地址都不连续性能可能相差几十倍。在设计自定义数据结构如链表 vs 数组和算法如快速排序 vs 堆排序前者通常缓存更友好时必须考虑数据访问的局部性。另一个例子是使用std::vector时如果知道元素的大致数量使用reserve预先分配内存可以避免多次重新分配和拷贝同时保证元素在内存中连续存储这对缓存友好。而std::list虽然插入删除快但元素分散在堆中遍历时缓存命中率低在需要频繁遍历的场景下std::vector的实际性能往往更好。