数据结构与算法之美总结(数组、链表、栈、队列、递归、排序及二分)


title: 数据结构与算法之美总结(数组、链表、栈、队列、递归、排序及二分)
date: 2023-04-15 01:41:26
tags:

  • 数据结构
  • 算法
    categories:
  • 数据结构与算法
    cover: https://cover.png
    feature: false

1. 前言

1、什么是数据结构?什么是算法?

  • 从广义上讲,数据结构就是指一组数据的存储结构。算法就是操作数据的一组方法
  • 从狭义上讲,是指某些著名的数据结构和算法,比如队列、栈、堆、二分查找、动态规划等。这些都是前人智慧的结晶,可以直接拿来用。这些经典数据结构和算法,都是前人从很多实际操作场景中抽象出来的,经过非常多的求证和检验,可以高效地帮助我们解决很多实际的开发问题

2、数据结构和算法有什么关系呢?

数据结构是为算法服务的,算法要作用在特定的数据结构之上。 因此,我们无法孤立数据结构来讲算法,也无法孤立算法来讲数据结构

比如,因为数组具有随机访问的特点,常用的二分查找算法需要用数组来存储数据。但如果选择链表这种数据结构,二分查找算法就无法工作了,因为链表并不支持随机访问

数据结构是静态的,它只是组织数据的一种方式。如果不在它的基础上操作、构建算法,孤立存在的数据结构就是没用的

大部分数据结构和算法知识点如下图所示:

这里面有

  • 10 个数据结构:数组、链表、栈、队列、散列表、二叉树、堆、跳表、图、Trie 树
  • 10 个算法:递归、排序、二分查找、搜索、哈希算法、贪心算法、分治算法、回溯算法、动态规划、字符串匹配算法

掌握了这些基础的数据结构和算法,再学更加复杂的数据结构和算法,就会非常容易、非常快

2. 复杂度分析

数据结构和算法本身解决的是“快”和“省”的问题,即如何让代码运行得更快,如何让代码更省存储空间。所以,执行效率是算法一个非常重要的考量指标。那如何来衡量你编写的算法代码的执行效率呢?这里就要用到时间、空间复杂度分析

2.1 为什么需要复杂度分析?

你可能会有些疑惑,把代码跑一遍,通过统计、监控,就能得到算法执行的时间和占用的内存大小。为什么还要做时间、空间复杂度分析呢?这种分析方法能比实实在在跑一遍得到的数据更准确吗?

首先可以肯定地说,这种评估算法执行效率的方法是正确的。很多数据结构和算法书籍还给这种方法起了一个名字,叫事后统计法。但是,这种统计方法有非常大的局限性

1、测试结果非常依赖测试环境

测试环境中硬件的不同会对测试结果有很大的影响。比如,拿同样一段代码,分别用 Intel Core i9 处理器和 Intel Core i3 处理器来运行,不用说,i9 处理器要比 i3 处理器执行的速度快很多。还有,比如原本在这台机器上 a 代码执行的速度比 b 代码要快,等换到另一台机器上时,可能会有截然相反的结果

2、测试结果受数据规模的影响很大

对同一个排序算法,待排序数据的有序度不一样,排序的执行时间就会有很大的差别。极端情况下,如果数据已经是有序的,那排序算法不需要做任何操作,执行时间就会非常短。除此之外,如果测试数据规模太小,测试结果可能无法真实地反应算法的性能。比如,对于小规模的数据排序,插入排序可能反倒会比快速排序要快!

所以,我们需要一个不用具体的测试数据来测试,就可以粗略地估计算法的执行效率的方法。这就是时间、空间复杂度分析方法

2.2 大 O 复杂度表示法

算法的执行效率,粗略地讲,就是算法代码执行的时间。但是,如何在不运行代码的情况下,用“肉眼”得到一段代码的执行时间呢?

这里有段非常简单的代码,求 1 , 2 , 3 , 4... n {1, 2, 3, 4 ... n} 1,2,3,4...n 的累加和,现在,就来估算一下这段代码的执行时间

int cal(int n) {
    int sum = 0;
    int i = 1;
    for (; i <= n; ++i) {
        sum = sum + i;
    }
    return sum;
}

从 CPU 的角度来看,这段代码的每一行都执行着类似的操作:读数据-运算-写数据。尽管每行代码对应的 CPU 执行的个数、执行的时间都不一样,但是,这里只是粗略估计,所以可以假设每行代码执行的时间都一样,为 unit_time。在这个假设的基础之上,这段代码的总执行时间是多少呢?

第 2、3 行代码分别需要 1 个 unit_time 的执行时间,第 4、5 行都运行了 n 遍,所以需要 2 n ∗ u n i t _ t i m e {2n*unit\_time} 2nunit_time 的执行时间,所以这段代码总的执行时间就是 ( 2 n + 2 ) ∗ u n i t _ t i m e (2n+2)*unit\_time (2n+2)unit_time。可以看出来,所有代码的执行时间 T ( n ) T_{\left(n \right)} T(n) 与每行代码的执行次数成正比

按照这个分析思路,再来看这段代码

int cal(int n) {
    int sum = 0;
    int i = 1;
    int j = 1;
    for (; i <= n; ++i) {
        j = 1;
        for (; j <= n; ++j) {
            sum = sum + i * j;
        }
    }
}

依旧假设每个语句的执行时间是 unit_time。那这段代码的总执行时间 T ( n ) T_{\left(n \right)} T(n) 是多少呢?

第 2、3、4 行代码,每行都需要 1 个 unit_time 的执行时间,第 5、6 行代码循环执行了 n 遍,需要 2 n ∗ u n i t _ t i m e {2n * unit\_time} 2nunit_time 的执行时间,第 7、8 行代码循环执行了 n 2 n^2 n2 遍,所以需要 2 n 2 ∗ u n i t _ t i m e {2n^2}*unit\_time 2n2unit_time 的执行时间。所以,整段代码总的执行时间 T ( n ) = ( 2 n 2 + 2 n + 3 ) ∗ u n i t _ t i m e T_{\left(n \right)} = (2n^2 +2n+3)*unit\_time T(n)=(2n2+2n+3)unit_time

尽管不知道 unit_time 的具体值,但是通过这两段代码执行时间的推导过程,可以得到一个非常重要的规律,那就是,所有代码的执行时间 T(n) 与每行代码的执行次数 n 成正比

把这个规律总结成一个公式: T ( n ) = O ( f ( n ) ) T_{\left( n \right)} = O{(f_{\left( n \right)})} T(n)=O(f(n))

其中,$T_{\left(n \right)} $ 表示代码执行的时间;n 表示数据规模的大小; f ( n ) f_{\left(n \right)} f(n) 表示每行代码执行的次数总和。因为这是一个公式,所以用 f ( n ) f_{\left(n \right)} f(n) 来表示。公式中的 O,表示代码的执行时间 $T_{\left(n \right)} $ 与 f ( n ) f_{\left(n \right)} f(n) 表达式成正比

所以,第一个例子中的 T ( n ) = O ( 2 n + 2 ) T_{\left(n \right)} = O{(2n+2)} T(n)=O(2n+2),第二个例子中的 T ( n ) = O ( 2 n 2 + 2 n + 3 ) T_{\left(n \right)} = O{(2n^2 + 2n + 3)} T(n)=O(2n2+2n+3)。这就是大 O 时间复杂度表示法。大 O 时间复杂度实际上并不具体表示代码真正的执行时间,而是表示代码执行时间随数据规模增长的变化趋势,所以,也叫作渐进时间复杂度(asymptotic time complexity),简称时间复杂度

当 n 很大时,可以把它想象成 10000、100000。而公式中的低阶、常量、系数三部分并不左右增长趋势,所以都可以忽略。只需要记录一个最大量级就可以了,如果用大 O 表示法表示刚讲的那两段代码的时间复杂度,就可以记为: T ( n ) = O ( n ) T_{\left(n \right)} = O(n) T(n)=O(n) T ( n ) = O ( n 2 ) T_{\left(n \right)} = O(n^2) T(n)=O(n2)

2.3 时间复杂度分析

前面介绍了大 O 时间复杂度的由来和表示方法。现在来看下,如何分析一段代码的时间复杂度?

1、只关注循环执行次数最多的一段代码

大 O 这种复杂度表示方法只是表示一种变化趋势。通常会忽略掉公式中的常量、低阶、系数,只需要记录一个最大阶的量级就可以了。所以,在分析一个算法、一段代码的时间复杂度的时候,也只关注循环执行次数最多的那一段代码就可以了。这段核心代码执行次数的 n 的量级,就是整段要分析代码的时间复杂度

比如前面的例子:

int cal(int n) {
    int sum = 0;
    int i = 1;
    for (; i <= n; ++i) {
        sum = sum + i;
    }
    return sum;
}

其中第 2、3 行代码都是常量级的执行时间,与 n 的大小无关,所以对于复杂度并没有影响。循环执行次数最多的是第 4、5 行代码,所以这块代码要重点分析。这两行代码被执行了 n 次,所以总的时间复杂度就是 O ( n ) O(n) O(n)

2、加法法则:总复杂度等于量级最大的那段代码的复杂度

如下例:

int cal(int n) {
    int sum_1 = 0;
    int p = 1;
    for (; p < 100; ++p) {
        sum_1 = sum_1 + p;
    }
    int sum_2 = 0;
    int q = 1;
    for (; q < n; ++q) {
        sum_2 = sum_2 + q;
    }
    int sum_3 = 0;
    int i = 1;
    int j = 1;
    for (; i <= n; ++i) {
        j = 1;
        for (; j <= n; ++j) {
            sum_3 = sum_3 + i * j;
        }
    }
    return sum_1 + sum_2 + sum_3;
}

这个代码分为三部分,分别是求 sum_1、sum_2、sum_3。可以分别分析每一部分的时间复杂度,然后把它们放到一块儿,再取一个量级最大的作为整段代码的复杂度

第一段的时间复杂度是多少呢?这段代码循环执行了 100 次,所以是一个常量的执行时间,跟 n 的规模无关

这里再强调一下,即便这段代码循环 10000 次、100000 次,只要是一个已知的数,跟 n 无关,照样也是常量级的执行时间。当 n 无限大的时候,就可以忽略。尽管对代码的执行时间会有很大影响,但是回到时间复杂度的概念来说,它表示的是一个算法执行效率与数据规模增长的变化趋势,所以不管常量的执行时间多大,都可以忽略掉。因为它本身对增长趋势并没有影响

那第二段代码和第三段代码的时间复杂度是多少呢?答案是 O ( n ) O(n) O(n) O ( n 2 ) O(n^2) O(n2),这应该很容易就能分析出来,综合这三段代码的时间复杂度,取其中最大的量级。所以,整段代码的时间复杂度就为 O ( n 2 ) O(n^2) O(n2)。也就是说:总的时间复杂度就等于量级最大的那段代码的时间复杂度。那将这个规律抽象成公式就是:

如果 T 1 ( n ) = O ( f ( n ) ) T1_{\left(n \right)} = O(f_{\left(n \right)}) T1(n)=O(f(n)) T 2 ( n ) = O ( g ( n ) ) T2_{\left(n \right)} = O(g_{\left(n \right)}) T2(n)=O(g(n))。那么 T ( n ) = T 1 ( n ) + T 2 ( n ) = m a x ( O ( f ( n ) ) , O ( g ( n ) ) = O ( m a x ( O ( f ( n ) ) , O ( g ( n ) ) ) ) T_{\left(n \right)} = T1_{\left(n \right)} + T2_{\left(n \right)} = max(O(f_{\left(n \right)}), O(g_{\left(n \right)}) =O(max(O(f_{\left(n \right)}), O(g_{\left(n \right)}))) T(n)=T1(n)+T2(n)=max(O(f(n)),O(g(n))=O(max(O(f(n)),O(g(n))))

3、乘法法则:嵌套代码的复杂度等于嵌套内外代码复杂度的乘积

类比上面的加法法则,如果 T 1 ( n ) = O ( f ( n ) ) T1_{\left(n \right)} = O(f_{\left(n \right)}) T1(n)=O(f(n)) T 2 ( n ) = O ( g ( n ) ) T2_{\left(n \right)} = O(g_{\left(n \right)}) T2(n)=O(g(n))。那么 T ( n ) = T 1 ( n ) ∗ T 2 ( n ) = O ( f ( n ) ) ∗ O ( g ( n ) ) = O ( f ( n ) ∗ g ( n ) ) T_{\left(n \right)} = T1_{\left(n \right)} * T2_{\left(n \right)} = O(f_{\left(n \right)}) * O(g_{\left(n \right)}) = O(f_{\left(n \right)} * g_{\left(n \right)}) T(n)=T1(n)T2(n)=O(f(n))O(g(n))=O(f(n)g(n))

也就是说,假设如果 T 1 ( n ) = O ( n ) T1_{\left(n \right)} = O(n) T1(n)=O(n) T 2 ( n ) = O ( n 2 ) T2_{\left(n \right)} = O(n^2) T2(n)=O(n2),则 T 1 ( n ) ∗ T 2 ( n ) = O ( n 3 ) T1_{\left(n \right)} * T2_{\left(n \right)} = O(n^3) T1(n)T2(n)=O(n3)

落实到具体的代码上,可以把乘法法则看成是嵌套循环,如下例:

int cal(int n) {
    int ret = 0;
    int i = 1;
    for (; i < n; ++i) {
        ret = ret + f(i);
    }
}
int f(int n) {
    int sum = 0;
    int i = 1;
    for (; i < n; ++i) {
        sum = sum + i;
    }
    return sum;
}

单独看 cal() 函数。假设 f() 只是一个普通的操作,那第 4~6 行的时间复杂度就是, T 1 ( n ) = O ( n ) T1_{\left(n \right)} = O(n) T1(n)=O(n)。但 f() 函数本身不是一个简单的操作,它的时间复杂度是 T 2 ( n ) = O ( n ) T2_{\left(n \right)} = O(n) T2(n)=O(n),所以,整个 cal() 函数的时间复杂度就是, T 1 ( n ) ∗ T 2 ( n ) = O ( n ) ∗ O ( n ) = O ( n 2 ) T1_{\left(n \right)} * T2_{\left(n \right)} = O(n) * O(n) = O(n^2) T1(n)T2(n)=O(n)O(n)=O(n2)

2.4 几种常见时间复杂度实例分析

常见的复杂度量级如下:

  • 常数阶: O ( 1 ) O(1) O(1)
  • 对数阶: O ( l o g n ) O(log n) O(logn)
  • 线性阶: O ( n ) O(n) O(n)
  • 线性对数阶: O ( n l o g n ) O(n logn) O(nlogn)
  • 平方阶: O ( n 2 ) O(n^2) O(n2)
  • 立方阶: O ( n 3 ) O(n^3) O(n3)
  • k 次方阶: O ( n k ) O(n^k) O(nk)
  • 指数阶: O ( 2 n ) O(2^n) O(2n)
  • 阶乘阶: O ( n ! ) O(n!) O(n!)

对于上面罗列的复杂度量级,可以粗略地分为两类,多项式量级和非多项式量级。其中,非多项式量级只有两个: O ( 2 n ) O(2^n) O(2n) O ( n ! ) O(n!) O(n!)

当数据规模 n 越来越大时,非多项式量级算法的执行时间会急剧增加,求解问题的执行时间会无限增长。所以,非多项式时间复杂度的算法其实是非常低效的算法

1、 O ( 1 ) O(1) O(1)

首先必须明确一个概念, O ( 1 ) O(1) O(1) 只是常量级时间复杂度的一种表示方法,并不是指只执行了一行代码。比如这段代码,即便有 3 行,它的时间复杂度也是 O ( 1 ) O(1) O(1),而不是 O ( 3 ) O(3) O(3)

int i = 8;
int j = 6;
int sum = i + j;

只要代码的执行时间不随 n 的增大而增长,这样代码的时间复杂度都记作 O ( 1 ) O(1) O(1)。或者说,一般情况下,只要算法中不存在循环语句、递归语句,即使有成千上万行的代码,其时间复杂度也是 O ( 1 ) O(1) O(1)

2、 O ( l o g n ) O(logn) O(logn) O ( n l o g n ) O(nlogn) O(nlogn)

对数阶时间复杂度非常常见,同时也是最难分析的一种时间复杂度,如下例:

i = 1;
while (i <= n) {
    i = i * 2;
}

根据前面讲的复杂度分析方法,第三行代码是循环执行次数最多的。所以,只要能计算出这行代码被执行了多少次,就能知道整段代码的时间复杂度

从代码中可以看出,变量 i 的值从 1 开始取,每循环一次就乘以 2。当大于 n 时,循环结束。实际上,变量 i 的取值就是一个等比数列。如果把它一个一个列出来,就应该是这个样子的: 2 0   2 1   2 2   . . .   2 x = n {2^0 \ 2^1 \ 2^2 \ ... \ 2^x = n} 20 21 22 ... 2x=n

所以,只要知道 x 值是多少,就知道这行代码执行的次数了。通过 2 x = n {2^x = n} 2x=n 求解 x 这个问题高中应该就学过了, x = l o g 2 n x = log_2 n x=log2n,所以,这段代码的时间复杂度就是 O ( l o g 2 n ) O(log_2 n) O(log2n)

现在,把代码稍微改下,再看看,这段代码的时间复杂度是多少?

i = 1;
while (i <= n) {
    i = i * 3;
}

根据刚刚讲的思路,很简单就能看出来,这段代码的时间复杂度为 O ( l o g 3 n ) O(log_3 n) O(log3n)

实际上,不管是以 2 为底、以 3 为底,还是以 10 为底,可以把所有对数阶的时间复杂度都记为 O ( l o g n ) O(log n) O(logn)。为什么呢?

对数之间是可以互相转换的,根据公式:

l o g a b = l o g c b l o g c a log_a b = {log_c b \over log_c a} logab=logcalogcb

可得 l o g 2 n = l o g 3 n l o g 3 2 log_2 n = {log_3 n \over log_3 2} log2n=log32log3n。所以 l o g 3 n log_3 n log3n 就等于 l o g 3 2 ∗ l o g 2 n log_3 2 * log_2 n log32log2n,所以 O ( l o g 3 n ) = O ( C ∗ l o g 2 n ) O(log_3 n) = O(C * log_2 n) O(log3n)=O(Clog2n),其中 C = l o g 3 2 C=log_3 2 C=log32 是一个常量。基于前面的一个理论:在采用大 O 标记复杂度的时候,可以忽略系数,即 O ( C f ( n ) ) = O ( f ( n ) ) O(Cf_{\left(n \right)}) = O(f_{\left(n \right)}) O(Cf(n))=O(f(n))。所以, O ( l o g 3 n ) O(log_3 n) O(log3n) 就等于 O ( l o g 2 n ) O(log_2 n) O(log2n)。因此,在对数阶时间复杂度的表示方法里,忽略对数的“底”,统一表示为 O ( l o g n ) O(log n) O(logn)

如果理解了前面讲的 O ( l o g n ) O(log n) O(logn),那 O ( n l o g n ) O(nlog n) O(nlogn) 就很容易理解了。如果一段代码的时间复杂度是 O ( l o g n ) O(log n) O(logn),循环执行 n 遍,时间复杂度就是 O ( n l o g n ) O(nlog n) O(nlogn) 了。而且, O ( n l o g n ) O(nlog n) O(nlogn) 也是一种非常常见的算法时间复杂度。比如,归并排序、快速排序的时间复杂度都是 O ( n l o g n ) O(nlog n) O(nlogn)

3、 O ( m + n ) O(m+n) O(m+n) O ( m ∗ n ) O(m*n) O(mn)

再来讲一种跟前面都不一样的时间复杂度,代码的复杂度由两个数据的规模来决定,如下例:

int cal(int m, int n) {
    int sum_1 = 0;
    int i = 1;
    for (; i < m; ++i) {
        sum_1 = sum_1 + i;
    }
    int sum_2 = 0;
    int j = 1;
    for (; j < n; ++j) {
        sum_2 = sum_2 + j;
    }
    return sum_1 + sum_2;
}

从代码中可以看出,m 和 n 是表示两个数据规模。无法事先评估 m 和 n 谁的量级大,所以在表示复杂度的时候,就不能简单地利用加法法则,省略掉其中一个。所以,上面代码的时间复杂度就是 O ( m + n ) O(m+n) O(m+n)

针对这种情况,原来的加法法则就不正确了,需要将加法规则改为: T 1 ( m ) + T 2 ( n ) = O ( f ( n ) + g ( n ) ) T1_{\left(m \right)} + T2_{\left(n \right)} = O(f_{\left(n \right)} + g_{\left(n \right)}) T1(m)+T2(n)=O(f(n)+g(n))。但是乘法法则继续有效: T 1 ( m ) ∗ T 2 ( n ) = O ( f ( n ) ∗ g ( n ) ) T1_{\left(m \right)} * T2_{\left(n \right)} = O(f_{\left(n \right)} * g_{\left(n \right)}) T1(m)T2(n)=O(f(n)g(n))

2.5 空间复杂度分析

理解了前面讲的时间复杂度分析,空间复杂度分析方法学起来就非常简单了

时间复杂度的全称是渐进时间复杂度,表示算法的执行时间与数据规模之间的增长关系。类比一下,空间复杂度全称就是渐进空间复杂度(asymptotic space complexity),表示算法的存储空间与数据规模之间的增长关系

如下例(一般没人会这么写,这里是为了方便讲解)

void print(int n) {
    int i = 0;
    int[] a = new int[n];
    for (i; i < n; ++i) {
        a[i] = i * i;
    }
    for (i = n - 1; i >= 0; --i) {
        print out a[i]
    }
}

跟时间复杂度分析一样,可以看到,第 2 行代码中,申请了一个空间存储变量 i,但是它是常量阶的,跟数据规模 n 没有关系,所以可以忽略。第 3 行申请了一个大小为 n 的 int 类型数组,除此之外,剩下的代码都没有占用更多的空间,所以整段代码的空间复杂度就是 O ( n ) O(n) O(n)

常见的空间复杂度就是 O ( 1 ) O(1) O(1) O ( n ) O(n) O(n) O ( n 2 ) O(n^2) O(n2),像 O ( l o g n ) O(logn) O(logn) O ( n l o g n ) O(nlogn) O(nlogn) 这样的对数阶复杂度平时都用不到。而且,空间复杂度分析比时间复杂度分析要简单很多

2.6 最好、最坏、平均情况时间复杂度

先看一个例子:

// n 表示数组 array 的长度
int find(int[] array, int n, int x) {
    int i = 0;
    int pos = -1;
    for (; i < n; ++i) {
        if (array[i] == x) pos = i;
    }
    return pos;
}

这段代码要实现的功能是,在一个无序的数组(array)中,查找变量 x 出现的位置。如果没有找到,就返回 -1。按照前面讲的分析方法,这段代码的复杂度是 O ( n ) O(n) O(n),其中,n 代表数组的长度

在数组中查找一个数据,并不需要每次都把整个数组都遍历一遍,因为有可能中途找到就可以提前结束循环了。但是,这段代码写得不够高效。可以这样优化一下这段查找代码,如下:

// n 表示数组 array 的长度
int find(int[] array, int n, int x) {
    int i = 0;
    int pos = -1;
    for (; i < n; ++i) {
        if (array[i] == x) {
            pos = i;
            break;
        }
    }
    return pos;
}

这个时候,问题就来了。优化完之后,这段代码的时间复杂度还是 O ( n ) O(n) O(n) 吗?很显然,前面讲的分析方法,解决不了这个问题

因为,要查找的变量 x 可能出现在数组的任意位置。如果数组中第一个元素正好是要查找的变量 x,那就不需要继续遍历剩下的 n-1 个数据了,那时间复杂度就是 O ( 1 ) O(1) O(1)。但如果数组中不存在变量 x,那就需要把整个数组都遍历一遍,时间复杂度就成了 O ( n ) O(n) O(n)。所以,不同的情况下,这段代码的时间复杂度是不一样的

为了表示代码在不同情况下的不同时间复杂度,需要引入三个概念:最好情况时间复杂度(best case time complexity)、最坏情况时间复杂度(worst case time complexity)和平均情况时间复杂度(average case time complexity)

  • 顾名思义,最好情况时间复杂度就是,在最理想的情况下,执行这段代码的时间复杂度。就像刚刚讲到的,在最理想的情况下,要查找的变量 x 正好是数组的第一个元素,这个时候对应的时间复杂度就是最好情况时间复杂度
  • 同理,最坏情况时间复杂度就是,在最糟糕的情况下,执行这段代码的时间复杂度。就像刚举的那个例子,如果数组中没有要查找的变量 x,需要把整个数组都遍历一遍才行,所以这种最糟糕情况下对应的时间复杂度就是最坏情况时间复杂度

最好情况时间复杂度和最坏情况时间复杂度对应的都是极端情况下的代码复杂度,发生的概率其实并不大。为了更好地表示平均情况下的复杂度,需要引入另一个概念:平均情况时间复杂度,后面简称为平均时间复杂度

平均时间复杂度又该怎么分析呢?还是借助刚才查找变量 x 的例子

要查找的变量 x 在数组中的位置,有 n+1 种情况:在数组的 0~n-1 位置中和不在数组中。把每种情况下,查找需要遍历的元素个数累加起来,然后再除以 n+1,就可以得到需要遍历的元素个数的平均值,公式如下(等差数列的和等于 首项加尾项 乘以 项数除2):

1 + 2 + 3 + . . . + n + n n + 1 = ( 1 + n ) × n 2 + n n + 1 = n + n 2 + 2 n 2 n + 1 = n ( n + 3 ) 2 ( n + 1 ) {1 + 2 + 3 + ... + n + n \over n + 1} = {{(1 + n) \times {n \over 2} + n} \over n + 1} = {{n + n^2 + 2n \over 2} \over n + 1} = {n(n + 3) \over 2(n+1)} n+11+2+3+...+n+n=n+1(1+n)×2n+n=n+12n+n2+2n=2(n+1)n(n+3)

在时间复杂度的大 O 标记法中,可以省略掉系数、低阶、常量,所以,把上面这个公式简化之后,得到的平均时间复杂度就是 O ( n ) O(n) O(n)

这个结论虽然是正确的,但是计算过程稍微有点儿问题。究竟是什么问题呢?上面讲的这 n+1 种情况,出现的概率并不是一样的

要查找的变量 x,要么在数组里,要么就不在数组里。这两种情况对应的概率统计起来很麻烦,为了方便理解,假设在数组中与不在数组中的概率都为 1/2。另外,要查找的数据出现在 0~n-1 这 n 个位置的概率也是一样的,为 1/n。所以,根据概率乘法法则,要查找的数据出现在 0~n-1 中任意位置的概率就是 1 2 n {1 \over 2n} 2n1

因此,前面的推导过程中存在的最大问题就是,没有将各种情况发生的概率考虑进去。如果把每种情况发生的概率也考虑进去,那平均时间复杂度的计算过程就变成了这样:

1 × 1 2 n + 2 × 1 2 n + 3 × 1 2 n + . . . + n × 1 2 n + n × 1 2 = ( ( 1 + n ) × n 2 ) × 1 2 n + n 2 = n + n 2 4 n + n 2 = 3 n + 1 4 1 \times {1 \over 2n} + 2 \times {1 \over 2n} + 3 \times {1 \over 2n} + ... + n \times {1 \over 2n} + n \times {1 \over 2} = ((1 + n) \times {n \over 2}) \times {1 \over 2n} + {n \over 2} = {n + n^2 \over 4n} + {n \over 2} = {3n + 1 \over 4} 1×2n1+2×2n1+3×2n1+...+n×2n1+n×21=((1+n)×2n)×2n1+2n=4nn+n2+2n=43n+1

这个值就是概率论中的加权平均值,也叫作期望值,所以平均时间复杂度的全称应该叫加权平均时间复杂度或者期望时间复杂度

引入概率之后,前面那段代码的加权平均值为 3 n + 1 4 {3n + 1 \over 4} 43n+1。用大 O 表示法来表示,去掉系数和常量,这段代码的加权平均时间复杂度仍然是 O ( n ) O(n) O(n)

实际上,在大多数情况下,并不需要区分最好、最坏、平均情况时间复杂度三种情况。很多时候,使用一个复杂度就可以满足需求了。只有同一块代码在不同的情况下,时间复杂度有量级的差距,才会使用这三种复杂度表示法来区分

2.7 均摊时间复杂度

时间复杂度,听起来跟平均时间复杂度有点儿像。前面说到,大部分情况下,并不需要区分最好、最坏、平均三种复杂度。平均复杂度只在某些特殊情况下才会用到,而均摊时间复杂度应用的场景比它更加特殊、更加有限,如下例(仅为方便讲解,一般没人会这么写):

// array 表示一个长度为 n 的数组
// 代码中的 array.length 就等于 n
int[] array = new int[n];
int count = 0;
void insert(int val) {
    if (count == array.length) {
        int sum = 0;
        for (int i = 0; i < array.length; ++i) {
            sum = sum + array[i];
        }
        array[0] = sum;
        count = 1;
    }
    array[count] = val;
    ++count;
}

这段代码实现了一个往数组中插入数据的功能。当数组满了之后,也就是代码中的 count == array.length 时,用 for 循环遍历数组求和,并清空数组,将求和之后的 sum 值放到数组的第一个位置,然后再将新的数据插入。但如果数组一开始就有空闲空间,则直接将数据插入数组

那这段代码的时间复杂度是多少呢?

最理想的情况下,数组中有空闲空间,只需要将数据插入到数组下标为 count 的位置就可以了,所以最好情况时间复杂度为 $ O(1)$。最坏的情况下,数组中没有空闲空间了,需要先做一次数组的遍历求和,然后再将数据插入,所以最坏情况时间复杂度为 O ( n ) O(n) O(n)

那平均时间复杂度是多少呢?答案是 O ( 1 ) O(1) O(1)。还是可以通过前面讲的概率论的方法来分析

假设数组的长度是 n,根据数据插入的位置的不同,可以分为 n 种情况,每种情况的时间复杂度是 O ( 1 ) O(1) O(1)。除此之外,还有一种“额外”的情况,就是在数组没有空闲空间时插入一个数据,这个时候的时间复杂度是 O ( n ) O(n) O(n)。而且,这 n+1 种情况发生的概率一样,都是 1 n + 1 {1 \over n+1} n+11。所以,根据加权平均的计算方法,求得的平均时间复杂度就是:

1 × 1 n + 1 + 1 × 1 n + 1 + . . . + n × 1 n + 1 = O ( 1 ) 1 \times {1 \over n + 1} + 1 \times {1 \over n + 1} + ... + n \times {1 \over n + 1} = O(1) 1×n+11+1×n+11+...+n×n+11=O(1)

但是这个例子里的平均复杂度分析其实并不需要这么复杂,不需要引入概率论的知识。这是为什么呢?先来对比一下这个 insert() 的例子和前面那个 find() 的例子,你就会发现这两者有很大差别

  • 首先,find() 函数在极端情况下,复杂度才为 O ( 1 ) O(1) O(1)。但 insert() 在大部分情况下,时间复杂度都为 O ( 1 ) O(1) O(1)。只有个别情况下,复杂度才比较高,为 O ( n ) O(n) O(n)
  • 对于 insert() 函数来说, O ( 1 ) O(1) O(1) 时间复杂度的插入和 O ( n ) O(n) O(n) 时间复杂度的插入,出现的频率是非常有规律的,而且有一定的前后时序关系,一般都是一个 O ( n ) O(n) O(n) 插入之后,紧跟着 n-1 个 O ( 1 ) O(1) O(1) 的插入操作,循环往复

所以,针对这样一种特殊场景的复杂度分析,并不需要像之前讲平均复杂度分析方法那样,找出所有的输入情况及相应的发生概率,然后再计算加权平均值

针对这种特殊的场景,引入了一种更加简单的分析方法:摊还分析法,通过摊还分析得到的时间复杂度叫均摊时间复杂度

那究竟如何使用摊还分析法来分析算法的均摊时间复杂度呢?

看上面这个在数组中插入数据的例子,每一次 O ( n ) O(n) O(n) 的插入操作,都会跟着 n-1 次 O ( 1 ) O(1) O(1) 的插入操作,所以把耗时多的那次操作均摊到接下来的 n-1 次耗时少的操作上,均摊下来,这一组连续的操作的均摊时间复杂度就是 O ( 1 ) O(1) O(1)。这就是均摊分析的大致思路

均摊时间复杂度和摊还分析应用场景比较特殊,所以并不会经常用到

对一个数据结构进行一组连续操作中,大部分情况下时间复杂度都很低,只有个别情况下时间复杂度比较高,而且这些操作之间存在前后连贯的时序关系,这个时候,就可以将这一组操作放在一块儿分析,看是否能将较高时间复杂度那次操作的耗时,平摊到其他那些时间复杂度比较低的操作上。而且,在能够应用均摊时间复杂度分析的场合,一般均摊时间复杂度就等于最好情况时间复杂度

尽管很多数据结构和算法书籍都花了很大力气来区分平均时间复杂度和均摊时间复杂度,但其实个人认为,均摊时间复杂度就是一种特殊的平均时间复杂度,没必要花太多精力去区分它们。最应该掌握的是它的分析方法,摊还分析。至于分析出来的结果是叫平均还是叫均摊,这只是个说法,并不重要

3. 数组(Array)

3.1 如何实现随机访问?

数组(Array)是一种线性表数据结构。它用一组连续的内存空间,来存储一组具有相同类型的数据

这个定义里有几个关键词,理解了这几个关键词,基本就能彻底掌握数组的概念了

1、线性表(Linear List)

顾名思义,线性表就是数据排成像一条线一样的结构。每个线性表上的数据最多只有前和后两个方向。其实除了数组,链表、队列、栈等也是线性表结构

而与它相对立的概念是非线性表,比如二叉树、堆、图等。之所以叫非线性,是因为,在非线性表中,数据之间并不是简单的前后关系

2、连续的内存空间和相同类型的数据

正是因为这两个限制,它才有了一个堪称“杀手锏”的特性:“随机访问”。但有利就有弊,这两个限制也让数组的很多操作变得非常低效,比如要想在数组中删除、插入一个数据,为了保证连续性,就需要做大量的数据搬移工作

那数组是如何实现根据下标随机访问数组元素的呢?

拿一个长度为 10 的 int 类型的数组 int[] a = new int[10] 来举例。在下图中,计算机给数组 a[10],分配了一块连续内存空间 1000~1039,其中,内存块的首地址为 base_address = 1000

计算机会给每个内存单元分配一个地址,计算机通过地址来访问内存中的数据。当计算机需要随机访问数组中的某个元素时,它会首先通过下面的寻址公式,计算出该元素存储的内存地址:

a[i]_address = base_address + i * data_type_size

其中 data_type_size 表示数组中每个元素的大小。在上面这个例子里,数组中存储的是 int 类型数据,所以 data_type_size 就为 4 个字节,这个公式非常简单,这里不作过多介绍了

这里要特别纠正一个“错误”。在面试的时候,常常会问数组和链表的区别,很多人都回答说,“链表适合插入、删除,时间复杂度 O ( 1 ) O(1) O(1);数组适合查找,查找时间复杂度为 O ( 1 ) O(1) O(1)

实际上,这种表述是不准确的。数组是适合查找操作,但是查找的时间复杂度并不为 O ( 1 ) O(1) O(1)。即便是排好序的数组,用二分查找,时间复杂度也是 O ( l o g n ) O(logn) O(logn)。所以,正确的表述应该是,数组支持随机访问,根据下标随机访问的时间复杂度为 O ( 1 ) O(1) O(1)

3.2 低效的“插入”和“删除”

前面提到,数组为了保持内存数据的连续性,会导致插入、删除这两个操作比较低效。现在究竟为什么会导致低效?又有哪些改进方法呢?

先来看插入操作

假设数组的长度为 n,现在,如果需要将一个数据插入到数组中的第 k 个位置。为了把第 k 个位置腾出来,给新来的数据,需要将第 k~n 这部分的元素都顺序地往后挪一位。那插入操作的时间复杂度是多少呢?

如果在数组的末尾插入元素,那就不需要移动数据了,这时的时间复杂度为 O ( 1 ) O(1) O(1)。但如果在数组的开头插入元素,那所有的数据都需要依次往后移动一位,所以最坏时间复杂度是 O ( n ) O(n) O(n)。 因为在每个位置插入元素的概率是一样的,所以平均情况时间复杂度为 1 + 2 + … n n = O ( n ) {1 + 2 + … n \over n} = O(n) n1+2+n=O(n)

如果数组中的数据是有序的,在某个位置插入一个新的元素时,就必须按照刚才的方法搬移 k 之后的数据。但是,如果数组中存储的数据并没有任何规律,数组只是被当作一个存储数据的集合。在这种情况下,如果要将某个数组插入到第 k 个位置,为了避免大规模的数据搬移,还有一个简单的办法就是,直接将第 k 位的数据搬移到数组元素的最后,把新的元素直接放入第 k 个位置

例如数组 a[10] 中存储了如下 5 个元素:a,b,c,d,e。现在需要将元素 x 插入到第 3 个位置。只需要将 c 放入到 a[5],将 a[2] 赋值为 x 即可。最后,数组中的元素如下: a,b,x,d,e,c

利用这种处理技巧,在特定场景下,在第 k 个位置插入一个元素的时间复杂度就会降为 O ( 1 ) O(1) O(1)。这个处理思想在快排中也会用到

再来看删除操作

跟插入数据类似,如果要删除第 k 个位置的数据,为了内存的连续性,也需要搬移数据,不然中间就会出现空洞,内存就不连续了

和插入类似,如果删除数组末尾的数据,则最好情况时间复杂度为 O ( 1 ) O(1) O(1);如果删除开头的数据,则最坏情况时间复杂度为 O ( n ) O(n) O(n);平均情况时间复杂度也为 O ( n ) O(n) O(n)

实际上,在某些特殊场景下,并不一定非得追求数组中数据的连续性。如果将多次删除操作集中在一起执行,删除的效率是不是会提高很多呢?

例如数组 a[10] 中存储了 8 个元素:a,b,c,d,e,f,g,h。现在,要依次删除 a,b,c 三个元素

为了避免 d,e,f,g,h 这几个数据会被搬移三次,可以先记录下已经删除的数据。每次的删除操作并不是真正地搬移数据,只是记录数据已经被删除。当数组没有更多空间存储数据时,再触发执行一次真正的删除操作,这样就大大减少了删除操作导致的数据搬移

如果你了解 JVM,你会发现,这就是 JVM 标记清除垃圾回收算法的核心思想。数据结构和算法的魅力就在于此,很多时候并不是要去死记硬背某个数据结构或者算法,而是要学习它背后的思想和处理技巧,这些东西才是最有价值的。如果你细心留意,不管是在软件开发还是架构设计中,总能找到某些算法和数据结构的影子

3.3 警惕数组的访问越界问题

首先,来分析一下这段 C 语言代码的运行结果:

int main(int argc, char * argv[]) {
    int i = 0;
    int arr[3] = {0};
    for(; i <= 3; i++) {
        arr[i] = 0;
        printf("hello world\n");
    }
    return 0;
}

这段代码的运行结果并非是打印三行“hello word”,而是会无限打印“hello world”,这是为什么呢?

因为,数组大小为 3,a[0],a[1],a[2],而代码因为书写错误,导致 for 循环的结束条件错写为了 i<=3 而非 i<3,所以当 i=3 时,数组 a[3] 访问越界

在 C 语言中,只要不是访问受限的内存,所有的内存空间都是可以自由访问的。根据前面讲的数组寻址公式,a[3] 也会被定位到某块不属于数组的内存地址上,而这个地址正好是存储变量 i 的内存地址,那么 a[3]=0 就相当于 i=0,所以就会导致代码无限循环

数组越界在 C 语言中是一种未决行为,并没有规定数组访问越界时编译器应该如何处理。因为,访问数组的本质就是访问一段连续内存,只要数组通过偏移计算得到的内存地址是可用的,那么程序就可能不会报任何错误

这种情况下,一般都会出现莫名其妙的逻辑错误,就像上面举的例子,debug 的难度非常的大。而且,很多计算机病毒也正是利用到了代码中的数组越界可以访问非法地址的漏洞,来攻击系统,所以写代码的时候一定要警惕数组越界

但并非所有的语言都像 C 一样,把数组越界检查的工作丢给程序员来做,像 Java 本身就会做越界检查,比如下面这几行 Java 代码,就会抛出 java.lang.ArrayIndexOutOfBoundsException

int[] a = new int[3];
a[3] = 10;

3.4 容器能否完全替代数组?

针对数组类型,很多语言都提供了容器类,比如 Java 中的 ArrayList、C++ STL 中的 vector。在项目开发中,什么时候适合用数组,什么时候适合用容器呢?

拿 Java 语言来举例。如果你是 Java 工程师,几乎天天都在用 ArrayList,对它应该非常熟悉。那它与数组相比,到底有哪些优势呢?

个人觉得,ArrayList 最大的优势就是可以将很多数组操作的细节封装起来。比如前面提到的数组插入、删除数据时需要搬移其他数据等。另外,它还有一个优势,就是支持动态扩容

数组本身在定义的时候需要预先指定大小,因为需要分配连续的内存空间。如果申请了大小为 10 的数组,当第 11 个数据需要存储到数组中时,就需要重新分配一块更大的空间,将原来的数据复制过去,然后再将新的数据插入

如果使用 ArrayList,就完全不需要关心底层的扩容逻辑,ArrayList 已经实现好了。每次存储空间不够的时候,它都会将空间自动扩容为 1.5 倍大小

不过,这里需要注意一点,因为扩容操作涉及内存申请和数据搬移,是比较耗时的。所以,如果事先能确定需要存储的数据大小,最好在创建 ArrayList 的时候事先指定数据大小

例如要从数据库中取出 10000 条数据放入 ArrayList。看下面这几行代码,相比之下,事先指定数据大小可以省掉很多次内存申请和数据搬移操作

ArrayList<User> users = new ArrayList(10000);
for (int i = 0; i < 10000; ++i) {
 users.add(xxx);
}

那么是不是数组就无用武之地了呢?当然不是,有些时候,用数组会更合适些

  1. Java ArrayList 无法存储基本类型,比如 int、long,需要封装为 Integer、Long 类,而 Autoboxing、Unboxing 自动拆箱和封箱则有一定的性能消耗,所以如果特别关注性能,或者希望使用基本类型,就可以选用数组
  2. 如果数据大小事先已知,并且对数据的操作非常简单,用不到 ArrayList 提供的大部分方法,也可以直接使用数组
  3. 还有一个是当要表示多维数组时,用数组往往会更加直观。比如 Object[][] array,而用容器的话则需要这样定义:ArrayList<ArrayList> array

对于业务开发,直接使用容器就足够了,省时省力。毕竟损耗一丢丢性能,完全不会影响到系统整体的性能。但如果是做一些非常底层的开发,比如开发网络框架,性能的优化需要做到极致,这个时候数组就会优于容器,成为首选

3.5 为什么大多数编程语言中,数组要从 0 开始编号,而不是从 1 开始呢?

从数组存储的内存模型上来看,“下标”最确切的定义应该是“偏移(offset)”。前面也讲到,如果用 a 来表示数组的首地址,a[0] 就是偏移为 0 的位置,也就是首地址,a[k] 就表示偏移 k 个 type_size 的位置,所以计算 a[k] 的内存地址只需要用这个公式:

a[k]_address = base_address + k * type_size

但是,如果数组从 1 开始计数,那计算数组元素 a[k] 的内存地址就会变为:

a[k]_address = base_address + (k - 1) * type_size

对比两个公式,不难发现,从 1 开始编号,每次随机访问数组元素都多了一次减法运算,对于 CPU 来说,就是多了一次减法指令

数组作为非常基础的数据结构,通过下标随机访问数组元素又是其非常基础的编程操作,效率的优化就要尽可能做到极致。所以为了减少一次减法操作,数组选择了从 0 开始编号,而不是从 1 开始

不过,上面解释得再多其实都算不上压倒性的证明,说数组起始编号非 0 开始不可。最主要的原因可能还是历史原因

C 语言设计者用 0 开始计数数组下标,之后的 Java、JavaScript 等高级语言都效仿了 C 语言,或者说,为了在一定程度上减少 C 语言程序员学习 Java 的学习成本,因此继续沿用了从 0 开始计数的习惯。实际上,很多语言中数组也并不是从 0 开始计数的,比如 Matlab。甚至还有一些语言支持负数下标,比如 Python

4. 链表(Linked List)

4.1 链表结构

相比数组,链表是一种稍微复杂一点的数据结构。这两个非常基础、非常常用的数据结构,常常将会放到一块儿来比较。所以,先来看这两者有什么区别

底层的存储结构

如下图,数组需要一块连续的内存空间来存储,对内存的要求比较高。如果申请一个 100MB 大小的数组,当内存中没有连续的、足够大的存储空间时,即便内存的剩余总可用空间大于 100MB,仍然会申请失败

而链表恰恰相反,它并不需要一块连续的内存空间,它通过“指针”将一组零散的内存块串联起来使用,所以如果申请的是 100MB 大小的链表,根本不会有问题

链表结构五花八门,这里介绍三种最常见的链表结构,它们分别是:单链表、双向链表和循环链表

1、单链表

刚刚讲到,链表通过指针将一组零散的内存块串联在一起。其中,把内存块称为链表的“结点”。为了将所有的结点串起来,每个链表的结点除了存储数据之外,还需要记录链上的下一个结点的地址。如图所示,把这个记录下个结点地址的指针叫作后继指针 next

从上图可以发现,其中有两个结点是比较特殊的,它们分别是第一个结点和最后一个结点。习惯性地把第一个结点叫作头结点,把最后一个结点叫作尾结点。其中,头结点用来记录链表的基地址。有了它,就可以遍历得到整条链表。而尾结点特殊的地方是:指针不是指向下一个结点,而是指向一个空地址 NULL,表示这是链表
上最后一个结点

与数组一样,链表也支持数据的查找、插入和删除操作

在进行数组的插入、删除操作时,为了保持内存数据的连续性,需要做大量的数据搬移,所以时间复杂度是 O ( n ) O(n) O(n)。而在链表中插入或者删除一个数据,并不需要为了保持内存的连续性而搬移结点,因为链表的存储空间本身就不是连续的。所以,在链表中插入和删除一个数据是非常快速的

如下图,针对链表的插入和删除操作,只需要考虑相邻结点的指针改变,所以对应的时间复杂度是 O ( 1 ) O(1) O(1)

但是,有利就有弊。链表要想随机访问第 k 个元素,就没有数组那么高效了。因为链表中的数据并非连续存储的,所以无法像数组那样,根据首地址和下标,通过寻址公式就能直接计算出对应的内存地址,而是需要根据指针一个结点一个结点地依次遍历,直到找到相应的结点

可以把链表想象成一个队伍,队伍中的每个人都只知道自己后面的人是谁,所以当希望知道排在第 k 位的人是谁的时候,就需要从第一个人开始,一个一个地往下数。所以,链表随机访问的性能没有数组好,需要 O ( n ) O(n) O(n) 的时间复杂度

2、循环链表

循环链表是一种特殊的单链表。实际上,循环链表也很简单。它跟单链表唯一的区别就在尾结点。单链表的尾结点指针指向空地址,表示这就是最后的结点了。而循环链表的尾结点指针是指向链表的头结点。如下图,它像一个环一样首尾相连,所以叫作“循环”链表

和单链表相比,循环链表的优点是从链尾到链头比较方便。当要处理的数据具有环型结构特点时,就特别适合采用循环链表。比如著名的约瑟夫问题。尽管用单链表也可以实现,但是用循环链表实现的话,代码就会简洁很多

3、双向链表

单向链表只有一个方向,结点只有一个后继指针 next 指向后面的结点。而双向链表,顾名思义,它支持两个方向,每个结点不止有一个后继指针 next 指向后面的结点,还有一个前驱指针 prev 指向前面的结点

双向链表需要额外的两个空间来存储后继结点和前驱结点的地址。所以,如果存储同样多的数据,双向链表要比单链表占用更多的内存空间。虽然两个指针比较浪费存储空间,但可以支持双向遍历,这样也带来了双向链表操作的灵活性。那相比单链表,双向链表适合解决哪种问题呢?

从结构上来看,双向链表可以支持 O ( 1 ) O(1) O(1) 时间复杂度的情况下找到前驱结点,正是这样的特点,也使双向链表在某些情况下的插入、删除等操作都要比单链表简单、高效

这时可能会说,前面讲到单链表的插入、删除操作的时间复杂度已经是 O ( 1 ) O(1) O(1) 了,双向链表还能再怎么高效呢?前面的分析比较偏理论,很多数据结构和算法书籍中都会这么讲,但是这种说法实际上是不准确的,或者说是有先决条件的。这里再来分析一下链表的两个操作

删除操作:在实际的软件开发中,从链表中删除一个数据无外乎下面两种情况

  • 删除结点中“值等于某个给定值”的结点
  • 删除给定指针指向的结点

对于第一种情况,不管是单链表还是双向链表,为了查找到值等于给定值的结点,都需要从头结点开始一个一个依次遍历对比,直到找到值等于给定值的结点,然后再通过前面讲的指针操作将其删除

尽管单纯的删除操作时间复杂度是 O ( 1 ) O(1) O(1),但遍历查找的时间是主要的耗时点,对应的时间复杂度为 O ( n ) O(n) O(n)。根据时间复杂度分析中的加法法则,删除值等于给定值的结点对应的链表操作的总时间复杂度为 O ( n ) O(n) O(n)

对于第二种情况,已经找到了要删除的结点,但是删除某个结点 q 需要知道其前驱结点,而单链表并不支持直接获取前驱结点,所以,为了找到前驱结点,还是要从头结点开始遍历链表,直到 p->next=q,说明 p 是 q 的前驱结点

但是对于双向链表来说,这种情况就比较有优势了。因为双向链表中的结点已经保存了前驱结点的指针,不需要像单链表那样遍历。所以,针对第二种情况,单链表删除操作需要 O(n) 的时间复杂度,而双向链表只需要在 O(1) 的时间复杂度内就搞定了!

插入操作同理,如果希望在链表的某个指定结点前面插入一个结点,双向链表比单链表有很大的优势。双向链表可以在 O ( 1 ) O(1) O(1) 时间复杂度搞定,而单向链表需要 O ( n ) O(n) O(n) 的时间复杂度

除了插入、删除操作有优势之外,对于一个有序链表,双向链表的按值查询的效率也要比单链表高一些。因为,可以记录上次查找的位置 p,每次查询时,根据要查找的值与 p的大小关系,决定是往前还是往后查找,所以平均只需要查找一半的数据

有没有觉得双向链表要比单链表更加高效呢?这就是为什么在实际的软件开发中,双向链表尽管比较费内存,但还是比单链表的应用更加广泛的原因。如果你熟悉 Java 语言,你肯定用过 LinkedHashMap 这个容器。如果你深入研究 LinkedHashMap 的实现原理,就会发现其中就用到了双向链表这种数据结构

实际上,这里有一个更加重要的知识点需要你掌握,那就是用空间换时间的设计思想。当内存空间充足的时候,如果更加追求代码的执行速度,就可以选择空间复杂度相对较高、但时间复杂度相对很低的算法或者数据结构。相反,如果内存比较紧缺,比如代码跑在手机或者单片机上,这个时候,就要反过来用时间换空间的设计思路

缓存实际上就是利用了空间换时间的设计思想。如果把数据存储在硬盘上,会比较节省内存,但每次查找数据都要询问一次硬盘,会比较慢。但如果通过缓存技术,事先将数据加载在内存中,虽然会比较耗费内存空间,但是每次数据查询的速度就大大提高了

对于执行较慢的程序,可以通过消耗更多的内存(空间换时间)来进行优化;而消耗过多内存的程序,可以通过消耗更多的时间(时间换空间)来降低内存的消耗

了解了循环链表和双向链表,如果把这两种链表整合在一起就是一个新的版本:双向循环链表

4.2 链表 VS 数组性能比较

数组和链表是两种截然不同的内存组织方式。正是因为内存存储的区别,它们插入、删除、随机访问操作的时间复杂度正好相反

不过,数组和链表的对比,并不能局限于时间复杂度。而且,在实际的软件开发中,不能仅仅利用复杂度分析就决定使用哪个数据结构来存储数据

数组简单易用,在实现上使用的是连续的内存空间,可以借助 CPU 的缓存机制,预读数组中的数据,所以访问效率更高。而链表在内存中并不是连续存储,所以对 CPU 缓存不友好,没办法有效预读

数组的缺点是大小固定,一经声明就要占用整块连续内存空间。如果声明的数组过大,系统可能没有足够的连续内存空间分配给它,导致“内存不足(out of memory)”。如果声明的数组过小,则可能出现不够用的情况。这时只能再申请一个更大的内存空间,把原数组拷贝进去,非常费时。链表本身没有大小的限制,天然地支持动态扩容,这也是它与数组最大的区别

Java 中的 ArrayList 容器虽然支持动态扩容,但实际上还是数组的拷贝操作。当往支持动态扩容的数组中插入一个数据时,如果数组中没有空闲空间了,就会申请一个更大的空间,将数据拷贝过去,而数据拷贝的操作是非常耗时的

举一个稍微极端的例子。如果用 ArrayList 存储了了 1GB 大小的数据,这个时候已经没有空闲空间了,当再插入数据的时候,ArrayList 会申请一个 1.5GB 大小的存储空间,并且把原来那 1GB 的数据拷贝到新申请的空间上。听起来是不是就很耗时?

除此之外,如果代码对内存的使用非常苛刻,那数组就更适合。因为链表中的每个结点都需要消耗额外的存储空间去存储一份指向下一个结点的指针,所以内存消耗会翻倍。而且,对链表进行频繁的插入、删除操作,还会导致频繁的内存申请和释放,容易造成内存碎片,如果是 Java 语言,就有可能会导致频繁的 GC(Garbage Collection,垃圾回收)。所以,在实际的开发中,针对不同类型的项目,要根据具体情况,权衡究竟是选择数组还是链表

4.3 如何基于链表实现 LRU 缓存淘汰算法?

缓存是一种提高数据读取性能的技术,在硬件设计、软件开发中都有着非常广泛的应用,比如常见的 CPU 缓存、数据库缓存、浏览器缓存等等

缓存的大小有限,当缓存被用满时,哪些数据应该被清理出去,哪些数据应该被保留?这就需要缓存淘汰策略来决定。常见的策略有三种:先进先出策略 FIFO(First In,First Out)、最少使用策略 LFU(Least Frequently Used)、最近最少使用策略 LRU(Least Recently Used)

这些策略其实见名知义,那么如何基于链表实现 LRU 缓存淘汰算法呢?

可以维护一个有序单链表,越靠近链表尾部的结点是越早之前访问的。当有一个新的数据被访问时,从链表头开始顺序遍历链表

  1. 如果此数据之前已经被缓存在链表中了,遍历得到这个数据对应的结点,并将其从原来的位置删除,然后再插入到链表的头部

  2. 如果此数据没有在缓存链表中,又可以分为两种情况:

    • 如果此时缓存未满,则将此结点直接插入到链表的头部
    • 如果此时缓存已满,则链表尾结点删除,将新的数据结点插入链表的头部

这样就用链表实现了一个 LRU 缓存,是不是很简单?

现在来看下 m 缓存访问的时间复杂度是多少。因为不管缓存有没有满,都需要遍历一遍链表,所以这种基于链表的实现思路,缓存访问的时间复杂度为 O ( n ) O(n) O(n)

实际上,可以继续优化这个实现思路,比如引入散列表(Hash table)来记录每个数据的位置,将缓存访问的时间复杂度降到 O ( 1 ) O(1) O(1)

除了基于链表的实现思路,实际上还可以用数组来实现 LRU 缓存淘汰策略

4.4 如何轻松写出正确的链表代码?

想要写好链表代码并不是容易的事儿,尤其是那些复杂的链表操作,比如链表反转、有序链表合并等,写的时候非常容易出错。为什么链表代码这么难写?究竟怎样才能比较轻松地写出正确的链表代码呢?

1、理解指针或引用的含义

事实上,看懂链表的结构并不是很难,但是一旦把它和指针混在一起,就很容易让人摸不着头脑。所以,要想写对链表代码,首先就要理解好指针

有些语言有“指针”的概念,比如 C 语言;有些语言没有指针,取而代之的是“引用”,比如 Java、Python。不管是“指针”还是“引用”,实际上,它们的意思都是一样的,都是存储所指对象的内存地址

实际上,对于指针的理解,只需要记住下面这句话就可以了:

将某个变量赋值给指针,实际上就是将这个变量的地址赋值给指针,或者反过来说,指针中存储了这个变量的内存地址,指向了这个变量,通过指针就能找到这个变量

在编写链表代码的时候,经常会有这样的代码:·p->next = q。这行代码是说,p 结点中的 next 指针存储了 q 结点的内存地址

还有一个更复杂的,也是写链表代码经常会用到的:p->next = p->next->next。这行代码表示,p 结点的 next 指针存储了 p 结点的下下一个结点的内存地址

掌握了指针或引用的概念,应该可以很轻松地看懂链表代码

2、警惕指针丢失和内存泄漏

写链表代码的时候,指针指来指去,一会儿就不知道指到哪里了。所以,在写的时候,一定注意不要弄丢了指针

指针往往都是怎么弄丢的呢?这里拿单链表的插入操作举例

如图所示,希望在结点 a 和相邻的结点 b 之间插入结点 x,假设当前指针 p 指向结点 a。如果将代码实现变成下面这个样子,就会发生指针丢失和内存泄露

p->next = x; // 将 p 的 next 指针指向 x 结点;
x->next = p->next; // 将 x 的结点的 next 指针指向 b 结点;

p->next 指针在完成第一步操作之后,已经不再指向结点 b 了,而是指向结点 x。第 2 行代码相当于将 x 赋值给 x->next,自己指向自己。因此,整个链表也就断成了两半,从结点 b 往后的所有结点都无法访问到了

对于有些语言来说,比如 C 语言,内存管理是由程序员负责的,如果没有手动释放结点对应的内存空间,就会产生内存泄露。所以,插入结点时,一定要注意操作的顺序,要先将结点 x 的 next 指针指向结点 b,再把结点 a 的 next 指针指向结点 x,这样才不会丢失指针,导致内存泄漏。所以,对于刚刚的插入代码,只需要把第 1 行和第 2 行代码的顺序颠倒一下就可以了

同理,删除链表结点时,也一定要记得手动释放内存空间,否则,也会出现内存泄漏的问题。当然,对于像 Java 这种虚拟机自动管理内存的编程语言来说,就不需要考虑这么多了

3、利用哨兵简化实现难度

首先,先来回顾一下单链表的插入和删除操作。如果在结点 p 后面插入一个新的结点,只需要下面两行代码就可以搞定

new_node->next = p->next;
p->next = new_node;

但是,当要向一个空链表中插入第一个结点,刚刚的逻辑就不能用了。需要进行下面这样的特殊处理,其中 head 表示链表的头结点。所以,从这段代码,可以发现,对于单链表的插入操作,第一个结点和其他结点的插入逻辑是不一样的

if (head == null) {
   head = new_node;
}

再来看单链表结点删除操作。如果要删除结点 p 的后继结点,只需要一行代码就可以搞定

p->next = p->next->next;

但是,如果要删除链表中的最后一个结点,前面的删除代码就不 work 了。跟插入类似,也需要对于这种情况特殊处理

if (head->next == null) {
   head = null;
}

从前面的一步一步分析,可以看出,针对链表的插入、删除操作,需要对插入第一个结点和删除最后一个结点的情况进行特殊处理。这样代码实现起来就会很繁琐,不简洁,而且也容易因为考虑不全而出错。如何来解决这个问题呢?

哨兵,这里说的哨兵是解决“边界问题”的,不直接参与业务逻辑

head = null 表示链表中没有结点了。其中 head 表示头结点指针,指向链表中的第一个结点。如果引入哨兵结点,在任何时候,不管链表是不是空,head 指针都会一直指向这个哨兵结点。也把这种有哨兵结点的链表叫带头链表。相反,没有哨兵结点的链表就叫作不带头链表

如下图,可以发现,哨兵结点是不存储数据的。因为哨兵结点一直存在,所以插入第一个结点和插入其他结点,删除最后一个结点和删除其他结点,都可以统一为相同的代码实现逻辑了

实际上,这种利用哨兵简化编程难度的技巧,在很多代码实现中都有用到,比如插入排序、归并排序、动态规划等

4、重点留意边界条件处理

软件开发中,代码在一些边界或者异常情况下,最容易产生 Bug。链表代码也不例外。要实现没有 Bug 的链表代码,一定要在编写的过程中以及编写完成之后,检查边界条件是否考虑全面,以及代码在边界条件下是否能正确运行

经常用来检查链表代码是否正确的边界条件有这样几个:

  • 如果链表为空时,代码是否能正常工作?
  • 如果链表只包含一个结点时,代码是否能正常工作?
  • 如果链表只包含两个结点时,代码是否能正常工作?
  • 代码逻辑在处理头结点和尾结点的时候,是否能正常工作?

当写完链表代码之后,除了看代码在正常的情况下能否工作,还要看在上面的边界条件下,代码仍然能否正确工作。如果这些边界条件下都没有问题,那基本上可以认为没有问题了

当然,边界条件不止上面列举的那些。针对不同的场景,可能还有特定的边界条件,这个需要自己去思考,不过套路都是一样的

实际上,不光光是写链表代码,在写任何代码时,也千万不要只是实现业务正常情况下的功能就好了,一定要多想想,代码在运行的时候,可能会遇到哪些边界情况或者异常情况。遇到了应该如何应对,这样写出来的代码才够健壮!

5、举例画图,辅助思考

对于稍微复杂的链表操作,比如前面提到的单链表反转,指针一会儿指这,一会儿指那,一会儿就被绕晕了。总感觉脑容量不够,想不清楚。所以这个时候就可以使用其他方法来辅助理解,如:举例法和画图法

可以找一个具体的例子,把它画在纸上,释放一些脑容量,留更多的给逻辑思考,这样就会感觉到思路清晰很多。比如往单链表中插入一个数据这样一个操作,一般都是把各种情况都举一个例子,画出插入前和插入后的链表变化,如图所示

看图写代码,就简单多了。而且,写完代码之后,也可以举几个例子,画在纸上,照着代码走一遍,很容易就能发现代码中的 Bug

6、多写多练,没有捷径

熟能生巧!这里精选了 5 个常见的链表操作。只要把这几个操作都能写熟练,不熟就多写几遍,保证之后再也不会害怕写链表代码

  • 单链表反转
  • 链表中环的检测
  • 两个有序的链表合并
  • 删除链表倒数第 n 个结点
  • 求链表的中间结点

5. 栈(Stack)

5.1 如何理解“栈”?

关于“栈”,有一个非常贴切的例子,就是一摞叠在一起的盘子。平时放盘子的时候,都是从下往上一个一个放;取的时候,我们也是从上往下一个一个地依次取,不能从中间任意抽出。后进者先出,先进者后出,这就是典型的“栈”结构

从栈的操作特性上来看,栈是一种“操作受限”的线性表,只允许在一端插入和删除数据

事实上,从功能上来说,数组或链表确实可以替代栈,但特定的数据结构是对特定场景的抽象,而且,数组或链表暴露了太多的操作接口,操作上的确灵活自由,但使用时就比较不可控,自然也就更容易出错

当某个数据集合只涉及在一端插入和删除数据,并且满足后进先出、先进后出的特性,就应该首选“栈”这种数据结构

5.2 如何实现一个“栈”?

栈主要包含两个操作,入栈和出栈,也就是在栈顶插入一个数据和从栈顶删除一个数据

实际上,栈既可以用数组来实现,也可以用链表来实现。用数组实现的栈,叫作顺序栈,用链表实现的栈,叫作链式栈

这里实现一个基于数组的顺序栈

// 基于数组实现的顺序栈
public class ArrayStack {
    private String[] items; // 数组
    private int count; // 栈中元素个数
    private int n; // 栈的大小
    // 初始化数组,申请一个大小为 n 的数组空间
    public ArrayStack(int n) {
        this.items = new String[n];
        this.n = n;
        this.count = 0;
    }
    // 入栈操作
    public boolean push(String item) {
        // 数组空间不够了,直接返回 false,入栈失败。
        if (count == n) return false;
        // 将 item 放到下标为 count 的位置,并且 count 加一
        items[count] = item;
        ++count;
        return true;
    }

    // 出栈操作
    public String pop() {
        // 栈为空,则直接返回 null
        if (count == 0) return null;
        // 返回下标为 count-1 的数组元素,并且栈中元素个数 count 减一
        String tmp = items[count - 1];
        --count;
        return tmp;
    }
}

了解了定义和基本操作,那它的操作的时间、空间复杂度是多少呢?

不管是顺序栈还是链式栈,存储数据只需要一个大小为 n 的数组就够了。在入栈和出栈过程中,只需要一两个临时变量存储空间,所以空间复杂度是 O ( 1 ) O(1) O(1)

注意,这里存储数据需要一个大小为 n 的数组,并不是说空间复杂度就是 O ( n ) O(n) O(n)。因为,这 n 个空间是必须的,无法省掉。所以说空间复杂度的时候,是指除了原本的数据存储空间外,算法运行还需要额外的存储空间

不管是顺序栈还是链式栈,入栈、出栈只涉及栈顶个别数据的操作,所以时间复杂度都是 O ( 1 ) O(1) O(1)

5.3 支持动态扩容的顺序栈

刚才那个基于数组实现的栈,是一个固定大小的栈,也就是说,在初始化栈时需要事先指定栈的大小。当栈满之后,就无法再往栈里添加数据了。尽管链式栈的大小不受限,但要存储 next 指针,内存消耗相对较多。那如何基于数组实现一个可以支持动态扩容的栈呢?

在数组那一节,实现一个支持动态扩容的数组的方法是当数组空间不够时,就重新申请一块更大的内存,将原来数组中数据统统拷贝过去,这样就实现了一个支持动态扩容的数组

所以,如果要实现一个支持动态扩容的栈,只需要底层依赖一个支持动态扩容的数组就可以了。当栈满了之后,就申请一个更大的数组,将原来的数据搬移到新数组中,如下图

实际上,支持动态扩容的顺序栈,平时开发中并不常用到。主要还是复杂度分析

对于出栈操作来说,不会涉及内存的重新申请和数据的搬移,所以出栈的时间复杂度仍然是 O ( 1 ) O(1) O(1)。但是,对于入栈操作来说,情况就不一样了。当栈中有空闲空间时,入栈操作的时间复杂度为 O ( 1 ) O(1) O(1)。但当空间不够时,就需要重新申请内存和数据搬移,所以时间复杂度就变成了 O ( n ) O(n) O(n)

也就是说,对于入栈操作来说,最好情况时间复杂度是 O ( 1 ) O(1) O(1),最坏情况时间复杂度是 O ( n ) O(n) O(n)。那平均情况下的时间复杂度又是多少呢?这个入栈操作的平均情况下的时间复杂度可以用摊还分析法来分析

为了分析的方便,需要事先做一些假设和定义:

  • 栈空间不够时,重新申请一个是原来大小两倍的数组
  • 为了简化分析,假设只有入栈操作没有出栈操作
  • 定义不涉及内存搬移的入栈操作为 simple-push 操作,时间复杂度为 O ( 1 ) O(1) O(1)

如果当前栈大小为 K,并且已满,当再有新的数据要入栈时,就需要重新申请 2 倍大小的内存,并且做 K 个数据的搬移操作,然后再入栈。但是,接下来的 K-1 次入栈操作,都不需要再重新申请内存和搬移数据,所以这 K-1 次入栈操作都只需要一个 simple-push 操作就可以完成,如下图

这 K 次入栈操作,总共涉及了 K 个数据的搬移,以及 K 次 simple-push 操作。将 K 个数据搬移均摊到 K 次入栈操作,那每个入栈操作只需要一个数据搬移和一个 simple-push 操作。以此类推,入栈操作的均摊时间复杂度就为 O ( 1 ) O(1) O(1)

通过这个例子的实战分析,也印证了前面讲到的,均摊时间复杂度一般都等于最好情况时间复杂度。因为在大部分情况下,入栈操作的时间复杂度 O 都是 O ( 1 ) O(1) O(1),只有在个别时刻才会退化为 O ( n ) O(n) O(n),所以把耗时多的入栈操作的时间均摊到其他入栈操作上,平均情况下的耗时就接近 O ( 1 ) O(1) O(1)

5.4 栈的应用

5.4.1 栈在函数调用中的应用

栈作为一个比较基础的数据结构,应用场景还是蛮多的。其中,比较经典的一个应用场景就是函数调用栈

操作系统给每个线程分配了一块独立的内存空间,这块内存被组织成“栈”这种结构, 用来存储函数调用时的临时变量。每进入一个函数,就会将临时变量作为一个栈帧入栈,当被调用函数执行完成,返回之后,将这个函数对应的栈帧出栈。如下例

int main() {
    int a = 1;
    int ret = 0;
    int res = 0;
    ret = add(3, 5);
    res = a + ret;
    printf("%d", res);
    reuturn 0;
}
int add(int x, int y) {
    int sum = 0;
    sum = x + y;
    return sum;
}

从代码中可以看出,main() 函数调用了 add() 函数,获取计算结果,并且与临时变量a 相加,最后打印 res 的值。流程图如下,图中显示的是,在执行到 add() 函数时,函数调用栈的情况

5.4.2 栈在表达式求值中的应用

再来看栈的另一个常见的应用场景,编译器如何利用栈来实现表达式求值

为了方便解释,将算术表达式简化为只包含加减乘除四则运算,比如: 34 + 13 ∗ 9 + 44 − 12 / 3 {34+13*9+44-12/3} 34+139+4412/3。对于这个四则运算,人脑可以很快求解出答案,但是对于计算机来说,理解这个表达式本身就是个挺难的事儿。如何来实现这样一个表达式求值的功能呢?

实际上,编译器就是通过两个栈来实现的。其中一个保存操作数的栈,另一个是保存运算符的栈。从左向右遍历表达式,当遇到数字,就直接压入操作数栈;当遇到运算符,就与运算符栈的栈顶元素进行比较

如果比运算符栈顶元素的优先级高,就将当前运算符压入栈;如果比运算符栈顶元素的优先级低或者相同,从运算符栈中取栈顶运算符,从操作数栈的栈顶取 2 个操作数,然后进行计算,再把计算完的结果压入操作数栈,继续比较

3 + 5 ∗ 8 − 6 {3+5*8-6} 3+586 这个表达式的计算过程如下图:

5.4.3 栈在括号匹配中的应用

可以借助栈来检查表达式中的括号是否匹配

简化一下背景。假设表达式中只包含三种括号,圆括号 ()、方括号 [] 和花括号{},并且它们可以任意嵌套。比如,{[{}]}或 [{()}([])] 等都为合法格式,而{[}()] 或 [({)] 为不合法的格式。现在有一个包含三种括号的表达式字符串,如何检查它是否合法呢?

这里也可以用栈来解决。用栈来保存未匹配的左括号,从左到右依次扫描字符串。当扫描到左括号时,则将其压入栈中;当扫描到右括号时,从栈顶取出一个左括号。如果能够匹配,比如“(”跟“)”匹配,“[”跟“]”匹配,“{”跟“}”匹配,则继续扫描剩下的字符串。如果扫描的过程中,遇到不能配对的右括号,或者栈中没有数据,则说明为非法格式

当所有的括号都扫描完成之后,如果栈为空,则说明字符串为合法格式;否则,说明有未匹配的左括号,为非法格式

5.5 如何实现浏览器的前进和后退功能?

依次访问完一串页面 a-b-c 之后,点击浏览器的后退按钮,就可以查看之前浏览过的页面 b 和 a。当后退到页面 a,点击前进按钮,就可以重新查看页面 b 和 c。但是,如果后退到页面 b 后,点击了新的页面 d,那就无法再通过前进、后退功能查看页面 c 了

如何实现呢?其实,用两个栈就可以非常完美地解决这个问题

使用两个栈,X 和 Y,把首次浏览的页面依次压入栈 X,当点击后退按钮时,再依次从栈 X 中出栈,并将出栈的数据依次放入栈 Y。当点击前进按钮时,依次从栈Y 中取出数据,放入栈 X 中。当栈 X 中没有数据时,那就说明没有页面可以继续后退浏览了。当栈 Y 中没有数据,那就说明没有页面可以点击前进按钮浏览了

比如顺序查看了 a,b,c 三个页面,就依次把 a,b,c 压入栈,这个时候,两个栈的数据就是这个样子:

当通过浏览器的后退按钮,从页面 c 后退到页面 a 之后,就依次把 c 和 b 从栈 X 中弹出,并且依次放入到栈 Y。这个时候,两个栈的数据就是这个样子:

这个时候又想看页面 b,于是又点击前进按钮回到 b 页面,就把 b 再从栈 Y 中出栈,放入栈 X 中。此时两个栈的数据是这个样子:

这个时候,通过页面 b 又跳转到新的页面 d 了,页面 c 就无法再通过前进、后退按钮重复查看了,所以需要清空栈 Y。此时两个栈的数据这个样子:

6. 队列(Queue)

6.1 如何理解“队列”?

队列这个概念非常好理解。可以把它想象成排队买票,先来的先买,后来的人只能站末尾,不允许插队。先进者先出,这就是典型的“队列”

栈只支持两个基本操作:入栈 push() 和出栈 pop()。队列跟栈非常相似,支持的操作也很有限,最基本的操作也是两个:入队 enqueue(),放一个数据到队列尾部;出队 dequeue(),从队列头部取一个元素

所以,队列跟栈一样,也是一种操作受限的线性表数据结构

队列的概念很好理解,基本操作也很容易掌握。作为一种非常基础的数据结构,队列的应用也非常广泛,特别是一些具有某些额外特性的队列,比如循环队列、阻塞队列、并发队列。它们在很多偏底层系统、框架、中间件的开发中,起着关键性的作用。比如高性能队列 Disruptor、Linux 环形缓存,都用到了循环并发队列;Java concurrent 并发包利用 ArrayBlockingQueue 来实现公平锁等

6.2 顺序队列和链式队列

队列跟栈一样,也是一种抽象的数据结构。它具有先进先出的特性,支持在队尾插入元素,在队头删除元素,那究竟该如何实现一个队列呢?

跟栈一样,队列可以用数组来实现,也可以用链表来实现。用数组实现的栈叫作顺序栈,用链表实现的栈叫作链式栈。同样,用数组实现的队列叫作顺序队列,用链表实现的队列叫作链式队列

下面来看下基于数组的实现方法

// 用数组实现的队列
public class ArrayQueue {
    // 数组:items,数组大小:n
    private String[] items;
    private int n = 0;
    // head 表示队头下标,tail 表示队尾下标
    private int head = 0;
    private int tail = 0;
    // 申请一个大小为 capacity 的数组
    public ArrayQueue(int capacity) {
        items = new String[capacity];
        n = capacity;
    }
    // 入队
    public boolean enqueue(String item) {
        // 如果 tail == n 表示队列已经满了
        if (tail == n) return false;
        items[tail] = item;
        ++tail;
        return true;
    }
    // 出队
    public String dequeue() {
        // 如果 head == tail 表示队列为空
        if (head == tail) return null;
        String ret = items[head];
        ++head;
        return ret;
    }
}

对于栈来说,只需要一个栈顶指针就可以了。但是队列需要两个指针:一个是 head 指针,指向队头;一个是 tail 指针,指向队尾

可以结合下面这幅图来理解。当 a、b、c、d 依次入队之后,队列中的 head 指针指向下标为 0 的位置,tail 指针指向下标为 4 的位置

当调用两次出队操作之后,队列中 head 指针指向下标为 2 的位置,tail 指针仍然指向下标为 4 的位置

这时肯定已经发现了,随着不停地进行入队、出队操作,head 和 tail 都会持续往后移动。当 tail 移动到最右边,即使数组中还有空闲空间,也无法继续往队列中添加数据了。这个问题该如何解决呢?

在前面数组那一节,也遇到过类似的问题,就是数组的删除操作会导致数组中的数据不连续,当时使用的办法是数据搬移!但是,每次进行出队操作都相当于删除数组下标为 0 的数据,要搬移整个队列中的数据,这样出队操作的时间复杂度就会从原来的 O ( 1 ) O(1) O(1) 变为 O ( n ) O(n) O(n)。能不能优化一下呢?

实际上,在出队时可以不用搬移数据。如果没有空闲空间了,只需要在入队时,再集中触发一次数据的搬移操作。借助这个思想,出队函数 dequeue() 保持不变,稍加改造一下入队函数 enqueue() 的实现,就可以轻松解决刚才的问题了。下面是具体的代码:

// 入队操作,将 item 放入队尾
public boolean enqueue(String item) {
    // tail == n 表示队列末尾没有空间了
    if (tail == n) {
        // tail ==n && head==0,表示整个队列都占满了
        if (head == 0) return false;
        // 数据搬移
        for (int i = head; i < tail; ++i) {
            items[i - head] = items[i];
        }
        // 搬移完之后重新更新 head 和 tail
        tail -= head;
        head = 0;
    }

    items[tail] = item;
    ++tail;
    return true;
}

从代码中可以看到,当队列的 tail 指针移动到数组的最右边后,如果有新的数据入队,可以将 head 到 tail 之间的数据,整体搬移到数组中 0 到 tail-head 的位置

再来看下基于链表的队列实现方法,基于链表的实现,同样需要两个指针:head 指针和 tail 指针。它们分别指向链表的第一个结点和最后一个结点。如图所示,入队时,tail -> next = new_node, tail = tail -> next;出队时,head = head->next

6.3 循环队列

前面用数组来实现队列的时候,在 tail==n 时,会有数据搬移操作,这样入队操作性能就会受到影响。那有没有办法能够避免数据搬移呢?来看看循环队列的解决思路

循环队列,顾名思义,它长得像一个环。原本数组是有头有尾的,是一条直线。现在把首尾相连,扳成了一个环,如下图

可以看到,图中这个队列的大小为 8,当前 head=4,tail=7。当有一个新的元素 a 入队时,放入下标为 7 的位置。但这个时候,并不把 tail 更新为 8,而是将其在环中后移一位,到下标为 0 的位置。当再有一个元素 b 入队时,将 b 放入下标为 0 的位置,然后 tail 加 1 更新为 1。所以,在 a,b 依次入队之后,循环队列中的元素就变成了下面的样子:

通过这样的方法,成功避免了数据搬移操作。看起来不难理解,但是循环队列的代码实现难度要比前面讲的非循环队列难多了。要想写出没有 bug 的循环队列的实现代码,最关键的是,确定好队空和队满的判定条件

在用数组实现的非循环队列中,队满的判断条件是 tail == n,队空的判断条件是 head == tail。那针对循环队列,如何判断队空和队满呢?

队列为空的判断条件仍然是 head == tail。但队列满的判断条件就稍微有点复杂了,如下图

就像图中画的队满的情况,tail=3,head=4,n=8,所以总结一下规律就是: ( 3 + 1 ) % 8 = 4 {(3 + 1) \% 8 = 4} (3+1)%8=4。多画几张队满的图,就会发现,当队满时, ( t a i l + 1 ) % n = h e a d (tail + 1) \% n = head (tail+1)%n=head

这时会发现,当队列满时,图中的 tail 指向的位置实际上是没有存储数据的。所以,循环队列会浪费一个数组的存储空间,代码如下:

public class CircularQueue {
    // 数组:items,数组大小:n
    private String[] items;
    private int n = 0;
    // head 表示队头下标,tail 表示队尾下标
    private int head = 0;
    private int tail = 0;
    // 申请一个大小为 capacity 的数组
    public CircularQueue(int capacity) {
        items = new String[capacity];
        n = capacity;
    }
    // 入队
    public boolean enqueue(String item) {
        // 队列满了
        if ((tail + 1) % n == head) return false;
        items[tail] = item;
        tail = (tail + 1) % n;
        return true;
    }
    // 出队
    public String dequeue() {
        // 如果 head == tail 表示队列为空
        if (head == tail) return null;
        String ret = items[head];
        head = (head + 1) % n;
        return ret;
    }
}

6.4 阻塞队列和并发队列

阻塞队列其实就是在队列基础上增加了阻塞操作。简单来说,就是在队列为空的时候,从队头取数据会被阻塞。因为此时还没有数据可取,直到队列中有了数据才能返回;如果队列已经满了,那么插入数据的操作就会被阻塞,直到队列中有空闲位置后再插入数据,然后再返回

上述的定义其实就是一个“生产者 - 消费者模型”!使用阻塞队列,可以轻松实现一个“生产者 - 消费者模型”!

这种基于阻塞队列实现的“生产者 - 消费者模型”,可以有效地协调生产和消费的速度。当“生产者”生产数据的速度过快,“消费者”来不及消费时,存储数据的队列很快就会满了。这个时候,生产者就阻塞等待,直到“消费者”消费了数据,“生产者”才会被唤醒继续“生产”

而且不仅如此,基于阻塞队列,还可以通过协调“生产者”和“消费者”的个数,来提高数据的处理效率。比如前面的例子,可以多配置几个“消费者”,来应对一个“生产者”

在多线程情况下,会有多个线程同时操作队列,这个时候就会存在线程安全问题,那如何实现一个线程安全的队列呢?

线程安全的队列叫作并发队列。最简单直接的实现方式是直接在 enqueue()dequeue() 方法上加锁,但是锁粒度大并发度会比较低,同一时刻仅允许一个存或者取操作。实际上,基于数组的循环队列,利用 CAS 原子操作,可以实现非常高效的并发队列。这也是循环队列比链式队列应用更加广泛的原因

6.5 队列在线程池等有限资源池中的应用

CPU 资源是有限的,任务的处理速度与线程个数并不是线性正相关。相反,过多的线程反而会导致 CPU 频繁切换,处理性能下降。所以,线程池的大小一般都是综合考虑要处理任务的特点和硬件环境,来事先设置的

当向固定大小的线程池中请求一个线程时,如果线程池中没有空闲资源了,这个时候线程池如何处理这个请求?是拒绝请求还是排队请求?各种处理策略又是怎么实现的呢?

一般有两种处理策略。第一种是非阻塞的处理方式,直接拒绝任务请求;另一种是阻塞的处理方式,将请求排队,等到有空闲线程时,取出排队的请求继续处理。那如何存储排队的请求呢?

我们希望公平地处理每个排队的请求,先进者先服务,所以队列这种数据结构很适合来存储排队请求。前面说过,队列有基于链表和基于数组这两种实现方式。这两种实现方式对于排队请求又有什么区别呢?

基于链表的实现方式,可以实现一个支持无限排队的无界队列(unbounded queue),但是可能会导致过多的请求排队等待,请求处理的响应时间过长。所以,针对响应时间比较敏感的系统,基于链表实现的无限排队的线程池是不合适的

而基于数组实现的有界队列(bounded queue),队列的大小有限,所以线程池中排队的请求超过队列大小时,接下来的请求就会被拒绝,这种方式对响应时间敏感的系统来说,就相对更加合理。不过,设置一个合理的队列大小,也是非常有讲究的。队列太大导致等待的请求太多,队列太小会导致无法充分利用系统资源、发挥最大性能

除了前面讲到队列应用在线程池请求排队的场景之外,队列可以应用在任何有限资源池中,用于排队请求,比如数据库连接池等。实际上,对于大部分资源有限的场景,当没有空闲资源时,基本上都可以通过“队列”这种数据结构来实现请求排队

7. 递归

7.1 如何理解“递归”?

假设你去电影院看电影,想知道现在坐在第几排,电影院里面太黑了,看不清,没法数,现在该怎么办?

于是你就问前面一排的人他是第几排,只要在他的数字上加一,就知道自己在哪一排了。但是,前面的人也看不清,所以他也问他前面的人。就这样一排一排往前问,直到问到第一排的人,说我在第一排,然后再这样一排一排再把数字传回来。直到你前面的人告诉你他在哪一排,于是你就知道答案了

这就是一个非常标准的递归求解问题的分解过程,去的过程叫“递”,回来的过程叫“归”。基本上,所有的递归问题都可以用递推公式来表示。刚刚这个例子,用递推公式将它表示出来就是这样的: f ( n ) = f ( n − 1 ) + 1 f(n) = f(n-1) + 1 f(n)=f(n1)+1,其中, f ( 1 ) = 1 f(1) = 1 f(1)=1

f ( n ) f(n) f(n) 表示你想知道自己在哪一排, f ( n − 1 ) f(n-1) f(n1) 表示前面一排所在的排数, f ( 1 ) = 1 f(1) = 1 f(1)=1 表示第一排的人知道自己在第一排。有了这个递推公式,就可以很轻松地将它改为递归代码,如下:

int f(int n) {
    if (n == 1) return 1;
    return f(n - 1) + 1;
}

7.2 递归需要满足的三个条件

上面这个例子是非常典型的递归,那究竟什么样的问题可以用递归来解决呢?这里总结了三个条件,只要同时满足以下三个条件,就可以用递归来解决

1、一个问题的解可以分解为几个子问题的解

何为子问题?子问题就是数据规模更小的问题。比如,前面讲的电影院的例子,你要知道,“自己在哪一排”的问题,可以分解为“前一排的人在哪一排”这样一个子问题

2、这个问题与分解之后的子问题,除了数据规模不同,求解思路完全一样

比如电影院那个例子,你求解“自己在哪一排”的思路,和前面一排人求解“自己在哪一排”的思路,是一模一样的

3、存在递归终止条件

把问题分解为子问题,把子问题再分解为子子问题,一层一层分解下去,不能存在无限循环,这就需要有终止条件

还是电影院的例子,第一排的人不需要再继续询问任何人,就知道自己在哪一排,也就是 f ( 1 ) = 1 f(1) = 1 f(1)=1,这就是递归的终止条件

7.3 如何编写递归代码?

写递归代码最关键的是写出递推公式,找到终止条件,剩下将递推公式转化为代码就很简单了

假设这里有 n 个台阶,每次可以跨 1 个台阶或者 2 个台阶,请问走这 n 个台阶有多少种走法?如果有 7 个台阶,你可以 2,2,2,1 这样子上去,也可以 1,2,1,1,2 这样子上去,总之走法有很多,那如何用编程求得总共有多少种走法呢?

仔细想下,实际上,可以根据第一步的走法把所有走法分为两类,第一类是第一步走了 1 个台阶,另一类是第一步走了 2 个台阶。所以 n 个台阶的走法就等于先走 1 阶后,n-1 个台阶的走法 加上先走 2 阶后,n-2 个台阶的走法。用公式表示就是: f ( n ) = f ( n − 1 ) + f ( n − 2 ) f(n) = f(n-1) + f(n-2) f(n)=f(n1)+f(n2)

有了递推公式,递归代码基本上就完成了一半。再来看下终止条件。当有一个台阶时,不需要再继续递归,就只有一种走法。所以 f ( 1 ) = 1 f(1) = 1 f(1)=1。这个递归终止条件足够吗?可以用 n=2,n=3 这样比较小的数试验一下

n=2 时, f ( 2 ) = f ( 1 ) + f ( 0 ) f(2) = f(1) + f(0) f(2)=f(1)+f(0)。如果递归终止条件只有一个 f ( 1 ) = 1 f(1)=1 f(1)=1,那 f ( 2 ) f(2) f(2) 就无法求解了。所以除了 f ( 1 ) = 1 f(1) = 1 f(1)=1 这一个递归终止条件外,还要有 f ( 0 ) = 1 f(0) = 1 f(0)=1,表示走 0 个台阶有一种走法,不过这样子看起来就不符合正常的逻辑思维了。所以,可以把 f ( 2 ) = 2 f(2) = 2 f(2)=2 作为一种终止条件,表示走 2 个台阶,有两种走法,一步走完或者分两步来走

所以,递归终止条件就是 f ( 1 ) = 1 f(1) = 1 f(1)=1 f ( 2 ) = 2 f(2) = 2 f(2)=2。这个时候,可以再拿 n=3,n=4 来验证一下,这个终止条件是否足够并且正确

把递归终止条件和刚刚得到的递推公式放到一起如下:

f ( 1 ) = 1 、 f ( 2 ) = 2 、 f ( n ) = f ( n − 1 ) + f ( n − 2 ) f(1) = 1、 f(2) = 2、 f(n) = f(n-1) + f(n-2) f(1)=1f(2)=2f(n)=f(n1)+f(n2)

有了这个公式,转化成递归代码就简单多了。最终的递归代码如下:

int f(int n) {
    if (n == 1) return 1;
    if (n == 2) return 2;
    return f(n - 1) + f(n - 2);
}

写递归代码的关键就是找到如何将大问题分解为小问题的规律,并且基于此写出递推公式,然后再推敲终止条件,最后将递推公式和终止条件翻译成代码

递归代码是比较难理解的,前面讲的电影院的例子,递归调用只有一个分支,也就是说“一个问题只需要分解为一个子问题”,很容易能够想清楚“递“和”归”的每一个步骤,所以写起来、理解起来都不难

但是,当面对的是一个问题要分解为多个子问题的情况,递归代码就没那么好理解了,像上面讲的第二个例子,人脑几乎没办法把整个“递”和“归”的过程一步一步都想清楚

计算机擅长做重复的事情,所以递归正和它的胃口。而人脑更喜欢平铺直叙的思维方式。当我们看到递归时,总想把递归平铺展开,脑子里就会循环,一层一层往下调,然后再一层一层返回,试图想搞清楚计算机每一步都是怎么执行的,这样就很容易被绕进去

对于递归代码,这种试图想清楚整个递和归过程的做法,实际上是进入了一个思维误区。很多时候,我们理解起来比较吃力,主要原因就是自己给自己制造了这种理解障碍。那正确的思维方式应该是怎样的呢?

如果一个问题 A 可以分解为若干子问题 B、C、D,你可以假设子问题 B、C、D 已经解决,在此基础上思考如何解决问题 A。而且,只需要思考问题 A 与子问题 B、C、D 两层之间的关系即可,不需要一层一层往下思考子问题与子子问题,子子问题与子子子问题之间的关系。屏蔽掉递归细节,这样子理解起来就简单多了

因此,编写递归代码的关键是,只要遇到递归,就把它抽象成一个递推公式,不用想一层层的调用关系,不要试图用人脑去分解递归的每个步骤

7.4 递归代码要警惕堆栈溢出

在实际的软件开发中,编写递归代码时,会遇到很多问题,比如堆栈溢出。而堆栈溢出会造成系统性崩溃,后果会非常严重。为什么递归代码容易造成堆栈溢出呢?又该如何预防堆栈溢出呢?

在“栈”那一节讲过,函数调用会使用栈来保存临时变量。每调用一个函数,都会将临时变量封装为栈帧压入内存栈,等函数执行完成返回时,才出栈。系统栈或者虚拟机栈空间一般都不大。如果递归求解的数据规模很大,调用层次很深,一直压入栈,就会有堆栈溢出的风险

比如前面的讲到的电影院的例子,如果将系统栈或者 JVM 堆栈大小设置为 1KB,在求解 f ( 19999 ) f(19999) f(19999) 时便会出现如下堆栈报错:

Exception in thread “main” java.lang.StackOverflowError

那么,如何避免出现堆栈溢出呢?可以通过在代码中限制递归调用的最大深度的方式来解决这个问题。递归调用超过一定深度(比如 1000)之后,就不继续往下再递归了,直接返回报错

但这种做法并不能完全解决问题,因为最大允许的递归深度跟当前线程剩余的栈空间大小有关,事先无法计算。如果实时计算,代码过于复杂,就会影响代码的可读性。所以,如果最大深度比较小,比如 10、50,就可以用这种方法,否则这种方法并不是很实用

7.5 递归代码要警惕重复计算

除此之外,使用递归时还会出现重复计算的问题。上面讲的第二个递归代码的例子,如果把整个递归过程分解一下的话,那就是这样的:

从图中,可以直观地看到,想要计算 f ( 5 ) f(5) f(5),需要先计算 f ( 4 ) f(4) f(4) f ( 3 ) f(3) f(3),而计算 f ( 4 ) f(4) f(4) 还需要计算 f ( 3 ) f(3) f(3),因此, f ( 3 ) f(3) f(3) 就被计算了很多次,这就是重复计算问题

为了避免重复计算,可以通过一个数据结构(比如散列表)来保存已经求解过的 f ( k ) f(k) f(k)。当递归调用到 f ( k ) f(k) f(k) 时,先看下是否已经求解过了。如果是,则直接从散列表中取值返回,不需要重复计算,这样就能避免刚讲的问题了

public int f(int n) {
    if (n == 1) return 1;
    if (n == 2) return 2;

    // hasSolvedList 可以理解成一个 Map,key 是 n,value 是 f(n)
    if (hasSolvedList.containsKey(n)) {
        return hasSovledList.get(n);
    }

    int ret = f(n - 1) + f(n - 2);
    hasSovledList.put(n, ret);
    return ret;
}

除了堆栈溢出、重复计算这两个常见的问题。递归代码还有很多别的问题

在时间效率上,递归代码里多了很多函数调用,当这些函数调用的数量较大时,就会积聚成一个可观的时间成本。在空间复杂度上,因为递归调用一次就会在内存栈中保存一次现场数据,所以在分析递归代码空间复杂度时,需要额外考虑这部分的开销,比如前面讲到的电影院递归代码,空间复杂度并不是 O ( 1 ) O(1) O(1),而是 O ( n ) O(n) O(n)

7.6 怎么将递归代码改写为非递归代码?

递归有利有弊,利是递归代码的表达力很强,写起来非常简洁;而弊就是空间复杂度高、有堆栈溢出的风险、存在重复计算、过多的函数调用会耗时较多等问题。所以,在开发过程中,我们要根据实际情况来选择是否需要用递归的方式来实现

那是否可以把递归代码改写为非递归代码呢?比如刚才那个电影院的例子,抛开场景,只看 f ( x ) = f ( x − 1 ) + 1 f(x) =f(x-1) + 1 f(x)=f(x1)+1 这个递推公式。这样改写看看:

int f(int n) {
    int ret = 1;
    for (int i = 2; i <= n; ++i) {
        ret = ret + 1;
    }
    return ret;
}

同样,第二个例子也可以改为非递归的实现方式

int f(int n) {
    if (n == 1) return 1;
    if (n == 2) return 2;

    int ret = 0;
    int pre = 2;
    int prepre = 1;
    for (int i = 3; i <= n; ++i) {
        ret = pre + prepre;
        prepre = pre;
        pre = ret;
    }
    return ret;
}

那是不是所有的递归代码都可以改为这种迭代循环的非递归写法呢?

笼统地讲,是的。因为递归本身就是借助栈来实现的,只不过使用的栈是系统或者虚拟机本身提供的,我们没有感知罢了。如果我们自己在内存堆上实现栈,手动模拟入栈、出栈过程,这样任何递归代码都可以改写成看上去不是递归代码的样子

但是这种思路实际上是将递归改为了“手动”递归,本质并没有变,而且也并没有解决前面讲到的某些问题,徒增了实现的复杂度

再来看一个例子,现在很多 App 都有推荐注册返佣金这个功能,这个功能中,用户 A 推荐用户 B 来注册,用户 B 又推荐了用户 C 来注册。我们可以说,用户 C的“最终推荐人”为用户 A,用户 B 的“最终推荐人”也为用户 A,而用户 A 没有“最终推荐人”

一般来说,会通过数据库来记录这种推荐关系。在数据库表中,可以记录两行数据,其中 actor_id 表示用户 id,referrer_id 表示推荐人 id

给定一个用户 ID,如何查找这个用户的“最终推荐人”?

long findRootReferrerId(long actorId) {
    Long referrerId = select referrer_id from [table] where actor_id = actorId;
    if (referrerId == null) return actorId;
    return findRootReferrerId(referrerId);
}

是不是非常简洁?用三行代码就能搞定了,不过在实际项目中,上面的代码并不能工作,为什么呢?这里面有两个问题

第一,如果递归很深,可能会有堆栈溢出的问题

第二,如果数据库里存在脏数据,还需要处理由此产生的无限递归问题。比如 demo环境下数据库中,测试工程师为了方便测试,会人为地插入一些数据,就会出现脏数据。如果 A 的推荐人是 B,B 的推荐人是 C,C 的推荐人是 A,这样就会发生死循环

第一个问题,前面说过可以用限制递归深度来解决。第二个问题,也可以用限制递归深度来解决。不过,还有一个更高级的处理方法,就是自动检测 A-B-C-A 这种“环”的存在

8. 排序

8.1 如何分析一个“排序算法”?

学习排序算法,除了学习它的算法原理、代码实现之外,更重要的是要学会如何评价、分析一个排序算法。那分析一个排序算法,要从哪几个方面入手呢?

1、排序算法的执行效率

对于排序算法执行效率的分析,一般会从这几个方面来衡量:

  1. 最好情况、最坏情况、平均情况时间复杂度
    在分析排序算法的时间复杂度时,要分别给出最好情况、最坏情况、平均情况下的时间复杂度。除此之外,还要说出最好、最坏时间复杂度对应的要排序的原始数据是什么样的

    为什么要区分这三种时间复杂度呢?第一,有些排序算法会区分,为了好对比,所以最好都做一下区分。第二,对于要排序的数据,有的接近有序,有的完全无序。有序度不同的数据,对于排序的执行时间肯定是有影响的,我们要知道排序算法在不同数据下的性能表现

  2. 时间复杂度的系数、常数 、低阶
    时间复杂度反应的是数据规模 n 很大的时候的一个增长趋势,所以它表示的时候会忽略系数、常数、低阶。但是实际的软件开发中,排序的可能是 10 个、100 个、1000 个这样规模很小的数据,所以,在对同一阶时间复杂度的排序算法性能对比的时候,就要把系数、常数、低阶也考虑进来

  3. 比较次数和交换(或移动)次数
    基于比较的排序算法的执行过程,会涉及两种操作,一种是元素比较大小,另一种是元素交换或移动。所以,如果在分析排序算法的执行效率的时候,应该把比较次数和交换(或移动)次数也考虑进去

2、排序算法的内存消耗

算法的内存消耗可以通过空间复杂度来衡量,排序算法也不例外。不过,针对排序算法的空间复杂度,还引入了一个新的概念,原地排序(Sorted in place)。原地排序算法,就是特指空间复杂度是 O(1) 的排序算法

3、排序算法的稳定性

仅仅用执行效率和内存消耗来衡量排序算法的好坏是不够的。针对排序算法,还有一个重要的度量指标,稳定性。这个概念是说,如果待排序的序列中存在值相等的元素,经过排序之后,相等元素之间原有的先后顺序不变

比如有一组数据 2,9,3,4,8,3,按照大小排序之后就是 2,3,3,4,8,9。这组数据里有两个 3。经过某种排序算法排序之后,如果两个 3 的前后顺序没有改变,那就把这种排序算法叫作稳定的排序算法;如果前后顺序发生变化,那对应的排序算法就叫作不稳定的排序算法

两个 3 哪个在前,哪个在后有什么关系啊,稳不稳定又有什么关系呢?为什么要考察排序算法的稳定性呢?

很多数据结构和算法课程,在讲排序的时候,都是用整数来举例,但在真正软件开发中,要排序的往往不是单纯的整数,而是一组对象,需要按照对象的某个 key 来排序

比如说,现在要给电商交易系统中的“订单”排序。订单有两个属性,一个是下单时间,另一个是订单金额。如果现在有 10 万条订单数据,希望按照金额从小到大对订单数据排序。对于金额相同的订单,希望按照下单时间从早到晚有序。对于这样一个排序需求,怎么来做呢?

最先想到的方法是:先按照金额对订单数据进行排序,然后,再遍历排序之后的订单数据,对于每个金额相同的小区间再按照下单时间排序。这种排序思路理解起来不难,但是实现起来会很复杂

借助稳定排序算法,这个问题可以非常简洁地解决。解决思路是这样的:先按照下单时间给订单排序,注意是按照下单时间,不是金额。排序完成之后,用稳定排序算法,按照订单金额重新排序。两遍排序之后,得到的订单数据就是按照金额从小到大排序,金额相同的订单按照下单时间从早到晚排序的。为什么呢?

稳定排序算法可以保持金额相同的两个对象,在排序之后的前后顺序不变。第一次排序之后,所有的订单按照下单时间从早到晚有序了。在第二次排序中,用的是稳定的排序算法,所以经过第二次排序之后,相同金额的订单仍然保持下单时间从早到晚有序

8.2 冒泡排序(Bubble Sort)

冒泡排序只会操作相邻的两个数据。每次冒泡操作都会对相邻的两个元素进行比较,看是否满足大小关系要求。如果不满足就让它俩互换。一次冒泡会让至少一个元素移动到它应该在的位置,重复 n 次,就完成了 n 个数据的排序工作

例如要对一组数据 4,5,6,3,2,1,从小到到大进行排序。第一次冒泡操作的详细过程就是这样:

可以看出,经过一次冒泡操作之后,6 这个元素已经存储在正确的位置上。要想完成所有数据的排序,只要进行 6 次这样的冒泡操作就行了

实际上,上面的冒泡过程还可以优化。当某次冒泡操作已经没有数据交换时,说明已经达到完全有序,不用再继续执行后续的冒泡操作。这里还有另外一个例子,这里面给 6 个元素排序,只需要 4 次冒泡操作就可以了

// 冒泡排序,a 表示数组,n 表示数组大小
public void bubbleSort(int[] a, int n) {
    if (n <= 1) return;
    for (int i = 0; i < n; ++i) {
        // 提前退出冒泡循环的标志位
        boolean flag = false;
        for (int j = 0; j < n - i - 1; ++j) {
            if (a[j] > a[j + 1]) { // 交换
                int tmp = a[j];
                a[j] = a[j + 1];
                a[j + 1] = tmp;
                flag = true; // 表示有数据交换
            }
        }
        if (!flag) break; // 没有数据交换,提前退出
    }
}

现在,结合前面分析排序算法的三个方面,有三个问题

  1. 冒泡排序是原地排序算法吗?

    冒泡的过程只涉及相邻数据的交换操作,只需要常量级的临时空间,所以它的空间复杂度为 O ( 1 ) O(1) O(1),是一个原地排序算法

  2. 冒泡排序是稳定的排序算法吗?

    在冒泡排序中,只有交换才可以改变两个元素的前后顺序。为了保证冒泡排序算法的稳定性,当有相邻的两个元素大小相等的时候,不做交换,相同大小的数据在排序前后不会改变顺序,所以冒泡排序是稳定的排序算法

  3. 冒泡排序的时间复杂度是多少?

    最好情况下,要排序的数据已经是有序的了,只需要进行一次冒泡操作,就可以结束了,所以最好情况时间复杂度是 O ( n ) O(n) O(n)。而最坏的情况是,要排序的数据刚好是倒序排列的,需要进行 n 次冒泡操作,所以最坏情况时间复杂度为 O ( n 2 ) O(n^2) O(n2)

最好、最坏情况下的时间复杂度很容易分析,那平均情况下的时间复杂是多少呢?前面讲过,平均时间复杂度就是加权平均期望时间复杂度,分析的时候要结合概率论的知识

对于包含 n 个数据的数组,这 n 个数据就有 n! 种排列方式。不同的排列方式,冒泡排序执行的时间肯定是不同的。比如前面举的那两个例子,其中一个要进行 6 次冒泡,而另一个只需要 4 次。如果用概率论方法定量分析平均时间复杂度,涉及的数学推理和计算就会很复杂。这里还有一种思路,通过“有序度”和“逆序度”这两个概念来进行分析

有序度是数组中具有有序关系的元素对的个数。有序元素对用数学表达式表示就是这样:

有序元素对:a[i] <= a[j], 如果 i < j

同理,对于一个倒序排列的数组,比如 6,5,4,3,2,1,有序度是 0;对于一个完全有序的数组,比如 1,2,3,4,5,6,有序度就是 n ∗ ( n − 1 ) / 2 n * (n-1) / 2 n(n1)/2,也就是 15。这种完全有序的数组的有序度叫作满有序度

逆序度的定义正好跟有序度相反(默认从小到大为有序)

逆序元素对:a[i] > a[j], 如果 i < j

关于这三个概念,还可以得到一个公式:逆序度 = 满有序度 - 有序度。排序的过程就是一种增加有序度,减少逆序度的过程,最后达到满有序度,就说明排序完成了

还是拿前面举的那个冒泡排序的例子来说明。要排序的数组的初始状态是 4,5,6,3,2,1 ,其中,有序元素对有 (4,5) (4,6) (5,6),所以有序度是 3。n=6,所以排序完成之后终态的满有序度为 n ∗ ( n − 1 ) / 2 = 15 n * (n-1) / 2 = 15 n(n1)/2=15

冒泡排序包含两个操作原子,比较和交换。每交换一次,有序度就加 1。不管算法怎么改进,交换次数总是确定的,即为逆序度,也就是 n ∗ ( n − 1 ) / 2 –初始有序度 n * (n-1) / 2 – 初始有序度 n(n1)/2–初始有序度。此例中就是 15–3=12,要进行 12 次交换操作

对于包含 n 个数据的数组进行冒泡排序,平均交换次数是多少呢?最坏情况下,初始状态的有序度是 0,所以要进行 n ∗ ( n − 1 ) / 2 n * (n-1) / 2 n(n1)/2 次交换。最好情况下,初始状态的有序度是 n ∗ ( n − 1 ) / 2 n * (n-1) / 2 n(n1)/2,就不需要进行交换。可以取个中间值 n ∗ ( n − 1 ) / 4 n * (n-1) / 4 n(n1)/4,来表示初始有序度既不是很高也不是很低的平均情况

换句话说,平均情况下,需要 n ∗ ( n − 1 ) / 4 n * (n-1) / 4 n(n1)/4 次交换操作,比较操作肯定要比交换操作多,而复杂度的上限是 O ( n 2 ) O(n^2) O(n2),所以平均情况下的时间复杂度就是 O ( n 2 ) O(n^2) O(n2)

这个平均时间复杂度推导过程其实并不严格,但是很多时候很实用,毕竟概率论的定量分析太复杂,不太好用

8.3 插入排序(Insertion Sort)

先来看一个问题。一个有序的数组,往里面添加一个新的数据后,如何继续保持数据有序呢?很简单,只要遍历数组,找到数据应该插入的位置将其插入即可

这是一个动态排序的过程,即动态地往有序集合中添加数据,可以通过这种方法保持集合中的数据一直有序。而对于一组静态数据,也可以借鉴上面讲的插入方法,来进行排序,于是就有了插入排序算法

那插入排序具体是如何借助上面的思想来实现排序的呢?

首先,将数组中的数据分为两个区间,已排序区间和未排序区间。初始已排序区间只有一个元素,就是数组的第一个元素。插入算法的核心思想是取未排序区间中的元素,在已排序区间中找到合适的插入位置将其插入,并保证已排序区间数据一直有序。重复这个过程,直到未排序区间中元素为空,算法结束

如图所示,要排序的数据是 4,5,6,1,3,2,其中左侧为已排序区间,右侧是未排序区间

插入排序也包含两种操作,一种是元素的比较,一种是元素的移动。当需要将一个数据 a 插入到已排序区间时,需要拿 a 与已排序区间的元素依次比较大小,找到合适的插入位置。找到插入点之后,还需要将插入点之后的元素顺序往后移动一位,这样才能腾出位置给元素 a 插入

对于不同的查找插入点方法(从头到尾、从尾到头),元素的比较次数是有区别的。但对于一个给定的初始序列,移动操作的次数总是固定的,就等于逆序度

为什么说移动次数就等于逆序度呢?如下图,满有序度是 n ∗ ( n − 1 ) / 2 = 15 n * (n-1) / 2 = 15 n(n1)/2=15,初始序列的有序度是 5,所以逆序度是 10。插入排序中,数据移动的个数总和也等于 10=3+3+4

// 插入排序,a 表示数组,n 表示数组大小
public void insertionSort(int[] a, int n) {
    if (n <= 1) return;
    for (int i = 1; i < n; ++i) {
        int value = a[i];
        int j = i - 1;
        // 查找插入的位置
        for (; j >= 0; --j) {
            if (a[j] > value) {
                a[j + 1] = a[j]; // 数据移动
            } else {
                break;
            }
        }
        a[j + 1] = value; // 插入数据
    }
}

分析插入排序算法,同样有三个问题

  1. 插入排序是原地排序算法吗?

    从实现过程可以很明显地看出,插入排序算法的运行并不需要额外的存储空间,所以空间复杂度是 O ( 1 ) O(1) O(1),也就是说,这是一个原地排序算法

  2. 插入排序是稳定的排序算法吗?

    在插入排序中,对于值相同的元素,可以选择将后面出现的元素,插入到前面出现元素的后面,这样就可以保持原有的前后顺序不变,所以插入排序是稳定的排序算法

  3. 插入排序的时间复杂度是多少?

    如果要排序的数据已经是有序的,并不需要搬移任何数据。如果从尾到头在有序数据组里面查找插入位置,每次只需要比较一个数据就能确定插入的位置。所以这种情况下,最好是时间复杂度为 O ( n ) O(n) O(n)。注意,这里是从尾到头遍历已经有序的数据

    如果数组是倒序的,每次插入都相当于在数组的第一个位置插入新的数据,所以需要移动大量的数据,所以最坏情况时间复杂度为 O ( n 2 ) O(n^2) O(n2)

在数组中插入一个数据的平均时间复杂度是 O ( n ) O(n) O(n)。所以,对于插入排序来说,每次插入操作都相当于在数组中插入一个数据,循环执行 n 次插入操作,所以平均时间复杂度为 O ( n 2 ) O(n^2) O(n2)

8.4 选择排序(Selection Sort)

选择排序算法的实现思路有点类似插入排序,也分已排序区间和未排序区间。但是选择排序每次会从未排序区间中找到最小的元素,将其放到已排序区间的末尾

选择排序空间复杂度为 O ( 1 ) O(1) O(1),是一种原地排序算法。选择排序的最好情况时间复杂度、最坏情况和平均情况时间复杂度都为 O ( n 2 ) O(n^2) O(n2)

选择排序是稳定的排序算法吗?

答案是否定的,选择排序是一种不稳定的排序算法。从上图可以看出,选择排序每次都要找剩余未排序元素中的最小值,并和前面的元素交换位置,这样破坏了稳定性

比如 5,8,5,2,9 这样一组数据,使用选择排序算法来排序的话,第一次找到最小元素 2,与第一个 5 交换位置,那第一个 5 和中间的 5 顺序就变了,所以就不稳定了。正是因此,相对于冒泡排序和插入排序,选择排序就稍微逊色了

为什么插入排序比冒泡排序更受欢迎?

冒泡排序和插入排序的时间复杂度都是 O ( n 2 ) O(n^2) O(n2),都是原地排序算法,为什么插入排序要比冒泡排序更受欢迎呢?

前面分析冒泡排序和插入排序的时候讲到,冒泡排序不管怎么优化,元素交换的次数是一个固定值,是原始数据的逆序度。插入排序是同样的,不管怎么优化,元素移动的次数也等于原始数据的逆序度

但是,从代码实现上来看,冒泡排序的数据交换要比插入排序的数据移动要复杂,冒泡排序需要 3 个赋值操作,而插入排序只需要 1 个。来看这段操作:

冒泡排序中数据的交换操作:
if (a[j] > a[j + 1]) { // 交换
    int tmp = a[j];
    a[j] = a[j + 1];
    a[j + 1] = tmp;
    flag = true;
}
插入排序中数据的移动操作:
if (a[j] > value) {
    a[j + 1] = a[j]; // 数据移动
} else {
    break;
}

执行一个赋值语句的时间粗略地计为单位时间(unit_time),然后分别用冒泡排序和插入排序对同一个逆序度是 K 的数组进行排序。用冒泡排序,需要 K 次交换操作,每次需要 3 个赋值语句,所以交换操作总耗时就是 3*K 单位时间。而插入排序中数据移动操作只需要 K 个单位时间

这个只是非常理论的分析,为了实验,针对上面的冒泡排序和插入排序的 Java 代码,我写了一个性能对比测试程序,随机生成 10000 个数组,每个数组中包含 200 个数据,然后在我的机器上分别用冒泡和插入排序算法来排序,冒泡排序算法大约 700ms 才能执行完成,而插入排序只需要 100ms 左右就能搞定!

所以,虽然冒泡排序和插入排序在时间复杂度上是一样的,都是 O ( n 2 ) O(n^2) O(n2),但是如果希望把性能优化做到极致,那肯定首选插入排序。插入排序的算法思路也有很大的优化空间,前面只是讲了最基础的一种。如果对插入排序的优化感兴趣,可以自行学习一下希尔排序

8.5 归并排序(Merge Sort)

如果要排序一个数组,先把数组从中间分成前后两部分,然后对前后两部分分别排序,再将排好序的两部分合并在一起,这样整个数组就都有序了

归并排序使用的就是分治思想。分治,顾名思义,就是分而治之,将一个大问题分解成小的子问题来解决。小的子问题解决了,大问题也就解决了

分治思想跟前面讲的递归思想很像。分治算法一般都是用递归来实现的。分治是一种解决问题的处理思想,递归是一种编程技巧,这两者并不冲突。接下来就来看看如何用递归代码来实现归并排序

写递归代码的技巧就是,分析得出递推公式,然后找到终止条件,最后将递推公式翻译成递归代码。所以,要想写出归并排序的代码,先写出归并排序的递推公式

递推公式:merge_sort(p…r) = merge(merge_sort(p…q), merge_sort(q+1…r))
终止条件:p >= r 不用再继续分解

merge_sort(p…r) 表示,给下标从 p 到 r 之间的数组排序。将这个排序问题转化为了两个子问题,merge_sort(p…q) 和 merge_sort(q+1…r),其中下标 q 等于 p 和 r 的中间位置,也就是 ( p + r ) / 2 (p+r)/2 (p+r)/2。当下标从 p 到 q 和从 q+1 到 r 这两个子数组都排好序之后,再将两个有序的子数组合并在一起,这样下标从 p 到 r 之间的数据就也排好序了。翻译成伪代码如下:

// 归并排序算法, A 是数组,n 表示数组大小
merge_sort(A, n) {
    merge_sort_c(A, 0, n-1)
}
// 递归调用函数
merge_sort_c(A, p, r) {
    // 递归终止条件
    if p >= r then return
    // 取 p 到 r 之间的中间位置 q
    q = (p+r) / 2
    // 分治递归
    merge_sort_c(A, p, q)
    merge_sort_c(A, q+1, r)
    // 将 A[p...q] 和 A[q+1...r] 合并为 A[p...r]
    merge(A[p...r], A[p...q], A[q+1...r])
}

merge(A[p…r], A[p…q], A[q+1…r]) 这个函数的作用就是,将已经有序的 A[p…q] 和 A[q+1…r] 合并成一个有序的数组,并且放入 A[p…r]。那这个过程具体该如何做呢?

如图所示,申请一个临时数组 tmp,大小与 A[p…r] 相同。用两个游标 i 和 j,分别指向 A[p…q] 和 A[q+1…r] 的第一个元素。比较这两个元素 A[i] 和 A[j],如果 A[i]<=A[j],就把 A[i] 放入到临时数组 tmp,并且 i 后移一位,否则将 A[j] 放入到数组 tmp,j 后移一位

继续上述比较过程,直到其中一个子数组中的所有数据都放入临时数组中,再把另一个数组中的数据依次加入到临时数组的末尾,这个时候,临时数组中存储的就是两个子数组合并之后的结果了。最后再把临时数组 tmp 中的数据拷贝到原数组 A[p…r] 中

merge() 函数写成伪代码,就是下面这样:

merge(A[p...r], A[p...q], A[q + 1...r]) {
    var i : = p,j : = q + 1,k : = 0 // 初始化变量 i, j, k
    var tmp : = new array[0...r - p] // 申请一个大小跟 A[p...r] 一样的临时数组
    while i <= q AND j <= r do {
        if A[i] <= A[j] {
        	tmp[k++] = A[i++] // i++ 等于 i:=i+1
        } else {
        	tmp[k++] = A[j++]
    	}
    }

    // 判断哪个子数组中有剩余的数据
    var start : = i,end : = q
    if j <= r then start : = j, end: = r
	// 将剩余的数据拷贝到临时数组 tmp
    while start <= end do {
    	tmp[k++] = A[start++]
    }

    // 将 tmp 中的数组拷贝回 A[p...r]
    for i : = 0 to r - p do {
    	A[p + i] = tmp[i]
    }
}

1、归并排序是稳定的排序算法吗?

归并排序稳不稳定关键要看 merge() 函数,也就是两个有序子数组合并成一个有序数组的那部分代码,在合并的过程中,如果 A[p…q] 和 A[q+1…r] 之间有值相同的元素,可以像伪代码中那样,先把 A[p…q] 中的元素放入 tmp 数组。这样就保证了值相同的元素,在合并前后的先后顺序不变。所以,归并排序是一个稳定的排序算法

2、归并排序的时间复杂度是多少?

归并排序涉及递归,时间复杂度的分析稍微有点复杂。递归的适用场景是,一个问题 a 可以分解为多个子问题 b、c,那求解问题 a 就可以分解为求解问题 b、c。问题 b、c 解决之后,我们再把 b、c 的结果合并成 a 的结果

如果定义求解问题 a 的时间是 T ( a ) T(a) T(a),求解问题 b、c 的时间分别是 T ( b ) T(b) T(b) T ( c ) T( c) T(c),那我就可以得到这样的递推关系式: T ( a ) = T ( b ) + T ( c ) + K T(a) = T(b) + T(c) + K T(a)=T(b)+T(c)+K,其中 K 等于将两个子问题 b、c 的结果合并成问题 a 的结果所消耗的时间

从上面的分析,可以得到一个重要的结论:不仅递归求解的问题可以写成递推公式,递归代码的时间复杂度也可以写成递推公式。套用这个公式,来分析一下归并排序的时间复杂度

假设对 n 个元素进行归并排序需要的时间是 T ( n ) T(n) T(n),那分解成两个子数组排序的时间都是 T ( n / 2 ) T(n/2) T(n/2)merge() 函数合并两个有序子数组的时间复杂度是 O ( n ) O(n) O(n)。所以,套用前面的公式,归并排序的时间复杂度的计算公式就是:

T(1) = C; n=1 时,只需要常量级的执行时间,所以表示为 C
T(n) = 2 * T(n/2) + n; n>1

通过这个公式,如何来求解 T ( n ) T(n) T(n) 呢?还不够直观?那再进一步分解一下计算过程

T(n) = 2 * T(n/2) + n
= 2 *(2 * T(n/4) + n/2) + n = 4 * T(n/4) + 2 * n
= 4 * (2 * T(n/8) + n/4) + 2 * n = 8 * T(n/8) + 3 * n
= 8 *( 2 * T(n/16) + n/8) + 3 * n = 16 * T(n/16) + 4 * n

= 2^k * T(n/2^k) + k * n

通过这样一步一步分解推导,可以得到 T ( n ) = 2 k T ( n / 2 k ) + k n T(n) = 2^k T(n / 2^k) + kn T(n)=2kT(n/2k)+kn。当 T ( n / 2 k ) = T ( 1 ) T(n / 2^k) = T(1) T(n/2k)=T(1)时,也就是 n / 2 k = 1 n / 2^k=1 n/2k=1,得到 k = l o g 2 n k=log_2 n k=log2n 。将 k 值代入上面的公式,得到 T ( n ) = C n + n l o g 2 n T(n) = Cn + nlog_2 n T(n)=Cn+nlog2n。如果用大 O 标记法来表示的话, T ( n ) T(n) T(n) 就等于 O ( n l o g n ) O(nlogn) O(nlogn)。所以归并排序的时间复杂度是 O ( n l o g n ) O(nlogn) O(nlogn)

从原理分析和伪代码可以看出,归并排序的执行效率与要排序的原始数组的有序程度无关,所以其时间复杂度是非常稳定的,不管是最好情况、最坏情况,还是平均情况,时间复杂度都是 O ( n l o g n ) O(nlogn) O(nlogn)

3、归并排序的空间复杂度是多少?

归并排序的时间复杂度任何情况下都是 O ( n l o g n ) O(nlogn) O(nlogn),看起来非常优秀。即便是快速排序,最坏情况下,时间复杂度也是 O ( n 2 ) O(n^2) O(n2)。但是,归并排序并没有像快排那样,应用广泛,这是为什么呢?因为它有一个致命的“弱点”,那就是归并排序不是原地排序算法

这是因为归并排序的合并函数,在合并两个有序数组为一个有序数组时,需要借助额外的存储空间。那归并排序的空间复杂度到底是多少呢?是 O ( n ) O(n) O(n),还是 O ( n l o g n ) O(nlogn) O(nlogn),应该如何分析呢?

如果继续按照分析递归时间复杂度的方法,通过递推公式来求解,那整个归并过程需要的空间复杂度就是 O ( n l o g n ) O(nlogn) O(nlogn)。不过,类似分析时间复杂度那样来分析空间复杂度,这个思路对吗?

实际上,递归代码的空间复杂度并不能像时间复杂度那样累加。刚刚忘记了最重要的一点,那就是,尽管每次合并操作都需要申请额外的内存空间,但在合并完成之后,临时开辟的内存空间就被释放掉了。在任意时刻,CPU 只会有一个函数在执行,也就只会有一个临时的内存空间在使用。临时内存空间最大也不会超过 n 个数据的大小,所以空间复杂度是 O ( n ) O(n) O(n)

8.6 快速排序(Quick Sort)

快排利用的也是分治思想。乍看起来,它有点像归并排序,但是思路其实完全不一样

快排的核心思想:如果要排序数组中下标从 p 到 r 之间的一组数据,选择 p 到 r 之间的任意一个数据作为 pivot(分区点)

遍历 p 到 r 之间的数据,将小于 pivot 的放到左边,将大于 pivot 的放到右边,将 pivot 放到中间。经过这一步骤之后,数组 p 到 r 之间的数据就被分成了三个部分,前面 p 到 q-1 之间都是小于 pivot 的,中间是 pivot,后面的 q+1 到 r 之间是大于 pivot 的

根据分治、递归的处理思想,可以用递归排序下标从 p 到 q-1 之间的数据和下标从 q+1 到 r 之间的数据,直到区间缩小为 1,就说明所有的数据都有序了

如果用递推公式来将上面的过程写出来的话,就是这样:

递推公式:
quick_sort(p…r) = quick_sort(p…q-1) + quick_sort(q+1, r)
终止条件:
p >= r

将递推公式转化成递归代码:

// 快速排序,A 是数组,n 表示数组的大小
quick_sort(A, n) {
 quick_sort_c(A, 0, n-1)
}
// 快速排序递归函数,p,r 为下标
quick_sort_c(A, p, r) {
 if p >= r then return
 
 q = partition(A, p, r) // 获取分区点
 quick_sort_c(A, p, q-1)
 quick_sort_c(A, q+1, r)
}

归并排序中有一个 merge() 合并函数,这里有一个 partition() 分区函数。partition() 分区函数实际上前面已经讲过了,就是随机选择一个元素作为 pivot(一般情况下,可以选择 p 到 r 区间的最后一个元素),然后对 A[p…r] 分区,函数返回 pivot 的下标

如果不考虑空间消耗的话,partition() 分区函数可以写得非常简单。申请两个临时数组 X 和 Y,遍历 A[p…r],将小于 pivot 的元素都拷贝到临时数组 X,将大于 pivot 的 元素都拷贝到临时数组 Y,最后再将数组 X 和数组 Y 中数据顺序拷贝到 A[p…r]

但是,如果按照这种思路实现的话,partition() 函数就需要很多额外的内存空间,所以快排就不是原地排序算法了。如果希望快排是原地排序算法,那它的空间复杂度得是 O(1),那 partition() 分区函数就不能占用太多额外的内存空间,就需要在 A[p…r] 的 原地完成分区操作

原地分区函数的实现思路非常巧妙,伪代码如下:

partition(A, p, r) {
  pivot := A[r]
  i := p
  for j := p to r-1 do {
    if A[j] < pivot {
      swap A[i] with A[j]
      i := i+1
    }
  }
  swap A[i] with A[r]
  return i
 }

这里的处理有点类似选择排序。我们通过游标 i 把 A[p…r-1] 分成两部分。A[p…i-1] 的元素都是小于 pivot 的,我们暂且叫它“已处理区间”,A[i…r-1] 是“未处理区间”。我们每次都从未处理的区间 A[i…r-1] 中取一个元素 A[j],与 pivot 对比,如果小于 pivot,则将其加入到已处理区间的尾部,也就是 A[i] 的位置

在数组某个位置插入元素,需要搬移数据,非常耗时。但我们也讲了一种处理技巧,就是交换,在 O(1) 的时间复杂度内完成插入操作。这里我们也借助这个思想,只需要将 A[i] 与 A[j] 交换,就可以在 O(1) 时间复杂度内将 A[j] 放到下标为 i 的位置

因为分区的过程涉及交换操作,如果数组中有两个相同的元素,比如序列 6,8,7,6,3,5,9,4,在经过第一次分区操作之后,两个 6 的相对先后顺序就会改变。所以,快速排序并不是一个稳定的排序算法

快速排序的性能分析

快排也是用递归来实现的。对于递归代码的时间复杂度,前面总结的公式,这里也还是适用的。如果每次分区操作,都能正好把数组分成大小接近相等的两个小区间,那快排的时间复杂度递推求解公式跟归并是相同的。所以,快排的时间复杂度也是 O(nlogn)

T(1) = C;   n=1 时,只需要常量级的执行时间,所以表示为 C。
T(n) = 2*T(n/2) + n; n>1

但是,公式成立的前提是每次分区操作,我们选择的 pivot 都很合适,正好能将大区间对等地一分为二。但实际上这种情况是很难实现的

举一个比较极端的例子。如果数组中的数据原来已经是有序的了,比如 1,3,5,6,8。如果我们每次选择最后一个元素作为 pivot,那每次分区得到的两个区间都是不均等的。我们需要进行大约 n 次分区操作,才能完成快排的整个过程。每次分区我们平均要扫描大约 n/2 个元素,这种情况下,快排的时间复杂度就从 O(nlogn) 退化成了 O ( n 2 ) O(n^2) O(n2)

刚刚讲了两个极端情况下的时间复杂度,一个是分区极其均衡,一个是分区极其不均衡。它们分别对应快排的最好情况时间复杂度和最坏情况时间复杂度。那快排的平均情况时间复杂度是多少呢?

假设每次分区操作都将区间分成大小为 9:1 的两个小区间。我们继续套用递归时间复杂度的递推公式,就会变成这样:

T(1) = C;  n=1 时,只需要常量级的执行时间,所以表示为 C。
 
T(n) = T(n/10) + T(9*n/10) + n; n>1

这个公式的递推求解的过程非常复杂,虽然可以求解,但不推荐用这种方法。实际上,递归的时间复杂度的求解方法除了递推公式之外,还有递归树。这里直接给你结论:T(n) 在大部分情况下的时间复杂度都可以做到 O(nlogn),只有在极端情况下,才会退化到 O ( n 2 ) O(n^2) O(n2)。而且,我们也有很多方法将这个概率降到很低

快排和归并用的都是分治思想,递推公式和递归代码也非常相似,那它们的区别在哪里呢?

可以发现,归并排序的处理过程是由下到上的,先处理子问题,然后再合并。而快排正好相反,它的处理过程是由上到下的,先分区,然后再处理子问题。归并排序虽然是稳定的、时间复杂度为 O(nlogn) 的排序算法,但是它是非原地排序算法。我们前面讲过,归并之所以是非原地排序算法,主要原因是合并函数无法在原地执行。快速排序通过设计巧妙的原地分区函数,可以实现原地排序,解决了归并排序占用太多内存的问题

如何在 O(n) 的时间复杂度内查找一个无序数组中的第 K 大元素?

快排核心思想就是分治分区,我们可以利用分区的思想,来解答这个问题:O(n) 时间复杂度内求无序数组中的第 K 大元素。比如,4, 2, 5, 12, 3 这样一组数据,第 3 大元素就是 4

我们选择数组区间 A[0…n-1] 的最后一个元素 A[n-1] 作为 pivot,对数组 A[0…n-1] 原地分区,这样数组就分成了三部分,A[0…p-1]、A[p]、A[p+1…n-1]

如果 p+1=K,那 A[p] 就是要求解的元素;如果 K>p+1, 说明第 K 大元素出现在 A[p+1…n-1] 区间,我们再按照上面的思路递归地在 A[p+1…n-1] 这个区间内查找。同理,如果 K<p+1,那我们就在 A[0…p-1] 区间查找

再来看,为什么上述解决思路的时间复杂度是 O(n)?

第一次分区查找,我们需要对大小为 n 的数组执行分区操作,需要遍历 n 个元素。第二次分区查找,我们只需要对大小为 n/2 的数组执行分区操作,需要遍历 n/2 个元素。依次类推,分区遍历元素的个数分别为、n/2、n/4、n/8、n/16.……直到区间缩小为 1

如果我们把每次分区遍历的元素个数加起来,就是:n+n/2+n/4+n/8+…+1。这是一个等比数列求和,最后的和等于 2n-1。所以,上述解决思路的时间复杂度就为 O(n)

你可能会说,我有个很笨的办法,每次取数组中的最小值,将其移动到数组的最前面,然后在剩下的数组中继续找最小值,以此类推,执行 K 次,找到的数据不就是第 K 大元素了吗?

不过,时间复杂度就并不是 O(n) 了,而是 O(K * n)。你可能会说,时间复杂度前面的系数不是可以忽略吗?O(K * n) 不就等于 O(n) 吗?

这个可不能这么简单地划等号。当 K 是比较小的常量时,比如 1、2,那最好时间复杂度确实是 O(n);但当 K 等于 n/2 或者 n 时,这种最坏情况下的时间复杂度就是 O ( n 2 ) O(n^2) O(n2)

9. 线性排序

三种时间复杂度是 O(n) 的排序算法:桶排序、计数排序、基数排序。因为这些排序算法的时间复杂度是线性的,所以我们把这类排序算法叫作线性排序(Linear sort)。之所以能做到线性的时间复杂度,主要原因是,这三个算法是非基于比较的排序算法,都不涉及元素之间的比较操作

9.1 桶排序(Bucket Sort)

桶排序,顾名思义,会用到“桶”,核心思想是将要排序的数据分到几个有序的桶里,每个桶里的数据再单独进行排序。桶内排完序之后,再把每个桶里的数据按照顺序依次取出,组成的序列就是有序的了

桶排序的时间复杂度为什么是 O(n) 呢?

如果要排序的数据有 n 个,我们把它们均匀地划分到 m 个桶内,每个桶里就有 k=n/m 个元素。每个桶内部使用快速排序,时间复杂度为 O(k * logk)。m 个桶排序的时间复杂度就是 O(m * k * logk),因为 k=n/m,所以整个桶排序的时间复杂度就是 O(n*log(n/m))。当桶的个数 m 接近数据个数 n 时,log(n/m) 就是一个非常小的常量,这个时候桶排序的时间复杂度接近 O(n)

桶排序看起来很优秀,那它是不是可以替代我们之前讲的排序算法呢?

答案当然是否定的。为实际上,桶排序对要排序数据的要求是非常苛刻的

首先,要排序的数据需要很容易就能划分成 m 个桶,并且,桶与桶之间有着天然的大小顺序。这样每个桶内的数据都排序完之后,桶与桶之间的数据不需要再进行排序

其次,数据在各个桶之间的分布是比较均匀的。如果数据经过桶的划分之后,有些桶里的数据非常多,有些非常少,很不平均,那桶内数据排序的时间复杂度就不是常量级了。在极端情况下,如果数据都被划分到一个桶里,那就退化为 O(nlogn) 的排序算法了

桶排序比较适合用在外部排序中。所谓的外部排序就是数据存储在外部磁盘中,数据量比较大,内存有限,无法将数据全部加载到内存中

比如说我们有 10GB 的订单数据,我们希望按订单金额(假设金额都是正整数)进行排序,但是我们的内存有限,只有几百 MB,没办法一次性把 10GB 的数据都加载到内存中。这个时候该怎么办呢?

我们可以先扫描一遍文件,看订单金额所处的数据范围。假设经过扫描之后我们得到,订单金额最小是 1 元,最大是 10 万元。我们将所有订单根据金额划分到 100 个桶里,第一个桶我们存储金额在 1 元到 1000 元之内的订单,第二桶存储金额在 1001 元到 2000 元之内的订单,以此类推。每一个桶对应一个文件,并且按照金额范围的大小顺序编号命名(00,01,02…99)

理想的情况下,如果订单金额在 1 到 10 万之间均匀分布,那订单会被均匀划分到 100 个文件中,每个小文件中存储大约 100MB 的订单数据,我们就可以将这 100 个小文件依次放到内存中,用快排来排序。等所有文件都排好序之后,我们只需要按照文件编号,从小到大依次读取每个小文件中的订单数据,并将其写入到一个文件中,那这个文件中存储的就是按照金额从小到大排序的订单数据了

不过,订单按照金额在 1 元到 10 万元之间并不一定是均匀分布的 ,所以 10GB 订单数据是无法均匀地被划分到 100 个文件中的。有可能某个金额区间的数据特别多,划分之后对应的文件就会很大,没法一次性读入内存。这又该怎么办呢?

针对这些划分之后还是比较大的文件,我们可以继续划分,比如,订单金额在 1 元到 1000 元之间的比较多,我们就将这个区间继续划分为 10 个小区间,1 元到 100 元,101 元到 200 元,201 元到 300 元…901 元到 1000 元。如果划分之后,101 元到 200 元之间的订单还是太多,无法一次性读入内存,那就继续再划分,直到所有的文件都能读入内存为止

9.2 计数排序(Counting Sort)

计数排序其实是桶排序的一种特殊情况。当要排序的 n 个数据,所处的范围并不大的时候,比如最大值是 k,我们就可以把数据划分成 k 个桶。每个桶内的数据值都是相同的,省掉了桶内排序的时间

我们都经历过高考,高考查分数系统你还记得吗?我们查分数的时候,系统会显示我们的成绩以及所在省的排名。如果你所在的省有 50 万考生,如何通过成绩快速排序得出名次呢?

考生的满分是 900 分,最小是 0 分,这个数据的范围很小,所以我们可以分成 901 个桶,对应分数从 0 分到 900 分。根据考生的成绩,我们将这 50 万考生划分到这 901 个桶里。桶内的数据都是分数相同的考生,所以并不需要再进行排序。我们只需要依次扫描每个桶,将桶内的考生依次输出到一个数组中,就实现了 50 万考生的排序。因为只涉及扫描遍历操作,所以时间复杂度是 O(n)

计数排序的算法思想就是这么简单,跟桶排序非常类似,只是桶的大小粒度不一样。不过,为什么这个排序算法叫“计数”排序呢?“计数”的含义来自哪里呢?

想弄明白这个问题,我们就要来看计数排序算法的实现方法。拿考生那个例子来解释。假设只有 8 个考生,分数在 0 到 5 分之间。这 8 个考生的成绩我们放在一个数组 A[8] 中,它们分别是:2,5,3,0,2,3,0,3

考生的成绩从 0 到 5 分,我们使用大小为 6 的数组 C[6] 表示桶,其中下标对应分数。不过,C[6] 内存储的并不是考生,而是对应的考生个数。像我刚刚举的那个例子,我们只需要遍历一遍考生分数,就可以得到 C[6] 的值

从图中可以看出,分数为 3 分的考生有 3 个,小于 3 分的考生有 4 个,所以,成绩为 3 分的考生在排序之后的有序数组 R[8] 中,会保存下标 4,5,6 的位置

那我们如何快速计算出,每个分数的考生在有序数组中对应的存储位置呢?这个处理方法非常巧妙

我们对 C[6] 数组顺序求和,C[6] 存储的数据就变成了下面这样子。C[k] 里存储小于等于分数 k 的考生个数

我们从后到前依次扫描数组 A。比如,当扫描到 3 时,我们可以从数组 C 中取出下标为 3 的值 7,也就是说,到目前为止,包括自己在内,分数小于等于 3 的考生有 7 个,也就是说 3 是数组 R 中的第 7 个元素(也就是数组 R 中下标为 6 的位置)。当 3 放入到数组 R 中后,小于等于 3 的元素就只剩下了 6 个了,所以相应的 C[3] 要减 1,变成 6

以此类推,当我们扫描到第 2 个分数为 3 的考生的时候,就会把它放入数组 R 中的第 6 个元素的位置(也就是下标为 5 的位置)。当我们扫描完整个数组 A 后,数组 R 内的数据就是按照分数从小到大有序排列的了

代码如下:

// 计数排序,a 是数组,n 是数组大小。假设数组中存储的都是非负整数。
public void countingSort(int[] a, int n) {
  if (n <= 1) return;
 
  // 查找数组中数据的范围
  int max = a[0];
  for (int i = 1; i < n; ++i) {
    if (max < a[i]) {
      max = a[i];
    }
  }
 
  int[] c = new int[max + 1]; // 申请一个计数数组 c,下标大小 [0,max]
  for (int i = 0; i <= max; ++i) {
    c[i] = 0;
  }
 
  // 计算每个元素的个数,放入 c 中
  for (int i = 0; i < n; ++i) {
    c[a[i]]++;
  }
 
  // 依次累加
  for (int i = 1; i <= max; ++i) {
    c[i] = c[i-1] + c[i];
  }
 
  // 临时数组 r,存储排序之后的结果
  int[] r = new int[n];
  // 计算排序的关键步骤,有点难理解
  for (int i = n - 1; i >= 0; --i) {
    int index = c[a[i]]-1;
    r[index] = a[i];
    c[a[i]]--;
  }
 
  // 将结果拷贝给 a 数组
  for (int i = 0; i < n; ++i) {
    a[i] = r[i];
  }
}

这种利用另外一个数组来计数的实现方式是不是很巧妙呢?这也是为什么这种排序算法叫计数排序的原因

计数排序只能用在数据范围不大的场景中,如果数据范围 k 比要排序的数据 n 大很多,就不适合用计数排序了。而且,计数排序只能给非负整数排序,如果要排序的数据是其他类型的,要将其在不改变相对大小的情况下,转化为非负整数

比如考生这个例子。如果考生成绩精确到小数后一位,我们就需要将所有的分数都先乘以 10,转化成整数,然后再放到 9010 个桶内。再比如,如果要排序的数据中有负数,数据的范围是 [-1000, 1000],那我们就需要先对每个数据都加 1000,转化成非负整数

9.3 基数排序(Radix Sort)

假设我们有 10 万个手机号码,希望将这 10 万个手机号码从小到大排序,你有什么比较快速的排序方法呢?

我们之前讲的快排,时间复杂度可以做到 O(nlogn),还有更高效的排序算法吗?桶排序、计数排序能派上用场吗?手机号码有 11 位,范围太大,显然不适合用这两种排序算法。针对这个排序问题,有没有时间复杂度是 O(n) 的算法呢?

刚刚这个问题里有这样的规律:假设要比较两个手机号码 a,b 的大小,如果在前面几位中,a 手机号码已经比 b 手机号码大了,那后面的几位就不用看了

借助稳定排序算法,这里有一个巧妙的实现思路。先按照最后一位来排序手机号码,然后,再按照倒数第二位重新排序,以此类推,最后按照第一位重新排序。经过 11 次排序之后,手机号码就都有序了

注意,这里按照每位来排序的排序算法要是稳定的,否则这个实现思路就是不正确的。因为如果是非稳定排序算法,那最后一次排序只会考虑最高位的大小顺序,完全不管其他位的大小关系,那么低位的排序就完全没有意义了

根据每一位来排序,我们可以用刚讲过的桶排序或者计数排序,它们的时间复杂度可以做到 O(n)。如果要排序的数据有 k 位,那我们就需要 k 次桶排序或者计数排序,总的时间复杂度是 O(k*n)。当 k 不大的时候,比如手机号码排序的例子,k 最大就是 11,所以基数排序的时间复杂度就近似于 O(n)

实际上,有时候要排序的数据并不都是等长的,比如我们排序牛津字典中的 20 万个英文单词,最短的只有 1 个字母,最长的有 45 个字母。对于这种不等长的数据,基数排序还适用吗?

实际上,我们可以把所有的单词补齐到相同长度,位数不够的可以在后面补“0”,因为根据 ASCII 值,所有字母都大于“0”,所以补“0”不会影响到原有的大小顺序。这样就可以继续用基数排序了

基数排序对要排序的数据是有要求的,需要可以分割出独立的“位”来比较,而且位之间有递进的关系,如果 a 数据的高位比 b 数据大,那剩下的低位就不用比较了。除此之外,每一位的数据范围不能太大,要可以用线性排序算法来排序,否则,基数排序的时间复杂度就无法做到 O(n) 了

如何根据年龄给 100 万用户排序?

实际上,根据年龄给 100 万用户排序,就类似按照成绩给 50 万考生排序。我们假设年龄的范围最小 1 岁,最大不超过 120 岁。我们可以遍历这 100 万用户,根据年龄将其划分到这 120 个桶里,然后依次顺序遍历这 120 个桶中的元素。这样就得到了按照年龄排序的 100 万用户数据

如何实现一个通用的、高性能的排序函数?

如何选择合适的排序算法?

线性排序算法的时间复杂度比较低,适用场景比较特殊。所以如果要写一个通用的排序函数,不能选择线性排序算法

如果对小规模数据进行排序,可以选择时间复杂度是 O ( n 2 ) O(n^2) O(n2) 的算法;如果对大规模数据进行排序,时间复杂度是 O(nlogn) 的算法更加高效。所以,为了兼顾任意规模数据的排序,一般都会首选时间复杂度是 O(nlogn) 的排序算法来实现排序函数

时间复杂度是 O(nlogn) 的排序算法不止一个,比如归并排序、快速排序,还有堆排序。堆排序和快速排序都有比较多的应用,比如 Java 语言采用堆排序实现排序函数,C 语言使用快速排序实现排序函数

不知道你有没有发现,使用归并排序的情况其实并不多。我们知道,快排在最坏情况下的时间复杂度是 O ( n 2 ) O(n^2) O(n2),而归并排序可以做到平均情况、最坏情况下的时间复杂度都是 O(nlogn),从这点上看起来很诱人,那为什么它还是没能得到“宠信”呢?

归并排序并不是原地排序算法,空间复杂度是 O(n)。所以,粗略点、夸张点讲,如果要排序 100MB 的数据,除了数据本身占用的内存之外,排序算法还要额外再占用 100MB 的内存空间,空间耗费就翻倍了

快速排序比较适合来实现排序函数,但是,我们也知道,快速排序在最坏情况下的时间复杂度是 O ( n 2 ) O(n^2) O(n2),如何来解决这个“复杂度恶化”的问题呢?

如何优化快速排序?

先来看下,为什么最坏情况下快速排序的时间复杂度是 O ( n 2 ) O(n^2) O(n2) 呢?前面讲过,如果数据原来就是有序的或者接近有序的,每次分区点都选择最后一个数据,那快速排序算法就会变得非常糟糕,时间复杂度就会退化为 O ( n 2 ) O(n^2) O(n2)。实际上,这种 O ( n 2 ) O(n^2) O(n2) 时间复杂度出现的主要原因还是因为我们分区点选的不够合理

那什么样的分区点是好的分区点呢?或者说如何来选择分区点呢?

最理想的分区点是:被分区点分开的两个分区中,数据的数量差不多

如果很粗暴地直接选择第一个或者最后一个数据作为分区点,不考虑数据的特点,肯定会出现之前讲的那样,在某些情况下,排序的最坏情况时间复杂度是 O ( n 2 ) O(n^2) O(n2)。为了提高排序算法的性能,我们也要尽可能地让每次分区都比较平均

这里介绍两个比较常用、比较简单的分区算法

1、三数取中法

我们从区间的首、尾、中间,分别取出一个数,然后对比大小,取这 3 个数的中间值作为分区点。这样每间隔某个固定的长度,取数据出来比较,将中间值作为分区点的分区算法,肯定要比单纯取某一个数据更好。但是,如果要排序的数组比较大,那“三数取中”可能就不够了,可能要“五数取中”或者“十数取中”

2、随机法

随机法就是每次从要排序的区间中,随机选择一个元素作为分区点。这种方法并不能保证每次分区点都选的比较好,但是从概率的角度来看,也不大可能会出现每次分区点都选的很差的情况,所以平均情况下,这样选的分区点是比较好的。时间复杂度退化为最糟糕的 O ( n 2 ) O(n^2) O(n2) 的情况,出现的可能性不大

快速排序是用递归来实现的。递归要警惕堆栈溢出。为了避免快速排序里,递归过深而堆栈过小,导致堆栈溢出,我们有两种解决办法:第一种是限制递归深度。一旦递归过深,超过了我们事先设定的阈值,就停止递归。第二种是通过在堆上模拟实现一个函数调用栈,手动模拟递归压栈、出栈的过程,这样就没有了系统栈大小的限制

举例分析排序函数

拿 Glibc 中的 qsort() 函数举例说明一下。虽说 qsort() 从名字上看,很像是基于快速排序算法实现的,实际上它并不仅仅用了快排这一种算法

如果去看源码就会发现,qsort() 会优先使用归并排序来排序输入数据,因为归并排序的空间复杂度是 O(n),所以对于小数据量的排序,比如 1KB、2KB 等,归并排序额外需要 1KB、2KB 的内存空间,这个问题不大。现在计算机的内存都挺大的,我们很多时候追求的是速度

但如果数据量太大,就跟我们前面提到的,排序 100MB 的数据,这个时候我们再用归并排序就不合适了。所以,要排序的数据量比较大的时候,qsort() 会改为用快速排序算法来排序

qsort() 是如何选择快速排序算法的分区点的呢?如果去看源码,你就会发现,qsort() 选择分区点的方法就是“三数取中法”

还有我们前面提到的递归太深会导致堆栈溢出的问题,qsort() 是通过自己实现一个堆上的栈,手动模拟递归来解决的

实际上,qsort() 并不仅仅用到了归并排序和快速排序,它还用到了插入排序。在快速排序的过程中,当要排序的区间中,元素的个数小于等于 4 时,qsort() 就退化为插入排序,不再继续用递归来做快速排序,因为我们前面也讲过,在小规模数据面前, O ( n 2 ) O(n^2) O(n2) 时间复杂度的算法并不一定比 O(nlogn) 的算法执行时间长。我们现在就来分析下这个说法

我们在讲复杂度分析的时候讲过,算法的性能可以通过时间复杂度来分析,但是,这种复杂度分析是比较偏理论的,如果我们深究的话,实际上时间复杂度并不等于代码实际的运行时间

时间复杂度代表的是一个增长趋势,如果画成增长曲线图,你会发现 O ( n 2 ) O(n^2) O(n2) 比 O(nlogn) 要陡峭,也就是说增长趋势要更猛一些。但是,我们前面讲过,在大 O 复杂度表示法中,我们会省略低阶、系数和常数,也就是说,O(nlogn) 在没有省略低阶、系数、常数之前可能是 O(knlogn + c),而且 k 和 c 有可能还是一个比较大的数

假设 k=1000,c=200,当我们对小规模数据(比如 n=100)排序时, n 2 n^2 n2 的值实际上比 knlogn+c 还要小

knlogn+c = 1000 * 100 * log100 + 200 远大于 10000
 
n^2 = 100*100 = 10000

所以,对于小规模数据的排序, O ( n 2 ) O(n^2) O(n2) 的排序算法并不一定比 O(nlogn) 排序算法执行的时间长。对于小数据量的排序,我们选择比较简单、不需要递归的插入排序算法

之前讲到的哨兵来简化代码,提高执行效率。在 qsort() 插入排序的算法实现中,也利用了这种编程技巧。虽然哨兵可能只是少做一次判断,但是毕竟排序函数是非常常用、非常基础的函数,性能的优化要做到极致

10. 二分查找(Binary Search)

针对有序数据集合的查找算法:二分查找(Binary Search)算法,也叫折半查找算法

10.1 无处不在的二分思想

二分查找是一种非常简单易懂的快速查找算法,生活中到处可见。比如经典的猜字游戏,随机写一个 0 到 99 之间的数字,然后来猜写的是什么。猜的过程中,每猜一次,就会提示猜的大了还是小了,直到猜中为止

假设写的数字是 23,步骤如下(如果猜测范围的数字有偶数个,中间数有两个,就选择较小的那个)

7 次就猜出来了,这个例子用的就是二分思想,按照这个思想,即便你猜的是 0 到 999 的数字,最多也只要 10 次就能猜中

这是一个生活中的例子,我们现在回到实际的开发场景中。假设有 1000 条订单数据,已经按照订单金额从小到大排序,每个订单金额都不同,并且最小单位是元。我们现在想知道是否存在金额等于 19 元的订单。如果存在,则返回订单数据,如果不存在则返回 null

最简单的办法当然是从第一个订单开始,一个一个遍历这 1000 个订单,直到找到金额等于 19 元的订单为止。但这样查找会比较慢,最坏情况下,可能要遍历完这 1000 条记录才能找到。那用二分查找能不能更快速地解决呢?

为了方便讲解,我们假设只有 10 个订单,订单金额分别是:8,11,19,23,27,33,45,55,67,98

还是利用二分思想,每次都与区间的中间数据比对大小,缩小查找区间的范围。如下图,其中,low 和 high 表示待查找区间的下标,mid 表示待查找区间的中间元素下标

二分查找针对的是一个有序的数据集合,查找思想有点类似分治思想。每次都通过跟区间的中间元素对比,将待查找的区间缩小为之前的一半,直到找到要查找的元素,或者区间被缩小为 0

10.2 O(logn) 惊人的查找速度

二分查找是一种非常高效的查找算法,高效到什么程度呢?我们来分析一下它的时间复杂度

我们假设数据大小是 n,每次查找后数据都会缩小为原来的一半,也就是会除以 2。最坏情况下,直到查找区间被缩小为空,才停止

可以看出来,这是一个等比数列。其中 n / 2 k n/2^k n/2k = 1 时,k 的值就是总共缩小的次数。而每一次缩小操作只涉及两个数据的大小比较,所以,经过了 k 次区间缩小操作,时间复杂度就是 O(k)。通过 n / 2 k n/2^k n/2k = 1,我们可以求得 k= l o g 2 n log_2n log2n,所以时间复杂度就是 O(logn)

O(logn) 这种对数时间复杂度。这是一种极其高效的时间复杂度,有的时候甚至比时间复杂度是常量级 O(1) 的算法还要高效。为什么这么说呢?

因为 logn 是一个非常“恐怖”的数量级,即便 n 非常非常大,对应的 logn 也很小。比如 n 等于 2 的 32 次方,这个数大约是 42 亿。也就是说,如果我们在 42 亿个数据中用二分查找一个数据,最多需要比较 32 次

我们前面讲过,用大 O 标记法表示时间复杂度的时候,会省略掉常数、系数和低阶。对于常量级时间复杂度的算法来说,O(1) 有可能表示的是一个非常大的常量值,比如 O(1000)、O(10000)。所以,常量级时间复杂度的算法有时候可能还没有 O(logn) 的算法执行效率高

反过来,对数对应的就是指数。有一个非常著名的“阿基米德与国王下棋的故事”,这也是为什么我们说,指数时间复杂度的算法在大规模数据面前是无效的

10.3 二分查找的递归与非递归实现

实际上,简单的二分查找并不难写,最简单的情况就是有序数组中不存在重复元素,我们在其中用二分查找值等于给定值的数据,代码如下:

public int bsearch(int[] a, int n, int value) {
  int low = 0;
  int high = n - 1;
 
  while (low <= high) {
    int mid = (low + high) / 2;
    if (a[mid] == value) {
      return mid;
    } else if (a[mid] < value) {
      low = mid + 1;
    } else {
      high = mid - 1;
    }
  }
 
  return -1;
}

low、high、mid 都是指数组下标,其中 low 和 high 表示当前查找的区间范围,初始 low=0, high=n-1。mid 表示 [low, high] 的中间位置。我们通过对比 a[mid] 与 value 的大小,来更新接下来要查找的区间范围,直到找到或者区间缩小为 0,就退出

这里有容易出错的 3 个地方

1、循环退出条件

注意是 low<=high,而不是 low<high

2、mid 的取值

实际上,mid=(low+high)/2 这种写法是有问题的。因为如果 low 和 high 比较大的话,两者之和就有可能会溢出。改进的方法是将 mid 的计算方式写成 low+(high-low)/2。更进一步,如果要将性能优化到极致的话,我们可以将这里的除以 2 操作转化成位运算 low+((high-low)>>1)。因为相比除法运算来说,计算机处理位运算要快得多

3、low 和 high 的更新

low=mid+1,high=mid-1。注意这里的 +1 和 -1,如果直接写成 low=mid 或者 high=mid,就可能会发生死循环。比如,当 high=3,low=3 时,如果 a[3] 不等于 value,就会导致一直循环不退出

实际上,二分查找除了用循环来实现,还可以递归来实现,过程也非常简单

// 二分查找的递归实现
public int bsearch(int[] a, int n, int val) {
  return bsearchInternally(a, 0, n - 1, val);
}
 
private int bsearchInternally(int[] a, int low, int high, int value) {
  if (low > high) return -1;
 
  int mid =  low + ((high - low) >> 1);
  if (a[mid] == value) {
    return mid;
  } else if (a[mid] < value) {
    return bsearchInternally(a, mid+1, high, value);
  } else {
    return bsearchInternally(a, low, mid-1, value);
  }
}

10.4 二分查找应用场景的局限性

1、首先,二分查找依赖的是顺序表结构,简单点说就是数组

那二分查找能否依赖其他数据结构呢?比如链表。答案是不可以的,主要原因是二分查找算法需要按照下标随机访问元素。数组按照下标随机访问数据的时间复杂度是 O(1),而链表随机访问的时间复杂度是 O(n)。所以,如果数据使用链表存储,二分查找的时间复杂就会变得很高

二分查找只能用在数据是通过顺序表来存储的数据结构上。如果你的数据是通过其他数据结构存储的,则无法应用二分查找

2、二分查找针对的是有序数据

二分查找对这一点的要求比较苛刻,数据必须是有序的。如果数据没有序,我们需要先排序。前面章节里我们讲到,排序的时间复杂度最低是 O(nlogn)。所以,如果我们针对的是一组静态的数据,没有频繁地插入、删除,我们可以进行一次排序,多次二分查找。这样排序的成本可被均摊,二分查找的边际成本就会比较低

但是,如果我们的数据集合有频繁的插入和删除操作,要想用二分查找,要么每次插入、删除操作之后保证数据仍然有序,要么在每次二分查找之前都先进行排序。针对这种动态数据集合,无论哪种方法,维护有序的成本都是很高的

所以,二分查找只能用在插入、删除操作不频繁,一次排序多次查找的场景中。针对动态变化的数据集合,二分查找将不再适用

3、数据量太小不适合二分查找

如果要处理的数据量很小,完全没有必要用二分查找,顺序遍历就足够了。比如我们在一个大小为 10 的数组中查找一个元素,不管用二分查找还是顺序遍历,查找速度都差不多。只有数据量比较大的时候,二分查找的优势才会比较明显

不过,这里有一个例外。如果数据之间的比较操作非常耗时,不管数据量大小,我都推荐使用二分查找。比如,数组中存储的都是长度超过 300 的字符串,如此长的两个字符串之间比对大小,就会非常耗时。我们需要尽可能地减少比较次数,而比较次数的减少会大大提高性能,这个时候二分查找就比顺序遍历更有优势

4、数据量太大也不适合二分查找

二分查找的底层需要依赖数组这种数据结构,而数组为了支持随机访问的特性,要求内存空间连续,对内存的要求比较苛刻。比如,我们有 1GB 大小的数据,如果希望用数组来存储,那就需要 1GB 的连续内存空间

注意这里的“连续”二字,也就是说,即便有 2GB 的内存空间剩余,但是如果这剩余的 2GB 内存空间都是零散的,没有连续的 1GB 大小的内存空间,那照样无法申请一个 1GB 大小的数组。而我们的二分查找是作用在数组这种数据结构之上的,所以太大的数据用数组存储就比较吃力了,也就不能用二分查找了

如何在 1000 万个整数中快速查找某个整数?

我们的内存限制是 100MB,每个数据大小是 8 字节,最简单的办法就是将数据存储在数组中,内存占用差不多是 80MB,符合内存的限制。我们可以先对这 1000 万数据从小到大排序,然后再利用二分查找算法,就可以快速地查找想要的数据了

10.5 二分变体

唐纳德·克努特(Donald E.Knuth)在《计算机程序设计艺术》的第 3 卷《排序和查找》中说到:“尽管第一个二分查找算法于 1946 年出现,然而第一个完全正确的二分查找算法实现直到 1962 年才出现”

“十个二分九个错”。二分查找虽然原理极其简单,但是想要写出没有 Bug 的二分查找并不容易,最简单的二分查找写起来确实不难,但是,二分查找的变形问题就没那么好写了

10.5.1 查找第一个值等于给定值的元素

前面写的二分查找是最简单的一种,即有序数据集合中不存在重复的数据,我们在其中查找值等于某个给定值的数据。如果我们将这个问题稍微修改下,有序数据集合中存在重复的数据,我们希望找到第一个值等于给定值的数据,这样之前的二分查找代码还能继续工作吗?

比如下面这样一个有序数组,其中,a[5],a[6],a[7] 的值都等于 8,是重复的数据。我们希望查找第一个等于 8 的数据,也就是下标是 5 的元素

如果我们用前面的二分查找的代码实现,首先拿 8 与区间的中间值 a[4] 比较,8 比 6 大,于是在下标 5 到 9 之间继续查找。下标 5 和 9 的中间位置是下标 7,a[7] 正好等于 8,所以代码就返回了

尽管 a[7] 也等于 8,但它并不是我们想要找的第一个等于 8 的元素,因为第一个值等于 8 的元素是数组下标为 5 的元素。所以,针对这个变形问题,我们可以稍微改造一下代码

100 个人写二分查找就会有 100 种写法。网上有很多关于变形二分查找的实现方法,有很多写得非常简洁,比如下面这个写法。但是,尽管简洁,理解起来却非常烧脑,也很容易写错

public int bsearch(int[] a, int n, int value) {
  int low = 0;
  int high = n - 1;
  while (low <= high) {
    int mid = low + ((high - low) >> 1);
    if (a[mid] >= value) {
      high = mid - 1;
    } else {
      low = mid + 1;
    }
  }
 
  if (low < n && a[low]==value) return low;
  else return -1;
}

更适合理解的写法如下:

public int bsearch(int[] a, int n, int value) {
  int low = 0;
  int high = n - 1;
  while (low <= high) {
    int mid =  low + ((high - low) >> 1);
    if (a[mid] > value) {
      high = mid - 1;
    } else if (a[mid] < value) {
      low = mid + 1;
    } else {
      if ((mid == 0) || (a[mid - 1] != value)) return mid;
      else high = mid - 1;
    }
  }
  return -1;
}

a[mid] 跟要查找的 value 的大小关系有三种情况:大于、小于、等于。对于 a[mid]>value 的情况,我们需要更新 high= mid-1;对于 a[mid]<value 的情况,我们需要更新 low=mid+1。这两点都很好理解。那当 a[mid]=value 的时候应该如何处理呢?

如果我们查找的是任意一个值等于给定值的元素,当 a[mid] 等于要查找的值时,a[mid] 就是我们要找的元素。但是,如果我们求解的是第一个值等于给定值的元素,当 a[mid] 等于要查找的值时,我们就需要确认一下这个 a[mid] 是不是第一个值等于给定值的元素

重点看第 11 行代码。如果 mid 等于 0,那这个元素已经是数组的第一个元素,那它肯定是我们要找的;如果 mid 不等于 0,但 a[mid] 的前一个元素 a[mid-1] 不等于 value,那也说明 a[mid] 就是我们要找的第一个值等于给定值的元素

如果经过检查之后发现 a[mid] 前面的一个元素 a[mid-1] 也等于 value,那说明此时的 a[mid] 肯定不是我们要查找的第一个值等于给定值的元素。那我们就更新 high=mid-1,因为要找的元素肯定出现在 [low, mid-1] 之间

10.5.2 查找最后一个值等于给定值的元素

前面的问题是查找第一个值等于给定值的元素,我现在把问题稍微改一下,查找最后一个值等于给定值的元素,又该如何做呢?

将上面代码的条件换一下就可以了

public int bsearch(int[] a, int n, int value) {
  int low = 0;
  int high = n - 1;
  while (low <= high) {
    int mid =  low + ((high - low) >> 1);
    if (a[mid] > value) {
      high = mid - 1;
    } else if (a[mid] < value) {
      low = mid + 1;
    } else {
      if ((mid == n - 1) || (a[mid + 1] != value)) return mid;
      else low = mid + 1;
    }
  }
  return -1;
}

是重点看第 11 行代码。如果 a[mid] 这个元素已经是数组中的最后一个元素了,那它肯定是我们要找的;如果 a[mid] 的后一个元素 a[mid+1] 不等于 value,那也说明 a[mid] 就是我们要找的最后一个值等于给定值的元素

如果我们经过检查之后,发现 a[mid] 后面的一个元素 a[mid+1] 也等于 value,那说明当前的这个 a[mid] 并不是最后一个值等于给定值的元素。我们就更新 low=mid+1,因为要找的元素肯定出现在 [mid+1, high] 之间

10.5.3 查找第一个大于等于给定值的元素

在有序数组中,查找第一个大于等于给定值的元素。比如,数组中存储的这样一个序列:3,4,6,7,10。如果查找第一个大于等于 5 的元素,那就是 6

实际上,实现的思路跟前面的那两种变形问题的实现思路类似,代码写起来甚至更简洁

public int bsearch(int[] a, int n, int value) {
  int low = 0;
  int high = n - 1;
  while (low <= high) {
    int mid =  low + ((high - low) >> 1);
    if (a[mid] >= value) {
      if ((mid == 0) || (a[mid - 1] < value)) return mid;
      else high = mid - 1;
    } else {
      low = mid + 1;
    }
  }
  return -1;
}

如果 a[mid] 小于要查找的值 value,那要查找的值肯定在 [mid+1, high] 之间,所以,我们更新 low=mid+1

对于 a[mid] 大于等于给定值 value 的情况,我们要先看下这个 a[mid] 是不是我们要找的第一个值大于等于给定值的元素。如果 a[mid] 前面已经没有元素,或者前面一个元素小于要查找的值 value,那 a[mid] 就是我们要找的元素。这段逻辑对应的代码是第 7 行

如果 a[mid-1] 也大于等于要查找的值 value,那说明要查找的元素在 [low, mid-1] 之间,所以,我们将 high 更新为 mid-1

10.5.4 查找最后一个小于等于给定值的元素

查找最后一个小于等于给定值的元素。比如,数组中存储了这样一组数据:3,5,6,8,9,10。最后一个小于等于 7 的元素就是 6。和上面的类似

public int bsearch7(int[] a, int n, int value) {
  int low = 0;
  int high = n - 1;
  while (low <= high) {
    int mid =  low + ((high - low) >> 1);
    if (a[mid] > value) {
      high = mid - 1;
    } else {
      if ((mid == n - 1) || (a[mid + 1] > value)) return mid;
      else low = mid + 1;
    }
  }
  return -1;
}

如何快速定位出一个 IP 地址的归属地?

如果 IP 区间与归属地的对应关系不经常更新,我们可以先预处理这 12 万条数据,让其按照起始 IP 从小到大排序。如何来排序呢?我们知道,IP 地址可以转化为 32 位的整型数。所以,我们可以将起始地址,按照对应的整型值的大小关系,从小到大进行排序

然后,这个问题就可以转化为第四种变形问题“在有序数组中,查找最后一个小于等于某个给定值的元素”了

当我们要查询某个 IP 归属地时,我们可以先通过二分查找,找到最后一个起始 IP 小于等于这个 IP 的 IP 区间,然后,检查这个 IP 是否在这个 IP 区间内,如果在,我们就取出对应的归属地显示;如果不在,就返回未查找到

  • 43
    点赞
  • 381
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 6
    评论
逻辑结构:描述数据元素之间的逻辑关系,如线性结构(如数组链表)、树形结构(如二叉树、堆、B树)、图结构(有向图、无向图等)以及集合和队列等抽象数据类型。 存储结构(物理结构):描述数据在计算机中如何具体存储。例如,数组的连续存储,链表的动态分配节点,树和图的邻接矩阵或邻接表表示等。 基本操作:针对每种数据结构,定义了一系列基本的操作,包括但不限于插入、删除、查找、更新、遍历等,并分析这些操作的时间复杂度和空间复杂度。 算法: 算法设计:研究如何将解决问题的步骤形式化为一系列指令,使得计算机可以执行以求解问题。 算法特性:包括输入、输出、有穷性、确定性和可行性。即一个有效的算法必须能在有限步骤内结束,并且对于给定的输入产生唯一的确定输出。 算法分类:排序算法(如冒泡排序、快速排序、归并排序),查找算法(如顺序查找、二分查找、哈希查找),图论算法(如Dijkstra最短路径算法、Floyd-Warshall算法、Prim最小生成树算法),动态规划,贪心算法,回溯法,分支限界法等。 算法分析:通过数学方法分析算法的时间复杂度(运行时间随数据规模增长的速度)和空间复杂度(所需内存大小)来评估其效率。 学习算法与数据结构不仅有助于理解程序的内部工作原理,更能帮助开发人员编写出高效、稳定和易于维护的软件系统。
时间复杂度是用来描述算法执行所需计算工作量的度量。计算时间复杂度的方法是通过分析算法中的基本操作的执行次数来确定。通常使用大O符号来表示时间复杂度,其中不包括低阶项和首项系数。时间复杂度的计算方法是根据算法中的循环、递归、条件判断等语句的执行次数来确定。具体来说,可以按照以下步骤计算时间复杂度: 1. 确定基本操作:首先要确定算法中的基本操作,即执行次数较多且占用较多时间的操作。 2. 计算执行次数:对于循环语句,需要确定循环的执行次数。对于递归算法,需要确定递归的深度。对于条件判断语句,需要确定条件成立的次数。 3. 确定时间复杂度:根据执行次数,确定时间复杂度的表达式。通常使用大O符号来表示时间复杂度,忽略低阶项和首项系数。 举个例子来说,如果一个算法中有一个循环,循环的执行次数是n,那么该算法的时间复杂度可以表示为O(n)。如果算法中有两个嵌套循环,第一个循环的执行次数是n,第二个循环的执行次数是m,那么该算法的时间复杂度可以表示为O(n*m)。 需要注意的是,时间复杂度只是对算法的运行时间进行估计,它并不表示具体的执行时间。时间复杂度的计算方法可以帮助我们比较不同算法的效率,选择最优的算法来解决问题。\[1\]\[2\]\[3\] #### 引用[.reference_title] - *1* *2* *3* [时间复杂度和空间复杂度](https://blog.csdn.net/weixin_30535843/article/details/97709606)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v91^control,239^v3^insert_chatgpt"}} ] [.reference_item] [ .reference_list ]

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 6
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Fan 

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值