数据结构进阶:快速排序深度解析与折半查找实战(Java)

数据结构进阶:快速排序深度解析与折半查找实战

引用:

在之前的《数据结构-八大选择排序(冒泡排序、简单选择排序、插入排序、希尔排序)》中,我们学习了冒泡排序、简单选择排序、插入排序和希尔排序这四种基础排序算法,其中前三者时间复杂度为O(n²),希尔排序通过分组优化将复杂度降至O(n log₂n),为处理中大规模数据提供了思路。

本文将在此基础上,进一步学习快速排序(八大排序中效率顶尖的排序算法)和折半查找(有序数组的高效查找算法)。快速排序作为八大排序的核心成员,时间复杂度稳定在O(n log n),是实际开发中排序场景的首选;折半查找则依托有序数组,将查找复杂度从线性的O(n)降至对数级的O(log n),二者结合能大幅提升数据处理效率。

一、快速排序:八大排序中的 “性能王者”

快速排序(Quick Sort)由计算机科学家 Tony Hoare 于 1960 年提出,其核心思想是 “分治法”—— 通过一趟排序将数组划分为 “左小右大” 的两部分,再对两部分递归执行排序,最终实现全局有序。它因排序效率高、适用场景广,成为八大排序中最常用的算法之一。

1.1 核心思想与执行步骤

快速排序的关键是 “基准数选择” 和 “数组划分”,以 “将待排序数组第一个元素作为基准数” 为例,完整步骤如下:

  1. 选基准:取数组待排序区间的第一个元素作为基准数(key),如区间[L, R]的基准数为a[L];
  2. 定指针:定义左右指针i和j,分别指向区间的起始(L)和末尾(R);
  3. j 指针移动:j从右向左移动,找到第一个小于基准数的元素后停止(若a[j] >= key则继续移动);
  4. i 指针移动:判断i是否小于j(未相遇),若未相遇,i从左向右移动,找到第一个大于基准数的元素后停止(若a[i] <= key则继续移动);
  5. 交换元素:再次判断i是否小于j(未相遇),若未相遇,交换a[i]和a[j]的值,重复步骤 3-5;
  6. 基准归位:当i与j相遇时,将基准数(a[L])与相遇位置的元素(a[i])交换,此时基准数左侧元素均小于它,右侧元素均大于它;
  7. 递归排序:以相遇位置i为分界点,将数组拆分为[L, i-1](左区间)和[i+1, R](右区间),对两个区间重复上述步骤,直到区间长度为 1(递归出口)。

1.2 时间复杂度与空间复杂度

  • 时间复杂度
  • 最好情况 / 平均情况:O(n log n)(每次划分能将数组均匀分为两部分,递归层数为log n,每层处理n个元素);
  • 最坏情况:O(n²)(数组已完全有序或逆序,每次划分仅能将数组分为 “1 个元素” 和 “n-1 个元素”,递归层数为n);
  • 优化方案:通过 “随机选择基准数” 或 “三数取中(取左、中、右三个位置元素的中位数作为基准)”,可将最坏情况概率降至极低,实际应用中复杂度接近O(n log n)。
  • 空间复杂度
  • 主要来自递归调用的栈空间,递归层数为log n(平均情况),因此空间复杂度为O(log n);
  • 若不优化基准选择,最坏情况下递归层数为n,空间复杂度为O(n)。
  • 稳定性:不稳定。例如数组[3, 2, 2, 1],基准数为 3,排序过程中会导致两个 2 的相对顺序改变。

1.3 Java 实现(双边指针法)

以下代码采用 “双边指针划分”,以第一个元素为基准数,包含完整的递归逻辑和测试案例(注:修复原代码中递归右区间的笔误,将qSort(a, L, pivot - 1)修正为qSort(a, pivot + 1, R)):


public class QuickSort {

public static void main(String[] args) {

// 测试用例:包含重复元素、无序数组

int[] arr = {5, 2, 9, 4, 7, 1, 3, 2};

System.out.print("排序前数组:");

for (int n : arr) System.out.print(n + " ");

// 执行快速排序

quickSort(arr);

System.out.print("\n排序后数组:");

for (int n : arr) System.out.print(n + " ");

// 输出:排序后数组:1 2 2 3 4 5 7 9

}

/**

* 快速排序入口方法

* @param a 待排序的int数组

*/

public static void quickSort(int[] a) {

// 边界判断:数组为空或长度小于2,无需排序

if (a == null || a.length < 2) return;

// 调用递归排序方法,初始区间为[0, a.length-1]

qSort(a, 0, a.length - 1);

}

/**

* 递归排序核心方法:对[L, R]区间的数组进行快速排序

* @param a 待排序数组

* @param L 区间左边界

* @param R 区间右边界

*/

private static void qSort(int[] a, int L, int R) {

// 递归出口:当左边界 >= 右边界时,区间长度为1或0,无需排序

if (L >= R) return;

// 一趟划分:返回基准数最终位置(相遇位置)

int pivot = partition(a, L, R);

// 递归排序左区间[L, pivot-1]

qSort(a, L, pivot - 1);

// 递归排序右区间[pivot+1, R](修复原笔误)

qSort(a, pivot + 1, R);

}

/**

* 双边指针划分法:将[L, R]区间划分为“左小右大”两部分,返回基准数位置

* @param a 待划分数组

* @param L 区间左边界(基准数初始位置)

* @param R 区间右边界

* @return 基准数最终位置(i与j的相遇位置)

*/

private static int partition(int[] a, int L, int R) {

int key = a[L]; // 1. 选择区间第一个元素作为基准数

int i = L; // 2. 左指针初始指向左边界

int j = R; // 2. 右指针初始指向右边界

// 循环:直到i与j相遇

while (i < j) {

// 3. j指针从右向左移动:找第一个小于基准数的元素(跳过>=key的元素)

// 注意:必须先移动j,确保相遇时元素小于基准数,保证基准归位正确

while (i < j && a[j] >= key) {

j--;

}

// 4. i指针从左向右移动:找第一个大于基准数的元素(跳过<=key的元素)

while (i < j && a[i] <= key) {

i++;

}

// 5. 若i<j(未相遇),交换i和j指向的元素

if (i < j) {

int tmp = a[i];

a[i] = a[j];

a[j] = tmp;

}

}

// 6. 基准归位:将基准数与相遇位置的元素交换

a[L] = a[i];

a[i] = key;

return i; // 7. 返回基准数位置,作为后续递归的分界点

}

}
关键说明:为什么先移动 j 指针?

若先移动 i 指针,可能导致相遇时元素大于基准数,此时与基准数交换会破坏 “左小右大” 的规则。例如数组[3, 1, 2]:

  • 先移动 i:i 会找到 2(大于 3),此时 j 仍在 2 位置,i 与 j 相遇,交换 3 和 2 后数组变为[2, 1, 3],基准数 3 左侧的 2>1,不符合 “左小右大”;
  • 先移动 j:j 会找到 2(小于 3),再移动 i(i=0 时 a [i]=3<=3,i++ 到 1,a [1]=1<=3,i++ 到 2,此时 i=j=2),交换 3 和 2 后数组变为[2, 1, 3],后续递归可正常处理左区间[0,1]。

1.4 快速排序与八大排序的关联

在八大排序(冒泡、选择、插入、希尔、归并、快速、堆、基数)中,快速排序与希尔排序、归并排序、堆排序同属 “高效排序算法”,时间复杂度均为O(n log n),但各有特点:

排序算法

时间复杂度(平均)

空间复杂度

稳定性

核心优势

快速排序

O(n log n)

O(log n)

不稳定

原地排序、实际效率最高,适合大规模数据

希尔排序

O(n log₂n)

O(1)

不稳定

原地排序、无递归栈开销,适合中等规模数据

归并排序

O(n log n)

O(n)

稳定

稳定排序、适合外排序(数据存磁盘)

堆排序

O(n log n)

O(1)

不稳定

原地排序、适合 Top-K 问题

快速排序凭借 “原地排序 + 低常数因子” 的优势,在内存中处理大规模数据时,效率通常优于其他O(n log n)级排序算法,是工业界的首选排序方案(如 Java 的Arrays.sort()对基本类型数组的实现就基于快速排序的优化版本 —— 双轴快速排序)。

二、折半查找:有序数组的 “高效查找利器”

折半查找(Binary Search)又称二分查找,仅适用于有序数组(升序或降序),其核心思想是 “每次排除一半不符合条件的元素”,大幅减少查找次数,是查找算法中的经典案例。

2.1 核心思想与两种区间模式

折半查找的本质是通过 “中间元素” 与 “目标元素” 的对比,不断缩小查找范围,直到找到目标或范围为空。根据查找区间的定义不同,可分为 “左闭右闭” 和 “左闭右开” 两种模式,两种模式的边界处理略有差异。

核心步骤(以升序数组为例):
  1. 定义查找区间的左右边界(left和right);
  2. 计算区间中间位置mid(避免溢出:mid = left + (right - left) / 2,而非(left + right) / 2,后者在left和right均为大值时可能超出 int 范围);
  3. 对比a[mid]与目标值target:
  • 若a[mid] == target:找到目标,返回mid;
  • 若a[mid] < target:目标在mid右侧,调整左边界;
  • 若a[mid] > target:目标在mid左侧,调整右边界;
  1. 重复步骤 2-3,直到区间为空(left > right或left == right,取决于区间模式),若未找到目标,返回 - 1(或其他标记值)。

2.2 两种区间模式的完整 Java 实现

模式 1:左闭右闭区间([left, right])
  • 初始边界:left = 0,right = a.length - 1(包含数组最后一个元素,因此right取数组长度减 1);
  • 边界调整:
  • 若a[mid] < target:目标在mid右侧,且mid已排除(a[mid]小于目标,不可能是结果),左边界更新为mid + 1;
  • 若a[mid] > target:目标在mid左侧,且mid已排除,右边界更新为mid - 1;
  • 循环终止条件:left > right(区间内无元素可查,如left=3、right=2,此时区间为空)。

public class BinarySearchClosed {

public static void main(String[] args) {

// 前提:数组必须有序(此处为升序)

int[] sortedArr = {1, 2, 3, 4, 5, 6, 7, 8, 9};

int target1 = 5; // 存在的目标值(预期索引4)

int target2 = 10; // 不存在的目标值(预期返回-1)

int target3 = 1; // 目标值在数组首位(预期索引0)

// 执行折半查找

int index1 = binarySearch(sortedArr, target1);

int index2 = binarySearch(sortedArr, target2);

int index3 = binarySearch(sortedArr, target3);

// 输出结果

System.out.printf("目标值%d的索引:%d\n", target1, index1); // 输出:4

System.out.printf("目标值%d的索引:%d\n", target2, index2); // 输出:-1

System.out.printf("目标值%d的索引:%d\n", target3, index3); // 输出:0

}

/**

* 左闭右闭区间的折半查找

* @param a 有序数组(升序)

* @param target 目标值

* @return 目标值索引(未找到返回-1)

*/

public static int binarySearch(int[] a, int target) {

// 边界判断:数组为空,直接返回-1(避免后续空指针异常)

if (a == null || a.length == 0) return -1;

int left = 0;

int right = a.length - 1; // 右边界初始为数组最后一个元素索引

// 循环:left <= right 表示区间内仍有元素可查(如left=right时,区间为[left,left],仍需判断)

while (left <= right) {

// 计算mid:避免left+right溢出(等价于(left+right)/2,但更安全)

int mid = left + (right - left) / 2;

if (a[mid] == target) {

return mid; // 找到目标,直接返回索引

} else if (a[mid] < target) {

// 目标在右侧,左边界更新为mid+1(mid已排除)

left = mid + 1;

} else {

// 目标在左侧,右边界更新为mid-1(mid已排除)

right = mid - 1;

}

}

// 循环结束仍未找到,返回-1(表示目标不存在)

return -1;

}

}
模式 2:左闭右开区间([left, right))
  • 初始边界:left = 0,right = a.length(不包含数组最后一个元素,因此right取数组长度,如数组长度为 6,right=6,最后一个元素索引为 5,不包含在区间内);
  • 边界调整:
  • 若a[mid] < target:目标在mid右侧,mid已排除(a[mid]小于目标),左边界更新为mid + 1;
  • 若a[mid] > target:目标在mid左侧,因区间右开,mid不包含在区间内,直接将右边界更新为mid;
  • 循环终止条件:left == right(区间为空,如left=3、right=3,此时区间[3,3)无任何元素)。
public class BinarySearchOpen {
    public static void main(String[] args) {
        // 前提:数组必须有序(此处为升序,若为降序需调整判断逻辑)
        int[] sortedArr = {1, 3, 5, 7, 9, 11, 13, 15};
        // 测试用例:包含存在、不存在、边界值三种场景
        int target1 = 7;   // 存在的目标值(预期索引3)
        int target2 = 8;   // 不存在的目标值(预期返回-1)
        int target3 = 15;  // 目标值在数组末尾(预期索引7)
        int target4 = 1;   // 目标值在数组首位(预期索引0)

        // 执行左闭右开区间的折半查找
        int index1 = binarySearch(sortedArr, target1);
        int index2 = binarySearch(sortedArr, target2);
        int index3 = binarySearch(sortedArr, target3);
        int index4 = binarySearch(sortedArr, target4);

        // 打印查找结果
        System.out.printf("目标值%d的索引:%d(预期:3)\n", target1, index1);
        System.out.printf("目标值%d的索引:%d(预期:-1)\n", target2, index2);
        System.out.printf("目标值%d的索引:%d(预期:7)\n", target3, index3);
        System.out.printf("目标值%d的索引:%d(预期:0)\n", target4, index4);
    }

    /**
     * 左闭右开区间的折半查找(核心方法)
     * @param a 有序数组(升序)
     * @param target 待查找的目标值
     * @return 目标值在数组中的索引,未找到返回-1
     */
    public static int binarySearch(int[] a, int target) {
        // 边界判断1:数组为空,直接返回-1(避免空指针异常)
        if (a == null || a.length == 0) {
            return -1;
        }

        int left = 0;
        int right = a.length; // 右边界初始为数组长度,符合左闭右开[left, right)定义

        // 循环条件:left < right(当left == right时,区间为空,终止循环)
        while (left < right) {
            // 计算中间位置mid:避免left+right溢出(等价于(left+right)/2,但更安全)
            int mid = left + (right - left) / 2;

            if (a[mid] == target) {
                // 找到目标值,直接返回索引
                return mid;
            } else if (a[mid] < target) {
                // 目标值在mid右侧:mid已排除,左边界更新为mid+1
                left = mid + 1;
            } else {
                // 目标值在mid左侧:因区间右开,右边界直接更新为mid(mid不包含在新区间内)
                right = mid;
            }
        }

        // 循环结束后仍未找到目标值,返回-1
        return -1;
    }
}

2.3 两种区间模式的差异对比与选择建议

为了帮助大家更清晰地掌握两种模式的核心区别,避免在实际开发中混淆边界处理逻辑,这里通过表格总结关键差异,并给出选择建议:

对比维度左闭右闭区间 [left, right]左闭右开区间 [left, right)
初始边界right = a.length - 1(包含最后一个元素)right = a.length(不包含最后一个元素)
循环终止条件left > right(区间无元素时终止)left == right(区间无元素时终止)
右边界调整逻辑a[mid] > target时,right = mid - 1a[mid] > target时,right = mid
适用场景更符合人类直觉(如 “查找 1-10 的数字”),适合新手更贴近计算机底层逻辑(如数组切片),适合框架开发
代码容错性边界处理稍复杂(易漏写-1边界处理更简洁(无需计算length-1

选择建议:

  1. 新手入门:优先选择左闭右闭区间。其逻辑更符合日常认知(比如 “查找数组从第 0 个到第 n-1 个元素”),边界调整时的+1-1更容易理解,能减少初期学习的混淆成本。
  2. 框架 / 工具开发:优先选择左闭右开区间。这种模式在处理 “数组切片”“子区间划分” 时更高效(例如从数组[0, 8)中截取[2, 5)子区间,直接复用边界值即可),且无需计算length-1,能减少代码中的硬编码错误。

2.4 折半查找的时间复杂度与应用场景

(1)时间复杂度分析

折半查找的核心优势是 “每次排除一半元素”,其时间复杂度与 “查找次数” 直接相关:

  • 假设数组长度为n,每次查找后剩余元素数量为n/2n/4n/8... 直到剩余 1 个元素;
  • 查找次数k满足n/(2^k) ≥ 1,即k ≤ log₂n,因此时间复杂度为 O(log n)(对数级复杂度,效率极高)。

对比线性查找(O(n)):当数组长度n=1000000时,线性查找最坏需要 100 万次比较,而折半查找最坏仅需 20 次(log₂1000000≈20),效率差距悬殊。

(2)应用场景

折半查找虽高效,但依赖 “有序数组” 这一前提,因此适合以下场景:

  1. 静态数据查询:数据一经初始化后很少修改(如字典词典、配置表),只需一次排序,后续可反复高效查询;
  2. 数据库索引优化:数据库中的 B + 树索引(主流索引结构),底层核心逻辑就是基于折半查找的思想,实现快速定位数据行;
  3. 二分答案问题:在 “寻找满足条件的最值” 场景中(如 “木材切割的最大长度”“平方根的近似值”),可通过折半查找缩小答案范围,而非遍历所有可能值。
(3)局限性
  • 不适合动态数据:若数据频繁插入 / 删除,需频繁重新排序,排序的O(n log n)成本会抵消折半查找的优势(此时建议用二叉搜索树、红黑树等动态结构);
  • 不适合小规模数据:当n较小时(如n<10),线性查找与折半查找的效率差距可忽略,但折半查找需额外处理边界逻辑,反而增加代码复杂度。

三、快速排序与折半查找的联动实战:高效处理 “排序 + 查询” 需求

在实际开发中,“先排序、后查询” 是高频需求(如用户列表按 ID 排序后查询指定用户、商品列表按价格排序后查询指定价格区间的商品)。下面通过一个完整案例,演示如何结合快速排序与折半查找,实现高效的数据处理流程。

实战需求:

现有一个无序的用户 ID 数组(int[] userIds = {5, 2, 9, 4, 7, 1, 3, 8, 6}),需完成以下操作:

  1. 对用户 ID 数组进行升序排序;
  2. 快速查询指定用户 ID 是否存在,若存在则返回其索引,不存在则提示 “用户不存在”。

完整实现代码:

import java.util.Scanner;

public class SortAndSearchDemo {
    public static void main(String[] args) {
        // 1. 定义无序的用户ID数组
        int[] userIds = {5, 2, 9, 4, 7, 1, 3, 8, 6};
        System.out.print("原始用户ID数组:");
        printArray(userIds);

        // 2. 调用快速排序,对数组升序排序
        quickSort(userIds);
        System.out.print("排序后用户ID数组:");
        printArray(userIds);

        // 3. 接收用户输入的目标ID,进行折半查找
        Scanner scanner = new Scanner(System.in);
        System.out.print("\n请输入要查询的用户ID:");
        int targetId = scanner.nextInt();

        // 选择左闭右开区间的折半查找(也可替换为左闭右闭模式)
        int index = binarySearchOpen(userIds, targetId);

        // 4. 输出查询结果
        if (index != -1) {
            System.out.printf("查询成功!用户ID%d在数组中的索引为:%d\n", targetId, index);
        } else {
            System.out.printf("查询失败!用户ID%d不存在\n", targetId);
        }

        scanner.close();
    }

    // ---------------------- 快速排序相关方法 ----------------------
    public static void quickSort(int[] a) {
        if (a == null || a.length < 2) return;
        qSort(a, 0, a.length - 1);
    }

    private static void qSort(int[] a, int L, int R) {
        if (L >= R) return;
        int pivot = partition(a, L, R);
        qSort(a, L, pivot - 1);
        qSort(a, pivot + 1, R);
    }

    private static int partition(int[] a, int L, int R) {
        int key = a[L];
        int i = L, j = R;
        while (i < j) {
            while (i < j && a[j] >= key) j--;
            while (i < j && a[i] <= key) i++;
            if (i < j) {
                int tmp = a[i];
                a[i] = a[j];
                a[j] = tmp;
            }
        }
        a[L] = a[i];
        a[i] = key;
        return i;
    }

    // ---------------------- 左闭右开折半查找相关方法 ----------------------
    public static int binarySearchOpen(int[] a, int target) {
        if (a == null || a.length == 0) return -1;
        int left = 0;
        int right = a.length;
        while (left < right) {
            int mid = left + (right - left) / 2;
            if (a[mid] == target) {
                return mid;
            } else if (a[mid] < target) {
                left = mid + 1;
            } else {
                right = mid;
            }
        }
        return -1;
    }

    // ---------------------- 辅助方法:打印数组 ----------------------
    public static void printArray(int[] a) {
        for (int i = 0; i < a.length; i++) {
            System.out.print(a[i] + " ");
        }
        System.out.println();
    }
}

运行结果示例:

原始用户ID数组:5 2 9 4 7 1 3 8 6

排序后用户ID数组:1 2 3 4 5 6 7 8 9

请输入要查询的用户ID:7

查询成功!用户ID7在数组中的索引为:6

效率分析:

  • 排序阶段:快速排序时间复杂度O(n log n),对n=9的数组仅需 3-4 轮递归,效率极高;
  • 查询阶段:折半查找时间复杂度O(log n),对n=9的数组最坏仅需 4 次比较(log₂9≈3.17);
  • 整体流程:若需多次查询,只需一次排序(O(n log n)),后续每次查询均为O(log n),相比 “每次查询都线性遍历(O(n))”,效率提升显著。

四、总结与拓展学习建议

4.1 核心知识点总结

  1. 快速排序

    • 核心思想:分治法 + 基准归位,通过双边指针划分数组为 “左小右大” 两部分,递归排序;
    • 关键优势:原地排序、平均时间复杂度O(n log n),实际开发中处理大规模数据效率最高;
    • 注意事项:基准选择影响最坏复杂度,建议用 “三数取中” 优化,且算法不稳定。
  2. 折半查找

    • 核心前提:仅适用于有序数组;
    • 两种模式:左闭右闭(新手友好)、左闭右开(框架开发友好),核心差异在边界处理;
    • 关键优势:时间复杂度O(log n),远超线性查找,适合静态数据的高频查询场景。
  3. 联动价值:快速排序为折半查找提供 “有序数据” 基础,折半查找为排序后的数据提供 “高效查询” 能力,二者结合是处理 “排序 + 查询” 需求的经典方案。

4.2 拓展学习建议

  1. 快速排序优化:深入学习 “随机基准”“三数取中”“尾递归优化” 等进阶技巧,理解 Java 中Arrays.sort()的双轴快速排序实现原理;
  2. 折半查找变种:学习 “查找第一个大于等于目标值的元素”“查找最后一个小于目标值的元素” 等变种场景,这些是二分答案问题的核心;
  3. 对比其他算法:将快速排序与归并排序(稳定排序)、堆排序(Top-K 问题)对比,将折半查找与哈希表查找(O(1)平均复杂度)对比,理解不同算法的适用边界;
  4. 实战练习:尝试用 “快速排序 + 折半查找” 解决实际问题(如成绩排名查询、商品价格区间定位),或在 LeetCode 上完成相关题目(如 LeetCode 912. 排序数组、LeetCode 704. 二分查找)。

通过本文的学习,相信大家已掌握快速排序与折半查找的核心逻辑与实战方法。数据结构与算法的学习核心在于 “理解思想 + 多练多写”,建议大家多手动模拟算法流程、多修改代码测试边界场景,逐步提升解决复杂问题的能力。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值