← 返回计算机基础总览

🧮 排序与查找算法面试题

高频基础算法 + LeetCode 经典题精讲

📚 排序与查找核心知识

1. 常见排序算法有哪些?时间复杂度和空间复杂度分别是多少?简单

常见排序算法复杂度对比

算法平均时间最坏时间空间复杂度稳定性
冒泡排序O(n²)O(n²)O(1)稳定
选择排序O(n²)O(n²)O(1)不稳定
插入排序O(n²)O(n²)O(1)稳定
希尔排序O(n^1.3~n^2)O(n²)O(1)不稳定
归并排序O(n log n)O(n log n)O(n)稳定
快速排序O(n log n)O(n²)O(log n)不稳定
堆排序O(n log n)O(n log n)O(1)不稳定
计数排序O(n + k)O(n + k)O(n + k)稳定
基数排序O(d·(n + k))O(d·(n + k))O(n + k)稳定

面试常问点

  • 为什么快速排序平均最快?——局部性好,常数小,缓存友好
  • 为什么很多库排序选用快速排序的变种?——综合性能最好,且可优化最坏情况
  • 为什么还需要归并排序?——稳定、适合链表、适合外部排序
2. 手写一个快速排序,并分析时间与空间复杂度。简单

代码实现(Java)

public void quickSort(int[] arr) {
    if (arr == null || arr.length <= 1) return;
    quickSort(arr, 0, arr.length - 1);
}

private void quickSort(int[] arr, int left, int right) {
    if (left >= right) return;

    int pivot = partition(arr, left, right);
    quickSort(arr, left, pivot - 1);
    quickSort(arr, pivot + 1, right);
}

private int partition(int[] arr, int left, int right) {
    int pivot = arr[right];      // 选择右端点作为基准
    int i = left;               // i 指向小于 pivot 的区域的右边界

    for (int j = left; j < right; j++) {
        if (arr[j] <= pivot) {
            swap(arr, i, j);
            i++;
        }
    }
    swap(arr, i, right);
    return i;
}

private void swap(int[] arr, int i, int j) {
    int tmp = arr[i];
    arr[i] = arr[j];
    arr[j] = tmp;
}

复杂度分析

  • 平均时间复杂度:O(n log n),每一层划分为两半,共 log n 层,每层处理 n 个元素
  • 最坏时间复杂度:O(n²),当数组本身有序且每次选到极端基准时退化
  • 空间复杂度:O(log n),递归调用栈

如何避免退化?

  • 随机选择基准:在[left, right]内随机一个位置作为 pivot
  • 三数取中:取 left/mid/right 三个数的中位数作为 pivot
3. 快速排序和归并排序有什么区别?在什么场景选哪一个?简单

对比总结

维度快速排序归并排序
平均时间复杂度O(n log n)O(n log n)
最坏时间复杂度O(n²)O(n log n)
空间复杂度O(log n)(递归栈)O(n)(需要临时数组)
稳定性不稳定稳定
实现难度
缓存友好性好(原地,局部性好)一般
适合数据结构数组数组、链表

应用场景

  • 快速排序:内存中大规模、无特殊要求时的通用排序首选
  • 归并排序:
    • 需要稳定排序(比如按工资排序,再按年龄保持相对顺序)
    • 链表排序(LeetCode 148)
    • 外部排序(数据量大到内存放不下,需要磁盘归并)
4. 如何在 O(n) 时间内找到第 K 大的元素?(LeetCode 215)中等

QuickSelect(快速选择)

思想与快速排序类似,每次划分后只递归一边,平均时间复杂度 O(n)。

public int findKthLargest(int[] nums, int k) {
    int target = nums.length - k;  // 第K大等价于下标为 n-k 的元素
    int left = 0, right = nums.length - 1;

    while (true) {
        int pivot = partition(nums, left, right);
        if (pivot == target) {
            return nums[pivot];
        } else if (pivot < target) {
            left = pivot + 1;
        } else {
            right = pivot - 1;
        }
    }
}

private int partition(int[] arr, int left, int right) {
    int pivot = arr[right];
    int i = left;
    for (int j = left; j < right; j++) {
        if (arr[j] <= pivot) {
            swap(arr, i, j);
            i++;
        }
    }
    swap(arr, i, right);
    return i;
}

private void swap(int[] arr, int i, int j) {
    int tmp = arr[i];
    arr[i] = arr[j];
    arr[j] = tmp;
}

复杂度

  • 平均时间复杂度:O(n)
  • 最坏时间复杂度:O(n²)(同快排退化情况)
  • 空间复杂度:O(1)

与堆解法对比

  • 最小堆解法:时间 O(n log k),空间 O(k),实现简单
  • QuickSelect:平均更快,空间更优,但最坏情况差
5. 说一说二分查找的模板与常见错误边界情况。中等

经典二分查找模板

public int binarySearch(int[] nums, int target) {
    int left = 0, right = nums.length - 1;
    while (left <= right) {              // 注意是 <=
        int mid = left + (right - left) / 2;  // 防止溢出
        if (nums[mid] == target) {
            return mid;
        } else if (nums[mid] < target) {
            left = mid + 1;             // 收缩左边界
        } else {
            right = mid - 1;            // 收缩右边界
        }
    }
    return -1;
}

常见错误点

  • 死循环:while 条件写成 left < right,但更新边界不正确
  • 越界:mid 计算为 (left + right) / 2 在极端情况下溢出
  • 终止条件混乱:不知道什么时候用 <,什么时候用 <=

左边界二分( lower_bound )

// 返回第一个 >= target 的下标
public int lowerBound(int[] nums, int target) {
    int left = 0, right = nums.length;  // 注意:右边开区间
    while (left < right) {
        int mid = left + (right - left) / 2;
        if (nums[mid] < target) {
            left = mid + 1;
        } else {
            right = mid;
        }
    }
    return left;
}

右边界二分( upper_bound )

// 返回第一个 > target 的下标
public int upperBound(int[] nums, int target) {
    int left = 0, right = nums.length;
    while (left < right) {
        int mid = left + (right - left) / 2;
        if (nums[mid] <= target) {
            left = mid + 1;
        } else {
            right = mid;
        }
    }
    return left;
}

实战经验

  • 优先记住「左闭右开」区间写法,bug 少
  • 写二分前先明确:找值?找左边界?找右边界?
6. 如何在旋转排序数组中查找目标值?(LeetCode 33)中等

思路

数组被旋转,但仍然可以通过二分判断哪一半是有序的,然后在有序部分内判断 target 是否可能存在。

public int search(int[] nums, int target) {
    int left = 0, right = nums.length - 1;
    
    while (left <= right) {
        int mid = left + (right - left) / 2;
        if (nums[mid] == target) return mid;
        
        // 左半部分有序
        if (nums[left] <= nums[mid]) {
            if (nums[left] <= target && target < nums[mid]) {
                right = mid - 1;
            } else {
                left = mid + 1;
            }
        } else { // 右半部分有序
            if (nums[mid] < target && target <= nums[right]) {
                left = mid + 1;
            } else {
                right = mid - 1;
            }
        }
    }
    
    return -1;
}

// 时间复杂度:O(log n)
// 空间复杂度:O(1)

常见扩展

  • 数组有重复元素(LeetCode 81)
  • 查找最小值(LeetCode 153/154)
7. 如何在一个有序数组中统计某个数字出现的次数?中等

思路

利用二分查找分别找到 target 的左边界和右边界,次数 = rightIndex - leftIndex + 1。

public int countOccurrences(int[] nums, int target) {
    int left = lowerBound(nums, target);
    int right = upperBound(nums, target) - 1;
    if (left >= nums.length || nums[left] != target) return 0;
    return right - left + 1;
}

private int lowerBound(int[] nums, int target) {
    int left = 0, right = nums.length;
    while (left < right) {
        int mid = left + (right - left) / 2;
        if (nums[mid] < target) {
            left = mid + 1;
        } else {
            right = mid;
        }
    }
    return left;
}

private int upperBound(int[] nums, int target) {
    int left = 0, right = nums.length;
    while (left < right) {
        int mid = left + (right - left) / 2;
        if (nums[mid] <= target) {
            left = mid + 1;
        } else {
            right = mid;
        }
    }
    return left;
}

复杂度

两个二分查找,各 O(log n),总时间复杂度 O(log n),空间 O(1)。

8. 如何在几乎有序的数组中排序?(每个元素与排序后的位置相差不超过 k)中等

思路:使用大小为 k+1 的最小堆

因为每个元素最多只会落在离它最终位置 k 以内的位置上,只需在窗口内维护一个最小堆即可。

public void sortNearlySorted(int[] nums, int k) {
    PriorityQueue minHeap = new PriorityQueue<>();
    int index = 0;
    
    for (int num : nums) {
        minHeap.offer(num);
        if (minHeap.size() > k) {
            nums[index++] = minHeap.poll();
        }
    }
    
    while (!minHeap.isEmpty()) {
        nums[index++] = minHeap.poll();
    }
}

// 时间复杂度:O(n log k)
// 空间复杂度:O(k)

应用场景

  • 几乎有序的日志、时间序列数据
  • 流式数据中维护「大致有序」
9. 如何在 O(n) 的期望时间内找到数组的前 K 大元素?困难

基于 QuickSelect 的 Top K

可以先用 QuickSelect 找到第 K 大的值,然后再一次遍历收集所有 ≥ 该值的元素(或在选择过程中直接维护)。

public int[] topK(int[] nums, int k) {
    if (k <= 0 || k > nums.length) return new int[0];
    int target = nums.length - k;
    int left = 0, right = nums.length - 1;

    while (true) {
        int pivot = partition(nums, left, right);
        if (pivot == target) break;
        else if (pivot < target) left = pivot + 1;
        else right = pivot - 1;
    }

    // 此时 [target..end] 都是前K大(未必有序)
    return Arrays.copyOfRange(nums, target, nums.length);
}

与堆解法对比

  • QuickSelect:期望时间 O(n),适合一次性离线计算
  • 最小堆:O(n log k),适合数据流、在线场景
10. 如何设计外部排序(数据量大到内存放不下)?困难

典型方案:多路归并排序

  1. 分块:将大文件分成若干块,每块大小不超过内存上限(例如 100MB)。
  2. 块内排序:将每一块读入内存,用快速排序/归并排序排好序,写回磁盘,生成有序子文件。
  3. 多路归并:同时打开多个有序子文件,使用最小堆进行 k 路归并,将结果写到最终输出文件。

多路归并核心代码示意

class Node {
    int value;
    int fileIndex;
}

PriorityQueue minHeap = new PriorityQueue<>((a, b) -> a.value - b.value);

// 初始化:从每个有序子文件读一个元素放入堆中

while (!minHeap.isEmpty()) {
    Node node = minHeap.poll();
    // 将 node.value 写入输出文件
    
    // 从 node.fileIndex 对应的文件再读一个元素入堆
}

关键优化点

  • 合理选择块大小以减少磁盘 I/O 次数
  • 使用缓冲区批量读写而不是单条记录
  • 多线程同时处理多个块,充分利用磁盘带宽
11. 快速排序在工程中的典型优化有哪些?中等

工程优化手段

  • 三数取中:pivot = median(a[left], a[mid], a[right]),避免退化
  • 小数组使用插入排序:当区间长度较小时改用插排,减少递归开销
  • 尾递归优化:总是先递归处理较小的一侧,较大一侧用循环替代递归,降低栈深度
  • 随机化:打乱数组或随机选 pivot,避免特定输入攻击

Java/Android 中的实践

早期 JDK 的 Arrays.sort 使用双轴快速排序(Dual-Pivot QuickSort),对整数数组性能更好。

12. O(n log n) 排序的下界是如何证明的?有没有能突破下界的排序?中等

比较排序下界

  • 任意基于比较的排序算法,可以抽象为一棵决策树,每个比较相当于一次二叉分支。
  • n 个不同元素有 n! 种排列,对应决策树叶子节点数 ≥ n!。
  • 高度为 h 的二叉树最多有 2^h 个叶子,因此:2^h ≥ n!,h ≥ log₂(n!)。
  • 利用 Stirling 公式:log₂(n!) ≈ n log₂ n - O(n),所以下界为 Ω(n log n)。

能否突破下界?

  • 比较排序:不可能突破 O(n log n) 下界。
  • 非比较排序:可以,比如计数排序、桶排序、基数排序。

非比较排序的限制

  • 键值范围有限(例如 0~k 且 k 不太大)
  • 或键值能分解为有限位数(如 32 位整数)
13. 计数排序、桶排序、基数排序分别适用于什么场景?困难

计数排序(Counting Sort)

  • 适用:整数排序,范围[0, k],k 不大
  • 时间复杂度:O(n + k)
  • 空间复杂度:O(n + k)
  • 典型应用:考试成绩、年龄统计

桶排序(Bucket Sort)

  • 适用:输入均匀分布在某个范围内
  • 思路:将元素分到多个桶中,每个桶内部排序,最后合并
  • 平均时间复杂度:O(n)
  • 典型应用:排序[0,1) 之间的浮点数

基数排序(Radix Sort)

  • 适用:整数或定长字符串
  • 思路:按位排序,从低位到高位(LSD)或从高位到低位(HSD)
  • 时间复杂度:O(d·(n + k)),d 为位数,k 为基数
  • 典型应用:手机号、身份证、学号排序

工程实践

非比较排序多用于特定场景(大量整数/ID),通用库排序仍以比较排序为主。

14. 如何在一个无限增长的数据流中,实时维护 Top K 大元素?中等

思路:固定大小最小堆

  • 维护一个大小为 K 的最小堆
  • 新元素 > 堆顶时,弹出堆顶并插入新元素
  • 最终堆中元素就是 Top K(无序),取出再排序即可
class TopKStream {
    private PriorityQueue minHeap;
    private int k;

    public TopKStream(int k) {
        this.k = k;
        this.minHeap = new PriorityQueue<>();
    }

    public void add(int val) {
        if (minHeap.size() < k) {
            minHeap.offer(val);
        } else if (val > minHeap.peek()) {
            minHeap.poll();
            minHeap.offer(val);
        }
    }

    public List getTopK() {
        return new ArrayList<>(minHeap);  // 可再排序
    }
}

// 单次 add: O(log k)
// 空间复杂度:O(k)

实际应用

  • 实时监控系统中的 Top K 慢接口
  • 搜索引擎的热门搜索词
  • 推荐系统中的热门内容统计
15. 结合项目实践,如何在日志分析系统中设计高性能排序与检索?困难

典型需求

  • 最近 24 小时内的错误日志按时间倒序分页
  • 按响应时间排序,查看 Top N 慢接口
  • 支持多维度过滤(服务名、接口、状态码等)

整体设计思路

  1. 写入阶段:按时间分片(按天/按小时),顺序写入磁盘或对象存储。
  2. 索引构建:为关键字段(时间、服务、TraceId)建立倒排索引或LSM-Tree索引。
  3. 查询阶段:
    • 先根据时间范围确定要扫描的分片
    • 利用索引过滤候选记录
    • 在候选集上使用堆或局部排序获得 Top N

示例:按响应时间取 Top N

  • 每个分片内部预先维护一个 Top N 堆
  • 查询时从多个分片的 Top N 合并,使用大小为 N 的最小堆多路归并

与排序/查找的联系

  • LSM-Tree 底层是多层有序文件 + 外部归并排序
  • 倒排索引中的 Posting List 是有序数组,适合二分查找和跳跃表
  • Top N 统计广泛使用堆、QuickSelect 等算法