尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

简单指令背后的奇怪算法:从排序到KMP的底层解密

简单指令背后的奇怪算法:从排序到KMP的底层解密 本期围绕《Core Dumped》这个主题聊聊“简单指令奇怪算法”。我们经常会有这样的体验业务代码里只是调了一下排序、匹配了一下字符串、算了一次幂看起来都是“一行指令”但底层可能已经执行了一套精密的算法调度甚至为了性能和稳定性做出了很多反直觉的取舍。这篇文章会从几个最经典的“简单指令”入手拆解底层算法为什么要这么设计再手写 KMP、快速幂、动态规划等案例最后给出工程中的性能排查与最佳实践。无论你是刚接触数据结构的初学者还是已经在业务中写过大量 CRUD 的开发者读完都能对“指令背后的算法”有一个更系统的认识。1. 从“简单指令”到“奇怪算法”这个问题到底是什么1.1 一行指令背后藏着什么先看一个很容易被忽略的事实我们通常所说的“指令”在计算机里有不同的层级。业务代码层Arrays.sort(arr)、String.indexOf(s)、Math.pow(a, n)这样的方法调用。标准库层JDK、Python 标准库、C STL 内部实现的算法路由。CPU 指令层最终编译成的汇编指令比如cmp、jmp、call。当我们在 CSDN 上讨论“指令”和“算法”时很容易把这三层混在一起。比如有人问“为什么Arrays.sort比我自己写的快速排序还快”其实是因为标准库里的排序函数不只是“排序”它对数据规模、数据类型、有序程度都做了判断然后在不同算法之间切换。这不是某一层单独决定的而是每一层都在做权衡。这就引出了题目里的“奇怪算法”你以为你在调用一个简单的指令实际上底层是一套非常复杂的算法策略。很多刚开始学算法的同学会感到困惑明明我手写了一个快排为什么数据稍微大一点就性能崩溃明明我的代码只有一行list.sort()为什么线上偶尔会慢到几百毫秒这些都是因为“指令简单”和“算法简单”之间并不能画等号。1.2 为什么算法会显得“奇怪”算法设计中有很多反直觉的地方比如快速排序平均复杂度是 O(n log n)但最坏情况可能退化到 O(n²)。二分查找看起来很快但如果数据是链表结构反而比线性查找更慢。贪心算法每一步都选局部最优但局部最优叠加起来不一定是全局最优。递归写法非常优雅但递归深度一深就可能触发栈溢出。同一个排序需求JVM 对基本类型数组和对象数组居然会选择完全不同的排序算法。这些现象单独拿出来都很好解释但放在“一行简单指令”背后就会让人觉得很奇怪。尤其当线上环境的数据分布、输入规模超出预期时这些“奇怪算法”会被瞬间放大成性能事故。1.3 你需要具备的算法思维这篇文章不想只停留在“指令 vs 算法”的概念辨析上而是希望建立一种工程直觉看到一行调用能大概猜出它底层可能做了什么。遇到性能劣化能快速定位是算法复杂度问题还是数据结构问题。写代码时能根据数据规模、稳定性要求、内存限制选择合适的算法而不是盲目手写。接下来我们先准备一下实验环境然后用几个经典案例把“简单指令”背后的算法拆开来看。2. 环境准备与实验设计2.1 实验环境本文的示例主要以 Java 为主部分思路也适用于 Python、C。版本信息如下但不是强制要求大家按自己的环境调整即可JDK 17JDK 8 均可运行本文代码Maven 3.8IDEIntelliJ IDEA 或 VS Code 均可Python 3.10可选用于做复杂数据和理解思路需要说明的是不同 JDK 版本对底层排序算法的实现细节可能略有差别但核心设计思想是一致的。本文的重点是讲清楚算法原理和设计思路而不是锁定某一个版本。2.2 项目结构建议新建一个普通的 Maven Java 工程目录结构如下algorithm-lab ├── pom.xml └── src/main/java/com/example/algorithm ├── SortDemo.java ├── KmpMatcher.java ├── QuickPow.java ├── CoinChange.java └── RecursionDemo.java其中pom.xml只需要最基础的依赖不需要额外引入三方框架。如果不想用 Maven直接新建一个 Java 类运行main方法也可以。project xmlnshttp://maven.apache.org/POM/4.0.0 xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd modelVersion4.0.0/modelVersion groupIdcom.example/groupId artifactIdalgorithm-lab/artifactId version1.0-SNAPSHOT/version properties maven.compiler.source17/maven.compiler.source maven.compiler.target17/maven.compiler.target /properties /project2.3 为什么选这些案例本文选择的案例都来自真实业务中非常常见的“简单指令”Arrays.sort()排序指令但底层是双轴快排/TimSort 的复杂决策。String.indexOf()字符串匹配指令引出 KMP 算法中的 next 数组。Math.pow()幂运算指令引出快速幂。找零钱问题用“看似简单的贪心”引出动态规划。这些案例覆盖了排序、字符串、数学运算、动态规划基本可以帮你建立“从指令到算法”的核心思维框架。3. 经典“简单指令”背后的复杂算法3.1Arrays.sort()与双轴快排先看一段最普通的代码int[] arr {5, 2, 8, 1, 9, 3}; Arrays.sort(arr); System.out.println(Arrays.toString(arr));这行Arrays.sort(arr)看起来只是“把数组排个序”但 JDK 底层到底做了什么在 OpenJDK 的实现中Arrays.sort(int[])使用的是 DualPivotQuicksort双轴快速排序。双轴快排是经典快速排序的一种改进它在数据分割时使用两个基准元素pivot把数据分成三段小于 pivot1 的部分。介于 pivot1 和 pivot2 之间的部分。大于 pivot2 的部分。这样做的好处是在一次划分后数据被分成三块而不是两块从而减少了递归深度和整体比较次数。对于基本类型数组JVM 不需要保证排序稳定性所以使用双轴快排因为它的平均性能很高而且内存访问比较局部化。但如果你排序的是对象数组String[] arr2 {banana, apple, cherry}; Arrays.sort(arr2);底层可能又会切换到 TimSort 或 ComparableTimSort。为什么因为对象的比较通常比基本类型更昂贵而且业务上往往需要稳定排序即相同值的元素要保持原有相对顺序。于是 JVM 选择了归并排序和插入排序结合体的 TimSort。这里有一个容易踩的坑如果你用Arrays.sort(int[])对基本类型排序然后期待它是稳定排序这是不对的。基本类型数组本身无法区分元素的“身份”稳定性没有意义但如果你用对象数组排序稳定性才有业务意义。3.2String.indexOf()与字符串匹配优化再看一个我们每天都会用到的指令String text hello world, welcome to core dumped; int index text.indexOf(world); System.out.println(index);如果问一个初学者indexOf底层是怎么实现的很多人会回答“就是逐个字符去匹配”也就是朴素字符串匹配算法从主串的第 0 个位置开始尝试匹配模式串。如果失败把主串的指针向右移动一位继续尝试。朴素匹配的时间复杂度是 O(n × m)其中 n 是主串长度m 是模式串长度。如果主串很长、模式串也很长比如在一份 10 万字符的日志里找一个 1000 字符的关键片段朴素匹配就会非常慢。现代 JDK 对indexOf做了一系列优化比如先定位模式串的首字符再用memcmp风格的大块比较减少比较次数。但它并没有在普通indexOf中直接使用 KMP 算法因为在很多真实场景下模式串很短朴素匹配 首字符定位已经足够快反而 KMP 需要额外构建 next 数组有初始化成本。这就引出了字符串匹配中的“奇怪算法”代表KMP 算法。KMP 的核心思想是当匹配失败时不把主串指针回退到开头而是利用已经匹配的信息将模式串尽量向右滑动。这个“已经匹配的信息”就记录在 next 数组中。后面第 4 节会手写一个完整的 KMP 实现。3.3Collections.sort()与 TimSortJava 中排序对象的另一个常用指令是ListInteger list Arrays.asList(5, 2, 8, 1, 9, 3); Collections.sort(list); System.out.println(list);在 JDK 7 之后Collections.sort最终调用的是List.sort底层使用 TimSort。TimSort 最早出现在 Python 中后来被广泛应用于 Java、Android 等环境。它的思路非常独特先扫描数组中存在的“自然有序段”run。对每个短 run使用插入排序进行局部排序。然后把多个 run 按照归并排序的方式合并。为什么说它“奇怪”因为传统排序算法通常把待排序数据看作完全无序的序列从零开始排序。但 TimSort 利用了真实数据中常见的一个特点数据往往是部分有序的。比如数据库查出来的数据可能已经按某个字段排得差不多了用户日志里的时间字段大部分也是递增的。TimSort 在这种情况下可以大幅减少比较和移动次数。同时TimSort 是稳定排序。这对业务非常关键比如我们先把订单按金额排序再把相同金额的订单按时间排序如果排序不稳定后一次排序可能破坏前一次的顺序。3.4Math.pow()与快速幂思想再看一个数学计算相关的指令double result Math.pow(2, 10); System.out.println(result); // 1024.0Java 的Math.pow是 native 方法最终调用的是底层 C/C 数学库。但如果我们在工程中自己实现一个幂运算或者要计算“大数取模的幂”快速幂就是一个必须掌握的算法。快速幂的核心思想很简单计算 a^n 时不一个一个地乘而是把指数 n 拆成二进制然后利用“平方”的方式快速计算。比如计算 3^1010 的二进制是 1010即 10 8 2。3^10 3^8 × 3^2。在循环中每次把底数平方一次3 → 3² → 3⁴ → 3⁸。遇到二进制位上为 1 的就把当前结果乘进去。这样时间复杂度从 O(n) 降到了 O(log n)。如果你要在工程中计算2^1000000000 % 1000000007用朴素循环几乎不可用而快速幂只需要几十次迭代。4. 亲手实现一个“奇怪算法”从原理到代码4.1 KMP 与 next 数组推导接下来我们动手实现 KMP 算法。很多人觉得 KMP 难主要是卡在 next 数组上。我们先看一个具体的模式串热搜词里正好有一个很经典的模式串p abacaba。定义next[i]为模式串p[0..i]这个子串中最长的相等前后缀的长度不包含子串本身。比如i0子串a没有相等前后缀next[0]0。i1子串ab前缀a不等于后缀bnext[1]0。i2子串aba前、后缀可以是a相等所以next[2]1。i3子串abac不存在相等前后缀next[3]0。i4子串abaca相等前后缀为anext[4]1。i5子串abacab相等前后缀为abnext[5]2。i6子串abacaba相等前后缀为abanext[6]3。所以模式串abacaba的 next 数组是[0, 0, 1, 0, 1, 2, 3]有了 next 数组KMP 的匹配过程就很容易了主串指针i不回头模式串指针j在失配时根据next[j-1]回退。下面给出完整代码。// 文件路径src/main/java/com/example/algorithm/KmpMatcher.java package com.example.algorithm; import java.util.Arrays; public class KmpMatcher { public static int[] buildNext(String pattern) { int m pattern.length(); int[] next new int[m]; int j 0; for (int i 1; i m; i) { while (j 0 pattern.charAt(i) ! pattern.charAt(j)) { j next[j - 1]; } if (pattern.charAt(i) pattern.charAt(j)) { j; } next[i] j; } return next; } public static int indexOf(String text, String pattern) { if (pattern.isEmpty()) { return 0; } int[] next buildNext(pattern); int j 0; for (int i 0; i text.length(); i) { while (j 0 text.charAt(i) ! pattern.charAt(j)) { j next[j - 1]; } if (text.charAt(i) pattern.charAt(j)) { j; } if (j pattern.length()) { return i - j 1; } } return -1; } public static void main(String[] args) { String text ababaacabacabacaba; String pattern abacaba; System.out.println(next数组: Arrays.toString(buildNext(pattern))); System.out.println(匹配位置: indexOf(text, pattern)); } }运行结果应该是next数组: [0, 0, 1, 0, 1, 2, 3] 匹配位置: 7这里需要注意国内的教材或面试资料中有时会把 next 数组定义为“从 0 开始失配时 j 回退到 next[j]”有时又把 next 数组定义为“失配时 j 回退到 next[j-1]”。本文采用的是后者也就是next[i]记录p[0..i]的最长相等前后缀长度。不同定义不影响算法本质但写代码时要统一。4.2 快速幂实现与验证快速幂在工程中非常实用尤其是涉及大数取模的场景比如 RSA 加密、哈希计算、概率算法中的随机数生成。下面给出迭代版实现同时支持取模避免中间结果溢出。// 文件路径src/main/java/com/example/algorithm/QuickPow.java package com.example.algorithm; public class QuickPow { public static long pow(long base, long exp, long mod) { long result 1 % mod; base base % mod; while (exp 0) { if ((exp 1) 1) { result result * base % mod; } base base * base % mod; exp 1; } return result; } public static void main(String[] args) { long mod 1_000_000_007L; System.out.println(2^10 % mod pow(2, 10, mod)); long start System.nanoTime(); long value pow(2, 1_000_000_000L, mod); long end System.nanoTime(); System.out.println(2^1000000000 % mod value); System.out.println(耗时(ms): (end - start) / 1_000_000.0); } }这段代码的核心点有两个每轮循环都让base base * base % mod相当于把指数不断折半。当指数当前二进制位为 1 时把累积结果乘上当前 base。这样做的时间复杂度是 O(log n)即使指数是 10 亿级别也只需要约 30 次循环。如果不用快速幂用for循环累乘不仅慢而且很快就会溢出。在运行上述代码时如果 exp 很大需要注意Math.pow只能处理 double 类型无法直接用于精确取模场景。这也是为什么我们经常要在工程里自己实现快速幂的原因。4.3 从贪心到动态规划看似简单的最优选择再看一个经典的“简单问题”找零钱问题。假设你手上有三种面值的硬币1 元、5 元、11 元现在要凑出 15 元问最少需要多少枚硬币。很多人的第一反应是贪心每次尽量选面值最大的硬币。于是先选 11 元剩余 4 元。4 元只能用 4 个 1 元硬币。总共需要 5 枚硬币。但最优解是 3 枚5 5 5 15。这里就出现了“简单指令奇怪结果”的典型例子贪心策略在有些货币体系下有效但在1、5、11这种组合下失效。原因很简单局部最优并不等于全局最优。对于这个问题我们需要用动态规划把子问题的最优解保存下来再逐步推导最终答案。下面给出动态规划实现// 文件路径src/main/java/com/example/algorithm/CoinChange.java package com.example.algorithm; import java.util.Arrays; public class CoinChange { public static int coinChange(int[] coins, int amount) { int[] dp new int[amount 1]; Arrays.fill(dp, amount 1); dp[0] 0; for (int i 1; i amount; i) { for (int coin : coins) { if (i coin) { dp[i] Math.min(dp[i], dp[i - coin] 1); } } } return dp[amount] amount ? -1 : dp[amount]; } public static void main(String[] args) { int[] coins {1, 5, 11}; System.out.println(最少硬币数: coinChange(coins, 15)); } }运行结果最少硬币数: 3这个例子想说明的是算法选择不能只靠直觉。看到“最优”“最小”“最短”等问题不要急着写一个看似合理的循环先想一想题目是否满足贪心选择性质如果不满足就应该考虑动态规划、BFS 或其它策略。4.4 运行与结果说明在 IDEA 或命令行中分别运行上面三个类的main方法预期结果如下类输出说明KmpMatchernext数组: [0, 0, 1, 0, 1, 2, 3]、匹配位置: 7next 数组推导正确KMP 匹配成功QuickPow2^10 % mod 1024、2^1000000000 % mod 490000 ...快速幂计算大数取模速度极快CoinChange最少硬币数: 3动态规划得到全局最优解如果运行结果和预期不一致可以先用小规模数据调试比如把amount改成 5、10核对 dp 数组的推导过程。5. 常见问题与排查思路在学习算法和在实际项目中应用这些“简单指令”时我们经常会踩到一些坑。下面整理一张常见问题排查表。问题现象常见原因解决思路排序结果看似正常但相同元素顺序被改变使用了不稳定的排序算法或对基本类型数组做了稳定性假设需要稳定排序时使用对象数组排序或 TimSort递归算法在数据稍大时栈溢出递归深度过大StackOverflowError改用迭代、尾递归或动态规划字符串匹配超时主串和模式串都很长朴素匹配退化使用 KMP 或 Aho-Corasick 等多模匹配算法幂运算结果错误或溢出直接使用Math.pow做精确取模或循环累乘太大使用快速幂并每一步取模哈希表查找突然变慢哈希函数设计不佳产生大量哈希冲突使用更均衡的哈希函数注意hashCode实现正则表达式匹配卡死灾难性回溯嵌套量词导致指数级匹配避免嵌套量词使用原子组、字符类优化循环嵌套导致数据规模增大后性能骤降时间复杂度未估算两层循环 O(n²) 中 n 变大先估算数据规模再看能否用哈希表或排序降低复杂度下面挑几个典型问题详细展开。5.1 排序稳定性判断失误排序稳定性是面试中经常被问到的点但在真实项目中很容易被忽略。比如ListTask tasks new ArrayList(); tasks.add(new Task(1, 3)); tasks.add(new Task(2, 2)); tasks.add(new Task(3, 2)); tasks.add(new Task(4, 1)); tasks.sort(Comparator.comparingInt(t - t.priority)); System.out.println(tasks);这里的List.sort底层是 TimSort是稳定排序所以相同 priority 的Task(2,2)和Task(3,2)会保持原来的相对顺序。如果把数据放入int[]用Arrays.sort排序你看不到“稳定”的概念因为基本类型值相同就是相同无法区分先后。在业务中如果先按时间排序再按优先级排序二次排序必须使用稳定排序否则第一次排序的结果会被打乱。这是很经典的一个工程细节。5.2 递归爆栈很多算法初学者喜欢写递归比如public static long fib(int n) { return n 1 ? n : fib(n - 1) fib(n - 2); }这段代码在n45时已经很慢n60时基本跑不出来n10000时可能直接抛出StackOverflowError。原因是递归调用会产生大量函数栈帧。斐波那契数列的朴素递归存在大量重复计算复杂度是 O(2^n)。解决方案有两种使用带备忘录的自顶向下动态规划。使用自底向上的循环只保存前两个值。如果问题本身必须用递归也要注意设置合适的递归深度并且评估最坏情况下的栈帧大小。在生产环境中还要考虑线程栈配置不能盲目调大-Xss因为每个线程都会占用独立栈空间。5.3 字符串匹配性能退化如果你在一个大文本中反复搜索同一个模式串或者需要搜索多个模式串朴素的String.indexOf可能不够用。一个真实的业务场景是日志分析几十万行日志每行几 KB需要从中找出关键错误码。如果每行都跑一次朴素匹配整体耗时就会被放大。这时可以考虑如果只搜一个模式串使用 KMP。如果同时搜多个模式串使用 Aho-Corasick 自动机它能在一次扫描中匹配多个模式。如果只是简单的精确匹配indexOf通常已经足够不要盲目引入复杂算法。5.4 哈希冲突导致查找退化假设你有一个自定义对象hashCode写得不合理所有实例的哈希值都一样。在 Java 8 之后如果哈希冲突过多HashMap可能会把链表升级为红黑树但性能仍然比正常的哈希分布差很多。更糟糕的是如果攻击者能够控制输入数据并且知道你的哈希算法还可能制造大量冲突触发 O(n²) 级别的操作。这类攻击被称为 HashDoS。工程建议是自定义hashCode时使用足够的扰动比如参考Objects.hash。不要把未脱敏的用户输入直接作为 HashMap 的大批量 Key。对于安全要求高的场景考虑带随机种子的哈希表。5.5 灾难性回溯正则表达式是“简单指令”中的经典代表。写一个正则看起来很简单但一些表达式可能导致匹配引擎回溯很多次。比如表达式(a)$如果用来匹配一段由很多a后面跟着b的字符串回溯次数会呈指数级增长。这类问题在 Java、Python、JavaScript 的默认正则引擎中都可能出现。排查方法先用小字符串测试运行时间观察是否随长度快速增长。尽量避免嵌套量词比如(a)、(a*)*。使用非回溯引擎或更严格的匹配方式。6. 最佳实践与工程建议6.1 先估复杂度再写“简单指令”写代码时建议先想一想数据规模的量级n 在 1000 以内O(n²) 可能没问题。n 在 10^5 左右O(n log n) 是常见选择O(n²) 就可能超时。n 在 10^9 以上基本只能接受 O(n) 或 O(log n) 的算法。这听起来很基础但在真实项目中很多人写完代码才发现数据比预期大几个数量级导致线上耗时突增。遇到这种情况不要急着优化局部细节先看整体算法复杂度是否合理。6.2 优先使用标准库标准库的排序、查找、字符串匹配、哈希表经过大量测试和调优通常比我们自己实现的版本更可靠。除非有非常明确的性能和功能需求否则不要随意手写排序或匹配算法。举个例子// 推荐 Arrays.sort(arr); // 不推荐除非你清楚为什么需要手写 quickSort(arr, 0, arr.length - 1);如果你确需手写算法比如需要定制比较器或做特殊的剪枝至少要针对最坏情况做测试并保留性能对比数据。6.3 根据数据规模选择算法一个典型场景是处理大数据量排序时外部归并排序比内存内排序更合适处理小规模数据时插入排序可能比快速排序更快。这就是 JDK 在排序实现中会做“小数组用插入排序”这种策略的原因。在实际业务中你可以参考这个思路数据量小优先考虑简单实现比如插入排序、选择排序。数据量中等使用快速排序或归并排序。数据量极大考虑分治、外部排序、分布式排序。6.4 为代码保留决策注释有时候我们会在代码中看到一行看似普通的调用但不敢随便改因为不知道它为什么存在。为了避免这种情况重要的算法选择旁边应加上注释说明“为什么这样做”。比如// 这里必须使用稳定排序因为后面还需要按时间二次排序 tasks.sort(Comparator.comparingInt(Task::getPriority));这种注释对后续维护非常有用。尤其是那些“看似简单实则反直觉”的代码写清楚原因能帮后来人节省大量排查时间。6.5 测试边界与压测算法代码的测试不能只测正常数据还要测各种边界情况空数组、空字符串。只有一个元素。所有元素相等。已经有序或逆序。大数、负数、溢出边界。哈希碰撞非常严重的输入。在提交到生产环境之前建议做一次简单的压测确认算法在预期的数据规模下耗时可接受。如果发现性能问题借助 profiling 工具定位热点而不是靠猜测。6.6 权限、日志与生产安全这里补充一点与“指令”相关的工程安全经验。如果我们的程序支持用户输入指令、表达式或正则表达式一定要做好权限校验和资源限制对用户输入的模式串长度做限制避免超长正则导致回溯爆炸。对算法执行时间做超时控制避免某个请求卡住线程池。对含敏感信息的日志进行脱敏不要在日志中打印完整密钥或用户隐私。涉及生产环境的变更先在测试环境验证并使用最小权限原则。这些虽然不是算法本身但会直接影响线上稳定性。7. 总结与延伸思考回到文章开头的问题一行sort()、一次indexOf()、一个pow()这些指令看起来都很简单但底层算法可以非常复杂甚至有些反直觉。从双轴快排到 TimSort从朴素字符串匹配到 KMP从贪心到动态规划每一个“奇怪算法”背后都对应着性能、稳定性、内存占用、实现成本之间的权衡。对于刚入门算法的同学下一步可以重点学习时间复杂度分析弄清楚大 O 表示法的含义再去读一读 JDK 中Arrays.sort、Collections.sort、HashMap的源码。对于有经验的开发者建议在业务代码中多问“为什么用这个算法”“数据规模变化后会不会退化”并且养成用注释记录决策的习惯。如果你也在项目中遇到过“看似简单、实际诡异”的算法问题欢迎在评论区分享你的例子。收藏本文备用以后遇到排序、字符串匹配、幂运算或性能退化相关的问题可以随时回来对照排查。
返回列表