ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

插入已排序数组的Java实现:从线性扫描到二分查找优化

插入已排序数组的Java实现:从线性扫描到二分查找优化 说实话这个题目我第一次见到是在大学数据结构的习题册里后来发现它几乎是Java面试里出现频率最高的“简单题”之一。别小看“将一个数按原有规律插入已排序数组”这行字它同时考察了三件事你对数组底层本质的理解、你对边界条件的敏感度以及你是否具备二分查找的优化意识。所以这篇东西我不打算只给一段能跑的代码而是把整个思考链路、多种解法、坑点排查都拆开揉碎讲清楚。这题的正确打开方式分两步第一步搞清楚“原有规律”到底是什么——绝大多数情况下是升序但面试官可能故意改成降序第二步明确“插入”这件事在Java数组里意味着什么——因为数组一旦创建长度就固定了所谓插入本质上是创建一个新数组把元素按正确顺序复制进去。理解了这两点后面的代码怎么写都不会偏。1. 问题拆解先搞懂“已有规律”和“插入”这两个词的真实含义1.1 “原有规律”不只是升序也可能是降序很多人看到“已排序数组”大脑直接就映射成升序了。在90%的题目描述里确实默认是升序但严谨一点想既然是“按原有规律插入”那数组可能是降序甚至可能是先升后降再恢复的逻辑比如循环移位的有序数组。作为工程师你写的代码不应该写死“只处理升序”否则换个数据方向就崩。我建议先写一个判断数组趋势的小工具。最笨但有效的办法是遍历数组找到第一个相邻不等的元素对用它们确定方向。为什么是“第一个不等的”因为数组可能前面一堆重复元素比如 {5, 5, 5, 6, 7, 9}只看开头两个 5 看不出升降序必须找到 5 和 6 这一对才算数。如果整个数组所有元素都相等那默认按升序处理即可反正插哪都一样。这一步本质上是把“规律”这个模糊概念转化成一个明确的布尔值 isAscending后面的插入逻辑全部基于这个布尔值分支。别小看这个动作它能给代码换取极大的稳健性也方便你后续把方法抽出来复用。1.2 Java数组的“插入”为什么不是原地操作数组是连续内存空间每个元素有固定下标这既是它的优势也是它的束缚。要在中间插入一个元素物理上根本没有“插入”这个动作只能把插入位置之后的元素整体往后挪一位给新元素腾地方。问题来了数组长度不变挪完最后一位会被挤出去。所以实际做法只有一个new 一个长度 1 的新数组把原数组元素分两段复制进去中间的空位留给待插入的数。整个过程用生活场景类比就是电影院的固定座位已经坐满了来了一个迟到的人要按票号顺序坐进去你不可能把椅子掰开只能重新开一个多一个座位的厅让所有观众按顺序重新入座。理解了这一点代码里 new int[arr.length 1] 这个动作就不突兀了。1.3 边界情况插入头部、尾部、空数组一个都不能漏写这个题很多新手挂就挂在边界上。我把边界情况列个清单写代码前先在脑子里过一遍数组为空length 0插入后数组长度变 1新元素就是唯一元素。待插入数比数组第一个元素还小升序场景插入位置是 0。待插入数比数组最后一个元素还大升序场景插入位置是末尾即原数组长度。待插入数在中间某两个元素之间常规情况。数组里已有和待插入数相等的元素这里有个决策点——插到相等元素前面还是后面。通常为了保持稳定性可以插到相等元素之后也就是“第一个大于待插入数”的位置。这五种情况任何一种没考虑到代码在测试用例里都会翻车。面试时如果能在写代码前主动把这些边界说出来印象分会高不少。2. 常规解法线性扫描找位置再分两段复制2.1 思路模拟排队插队这个解法的思路特别直白。想象你手里拿一个号码牌要插进一个按号码升序排好的队伍里。你从队头开始往队尾走只要前面的人的号码比你小你就继续往后走等你发现前面的人号码比你大了那位置就是你的。这个位置就是我们要找的插入点。找到位置之后队伍怎么重新排整个过程分三步先把插入点前面的人原本地复制到新队伍里把号码牌放到插入点再把插入点后面的人依次往后挪一个位置复制过去。这三步对应到代码就是三次循环或两次循环加一次赋值。2.2 完整代码实现与逐行说明import java.util.Arrays; public class InsertIntoSortedArray { /** * 将 num 按照升序规律插入已排序数组 arr * param arr 已升序排序的数组 * param num 待插入的数 * return 插入后的新数组 */ public static int[] insertAscending(int[] arr, int num) { int n arr.length; int[] result new int[n 1]; // 1. 找到第一个大于 num 的位置作为插入点 int pos 0; while (pos n arr[pos] num) { pos; } // 2. 复制插入点之前的元素 for (int i 0; i pos; i) { result[i] arr[i]; } // 3. 放入待插入的元素 result[pos] num; // 4. 复制插入点之后的元素整体后移一位 for (int i pos; i n; i) { result[i 1] arr[i]; } return result; } public static void main(String[] args) { int[] arr {1, 3, 5, 7, 9}; int num 6; int[] newArr insertAscending(arr, num); System.out.println(Arrays.toString(newArr)); // 输出 [1, 3, 5, 6, 7, 9] int[] arr2 {1, 3, 5, 7, 9}; System.out.println(Arrays.toString(insertAscending(arr2, 0))); // 插入头部 System.out.println(Arrays.toString(insertAscending(arr2, 10))); // 插入尾部 } }注意第四步循环的边界i 从 pos 开始一直循环到 n - 1result 的下标是 i 1。如果你把循环条件写成 i n就会越界。这个细节我在帮同事 review 代码时见过不下三次基本全是这里翻车。2.3 复杂度分析为什么说这个解法“能用但可以更好”找插入点用了一个 while 循环最坏情况是待插入数比所有元素都大要完整遍历 n 个元素时间复杂度 O(n)。复制阶段不管插在哪个位置都要复制 n 个元素到新数组也是 O(n)。所以总的时间复杂度是 O(n)额外空间是 O(n)——因为新数组是必须的这个 O(n) 空间省不掉。如果你只说“复杂度是 O(n)”面试官大概率会追问一句“既然数组是有序的有没有办法让查找插入位置更快”这时候你要是答不上来就亏了。查找位置这个 O(n) 完全可以优化成 O(log n)也就是二分查找。复制元素的 O(n) 是物理上不可避免的但查找部分的优化能体现你懂数据结构。不过话说回来如果数组很小、只有十几二十个元素线性扫描的代码简单直接反而不容易出错。工程上这叫“够用就好”没必要为了优化而优化。但既然这题常出现在面试里我建议两种写法都熟练至少都知道怎么写。3. 优化解法二分查找定位插入点一次搬移搞定3.1 为什么有序数组可以用二分查找二分查找的核心前提是数据有序。既然题目里说了是“已排序数组”那查找插入点这件事就可以从线性变成对数级别。关键不是“找到这个数”而是“找到第一个大于待插入数的下标”这个下标就是插入点。这里有个易混淆的点传统二分是找“等于 target 的元素”而我们要找的是“大于 target 的第一个位置”属于二分查找的变体。用康奈尔笔记的经典说法这是一个 lower_bound/upper_bound 的变种“第一个大于 num 的位置”。注意我特意避开了“小于等于”的宽松判断就是为了在重复元素场景下保持某种稳定行为——如果你希望插到相等元素之后那条件就是 arr[mid] num 时往右靠。3.2 代码实现二分模板的选择与边界验证import java.util.Arrays; public class InsertWithBinarySearch { public static int[] insertAscending(int[] arr, int num) { int n arr.length; int[] result new int[n 1]; // 二分查找找出第一个大于 num 的位置 int left 0, right n; while (left right) { int mid (left right) 1; // 无符号右移避免溢出 if (arr[mid] num) { left mid 1; } else { right mid; } } int pos left; // 使用 System.arraycopy 完成两段复制简洁高效 System.arraycopy(arr, 0, result, 0, pos); result[pos] num; System.arraycopy(arr, pos, result, pos 1, n - pos); return result; } public static void main(String[] args) { int[] arr {1, 3, 5, 5, 7, 9}; System.out.println(Arrays.toString(insertAscending(arr, 5))); // 输出 [1, 3, 5, 5, 5, 7, 9]新 5 插在原有 5 的后面 int[] arr2 {}; System.out.println(Arrays.toString(insertAscending(arr2, 4))); // 输出 [4]空数组边界OK int[] arr3 {2, 4, 6}; System.out.println(Arrays.toString(insertAscending(arr3, 1))); // 输出 [1, 2, 4, 6]头部边界OK } }这个二分模板的关键点有两个。一个是 (left right) 1 而不是 (left right) / 2前者在极端大数场景下能避免整数溢出另一个是循环条件是 left right 而不是 left right配合 left mid 1 和 right mid 的更新方式最终 left 就是我们要的插入点。这个模板是二分查找求“右边界”的经典写法建议死记下来因为它还可以直接迁移到很多其他场景。3.3 手工推导一个例子彻底搞懂二分过程拿 arr {1, 3, 5, 7, 9}, num 6 举例走一遍循环初始 left 0, right 5mid 25arr[2] 5 6所以 left 3。此时 left 3, right 5mid 49arr[4] 9 6所以 right 4。此时 left 3, right 4mid 37arr[3] 7 6所以 right 3。循环结束left 3插入点 pos 3。验证一下arr[2] 5 6arr[3] 7 6插到下标 3 完全正确。二分的优势在这里体现得很直观——只做了三次比较就找到了位置线性扫描要做四次。数据量越大差距越明显。3.4 为什么复制阶段仍然要用 System.arraycopy很多初学者喜欢自己写 for 循环复制这没问题但 Java 里其实有一个更优雅的 native 方法 System.arraycopy它是系统级的内存复制在绝大多数 JVM 上比手写循环更快代码也更简洁。一行 System.arraycopy(arr, 0, result, 0, pos) 就把前半段复制完了再一行把后半段平移复制到 target 的 pos 1 起始位置。这里有个小细节后半段复制时源和目标都是数组但不会冲突因为源下标和目标下标的方向是一致的目标下标总是比源下标大 1且从后往前复制由 native 方法内部保证不会覆盖还未读取的数据。不过我得提醒一句System.arraycopy 的五参数签名容易写错我每次都容易把 srcPos 和 destPos 搞混。记法就一句话先源后目标位置参数跟着源和目标走。写完看一眼源是原数组、目标是新数组两边的位置参数别对调。4. 适配“原有规律”升序、降序与更广义的排序规律4.1 通用化设计判断数组现有趋势既然题目说的是“原有规律”那代码就不该只认升序。我先写一个方法判断数组方向这个方法的边界处理和 1.1 节说的一样找到第一对不相等元素来确定方向private static boolean isAscending(int[] arr) { for (int i 1; i arr.length; i) { if (arr[i] ! arr[i - 1]) { return arr[i] arr[i - 1]; } } return true; // 所有元素相等时默认升序 }这个方法的时间复杂度是 O(n)但只执行一次不会影响整体效率。它最大的作用是让插入方法对外可以做到“不关心方向只自动适配”你传入一个降序数组它也能正确插入。4.2 统一版插入方法一个方法同时兼容升序和降序import java.util.Arrays; public class InsertGeneric { public static int[] insert(int[] arr, int num) { int n arr.length; int[] result new int[n 1]; if (n 0) { result[0] num; return result; } boolean asc isAscending(arr); // 利用比较函数统一判断插入位置 int pos 0; while (pos n) { if (asc) { if (arr[pos] num) break; } else { if (arr[pos] num) break; } pos; } // 分两段复制 for (int i 0; i pos; i) { result[i] arr[i]; } result[pos] num; for (int i pos; i n; i) { result[i 1] arr[i]; } return result; } private static boolean isAscending(int[] arr) { for (int i 1; i arr.length; i) { if (arr[i] ! arr[i - 1]) { return arr[i] arr[i - 1]; } } return true; } public static void main(String[] args) { int[] ascArr {1, 3, 5, 7}; int[] descArr {9, 7, 5, 3}; System.out.println(Arrays.toString(insert(ascArr, 6))); // [1, 3, 5, 6, 7] System.out.println(Arrays.toString(insert(descArr, 6))); // [9, 7, 6, 5, 3] } }这个版本的查找部分为了兼顾两种方向写了点分支逻辑。写过之后你会发现其实核心逻辑就是“找到第一个违背当前方向规则的元素位置”升序时断点是第一个大于 num 的元素降序时断点是第一个小于 num 的元素。抓住这个本质代码就很好写。4.3 扩展到字符串字典序和对象排序“原有规律”这个词如果放到更广义的场景不一定只是数值的升降序。比如字符串数组规律可能是字典序对象数组规律可能是按某个字段排序。Java 里有个现成的接口 Comparator 专门干这事。如果你能把插入方法抽象成基于 Comparator 的逻辑那代码就能通吃数值、字符串、自定义对象。思路不复杂把 while 循环里“arr[pos] num”这种比较替换成 comparator.compare(arr[pos], num) 的正负号判断。比如升序场景下跳出循环的条件就是 comparator.compare(arr[pos], num) 0。这样做的好处是方法签名从 int[] 变成泛型 T[]适应性大大增强。我平时在项目里封装通用工具类时就特别喜欢用这个套路因为不知道明天要插入什么类型的数据。5. 常见问题与排查技巧实录5.1 错误代码清单这些坑我亲眼见过有人踩先说两个最常见的 bug都是用调试器看半天才发现的那种。第一个二分查找的循环条件写成 left right然后 mid 更新逻辑没配套调整最终要么死循环要么插入点死活不对。我之前有个同事把 left right 改成 left right 后又用原来的 left mid 1 / right mid 更新法结果 left 和 right 可能交错插入位置偏了一位。正确做法是选一种模板就坚持到底条件是 left right 时用 left mid 1 和 right mid 的组合。第二个线性扫描的循环里把 arr[pos] num 写成 arr[pos] num。表面上看只差一个等号但遇到重复元素时行为完全不同。比如 {1, 3, 5, 7} 插入 5如果你用 跳过去pos 最后停在 7 的位置把 5 插到了 7 前面这没错但如果你期望的是插到已有 5 的后面就必须用 。所以在动手前想清楚“相等元素插哪边”再决定这个条件怎么写。还有一个常见问题忘记处理空数组。我见过有人写 int[] result new int[arr.length 1]然后直接用 arr[0] 去初始化什么结果空数组直接 ArrayIndexOutOfBoundsException。空数组时 length 是 0任何访问 arr[0] 的操作都会崩必须在一开始就单独返回。5.2 面试官最可能追问的四个变体追问一如果数组很长你怎么优化这是个引导性问题答案当然是二分查找System.arraycopy把比较次数从 O(n) 降到 O(log n)复制仍然是 O(n)。追问二如果允许用 ArrayList 呢ArrayList 底层也是数组但它有自动扩容机制。你只需要调用 list.add(pos, num)内部会自动搬移元素。但面试官问这个其实是想看你是不是理解“ArrayList 的 add 方法内部就是 System.arraycopy”这个事实你如果能说出这一点基本就稳了。追问三如果原数据结构不是数组而是链表呢链表插入不需要搬移元素找到插入位置后改指针就行时间复杂度是 O(n) 查找 O(1) 插入。所以链表适合频繁插入删除的场景数组适合随机访问场景——这是数据结构的经典取舍值得展开说两句。追问四如果待插入的数可能很多比如批量插入怎么办可以考虑把待插入的多个数先排序然后做类似归并的合并一次遍历解决问题。这其实就是归并排序合并过程的简化版。5.3 答题节奏与工程化建议以我面试候选人的经验看这道题最好的回答节奏是这样的先说思路——数组插入就是建新数组找位置复制再说边界——头部、尾部、空数组、重复元素然后写代码时先写线性扫描版本写完后主动提一句“这里可以用二分优化查找部分”如果面试官点头再现场改成二分版本。工程上这种工具方法我建议直接塞进一个 ArrayUtils 类里方法名起得具体一点比如 insertIntoSortedArray参数加上 Comparator 以便扩展。项目里真正用到时涉及并发环境的话要注意原数组可能被其他线程修改插入前最好先做防御性拷贝或者用不可变数组。这些细节虽然不会每次都问但实际写生产代码时很关键。有一点我想特别强调这题看起来简单但能不能写对很大程度上取决于你有没有把“边界条件”当成一等公民对待。有经验的工程师写这种代码的速度可能没那么快但每一步都稳新手常犯的错误就是噼里啪啦写完一跑哎下标越界了。你在面试时如果能边写边说“这个 pos 要防止走到 n 的位置所以循环条件要加 pos n”面试官立刻能看出你有实战意识。我在实际带人的过程中发现很多人学这个知识点只是把代码背下来了没有理解二分查找的 right 其实是一个“候选区域右边界”所以换个题目比如找第一个小于 target 的位置就懵了。建议你把 3.2 节的模板亲手推导三遍然后自己改出“第一个小于等于 target 的位置”的版本练熟了才算真的会。这个题目往后扩展还有很多空间比如循环有序数组的插入、二维有序数组的查找本质上都是同一套思维利用有序性减少搜索范围再用搬移操作完成插入。你先把这个一维数组版本吃透后面那些变化就都能触类旁通了。
返回列表