Skip to content
On this page

数据结构与算法

数据结构与算法是相辅相成的。

数据结构是为算法服务的,算法要作用在特定的数据结构之上。

学习数据结构与算法,有一个很重要的概念就是-复杂度分析。

数据结构与算法解决的是如何更快、更省地存储和处理数据。

因此,我们就需要一个考量效率和资源消耗的方法,就是复杂度分析方法。

数据结构与算法学习图示:

上图是 20 个最常用的、最基础的数据结构与算法。

10 个数据结构:数组、链表、栈、队列、散列表、二叉树、堆、跳表、图、树。

10 个算法:递归、排序、二分查找、搜索、哈希算法、贪心算法、分治算法、回溯算法、动态规划、字符串匹配算法。

复杂度分析

数据结构与算法本身解决的是两个问题:

  • 如何让代码运行得更快;
  • 如何让代码更省空间。

对于算法来说,执行效率是一个非常重要的考量指标。如何衡量编写的算法代码的执行效率?

这就牵扯到 2 个概念:时间复杂度分析、空间复杂度分析。

复杂度分析是算法学习的精髓,只要掌握了它,数据结构和算法的内容基本就掌握了一半。

为什么需要复杂度分析

你可能会问如果把代码跑一遍,通过统计、监控,就可以得到算法执行的时间和占用的内存大小。

那么为什么还需要做复杂度分析?

首先这种评估算法执行效率的方法是对的,它也有一个名字叫 事后统计法

这种统计方法有非常大的局限性。

  • 测试结果非常依赖测试环境;
  • 测试结果收数据规模的影响很大;

我们需要一种不需要具体数据测试,就可以粗略估计算法的执行效率的方法。

这就是我们需要了解的时间、空间复杂度分析方法。

大 O 复杂度表示法

所有代码的执行时间 T(n) 与每行代码的执行次数 f(n) 成正比。

我们可以把这个规律总结成一个公式:

js
T(n) = O(f(n));

T(n) 表示代码执行的时间,n 表示数据规模的大小。

f(n) 表示每行代码执行的次数总和。

O 表示代码执行时间 T(n) 与 f(n) 表达式成正比。

大 O 时间复杂度实并不具体表示代码的真正执行时间,而是表示代码执行时间随数据规模增长的变化趋势。

所以也被叫做渐进时间复杂度(asymptotic time complexity),简称时间复杂度。

时间复杂度分析

分析一段代码的时间复杂度有三个比较实用的方法:

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

我们在分析一个算法、一段代码的时间复杂度时,只需要关注循环执行次数最多的那段代码即可。

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

比如上面这段代码,因为存在一个 for 循环,其中的代码被执行了 n 次,所以时间复杂度为 O(n)。

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

js
function cal (n) {
  let sum1 = 0;
  for (let p = 1; p < 100; p++) {
    sum1 = sum1 + p;
  }
  
  let sum2 = 0;
  for (let q = 1; q < n; q++) {
    sum2 = sum2 + q;
  }
  
  let sum3 = 0;
  for (let i = 1; i <= n; i++) {
    for (let j = 1; j <= n; j++) {
      sum3 = sum3 + i * j;
    }
  }
  
  return sum1 + sum2 + sum3;
}

这段代码可以分为 3 部分,分别求 sum1、sum2、sum3。

我们可以分别分析每一部分的时间复杂度,然后把它们放到一起,再取一个量级最大的作为整段代码的复杂度。

第一段代码是一个常量的执行时间,跟 n 的规模无关。

尽管常量对代码的执行时间有很大影响,但是对于时间复杂度来说,它表示的是一个算法执行效率与数据规模增长的变化趋势,所以我们可以忽略掉常量的执行时间。它本身对于增长趋势并没有影响。

第二段代码和第三段代码的时间复杂度分别是 O(n) 和 O(n²)。

综合这三段代码的时间复杂度,我们取其中最大的量级,所以,这段代码的时间复杂度为 O(n²)。

也就是说,总的时间复杂度等于量级最大的那段代码的时间复杂度。

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

我们可以把乘法法则看作嵌套循环。

js
function cal (n) {
  let ret = 0;
  
  for (let i = 1; i <= n; i++) {
    ret = ret + f(i);
  }

  return ret;
}

function f (n) {
  let sum = 0;
  
  for (let i = 1; i <= n; i++) {
    sum = sum + i;
  }
  
  return sum;
}

常见的时间复杂度实例分析

我们把时间复杂度为非多项式量级的算法问题较多 NP(Non-DeterministicPolynomial,非确定多项式)问题。

当数据规模 n 越来越大时,非多项式量级算法的执行时间会急剧增加,求解问题的执行时间会无限增长。

所以,非多项式时间复杂度的算法是一种非常低效的算法。

O(1)

O(1) 只是常量级时间复杂度的一种表示方法,并不指只执行一行代码。

js
let i = 8;
let j = 6;
let sum = i + j;

只要代码的执行时间不随 n 的增大而增长,这样代码的时间复杂度我们都记作 O(1)。

一般情况下,只要算法中不存在循环语句、递归语句,即使有成千上万的代码,时间复杂度也为 O(1)。

O(logn)、O(nlogn)

对数时间复杂度非常常见。

js
let i = 1;

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

对于上面这个例子,只要我们能计算出这行代码被执行了多少次,就可以知道整段代码的时间复杂度。

变量 i 从 1 开始,每次循环都会乘以 2。当 i 大于 n 时,循环结束。

$2^0$ $2^1$ $2^2$ ... $2^k$ ... $2^x$ = n

我们可以通过 $2^x = n$ 求解 x,即 $x = log2^n$。所以时间复杂度为 $O(log2^n)$;

现在再看这段代码:

js
let i = 1;

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

根据刚才的思路,很简单就可以看出,这段代码的时间复杂度为 $O(log3^n)$。

实际上,不管是以 2 为底,以 3 为底,还是以 10 为底,我们可以把所有对数阶时间复杂度都记为 $O(logn)$。

采用大 O 标记复杂度的时候,可以忽略系数,即 O(Cf(n)) = O(f(n))。

我们介绍过计算时间复杂度的乘法法则,如果一段代码的时间复杂度是 $O(logn)$,我们循环执行 n 遍,时间复杂度就是 $O(nlogn)$。

O(m + n)、O(m * n)

代码的复杂度由两个数据的规模决定。

js
function cal (m, n) {
  let sum1 = 0;
  for (let i = 1; i < m; i++) {
    sum1 = sum1 + i;
  }
  
  let sum2 = 0;
  for (let j = 1; j < n; j++) {
    sum2 = sum2 + j;
  }
  
  return sum1 + sum2;
}

由代码可以看出, m 和 n 是表示两个数据规模。

我们无法事先评估 m 和 n 谁的量级大,所以我们在表示复杂度的时候,就不能简单地利用加法法则,省略掉其中一个。

所以,上面代码的时间复杂度就是 O(m + n)。

针对上述情况,原来的加法法则就不正确了。

可以将加法法则改为 T1(m) + T2(n) = O(f(m) + g(n))。

乘法法则继续有效:T1(m) * T2(n) = O(f(m) * f(n))。

空间复杂度分析

时间复杂度的全称是渐进时间复杂度,表示算法的执行时间与数据规模之间的增长关系。

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

还是看下面例子(这段代码有点 low,主要是方便解释)。

js
function print (n) {
  let i = 0;
  let a = [];
  
  for (i; i < n; i++) {
    a[i] = i * i;
  }
  
  for (i = n - 1; i >= 0; --i) {
    console.log(a[i]);
  }
}

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

我们常见的空间复杂度就是 $O(1)、O(n)、O(n^2)$,像 $O(logn)、O(nlogn)$ 这样的对数阶复杂度平时都用不到。

而且,空间复杂度比时间复杂度分析要简单很多。

总结

复杂度也叫渐进复杂度,包括时间复杂度和空间复杂度,用来分析算法执行效率与数据规模之间的增长关系,可以粗略地表示,越高阶复杂度的算法,执行效率越低。常见的复杂度不多,从低阶到高阶有:$O(1)、O(logn)、O(n)、O(nlogn)、O(n^2)$。

复杂度分析并不难,关键在于多练。

技术拓展

项目性能测试和代码的复杂度分析是否冲突?

渐进式时间、空间复杂度分析和性能基准测试并不冲突,而是相辅相成的。一个低阶的时间复杂度程序极大的可能性会优于一个高阶的时间复杂度程序。所以在实际编程中,时刻关系理论时间,空间度模型是有助于产出效率高的程序的。

同时,因为渐进式时间,空间复杂度分析只是提供一个粗略的分析模型,因此也不会浪费太多时间。重点在于编程时,要具有这种复杂度分析的思维。

时间复杂度

今天主要分析4个复杂度分析方面的知识点,最好情况时间复杂度(best case time complexity)、最坏情况时间复杂度(worst case time complexity)、平均情况时间复杂度(average case time complexity)、均摊时间复杂度(amortized time complexity)。

最好、最坏情况时间复杂度

先看下面的例子。

js
function find (arr, n, x) {
	let pos = -1;
  
  for (let i = 0; i < n; i++) {
    if (arr[i] == x) pos = i;
  }
  
  return pos;
}

这段代码要实现的功能是,在一个无序数组中,查找变量 x 出现的位置,如果没有找到,就返回 -1。

按照之前讲的分析方法,这段代码的复杂度是 O(n),其中,n 代表数组长度。

但其实我们在数组中查找一个数据,并不需要每次把整个数组都遍历一遍,如果找到元素就可以结束循环。

我们可以优化一下这段代码。

js
function find (arr, n, x) {
	let pos = -1;
  
  for (let i = 0; i < n; i++) {
    if (arr[i] == x) {
      pos = i;
      break;
    };
  }
  
  return pos;
}

这时,就出现一个问题。在我们优化完之后,这段代码的时间复杂度还是 O(n) 吗?

案例中要查找的变量 x 可能出现在数组的任意位置。

如果数组第一个元素正好是要查找的变量 x,那就不需要继续遍历剩下的 n - 1 个数据,这时时间复杂度是 O(1)。

如果数据中不存在变量 x,那就需要把数组遍历一遍,时间复杂度就成了 O(n)。

所以,不同情况下,这段代码的时间复杂度是不同的。

为了表示代码在不同情况下的不同时间复杂度,我们需要引入三个概念:

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

顾名思义,最好情况时间复杂度就是,在理想的情况下,执行这段代码的时间复杂度。

同理,最坏情况时间复杂度就是,在最糟糕的情况下,执行这段代码的时间复杂度。

平均情况时间复杂度

最好情况时间复杂度和最坏情况时间复杂度对应的都是极端情况下的代码复杂度,发生的概率其实并不大。

我们需要引入另一个概念,平均情况时间复杂度。后面简称为平均时间复杂度。

我们继续以刚才的例子进行分析。

js
function find (arr, n, x) {
	let pos = -1;
  
  for (let i = 0; i < n; i++) {
    if (arr[i] == x) {
      pos = i;
      break;
    };
  }
  
  return pos;
}

要查找的变量 x 在数组中的位置,有 n + 1 种情况(在数组中的情况和不在数组中的情况)。

我们需要把每种情况下,查找需要遍历的元素个数累加起来,然后再除以 n + 1,就可以得到需要遍历的元素个数的平均值。

我们知道,时间复杂度的大 O 标记法中,可以省略掉系数、低阶、常量,所以,咱们把刚刚这个公式简化之后,得到的平均时间复杂度就是 O(n)。不过这个结论虽然是正确的,但是计算过程稍微有点儿问题。上面所说的 n + 1种情况,出现的概率并不是一样的。

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

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

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

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

你可能会说,平均时间复杂度分析也太复杂了,还要涉及概率论的知识。实际上,在大多数情况下,我们并不需要区分最好、最坏、平均情况时间复杂度三种情况。很多时候,我们使用一个复杂度就可以满足需求了。只有同一块代码在不同的情况下,时间复杂度有量级的差距,我们才会使用这三种复杂度表示法来区分。

均摊时间复杂度

现在,你应该已经掌握算法复杂度分析的大部分内容。下面来讲一个更加高级的概念,均摊时间复杂度以及它对应的分析方法,摊还分析(平摊分析)。

均摊时间复杂度,听起来和平均时间复杂度有点类似。初学者经常把这两个概念弄混。大部分情况下,我们并不需要区分最好、最坏、平均三种复杂度。平均复杂度只在某种特殊情况下才会用到,而均摊时间复杂度应用的场景比它更加特殊、更加有限。

还是借助一个例子来理解。

js
const arr = new Array(10);
let count = 0;

function insert (val) {
  if (count == arr.length) {
    let sum = 0;
    for (let i = 0; i < arr.length; i++) {
      sum = sum + arr[i];
    }
    arr[0] = sum;
    count = 1;
  }
  
  arr[count] = val;
  count++;
}

这段代码实现了一个向数组中插入数据的功能。当数组满了之后,我们用 for 循环遍历数组求和,并清空数组,将求和之后的 sum 值放到数组的第一个位置,然后将将新的数据插入。如果数组存在空闲空间,则直接将数据插入数组。

那这段代码的时间复杂度是多少?我们可以用之前讲过的三种时间复杂度的分析方法来分析。

最理想的情况下,数组中有空闲空间,只需要将数据插入到数组下标为 count 的位置,所以最好情况时间复杂度为 O(1)。

最坏的情况下,数组中没有空闲空间,需要先做一次数组的遍历求和,然后再将数据插入,所以最坏情况时间复杂度为 O(n)。

平均复杂度为 O(1)。我们可以通过之前讲的概率论来分析。

假设数组的长度为 n,根据数据插入的位置不同,我们可以分为 n 种情况,每种情况的时间复杂度都为 O(1)。

除此之外,还有一种 ”额外“ 情况,就是在数组没有空闲空空间时插入一个数据,这个时候的时间复杂度为 O(n)。

而且,这 n + 1 中情况发生的概率一样,都是 1/(n + 1)。所以,根据加权平均的计算方法,我们求得的平均时间复杂度就是:

至此为止,前面说的时间复杂度的计算,理解起来应该都没有问题。但是这个例子中的平均复杂度的计算其实并不需要这么复杂,不需要引入概率论的知识。我们先来对比一下这个 insert() 例子和之前的 find() 例子,你就会发现两者有很大差别。

首先,find() 函数在极端情况下,复杂度才为 O(1)。但 insert() 在大部分情况下,时间复杂度都为 O(1)。只有个别情况下,复杂度才比较高,为 O(n)。这是 insert()第一个区别于 find() 的地方。

我们再来看第二个不同的地方。对于 insert() 函数来说,O(1) 时间复杂度的插入和 O(n) 时间复杂度的插入,出现的频率是非常有规律的,而且有一定的前后时序关系,一般都是一个 O(n) 插入之后,紧跟着 n-1 个 O(1) 的插入操作,循环往复。

所以,针对这样一种特殊场景的复杂度分析,我们并不需要像之前讲平均复杂度分析方法那样,找出所有的输入情况及相应的发生概率,然后再计算加权平均值。针对这种特殊的场景,我们引入了一种更加简单的分析方法:摊还分析法,通过摊还分析得到的时间复杂度我们起了一个名字,叫均摊时间复杂度。

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

均摊时间复杂度和摊还分析应用场景比较特殊,所以我们并不会经常用到。为了方便你理解、记忆,我这里简单总结一下它们的应用场景。如果你遇到了,知道是怎么回事儿就行了。

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

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

总结

今天我们学习了几个复杂度分析相关的概念,分别有:最好情况时间复杂度、最坏情况时间复杂度、平均情况时间复杂度、均摊时间复杂度。之所以引入这几个复杂度概念,是因为,同一段代码,在不同输入的情况下,复杂度量级有可能是不一样的。

在引入这几个概念之后,我们可以更加全面地表示一段代码的执行效率。而且,这几个概念理解起来都不难。最好、最坏情况下的时间复杂度分析起来比较简单,但平均、均摊两个复杂度分析相对比较复杂。

一、数组

说起数组,我想你肯定不会陌生,甚至还会自信的说,它很简单。

的确,在每一种编程语言中,都会有数组这种数据类型。不过,它不仅仅是一种编程语言的类型,还是一种最基础的数据结构。

尽管数组看起来非常基础、简单,但是绝大多人并没有理解这个基础结构的精髓。

在大部分编程语言中,数组都是从 0 开始编写的,但你是否下意识地想过,为什么数组要从 0 开始,而不是 1 开始?从 1 开始不是更符合人类的思维习惯?你可以带着这个问题学习接下来的内容。

如何实现随机访问

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

ECMAScript 中定义的数组可以存储任意类型数据。

这里有几个关键词,理解这几个关键词,就可以彻底掌握数组的概念。

线性表(Linear List)

顾名思义,线性表就是数据排成像一条线一样的结构。每个线性表上的数据最多只有前和后两个方向。

其实除了数组,链表、队列、栈等也是线性表结构。

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

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

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

那么,数组是如何根据下标随机访问数组元素的?

我们拿一个长度为 10 的数组 const arr = new Array(10); 来举例。 当你创建数组时,计算机给数组分配了一块连续内存空间 1000~1039,其中,内存的首地址为 base_address = 1000。

我们知道,计算机会给每个内存单元分配一个地址,计算机通过地址来访问来访问内存中的数据。

当计算机需要随机访问数据中的某个元素是时,它会首先通过下面的寻址公式,计算出该元素的内存地址:

js
a[i]_address = base_address + i * data_type_size

其中 data_type_size 表示数组中每个元素的大小。

数组和链表的区别

关于数据和链表区别,经常有人说 ”链表适合插入、删除、时间复杂度 O(1)“,数组适合查找,查找时间复杂度为 O(1)。

实际上这种表述是不准确的。数组的确适合查找操作,但是查找的时间复杂度并不为 O(1)。

即便是排好序的数组,用二分查找,时间复杂也是 $O(log^n)$ 。所以正确的表述应该是,数据支持随机访问,根据下标随机访问的时间复杂度为 O(1)。

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

前面提到过,数组为了保持内存数据的连续性,会导致插入、删除这两个操作比较低效。

现在就来详细说一下,究竟为什么会导致低效?有哪些改进方法?

插入操作

假如数组的长度为 n,现在,如果我们需要将一个数据插入到数组中的第 k 个位置。 这时,我们需要将 k~n 这部分的元素顺序地向后以移动一位。那插入的时间复杂度是多少?

如果是在数组的末尾插入元素,那就不需要移动数据,这时的时间复杂度是 O(1)。 如果在数组中的开头插入数据,那后面所有的数据都要移动,所以最坏时间复杂度是 O(n)。 因为我们在每个位置插入元素的概率是一样的,所以平均情况时间复杂度为 (1 + 2+ 3 + ... n) / n = O(n)。

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

为了更好理解,下面来举个例子。

js
const arr = new Array(10);

arr.push('a');
arr.push('b');
arr.push('c');
arr.push('d');
arr.push('e');

我们需要将元素插入到第 3 个位置。这时只需要将 c 放入 a[5],将 a[2] 赋值给 x 即可。

最后数组中元素如下:a, b, x, d, e, c。

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

删除操作

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

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

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

继续来看例子。比如数组中存储了 8 个元素:a,b,c,d,e,f,g,h。现在,我们需要依次删除 a,b,c 三个元素。

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

如果你了解过 JVM,你会发现,这其实就是 JVM 标记清除垃圾回收算法的核心思想。

数据结构和算法的魅力就在于此,很多时候我们并不是要死记硬背某个数据结构或算法,而是学习它背后的思想和处理技巧,这些东西才是最有价值的。如果你足够细心,应该会发现不管是在软件开发还是架构设计中,总能找到某些算法和数据结构的影子。

数组的越界问题

了解了数组的基本操作之后,可以说下数据访问越界的问题。

数组越界问题在ECMAScript 中定义的数组中体现并不明显。因为其存在静默失败的特性。

越界访问只会返回 undefiend,但是数组也存在存储范围,其范围是0 到 2^32 - 1。

js
let arr = [];

arr[Math.pow(2, 32) - 2] = 10;

arr.push(12);

console.log(arr); // Invalid array length

但其实,在其他编程语言中,数组访问越界往往会产生严重的问题。比如下面这个 C 语言代码案例。

js
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 world“,而是会无限打印 ”hello world“。

原因数组大小为 3,而上述代码因为书写错误,当 i = 3 时,数组 a[3] 访问越界。

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

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

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

不过并非所有的语言都像 C 一样,把数组越界检查的工作丢给程序员来做,向 java 本身存在越界检查,js 则会静默失败处理。

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

运行上述 java 代码,会抛出 java.lang.ArrayIndexOutOfBoundsException。

容器能否替代数组?

针对数组类型,很多语言都提供了容器类。比如 Java 中的 ArrayList 、C++ STL 中的 vector。

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

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

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

如果使用 ArrayList,我们就完全不需要关心底层的扩容逻辑,ArrayList 已经帮我们实现好了。每次存储空间不够的时候,它都会将空间自动扩容为 1.5 倍左右。不过,扩容操作涉及内存申请和数据搬移,是比较耗时的。所以,如果事先能确定需要存储的数据大小,最好在创建 ArrayList 的时候事先指定数据大小。

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

js
ArrayList<User> users = new ArrayList(10000);

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

作为 java 这种高级语言来说,是不是数组就没有价值了?相信你也知道答案,当然不是,任何数据结构都有其存在的意义。

  • Java ArrayList 无法存储基本类型,比如 int、long,需要封装为 Integer、Long 类,而 Autoboxing、Unboxing 则有一定的性能损耗,所以如果特别关注性能,或者希望使用基本类型,就可以选用数组;
  • 如果数据大小事先已知,并且对数据的操作非常简单,不适用 ArrayList 提供的大部分方法,也可以直接使用数组;
  • 当表示多维数组时,用数组表示会比较直观。比如 Object [][] array; 。使用容器则需要这样定义 ArrayList<ArrayList<object>>

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

总结

今天主要学习了数组。数组是最基础、最简单的数据结构了。数组用一块连续的内存空间,来存储相同类型的一组数据,最大的特点就是支持随机访问,但插入、删除操作也因此变得比较低效,平均情况复杂度为 O(n)。

在平时的业务中,我们可以直接使用编程语言提供的容器类,但是如果特别底层的开发,直接使用数组比较合适。

技术拓展

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

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

js
a[k]_address = base_address + k * type_size

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

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

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

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

不过,上面说的都算不上压倒性的证明,最主要的原因可能还是历史原因。

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

V8 中的垃圾回收算法

v8 引擎对于栈和堆中的垃圾回收有不同的处理措施。

对于栈来说,使用 ESP 的指针向下移动来销毁该函数保存在栈中的执行上下文。

对于堆来说,就需要使用 JavaScript 的垃圾回收器。

V8 中会把堆分为老生代和新生代两个区域,新生代存放的是生存时间短的对象,老生代存放的是生存时间比较长的对象。

新生区通常只支持 1~8M 的容量,老生区支持的容量大很多。

对于新生区和老生区分别使用两个不同的垃圾回收器:

  • 副垃圾回收器,主要负责新生代的垃圾回收;
  • 主垃圾回收器,主要负责老生代的垃圾回收;

副垃圾回收器,使用 Scavenge 算法进行处理。

主垃圾回收器,采用标记-清除(Mark-Sweep)的算法进行垃圾回收。还有另一种算法标记-整理(Mark-Compact)算法。

二维数组的内存寻址公式

对于 m * n 的数组,a[i][j](i < m, j < n) 的寻址公式:

js
address = base_address + (i * n + j) * type_size

另外,对于数组访问越界造成无限循环,应该和编译器有关,对于不同的编译器,在内存分配时,会按照内存地址递增或者递减的方式进行分配。如果是内存地址递减的方式,就会造成无限循环。

二、链表

学习链表之前,先来讨论一个经典的链表应用场景,那就是 LRU 缓存淘汰算法。

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

缓存的大小有限,当缓存被用满时,哪些数据应该被清理出去,哪些数据应该保存,这时就需要缓存淘汰策略来决定。

常见的缓存淘汰策略有三种:

  • 先进先出策略 FIFO(First In,First Out);
  • 最少使用策略 LFU(Least Frequently Used);
  • 最近最少使用策略 LRU(Least Recently Used);

链表结构

相对于数组,链表是一种稍微复杂的数据结构。

我们可以先从底层的存储结构来看。为了直观对比,先看下面这张图。

从上图可以看到,数组需要一块连续的内存空间来存储,对内存的要求比较高。链表并不需要一块连续的内存空间,它通过 “指针” 将一组零散的内存块串联起来使用。

如果我们申请一个 100MB 大小的数组,当内存中没有联系的、足够大的存储空间时,即便内存的剩余总可用空间大于 100 MB,仍然会申请失败。链表则不会这样,所以当我们申请的是 100 MB 大小的链表,不会考虑是否存在连续的空间,不会存在申请失败的问题。

链表的结构有很多,今天主要介绍三种常见的链表结构,它们分别是:单链表、双向链表、循环链表。

单链表

上面刚刚讲到,链表通过指针将一组零散的内存块串联在一起。其中,我们把内存块称为链表的 “结点”。

为了将所有的结点串起来,每个链表的结点除了存储数据之外,还需要记录链上的下一个结点的地址。我们把这个记录下个结点地址的指针叫做后继指针 next。

在单链表中,有两个结点是比较特殊的,分别是第一个结点和最后一个结点。

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

和数组相同,链表也支持数据的查找、插入和删除操作。

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

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

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

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

循环链表

循环链表是一种特殊的单链表。循环链表也很简单,它跟单链表唯一的区别就在于尾结点。

单链表的尾结点指针指向空地址,空地址代表是最后的结点。 循环链表的尾结点指针指向链表的头节点,它像一个环一样首尾相连,所以叫做 ”循环“ 链表。

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

双向链表

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

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

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

双向循环链表

上面已经说了循环链表和双向链表,如果把这两种链表整合在一起,就是双向循环链表。

双向链表为什么比单链表高效

你可能会问,刚才说到单链表的插入、删除操作的时间复杂度已经是 O(1) 了,双向链表为什么比单链表更加高效?

先来看删除操作。

实际软件开发中,链表删除一个数据通常存在两种情况:

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

对于第一种情况,不管是单链表还是双向链表,为了查找到值等于给定值的结点,都需要从头结点一个一个一次遍历对比,直到找到值等于给定值的结点,然后将其删除。尽管单纯的删除操作时间复杂度是 O(1),但遍历查找的时间是主要的耗时点,对应的时间复杂度为 O(n)。根据时间复杂度分析中的加法法则,删除值等于给定值的结点对应的链表操作的时间复杂度为 O(n)。

对于第二种情况,我们已经找到要删除的结点,但是删除某个结点 q 需要知道其前驱结点,但是单链表并不支持直接获取前驱结点,所以,为了找到前驱结点,还是需要从头结点开始遍历链表,直到找到 p.next = q,说明 p 是 q 的前驱结点。但是对于双向链表来说,这种情况就比较有优势了。因为双向链表中的结点已经保存了前驱结点的指针,不需要想单链表那样遍历。所以,针对第二种情况,单链表删除操作需要 O(n) 的时间复杂度,而双向链表只需要 O(1) 的时间复杂度就搞定了。

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

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

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

空间换时间的设计思想

当内存空间充足的时候,如果我们更加追求代码的执行速度,我们就可以选择空间复杂度相对较高,但时间复杂度相对很低的算法或者数据结构。相反,如果内存比较紧缺,比如代码跑在手机或者单片机上,这个时候,就要反过来用时间或空间的设计思路。

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

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

链表 VS 数据

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

数组链表
插入删除O(n)O(1)
随机访问O(1)O(n)

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

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

CPU 在内存中读取数据时,会把读取到的数据加载到 CPU 的缓存中。CPU 每次从内存中读取数据会读取一个数据块并保存到 CPU 缓存中,然后下次访问内存数据的时候会优先从 CPU 缓存开始查找,如果找不到才从内存中取。这样就实现了比内存访问速度更快的机制,也就是 CPU 缓存存在的意义。对于数组来说,存储空间是连续的,所以在加载某个下标时可以把后面的几个元素也加载到 CPU 缓存中,这样执行速度就会快于存储空间不连续的链表存储。

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

你可能会反驳,Java 中的 ArrayLIst 容器,也可以支持动态扩容啊?

如果我们往支持动态扩容的数组中插入一个数据时,如果数组中没有空闲空间,就会申请一个更大的空间,将数据拷贝过去,而数据拷贝的操作是非常耗时的。举个比较极端的例子,如果我们用 ArrayList 存储了 1GB 大小的数据,这个时候已经没有空闲空间,当我们再插入数据的时候,ArrayList 会申请一个 1.5 GB 大小的存储空间,并且把原来 1 GB 的数据拷贝到新申请的空间上。听起来是不是就很耗时?

除此之外,如果你的代码对内存的使用非常苛刻,那数组就更适合你。因为链表中的每个结点都需要消耗额外的存储空间去存储一份指向下一个结点的指针,所以内存消耗也会翻倍。而且,对链表进行频繁的插入、删除操作,还会导致频繁的内存申请和释放,容易造成内存碎片,如果是 Java 语言,有可能导致频繁的 GC(Garbage Collection,垃圾回收)。

所以,在我们实际的开发中,针对不同类型的项目,跟根据具体情况,权衡究竟是选择数组还是链表。

链表代码编写技巧

理解指针或引用含义

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

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

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

我们可以结合链表代码的编写过程,慢慢分析。

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

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

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

警惕指针丢失和内存泄漏

不知道你是否存在这样的感觉,写链表代码的时候,指针指来指去,一会儿就不知道指到哪里了。 所以,我们在写链表代码的时候,一定注意不要弄丢了指针。

指针往往是怎么弄丢的?这里举一个例子。

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

js
p.next = x;
x.next = p.next;

初学者经常会在儿犯错。p.next 指针在完成第一步操作之后,已经不再指向结点 b,而是指向结点 x。第二行代码相当于将 x 赋值给 x.next,即自己指向自己。因此,整个链表就断为两半,从结点 b 往后的所有结点都无法访问到了。

对于有些语言来说,例如 C 语言,内存管理是程序员负责的,如果没有手动释放结点对应的内存空间,就会产生内存泄漏。 所以,我们插入结点时,一定要注意操作顺序,要先将结点 x 的 next 指针指向结点 b,再把结点 a 和 next 指针指向结点 x,这样才不会丢失指针,导致内存泄漏。

js
x.next = p.next;
p.next = x;

同理,删除链表结点时,也一定记得手动释放内存空间,否则,也会出现内存泄漏的问题。

利用哨兵简化实现难度

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

js
new_node.next = p.next;
p.next = new_node;

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

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

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

js
p.next = p.next.next;

但是,如果我们要删除链表中的最后一个节点,前面的删除代码就不能工作。跟插入结点类似,我们也需要对这种情况单独处理。

js
if (head.next === null) {
  head = null;
}

综上所述,针对链表的插入、删除操作,需要对插入第一个结点和删除最后一个结点的情况进行特殊处理。这样代码实现起来就会很繁琐,不简洁,而且也容易因为考虑不全儿出错。那么如何解决这个问题?

那就要引出哨兵这个概念。哨兵,解决的是国家之前的边界问题。在链表中也是如此,这里说的哨兵也是解决 ”边界问题“ 的,不直接参与业务逻辑。

如果我们引入哨兵结点,在任何时候,不管链表是否为空,head 指针都会一直指向这个哨兵结点。我们把这种有哨兵结点的链表叫带头链表。相反,没有哨兵结点的链表叫做不带头链表。

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

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

为了便于理解,再举一个非常简单的例子。

js
const str = '12345',
      len = str.length;

console.log(find(str, len, 3));
js
function find (str, n, key) {
  if (str === null || n <= 0) {
    return -1;
  }

  let i = 0;

  while (i < n) {
    if (str[i] == key) {
      return i;
    }
    i++;
  }

  return -1;
}
js
function find (str, n, key) {
  if (str === null || n <= 0) {
    return -1;
  }

  if (str[n - 1] == key) {
    return n - 1;
  }

  const temp = str[n - 1];

  str[n - 1] = key;

  let i = 0;

  while (str[i] != key) {
    i++;
  }

  str[n - 1] = temp;

  if (i == n - 1) {
    return -1;
  } else {
    return i;
  }
} 

对比两段代码,在字符串 str 很长的时候,比如几万、几十万,你觉得哪段代码运行得更快?

答案是代码二,因为两段代码中执行次数最后就是 while 循环那一部分。第二段代码中,我们通过一个哨兵 a[n - 1] = key,成功省掉了一个比较语句 i < n,不要小看这一条语句,当累积执行万次、十几万次时,积累的时间就很明显了。

当然,这只是为了举例说明哨兵的作用,写代码的时候千万不要写第二段那样的代码,因为可读性太差了。大部情况下,我们并不需要如此追求极致的性能。

留意边界条件处理

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

经常用来检查链表代码是否正确的边界条件如下:

  • 链表为空的情况
  • 链表只包含一个结点的情况
  • 链表只包含两个结点的情况
  • 代码逻辑处理头节点和尾结点的情况

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

当然,边界条件不止我列举的那些。针对不同的场景,还有特定的边界条件,这个需要你自己去思考。

实际上,不光是写链表代码,在写任何代码时,一定要多想想,可能会遇到哪些边界情况或者异常情况,这样写出来的代码才够健壮、

举例画图,辅助思考

对于稍微复杂的链表操作,比如单链表反转,指针一会儿指这,一会儿指那,一会儿就被绕晕了。 所以这个时候就可以考虑使用举例法和画图法。

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

多写多练,没有捷径

如果你已经理解并掌握前面所讲的方法,但是手写链表代码还是会出各种各样的错误,也不要着急。 把常见的链表操作都自己多写几遍,出问题就一点一点调试,熟能生巧。

常见的链表操作

206:单链表反转

141:链表中环的检测

21:两个有序的链表合并

19:删除链表倒数第 n 个结点

876:求链表的中间节点

总结

写链表代码是最考验逻辑思维能力的。因为,链表代码到处都是指针的操作、边界条件的处理,稍有不慎就容易产生 Bug。 链表代码写的好坏,可以看出一个人写代码是否够细心,考虑问题是否全面,思维是否缜密。所以这也是很多面试官喜欢让人手写链表代码的原因。

技术拓展

JS 实现单链表

js
function Node (val) {
  this.val = val;
  this.next = null;
}

function LinkedList () {
  this.head = new Node('head');
}

LinkedList.prototype.find = function (val) {
  let currNode = this.head;
  
  while (currNode.val != val) {
    currNode = currNode.next;
  }

  return currNode;
}

LinkedList.prototype.insert = function (newVal, val) {
  const newNode = new Node(newVal);
  const current = this.find(val);
  newNode.next = current.next;
  current.next = newNode;
}

LinkedList.prototype.print = function () {
  let currNode = this.head;

  while (currNode.next != null) {
    console.log(currNode.next.val);
    currNode = currNode.next;
  }
}

LinkedList.prototype.findPrevious = function (val) {
  let currNode = this.head;

  while (currNode.next != null && currNode.next.val != val) {
    currNode = currNode.next;
  }

  return currNode;
}


LinkedList.prototype.remove = function (val) {
  const prevNode = this.findPrevious(val);

  if (prevNode.next != null) {
    prevNode.next = prevNode.next.next;
  }
}

module.exports = {
  LinkedList
}

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

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

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

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

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

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

下面,我们来分析一下缓存访问的时间复杂度是多少。

不管缓存有没有满,我们都需要遍历一遍链表,所以这种基于链表的实现思路,缓存访问的时间复杂度为 O(n)。

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

vue 的 keep-alive 内置组件查找缓存也使用了 LRU 缓存算法,是基于数组和对象实现的。

单链表实现的字符串,判断是不是回文串

可以使用快慢指针法。快指针每步两格走,到达链表末端时,慢指针刚到到达中心。

慢指针到达中心前,将走过的节点反向,在中心点开辟一个新的指针向回走,慢指针继续继续向前,慢指针扫完整个链表时,就可以判断这是回文串,否则就提前退出。总的时间复杂度为 O(n),空间复杂度是 O(1)。

js
function Node (val) {
  this.val = val;
  this.next = null;
}

function LinkedList (values) {
  if (values) {
    this.head = new Node(values.shift());
    
    currNode = this.head;

    values.forEach(val => {
      currNode.next = new Node(val);
      currNode = currNode.next;
    });
  }
}
js
const list = new LinkedList(['a', 'b', 'c', 'c', 'b', 'a']);

const head = list.head;

console.log(isPalindrome(head));

function isPalindrome (head) {
  let slow = head,
      fast = head,
      prev = null;

  while (fast != null && fast.next != null) {
    fast = fast.next.next;

    const next = slow.next;

    slow.next = prev;
    prev = slow;
    slow = next;
  }

  if (fast != null) {
    slow = slow.next;
  }

  while (slow != null) {
    if (slow.val != prev.val) {
      return false;
    }

    slow = slow.next;
    prev = prev.next;
  }

  return true;
}

哨兵是否还有其他应用场景

。。。

三、栈

浏览器的前进、后退功能,我想你肯定很熟悉。

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

假设你是 Chrome 浏览器的开发工程师,你会如何实现这个功能?其实这就需要用到我们今天要将的 ”栈“ 这种数据结构。

栈是什么

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

栈是一种 ”操作受限“ 的线性表,只允许在一端插入和删除数据。从功能上说,数组和链表确实可以替代栈,但是特定的数据结构是对特定场景的抽象,而且,数组或链表暴露了太多的操作接口,操作上的确灵活自由,但使用时就比较不可控,自然也就更容易出错。

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

如何实现一个栈

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

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

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

js
class ArrayStack {
  items;
  count;
  n;

  constructor (n) {
    this.items = new Array(n);
    this.n = n;
    this.count = 0;
  }

  push (item) {
    if (this.count == this.n) return false;

    this.items[this.count] = item;
    this.count++;

    return true;
  }

  pop () {
    if (this.count == 0) return null;

    const tmp = this.items[this.count - 1];
    this.count--;

    return tmp;
  }
}

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

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

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

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

支持动态扩容的顺序栈

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

在之前的数组那一节,已经说过实现动态扩容的数组思路。当数组空间不够时,我们就重新申请一块更大的内存,将原来数组中数据统统拷贝过去。这样就实现了一个支持动态扩容的数组。

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

实际上,支持动态扩容的栈,在平时开发中并不常用到。说这块的目的,主要是希望练习一下前面讲的复杂度分析方法。 你不用死记硬背入栈、出栈的时间复杂度,你需要掌握的是分析方法。能够自己分析才算是真正掌握。

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

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

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

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

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

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

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

栈在函数调用中的应用

前面讲的都偏理论一些,现在来看下,栈在软件工程中的实际应用。栈作为一个比较基础的数据结构,应用场景还是蛮多的。其中,一个比较经典的应用场景是函数调用栈。

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

栈在表达式取值中的应用

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

为了方便解释,我们可以将算数表达式简化为只包含加减乘除四则运算,比如 34 + 13 * 9 + 44 - 12/3 。对于这个四则运算,人脑可以很快求解出答案,但是对于计算机来说,理解这个表达式本身就是一个很难的事。如果换做你,让你实现这样一个表达式求值的功能,你会怎么做?

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

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

下面是 3 + 5 * 8 - 6 这个表达式的计算过程中的图,你可以结合图和思考上述过程。

这样用两个栈来解决的思路是不是非常巧妙?你有没有想到?

栈在括号匹配中的应用

除了用栈来实现表达式求值,我们还可以借助栈来检查表达式中的括号是否匹配。

假设表达式中只包含三种括号,()、[]、{},并且它们可以任意嵌套。如果给你一个包含三种符号的表达式字符串,应该如何检查它是否合法?

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

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

实现浏览器的前进、后退功能

实现浏览器的前进、后退功能,用两个栈就可以非常完美解决。

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

图示中左边为栈 X,右边为栈 Y。

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

当你通过浏览器的回退按钮,从页面 c 后退到页面 a 之后,我们就依次把 c 和 b 从栈 X 中弹出,并且依次放入到栈 Y。

这个时候你又想看到页面 b,于是你又点击前进按钮回到 b 页面,我们就把 b 再从栈 Y 中出栈,放入栈 X 中。

这个时候,你通过 b 又跳转到新的页面 d 了,页面 c 就无法在通过前进和、后退按钮重复查看了,所以需要清空栈 Y。

总结

栈是一种操作受限的数据结构,只支持入栈和出栈操作。后进先出是它最大的特点。栈既可以通过数组实现,也可以通过链表实现。不管是基于数组还是链表,入栈、出栈的时间复杂度都为 O(1)。除此之外,还讲了一种支持动态扩容的顺序栈,你需要掌握它的均摊时间复杂度分析方法。

技术拓展

为什么函数调用栈要有 ”栈“ 来保存临时变量

其实,我们不一定要用栈保存临时变量,只不过如果这个函数调用符合后进先出的特性,用栈中这种数据结构实现,是顺理成章的选择。

从调用函数进入被调用函数,对于数据来说,变化的是作用域。只要能保证每进入一个新的函数,都是一个新的作用域就可以。要实现这个需求,用栈就非常方便。进入被调用函数的时候,分配一段栈空间给这个函数的变量,在函数结束的时候,将栈顶复位,正好回到调用函数的作用域内。

JVM 内存管理中的 ”堆栈“

。。。

四、队列

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

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

实际上,这些问题并不复杂,其底层的数据结构就是我们今天要学的内容,队列(queue)。

如何理解队列

队列的概念很好理解。你可以将其想象成排队买票,先来的先买,后来的人只能站末尾,不允许插队。先进先出,就是典型的 ”队列“。

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

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

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

顺序队列和链式队列

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

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

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

js
class ArrayQueue {
  items = null;
  n = 0;
  head = 0;
  tail = 0;

  constructor (capacity) {
    this.items = new Array(capacity);
    this.n = capacity;
  }

  enqueue (item) {
    if (this.tail === this.n) return false;

    this.items[this.tail] = item;
    this.tail++;

    return true;
  }

  dequeue () {
    if (this.head === this.tail) return null;

    const ret = this.items[this.head];
    this.head++;

    return ret;
  }
}

比起栈的数组实现,队列的数组实现稍微有点复杂。

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

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

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

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

在数组那一节,我们也遇到类似的问题,数组的删除操作会导致数组中的数据不连续。当时的方案是数据搬移。

但是每次出队操作都相当于删除数组下标为 0 的数据,要搬移整个队列中的数组,这样出队操作的时间复杂度就会从原来的 O(1) 变成 O(n)。能不能针对这个现象优化下?

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

js
class ArrayQueue {
  items = null;
  n = 0;
  head = 0;
  tail = 0;

  constructor (capacity) {
    this.items = new Array(capacity);
    this.n = capacity;
  }

  enqueue (item) {
    if (this.tail === this.n) {
      if (this.head === 0) return false;

      for (let i = this.head; i < this.tail; i++) {
        this.items[ i - this.head] = this.items[i];
      }

      this.tail -= this.head;
      this.head = 0;
    };

    this.items[this.tail] = item;
    this.tail++;

    return true;
  }

  dequeue () {
    if (this.head === this.tail) return null;

    const ret = this.items[this.head];
    this.head++;

    return ret;
  }
}

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

这种实现思路中,出队操作的时间复杂度仍然是 O(1),但入队操作的时间复杂度还是 O(1)。接下来,看下基于链表的队列实现。

基于链表的实现,同样需要两个指针:head 指针和 tail 指针。它们分别指向链表的第一个结点和最后一个结点。 如图所示:入队时,tail.next = new_node、tail = tail.next ;入队时,`head = head.next。

循环队列

我们用数组实现队列时,在 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。就算多画几张队满的图,你就会发现,当队列满时,(tail + 1) % n = head。

在一般情况下,我们可以看出来,当队列满时,tail + 1 = head。但是,有个特殊情况,当tail = n - 1,而 head = 0时,这时候,tail + 1 = n,而 head = 0,所以使用 (tail + 1) % n的值,通过对 n 取余,才能得到 head 的真实值。而且,tail + 1 最大的情况就是 n ,不会大于 n,这样,tail+1 除了最大情况,不然怎么余 n 都是 tail + 1 本身,也就是 head。所以,正确的队满判断公式就是:(tail + 1)% n = head。

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

js
class CircularQueue {
  items = null;
  n = 0;
  head = 0;
  tail = 0;

  constructor (capacity) {
    this.items = new Array(capacity);
    this.n = capacity;
  }

  enqueue (item) {
    if ((this.tail + 1) % this.n === this.head) return false;

    this.items[this.tail] = item;
    this.tail = (this.tail + 1) % this.n;

    return true;
  }

  dequeue () {
    if (this.head === this.tail) return null;

    const ret = this.items[this.head];
    this.head = (this.head + 1) % this.n;

    return ret;
  }
}

循环队列和并发队列

前面说的内容理论比较多,看起来很难与实际的项目开发扯上关系。确实,队列这种数据结构很基础,平时的业务开发也不大可能从零实现一个队列,甚至都不会直接用到。而有一些特殊特性的队列应用比较广泛,比如阻塞队列和并发队列。

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

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

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

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

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

总结

队列的最大特点就是先进先出,主要的两个操作是入队和出队。和栈一样,它既可以用数组来实现,也可以用链表实现。用数组实现的叫顺序队列,用链表实现的叫链式队列。数组实现队列时,会有数据搬移操作,要想解决此问题,我们可以基于数组实现循环队列。

循环队列是重点。要想写出没有 bug 的循环队列实现代码,关键要确定好队空和队满的判断条件,具体的代码要自己能写出来。

除此之外,还分享了几种高级队列结构,阻塞队列、并发队列(底层还是队列,在之上附加了很多其他功能)。 阻塞队列就是入队、出队操作可以阻塞,并发队列就是队列的操作多线程安全。

技术拓展

线程池无空闲空间问题

线程池没有空闲空间时,新的任务请求线程资源时,线程池该如何处理?各种处理策略是如何实现的?

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

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

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

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

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

队列排队请求的应用场景

  • 分布式锁中的消息队列 kafka
  • sockets 网络连接队列
  • 数据库连接队列

如何实现无锁并发队列

CAS 实现无锁队列。

入队前,获取 tail 位置。入队时比较 tail 是否发生变化,如果否,允许入队;反之,入队失败。

出队则获取 head 位置,进行 CAS。

CAS(Compare and Swap),比较再交换。

CAS 是一种无锁算法,CAS 有 3 个操作数,内存值 V,旧的预期值 A,要修改的新值 B。当且仅当预期值 A 和 内存值 V 相同时,将内存值 V 修改为 B,否则什么都不做。

五、递归

邀请好友返红包这个功能想必你应该并不陌生吧?现在很多 App 都有这个功能。这个功能中,用户 A 邀请 用户 B 来注册,用户 B 又邀请了用户 C 来注册。我们可以说,用户 C 的 “最终邀请人” 是用户 A,用户 B 的 “最终邀请人” 也是用户 A,而用户 A 没有 “最终邀请人”。

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

actor_idinvite_id
BA
CB

基于这个背景,我们可以提出一个问题,给定一个用户 ID,如何查找这个用户的 “最终邀请人”。带着这个问题,我们来学习今天的内容,递归(Recursion)。

递归是什么

递归是一种应用非常广泛的算法(编程技巧)。很多数据结构与算法的编程实现都会用到递归,比如 DFS 深度优先搜索、前中后序二叉树遍历等等。所以,搞懂递归非常重要,否则,后面复杂一些的数据结构和算法学起来就会比较吃力。

递归求解分为两个过程,去的过程叫 “递”,回来的过程叫 “归”。基本上,所有的递归问题都可以用递归公式来表示。

递归需要满足三个条件

递归究竟可以解决什么样的问题?通常情况下,只要同时满足以下三个条件,就可以用递归来解决。

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

何为子问题?子问题就是数据规模更小的问题。

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

存在递归终止条件

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

如何编写递归代码

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

首先你要记住这个理论。然后可以根据下面的例子,帮助理解。

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

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

js
f(n) = f(n - 1) + f(n - 2);

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

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

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

我们把递归终止条件和刚刚得到的递归公式放到一起就是这样的:

js
f(1) = 1;
f(2) = 2;
f(3) = f(n - 1) + f(n - 2);

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

js
function f (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 两层之间的关系即可,不需要一层一层往下思考与子问题之间的关系。屏蔽掉细节,理解起来就简单多了。

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

警惕堆栈溢出

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

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

那么,如何避免堆栈溢出?

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

警惕重复计算

使用递归时还会出现重复计算的问题。下图是刚才递归代码例子的递归过程。

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

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

js
const cache = new Map();

function f (n) {
  if (n === 1) return 1;
  if (n === 2) return 2;
  
  if (cache.has(n)) {
    return cache.get(n);
  }

  const ret = f(n - 1) + f(n - 2);
  cache.set(n, ret);
  
  return ret;
}

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

在时间效率上,递归代码里多了很多函数调用,当这些函数调用的数量较大时,就会积聚成一个可观的时间成本。在空间复杂度上,因为递归调用一次就会在内存栈中保存一次现场数据,所以在分析递归代码空间复杂度时,需要额外考虑这部分的开销。

如何把递归代码改写为非递归代码

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

我们是否可以把递归代码改为非递归代码?刚才的例子就可以改为非递归代码。

js
function f (n) {
  if (n === 1) return 1;
  if (n === 2) return 2;
  
  let ret = 0,
      pre = 2,
      prepre = 1;
  
  for (let i = 3; i <=n ; i++) {
    ret = pre + prepre;
    prepre = pre;
    pre = ret;
  }
  
  return ret;
}

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

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

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

总结

递归是一种非常高效、简洁的编程技巧。只要满足 “三个条件” 的问题就可以通过递归代码来解决。

不过递归代码也比较难写、难理解。编写递归代码的关键就是不要把自己绕进去,正确姿势是写出递归公式,找出终止条件,然后再翻译成代码。

递归代码虽然简单高效,但是,递归代码也有很多弊端。比如,堆栈溢出、重复计算、函数调用耗时多、空间复杂度高等,所以,在编写递归代码的时候,一定要控制好这些副作用。

技术拓展

最终邀请人

寻找最终邀请人,大概代码如下:

js
function findRootInviteId (actorId) {
  const inviteId = select invite_id from [table] where actor_id = actorId;
  if (inviteId == null) return actorId;
  return findRootInviteId(inviteId);
}

是不是非常简洁明了,三行代码就可以搞定。但是实际项目中,上面的代码并不能工作。

第一,如果递归很深,可能存在堆栈溢出的问题。

第二,如果数据库里存在脏数据,还需要处理由此产生的无限递归问题。

第一个问题可以用限制递归深度来解决,第二个也可以用限制递归深度来解决。不过,还有一个更高级的处理方法,就是自动检测 “环” 的存在(双指针解法)。除此之外还可以通过散列表缓存已经存在的值,如果发现重复值,表示已经存在环。

递归代码如何调试

打印日志发现,递归值。

结合条件断点进行调试。

六、排序

排序对于任何一个程序员来说,可能都不陌生。你学习的第一个算法,可能就是排序。大部分编程语言中,也都提供了排序函数。在平常的项目中,我们也经常会用到排序。排序非常重要,所以值得我们花费时间去研究。

排序算法有很多,有些可能你连名字都没有听过,比如猴子排序、睡眠排序、面条排序等。当然,这篇文章也只介绍一些经典的排序算法:冒泡排序、插入排序、选择排序、归并排序、快速排序、计数排序、基数排序、桶排序。按照时间复杂度可以把他们分为三类。

排序算法时间复杂度是否基于比较
冒泡、插入、选择$O(n^2)$
快排、归并$O(nlogn)$
桶、计数、基数$O(n)$×

带着问题去学习,是最有效的学习方法。有这样一个问题:插入排序和冒泡排序的时间复杂度相同,都是 $O(n^2)$,在实际的开发中,为什么我们更倾向于使用插入排序算法而不是冒泡排序算法?

如何分析一个排序算法?

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

排序算法的执行效率

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

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

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

为什么要区分这三种时间复杂度?

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

2. 时间复杂度的系数、常数、低阶

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

3. 比较次数和交换(或移动)次数

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

排序算法的内存消耗

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

排序算法的稳定性

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

这里通过一个例子解释一下。比如我们有一组数据 2,9,3,4,8,3,按照大小排序之后就是 2,3,3,4,8,9。

这组数据有两个 3。经过某种排序算法排序之后,如果两个 3 的前后顺序没有改变,那我们就把这种排序算法叫做稳定的排序算法;如果前端顺序发生变化,那对应的排序算法就叫做不稳定的排序算法。

你可能会问,两个 3 哪个在前,那个在后有什么关系,稳不稳定又有什么关系?为什么要注意排序算法的稳定性?

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

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

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

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

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

冒泡排序(Bubble Sort)

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

我们用一个例子来看下冒泡排序的整个过程。我们要对一组数据 4,5,6,3,2,1,从小到大进行排序。

第一次冒泡操作的详细过程如下:

js
4 5 6 3 2 1
4 5 6 3 2 1
4 5 6 3 2 1
4 5 3 6 2 1
4 5 3 2 6 1
4 5 3 2 1 6

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

冒泡次数冒泡后结果
初始状态4 5 6 3 2 1
第 1 次冒泡4 5 3 2 1 6
第 2 次冒泡4 3 2 1 5 6
第 3 次冒泡3 2 1 4 5 6
第 4 次冒泡2 1 3 4 5 6
第 5 次冒泡1 2 3 4 5 6
第 6 次冒泡1 2 3 4 5 6

实际上,上面的冒泡过程还可以优化。当某次冒泡操作已经没有数据交换时,说明已经达到完全有序,不用再继续执行后续的冒泡操作。

冒泡次数冒泡后结果是否有数据交换
初始状态3 5 4 1 2 6
第 1 次冒泡3 4 1 2 5 6
第 2 次冒泡3 1 2 4 5 6
第 3 次冒泡1 2 3 4 5 6
第 4 次冒泡1 2 3 4 5 6无,结束排序操作

冒泡排序算法的原理比较容易理解,具体代码如下:

js
function bubbleSort (arr, n) {
  if (n <= 1) return;

  for (let i = 0; i < n; i++) {
    let flag = false;

    for (let j = 0; j < n - i - 1; j++) {
      if (arr[j] > arr[j + 1]) {
        const tmp = arr[j];

        arr[j] = arr[j + 1];
        arr[j + 1] = tmp;

        flag = true;
      }
    }

    if (!flag) break;
  }
}

结合刚才分析排序算法的三个方面,如下分析:

1. 冒泡排序是原地排序算法。

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

2. 冒泡排序是稳定排序算法。

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

3. 冒泡排序的时间复杂度。

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

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

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

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

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

比如 2, 4, 3, 1, 5, 6 这组数据的有序度为 11,因为其有序元素对有 11 个,分别是:

js
(2, 4)、(2, 3)、(2, 5)、(2, 6)
(4, 5)、(4, 6)、(3, 5)、(3, 6)
(1, 5)、(1, 6)、(5, 6)

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

等差数列公式 :首项加末项的和乘项数除以二 ,这里的项数是n,首项是n-1,末项是0,所以:n(n+1+0)/2

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

js
有序元素对: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。

冒泡次数冒泡后结果有序度
初始状态4 5 6 3 2 13
第 1 次冒泡4 5 3 2 1 66
第 2 次冒泡4 3 2 1 5 69
第 3 次冒泡3 2 1 4 5 612
第 4 次冒泡2 1 3 4 5 615
第 5 次冒泡1 2 3 4 5 615

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

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

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

夹挤定理,又称夹逼定理、三明治定理,是有关函数极限的定理。它指出若有两个函数在某点的极限相同,且有第三个函数的值在这两个函数之间,则第三个函数在该点的极限也相同。

插入排序(Insertion Sort)

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

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

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

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

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

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

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

为什么说移动次数等于逆序度?下面有个图,你一看你明白。首先满序度是 n * (n - 1) / 2 = 15,初始序列的有序度是 5,所以逆序度是 10。插入排序中,数据移动的个数总和也等于 10 = 3 + 3 + 4。

插入排序的原理也很简单,下面是代码实现。

js
function insertionSort (arr, n) {
  if (n <= 1) return;

  for (let i = 1; i < n; i++) {
    const tmp = arr[i];
    
    let j = i - 1;

    for (; j >= 0; j--) {
      if (arr[j] > tmp) {
        arr[j + 1] = arr[j];
      } else {
        break;
      }
    }

    arr[j + 1] = tmp;
  }
}

现在,我们再来深入分析插入排序算法。

1. 插入排序是原地排序算法。

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

2. 插入排序是稳定排序算法。

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

3. 插入排序的时间复杂度。

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

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

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

选择排序(Selection Sort)

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

选择排序代码实现:

js
function selectionSort (arr, n) {
  if (n <= 1) return;

  for (let i = 0; i < n - 1; i++) {
    let minIdx = i;

    for (let j = i + 1; j < n; j++) {
      if (arr[j] < arr[minIdx]) {
        minIdx = j;
      }
    }

    const tmp = arr[i];
    arr[i] = arr[minIdx];
    arr[minIdx] = tmp;
  }

}

同样,这里也对选择排序算法进行分析。

1. 选择排序是原地排序算法。

选择排序空间复杂度为 O(1),是一种原地排序算法。

2. 选择排序不是稳定排序算法。

选择排序每次都要找剩余未排序元素中的最小值,并和前面的元素交换位置,这样就破坏了稳定性。

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

3. 选择排序的时间复杂度。

选择排序的最好情况时间复杂度、最坏情况和平均时间复杂度都为 $O(n^2)$。

不管原来的顺序是什么,都要从无序数组中找到最小值,而最小值只能通过全部比较一次才能得到。

归并排序(Merge Sort)

前面介绍了冒泡排序、插入排序、选择排序这三种排序算法,它们的时间复杂度都是 $O(n^2)$,比较高,适合小规模数据的排序。接下来介绍两种时间复杂度为 $O(nlogn)$ 的排序算法,归并排序和快速排序。这两种排序算法适合大规模的数据排序,比之前介绍的三种排序算法更常用。

归并排序和快速排序都用到了分治思想,非常巧妙。我们可以借鉴这个思想,来解决非排序的问题,比如如何在 $O(n)$ 的时间复杂度内查找一个无序数组中的第 K 大元素?

原理

归并排序的核心思想很简单。如果要排序一个数组,我们先把数组从中间分成前后两部分,然后对前后两部分分别排序,再将排好序的两部分合并在一起,这样整个数组就有序了。

归并排序使用的是分治思想。分治,即分而治之,就是将一个大问题分解为小的问题来解决。小的问题解决了,大问题就解决了。

从刚才的描述中,你有没有感觉到,分治思想跟我们前面讲的递归思想很像。的确是这样,分治算法一般都是用递归来实现的。分治是一种解决问题的处理思想,递归是一种编程技巧,这两者并不冲突。

那么归并排序是如何使用递归代码来实现的?

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

js
// 递推公式
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 到 q 和从 q+1 到 r 这两个子数组都排好序之后,我们再将两个有序的子数组合并在一起,这样下标从 p 到 r 之间的数据就也排好序了。

有了递推公式,转换为代码就简单多了。

js
const merge = (p, r, q) => {
  let  i = p,
       j = q + 1,
       k = 0;

  const tmp = new Array(r - p + 1);

  while (i <= q && j <= r) {
    if (arr[i] <= arr[j]) {
      tmp[k++] = arr[i++];
    } else {
      tmp[k++] = arr[j++];
    }
  }

  let start = i,
      end = q;

  if (j <= r) {
    start = j;
    end = r;
  }
    
  while (start <= end) {
    tmp[k++] = arr[start++];
  }

  for (i = 0; i <= r - p; i++) {
    arr[p + i] = tmp[i];
  }
}

function mergeSort (arr, n) {
  const next = (arr, p, r) => {
    if (p >= r) return;

    const q = Math.floor(p + (r - p) / 2);

    next(arr, p, q);
    next(arr, q + 1, r);

    merge(p, r, q);
  }

  next(arr, 0, n - 1);
}

你是否还记得利用哨兵简化编程的处理技巧?代码中的 merge 合并函数如果借助哨兵,代码还会简介很多。

性能分析

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),求解问题 b、c 的时间分别为 T(b) 和 T(c),那我们就可以得到这样的递推关系式:

js
T(a) = T(b) + T(c) + k

其中 K 等于将两个子问题 b、c 的结果合并成问题 a 的结果所消耗的时间。

不仅递归求解的问题可以写成递推公式,递归代码的时间复杂度也可以写成递推公式。

套用上述公式,我们来分析一下归并排序的时间复杂度。

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

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

通过这个公式,如何求解 T(n)?我们可以进一步分解计算过程。

js
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) + kn$。

当 $T(n/2^k) = T(1)$ 时,也就是 $n/2^k = 1$,我们得到 k = log2n。我们将 k 值代入上面的公式,得到 T(n) = Cn + nlog2n。如果我们用大 O 标记法来表示的话,T(n) 就等于 O(nlogn)。所以递归排序的时间复杂度是 O(nlogn)。

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

3. 归并排序的空间复杂度。

归并排序的时间复杂度任何情况下都是 O(nlogn),看起来非常优秀。但是,归并排序并没有像快排那样,应用广泛,因为它有一个致命的 “弱点”,归并排序不是原地排序算法。

这是因为归并排序的合并函数,在合并两个有序数组为一个有序数组时,需要借助额外的存储空间。那归并排序的空间复杂度是多少?

如果我们按照分析递归时间复杂度的方法,通过递归公式进行求解,那整个归并过程需要的时间复杂度就是 O(nlogn)。不过,类似分析时间复杂度那样来分析空间复杂度,这个思路并不对。

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

快速排序(Quick Sort)

原理

快速排序算法(QuickSort),我们习惯性把它简称为 “快排”。快排利用的也是分治思想。看起来,它有点像归并排序,但是思路完全不一样。后面会介绍两者区别。现在,我们先来看下快排的核心思想。

快排的核心思想:

如果要排序数组中下标从 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,就说明所有的数据都有序了。

如果我们用递归公式将上面的过程写出来,就是下面这样:

js
// 递归公式
quick_sort(p … r) = quick_sort(p … q-1) + quick_sort(q + 1 … r)

// 终止条件
p >= r

将递归公式转换为代码,代码如下:

js
function quickSort (arr, n) {
  const next = (arr, p, r) => {
    if (p >= r) return;

    const q = partition(arr, p, r);
    
    next(arr, p, q - 1);
    next(arr, q + 1, r);
  }

  next(arr, 0, n - 1);
}

归并排序中有一个 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] 的原地完成分区操作。

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

js
const partition = (arr, p, r) => {
  const pivot = arr[r];
  let i = p;

  for (let j = p; j < r; j++) {
    if (arr[j] < pivot) {
      if (i == j) {
        i++;
      } else {
        const tmp = arr[i];
        arr[i++] = arr[j];
        arr[j] = tmp;
      }
    }
  }

  const tmp = arr[i];
  arr[i] = arr[r];
  arr[r] = tmp;

  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] 的位置。

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

至此,快速排序的原理你应该掌握了。现在,我们来看另一个问题:快排和归并用的都是分治思想,递归公式和规划代码也非常相似,那它们有什么区别那?

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

性能分析

上面在讲快排的实现原理时,已经分析过稳定性和空间复杂度。快排是一种原地、不稳定的排序算法。现在,我们主要来看快排的时间复杂度。

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

js
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)$。

上面我们介绍了两种极端情况下的时间复杂度,一个是分区极其均衡,一个是分区及其不均衡。它们分别对应快排的最好情况时间复杂度何获最坏情况时间复杂度。那快排的平均情况时间复杂度是多少?

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

js
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)、O(n^2)、O(n^2)$
插入排序$O(n)、O(n^2)、O(n^2)$
选择排序×$O(n^2)、O(n^2)、O(n^2)$
归并排序×$O(nlogn)$、$O(nlogn)$、$O(nlogn)$
快速排序$O(nlogn)$、$O(n^2)$、$O(nlogn)$

3 种时间复杂度为 $O(n^2)$ 的排序算法中,冒泡排序、选择排序,可能纯粹停留在理论层面,实际应用不多,插入排序比较有用。有些编程语言中的排序函数的实现原理会用到插入排序算法。不过 冒泡、插入、选择排序,针对小规模数据的排序的确非常高效,但是在大规模数据排序的时候,时间复杂度还是相对比较高,所以针对于大规模排序,更倾向于时间复杂度为 $O(nlogn)$ 的排序算法。

归并排序和快速排序是两种稍微复杂的排序算法,它们用的都是分治的思想,代码都通过递归来实现,过程非常相似。理解归并排序的重点是理解递推公式和 merge() 合并函数。同理,理解快排的重点也是理解递推公式,还有 partition() 分区函数。

归并排序算法是一种在任何情况下时间复杂度都比较稳定的排序算法,这也使它存在致命的缺点,即归并排序不是原地排序算法,空间复杂度比较高,是 O(n)。正因如此,它也没有快排应用广泛。

归并排序也可以原地排序,但是非常复杂,时间代价很高。

快速排序算法虽然最坏情况下的时间复杂度是 $O(n^2)$,但是平均情况下时间复杂度都是 $O(nlogn)$。不仅如此,快速排序算法时间复杂度退化成 $O(n^2)$ 的概率非常小,我们可以通过合理地选择 pivot 来避免这种情况。

技术拓展

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

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

冒泡排序不管怎么优化,元素交换的次数是一个固定值,是原始数据的逆序度。插入排序是同样的,不管怎么优化,元素移动的次数也等于原始数据的逆序度。 但是从代码实现上来看,冒泡排序的数据交换要比插入排序的数据移动要复杂,冒泡排序需要 3 个赋值操作,插入排序只需要 1 个。

js
// 冒泡排序
if (arr[j] > arr[j + 1]) {
  const tmp = arr[j];

  arr[j] = arr[j + 1];
  arr[j + 1] = tmp;

  flag = true;
}

// 插入排序
if (arr[j] > tmp) {
  arr[j + 1] = arr[j];
} else {
  break;
}

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

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

如果使用链表存储,排序算法还能使用吗?

特定的算法依赖特定的数据结构。如果数据存储在链表中,这三种排序算法还能工作嘛?相应的时间、空间复杂度又是多少?

对于以上问题,应该有个前提,就是是否允许修改链表的节点 value 值,还是只能改变节点的位置。

一般而言,考虑只能改变节点位置,冒泡排序相比数组实现,比较次数一致,但交换操作更复杂;插入排序比较次数一致,不需要有后移操作,找到位置后可以直接插入,但排序完毕后可能需要倒置链表;选择排序比较次数一致,交换操作同样比较麻烦。 综上所述,时间复杂度和空间复杂度并无明显变化,若追求极致性能,冒泡排序的时间复杂度系数会变大,插入排序系数会减小,选择排序无明显变化。

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)$ 了。

多文件日志合并问题

假如你有 10 个接口访问日志文件,每个日志文件大小约为 300 MB,每个文件里的日志都是按照时间戳从小到大排序。你希望将这 10 个较小的日志文件,合并为 1 个日志文件,合并之后的日志仍然按照时间戳从小到大排列。如果处理上述排序任务的机器人内存只有 1 GB,你有什么解决思路?

先构建十条 io 流,分别指向 10 个文件,每条 io 流读取对应文件的第一条数据,然后比较时间戳,选择出时间戳最小的那条数据,将其写入一个新的文件,然后指向该时间戳的 io 流读取下一行数据,然后继续刚才的操作,比较选出最小的时间戳数据,写入新文件,io 流读取下一行数据,以此类推,完成文件的合并。 这种处理方式,日志文件有 n 个数据就要比较 n 次,每次比较选出一条数据来写入,时间复杂度是 O(n),空间复杂度是 O(1),几乎不占内存。

七、线性排序

之前我们分析了几种常用排序算法的原理、时间复杂度、空间复杂度、稳定性等。这篇文章会介绍三种时间复杂度是 O(n) 的排序算法:桶排序、计数排序、基数排序,因为这些排序算法的时间复杂度是线性的,所以我们把这类排序算法叫做线性排序(Linear sort)。这三个算法是非基于比较的排序算法,不涉及元素之间的比较操作。

这几种排序算法理解起来不难,时间、空间复杂度分析起来也很简单,但是对要排序的数据要求很苛刻,所以这也是我们今天的学习重点,掌握这些排序算法的适用场景。

桶排序(Bucket sort)

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

比如我们对这组金额在 0-50 之间的订单进行排序:

22,5,11,41,45,26,29,10,7,8,30,27,42,43,40。

桶排序的时间复杂度为什么是 O(n) ?我们一块儿来分析一下。

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

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

答案当然是否定的。为了让你轻松理解桶排序的核心思想,刚才做了很多假设。实际上,桶排序对于要排序的数据要求是非常苛刻的。

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

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

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

比如我们有 10 GB 的订单数据,我们希望按订单金额(假设订单金额都是正整数)进行排序,但是我们的内存有限,只有几百 MB,没办法一次把 10 GB 的数据都加载到内存中。其实我们可以借助桶排序的处理思想来解决这个问题。

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

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

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

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

计数排序(Counting sort)

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

比如按年龄排序可以把年龄划分为0-100个桶 遍历所有人将对应年龄的人放到对应的桶,再将各个桶拼接这个时候就完成排序了 和桶排序不同的是计数排序中桶存的不是元素而是元素的数量

我们都经历过高考,高考查分数系统你应该还记得。我们查分数的时候,系统会显示我们的成绩以及所在省的排名。如果你所在的省有 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] 的值。

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

思路大概是这样:我们对 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] 要减一,变成 6 。

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

上面的过程有点复杂,这里写成了代码,你可以对照看下。

js
function countingSort (arr, n) {
  if (n <= 1) return;

  let max = arr[0];

  for (let i = 1; i < n; i++) {
    if (max < arr[i]) {
      max = arr[i];
    }
  }

  const c = new Array(max + 1);

  for (let i = 0; i <= max; i++) {
    c[i] = 0;
  }

  for (let i = 0; i < n; i++) {
    c[arr[i]]++;
  }

  for (let i = 1; i <= max; i++) {
    c[i] = c[i - 1] + c[i];
  }

  const r = new Array(n);

  for (let i = n - 1; i >= 0; i--) {
    const idx = c[arr[i]] - 1;

    r[idx] = arr[i];
    c[arr[i]]--;
  }

  for (let i = 0; i < n; i++) {
    arr[i] = r[i];
  }
}

这里利用另外一个数组来计数的实现方式是不是很巧妙?这也是为什么这种排序算法叫计数排序的原因。不过,千万不能死记硬背,重要的是理解和会用。

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

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

基数排序(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 个字母。对于这种不等长的值,基础排序还适用嘛?

Pneumonoultramicroscopicsilicovolcanoconiosis 矽肺病,肺尘病,硅酸盐沉着病

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

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

总结

今天学习了 3 种线性时间复杂度的排序算法,有桶排序、计数排序、基数排序。它们对要排序的数据都有比较苛刻的要求,应用不是非常广泛。但是如果数据特征比较符合这些排序算法的要求,应用这些,算法,会非常高效,线性时间复杂度可以达到 O(n)。

桶排序和计数排序的排序思想是非常相似的,都是针对范围不大的数据,将数据划分为不同的桶来实现排序。基数排序要求数据可以划分为高低位,位之间有递进关系。比较两个数,我们只需要比较高位,高位相同的再比较低位。而且每一位的范围不能太大,因为基数排序算法要借助桶排序或者计数排序来完成每一个位的排序工作。

技术拓展

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

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

字符串排序(D,a,F,B,c,A,Z)

如果我们需要对 D,a,F,B,c,A,Z 这个字符串进行排序,要求将其中所有小写字母都排在大写字母的前面,但小写字母内部和大写字母内部不要求有序。比如经过排序之后为 a,c,z,D,F,B,A,这个如何实现?如果字符串存储的不仅有大小写字母,还有数字。要将小写字母的放到最前面,大写字母放到后面,数字放到中间,不用排序算法,又该怎么解决?

。。。

八、排序优化

几乎所有的编程语言都会提供排序函数,比如 C 语言中 qsort(),C++ STL 中的 sort()、stable_sort(),Java 语言中的 Collection.sort(),还有 JavaScript 中的 sort()。在平时开发中中,我们也是直接使用这些现成的函数来实现业务逻辑中的排序功能。那你知道这些排序函数是如何实现的吗?底层都利用了哪种排序算法?

基于这些问题,我们来研究如何实现一个通用的、高性能的排序函数?

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

如果要实现一个通用的、高效率的排序函数,我们应该选择哪种排序算法?

时间复杂度稳定排序?原地排序?
冒泡排序$O(n^2)$
插入排序$O(n^2)$
选择排序$O(n^2)$×
快速排序$O(nlogn)$
归并排序$O(nlogn)$×
计数排序$O(n + k)$ k 是数据范围×
桶排序$O(n)$×
基数排序$O(dn)$ d 是维度×

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

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

时间复杂度是 $O(ologn)$ 的排序算法不止一个,我们已经讲过归并排序、快速排序,后面讲堆的时候还有讲到堆排序。堆排序和快速排序都有比较多的应用,比如 Java 语言采用堆排序实现排序函数,C 语言使用快速排序实现排序函数。

不知道你有没有发现,使用归并排序的情况并不多。我们知道,快排在最坏情况下的时间复杂度是 $O(n^2)$,而归并排序可以做到平均情况、最坏情况下的时间复杂度都是 $O(nlogn)$,但是为什么它还是没有得到广泛应用?

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

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

如何优化快速排序?

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

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

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

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

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

1. 三数取中法

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

2. 随机法

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

如果想了解更多寻找分区点的方法,可以自己再去深入学习一下。

我们知道,快速排序是用递归来实现的。递归的话,必须要警惕堆栈溢出。 为了避免在快速排序里,递归过深而堆栈过小,导致堆栈溢出,我们有两种解决方法:

  • 限制递归深度。一旦递归过深,超过了我们事先设定的阈值,就停止递归;
  • 通过在堆上实现一个函数调用栈,手动模拟递归压栈、出栈的过程,这样就没有系统栈大小的限制;

分析排序函数

为了让你对实现一个排序函数有一个直观的感受,这里拿 Glibc 中的 qsort() 函数举例说明一下。qsort() 从名字上来看,很像是基于快速排序算法实现的,实际上它并不仅仅用了快排这一种算法。

如果你去看源码,你会发现,qsort() 会优先使用归并排序来排序输入数据,因为归并排序的空间复杂度是 $O(n)$,所以对于小数据量的排序,比如 1 KB、2 KB 等,归并排序额外需要 1 KB、2 KB 的内存空间,这个问题不大。这就是一个空间换时间的技巧。

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

那 qsort() 是如何选择快速排序算法中的分区点的?如果去看源码,就会发现,qsort() 选择分区点的方法就是 “三数取中法”。 还有我们前面提到的递归太深会导致堆栈溢出的问题,qsort() 是通过自己实现一个堆上的栈,手动模拟递归来解决的。

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

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

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

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

js
knlogn + c = 1000 * 100 * log100 + 200 // 远大于 10000

n^2 = 100 * 100 = 10000

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

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

好了,上面的 qsort() 已经分析完毕,其实也没有很难。基本上都用到了我们前面讲的知识点,有了前面的积累,看一些底层的类库实现也会更容易一些。

总结

这篇文章分析了一个如何实现一个工业级的通用的、高效的排序函数,内容比较偏实战,而且也贯穿了前面几节的内容。大部分排序函数都是采用 $O(nlogn)$ 排序算法来实现,但是为了尽可能地提高性能,会做很多优化。还讲了快速排序的一些优化策略,比如合理选择分区点,避免递归太深等。

技术拓展

你所熟知的语言的排序函数是如何实现的?

上面我们分析的 qsort() 是 C 语言中的底层排序算法。你能不能分析一下你所熟悉的语言中的排序函数是用什么排序算法实现的?都存在哪些优化技巧?

。。。

九、二分查找(Binary Search)

今天学一种针对有序数据集合的查找算法:二分查找(Binary Search)算法,也叫折半查找算法。二分查找的思想很简单,但是看似越简单的东西往往越南难掌握好,想要灵活应用就更加困难。

假设我们有 1000 万个整数数据,每个数据占 8 个字节,如何设计数据结构和算法,快速判断某个整数是否出现在这 1000 万数据中?我们希望这个功能不能占用太多的内存空间,最多不要超过 100 MB,你会怎么做?

二分思想

二分查找是一种非常简单易懂的快速查找算法,生活中到处可见。比如说,我们现在来做一个猜字游戏。我随机写一个 0 到 99 之间的数字,然后你来猜我写的是什么。猜的过程中,每猜一次,就会告诉你猜的大了还是小了,知道猜中为止。你来想想,如何快速猜中我写的数字?

假设我写的数字是 23,你可以按照下面的步骤来试一试(猜测范围有偶数,有两个中间数,就选择较小的那个)。

次数猜测范围中间数对比大小
第 1 次0 - 994949 > 23
第 2 次0 - 482424 > 23
第 3 次0 - 231111 < 23
第 4 次12 - 231713 < 23
第 5 次18 - 232020 < 23
第 6 次21 - 232222 < 23
第 7 次23

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

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

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

我们假设有 10 个订单,订单金额分别是:8,11,19,23,27,33,45,55,67,98。

还是利用二分思想,每次都与区间的中间数据比对大小,缩小查找区间的范围。能看懂这两个例子,说明你对二分的思想应该掌握的很好了。这里稍微总结一下,二分查找针对的是一个有序的数据集合,查找思想有点类似于分治思想。每次都通过跟区间的中间元素对比,将待查找的区间缩小为之前的一半,直到找到要查找的元素,或者区间被缩小为 0。

O(logn) 的查找速度

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

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

$n、n / 2、n / 4、n / 8 ... n / 2 ^ k ... $

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

二分查找是我们目前为止遇到的第一个时间复杂度为 O(logn) 的算法。后面我们还会说堆、二叉树的操作,它们的时间复杂度也是 O(logn)。这里就深入讲一讲 O(logn) 这种对数时间复杂度。这是一种极其高效的时间复杂度,有的时候甚至比时间复杂度是常量级 O(1) 的算法还要高效。

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

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

反过来,对数对应的就是指数。指数时间复杂度的算法在大规模数据目前是无效的。

递归实现与非递归实现

实际上,简单的二分查找并不难写。二分查找的变体问题,才是真正烧脑的。我们先来看如何写简单的二分查找。

最简单的情况就是有序数组中不存在重复元素,我们在其中用二分查找值等于给定值的数据。

js
function bsearch (arr, n, val) {
  let low = 0;
  let high = n - 1;

  while (low <= high) {
    const mid  = Math.floor((low + high) / 2);

    if (arr[mid] == val) {
      return mid;
    } else if (arr[mid] < val) {
      low = mid + 1;
    } else {
      high = mid - 1;
    }
  }

  return -1;
}

解释一下上述代码,low、high、mid 都是指数组下标,其中 low 和 high 表示当前查找的区间范围,初始 low = 0,high = n - 1。mid表示 [low, high] 的中间位置。我们通过对比 arr[mid] 与 value 的大小,来更新接下来要查找的区间范围,直到找到或者区间缩小为 0,就退出。现在,着重强调一下容易出错的三个地方。

  • 循环退出条件

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

  • mid 的取值

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

  • low 和 high 的更新

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

实际上,二分查找除了用循环实现,还可以用递归实现。

js
function bsearch (arr, low, high, val) {
  if (low > high) return -1;

  const mid = low + Math.floor((high - low) >> 1);

  if (arr[mid] == val) {
    return mid;
  } else if (arr[mid] < val) {
    return bsearch(arr, mid + 1, high, val);
  } else {
    return bsearch(arr, low, mid - 1, val);
  }
}

应用场景的局限性

二分查找的时间复杂度是 O(logn),查找数据的效率非常高。不过,并不是什么情况下都可以使用二分查找,它的应用场景有很大局限性。

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

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

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

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

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

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

所以,二分查找只能用字啊插入、删除操作不频繁,一次排序多次查找的场景中。针对动态变化的数据集合,二分查找将不再适用。那针对动态数据集合,如何在其中快速查找某个数据?这部分在学二叉树的时候会讲。

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

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

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

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

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

二分查找的变形问题

上面讲了二分查找的原理,并且介绍了最简单的一种二分查找的代码实现。下面来看几种二分查找的变形问题。

不知道你有没有听过这样一个说法:”十个二分九个错“。二分查找虽然原理极其简单,但是想要写出没出 Bug 的二分查找并不容易。

你可能会说,我们刚才写的二分查找的代码并不难写啊。那是因为上面讲的只是二分查找最简单的一种情况,在不存在重复元素的有序数组中,查找值等于给定值的元素,最简单的二分查找写起来确实不难,但是,二分查找的变形问题就没那么好写了。

二分查找的变形问题很多,这里只选几个典型的讲解,其它的你可以借助今天的思路自己来分析。

需要特别说明一点,为了简化讲解,数据都是以从小到大排列为前提,如果你要处理的数据是从大到小排列的,解决思路是一样的。

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

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

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

js
a[10] 134568881118

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

尽管 a[7] 等于 8,但它并不是我们想要找的第一个等于 8 的元素,因为第一个值等于 8 的元素是数组下标为 5 的元素。我们之前讲的二分查找代码就无法处理这种情况了。所以,针对这个变形问题,我们可以稍微改造下上一节的代码。

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

js
function bsearch (arr, n, val) {
  let low = 0;
  let high = n - 1;

  while (low <= high) {
    const mid = low + Math.floor((high - low) >> 1);

    if (arr[mid] >= val) {
      high = mid - 1;
    } else {
      low = mid + 1;
    }
  }

  return low < n && arr[low] == val ? low : -1;
}

看完这个实现,你是不是觉得很难理解?如果你只是死记硬背这个写法,过不了几天,你就会全都忘光,再让你写,90% 的可能会写错。所以,这里换了一种更加容易理解的方式。

js
function bsearch (arr, n, val) {
  let low = 0;
  let high = n - 1;

  while (low <= high) {
    const mid = low + Math.floor((high - low) >> 1);

    if (arr[mid] > val) {
      high = mid - 1;
    } else if (arr[mid] < val) {
      low = mid + 1;
    } else {
      if ((mid == 0) || (arr[mid - 1] != val)) return mid;
      high = mid - 1;
    }
  }

  return -1;
}

下面来稍微解释一下这段代码。arr[mid] 跟要查找的 value 的大小关系有三种情况:大于、小于、等于。

对于 arr[mid] > val 的情况,我们需要更新 high = mid - 1; 对于 arr[mid] < val 的情况,我们需要更新 low = mid + 1。

这两点都好理解。那当 arr[mid] = val 的时候应该如何处理?

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

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

数组是第小到大排列,而要查找的第一个,因此和前一个数值判断。

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

对比上面的两端代码,是不是下面那种更好地理解?实际上,很多人都觉得变形的二分查找很难写,主要原因是太追求第一种那样完美、简洁的写法。而对于我们做工程开发的人来说,代码易读懂、没 Bug,其实更重要。

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

前面的问题是查找第一个值等于给定值的元素,现在把问题稍微修改一下,查找最后一个值等于给定值的元素。

如果你掌握了前面的写法,那这个问题你应该很轻松就能解决。

js
function bsearch (arr, n, val) {
  let low = 0;
  let high = n - 1;

  while (low <= high) {
    const mid = low + Math.floor((high - low) >> 1);

    if (arr[mid] > val) {
      high = mid - 1;
    } else if (arr[mid] < val) {
      low = mid + 1;
    } else {
      if ((mid ==  n - 1) || (arr[mid + 1] != val)) return mid;
      low = mid + 1;
    }
  }

  return -1;
}

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

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

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

现在我们再来看另外一类变形问题。在有序数组中,查找一个大于等于给定值的元素。比如,数组中存储的这样一个序列:3,4,6,7,10。如果查找第一个大于等于 5 的元素,那就是 6。

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

js
function bsearch (arr, n, val) {
  let low = 0;
  let high = n - 1;

  while (low <= high) {
    const mid = low + Math.floor((high - low) >> 1);

    if (arr[mid] >= val) {
      if ((mid == 0) || (arr[mid - 1] < val)) return mid;
      high = mid - 1;
    } else {
      low = mid + 1;
    }
  }

  return -1;
}

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

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

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

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

现在,我们来看最后一种二分查找的变形问题,查找最后一个小于等于给定值的元素。比如,数组中存储了这样一组数据:3,5,6,8,9,10。最后一个小于等于 7 的元素就是 6。实际上,实现思路和上一个问题一样。

js
function bsearch (arr, n, val) {
  let low = 0;
  let high = n - 1;

  while (low <= high) {
    const mid = low + Math.floor((high - low) >> 1);

    if (arr[mid] > val) {
      high = mid - 1;
    } else {
      if ((mid == n - 1) || (arr[mid + 1] > val)) return mid;
      low = mid + 1;
    }
  }

  return -1;
}

总结

今天学习了一种针对有序数据的高效查找算法,二分查找,它的时间复杂度是 O(logn)。

二分查找的核心思想理解起来非常简单。每次通过区间中的中间元素对比,将待查找的区间缩小为一半,知道找到要查找的元素,或者区间被缩小为 0。但是二分查找的代码比较容易写错。你需要着重掌握它的三个容易出错的地方:循环退出条件、mid 取值、low 和 high 的更新

二分查找虽然性能比较优秀,但应用场景比较有限。底层必须依赖数组,并且要求数据是有序的。对于较小规模的数据查找,我们直接使用顺序便利就可以,二分查找的优势并不明显。 二分查找更适合处理静态数据,也就是没有频繁的数据插入、删除操作。

凡是用二分查找能解决的,绝大部分那我们更倾向于散列表或者二叉查找树。即便是二分查找在内存使用上更节省,但是逼近内存如此紧缺的情况并不多。实际上,二分查找更适合用在 “近似” 查找问题,在这类问题上,二分查找的优势更加明显。比如上面讲的这几种变体问题,用其他数据机构,比如散列表、二叉树,就比较难实现。

变体的二分查找算法写起来非常烧脑,很容易因为细节处理不好产生 Bug,这些容易出错的细节有:终止条件、区间上下界更新方法、返回值选择。今天的内容最好自己实现一遍,对锻炼编码能力、逻辑思维、写出 Bug Free 代码,会很有帮助。

技术拓展

1. 如何在 100 万个整数中快速查找某个整数

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

看起来这个问题并不难,很轻松就能解决。实际上,如果你对数据结构和算法有一定了解,知道散列表和二叉树这些支持快速查找的动态数据结构。你可能会觉得,使用散列表和二叉树也可以解决这个问题,实际上是不行的。

虽然绝大部门情况下,用二分查找可以解决的问题,用散列表和二叉树可以解决。但是,不管是散列表还是二叉树,都会需要比较多的额外的内存空间。如果使用散列表或者二叉树来存储这 1000 万的数据,用 100 MB 的内存肯定是存不下的。而二分查找底层依赖的师叔祖,除了数据本身之外,不需要额外存储其他信息,是最省内存空间的存储方式,所以刚好在限定的内存大小下解决这个问题。

2. 如何求一个数的平方根,要求精确到小数点后 6 位

。。。

3. 二分查找数据用链表存储的时间复杂度

假设链表长度为 n,二分查找每次都要找到中间点:

第一次查找中间点,需要移动指针 n / 2 次; 第二次需要移动指针 n / 4 次; 第三次需要移动指针 n / 8 次; 。。。

以此类推,一直到 1 为止。

总共指针移动次数(查找次数)= n / 2 + n / 4 + n / 8 + ... + 1。这是一个等比数列,根据等比数列求和公式:sum = n - 1。 最后算法时间复杂度是:O(n - 1),忽略常数,时间复杂度为 O(n),时间复杂度和顺序查找时间复杂度相同。但是在二分查找的时候,由于要进行多余的计算,严格来说,会比顺序查找时间慢。

4. 如何快速定位一个 IP 地址的归属地

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

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

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

5. 循环有序数组,如何实现二分查找算法

如果有序数组是一个循环有序数组,比如 4,5,6,1,2,3。针对这种情况,如何实现一个求 “值等于给定值” 的二分查找算法?

。。。

十、跳表(Skip list)

之前讲过,二分查找底层依赖的是数组随机访问的特性,所以只能用数组来实现。如果数据存储在链表中,就真的没法使用二分查找算法了嘛?

实际上,我们只需要对链表稍加改造,就可以支持类似 ”二分“ 的查找算法。我们把改造之后的数据结构叫做跳表(Skip list)。

跳表这种数据结构你可能很陌生,因为一般的数据结构和算法书籍都不怎么会讲。但是它确实是一种各方面性能都比较优秀的动态数据结构,可以支持快速地插入、删除、查找操作,写起来也不复杂,甚至可以替代 红黑树(Red-black tree)。

Redis 中的有序集合(Sorted Set)就是用跳表实现的。如果你有基础,应该知道红黑树也可以实现快速地插入、删除和查找操作。那 Redis 为什么会选择用跳表来实现有序集合?为什么不用红黑树?

如何理解跳表

对于一个单链表来讲,即便链表中存储的数据是有序的,如果我们想在其中查找某个数据,也只能从头到尾遍历链表。这样查找效率很低,时间复杂度会很高,是 O(n)。

那怎么提高查询效率那?如果向图中那样,对链表建立一级 ”索引“,查找起来是不是就会更快一些?每两个结点提取一个结点到上级,我们把抽出来的那一级叫做索引或索引层。图中的 down 表示 down 指针,指向下一结点。

如果我们现在要查找某个结点,比如 16。我们可以先在索引层遍历,当遍历到索引层中值为 13 的结点时,我们发现下一个结点是 17,那要查找的结点 16 肯定就在这两个结点之间。然后我们通过索引层结点的 down 指针,下降到原始链表这一层,继续遍历。这个时候,我们只需要再遍历 2 个结点,就可以找到值等于 16 的这个结点了。这样,原来如果要查找 16,需要遍历 10 个结点,现在只需要遍历 7 个结点。

从这个例子里,我们可以看出,加上一层索引之后,查找一个结点需要遍历的节点个数减少了,也就是查询效率提高了。那如果我们再加一层索引,效率会不会提升很多?

跟前面建立一级索引的方式相似,我们在第一级索引的基础之上,每两个结点就抽出一个结点到第二级索引。现在我们再来查找 16,只需要遍历 6 个结点了,需要遍历的结点数量又减少了。

这个例子数据量不大,所以即便是加了两级索引,查找效率的提升也并不明显。这里有一个包含 64 个结点的链表,按照前面的思路,建立了五级索引。

从图中我们可以看出,原来没有索引的时候,查找 62 需要遍历 62 个结点,现在只需要遍历 11 个结点,速度提高了很多。所以,当链表的长度 n 比较大时,比如 1000、10000 的时候,构建索引之后,查找效率的提升就会非常明显。

前面讲的这种链表加多级索引的结构,就是跳表。现在你应该比较清晰地知道,跳表确实可以提高查询效率。接下来,准备定量分析一下,用跳表查询有多快。

用跳表查询有多快?

之前讲过,算法的执行效率可以通过时间复杂度来度量,这里依旧可以用。我们知道,在一个单链表中查询某个数据的时间复杂度是 O(n)。那在一个具有多级索引的跳表中,查询某个数据的时间复杂度是多少?

这个时间复杂度的分析方法比较难想到。可以先把问题分解一下,如果链表里有 n 个结点,会有多少级索引?

按照我们刚才说的,每两个结点会抽出一个结点作为上一级索引的结点,那第一级索引的结点个数大约为 n / 2,第二级索引的结点个数大约为 n / 4,第三级索引的结点个数大约是 n / 8,以此类推,也就是说,第 k 级索引的结点个数是第 k - 1 级索引的结点个数的 1 / 2,那第 k 级索引结点的个数就是 $ n / 2 ^ k $ 。

假设索引有 h 级,最高级的索引有 2 个结点。通过上面的公式,我们可以得到 $n / 2 ^ k = 2$ ,从而求得 h=log2n-1。如果包含原始链表这一层,整个跳表的高度就是 $log2^n$ 。我们在跳表中查询某个数据的时候,如果每一层都要遍历 m 个结点,拿在跳表中查询一个数据的时间复杂度就是 $O(m * logn)$。

那这个 m 的值是多少?按照前面这种索引结构,我们每一级索引都最多只需要遍历 3 个结点,也就是说 m = 3,不过为什么是 3?

假设我们要查找的数据是 x,那在第 k 级索引中,我们遍历到 y 结点之后,发现 x 大于 y,小于后面的结点 z,所以我们通过 y 的 down 指针,从第 k 级索引下降到 k - 1 级索引。在第 k - 1 级索引中中,y 和 z 之间只有 3 个结点(包含 y 和 z),所以,我们在 K - 1 级索引中最多只需要遍历 3 个结点,以此类推,每一级索引最多只需要遍历 3 个结点。

通过上面的分析,我们得到 n = 3,所以在跳表中查询任意数据的时间复杂度就是 O(logn)。这个查找的时间复杂度和二分查询是一样的。换句话说,我们其实是基于单链表实现了二分查找,是不是很神奇?不过,这种查询效率的提升,前提是建立了很多级索引,也就是空间换时间的设计思路。

跳表是不是很浪费内存?

比起单纯的单链表,跳表需要存储多级索引,肯定要消耗更多的存储空间。那到底需要消耗多少的额外的存储空间?我们来分析一下跳表的空间复杂度。

跳表的空间复杂度分析并不难,假设原始链表大小为 n,那第一级索引大约有 n / 2 个结点,第二级索引大约有 n / 4 个结点,以此类推,每上升一级就会减少一半,直到剩下两个结点。如果我们把每层索引的结点数写出来,就是一个等比数列。

这几级索引的结点总和就是 n / 2 + n / 4 + n / 8 + ,,, + 8 + 4 + 2 = n - 2。所以,跳表的空间复杂度是 O(n)。也就是说,如果包含 n 个结点的单链表构造成跳表,我们需要额外再用接近 n 个结点的存储空间。那我们有没有办法降低索引占用的内存空间呢?

我们前面都是每两个结点抽一个结点到上级索引,如果我们每三个结点或者五个结点,抽一个结点到上级索引,是不是就不用那么多索引结点了呢?

从图中可以看出,每一级索引大约需要 n / 3 个结点,第二级索引需要大约 n / 9 个结点。每往上一级,索引结点个数都除以 3 。为了方便计算,我们假设最高一级的索引结点个数是 1。我们把每级索引的结点个数写下来,也是一个等比数列。

通过等比数列求和公式,总的索引结点大概是 n/3+n/9+n/27+...+9+3+1=n/2。尽管空间复杂度还是 O(n)。但比上面的每两个结点抽一个结点的索引构建方法,要减少了一半的索引结点存储空间。

实际上,在软件开发中,我们不必太在意索引占用的额外空间。在讲数据结构和算法时,我们习惯性地把要处理的数据看成整数,但在实际的软件开发中,原始链表中存储的有可能是很大的对象,而索引结点只需要存储关键值和几个指针,并不需要存储对象,所以当对象比索引结点大很多时,那索引占用的额外空间就可以忽略了。

高效的动态插入和删除

跳表是动态数据结构,不仅支持查找操作,还支持动态的插入、删除操作,而且插入、删除操作的时间复杂度也是 $O(logn)$。

现在我们来看下,如何在跳表中插入一个数据,以及它是如何做到 $O(logn)$ 的时间复杂度的。

我们知道,在单链表中,一旦定位好要插入的位置,插入结点的时间复杂度是很低的,就是 $O(1)$。但是,为了保证原始链表中数据的有序性,我们需要先找到插入的位置,这个查找操作就会比较耗时。

对于纯粹的单链表,需要遍历每个结点,来找到插入的位置。但是,对于跳表来说,我们讲过查找某个结点的时间复杂度是 $O(logn)$ ,所以这里查找某个数据应该插入的位置,方法也是类似的,时间复杂度也是 $O(logn)$。下面有一张图,你可以很清晰地看到插入的过程。

我们再来看删除操作。如果这个结点在索引中也有出现,我们除了要删除原始链表中的结点,还要删除索引中的。因为单链表中的删除操作需要拿到要删除结点前的前驱结点,然后通过指针操作完成删除。所以在查找要删除的结点时,一定要获取前驱结点。当前,如果我们用的是双向链表,就不需要考虑这个问题了。

跳表动态更新

当我们不停地往跳表中插入数据时,如果我们不更新索引,就有可能出现某 2 个索引结点之间数据非常多的情况。极端情况下,跳表还会退化成单链表。

作为一种动态数据结构,我们需要某种手段来维护索引与原始链表大小之间的平衡,也就是说,如果链表中结点多了,索引结点就相应地增加一些,避免复杂度退化,以及查找、插入、删除操作性能下降。

如果你了解红黑树、AVL 树这样平衡二叉树,你就知道它们是通过左右旋的方式保持左右子树的大小平衡,而跳表是通过随机函数来维护前面提到的 ”平衡性“。

当我们往跳表中插入数据的时候,我们可以选择同时将这个数据插入到部分索引层中。那么该如何选择加入哪些索引层?

我们可以通过一个随机函数,来决定将这个结点插入到哪几级索引中,假设随机函数生成了值 K,那我们就将这个结点添加到第一级到第 K 级索引中。

随机函数的选择很有讲究,从概率上说,能够保证跳表的索引大小和数据大小平衡性,不至于性能过度退化。至于随机函数的选择,如果你感兴趣的话,可以去看看 Redis 中关于有序集合的跳表实现。

JavaScript 代码实现:

js
const MAX_LEVEL = 16;

class Node {
  constructor (options) {
    const {
      data = -1,
      maxLevel = 0,
      refer = new Array(MAX_LEVEL)
    } = options || {};

    this.data = data;
    this.maxLevel = maxLevel;
    this.refer = refer;
  }
}

class SkipList {
  constructor () {
    this.head = new Node();
    this.levelCount = 1;
  }

  getRandomLevel () {
    let level = 1;

    for (let i = 1; i < MAX_LEVEL; i++) {
      if (Math.random() < 0.5) {
        level++;
      }
    }

    return level;
  }

  insert (value) {
    const level = this.getRandomLevel();
    const newNode = new Node();

    newNode.data = value;
    newNode.maxLevel = level;

    const update = new Array(level).fill(new Node());

    let p = this.head;

    for (let i = level - 1; i >= 0; i--) {
      while (p.refer[i] !== undefined && p.refer[i].data < value) {
        p = p.refer[i];
      }
      update[i] = p;
    } 

    for (let i = 0; i < level; i++) {
      newNode.refer[i] = update[i].refer[i];
      update[i].refer[i] = newNode;
    }

    if (this.levelCount < level) {
      this.levelCount = level;
    }
  }

  find (value) {
    if (!value) return null;

    let p = this.head;

    for (let i = this.levelCount - 1; i >= 0; i--) {
      while (p.refer[i] != undefined && p.refer[i].data < value) {
        p = p.refer[i];
      }
    }

    if (p.refer[0] !== undefined && p.refer[0].data === value) {
      return p.refer[0];
    }

    return null;
  }

  remove (value) {
    let _node;
    let p = this.head;
    
    const update = new Array(new Node());

    for (let i = this.levelCount - 1; i >= 0; i--) {
      while (p.refer[i] !== undefined && p.refer[i].data < value) {
        p = p.refer[i];
      }
      update[i] = p;
    }

    if (p.refer[0] !== undefined && p.refer[0].data === value) {
      _node = p.refer[0];

      for (let i =0; i <= this.levelCount - 1; i++) {
        if (update[i].refer[i] !== undefined && update[i].refer[i].data === value) {
          update[i].refer[i] = update[i].refer[i].refer[i];
        }
      }

      return _node;
    }

    return null;
  }

  print () {
    let p = this.head;

    while (p.refer[0] !== undefined) {
      console.log(p.refer[0].data);
      p = p.refer[0];
    }
  }
}

测试用例:

js
const list = new SkipList();
const length = 20000;

// 顺序插入
for (let i = 1; i <= 10; i++) {
  list.insert(i);
}

// 输出一次
list.print();

console.time('create length')
// 插入剩下的
for (let i = 11; i <= length; i++) {
  list.insert(i);
}
console.timeEnd('create length')

// 搜索 10次
for (let j = 0; j < 10; j++) {
  const key = Math.floor(Math.random() * length + 1);
  console.log(key, list.find(key))
}

// 搜索不存在的值
console.log('null:', list.find(length + 1));

// 输出一次
list.print();

// 搜索 5000 次统计时间
console.time('search 5000');
for (let j = 0; j < 5000; j++) {
  let key = Math.floor(Math.random() * length + 1);
  console.log(key, list.find(key))
}
console.timeEnd('search 5000');

总结

跳表使用空间换时间的设计思路,通过构建多级索引来提高查询的效率,实现了基于链表的 ”二分查找“。跳表是一种动态数据结构,支持快速地插入、删除、查找操作,时间复杂度都是 O(logn)。

跳表的空间复杂度是 O(n)。不过,跳表的实现灵活,可以通过改变索引构建策略,有效平衡执行效率和内存消耗。虽然跳表的代码实现并不简单,但是作为一种动态数据结构,比起红黑树来说,实现要简单很多。所以很多时候,我们为了代码的简单、易读,比起红黑树,我们更倾向用跳表。

技术拓展

为什么 Redis 要用跳表来实现有序集合,而不是红黑树?

Redis 中的有序集合是通过跳表来实现的,严格点讲,其实还用到了散列表。如果你去查看 Redis 的开发手册,就会发现,Redis 中的有序集合支持的核心操作主要有下面这几个:

  • 插入一个数据
  • 删除一个数据
  • 查找一个数据
  • 按照区间查找数据(比如查找值在 [100, 356] 之间的数据)
  • 迭代输出有序序列

其中,插入、删除、查找以及迭代输出有序序列这几个操作,红黑树也可以完成,时间复杂度跟跳表一样的。但是,按照区间来查找数据这个操作,红黑树的效率没有跳表高。

对于按照区间查找数据这个操作,跳表可以做到 O(logn) 的时间复杂度定位区间的起点,然后在原始链表中顺序往后遍历就可以。这样做非常高效。

当然,Redis 之所以用跳表实现有序集合,还有其他原因,比如,跳表更容易代码实现。虽然跳表的实现也不简单,但比起红黑树来说还是好懂、好些多了,而简单就意味着可读性好,不容易出错。还有,跳表更加灵活,它可以通过改变索引构建策略,有效平衡执行效率和内存消耗。

不过,跳表也不能完全替代红黑树。因为红黑树比跳表的出现要早一些,很多编程语言中的 Map 类型都是通过红黑树来实现的。我们做业务开发的时候,直接拿来用就可以了,不用费劲自己去实现一个红黑树,但是跳表并没有一个现成的实现,所以在开发中,如果你想使用跳表,必须要自己实现。

对于跳表来说,如果三个或者五个结点作为上级索引,查询时间复杂度是多少?

如果每三个或者五个节点提取一个节点作为上级索引,那么对应的查询数据时间复杂度,应该也还是 O(logn)。

假设每 5 个节点提取,那么最高一层有 5 个节点,而跳表高度为 log5n,每层最多需要查找 5 个节点,即 O(mlogn) 中的 m = 5,最终,时间复杂度为 O(logn)。

十一、散列表(Hash Table)

Word 这种文本编辑器你应该经常使用,那你有没有留意过它的拼写检查功能?一旦我们在 Word 里输入一个错误的英文单词,它就会用标红的方式提示 ”拼写错误“。Word 这个单词拼写检查功能,虽然很小但却非常实用。那你有没有想过,这个功能是如何实现的?

其实,一点儿也不难。只要你学完今天的内容,散列表(Hash Table),你就可以轻松实现这个功能。

散列思想

散列表的英文叫做 ”Hash Table“,我们平时也叫它 ”哈希表“ 或者 ”hash 表“,你一定也经常听过它。

散列表用的是数组支持按照下标随机访问的特性,所以散列表其实就是数组的一种扩展,由数组演化而来。可以说,如果没有数组,就没有散列表。

用一个例子来解释一下。假设我们有 89 名选手参加学校运动会。为了方便记录成绩,每个选手胸前都会贴上自己的参赛号码。这 89 名选手的编号依次是 1 到 89。现在我们希望编程实现这样一个功能,通过编号快速找到对应的选手信息。你会怎么做?

我们可以把这 89 名选手的信息放在数组中。编号为 1 的选手,放到数组下标为 1 的位置;编号为 2 的选手,放到数组下标为 2 的位置。以此类推,编号为 k 的选手放到下标为 k 的位置。

因为参赛编号跟数组下标一一对应,当我们需要查询参赛编号为 x 的选手的时候,我们只需要将下标为 x 的数组元素取出来就可以了,时间复杂度就是 O(1)。这样按照编号查找选手信息,效率是不是很高?

实际上,这个例子已经用到了散列的思想。在这个例子里,参赛编号是自然数,并且与数组的下标形成一一映射,所以利用数组支持根据下标随机访问的特性,可以实现快速查找编号对应的选手信息。

你可能会说,这个例子中蕴含的散列思想还不够明显,那我们来改造一下这个例子。

假设校长说,参赛编号不能设置的这么简单,要加上年级、班级这些更详细的信息,所以我们把编号的规则稍微改一下,用 6 位数字来表示。比如 051167,其中前两位 05 表示年级,中间两位 11 表示班级,最后两位还是燕来的编号(区间 0 - 89)。这个时候我们该如何存储选手信息,才能够支持通过编号来快速查询选手信息呢?

思路还是和前面类似。尽管我们不能直接把编号作为数组下标,但我们可以截取参赛编号的后两位作为数组下标,来存取选手信息数据。当通过参赛编号查询选手信息的时候,我们用同样的方法,取参赛编号的后两位,作为数组下标,来读取数组中的数据。

这就是典型的散列思想。其中,参赛选手的编号我们叫做键(key)或者关键字。我们用它来标识一个选手。我们把参赛编号转化为数组下标的映射方法就叫做散列函数(”Hash 函数“,”哈希函数“),而散列函数计算得到的值就叫做散列值(”Hash 值“,”哈希值“)。

通过这个例子,我们可以总结出这样的规律:散列表用的就是数组支持按照下标随机访问的时候,时间复杂度是 O(1) 的特性。我们通过散列函数把元素的键值映射为下标,然后将数据存储在数组中对应下标的位置。当我们按照键值查询元素时,再用同样的散列函数,将键值转换为数组下标,从对应的数组下标的位置读取数据。

散列函数

从上面的例子中我们可以看到,散列函数在散列表中起着非常关键的作用。

散列函数,顾名思义,它是一个函数。我们可以把它定义成 hash(key),其中 key 表示元素的键值,hash(key) 的值表示经过散列函数计算得到的散列值。

那第一个例子中,编号就是数组下标,所以 hash(key) 就等于 key。改造后的例子,写成散列函数稍微有点复杂。写成伪代码如下:

js

int hash(String key) {
  // 获取后两位字符
  string lastTwoChars = key.substr(length-2, length);
  // 将后两位字符转换为整数
  int hashValue = convert lastTwoChas to int-type;
  return hashValue;
}

刚才举的学校运动会的例子,散列函数比较简单,也比较容易想到。但是,如果参赛选手的编号是随机生成的 6 位数字,又或者用的是 a 到 z 之间的字符串,该如何构造散列函数呢?

散列函数设计的三点基本要求:

  • 散列函数计算得到的散列值是一个非负整数;
  • 如果 key1 = key2,那 hash(key1) == hash(key2);
  • 如果 key1 ≠ key2,那 hash(key1) ≠ hash(key2)。

第一点理解起来应该没有任何问题。因为数组下标是从 0 开始的,所以散列函数生成的散列值也要是非负整数。第二点也很好理解。相同的 key,讲过散列函数得到的值也应该是相同的。

第三点理解起来可能会有问题,需要着重说一下。这个要求看似合情合理,但在实际情况下,要想找到一个不同的 key 对应的散列值都不一样的散列函数,几乎是不可能的。即便像业界著名 MD5、SHA、CRC 等哈希算法,也无法避免这种散列冲突。而且,因为数组的存储空间有限,也会加大散列冲突的概率。

所以我们几乎无法找到一个完美的无冲突的散列函数,即便能找到,付出的时间成本、计算成本也是很大的,所以针对散列冲突问题,我们需要通过其他途径来解决。

散列冲突

再好的散列函数也无法避免散列冲突。那究竟该如何解决散列冲突问题呢?我们常用的散列冲突解决方法有两类,开放寻址法(open addressing)和链表法(chaining)。

1. 开放寻址法

开发寻址法的核心思想是,如果出现了散列冲突,我们就重新检测一个空闲位置,将其插入。那如何重新检测新的位置呢?先说一个比较简单的探测方法,线性探测(Linear Probing)。

当我们往散列表中插入数据时,如果某个数据经过散列函数散列之后,存储位置已经被占用了,我们就从当前位置开始,依次往后查找,看是否有空闲位置,直到找到为止。

说起来可能比较抽象,举一个例子具体说明一下(图中黄色色块表示空闲位置,橙色色块表示已存储数据)。

从图中可以看出,散列表的大小为 6,在元素 x 插入散列表之前,已经有 4 个元素插入到散列表中。x 经过 Hash 算法之后,被散列到位置下标为 4 的位置,但是这个位置已经有数据了,所以就产生冲突。于是我们就顺序地往后一个一个找,看有没有空闲的位置,遍历到尾部没有找到空闲的位置,于是我们再从表头开始找,直到找到空闲位置 2,于是将其插入到这个位置。

在散列表中查找元素的过程中有点类似插入过程。我们通过散列函数求出要查找元素的键值对应的散列值,然后比较数组中下标为散列值的元素和要查找的元素。如果相等,则说明就是我们要找的元素;否则就顺序往后依次查询。如果遍历到数组中的空闲位置,还没有找到,就说明要查找的元素并没有在散列表中。

散列表跟数组一样,不仅支持插入、查找操作,还支持删除操作。对于使用线性探测解决冲突的散列表,删除操作稍微有点特别。我们不能单纯地把要删除的元素设置为空。这是什么呢?

还记得我们刚讲的查找操作吗?在查找的时候,一旦我们通过线性探测方法,找到一个空闲位置,我们就可以认定散列表中不存在这个数据。但是,如果这个空闲位置是我们后来删除的,就会导致原来的查找算法失效。本来存在的数据,会被认定为不存在。我们可以将删除的元素,特殊标记为 deleted。当线性探测查找的时候,遇到标记为 deleted 的空间,并不是停下来,而是继续往下探测。

你可能已经发现一个问题,线性探测法其实存在很大问题。当散列表中插入的数据越来越多时,散列冲突发生的可能性就会越来越大,空闲位置会越来越少,线性探测的时间就会越来越久。极端情况下,我们可能需要探测整个散列表,所以最坏情况的时间复杂度为 O(n)。同理,在删除和查找时,也有可能会线性探测整张散列表,才能找到要查找或者删除的数据。

对于开放寻址冲突解决方法,除了线性探测之外,还有另外两种比较经典的探测方法,二次探测(Quadratic probing)和双重散列(Double hashing)。

所谓二次探测,跟线性探测很像,线性探测每次探测的步长是 1,那它探测的下标序列就是 hash(key) + 0 ,hash(key) + 1,hash(key) + 2 ... 而二次探测的步长就变成了原来的 “二次方”,也就是说,它探测的下标序列是 hash(key) + 0,hash(key) + $1^2$,hash(key) + $2^2$ ...

所谓双重散列,意思就是不仅要使用一个散列函数。我们使用一组散列函数 hash1(key),hash2(key),hash3(key) ... 我们先用第一个散列函数,如果计算得到的存储位置已经被占用,再用第二个散列函数,依次类推,直到找到空闲的存储位置。

不管采用哪种探测方法,当散列表中空闲位置不多的时候,散列冲突的概率就会大大提高。为了尽可能保证散列表的操作效率,一般情况下,我们会尽可能保证散列表中有一定比例的空闲槽位。我们用装载因子(load factor)来表示空位的多少。

装载因子的计算公式是:

js
散列表的装载因子 = 填入表中的元素个数 / 散列表的长度

装载因子越大,说明空闲位置越少,冲突越多,散列表的性能会下降。

2. 链表法

链表法是一种更加常用的散列冲突解决办法,相比开放寻址法,它要简单很多。我们来看这个图,在散列表中,每个 "桶(bucket)" 或者 “槽(slot)” 会对应一条链表,所有散列值相同的元素我们都放到槽位对应的链表中。

当插入的时候,我们只需要通过散列函数计算出对应的散列槽位,将其插入到对应链表中即可,所以插入的时间复杂度是 O(1)。当查找、删除一个元素时,我们同样通过散列函数计算出对应的槽,然后遍历链表查找或者删除。那查找和删除操作的时间复杂度是多少?

实际上,这两个操作的时间复杂度跟链表的长度 k 成正比,也就是 O(k)。对于散列比较均匀的散列函数来说,理论上讲,k = n / m,其中 n 表示散列中数据的个数,m 表示单列表中 “槽” 的个数。

如何设计散列函数?

散列表的查询效率并不能笼统地说成是 O(1)。它跟散列函数、装载因子、散列冲突等都有关系。如果散列函数设计的不好,或者装载因子过高,都可以导致散列冲突发生的概率升高,查询效率下降。

极端情况下,某些恶意攻击者,还有可能通过精心构造的数据,使得所有的数据经过散列函数之后,都散列到同一个槽里。如果我们使用的是基于链表的冲突解决方法,那这个时候,散列表就会退化为链表,查询的时间复杂度就从 O(1) 急剧退化为 O(n)。

如果散列表中有 10 万条数据,退化后的散列表查询的效率就下降了 10 万倍。更直接点说,如果之前运行 100 次查询只需要 0.1 秒,那现在就需要 1 万秒。这样就有可能因为查询操作消耗大量 CPU 或者线程资源,导致系统无法响应其他请求,从而达到拒绝服务器攻击(DoS)的目的。这也就是散列表碰撞攻击的基本原理。

所以我们有必要学习如何设计一个可以应对各种异常情况的工业级散列表,来避免在散列冲突的情况下,散列表性能的急剧下降,并且能抵抗散列碰撞攻击?

散列函数设计的好坏,决定了散列表冲突的概率大小,也直接决定了散列表的性能。那什么样的散列函数才是好的散列函数?

首先,散列函数的设计不能太复杂。过于复杂的散列函数,势必会消耗很多计算时间,也就间接地影响散列表的性能。其次,散列函数生成的值尽可能随机并且均匀分布,这样才能避免或者最小化散列冲突,而且即便出现冲突,散列到每个槽里的数据也会比较平均,不会出现某个槽内数据特别多的情况。

实际工作中,我们还需要综合考虑各种因素。这些因素有关键字的长度、特点、分布、还有散列表的大小等。散列函数各式各样,这里举几个常用的、比较简单的散列函数的设计方法。

第一个例子就是上面所说的学生运动会的例子,我们通过分析参赛编号的特征,把编号中的后两位作为散列值。我们还可以用类似的散列函数处理手机号码,因为手机号码前几位重复的可能性很大,但是后面几位就比较随机,我们可以取手机号的后四位作为散列值。这种散列函数的设计方法,我们一般叫做 “数据分析法”。

第二个例子就是文章的开篇思考题,如果实现 Word 拼写检查功能。这里面的散列函数,我们可以这样设计:将单词中每个字母的 ASCII 码 “进位” 相加,然后再跟散列表的大小求余、取模、作为散列值。比如,英文单词 nice,我们转换出来的散列值就是下面这样:

js
hash("nice") = (("n" - "a") * 26 * 26 * 26 + ("i" - "a") * 26 * 26 + ("c" - "a") * 26 + ("e"-"a")) / 78978

散列函数的设计方法还有很多,比如直接寻址法、平方寻址法、折叠法、随机数法等,这些你只要了解就行,不需要全部掌握。

装载因子过大怎么办?

散列表的装载因子越大,说明散列表中的元素越多,空闲位置越少,散列冲突的概率就越大。不仅插入数据的过程要多次寻址或者拉很长的链,查找的过程也会因此变得很慢。

对于没有频繁插入和删除的静态数据集合来说,我们很容易根据数据的特点、分布等,设计出完美的、极少冲突的散列函数,因为毕竟之前数据都是已知的。

对于动态散列表来说,数据集合是频繁变动的,我们事先无法预估将要加入的数据个数,所以我们也无法事先申请一个足够大的散列表。随着数据慢慢加入,装载因子就会慢慢变大。当装载因子大到一定程度之后,散列冲突就会变得不可接受。这个时候,我们该如何处理呢?

还记得我们前面多次讲的 “动态扩容” 吗?你可以想一下,我们是如何做到数组、栈、队列的动态扩容的。

针对散列表,当装载因子过大时,我们也可以进行动态扩容,重新申请一个更大的散列表,将数据搬移到这个新散列表中。假设每次扩容我们都申请一个原来散列表大小两倍的空间。如果原来散列表的装载因子是 0.8,那经过扩容之后,新散列表的装载因子就下降为原来的一半,变成 0.4。

针对数组的扩容,数据搬移操作比较简单。但是,针对散列表的扩容,数据搬移操作要复杂很多。因为散列表的大小变了,数据的存储位置也变了,所以我们需要通过散列函数重新设计数据的存储位置。

比如下图这个例子。在原来的散列表中,21 这个元素原来存储在下标为 0 的位置,搬移到新的散列表中,存储在下标为 7 的位置。

对于支持动态扩容的散列表,插入操作的时间复杂度是多少?

插入一个数据,最高情况下是,不需要扩容,最好时间复杂度是 O(1)。最坏情况下,散列表装载因子过高,启动扩容,我们需要重新申请内存空间,重新计算哈希位置,并且搬移数据,所以时间复杂度是 O(n)。用摊还分析法,均摊情况下,时间复杂度接近于最好情况,就是 O(1)。

实际上,对于动态散列表,随着数据的删除,散列表的数据会越来越少, 空闲空间会越来越多。如果我们对空间消耗非常敏感,我们可以在装载因子小于某个值之后,启动动态缩容。当然,如果我们更加在意效率,能够容忍多消耗一点内存空间,那就可以不用费劲进行缩容了。

当散列表的装载因子超过某个阈值时,就需要进行扩容。装载因子阈值需要选择得当。如果太大,会导致冲突过多;如果太小,会导致内存浪费严重。

装载因子阈值的设置要权衡时间、空间复杂度。如果内存空间不紧张,对执行效率要求很高,可以降低负载因子的阈值;相反,如果内存空间紧张,对执行效率要求不高,可以增加负载因子的值,甚至可以大于 1。

如何避免低效的扩容?

大部分情况下,动态扩容的散列表插入一个数据都很快,但是在特殊情况下,当装载因子已经达到阈值,需要先进行扩容,再插入数据。这个时候,插入数据就会变得很慢,甚至会无法接受。

举一个极端的例子,如果散列表当前大小为 1 GB,要想扩容为原来的两倍大小,那就需要对 1 GB 的数据重新计算哈希值,并且从原来的散列表搬移到新的散列表,听起来就很耗时。

如果我们的业务代码直接服务于用户,尽管大部分情况下,插入一个数据的操作都很快,但是,极个别非常慢的插入操作,也会让用户崩溃。这个时候,“一次性” 扩容的的机制就不合适了。

为了解决一次性扩容耗时过多的情况,我们可以将扩容操作穿插在插入操作的过程中,分批完成。当装载因子触达到阈值之后,我们只申请新空间,但并不将老的数据搬移到新散列表中。

当有新数据要插入时,我们将新数据插入到新散列表中,并且从老的散列表中拿出一个数据放入到新散列表。每次插入一个数据到散列表,我们都重复上面的过程。经过多次插入操作之后,老的散列表中的数据就一点一点全部搬移到新散列表中了。这样没有了集中的一次性数据搬移,插入操作就变得很快乐。

这期间的查询操作怎么来做呢?对于查询操作,为了兼容新、老散列表中的数据,我们先从新散列表中查找,如果没有找到,再去老的散列表中查找。

通过这样均摊的方法,将一次性扩容代价,均摊到多次插入操作中,就避免了一次性扩容耗时过多的情况。这种实现方式,任何情况下,插入一个数据的时间复杂度都是 O(1)。

如果选择冲突解决方法?

上面我们介绍了两种主要的散列冲突的解决方法,开放寻址法和链表法。这两种冲突解决办法在实际的软件开发中都非常实用。比如,Java 中 LinkedHashMap 就采用了链表法解决冲突,ThreadLocalMap 则通过线性探测的开放寻址法来解决冲突。那你知道,这两种冲突解决方法各有什么优势和劣势,又各自适用哪些场景呢?

1. 开放寻址法

开放寻址法不像链表法,需要拉很多链表。散列表中的数据都存储在数组中,可以有效地利用 CPU 缓存加快查询速度。而且,这种方法实现的散列表,序列化起来比较简单。链表法包含指针,序列化起来就没那么容易。你可不能小看序列化,很多场合都会用到。

用开放寻址法解决冲突的散列表,删除数据的时候比较麻烦,需要特殊标记已经删除掉的数据。而且,开放寻址法中,所有的数据都存储在一个数组中,比起链表法来说,冲突的代价更高。所以,使用开放寻址法解决冲突的散列表,装载因子的上限不能太大。这也导致这种方法比链表法更浪费空间。

总结一下,当数据量比较小、装载因子小的时候,适合采用开放寻址法。这也是 Java 中的 ThreadLocalMap 使用开放寻址法解决散列冲突的原因。

2. 链表法

链表法对内存的利用率比开放寻址法更高。因为链表结点可以在需要的时候再创建,并不需要像开放寻址法那样事先申请好。实际上,这一点也是链表优于数组的地方。

链表法比起开放寻址法,对大装载因子的容忍度更高。开放寻址法只能适用装载因子小于 1 的情况。接近 1 时,就可能有大量的散列冲突,导致大量的探测、再散列等,性能会下降很多。但是对于链表法来说,只要散列函数的值随机均匀,即使装载因子变成 10,也就是链表的长度变长了,虽然查找效率会有所下降,但是比起顺序查找还是快很多。

链表因为要存储指针,所以对于比较小的对象的存储,是比较消耗内存的,还有可能会让内存的消耗翻倍。而且,因为链表中的结点是零散分布在内存中的,不是连续的,所以对 CPU 缓存是不友好的,这方面对于执行效率也有一定的影响。

当然,如果我们存储的是大对象,也就是说要存储的对象的大小远远大于一个指针的大小(4 个字节或者 8 个字节),那链表中指针的内存消耗在大对象面前就可以忽略了。

实际上,我们对链表法稍加改造,可以实现一个更加高效的散列表。那就是,我们将链表法中的链表改造为其它高效的动态数据结构,比如跳表、红黑树。这样,即便出现散列冲突,极端情况下,所有的数据都散列到同一个桶里,那最终退化成的散列表的查找时间也只不过是 O(logn)。这样也就有效避免了前面讲到的散列碰撞攻击。

总结一下,基于链表的散列冲突处理方法比较适合存储大对象、大数据量的散列表,而且,比起开放寻址法,它更加灵活,支持更多的优化策略,比如用红黑树代替链表。

工业级散列表举例分析

上面都是讲实现一个工业级散列表需要涉及的一些关键技术,现在,我就拿一个具体的例子,Java 中的 HashMap 这样一个工业级的散列表,来具体看下,这些技术是怎么应用的。

1. 初始大小

HashMap 默认的初始大小是 16,当然这个默认值是可以设置的,如果事先知道大概的数据量有多大,可以通过修改默认初始大小,减少动态扩容的次数,这样会大大提高 HashMap 的性能。

2. 装载因子和动态扩容

最大装载因子默认是 0.75 ,当 HashMap 中元素个数超过 0.75 * capacity(capacity 表示散列表的容量)的时候,就会启动扩容,每次扩容都会扩容为原来的两倍大小。

3. 散列冲突解决方法

HashMap 底层采用链表法来解决冲突。即便负载因子和散列函数设计得再合理,也免不了会出现拉链过长的情况,一旦出现拉链过长,则会严重影响 HashMap 的性能。

于是,在 JDK 1.8 版本中,为了对 HashMap 做进一步优化,引入了红黑树。当链表长度太长(默认超过 8)时,链表就转换为红黑树。我们可以利用红黑树快速增删改查的特点,提高 HashMap 的性能。当红黑树节点少于 8 个的时候,又会将红黑树转换为链表。因为在数据量较小的情况下,红黑树要维护平衡,比起链表来,性能上的优势并不明显。

4. 散列函数

散列函数的设计并不复杂,追求的是简单高效、分布均匀。

java
int hash (Object key) {
  int h = key.hashCode()
  return (h ^ (h >>> 16)) & (capicity -1); //capicity 表示散列表的大小
}

其中,hashCode() 返回的是 Java 对象的 hash code。比如 String 类型的对象的 hashCode() 就是下面这样:

java
public int hashCode () {
  int var1 = this.hash;
  if (var1 == 0 && this.value.length > 0) {
    char[] var2 = this.value;
    for (int var3 = 0; var3 < this.value.length; ++var3) {
      var1 = 31 * var1 + var2[var3];
    }
    this.hash = var1;
  }
  return var1;
}

散列表和链表的组合使用

链表和散列表两种数据结构,经常会被放在一起使用。

在学习链表时,我们知道可以用链表实现 LRU 缓存淘汰算法,但是链表实现的 LRU 缓存淘汰算法的时间复杂度是 O(n),通过散列表可以将这个时间复杂度降低到 O(1)。

在学习跳表时,我们知道 Redis 的有序集合是使用跳表来实现的,跳表可以看作一种改进版的链表。当时我们也提到,Redis 有序集合不仅使用了跳表,还用到了散列表。

除此之外,如果你熟悉 Java 编程语言,你会发现 LinkedHashMap 这样一个常用的容器,也用到了散列表和链表两种数据结构。

现在我们来研究,在这这几个问题中,散列表和链表都是如何组合起来使用的,以及为什么散列表和链表会经常放到一起使用。

LRU 缓存淘汰算法

借助散列表,我们可以把 LRU 缓存淘汰算法的时间复杂度降低为 O(1)。

首先,我们来回顾一下当时我们是如何通过链表来实现 LRU 缓存淘汰算法的。

我们需要维护一个按照访问时间从大到小有序排列的链表结构。因为缓存大小有限,当缓存空间不够,需要淘汰一个数据的时候,我们就直接将链表头部的结点删除。

当要缓存某个数据的时候,先在链表中查找这个数据。如果没有找到,则直接将数据放到链表的尾部;如果找了,我们就把它移动到链表的尾部。因为查找数据需要遍历链表,所以单纯用链表实现的 LRU 缓存淘汰算法的时间复杂度很高,是 O(n)。

实际上,总结一下,一个缓存(cache)系统主要包括下面这几个操作:

  • 往缓存中添加一个数据
  • 从缓存中删除一个数据
  • 在缓存中查找一个数据

这三个操作都要涉及 “查找” 操作,如果单纯地采用链表的话,时间复杂度只能是 O(n)。如果我们将散列表和链表两种数据结构组合使用,可以将这三个操作的时间复杂度都降低到 O(1)。具体的结构就是下面这个样子:

我们使用双向链表存储数据,链表中的每个结点处理存储数据(data)、前驱指针(prev)、后继指针(next)之外,还新增了一个特殊的字段 hnext。这个 hnext 有什么作用呢?

因为我们的散列表是通过链表法解决散列冲突的,所以每个结点会在两条链中。一个链是刚刚我们提到的双向链表,另一个链是散列表中的拉链。前驱和后继指针是为了将结点串在双向链表中,hnext 指针是为了将结点串在散列表的拉链中。

拉链就是指散列表hash冲突所维护的那个单链表。 双向链表的prev和next指针是纵向指针,hnext是横向指针,hnexh维护的是散列表解决冲突的单链表,prev和next指针的双链表维护的是数据缓存的时间线,决策对节点的淘汰和增加。

了解这个散列表和双向链表的组合存储结构之后,我们再来看,前面讲到的缓存的三个操作,是如何操作时间复杂度是 O(1) 的。

首先,我们来看如何查找一个数据。散列表查找数据的时间复杂度接近于 O(1),所以通过散列表,我们可以很快地在缓存中找到一个数据。当找到数据之后,我们还需要将它移动到双向链表的尾部。

其次,我们来看如何删除一个数据。我们需要找到数据所在的结点,然后将结点删除。借助散列表,我们可以在 O(1) 时间复杂度找到要删除的结点。因为我们的链表是双向链表,双向链表可以通过前驱指针 O(1) 时间复杂度获取前驱结点,所以在双向链表中,删除结点只需要 O(1) 的时间复杂度。

最后,我们来看如何添加一个数据。添加数据到缓存稍微有点麻烦,我们需要先看这个数据是否已经在缓存中。如果已经在其中,需要将其移动到双向链表的尾部;如果不在其中,还要看缓存中有没有满。如果满了,则将双向链表头部的结点删除,然后再将数据放到链表的尾部;如果没有满,就直接将数据放到链表的尾部。

这整个过程涉及的查找操作都可以用散列表来完成。其他的操作,比如删除头结点、链表尾部插入数据等,都可以在 O(1) 的时间复杂度内完成。所以,这三个操作的时间复杂度都是 O(1)。至此,我们就通过散列表和双向链表的组合使用,实现了一个高效的、支持 LRU 缓存淘汰算法的缓存系统原型。

Redis 有序集合

研究跳表时,说到有序集合的操作时,当时做了简化。实际上,在有序集合中,每个成员对象有两个重要的属性,key(键值)和 score (分值)。我们不仅会通过 score 来查找数据,还会通过 key 来查找数据。

举个例子,比如用户积分排行榜有这样一个功能:我们可以通过用户的 ID 来查找积分信息,也可以通过积分区间来查找用户 ID 或者姓名信息。这里包括 ID、姓名和积分的用户信息,就是成员对象,用户 ID 就是 key,积分就是 score。

所以,如果我们细化一下 Redis 有序集合的操作,那就是下面这样:

  • 添加一个成员对象;
  • 按照键值来删除一个成员对象;
  • 按照键值来查找一个成员对象;
  • 按照分值区间查找数据,比如查找积分在 [100, 356] 之间的成员对象;
  • 按照分值从小到大排序成员变量;

如果我们仅仅按照分值将成员对象组织成跳表的数据结构,那按照键值来删除、查询成员对象就会很慢,解决方法与 LRU 缓存淘汰算法的解决方法类似。我们可以再按照键值构建一个散列表,这样按照 key 来删除、查找一个成员对象的时间复杂度就变成了 O(1)。同时,借助跳表结构,其他操作也非常高效。

实际上,Redis 有序集合的操作还有另外一类,也就是查找成员对象的排名(Pank)或者根据排名区间查找成员对象。这个功能单纯用刚刚讲的这种组合结构就无法高效实现了。这块内容后面再细究。

Java LinkedHashMap

如果你熟悉 Java,那你几乎天天都会用到这个容器。HashMap 底层是通过散列表这种数据结构实现的。而 LinkedHashMap 前面比 HashMap 多了一个 “Linked”,这里的 “Linked” 是不是代表使用链表法解决散列冲突?

实际上,LinkedHashMap 并没有这么简单,其中的 “linked” 也不仅仅代表它是通过链表法解决散列冲突的。

先来看一段代码。你觉得这段代码会以什么样的顺序打印 3,1,5,2 这几个 key?为什么会这样打印?

java
HashMap<Integer, Integer> m = new LinkedHashMap<>();

m.put(3, 11);
m.put(1, 12);
m.put(5, 23);
m.put(2, 22);

for (Map.Entry e : m.entrySet()) {
  System.out.println(e.getKey());
}

先告诉你答案,上面的代码是按照数据插入的顺序依次来打印,也就是说,打印的顺序就是 3,1,5,2。

散列表的数据不是经过散列函数打乱之后无规律存储的嘛?这里是如何实现按照数据的插入顺序来遍历打印的呢?

你这会儿可能已经猜到了,LinkedHashMap 也是通过散列表和链表组合在一起实现的。实际上,它不仅支持按照插入顺序遍历数据,还支持按照顺序来遍历数据。你可以看下面这段代码。

java
// 10是初始大小,0.75 是装载因子,true 是表示按照访问时间排序
HashMap<Integer, Integer> m = new LinkedHashMap<>(10, 0.75f, true);

m.put(3, 11);
m.put(1, 12);
m.put(5, 23);
m.put(2, 22);

m.put(3, 26);
m.get(5);

for (Map.Entry e : m.entrySet()) {
  System.out.println(e.getKey());
}

这段代码打印的结果是 1,2,3,5。来具体分析一下,为什么这段代码会按照这样的顺序来打印。

每次调用 put() 函数,往 LinkedHashMap 中添加数据,都会将数据添加到链表的尾部,所以,在前面操作完成之后,链表中的数据是下面这样:

在第 9 行代码中,再次将键值为 3 的数据放入到 LinkedHashMap 的时候,会先查找这个键值是否已经存在,然后,再将已经存在的 (3, 11) 删除,并且将新的 (3, 26) 放到链表的尾部。所以,这个时候链表中的数据就是下面这样。

当第 10 行代码访问到 key 为 5 的数据的时候,我们将访问到数据移动到链表的尾部。所以,第 9 行代码之后,链表中的数据是下面这样:

所以,最后打印出来的数据是 1,2,3,5。从上面的分析,你有没有发现,按照访问时间排序的 LinkedHashMap 本身就是一个支持 LRU 缓存淘汰策略的缓存系统?实际上,它们两个的实现原理也是一模一样的。

实际上,LinkedHashMap 是通过双向链表和散列表这种数据结构组合实现的。LinkedHashMap 中的 “Linked” 实际上指的是双向链表,并非指用链表法解决散列冲突。

java 1.8 中 linkedhashmap 的实现原理就是通过散列表数组 + 双向链表 + 处理散列冲突的横拉链表/红黑树实现的,原理同 LRU。jdk1.7 没有红黑树,易退化成链表。 另外,其缺点是线程不安全,jdk 1.7 中,它采用头插法(新数据插入双向链表头部),但不同步, 因此当两个线程同时插入数据,且需要扩容时,新插入的数据和与其连接的上一个数据会互相指向对方形成链表环。因此 jdk1.8改成了尾插法。但依然有不同步导致数据被覆盖的问题(类似 CAS 的 ABA 问题)。 所以多线程要使用线程安全的ConcurrentHashMap(其散列冲突策略与 hashmap 一致。因为它也属于 hashmap 的子类)。其实现原理是 volatile 管变量,cas结合 synchronized 进行赋值,形成针对指向索引节点的 分段锁。 除此之外还有 hashtable 也是线程安全,但其对整个方法加了synchronized,锁住的粒度太大,影响并发性能,jdk 1.8 之后已不推荐使用。

总结

散列表来源于数组,它借助散列函数对数组这种数据结构进行扩展,利用的是数组支持按照下标随机访问的元素的特性。散列表两个核心问题是散列函数和散列冲突解决。散列冲突有两种常用的解决方法,开放寻址和链表法。散列函数设计的好坏决定了散列冲突的概率,也就决定散列表的性能。

关于散列表的设计,我们要尽可能让散列后的值随机且均匀分布,这样会尽可能地减少散列冲突,即便冲突之后,分配到每个槽内的数据也比较均匀。除此之外,散列函数的设计也不能太复杂,太复杂就会太耗时间,也会影响散列表的性能。

关于散列表解决方法的选择,大部分情况下,链表法更加普适。而且,我们还可以通过链表法中的链表改造其他动态查找数据结构,比如红黑树、来避免散列表时间复杂度退化成 O(n),抵御散列碰撞攻击。但是,对于小规模数据、装载因子不高的散列表,比较适合用开放寻址法。

对于动态散列表来说,不管我们如何设计散列函数,选择什么样的散列冲突解决方法。随着数据的不断增加,散列表总会出现装载因子过高的情况。这个时候,我们就需要启动动态扩容。

散列表这种数据结构虽然支持高效的插入、删除、查找操作,但是散列表中的数据都是通过散列函数打乱之后无规律存储的。也就是说,它无法支持按照某种顺序快速地遍历数据。如果希望按照顺序遍历散列表中的数据,那我们需要将散列表中的数据拷贝到数组中,然后排序,再遍历。

因为散列表是动态数据结构,不停地有数据的插入、删除。所以每当我们希望按顺序遍历列表中的数据时,都需要先排序,按效率必然会很低。为了解决这个问题,我们将散列表和链表(或者跳表)结合在一起使用。

技术拓展

1. word 文档中单词拼写检查功能是如何实现的?

常用的英文单词有 20 万个左右,假设单词的平均长度是 10 个字母,平均一个单词占用 10 的字节的内存空间,那 20 万英文单词大约占 2 MB 的存储空间,就算放大 10 倍也就是 20 MB。对于现在的计算机来说,这个大小完全可以放在内存里面。所以我们可以用散列表来存储整个英文单词词典。

当用户输入某个英文单词时,我们拿用户输入的单词去散列表中查找。如果查找,则说明拼写正确;如果没有查找,则说明拼写可能错误,给予提示。借助散列表这种数据结构,我们就可以轻松实现快速判断是否存在拼写错误。

2. 假设有 10 万条 URL 访问日志,如何按照访问次数给 URL 排序?

遍历 10 万条数据,以 URL 为 key,访问次数为 value,存入散列表中,同时记录访问次数的最大值 k,时间复杂度是 O(n)。

如果 K 不是很大,可以使用桶排序,时间复杂都为 O(n)。如果 k 非常大,可以使用快排,复杂度为 O(nlogn)。

3. 有两个字符串数组,每个数组大约有 10 万条字符串,如何快速找出两个数组中相同的字符串?

以第一个字符串构建散列表,key 为字符串,value 为出现次数。再遍历第二个字符串,以字符串为 key 在散列表中查找,如果 value 大于零,说明存在相同字符串。时间复杂度为 O(n)。

4. 如何设计一个工业级的散列函数?

首先,什么是一个工业级的散列表?工业级的散列表应该具有哪些特性?

  • 支持快速地查询、插入、删除操作;
  • 内存占用合理,不能浪费过多的内存空间;
  • 性能稳定,极端情况下,散列表的性能不会退化到无法接受的情况。

如何实现这样的一个散列表?

  • 设计一个合适的散列函数;
  • 定义装载因子阈值,并且设计动态扩容策略;
  • 选择合适的散列冲突解决方法;

具体如何选择,还要结合具体的业务场景、具体的业务数据来具体分析。

5. 你熟悉的编程语言,哪些数据类型是基于散列表实现的?

你熟悉的编程语言中,哪些数据类型底层是基于散列表实现的?

散列函数是如何设计的?散列冲突是通过哪种方法解决的?是否支持动态扩容呢?

。。。

6. 挑战性问题

假设猎聘网有 10 万名猎头,每个猎头都可以通过做任务(比如发布职位)来积累积分,然后通过积分来下载简历。假设你是猎聘网的一名工程师,如何在内存中存储这 10 万个猎头 ID 和积分信息,让它能够支持这样几个操作:

  • 根据猎头的 ID 快速查找、删除、更新这个猎头的积分信息;
  • 查找积分在某个区间的猎头 ID 列表;
  • 查找按照积分从小到大排名在第 x 位到第 y 位之间的猎头 ID 列表。

以积分排序构建一个跳表,再以猎头 ID 构建一个散列表。

  • ID 在散列表中所以可以 O(1) 查找到这个猎头;
  • 积分以跳表存储,跳表支持区间查询;

十二、哈希算法

还记得 2011 年 CSDN 的 ”脱库“ 事件吗?当时 CSDN 网站被黑客攻击,超过 600 万用户的注册邮箱和密码明文被泄露,很多网友对 CSDN 明文保存用户密码行为产生不满。如果你是 CSDN 的一名工程师,你会如何存储用户密码这么重要的数据?仅仅 MD5 加密存储一下就可以吗?要想搞清楚这个问题,就要先弄明白哈希算法。

哈希算法历史悠久,业界著名的哈希算法也有很多,比如 MD5、SHA 等。在我们平时开发中,基本上都是拿现成的直接用。所以,今天的重点也不是剖析哈希算法的原理,也不会说如何设计一个哈希算法,而是从实战的角度分析,在实际的开发中,我们该如何用哈希算法解决问题。

什么是哈希算法

我们经常听到有人把 ”散列表“ 叫做 ”哈希表“ ”Hash 表“,把 ”哈希算法“ 叫做 ”Hash 算法“ 或者 ”散列算法“。那到底什么是哈希算法?

哈希算法的定义和原理非常简单,一句话就可以概括。将任意长度的二进制值串映射为固定长度的二进制数组,这个映射的规则就是哈希算法,而通过原始数据映射之后得到的二进制串就是哈希值。但是,要想设计一个优秀的哈希算法并不容易,一般要满足以下要求:

  • 从哈希值不能反向推导出原始数据(哈希算法也叫单向哈希算法);
  • 对输入数据非常敏感,哪怕原始数据值修改一个 Bit,最后得到的哈希值也不相同;
  • 散列冲突的概率要很小,对于不同的原始数据,哈希值相同的概率非常小;
  • 哈希算法的执行效率要尽量高效,针对较长的文本,也能快速地计算出哈希值。

哈希算法要处理的文本可能是各种各样的。比如,对于非常长的文本,如果哈希算法的计算时间很长,那就只能停留在理论研究的层面,很难应用到实际的软件开发中。比如,我们把一篇 4000 多字的文章,用 MD5 计算哈希值,用不到 1ms 的时间。

哈希算法的应用非常多,这里选了最常见的七个进行介绍,分别是 安全加密、唯一标识、数据校验、散列函数、负载均衡、数据分片、分布式存储。

应用场景

安全加密

哈希算法最常见的应用场景就是安全加密。最常用于加密的哈希算法是 MD5(MD5 Message-Digest Algorithm,MD5 消息摘要算法)和 SHA(Secure Hash Algorithm,安全散列算法)。

MD5 摘要算法,可以理解为数字签名,不是传统意义上的加密算法。

除了这个两个之外,还有很多其他的加密算法,比如 DES(Data Encryption Standard,数据加密标准)、AES(Advanced Encryption Standard,高级加密标准)。

上面说的哈希算法的四点要求,对用于加密哈希算法来说,有两点格外重要。第一点是很难根据哈希值反向推导出原始数据,第二点是散列冲突的概率要很小。

第一点很好理解,加密的目的就是防止原始数据泄露,所以很难通过哈希值反向推导原始数据,这是一个最基本的要求。实际上,不管是什么哈希算法,我们只能尽量减少碰撞冲突的概率,理论上是没办法做到完全不冲突的。

这里就基于组合数学中一个非常基础的理论,鸽巢理论(抽屉原理)。这个原理本身就很简单,它是说,如何有 10 个鸽巢,有 11 只鸽子,那肯定有 1 个鸽巢中的鸽子数量多于 1 个,换句话说就是,肯定有 2 个鸽子在 1 个鸽巢内。

为什么哈希算法无法做到零冲突?我们知道,哈希算法产生的哈希值的长度是固定有限的。比如 MD5,哈希值是固定的 128 位二进制串,能表示的数据是有限的,最多能表示 2^28 + 1 个数据,而我们要的哈希是无穷的。基于鸽巢原理,如果我们对 2 ^ 128 + 1 个数据求哈希值,就必然会存在哈希值相同的情况。所以,一般情况下,哈希值越长的哈希算法,散列冲突的概率越低。

js
2 ^ 128 = 340282366920938463463374607431768211456

不过,即便哈希算法存在散列冲突的情况,但是因为哈希值的范围很大,冲突的概率极低,所以相对来说还是很难破解的。像 MD5,有 2 ^ 128 个不同的哈希值,这个数据已经是一个天文数字了,所以散列冲突的概率要小于 1 / 2 ^ 128。

如果我们拿到一个 MD5 哈希值,希望通过毫无规律的穷举的方法,找到跟这个 MD5 值相同的另一个数据,那耗费的时间应该是个天文数字。所以,即便哈希算法存在冲突,但是在有有限的时间和资源中,哈希算法还是很难被破解的。

除此之外,没有绝对安全的加密。越复杂、越难破解的加密算法,需要的计算时间也越长。比如 SHA-256 比 SHA-1 要更复杂、更安全,相应的计算时间就会比较长。密码学界也一直致力于找到一种快速并且很难被破解的哈希算法。我们在实际的开发过程中,也需要权衡破解难度和计算时间,来决定究竟使用哪种加密算法。

唯一标识

如果在海量的图库中,搜索一张图是否存在,我们不能单纯地用图片的元信息(比如图片名称)来对比,因为可能存在名称相同但图片内容不同,或者名称不同图片内容相同的情况。那我们该如何搜索呢?

任何文件在计算中都可以表示为二进制码,所以,比较笨的办法就是,拿要查找的图片的二进制码与图库中所有图片的二进制码串一一比对。如果相同,则说明图片在图库中存在。但是,每个图片小则几十 KB、大则几 MB,转换为二进制是一个非常长的串,比对起来非常耗时。有没有比较快的方法呢?

我们可以给每一个图片取一个唯一标识,或者说信息摘要。比如,我们可以从图片的二进制码串开头取 100 个字节,从中间在取 100 个字节,从最后再取 100 个字节,然后将这个 300 个字节放到一块,通过哈希算法(比如 MD5),得到一个哈希字符串,用它作为图片的唯一标识。通过这个唯一标识来判断图片是否在图库中,这样就可以减少很多工作量。

如果还想继续提高效率,我们可以把每个图片的唯一标识,和相应的图片文件在图库中的路径信息,都存储在散列表中。当到查看某个图片是不是还在图库中时,我们先通过哈希算法对这个图片取唯一标识,然后在散列表中查找是否存在这个唯一标识。

如果不存在,那就说明这个图片不在图库中;如果存在,我们再通过散列表中存储的文件路径,获取到这个已经存在的图片,跟现在要插入的图片做全量的对比,看是否完全一样。如果一样,就说明已经存在;如果不一样,说明两张图片尽管唯一标识相同,但是并不是相同的图片。

数据校验

电驴这样的 BT 下载软件你肯定用过吧。我们知道,BT 下载的原理是基于 P2P 协议的。我们从多个机器上并行下载一个 2 GB 的电影,这电影文件可能会被分割成很多文件块(比如可以分成 100 块,每块大约 20 MB)。等所有的文件块都下载完成之后,再组装成一个完整的电影文件就行了。

网络传输是不安全的,下载的文件块有可能被宿主机器恶意修改过,又或者下载过程中出现了错误,所以下载的文件块可能是不完整的。如果我们没有能力检测这种恶意修改或者文件下载出错,就会导致最终合并后的电影无法观看,甚至导致电脑中毒。现在的问题是,如何来校验文件块的安全、正确、完整呢?

具体的 BT 协议很复杂,校验方法也有很多,这里只说其中的一种思路。

我们通过哈希算法,对 100 个文件快分别去哈希值,并且保存在种子文件中。哈希算法有一个特点,对数据敏感。只要文件块的内容有一丁点儿的改变,最后计算出的哈希值就会完全不同。所以,当文件块下载完成之后,我们可以通过相同的哈希算法,对下载好的文件逐一求哈希值,然后跟种子文件中保存的哈希值比对。如果不同,说明这个文件不完整或者被篡改了,需要重新从其它宿主机器上下载这个文件块。

散列函数

散列函数也是哈希算法的一种应用。

散列函数是设计一个散列表的关键。它直接决定了散列冲突的概率和散列表的性能。不过,相比哈希算法的其它应用,散列函数对于散列算法冲突的要求要低很多。即便出现个别散列冲突,只要不是过于严重,我们都可以通过开放寻址法或者链表法解决。

散列函数对于散列计算得到的值,是否能反向解析也并不关心。散列函数中用到的散列算法,更加关注散列后的值是否能平均分布,也就是,一组数据是否能均匀地散列在各个槽中。除此之外,散列函数执行的快慢,也会影响散列表的性能,所以,散列函数用的散列算法一般都比较简单,比较追求效率。

负载均衡

负载均衡算法有很多,比如轮询、随机、加权轮询等。那如何才能实现一个会话粘滞(session sticky)的负载均衡算法呢?也就是说,我们需要在同一个客户端上,在一次会话中的所有请求都路由到同一个服务器上。

最直接的方法就是,维护一张关系映射表,这张表的内容是客户端 IP 地址或者会话 ID 与服务器编号的映射关系。客户端发出的每次请求,都要先在映射表中查找应该路由到的服务器编号,然后再请求编号对应的服务器。这种方法简单直观,但也有其弊端:

  • 如果客户端很多,映射表可能会很大,比较浪费内存空间;
  • 客户端下线、上线,服务器扩容、缩容都会导致映射失效,这样维护映射表的成本就会很大;

如果借助哈希算法,这些问题都可以非常完美地解决。我们可以通过哈希算法,对客户端 IP 地址或者会话 ID 计算哈希值,将取得的哈希值与服务器列表的大小进行取模运算,最终得到得值就是应该被路由到的服务器编号。这样,我们可以把同一个 IP 过来的所有请求,都路由到同一个后端服务器上。

数据分片

哈希算法还可以用于数据的分片。

如何统计 ”搜索关键词“ 出现的次数

假如我们有 1 T 的日志文件,这里面记录了用户的搜索关键词,我们想要快速统计每个关键词出现的次数,该怎么做?

这个问题有两个难点,第一个是搜索日志很大,没办法放到一台机器的内存中。第二个难点是,如果用一台机器来处理这么巨大的数据,处理时间会很长。

针对这两个难点,我们可以先对数据进行分片,然后采用多台机器处理的方法,来提高处理速度。具体的思路是这样的:为了提高处理速度,我们用 n 台机器并行处理,再从搜索记录的日志文件中,依次读出每个搜索关键词,并且通过哈希函数计算哈希值,然后再跟 n 取模,最终得到的值,就是应该被分配的机器编号。

这样,哈希值相同的搜索关键词就被分配到同一个机器上。也就是说,同一个搜索关键词会被分配到同一个机器上。每个机器会分别计算关键词出现的次数,最后合并起来就是最终的结果。

实际上,这里的处理过程也是 MapReduce 的基本设计思想。

先将总数据分解为并行处理的每一条,再将处理好的每一条合并,就是处理好的总数据。 分而治之思想。

如何判断图片是否在图库中

判断图片在图库中,上面我们介绍过一种方法,即给每个图片取唯一标识(信息摘要),然后构建散列表。

假设现在我们的图库中有 1 亿张图片,很显然,在单台机器上构建散列表是行不通的。因为单台机器的内存有限,而一亿张图片构建散列表虽然远远超过了单台机器的内存上限。

我们同样可以对数据进行分片,然后采用多机处理。准备 n 台机器,让每台机器只维护某一部分图片对应的散列表。我们每次从图库中读取一个图片,计算唯一标识,然后与机器个数 n 求余取模,得到的值就对应要分配的机器编号,然后将这个图片的唯一标识和图片路径发往对应的机器构建散列表。

当我们要判断一个图片是否在图库中时,可以通过同样的哈希算法,计算这个图片的唯一标识,然后与机器个数 n 求余取模。假设得到的值是 k,那就去编号 k 的机器构建的散列表中去找。

那么,给这 1 亿张图片构建散列表大约需要多少台机器?

散列表中每个数据单元包含两个信息,哈希值和图片文件的路径。假设我们通过 MD5 来计算哈希值,那长度就是 128 比特,也就是 16 字节。文件路径长度的上限是 256 字节,我们假设平均长度是 128 字节。如果我们用链表法来解决冲突,那还需要存储指针,指针占用 8 字节。所以,散列表中每个数据单元就占用 152 字节(估算值,并不精确)。

假设一台机器的内存大小是 2 GB,散列表的装载因子为 0.75,那一台机器可以给大约 1000 万(2 GB * 0.75 / 152)张图片构建散列表。所以,如果要对 1 亿张图片构建索引,需要大约是十几台机器。在工程中,这种估算还是很重要的,能让我们事先对需要投入的资源、资金有个大概的了解,能更好地评估解决方案的可行性。

实际上,针对这种海量数据的处理问题,我们都可以采用多机分布式处理。借助这种分片的思路,可以突破单机内存、CPU 等资源限制。

分布式存储

现在互联网面对的都是海量的数据、海量的用户。我们为了提高数据的读取、写入能力,一般都采用分布式的方式来存储数据,比如分布式缓存。当我们有海量的数据需要缓存,一个缓存机器肯定是不够的。所以,我们就需要将数据分布在多台机器上。

如何决定将哪个数据放到哪个机器上呢?我们可以借助前面数据分片的思想,即通过哈希算法对数据取哈希值,然后对机器个数取余,然后对机器个数取模,这个最终值就是应该要存储的缓存机器编号。

但是,如果数据增多,原来的 10 个机器已经无法承受,就需要扩容了。比如扩容到 11 个机器,这时候就存在问题了。因为,这里并不是简单地加个机器就可以了。

原来的数据是通过与 10 来取模的。比如 13 这个数据,存储在编号为 3 这台机器上。但是新加了一台机器,我们对数据按照 11 取模,原来 13 这个数据就被分配到 2 号这台机器上。因此,所有的数据都要重新计算哈希值,然后重新搬移到正确的机器上。这样就相当于,缓存中的数据一下子就都失效了。所有的数据请求都会穿透缓存,直接去请求数据库。这样就可能发生雪崩效应,压跨数据库。

所以,我们需要一种方法,使得新加入一个机器后,并不需要做大量的数据搬移。这时候,一致性哈希算法就要登场了。

假设我们有 k 个机器,数据的哈希值的范围是 [0, MAX]。我们整个范围划分成 m 个小区间(m 远大于 k),每个机器负责 m/k 个小区间。当有新机器加入的时候,我们就将某几个小区间的数据,从原来的机器中搬移到新的机器。这样,既不用全部重新哈希、搬移数据,也保持了各个机器上数据数量的均衡。

一致性哈希算法的基本思想就是这么简单。除此之外,它还会借助一个虚拟的环和虚拟结点,更加优美地实现出来。有兴趣研究的话,可以看这个介绍

除了上面讲的分布式缓存,实际上,一致性哈希算法的应用非常广泛,在很多分布式存储系统中,都可以见到一致性哈希算法的影子。

总结

哈希算法的应用场景

  • 唯一标识。哈希算法可以对大数据做信息摘要,通过一个较短的二进制编码来表示很大的数据;
  • 数据校验。校验数据的完整性的正确性;
  • 安全加密。哈希算法都会出现散列冲突,但是这个冲突概率非常小。越是复杂哈希算法越难破解,但同样计算时间也就越长。所以,选择哈希算法的时候,要权衡安全性和计算时间来决定用哪种哈希算法;
  • 散列函数。散列函数对哈希算法的要求比较特别,它更看重散列的平均性和哈希算法的执行效率;
  • 负载均衡;
  • 数据分片;
  • 分布式存储。

技术拓展

如何存储用户密码这样的私密数据?

我们可以通过哈希算法,对用户密码进行加密之后再存储,不过最好选择相对安全的加密算法,比如 SHA 等(因为 MD5 已经号称被破解了)。不过仅仅这样加密之后存储还不够。

不知道你是否听说过字典攻击。如果用户信息被 ”脱裤“,黑客虽然拿到的是加密之后的密文,但可以通过 ”猜“ 的方式破解密码,这是因为,有些用户的密码太简单。比如很多人习惯用 00000、123456 这样的简单数字组合做密码,很容易被猜中。

我们需要维护一个常用密码的字典表,把字典中的每个密码用哈希算法计算哈希值,然后拿哈希值跟脱库后的密文比对。如果相同,基本上就可以认为,这个加密之后的密码对应的明文就是字典中的这个密码。

注意,上面说的是 ”基本上可以认为“,因为哈希算法存在散列冲突,也有可能出现密文一样,但是明文并不一样的情况。

针对字典攻击,我们可以引入一个盐(salt),跟用户的密码组合在一起,增加密码的复杂度。我们拿组合之后的字符串来做哈希算法加密,将它存储到数据库中,进一步增加破解的难度。不过,安全和攻击是一种博弈关系,不存在绝对的安全。所有的安全措施,只是增加攻击的成本而已。

加salt,也可理解为为密码加点佐料后再进行 hash 运算。比如原密码是 123456,不加盐的情况加密后假设是是 xyz。 黑客拿到脱机的数据后,通过彩虹表匹配可以轻松破解常用密码。如果加盐,密码 123456 加盐后可能是 12ng34qq56zz,再对加盐后的密码进行hash 后值就与原密码 hash 后的值完全不同了。而且加盐的方式有很多种,可以是在头部加,可以在尾部加,还可在内容中间加,甚至加的盐还可以是随机的。这样即使用户使用的是最常用的密码,黑客拿到密文后破解的难度也很高。

除了 hash + salt,现在大多公司都采用无论密码长度多少,计算字符串 hash 时间都固定或者足够慢的算法如PBKDF2WithHmacSHA1,来降低硬件计算 hash 速度,减少不同长度字符串计算 hash 所需时间不一样而泄漏字符串长度信息,进一步减少风险。

区块链使用的是哪种哈希算法?

区块链是一个很火的领域,它被很多人神秘化,不过底层的实现原理并不复杂。其中,哈希算法就是它的一个非常重要的理论基础。你能讲一讲区块链使用的是哪种哈希算法?是为了解决什么问题而使用的呢?

区块链是一块块区块组成的,每个区块分为两部分:区块头和区块体。 区块头保存着自己的区块体和上一个区块头的哈希值。 因为这种链式关系和哈希值的唯一性,只要区块链上任意一个区块被修改过,后面所有区块保存的哈希值就不对了。

区块链使用的是 SHA256 哈希算法,计算哈希值非常耗时,如果要篡改一个区块,就必须重新计算该区块后面所有的区块的哈希值,短时间内几乎不可能做到。

如何才能实现一个会话粘滞的负载均衡算法呢??

在负载均衡应用中,利用哈希算法替代映射表,可以实现一个会话粘滞的负载均衡策略。在数据分片应用中,通过哈希算法对处理的海量数据进行分片,多机分布式处理,可以突破单机资源的限制。在分布式存储应用中,利用一致性哈希算法,可以解决缓存等分布式系统的扩容、缩容导致数据大量搬移的难题。

哈希算法其他的应用场景

网络协议中的 CRC 校验、Git commit id。

十三、二叉树

之前学习的都是线性表结构,栈、队列等。今天开始学习一种非线性表结构,树。

思考:二叉树有哪几种存储方式?什么样的二叉树适合用数组来存储。

树(Tree)

“树” 这种数据结构很像我们现实生活中的 “树”,这里面每个元素我们叫做 “节点”。用来连接相邻节点之间的关系,我们叫做 “父子关系”。

比如下面这幅图,A 节点就是 B 节点的父节点,B 节点是 A 节点的子节点。B、C、D 这三个节点的父节点是同一个节点,所以它们之间互称为兄弟节点。我们把没有父节点的节点就做根节点,也就是图中的节点 E。我们把没有子节点的节点就做叶子节点或者叶节点,比如图中的 G、H、I、J、K、L 都是叶子节点。

除此之外,关于 ”树“,还有三个比较相似的概念:高度(Height)、深度(Depth)、层(Level)。它们的定义如下:

  • 节点高度:节点到叶子节点的最长路径(边数)
  • 节点深度:根节点到这个节点所经历的边的个数
  • 节点层数:节点的深度 + 1
  • 树的高度:根节点的高度

这几个概念的定义比较容易被混淆,描述起来比较空洞。

记这几个概念,还有一个窍门,就是类比 ”高度“ "深度" ”层“ 这几个名词在生活中的含义。

在我们的生活中,”高度“ 这个概念,其实就是从下往上度量,比如我们要度量第 10 层楼的高度、第 13 层楼的高度,起点都是地面。所以,树这种数据结构的高度也是一样,从最底层开始计数,并且计数的起点是 0。

”深度“ 这个概念在生活中是从上往下度量的,比如水中鱼的深度,是从水平面开始度量的。所以,树这种数据结构的深度也是类似的,从根节点开始度量,并且计数起点也是 0。

”层数“ 跟深度的计算类似,不过,计数起点是 1,也就是说根节点位于第 1 层。

二叉树(Binary Tree)

树结构多种多样,不过我们常用的还是二叉树。

二叉树,顾名思义,每个节点最多有两个 ”叉“,也就是两个子节点,分别是左子节点和右子节点。不过,二叉树并不要求每个节点都有两个子节点,有的节点只有左子节点,有的节点只有右子节点。

上图中都是二叉树,里面还有两个比较特殊的二叉树。分别是第 2 个和 第 3 个。

其中,第二个二叉树中,叶子节点全在最底层,除了叶子节点之外,每个节点都有左右两个子节点,这种二叉树就做满二叉树

第三个二叉树中,叶子节点都在最底下两层,最后一层的叶子节点都靠左排列,并且除了最后一层,其它层的节点个数都要达到最大,这种二叉树叫做完全二叉树

你可能会说,满二叉树的特性非常明显,把它单独拎出来讲,可以理解。但是完全二叉树的特性不怎么明显,单从长相来说,完全二叉树并没有特别特殊的地方,更像是 ”芸芸众树“ 中的一种。

那我们为什么还要特意把它拎出来讲?为什么偏偏把最后一层的叶子节点靠左排列的叫做完全二叉树?如果靠右排列就不能叫完全二叉树了?这个定义的由来或者说目的在哪里?

要理解完全二叉树定义的由来,我们需要先了解,如何表示(存储)一棵二叉树。 要想存储一棵二叉树,我们有两种办法,一种是基于指针或者引用的二叉链式存储法,一种是基于数组的顺序存储法。

我们先来看比较简单、直观的链式存储法。从图中你可以清楚地看到,每个节点有三个字段,其中一个存储数据,另外两个指向左右子节点的指针。我们只要拿到根节点,就可以通过左右子节点的指针,把整棵树都串起来。这种存储方式我们比较常用,大部分二叉树都是通过这种代码结构来实现的。

我们再来看,基于数组的顺序存储法。我们把根节点存储在下标 i = 1 的位置,那左子节点存储在下标 2 * i = 2 的位置,右子节点存储在 2 * i + 1 = 3 的位置。以此类推,B 节点的左子节点存储在 2 * i = 2 * 2 = 4 的位置,右子节点存储在 2 * i + 1 = 2 * 2 + 1 = 5 的位置。

总结一下,如果节点 X 存储在数组中下标为 i 的位置,下标为 2 * i 的位置存储的就是左子节点,下标为 2 * i + 1 的位置存储的就是右子节点。反过来,下标为 i / 2 的位置存储就是它的父节点。通过这种方式,我们只要知道根节点存储的位置(一般情况下,为了方便计算子节点,根节点会存储在下标为 1 的位置),这样就可以通过下标计算,把整棵树串起来。

不过,刚才举的例子是一颗完全二叉树,所以仅仅 ”浪费了“ 了一个下标为 0 的存储位置。如果是非完全二叉树,其实会浪费比较多的数组存储空间。

所以,如果某棵二叉树是一颗完全二叉树,那用数组存储无疑是最节省内存的一种方式。因为数组的存储方式并不需要向链式存储法那样,要存储额外的左右子节点的指针。这也是为什么完全二叉树会单独拎出来说的原因,也是为什么完全二叉树要求最后一层的子节点都靠左的原因。

堆和堆排序的时候,你会发现,堆其实就是一种完全二叉树,最常用的存储方式就是数组。

二叉树的遍历

如果将所有节点都遍历打印出来?经典的方法有三种,前序遍历、中序遍历和后序遍历。其中,前、中、后序,表示的是节点与它的左右子树节点遍历打印的线后顺序。

  • 前序遍历指,对于树中的任意节点来说,先打印这个节点,然后再打印它的左子树,最后打印它的右子树;
  • 中序遍历指,对于树中的任意节点来说,先打印它的左子树,然后再打印它本身,最后打印它的右子树;
  • 后序遍历指,对于树中的任意节点来说,先打印它的左子树,然后再打印它的右子树,最后打印这个节点本身。

实际上,二叉树的前、中、后序遍历就是一个递归的过程。比如,前序遍历,其实就是先打印根节点,然后再递归地打印左子树,最后递归的打印右子树。

编写递归代码的关键,就是看能不能写出递归公式,而写出递归公式的关键就是,如果要解决问题 A,就假设子问题 B、C 已经解决,然后再来看如何利用 B、C 来解决 A。所以,我们可以把前、中、后序遍历的递归公式都写出来。

js
前序遍历的递推公式:
preOrder(r) = print r -> preOrder(r->left) -> preOrder(r->right)

中序遍历的递推公式:
inOrder(r) = inOrder(r->left) -> print r -> inOrder(r->right)

后序遍历的递推公式:
postOrder(r) = postOrder(r->left) -> postOrder(r->right) -> print r

有了递归公式,代码写起来就简单多了。

js
const preorder = (root) => {
  if (!root) return;

  console.log(root.val);

  preorder(root.left);
  preorder(root.right);
}

const inorder = (root) => {
  if (!root) return;

  inorder(root.left);
  console.log(root.val);
  inorder(root.right);
}

const postorder = (root) => {
  if (!root) return;

  postorder(root.left);
  postorder(root.right);
  console.log(root.val);
}

二叉树的前、中、后序遍历的递归实现是不是很简单?递归过程中,每个节点最多会被访问两次,所以遍历操作的时间复杂度,跟节点的个数 n 成正比,也就是说二叉树遍历的时间复杂度是 O(n)。

2次具体指的是,一次是获取该节点的左子节点对象,另一次是获取该节点的右子节点对象。

二叉查找树(Binary Search Tree)

二叉查找树最大的特点就是,支持动态数据集合的快速插入、删除、查找操作。

散列表也是支持上述操作的,并且散列表的这些操作比二叉查找树更高效,时间复杂度是 O(1)。既然存在了高效的散列表,使用二叉树的地方是不是都可以替换成散列表?有没有哪些地方是散列表做不了,必须要用二叉树来做。

二叉查找树是二叉树中最常用的一种类型,也叫二叉搜索树。顾名思义,二叉查找树是为了快速查找而生的。不过,它不仅仅支持快速地查找一个数据,还支持快速地插入、删除一个数据。

这些都依赖于二叉查找树的特殊结构。二叉查找树要求,在书中的任意一个节点,其左子树中的每个节点的值,都要小于这个节点的值 ,而右子树节点的值都要大于这个节点的值。下面有几个二叉查找树的例子,你一看就明白了。

查找操作

首先,我们看如何在二叉查找树中查找一个节点。我们先取根节点,如果它等于我们要查找的数据,就返回。如果要查找的数据比根节点的值小,那就在左子树中递归查找;如果要查找的数据比根节点大,那就在右子树中递归查找。

js
class Node {
  data = null;
  left = null;
  right = null;

  constructor (data) {
    this.data = data;
  }
}

class BinarySearchTre {
  tree = null;
  
  find (data) {
    let p = this.tree;

    while (p != null) {
      if (data < p.data) {
        p = p.left;
      } else if (data > p.data) {
        p = p.right;
      } else {
        return p;
      }
    }

    return null;
  }
}

插入操作

二叉查找树的插入过程有点类似查找操作。新插入的数据一般都是在叶子节点上,所以我们只需要从根节点开始,依次比较要插入的数据和节点的大小关系。

如果要插入的数据比节点的数据大,并且节点的右子树为空,就将新数据直接插到右子节点的位置;如果不为空,就再递归遍历右子树。查找插入位置;如果要插入的数据比节点数值小。并且节点的左子树为空,就将新数据插入到左子节点的位置;如果不为空,就再递归遍历左子树,查找插入位置。

js
class BinarySearchTre {
  tree = null;

  find (data) {
    let p = this.tree;

    while (p != null) {
      if (data < p.data) {
        p = p.left;
      } else if (data > p.data) {
        p = p.right;
      } else {
        return p;
      }
    }

    return null;
  }

  insert (data) {
    if (this.tree == undefined) {
      this.tree = new Node(data);
      return;
    }

    let p = this.tree;

    while (p != null) {
      if (data > p.data) {
        if (p.right == null) {
          p.right = new Node(data);
          return;
        }
        p = p.right;
      } else {
        if (p.left == null) {
          p.left = new Node(data);
          return;
        }
        p = p.left;
      }
    }
  }
}

删除操作

二叉树的查找、插入操作都比较简单,但是删除操作就比较复杂了。针对要删除节点的子节点个数不同,我们需要分三种情况处理。

第一种情况是,如果要删除的节点没有子节点,我们只需要直接将父节点中,指向要删除节点的指针关系为 null。

第二种情况是,如果要删除的节点只有一个子节点(只有左子节点或者右子节点),我们只需要更新父节点,指向要删除节点的指针,让它指向要删除节点的子节点就可以。

第三种情况是,如果要删除的节点有两个子节点。我们需要找到这个节点的右子树中的最小节点,把它替换到删除的节点上,然后再删除这个最小节点,因为最小节点肯定没有左子节点(如果有左子节点,那就不是最小节点了),所以,我们可以应用上面两条规则来删除这个最小节点。

js
class BinarySearchTre {
  tree = null;

  delete (data) {
    let p = this.tree;
    let pp = null;
    
    while (p != null && p.data != data) {
      pp = p;
      if (data > p.data) {
        p = p.right
      } else {
        p = p.left;
      }
    }

    if (p == null) return;

    if (p.left != null && p.right != null) {
      let minP = p.right;
      let minPP = p;

      while (minP.left != null) {
        minPP = minP;
        minP = minP.left;
      }

      p.data = minP.data;
      p = minP;
      pp = minPP;
    }

    let child;

    if (p.left != null) {
      child = p.left;
    } else if (p.right != null) {
      child = p.right;
    } else {
      child = null;
    }

    if (pp == null) {
      this.tree = child;
    } else if (pp.left == p) {
      pp.left = child;
    } else {
      pp.right = child;
    }
  }
}

实际上,关于二叉查找树的删除操作,还有个非常简单、取巧的方法,就是单纯将要删除的节点标记为 “已删除”,但不是真正从树中将这个节点去掉。这样原本删除的节点还需要存储在内存中,比较浪费内存空间,但是删除操作就变得简单很多。而且,这种处理方法也并没有增加插入、查找操作代码实现的难度。

其他操作

除了插入、删除、查找操作之外,二叉查找树还可以支持快速地查找最大节点和最小节点、前驱节点和后继节点。除了这几个操作之外,还有一个重要的特性,就是中序遍历二叉查找树,可以输出有序的数据序列,时间复杂度为 O(n),非常高效。因此,二叉查找树也叫做二叉排序树。

支持重复数据的二叉查找树

前面说二叉查找树的时候,默认树中节点存储的都是数字。很多时候,在实际的软件开发中,我们在二叉查找树中存储的,是一个包含很多字段的对象。我们利用对象的某个字段作为键值(key)来构建二叉查找树。我们把对象中的其他字段叫做卫星数据。

前面我们讲的二叉查找树的操作,针对的都是不存在键值相同的情况。那如果存储的两个对象键值相同,这种情况该怎么处理?这里有两种解决方法。

第一种方法比较容易。二叉查找树中每一个节点不仅会存储一个数据,因此我们可以通过链表和支持动态扩容的数组等数据结构,把值相同的数据都存储在同一个节点上。

第二种方法不是很好理解,不过更加优雅。每个节点仍然只存储一个数据。在查找插入位置的过程中,如果碰到一个节点的值与要插入的数据的值相同,我们就将这个要插入的值放到这个节点的右子树,也就是说,把这个新的插入的数据当作大于这个节点的值处理。

当要查找数据的时候,遇到值相同的节点,我们并不停止查找操作,而是继续在右子树中查找,知道遇到叶子节点,才停止。这样就可以把键值等于要查找值的所有节点都找出来。

对于删除操作,我们也需要先查找到每个要删除的节点,然后再按照前面讲的删除操作的方法,依次删除。

时间复杂度分析

二叉查找树的形态各式各样。比如下图中,对于同一组数据,构造了三种二叉查找树。它们的查找、插入、删除操作的执行效率都是不一样的。图中第一种二叉查找树,根节点的左右子树极度不平衡,已经退化成了链表,所以查找的时间复杂度就变成了 O(n)。

刚刚分析的其实就是最糟糕的一种情况,我们现在来分析一个最理想的情况,二叉查找树是一棵完全二叉树(或满二叉树)。这个时候,插入、删除、查找的时间复杂度是多少。

不管操作是插入、删除还是查找,时间复杂度其实都跟树的高度成正比,也就是 O(height)。既然这样,现在问题就转变成另外一个,也就是,如何求一棵包含 n 个节点的完全二叉树的高度。

树的高度就等于最大层数减一,为了方便计算,我们转换成层来表示。从图中可以看出,包含 n 个节点的完全二叉树中,第一层包含 1 个节点,第二层包含 2 个节点,第三层包含 4 个节点,依此类推,下面一层节点个数是上一层的 2 倍,第 K 层包含的节点个数就是 2 ^ (K - 1)。

不过,对于完全二叉树来说,最后一层的节点个数其实不遵循上面的规律。它包含的节点个数在 1个到 2 ^ (L - 1) 个之间(我们假设最大层数是 L)。如果我们把每一层的节点个数加起来就是总的节点个数 n。也就是说,如果节点的个数是 n,那么 n 满足这样一个关系:

js
n >= 1 + 2 + 4 + 8 + ... + 2 ^ (L-2) + 1
n <= 1 + 2 + 4 + 8 + ... + 2 ^ (L-2) + 2 ^ (L-1)

借助等比数列的求和公式,我们可以计算出,L 的范围是[ log2(n+1), log2n +1 ]。完全二叉树的层数小于等于 log2n +1,也就是说,完全二叉树的高度小于等于 log2n。

显然,极度不平衡的二叉查找树,它的查找性能肯定不能满足我们的需求。我们需要构建一个不管怎么删除、插入数据,在任何时候,都能保持任意节点左右子树都比较平衡的二叉查找树,这就是后面我们要说的,平衡二叉查找树,它是一种特殊的二叉查找树。平衡二叉查找树的高度接近于 logn,所以插入、删除、查找操作的时间复杂度也比较稳定,是 O(logn)。

总结

树,一种非线性表数据结构。关于树,有几个比较常用的概念需要你掌握,那就是:根节点、叶子节点、父节点、子节点、兄弟节点,还有节点的高度、深度、层数,以及树的高度。

我们平时最常用的树就是二叉树。二叉树的每个节点最多有两个子节点,分别是左子节点和右子节点。二叉树中,有两种比较特殊的树,分别是满二叉树和完全二叉树。满二叉树又是完全二叉树的一种特殊情况。

二叉树既可以用链式存储,也可以用数组顺序存储。数组顺序存储的方式比较适合完全二叉树,其它类型的二叉树用数组存储会比较浪费存储空间。除此之外,二叉树里非常重要的操作就是前、中、后序遍历操作,遍历的时间复杂度是 O(n),你需要理解并能用递归代码来实现。

二叉查找树,支持快速地查找、插入、删除操作。二叉查找树中,每个节点的值都大于左子树节点的值,小于右子树节点的值。不过,这只是针对没有重复数据的情况。对于存在重复数据的二叉查找树,有两种构建方法,一种是让每个节点存储多个值相同的数据;另一种是,每个节点中存储一个数据。针对这种情况,我们只需要稍加改造原来的插入、删除、查找操作即可。

二叉查找树中,查找、插入、删除等很多操作的时间复杂度都跟树的高度成正比。两个极端情况的时间复杂度分别是 O(n) 和 O(logn),跟别对应二叉树退化成链表和完全二叉树。为了避免时间复杂度的退化,还有一种更加复杂的树,平衡二叉查找树,时间复杂度可以做到稳定的 O(logn)。

技术拓展

给定一组数组 1,3,5,6,9,10,可以构建出多少种不同的二叉树

如果是完全二叉树,老师说过可以放在数组里面,那么问题是否可以简化为数组内的元素有多少种组合方式,这样的话,就是 n!。

二叉树层序遍历如何实现

js
const bfs = (root) => {
  const queue = [root];

  while (queue.length) {
    const n = queue.shift();

    console.log(n.val);

    n.children.forEach(child => {
      queue.push(child);
    });
  }
}

相对于散列表,为什么要使用二叉查找树?

散列表的插入、删除、查找操作的时间复杂度可以做到常量级的 O(1),非常高效。而二叉查找树在比较平衡的情况下,插入、删除、查找操作时间复杂度才是 O(logn),相对于散列表,好像没有什么优势,那我们为什么还要用二叉查找树?

第一,散列表中的数据是无序存储的,如果要输出有序的数据,需要先进行排序。对于二叉查找树来说,只需要中序遍历,就可以在 O(n) 的时间复杂度内,输出有序的数据序列。

第二,散列表扩容耗时很多,而且当遇到散列冲突时,性能不稳定,尽管二叉查找树的性能不稳定,但是在工程中,我们最常用平衡二叉查找树的性能非常稳定,时间复杂度稳定在 O(logn)。

第三,尽管散列表的查找等操作的时间复杂度是量级的,但因为哈希冲突的存储,这个常量不一定比 logn 小,所以实际的查找速度可能不一定比 O(logn) 快。加上哈希函数的耗时,也不一定比平衡二叉查找树的效率高。

第四,散列表的构造比二叉查找树要复杂,需要考虑的东西很多。比如散列函数的设计、冲突解决办法、扩容、缩容等。平衡二叉查找树只需要考虑平衡性这一个问题,而且这个问题的解决方案比较成熟、固定。

最后,为了避免过多的散列冲突,散列装载因子不能太大,特别是基于开放寻址法解决冲突的散列表,不然会浪费一定的存储空间。

综上所述,平衡二叉查找树在某些方面还是由于散列表的,所以,这两者的存在并不冲突。在实际的开发中,需要结合具体的需求来选择使用哪一个。

通过编程,求出给定二叉树的确切高度

确定二叉树高度有两种思路:

第一种是深度优先思想的递归,分别求左右子树的高度。当前节点的高度就是左右子树中较大的那个+1;

第二种可以采用层次遍历的方式,每一层记录都记录下当前队列的长度,这个是队尾,每一层队头从0开始。然后每遍历一个元素,队头下标+1。直到队头下标等于队尾下标。这个时候表示当前层遍历完成。每一层刚开始遍历的时候,树的高度+1。最后队列为空,就能得到树的高度。

十四、红黑树

之前,我们依次讲了树、二叉树、二叉查找树。二叉查找树是最常用的一种二叉树,它支持快速插入、删除、查找操作,各个操作的时间复杂度跟树的高度成正比,理想情况下,时间复杂度是 O(logn)。

不过二叉查找树在频繁的动态更新过程中,,可能会出现树的高度远大于 log2n 的情况,从而导致各个操作的效率下降。极端情况下,二叉树会退化成链表,时间复杂度会退化到 O(n)。之前也提到过,要解决这个复杂度退化的问题,我们需要设计一种平衡二叉查找树,也就是今天要讲的这种数据结构。

很多书籍中,但凡讲到平衡二叉树,就会拿红黑树举例。不仅如此,如果你有一定的开发经验,你会发现,在工程中,很多用到平衡二叉查找树的地方都会用到红黑树。但是你有没有想过,为什么工程中都喜欢用红黑树,而不是其他平衡二叉查找树?

什么是 “平衡二叉查找树”

平衡二叉树的严格定义是这样的:二叉树的任意一个节点的左右子树的高度相差不能大于1。从这个定义来看,我们之前所说的完全二叉树、满二叉树其实都是平衡二叉树,但是非完全二叉树也有可能是平衡二叉树。

平衡二叉查找树不仅要满足上面平衡二叉树的定义,还要满足二叉查找树的特点。最先被发明的平衡二叉树是 AVL 树,它严格符合平衡二叉树的定义,即任何节点的左右子树相差都不超过 1,是一种高度平衡的二叉查找树。

但是很多平衡二叉树其实并没有严格符合上面的定义(树中任意一个节点的左右子树的高度相差不能大于 1),比如我们下面要讲的红黑树,它从根节点到各个叶子节点的最长路径,有可能会比最短路径大一倍。

我们学习数据结构与算法是为了应用到实际的开发中,所以,也没必要死扣定义。发明平衡二叉树这类数据结构的初衷是,解决普通二叉查找树在频繁的插入、删除等动态更新的情况下,出现时间复杂度退化的问题。

所以,平衡二叉查找树中 ”平衡“ 的意思,其实就是让整棵树左右看起来比较 ”对称“,比较 ”平衡“,不要出现左子树很高,右子树很矮的情况。这样就能让整棵树的高度相对来说低一些,相应的插入、删除、查找等操作的效率高一些。

所以,如果我们设计一个新的平衡二叉树,只要树的高度不比 log2n 大很多(比如树的高度仍然是对数量级的),尽管它不符合我们前面讲的严格的平衡二叉树的定义,但我们仍然可以说,这是一个合格的平衡二叉查找树。

如何定义一棵 ”红黑树“

平衡二叉树其实有很多,比如 Splay Tree(伸展树)、Treap(树堆)等,但是我们提到平衡二叉查找树,听到的基本都是红黑树。有时候,我们甚至默认平衡二叉查找树就是红黑树,现在我们就来学习一下红黑树。

红黑树的英文是 ”Red-Black Tree“,简称 R-B Tree。它是一种不严格的平衡二叉查找树。红黑树中的节点,一类被标记为黑色,一类被标记为红色。除此之外,一棵红黑树还需要满足这样几个要求:

  • 根节点是黑色的;
  • 每个叶子节点都是黑色的空节点(NIL),也就是说,叶子节点不存储数据;
  • 任何相邻的节点都不可能同时为红色,也就是说,红色节点是被黑色节隔开的;
  • 每个节点,从该节点到达其可达叶子节点的所有路径,都包含相同数目的黑色节点。

这里的第二点要求 ”叶子节点都是黑色的空节点“,主要是为了简化红黑树的代码实现而设置的。

为什么说红黑树是 ”近似“ 平衡的

平衡二叉树的初衷,是为了解决二叉查找树因为动态更新导致的性能退化问题,所以,”平衡“ 的意思可以等价为性能不退化。”近似平衡“ 就等价为性能不会退化得太严重。

二叉查找树很多操作的性能都跟树的高度成正比。一棵及其平衡的二叉树(满二叉树或完全二叉树)的高度大约是 log2n,所以如果要证明红黑树是近似平衡的,我们只需要分析,红黑树的高度是否比较稳定地趋近 log2n 就好。

红黑树的高度不是很好分析,我们可以一步步来推导。

首先,我们来看,如果我们将红色节点从红黑树中去掉,那单纯包含黑色节点的红黑树的高度是多少。

红色节点删除之后,有些节点就没有父节点了,它们会直接拿这些节点的祖父节点(父节点的父节点)作为父节点。所以,之前的二叉树就变成了四叉树。

红黑树的定义里有这么一条:从任意节点到达可达的叶子节点的每个路径包含相同数目的黑色节点。我们从四叉树中取出某些节点,放到叶节点位置,四叉树就变成了完全二叉树。所以,仅包含黑色节点的四叉树的高度,比包含相同节点个数的完全二叉树的高度还要小。

完全二叉树的高度近似于 log2n,这里的四叉 “黑树 ”的高度要低于完全二叉树,所以去掉红色节点的 “黑树” 的高度也不会超过 log2n。

现在我们知道只包含黑色节点的 ”黑树“ 的高度,那我们现在把红色节点加回去,高度会变成多少呢?

从上面的图和定义来看,在红黑树中,红色节点不能相邻,也就是说,有一个红色节点就要至少有一个黑色节点,将它跟其他红色节点隔开。红黑树中包含最多黑色节点的路径不会超过 log2n,所以加入红色节点之后,最长路径不会超过 2log2n,也就是说,红黑树的高度近似 2log2n。

所以说,红黑树的高度只比高度平衡的 AVL 树的高度(log2n)仅仅大了一倍,在性能上,下降得并不多。这样推导出来的结果不够精确,实际上红黑树的性能更好。

实现红黑树的基本思想

不知道你有没有玩过魔方?其实魔方的复原解法是有固定算法的:遇到哪几面是什么样子,对应就怎么转几下。你只要跟着这个复原步骤,就肯定能将魔方复原。

实际上,红黑树的平衡过程跟魔方复原非常神似,大致过程就是:遇到什么样的节点排布,我们就对应怎么去调整。只要按照这些固定的调整规则来操作,就能将一个非平衡的红黑树调整成平衡的。

一棵合格的红黑树需要满足这样几个要求:

  • 根节点是黑色的;
  • 每个叶子节点都是黑色的空节点(NIL),也就是说,叶子节点不存储数据;
  • 任何相邻的节点都不能同时为红色,也就是说,红色节点是被黑色节点隔开的;
  • 每个节点,从该节点到达其可达叶子节点的所有路径,都包含相同数目的黑色节点。

在插入、删除节点的过程中,第三、第四点要求可能会被破坏,我们今天要讲的 ”平衡调整“ ,实际上就是要把破坏的第三、第四点恢复过来。

在正式开始之前,先介绍两个非常重要的操作,左旋(rotate left)、右旋(rotate right)。左旋全称其实是叫围绕某个节点的左旋,右旋就是围绕某个节点的右旋。

在下面的平衡调整中,会一直用到这两个操作。下面有一张示意图,可以帮你理清这两个操作。图中的 a,b,r 表示子树,可以为空。

之前我们说过,红黑树的插入、删除操作会破坏红黑树的定义,具体来说就是会破坏红黑树的平衡,所以,我们现在就来看下,红黑树在插入、删除数据之后,如何调整平衡,继续当一颗合格的红黑树。

插入操作的平衡调整

红黑树规定,插入的节点必须是红色的。而且,二叉查找树中新插入的节点都是放在叶子节点上。所以,关于插入操作的平衡调整,有这样两种特殊情况,但是也都非常好处理。

  • 如果插入节点的父节点是黑色的,那我们什么都不用做,它仍然满足红黑树的定义;
  • 如果插入的节点是根节点,那我们直接改变它的颜色,把它变成黑色就可以了。

除此之外,其他情况都会违背红黑的定义,于是我们就需要进行调整,调整的过程中包含两种基础的操作:左右旋转和改变颜色。

红黑树的平衡调整过程是一个迭代的过程。我们把正在处理的节点叫做关注节点。关注节点会随着不停地的迭代处理,而不断发生变化。最开始的关注节点就是新插入的节点。

新节点插入之后,如果红黑树的平衡被打破,那一般会有下面三种情况。我们只需要根据每种情况的特点,不停地调整,就可以让红黑树继续符合定义,也就是继续保持平衡。

下面我们依次来看每种情况的调整过程。为了简化描述,这里把父节点的兄弟节点叫做叔叔节点,父节点的父节点叫做祖父节点。

CASE 1: 关注点是 a,叔叔节点 d 是红色

  • 将关注节点 a 的父节点 b、叔叔节点 d 的颜色都设置成黑色;
  • 将关注节点 a 的祖父节点 c 的颜色设置成红色;
  • 关注节点变成 a 的祖父节点 c;
  • 跳到 CASE 2 或者 CASE 3;

https://rbtree.phpisfuture.com/

如果节点c不是根节点,就转到case2或3;如果c是根节点,则将c转成黑色,就调整结束了。

CASE 2:如果关注节点是 a,它的叔叔节点 d 是黑色,关注节点 a 是其父节点 b 的右子节点,依次执行下面的操作

  • 关注节点变成节点 a 的父节点 b;
  • 围绕新的关注节点 b 左旋;
  • 跳到 CASE 3。

CASE 3:如果关注节点是 a,它的叔叔节点 d 是黑色,关注节点 a 是其父节点 b 的左子节点,依次执行下面的操作

  • 围绕关注节点 a 的祖父节点 c 右旋;
  • 将关注节点 a 的父节点 b、兄弟节点 c 的颜色互换;
  • 调整结束。

删除操作的平衡调整

红黑树插入操作的平衡调整还不是很难,但是它的删除操作的平衡调整相对就难很多。不过原理都是类似的,我们依旧只需要根据关注节点与周围节点的排布特点,按照一定的规则去调整就可以。

删除操作的平衡调整分为两步,第一步是针对删除节点初步调整。初步调整只是保证整棵红黑树在一个节点删除之后,仍然满足最后一条定义的要求,也就是说,每个节点,从该节点到达其可达叶子节点的所有路径,都包含相同数目的黑色节点;第二步是准对关注节点进行二次调整,让它满足红黑树的第三条定义,即不存在相邻的两个红色节点。

1. 针对删除节点初步调整

红黑树的定义中 ”只包含红色节点和黑色节点“,经过初步调整之后,为了保证满足红黑树定义的最后一条要求,有些节点会被标记成两种颜色,”红 - 黑“ 或者 ”黑 - 黑“。如果一个节点被标记了 ”黑 - 黑“,那在计算黑色节点个数的时候,要算成两个黑色节点。

如果一个节点既可以是红色,也可以是黑色,在画图的时候,这里用一半红色一半黑色来表示。如果一个节点是 ”红-黑“ 或者 ”黑 - 黑“,这里用左上角一个小黑点来表示额外的黑色。

CASE 1:如果要删除的节点是 a,它只有一个子节点 b,那我们就依次进行下面的操作:

  • 删除节点 a,并且把节点 b 替换到节点 a 的位置,这一部分操作跟普通的二叉查找树的删除操作一样;
  • 节点 a 只能是黑色,节点 b 也只能是红色,其他情况均不符合红黑树的定义。这种情况下,我们把节点 b 改成黑色;
  • 调整结束,不需要进行二次调整。

CASE 2:如果要删除的节点 a 有两个非空子节点,并且它的后继节点就是节点 a 的右子节点 c。依次进行下面的操作:

  • 如果节点 a 的后继节点就是右子节点,那右子节点 c 肯定没有左子树。我们把节点 a 删除,并且将节点 c 替换成节点 a 的位置。这一部分操作跟普通的二叉查找树的删除无异;
  • 然后把节点 c 的颜色设置为跟节点 a 相同的颜色;
  • 如果节点 c 是黑色,为了不违反红黑树的最后一条定义,我们给节点 c 的右子节点 d 多加一个黑色,这个时候节点 d 就变成了 ”红 - 黑“ 或者 ”黑 - 黑“;
  • 这个时候,关注节点变成了节点 d,第二部的调整操作就会针对关注节点来做。

CASE 3:如果要删除的节点 a,它有两个非空节点,并且节点 a 的后继节点不是右子节点,我们就依次进行下面的操作:

  • 找到后继节点 d,并将它删除,删除后继节点 d 的过程参照 CASE 1;
  • 将节点 a 替换成后继节点 d;
  • 把节点 d 的颜色设置为跟节点 a 相同的颜色;
  • 如果节点 d 是黑色,为了不违反红黑树的最后一条定义,我们给节点 d 的右子节点 c 多加一个黑色,这个时候节点 c 就成了 ”红 - 黑“ 或者 ”黑 - 黑“;
  • 这个时候,关注节点就变成了节点 c,第二步的调整操作就会针对关注节点来做;

2. 针对关注节点进行二次调整

经过初次调整之后,关注节点变成了 ”红 - 黑“ 或者 ”黑 - 黑“ 节点。针对这个关注节点,我们再分四种情况来进行二次调整。二次调整是为了让红黑树中不存在相邻的红色节点。

CASE 1:如果关注节点是 a,它的兄弟节点 c 是红色的,我们就依次进行下面的操作:

  • 围绕关注节点 a 的父节点 b 左旋;
  • 关注节点 a 的父节点 b 和祖父节点 c 交换颜色;
  • 关注节点不变;
  • 继续从四种情况中选择合适的规则来调整。

CASE 2:如果关注节点是 a,它的兄弟节点 c 是黑色的,并且节点 c 的左右子节点 d、e 都是黑色的,依次进行下面的操作:

  • 将关注节点 a 的兄弟节点 c 的颜色变成红色;
  • 从关注节点 a 中去掉一个黑色,这个时候节点 a 就是单纯的红色或者黑色;
  • 给关注点 a 的父节点 b 添加一个黑色,这个时候节点 b 就变成了 ”红- 黑“ 或者 ”黑 - 黑“;
  • 关注节点从 a 变成其父节点 b;
  • 继续从四种情况中选择符合的规则来调整;

CASE 3:如果关注节点是 a,它的兄弟节点 c 是黑色,c 的左子节点 d 是红色,c 的右子节点 e 是黑色,依次进行下面的操作:

  • 围绕关注节点 a 的兄弟节点 c 右旋;
  • 节点 c 和节点 d 交换颜色;
  • 关注节点不变;
  • 跳转到 CASE 4,继续调整;

CASE 4:如果关注节点 a 的兄弟节点 c 是黑色的,并且 c 的右子节点是红色的,依次进行下面的操作:

  • 围绕关注节点 a 的父节点 b 左旋;
  • 将关注节点 a 的兄弟节点 c 的颜色,跟关注节点 a 的父节点 b 设置成相同的颜色;
  • 将关注节点 a 的父节点 b 的颜色设置为黑色;
  • 从关注节点 a 中去掉一个黑色,节点 a 就变成了单纯的红色或者黑色;
  • 将关注节点 a 的叔叔节点 e 设置为黑色;
  • 调整结束。

总结

红黑树是一种平衡二叉树。它是为了解决普通二叉查找树在数据更新的过程中,复杂度退化的问题产生的。红黑树的高度近似于 log2n,它是近似平衡,插入、删除、查找操作的时间复杂度都是 O(logn)。

因为红黑树是一种性能比较稳定的二叉查找树,所以,在工程中,但凡是用到动态插入、删除、查找数据的场景,都可以用到它。不过,它实现起来比较复杂,如果自己写代码实现,难度比较高。这个时候,我们更倾向于使用跳表来替代它。

红黑树的操作过程如下:

第一点,把红黑树的平衡调整的过程比作魔方复原,不要过于深究这个算法的正确性。你只需要明白,只要按照固定的操作步骤,保持插入、删除的过程,不破坏平衡树的定义就行了。

第二点,找准关注节点,不要搞丢、搞错关注节点。因为每种操作规则,都是基于关注节点来做的,只有弄对了关注节点,才能对应到正确的操作规则中。在迭代的调整过程中,关注点在不停地的改变,所以,这个过程一定要注意,不能弄丢了关注节点。

第三点,插入操作的平衡调整比较简单,但是删除操作就比较复杂。针对删除操作,我们有两次调整,第一次是对要删除的节点做初步调整,让调整后的红黑树继续满足第四条定义,”每个节点到可达叶子节点的路径都包含相同个数的黑色节点“。但是这个时候,第三条定义就不满足了,有可能会存储两个黑色节点相邻的情况。第二次调整就是解决这个问题,让红黑树不存在相邻的红色节点。

技术拓展

为什么大家都喜欢用红黑树这种平衡二叉查找树?

前面提到 Treap。Splay Tree,绝大部分情况下,它们的操作效率都很高,但是无法避免极端情况下时间复杂度的退化。尽管这种情况出现的概率不大,但是对于单次操作时间非常敏感的场景来说,它们并不适用。

AVL 树是一种高度平衡的二叉树,所以查找的效率非常高。但是,有利就有弊,AVL 树为了维持这种高度的平衡,就要付出更多的代价。每次,插入、删除都要做调整,比较复杂、耗时。所以,对于频繁的插入、删除操作的数据集合,使用 AVL 树的代价就有点高。

红黑树做到了近似平衡,在维护平衡的成本上,要比 AVL 树低。所以,红黑树的插入、删除、查找各种操作性能都比较稳定。对于工程应用来说,要面对各种异常情况,为了支撑这种工业级的应用,我们更倾向于这种性能稳定的平衡二叉查找树。

除了红黑树还有什么数据结构支持动态数据插入、删除、查找操作?

散列表、跳表。

为什么红黑树的定义中,要求叶子节点是黑色的空节点

假设红黑树的定义中不包含刚刚提到的那一条 ”叶子节点必须是黑色的空节点“,我们往一棵红黑树中插入一个数据,新插入节点的父节点也是红色的,两个红色的节点相邻,这个时候,红黑树的定义就被破坏了。那我们应该如何调整?

你会发现,这个时候,我们前面在讲插入时,三种情况下的平衡调整规则,没有一种是适用的。但是,如果我们把黑色的空节点都给它加上,变成下面这样,你会发现,它满足 CASE 2 了。

你可能会说,可以调整一下平衡调整规则啊。比如把 CASE 2 改为 ”如果关注节点 a 的叔叔节点 b 是黑色或者不存在,a 是父节点的右子节点,就进行某某操作“。当然可以,但是这样的话规则就没有原来简洁了。

你可能还会说,这样给红黑树添加黑色的空的叶子节点,会不会比较浪费存储空间?答案是不会的,虽然我们在讲解或者画图的时候,每个黑色的、空的叶子节点都是独立画出来的。实际上,在具体实现的时候,我们只需要像下面这样,共用一个黑色的、空的叶子节点就行。

十五、递归树

我们都知道,递归代码的时间复杂度分析起来很麻烦。除了用递归公式这种比较复杂的分析方法,有没有更简单的方法呢?

今天,我们就来学习另外一种方法,借助递归树来分析递归算法的时间复杂度。

递归树与时间复杂度分析

递归的思想就是,将大问题分解为小问题,然后再将小问题分解为小问题。这样一层层地分解,直到问题的数据规模被分解得足够小,不用继续递归分解为止。

如果我们把这个一层一层的分解过程画成图,它其实就是一棵树。我们给这棵树起一个名字,叫做递归树。

下图中画了一棵斐波那契数列的递归树,你可以看下。节点里的数字表示数据的规模,一个节点的求解可以分解为左右子节点两个问题的求解。

下面,我们来看如何用递归树来求解时间复杂度。

归并排序算法不知道你还是否还记得?它的递归代码非常简洁。现在我们就借助归并排序来看下,如何用递归树,来分析递归代码的时间复杂度。

归并排序的原理就不详细介绍了。归并排序每次会将数据规模一分为二。把归并排序画成递归树,就是下面这个样子:

因为每次分解都是一分为二,所以代价很低,我们把时间上的消耗记作常量 1。归并算法中比较耗时的是归并操作,也就是把两个子数组合并为一个大数组,从图中我们可以看出,每一层归并操作消耗的时间总和是一样的,跟要排序的数据规模有关。我们把每一层归并操作消耗的时间记作 n。

现在,我们只需要知道这棵树的高度是 h,用高度 h 乘以每一层的时间消耗 n,就可以得到总的时间复杂度 O(n * h)。

从归并排序的原理和递归树,可以看出来,归并排序递归树是一颗满二叉树。满二叉树的高度大约是 $log2^n$ ,所以,归并排序递归实现的时间复杂度就是 O(nlogn)。这里的时间复杂度都是估算的,对树的高度计算也没有很精确,但是这并不影响复杂度的计算结果。

快速排序的时间复杂度

快速排序在最好情况下,每次分区都能一分为二,这个时候用递推公式 T(n) = 2T(n/2) + n,很容易就能推导出时间复杂度是 O(nlogn)。但是,不可能每次分区都这么幸运,正好一分为二。

假设平均情况下,每次分区之后,两个分区的比例为 1 : k。当 k = 9 时,如果用递推公式的方法来求解时间复杂度的话,递归公式就写成 T(n) = T(n / 10) + T(9n / 10) + n。

这个公式可以推导出时间复杂度,但是推导过程非常复杂。那用递归树来分析快速排序的平均情况时间复杂度,是不是比较简单?

我们还是取 k 等于 9,也就是说,每次分区都很不平均,一个分区是另一个分区的 9 倍。如果我们把递归分解的过程画成递归树,就是下面这个样子:

快速排序的过程中,每次分区都要遍历待分区区间的所有数据,所以,每一层分区操作所遍历的数据的个数之和就是 n。我们现在只要求出递归树的高度 h,这个快排过程遍历的数据个数就是 h * n,也就是说,时间复杂度就是 O(h * n)。

因为每次分区并不是均匀地一分为二,所以递归树并不是满二叉树。这样一个递归树的高度是多少?

我们知道,快速排序结束的条件就是待排序的小区间,大小为 1,也就是说叶子节点里的数据规模是 1。从根节点 n 到叶子节点 1,递归树中最短的一个路径每次都乘以 1/10,最长的一个路径每次都乘以 9/10。通过计算,我们可以得到,从根节点到叶子节点的最短路径是 $log10^n$, 最长的路径是 $log (\cfrac{10}{9}) ^ n $。

所以,遍历数据的个数总和就介于 $nlog10^n$ 和 $nlog (\cfrac{10}{9}) ^ n $。根绝复杂度的大 O 表示法,对数复杂度的底数不管是多少,我们统一写成 logn。所以,当分区大小比例是 1:9 时,快速排序的时间复杂度仍然是 $O(nlogn)$。

刚刚我们假设 k = 9,那如果 k = 99,也就是说,每次分区及其不平均,两个区间大小是 1:99,这个时候的时间复杂度是多少呢?

我们可以类比上面 k = 9 的分析过程。当 k = 99 时,树的最短路径就是 $log100^n$,最长路径是 $log (\cfrac{100}{99}) ^ n $,所以总遍历数据个数介于 $nlog100^n$ 和 $nlog (\cfrac{100}{99}) ^ n $ 之间。尽管底数变了,但是时间复杂度仍然是 $O(nlogn)$。

也就是说,对于 k 等于 9,甚至是 999,9999 ... ,只要 k 的值不随 n 变化,是一个事先确定的常量,那快排的时间复杂度就是 $O(nlogn)$。所以,从概率论的角度来说,快排的平均时间复杂度就是 $O(nlogn)$。

斐波那契数列的时间复杂度

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

这样一段代码的时间复杂度是多少呢?下面来看如何使用递归树进行分析。

先把上面的递归代码画成递归树,就是下面这个样子:

这颗递归树的高度是多少?

fn(n) 分解为 f(n - 1) 和 f(n - 2),每次数据规模都是 -1 或者 -2,叶子节点的数据规模是 1 或者 2。所以,从根节点走到叶子节点,每条路径是长短不一的。如果每次都是 -1,那最长路径大约就是 n;如果每次都是 -2,那字段路径大约是 $\cfrac{n}{2}$ 。

每次分解之后的合并操作只需要一次加法运算,我们把这次加法运算的时间消耗记作 1。所以,从上往下,第一层的总时间消耗是 1,第二层的总时间消耗为 2,第三层的总时间消耗是 $2^2$ 。依次类推,第 k 层的时间消耗就是 $2 ^ {k-1}$,那整个算法的总时间消耗就是每一层消耗之和。

如果路径长度都为 n,那这个总和就是 $2^n - 1$。

如果路径长度都是 $\cfrac{n}{2}$ ,那整个算法的总时间消耗就是 $2 ^ {\cfrac{n}{2}} - 1$。

所以,这个算法的时间复杂度就介于 $O(2^n)$ 和 $O(2 ^ {\cfrac{n}{2}})$ 之间。虽然这样得到的结果还不够精确,只是一个范围,但是我们也基本知道上面算法的时间复杂度是指数级的,非常高。

全排列的时间复杂度

我们在高中的时候都学过排列组合。“如何把 n 个数据的所有排列都找出来”,这就是全排列的问题。

举个例子。比如,1,2,3 这样 3 个数据,有下面几种不同的排列:

js
1, 2, 3
1, 3, 2
2, 1, 3
2, 3, 1
3, 1, 2
3, 2, 1

我们可以用递归来打印一组数据的所有排列。

如果我们确定了最后一位数据,那就变成了求解剩下 n - 1 个数据的排列问题。而最后一位数据可以是 n 个数据中的任意一个,因此它的取值就有 n 种情况。所以,“n 个数据排列” 问题,就可以分解为 n 个 “n - 1 个数据的排列” 的子问题。

如果把它写成递推公式,就是下面这个样子:

js

假设数组中存储的是1,23...n。
        
f(1,2,...n) = {最后一位是1, f(n-1)} + {最后一位是2, f(n-1)} +...+{最后一位是n, f(n-1)}

如果我们把递推公式改写成代码,就是下面这个样子:

js
function printPerumtations (data, n, k) {
  if (k === 1) {
    for (let i = 0; i < n; i++) {
      console.log(data[i] + " ");
    }
    console.log('---');
  }
  
  for (let i = 0; i < k; i++) {
    let tmp = data[i];
    data[i] = data[k - 1];
    data[k - 1] = tmp;
    
    printPerumtations(data, n, k - 1);
    
    tmp = data[i];
    data[i] = data[k - 1];
    data[k - 1] = tmp;
  }
}

如果我们不用前面讲的递归树的分析方法,这个代码代码的时间复杂度就会比较难分析。现在,我们来看下,如何借助递归树,轻松分析出这个代码的时间复杂度。

首先,我们还是画出递归树。不过,现在的递归树已经不是标准的二叉树了。

每一层分解有 n 次交换操作,第二层有 n 个节点,每个节点分解需要 n - 1 次交换,所以第二层总的交换次数是 n * (n - 1)。第三层有 n * (n - 1) 个节点,每个节点分解需要 n - 2 次交换,所以第三层总的交换次数是 n * (n - 1) * (n - 2)。

以此类推,第 k 层总的交换次数就是 n * (n - 1) * (n - 2) * ... * (n - k + 1)。最后一层的交换次数就是 n * ( n - 1) * ... * 2 * 1。每一层的交换次数之和就是总的交换次数。

js
n + n * (n-1) + n * (n-1) * (n-2) +... + n * (n-1) * (n-2) * ... * 2 * 1

这个公式的求和比较复杂,先来看最后一个数,n * (n-1) * (n-2) * ... * 2 * 1 等于 n! ,而前面的 n - 1 个数都小于最后一个数,所以,总和肯定小于 n * n! ,也就是说,全排列的递归算法的时间复杂度大于 O(n!),小于 O(n * n!),虽然我们没法知道非常精确的时间复杂度,但是这样一个范围已经让我们知道,全排列的时间复杂度是非常高的。

总结

今天,我们用递归树分析了递归代码的时间复杂度。我们之前在学习排序的时候,也学过一种递归公式的时间复杂度分析方法,现在我们已经学习了两种递归代码的时间复杂度分析方法了。

有些代码比较适合用递推公式来分析,比如归并排序的时间复杂度、快速排序的最好情况时间复杂度;有些比较适合采用递归树来分析,比如快速排序的平均时间复杂度。而有些可能两个都不适用,比如二叉树的递归前中后序遍历。

技术拓展

细胞分裂问题

一个细胞的生命周期是 3 小时,1 小时分裂一次。求 n 小时候,容器内有多少细胞。 请用已经学过的递归时间复杂度的分析方法,分析这个递归问题的时间复杂度。

。。。

十六、堆和堆排序

"堆" (Heap),堆这种数据结构的应用场景非常多,最经典的就是堆排序。堆排序是一种原地的、时间复杂度为 O(nlogn) 的排序算法。

我们之前学过快速排序,平均情况下,它的时间复杂度也是 O(nlogn)。尽管这两种排序算法的时间复杂度都是 O(nlogn),甚至堆排序的比快速排序的时间复杂度还要稳定,但是,在实际的软件开发中,快速排序的性能要比堆排序好,这是为什么呢?

如何理解 “堆”

堆是一种特殊的树:

  • 堆是一个完全二叉树;
  • 堆中每一个节点的值都必须大于等于(或小于等于)其子树中每个节点的值。

第一点,堆必须是一个完全二叉树。完全二叉树即除了最后一层,其他层的节点个数都是满的,最后一层的节点都靠左排列。

第二点,堆中的每个节点的值必须大于等于(或小于等于)其子树中每个节点的值。实际上,还可以换一种说法,堆中的每个节点的值都大于等于(或小于等于)其左右子节点的值。

对于每个节点的值都大于等于子树中每个节点值的堆,我们叫做 “大顶堆”。对于每个节点的值都小于等于子树中每个节点值的堆,我们叫做 “小顶堆”。

你可以看看,下面这几个二叉树是不是堆?

其中第 1 个和第 2 个是大顶堆,第 3 个是小顶堆,第 4 个不是堆。除此之外,从图中还可以看出来,对于同一组数据,我们可以构建多种不同形态的堆。

如何实现一个堆?

要实现一个堆,我们先要知道,堆都支持哪些操作以及如何存储一个堆。

完全二叉树比较使用用数组存储。用数组存储完全二叉树是非常节省空间的。因为我们不需要存储左右子节点的指针,单纯地通过数组的下标,就可以找到一个节点的左右子节点和父节点。

这里有一个用数组存储堆的例子,你可以先看下。

从图中我们可以看到,数组下标为 i 的节点的左子节点,就是下标为 i * 2 的节点,右子节点就是下标为 i * 2 + 1 的节点,父节点就是 $\cfrac{i}{2}$ 的节点。知道了如何存储一个堆,下面再来介绍堆的几个非常核心的操作。

如果没有特殊说明,下面都是拿大顶堆讲解。

插入元素

往堆中插入一个元素后,我们需要继续满足堆的两个特性。

如果我们把新插入的元素放到堆的最后,是不符合堆的特性的。我们需要进行调整,让其重新满足堆的特性,这个过程叫做堆化(heapify)。

堆化有两种,从下往上和从上往下。这里先讲从下往上的堆化方法。

堆化非常简单,就是顺着节点所在的路径,向上或者向下,然后交换。

下面有一张堆化的过程分解图。我们可以让新插入的节点与父节点对比大小。如果不满足子节点小于等于父节点的大小关系,我们就互换两个节点。一直重复这个过程,直到父子节点满足刚说的那种大小关系。

堆化的代码如下:

js
class MaxHeap {
  a = [];
  n;
  count;

  constructor (capcity) {
    this.a = new Array(capcity + 1);
    this.n = capcity;
    this.count = 0;
  }

	getParentIndex (i) {
    return i >> 1;
  }

  insert (data) {
    if (this.count >= this.n) return;

    this.count++;

    this.a[this.count] = data;

    let i = this.count;

    const parentIdx = this.getParentIndex(i);
    
    while (parentIdx > 0 && this.a[i] > this.a[parentIdx]) {
      this.swap(this.a, i, parentIdx);
      i = parentIdx;
    }
  }

  swap (arr, i1, i2) {
    const temp = arr[i1];
    arr[i1] = arr[i2];
    arr[i2] = temp;
  }
}

删除堆顶元素

堆的定义中第二条,任何节点的值都大于等于(或小于等于)子树节点的值,我们可以发现,堆顶元素存储的就是堆中数据的最大值或者最小值。

假设我们构造的是大顶堆,堆顶元素就是最大的元素。当我们删除顶部元素之后,就需要把第二大的元素放到堆顶,那第二大的元素肯定会出现在左右子节点。然后我们再迭代地删除第二大节点,以此类推,知道叶子节点被删除。

这里也有一个图。不过这种删除方法有点问题,就是最后堆化出来的堆并不满足完全二叉树的特性。

实际上,我们稍微改变一下思路,就可以解决这个问题。我们可以把最后一个节点放到堆顶,然后利用同样的父子节点对比方法。对于不满足父子节点大小关系的,互换两个节点,并且重复进行这个过程,直到父子节点之间满足大小关系为止。这就是从上往下的堆化方法。

因为我们移除的是数组中的最后一个元素,而在堆化的过程中,都是交换操作,不会出现数组中的 “空洞”,这种方法堆化之后的结果,肯定满足完全二叉树的特性。

删除的代码如下:

js
getLeftIndex (i) {
  return i * 2;
}

getRightIndex (i) {
  return i * 2 + 1;
}

heapify (a, n, i) {
  while (true) {
    let maxPos = i;

    if (i * 2 <= n && a[i] < a[i * 2]) {
      maxPos = i * 2;
    }

    if (i * 2 + 1 <= n && a[maxPos] < a[i * 2 + 1]) {
      maxPos = i * 2 + 1;
    }

    if (maxPos == i) {
      break;
    }

    this.swap(a, i, maxPos);

    i = maxPos;
  }
}

removeMax () {
  if (this.count === 0) return -1;

  this.a[1] = this.a[this.count];

  this.count--;

  this.heapify(this.a, this.count, 1);
}

一个包含 n 个节点的完全二叉树,树的高度不会超过 $log2^n$。堆化的过程是顺着节点所在路径比较交换的,所以堆化的时间复杂度跟树的高度成正比,也就是 $O(logn)$。插入数据和删除栈顶元素的主要逻辑就是堆化,所以,往堆中插入一个元素和删除栈顶元素的时间复杂度都是 $O(logn)$。

如何基于堆实现排序

借助堆这种数据结构实现的排序算法,叫做堆排序。这种排序算法的时间复杂度非常稳定,是 $O(nlogn)$,并且它也是原地排序算法。

我们可以把堆排序的过程大致分解为两个大的步骤,建堆和排序。

建堆

我们首先把数组原地建成一个堆。所谓 “原地” 就是,不借助另一个数组,就再原数组上操作。建堆的过程,有两种思路。

第一种是借助我们前面讲的,在堆中插入一个元素的思路。尽管数组中包含 n 个数组,但是我们可以假设,起初堆中只包含一个数据,就是下标为 1 的数据。然后,我们调用前面讲的插入操作,将下标从 2 到 n 的数据依次插入到堆中。这样我们就将包含 n 个数据的数组,组成了堆。

第二种实现思路,与第一种相反。第一种建堆思路的处理过程是从前往后处理数组数据,并且每个数据插入堆中,都是从下往上堆化。第二种实现思路,是从后往前处理数组,并且每个数据都是从上往下堆化。因为叶子节点往下堆化只能自己跟自己比较,所以我们直接从最后一个非叶子节点开始,依次堆化就行了。

代码如下:

js
build (a, n) {
  for (let i = n / 2; i >= 1; i--) {
    this.heapify(a, n, i);
  }
}

你可能已经发现,在这段代码中,我们对下标从 $\cfrac{n}{2}$ 开始到 1 的数据进行堆化,下标是 $\cfrac{n}{2} + 1$ 到 $n$ 的节点是叶子节点,我们不需要堆化。实际上,对于完全二叉树来说,下标从 $\cfrac{n}{2} + 1$ 到 $n$ 的节点都是叶子节点。

现在,我们来看,建堆操作的时间复杂度是多少。每个节点堆化的时间复杂度是 O(logn),那 $\cfrac{n}{2} + 1$ 个节点堆化的总时间是不是就是 O(nlogn)。虽然这个答案也没错,但是这个值不够精确,实际上,堆排序的建堆过程的时间复杂度是 O(n)。

因为叶子节点不需要堆化,所以需要堆化的节点从倒数第二层开始。每个节点堆化的过程中,需要比较和交换的节点个数,跟这个节点高度 k 成正比。

我们把每一层的节点个数和对应高度画出来,只需要将每个节点的高度求和,得出的就是建堆的时间复杂度。

我们将每个非叶子节点的高度求和,就是下面这个公式:

这个公式的求解稍微有点技巧,我们把公式左右都乘以 2,就得到另一个公式 S2。我们将 S2 错位对齐,并且用 S2 减去 S1,可以得到 S。

S 的中间部分是一个等比数列,所以最后可以用等比数列的求和公式来计算,最终的结果如下。

因为 h = $log2 ^ n$,代入公式 S,就可以得到 S = O(n),所以,建堆的时间复杂度就是 O(n)。

排序

建堆结束之后,数组中的数据已经是按照大顶堆的特性来组织的。数组中的第一个元素就是堆顶,也就是最大的元素。我们把它与最后一个元素交换,那最大元素就放到了下标为 n 的位置。

这个过程有点类似于上面讲的 “删除堆顶元素” 的操作,当堆顶元素移除之后,我们把下标为 n 的元素放到堆顶,然后通过堆化的方法,将剩下的 n - 1 个元素重新构建成堆。堆化完成之后,我们再取堆顶的元素,放到下标是 n - 1的位置,一直重复这个过程,知道最后堆中只剩下下标为 1 的一个元素,排序工作就完成了。

js
sort (a, n) {
  this.build(a, n);

  let k = n;

  while (k > 1) {
    this.swap(a, 1, k);
    k--;
    this.heapify(a, k, 1);
  }
}

现在,我们再来分析一下堆排序的时间复杂度、空间复杂度以及稳定性。

整个堆排序的过程,都只需要极个别临时存储空间,所以堆排序是原地排序算法。堆排序包括建堆和排序两个操作,建堆过程的时间复杂度是 O(n),排序过程的时间复杂度是 O(nlogn),所以,堆排序整体的时间复杂度是 O(nlogn)。

堆排序不是稳定的排序算法,因为在排序的过程中,存在将堆的最后一个节点和堆顶节点互换的操作,所以就有可能改变值相同数据的原始相对顺序。

前面的讲解以及代码中,都假设堆中的数据是从数组下标为 1 的位置开始存储。如果从 0 开始存储,实际上处理思路是一致的,唯一变化的是代码实现的时候,计算子节点和父节点的下标公式。如果节点的下标是 i,那左子节点的下标就是 2 * i + 1,右子节点的下标就是 2 * i + 2,父节点的下标就是 $\cfrac{i - 1}{2}$。

总结

堆是一种完全二叉树。它最大的特点是:每个节点的值都大于等于(或小于等于)其子树节点的值。因此,堆被分成两类,大顶堆和小顶堆。

堆中比较重要的操作是插入一个数据和删除堆顶元素。这两个操作都要用到堆化。插入一个数据的时候,我们把新插入的数据放到数组的最后,然后从下往上堆化;删除堆顶数据的时候,我们把数组中的最后一个元素放到堆顶,然后从上往下堆化。这两个操作时间复杂度都是 O(logn)。

除此之外,还学了堆的一个经典应用,堆排序。堆排序包括两个过程,建堆和排序。我们将下标 $\cfrac{n}{2}$ 到 $1$ 的节点,依次进行从上到下的堆化操作,然后就可以将数组中的数据组织成这种数据结构。接下来,我们迭代地将堆顶的元素放到堆的末尾,并将堆的大小减一,然后再堆化,重复这个过程,直到堆中只剩下一个元素,整个数组中的数据就都有序排列了。

技术拓展

1. 为什么快速排序比堆排序性能好?

堆排序数据访问的方式没有快速排序友好。

对于快速排序来说,数据是顺序访问的。而对于堆排序来说,数据四跳着访问的。比如,对排序中,最重要的一个操作就是数据的堆化。比如下面这个例子,对堆顶节点进行堆化,会依次访问数组下标 1,2,4,8 的元素,而不是像快速排序那样,局部顺序访问,所以,这样对 CPU 缓存不友好。

对于相同的数据,在排序过程中,堆排序算法的数据交换次数要多于快速排序。

我们在讲排序的时候,提过两个概念,有序度和逆序度。对于基于比较的排序算法来说,整个排序过程就是由两个基本的操作组成的,比较和交换(或移动)。快速排序交换的次数不会比逆序度多。

但是堆排序的第一步是建堆,建堆的过程会打乱数据原有的相对先后顺序,导致原有数据的有序度降低。比如,对于一组已经有序的数组来说,经过建堆之后,数据反而变得更无序了。

你也可以自己做个实验看看。用一个记录交换次数的变量,在代码中,每次交换的的时候,就对这个变量加一,排序完成之后,这个变量的值就是总的数据交换次数。这样就能很直观地看到,堆排序比快速排序交换次数多。

2. 为什么下标从 $\cfrac{n}{2} + 1$ 到 $n$ 的都是叶子节点?

。。。

3 . 堆的其他应用场景

topK、流里面的中值、流里面的中位数。

十七、堆的应用场景

搜索引擎的热门排行榜你用过吗?你知道这个功能是如何实现的吗?实际上,它的实现并不复杂。搜索引擎每天会接收大量的用户请求,它会把这些用户输入的搜索关键词记录下来,然后再离线地统计分析,得到最热门的 Top 10 搜索关键词。

假设我们现在有一个包含 10 亿个搜索关键词的日志文件,如何能快速获取到热门榜 Top 10 的搜索关键词呢?这个问题可以用堆来解决,这也是堆这种数据结构一个非常典型的应用。

堆有几个非常重要的应用:优先级队列、求 Top K 和 求中位数。

优先级队列

优先级队列,顾名思义,它首先是一个队列。队列最大的特点是先进先出。不过,在优先级队列中,数据的出队顺序不是先进先出,而是按照优先级来,优先级最高的,最先出队。

实现优先级队列的方法有很多,但是用堆来实现是最直接、最高效的。堆和优先级队列非常相似,一个堆就可以看作一个优先级队列。很多时候,它们只是概念的区分。往优先级队列中插入一个元素,就相当于往堆中插入一个元素;从优先级队列中取出优先级最高的元素,就相当于取出栈顶元素。

优先级队列的应用场景非常多,很多数据结构和算法都依赖它。比如,赫夫曼编码、图的最短路径、最小生成树算法等。只说这些应用场景比较空泛,下面举两个例子说一下优先级队列具体是干啥用的。

1. 合并有序小文件

假如我们有 100 个小文件,每个文件的大小是 100 MB,每个文件中存储的都是无序的字符串。我们希望将这 100 个小文件合并成一个有序的大文件。这里就会用到优先级队列。

整体思路有点像归并排序中的合并函数。我们从这个 100 个的文件中,各取一个字符串,放进数组中,然后比较大小,把最小的那个字符串放入合并后的大文件中,并从数组中删除。

假设, 这个最小的字符串来自于 1.3 txt 这个小文件,我们就再从这个小文件取下一个字符串,放到数组中,重新比较大小,并且选择最小的放入合并后的大文件,将它从数组中删除。依此类推,直到所有的文件中的数据都放入到大文件为止。

这里我们用数组这种树结构,来存储从小文件中取出来的字符串。每次从数组中取最小字符串,都需要循环遍历整个数组,显然,这不是很高效。这里就可以用到优先级队列,也可以说是堆。

我们将从小文件中取出的字符串放入到小顶堆中,堆顶的元素,也就是优先级队列队首的元素,就是最小的字符串。我们将这个字符串放入到大文件中,并将其从堆中删除。然后再从小文件中取出下一个字符串,放入到堆中。循环这个过程,就可以将 100 个小文件中的数据依次放入到大文件中。

删除堆顶数据和往堆中插入数据的时间复杂度都是 O(logn),n 表示堆中的数据个数,这里就是 100。

2. 高性能定时器

假设我们有一个定时器,定时器中维护了很多的定时任务,每个任务都设定了一个要触发执行的时间点。定时器每过一个很小的单位时间(比如 1 秒),就扫描一遍任务,看是否有任务到达设定的执行时间。如果到达了,就拿出来执行。

设定时间任务
2021.05.30 17:20Task A
2021.05.30 19:20Task B
2021.05.30 15:31Task C
2021.05.30 13:55Task D

但是,这样没过 1 秒就扫描一遍任务列表的做法比较低小,主要原因有两点:

  • 任务的约定执行时间离当前时间可能还有很久,这样前面很多次扫描其实都是无用的;
  • 每次扫描都要扫描整个任务列表,如果任务列表很大,会比较耗时。

针对这些问题,我们可以用优先级队列来解决。我们按照任务设定的执行时间,将这些任务存储在优先级队列中,队列首部(最小堆的堆顶)存储的是最先执行的任务。

这样,定时器就不需要每隔 1 秒就扫描一遍任务列表了。它会拿队首任务的执行时间点,与当前时间点相减,得到一个时间间隔 T。

这个时间间隔 T 就是,从当前时间开始,需要等待多久,才会有第一个任务需要被执行。这样,定时器就可以设定在 T 秒之后,再来执行任务。从当前时间点到 (T - 1) 秒这段时间里,定时器不需要做任何事情。

当 T 秒时间过去之后,定时器取优先级队列中队首的任务执行。然后再计算新的队首任务的执行点与当前时间点的差值,把这个值作为定时器执行下一个任务需要等待的时间。

这样,定时器既不用间隔1 秒就轮询一次,也不用遍历整个任务列表,性能也就提高了。

利用堆求 Top K

求 Top K 的问题抽象成两类。一类是针对静态数据集合,即数据集合事先确定,不会变。另一类是针对动态集合,即数据集合事先不确定,有数据动态地加入到集合中。

针对静态数据,我们可以维护一个大小为 K 的小顶堆,顺序遍历数组,从数组中取出数据与堆顶元素比较。如果比堆顶元素大,我们就把堆顶元素删除,并且将这个元素插入到堆中;如果比堆顶元素小,则不做处理,继续遍历数组。这样等数组中的数组都遍历完之后,堆中的数据就是前 K 大数据了。

遍历数组需要 O(n) 的时间复杂度,一次堆化操作需要 O(logK) 的时间复杂度,所以最坏情况下,n 个元素都入堆一次,时间复杂度就是 O(nlogK)。

针对动态数据求得的 Top K 就是实时 Top K。一个数据集合中有两个操作,一个是添加数据,一个是询问当前的前 K 大数据。

如果每次询问前 K 大数据,我们都基于当前的数据重新计算,那时间复杂度就是 O(nlogK),n 表示当前数据的大小。实际上,我们可以一直都维护一个 K 大小的小顶堆,当有数据被添加到集合中时,我们就拿它与堆顶的元素对比。如果比堆顶元素大,我们就把堆顶元素删除,并且将这个元素插入到堆中;如果比堆顶元素小,则不做处理。这样,无论任何时候需要查询当前的前 K 大数据,我们都可以立刻但会给他。

利用堆求中位数

中位数,顾名思义,就是处于中间位置的那个数。如果数据的个数是奇数,把数据从小到大排列,那第 $\cfrac{n}{2} + 1$ 个数据就是中位数(假设数据是从 0 开始编号的);如果数据的个数是偶数,那处于中间位置的数据有两个,第 $\cfrac{n}{2}$ 和 $\cfrac{n}{2} + 1$ 个数据,这个时候,我们可以随意取一个作为中位数,比如取两个数中靠前的那个,就是第 $\cfrac{n}{2}$ 个数据。

对于一组静态数据,中位数是固定的,我们可以先排序,第 $\cfrac{n}{2}$ 个数据就是中位数。每次询问中位数的时候,我们直接返回这个固定的值就好。所以,尽管排序的代价比较大,但是边际成本会很小。但是,如果我们面对的是动态数据集合,中位数在不停地变动,如果再用先排序的方法,每次询问中位数的时候,都要先进行排序,那效率就很低了。

借助堆这种数据结构,我们不用排序,就可以高效地实现中位数操作。

我们需要维护两个堆,一个大顶堆,一个小顶堆。大顶堆中存储前版本分数据,小顶堆中存储后半部分数据,且小顶堆中的数据都大于大大顶堆的数据。

也就是说,如果有 n 个数据,n 是偶数,我们从小到大排序,那前 $\cfrac{n}{2}$ 个数据存储在大顶堆中,后 $\cfrac{n}{2}$ 个数据存储在小顶堆中。这样,大顶堆的堆顶元素就是我们要找的中位数。如果 n 是奇数,情况是类似的,大顶堆就存储 $\cfrac{n}{2} + 1$ 个数据,小顶堆就存储 $\cfrac{n}{2}$ 个数据。

前面也提到过,数据是动态变化的,当新添加一个数据的时候,我们如何调整两个堆,让大顶堆的顶堆元素继续是中位数呢?

如果新加入的数据小于等于大顶堆的元素,我们就将这个新数据插入到大顶堆;否则,我们就将这个数据插入到小顶堆。

这个时候就有可能出现,两个堆中的数据个数不符合前面约定的情况:如果 n 是偶数,两个堆中的数据个数都是 $\cfrac{n}{2}$;如果 n 是奇数,大顶堆有 $\cfrac{n}{2} + 1$ 个数据,小顶堆有 $\cfrac{n}{2}$ 个数据。这个时候,我们可以从一个堆中不停地将堆顶元素移动到另一个堆,通过这样的调整,来让两个堆中的数据满足上面的约定。

于是,我们就可以利用两个堆,一个大顶堆,一个小顶堆,实现在动态数据集合中求中位数的操作。插入数据因为需要涉及堆化,所以时间复杂度就变成了 O(logn),但是求中位数我们只需要返回大顶堆的堆顶元素就可以,所以时间复杂度是 O(1)。

实际上,利用两个堆不仅可以快速求出中位数,还可以快速地求其他百分位的数据,原理是类似的。比如快速求接口的 99% 响应时间。

先解释一下,什么是 “99% 响应时间”。中位数的概念就是将数据从小到大排列,处于中间位置,就叫中位数,这个数据会大于等于前面 50% 的数据。99 百分位数的概念可以类比中位数,如果将一组数据从小到大排列,这个 99 百分位数就是大于前面 99% 数据的那个数据。

如果还不太理解,那就再举个例子。假设有 100 个数据,分别是 1,2,3 ...,100,那 99 百分位就是 99,因为小于等于 99 的个数占比占总个数的 99%。

弄清这个概念,我们再来看 99% 响应时间。如果有 100 个接口访问请求,每个接口请求的响应时间都不同,比如 55 毫秒、100 毫秒、23 毫秒,我们把这 100 个接口的响应时间从小到大排列,排在第 99 的那个数据就是 99% 响应时间,也叫 99 百分位响应时间。

总结一下,如果有 n 个数据,将数据从小到大排列之后,99 百分位数大约就是第 n * 99% 个数据,同类,80 百分位数大约是第 n * 80% 个数据。明白这些,我们再来看如何求 90% 响应时间。

我们维护两个堆,一个大顶堆,一个小顶堆。假设当前总数据的个数为 n,大顶堆中保存 n * 99% 个数据,小顶堆中保存 n * 1% 个数据。大顶堆堆顶的数据就是我们要找的 99% 响应时间。

每次插入一个数据的时候,我们要判断这个数据跟大顶堆和小顶堆数据的大小关系,然后决定插入到哪个堆中。如果这个新插入的数据比大顶堆的堆顶数据小,那就插入大顶堆;如果这个新插入的数据比小顶堆的堆顶数据大,那就插入小顶堆。

但是,为了保持大顶堆中的数据占 90%,小顶堆中的数据占 1%,在每次新插入数据之后,我们都要重新计算,这个时候大顶堆和小顶堆中的数据个数,是否还符合 99: 1 这个比例。如果不符合,我们就将一个堆中的数据移动到另一个堆,知道满足这个比例。移动的方法类似于前面求中位数的方法。

通过这样的方法,每次插入数据,可能会涉及几个数据的堆化操作,所以时间复杂度是 O(logn)。每次求 99% 响应时间的时候,直接返回大顶堆中的堆顶数据即可,时间复杂度是 O(1)。

总结

今天介绍了堆的几个重要的应用,分别是:优先级队列、求 Top K 问题和求中位数问题。

优先级队列是一种特殊的队列,优先级高的数据先出队,而不是像普通的队列那样,先进先出。实际上,堆就可以看作是优先级队列,只是称谓不一样。求 Top K 问题又可以分为针对静态数据和针对动态数据,只需要利用一个堆,就可以做到非常高效率地查询 Top K 的数据。求中位数实际上还有很多变形,比如求 99 百分位数据等,处理的思路都是一样的,即利用两个堆,一个大顶堆,一个小顶堆,随着数据的动态添加,动态调整两个堆中的数据,最后大顶堆和堆顶元素就是要求的数据。

技术拓展

如何快速获取 top 10 最热门的搜索关键词

处理这个问题,有很多高级的解决方法,比如使用 MapReduce。但是,如果我们将处理的场景限定为单机,可以使用的内存为 1 GB。那这个问题该如何解决呢?

因为用户搜索的关键词,有很多可能都是重复的,所以我们首先要统计每个搜索关键词出现的频率。我们可以通过散列表、平衡二叉查找树或者其他一些支持快速查找、插入的数据结构,来记录关键词及其出现的次数。

假设我们选用散列表。我们就顺序扫描这 10 亿个搜索关键词。当扫描到某个关键词时,我们去散列表查询。如果存在,我们就对应的次数加一;如果不存在,我们就将它插入到散列表,并记录次数为 1。以此类推,等遍历万这 10 亿个搜索关键词之后,散列表就存储了不重复的搜索关键词以及出现的次数。

然后,我们再根据前面讲的用堆求 Top K 的方法,建立一个大小为 10 个小顶堆,遍历散列表,依次取出每个搜索关键词及对应出现的次数,然后与堆顶的搜索关键词对比。如果出现次数比堆顶搜索关键词的次数多,那就删除堆顶的关键词,将这个出现次数最多的关键词加入到堆中。

以此类推,当遍历完整个散列表中的搜索关键词之后,堆中的搜索关键词就是出现次数最多的 Top 10 搜索关键词了。

上面的解决思路其实还存在漏洞。10 亿个关键词还是很多的,我们假设 10 亿条搜索关键词中不重复的有 1 亿条,如果每个搜索关键词的平均长度是 50 个字,那存储 1 亿个关键词起码需要 5 GB 的内存空间,而散列表因为要避免冲突,不会选择太大的散装因子,所以消耗的内存空间就更多。而我们的机器只有 1 GB 的可用内存空间,所以我们无法一次性将所有的搜索关键词加入到内存中。

我们再学哈希算法时讲过,相同数据讲过哈希算法得到的哈希值是一样的。我们可以根据哈希算法这个特点,将 10 亿条搜索关键词先通过哈希算法分片到 10 个文件中。

具体可以这样做:我们创建 10 个空文件 00,01,02, ...,09。我们遍历这 10 亿个关键词,并且通过哈希算法对其求哈希值,然后哈希值同 10 取模,得到的结果就是这个搜索关键词应该被分到的文件编号。

对这 10 亿个关键词分片之后,每个文件都只有 1 亿的关键词,去除重复的,可能就只有 1000 万个,每个关键词平均 50 个字节,所以总的大小就是 500 MB。1 GB 的内存完全放得下。

我们针对每个包含 1 亿条搜索关键词的文件,利用散列表和堆,分别求出 Top 10,然后把这个 10 个 Top 10 放在一块,然后取这个 100 个关键词中,出现次数最多的 10 个关键词,这就是这 10 亿数据中的 Top 10 最频繁的搜索关键词了。

排名 Top 10 的新闻摘要

有一个访问量非常大的新闻网站,我们希望将点击量排名 Top 10 的新闻摘要,滚动显示在网站首页 banner 上,并且每隔 1 小时更新一次。你会如何来实现呢?

。。。

十八、图的表示

微博、微信、QQ 这些社交软件我想你肯定都玩过。在微博中,两个人可以互相关注;在微信中,两个人可以互加好友。那你知道,如何存储微博、微信等这些社交网络的好友关系嘛?

这就要用到我们今天要讲的这种数据结构:图。实际上,涉及图的算法有很多,也非常复杂,比如图的搜索、最短路径、最小生成树、二分图等。今天我们主要来研究图存储这一方面。

如何理解 “图”

图(Graph)是一种非线性表结构,与树相比,这是一种更加复杂的非线性表结构。

树中的元素我们称为节点,图中的元素我们就叫做顶点(vertex)。图中的一个顶点可以与任意其他顶点建立连接关系。我们把这种建立的关系叫做边(edge)。

拿微信举例。我们可以把每个用户看作一个顶点。如果两个用户之间互加好友,那就在两者之间建立一条边。所以,整个微信的好友关系就可以用一张图来表示。其中,每个用户有多少个好友,对应到图中,就叫做顶点的度(degree),就是跟顶点相连接的边的条数。

实际上,微博的社交关系和微信不同,或者说更加复杂。微博允许单向关注,也就是说,用户 A 关注了 B,但是用户 B 可以不关注 A。那我们如何用图来表示这种单向的社交关系?

我们可以把刚刚讲的图结构稍微改造一下,引入边的 “方向” 的概念。

如果用户 A 关注了用户 B,我们就在图中画一条从 A 到 B 的带箭头的边,来表示边的方向。如果用户 A 和用户 B 互相关注了,那我们就画一条从 A 指向 B 的边,再画一条从 B 指向 A 的边。我们把这种边有方向的图叫做 “有向图”。我们把边没有方向的图叫做 “无向图”。

在无向图中存在 “度” 这个概念,表示一个顶点有多少条边。在有向图中,我们把度分为入度(In-degree)和出度(Out-degree)。

顶点的入度,表示有多少条边指向这个顶点;顶点的出度,表示有多少条边是以这个顶点为起点指向其他顶点。对应到微博的例子,入度就表示有多少粉丝,出度就表示关注了多少人。

QQ 中的社交关系要更复杂一点。QQ 还有亲密度这样一个功能。QQ 不仅记录了用户之间的好友关系,还记录了两个用户之间的亲密度,如果两个用户经常往来,那亲密度就高;如果不经常往来,亲密度就比较低。那么如何在图中记录这种好友关系的亲密度?

这里就要用到另一种图,带权图(weighted graph)。在带权图中,每条边都有一个权重(weight),我们可以通过这个权重来表示 QQ 好友之间的亲密度。

关于图的概念有很多,今天也只是介绍了几个常用的,理解起来也不复杂。

邻接矩阵

图最直观的一种存储方式就是,邻接矩阵(Adjacency Matrix)。

邻接矩阵的底层依赖一个二维数组。对于无向图来说,如果顶点 i 与顶点 j 之间有边,我们就将 A[i][j]A[j][i] 标记为1,如果顶点 i 到顶点 J 之间,有一条箭头从顶点 i 指向顶点 j 的边,那我们就将 A[i][j] 标记为 1。同理,如果有一条箭头从顶点 j 指向顶点 i 的边,我们就先将 A[j][i] 标记为 1。对于带权图,数组中就存储相应的权重。

用邻接矩阵来表示一个图,虽然简单、直观,但是比较浪费内存空间。

对于无向图来说,如果 A[i][j] 等于 1,那 A[j][i] 肯定也等于 1。实际上,我们只需要存储一个就可以。也就是说,无向图的二维数组中,如果我们将其用对角线划分为上下两部分,那我们只需要利用上面或者下面这样一半的空间就足够了,另外一半就浪费了。

还有,如果我们存储的是稀疏图(Sparse Matrix),也就是说,顶点很多,但每个顶点的边并不多,那邻接矩阵的存储方法就更加浪费空间。比如微信有好几亿的用户,对应到图上就是好几亿的顶点。但是每个用户的好友并不会很多,一般也就一两百个而已。如果我们用邻接矩阵来存储,那绝大部分的存储空间都被浪费了。

但这也并不是说,邻接矩阵的存储方法就完全没有优点。首先,邻接矩阵的存储方式简单、直接,因为基于数组,所以在获取两个顶点的关系时,就非常高效。其次,用邻接矩阵存储图的另外一个好处是方便计算。这是因为,用邻接矩阵的方式存储图,可以将很多图的运算转换成矩阵的计算。比如求解最短路径问题时会提到一个 Floyd-Warshall 算法,就是利用矩阵循环相乘若干次得到结果。

邻接表存储方法

针对上面邻接矩阵比较浪费内存空间的问题,我们再来看另外一种图的存储方法,邻接表(Adjacency List)。

下面有一张邻接表的图,你可以先看下。乍一看,邻接表是不是有点像散列表?每个顶点对应一条链表,链表中存储的是与这个顶点相连接的其他顶点。图中画的是一张有向图的邻接表存储方式,每个顶点对应的链表里面,存储的是指向的顶点。对于无向图来说,也是类似的。不过,每个顶点的链表中存储的,是跟这个顶点有边相连的顶点。

邻接矩阵存储起来比较浪费空间,但是使用起来比较节省空间。相反,邻接表存储起来比较节省空间,但是使用起来就比较耗时间。

如上图所示,如果我们要确定,是否存在一条从顶点 2 到顶点 4 的边,那我们就要遍历顶点 2 对应的那条链表,看链表是否存在顶点 4。我们之前也说过,链表的存储方式对缓存不友好。所以,比起邻接矩阵的存储方式,在邻接表中查询两个顶点之间的关系就没有那么高效。

链表的存储空间不连续,无法利用局部性原理,将前后的节点都 cache 。

基于链表法解决冲突的散列表中,如果链过长,为了提高查找效率,我们可以将链表换成其他更加高效的数据结构,比如平衡二叉查找树等。刚才也说到,邻接表长得很像散列。所以,我们也可以将邻接表同散列表一样进行 “改造升级”。

我们可以将邻接表中的链表改成平衡二叉树。实际开发中,我们可以选择红黑树。这样,我们就可以更加快速地查找两个顶点之间是否存在边了。当然,这里的二叉查找树可以换成其他数据结构,比如跳表、散列表等。除此之外,我们还可以将链表改成有序动态数组,可以通过二分查找的方法来快速定位两个顶点之间是否存在边。

总结

关于图,你需要理解这样几个概念:无向图、有向图、带权图、顶点、边、度、入度、出度。除此之外,还有图的两个主要的存储方式:邻接矩阵和邻接表。

邻接矩阵存储方法的缺点是比较浪费内存空间,但是查询效率高,而且方便矩阵运算。邻接表存储方法中每个顶点都对应一个链表,存储与其相连接的其他顶点。尽管邻接表的存储方式比较节省存储空间,但链表不方便查找,所以查询效率没有邻接矩阵存储方式高。针对这个问题,邻接表还有改进升级版,即将链表换成更加高效的动态数据结构,比如平衡二叉查找树、跳表、散列表等。

技术拓展

如何存储微博、微信等社交网络中的好友关系?

数据结构是为算法服务的,所以具体选择哪种存储方法,与期望支持的操作有关系。针对微博用户关系,假设我们需要支持下面这几个操作:

  • 判断用户 A 是否关注了用户 B;
  • 判断用户 A 是否是用户 B 的粉丝;
  • 用户 A 关注用户 B;
  • 用户 A 取消关注用户 B;
  • 根据用户名称的首字母排序,分页获取用户的粉丝列表;
  • 根据用户名称的首字母排序,分页获取用户的关注列表;

存储一个图,我们讲了两种主要的存储方法,邻接矩阵和邻接表。因为社交网络是一张稀疏图,使用邻接矩阵存储比较浪费存储空间。所以,我们可以采用邻接表来存储。

不过,用一个邻接表来存储这种有向图是不够的。我们去查找某个用户关注了哪些用户非常容易,但是如果想知道某个用户都被那些用户关注了,也就是用户的粉丝列表,是非常困难的。

基于此,我们需要一个逆邻接表。邻接表中存储了用户的关注关系,逆邻接表中存储的是用户的被关注关系。对应到图上,邻接表中,每个顶点的链表中,存储的就是这个顶点指向的顶点,逆邻接表中,每个顶点的链表中,存储的是指向这个顶点的顶点。如果要查找某个用户关注了哪些用户,我们可以在邻接表中查找;如果要查找某个用户被哪些用户关注了,我们从逆邻接表中查找。

基础的邻接表不适合快速判断两个用户之间是否是关注与被关注的关系,所以我们选择改进版本,将邻接表中的链表改为支持快速查找的动态数据结构。那么该选择哪种动态数据结构呢?红黑树、跳表、有序动态数组还是散列表?

因为我们需要按照用户名称的首字母排序,分页获取用户的粉丝列表或者关注列表,用跳表这种数据结构再适合不过了。这是因为,跳表插入、删除、查找都非常高效,时间复杂度是 O(logn),空间复杂度比较高,是 O(n)。最重要的一点,跳表中存储的数据本来就是有序的,分页获取粉丝列表或关注列表,就非常高效。

对于小规模的数据,比如社交网络中只有几万、几十万个用户,我们可以将整个社交关系存储在内存中,上面的解决思路是没有问题的。但是如果像微博那样有上亿的用户,数据规模太大,我们就无法存储在内存中了。

我们可以通过哈希算法等数据分片方式,将邻接表存储在不同的机器上。你可以看下图,我们在机器 1 上存储顶点 1,2,3 的邻接表,在机器 2 上,存储顶点 4,5 的邻接表。逆邻接表的处理方式也一样。当要查询顶点与顶点关系的时候,我们就利用同样的哈希算法,先定位顶点所在机器,然后再在相应的机器上查找。

除此之外,我们还有另外一种解决思路,就是利用外部存储(比如硬盘),因为外部存储的存储空间要比内存会宽裕很多。数据库是我们经常用来持久化存储关系数据的,所以这里也介绍一种数据库的存储方式。

我们可以用下张图存储这样一个图。为了高效地支持前面定义的操作,我们可以在表上建立多个索引,比如第一列、第二列,给这两列都建立索引。

图的其他应用场景

地图、网络等。

十九、深度和广度优先搜索

给你一个用户,如何找出这个用户的所有三度(其中包含一度、二度和三度)好友关系?

这就要用到今天要说的深度优先和广度优先搜索算法。

什么是 “搜索” 算法

算法是作用于具体数据结构之上的,深度优先搜索算法和广度优先搜索算法都是基于 “图” 这种数据结构的。这是因为,图这种数据结构的表达能力很强,大部分涉及搜索的场景都可以抽象成 “图”。

图上的搜索算法,最直接的理解就是,在图中找出从一个顶点出发,到另一个顶点的路径。具体方法有很多,比如今天要讲的两种最简单、最 “暴力” 的深度优先、广度优先搜索,还有 A*IDA* 等启发式搜索算法。

图有两种主要存储方法,邻接表和邻接矩阵。今天我们主要使用邻接表来存储图。

这里先给出图的代码实现。深度优先搜索算法和广度优先搜索算法,既可以用在无向图,也可以用在有向图上。今天,我们主要针对无向图来讲解。

js
class Graph {
  v;
  agj;

  constructor (v) {
    this.v = v;
    this.agj = new Array(v);

    for (let i = 0; i < v; i++) {
      this.agj[i] = [];
    }
  }

  addEdge (s, t) {
    this.addEdge[s].push(t);
    this.addEdge[t].push(s);
  }
}

广度优先搜索(BFS)

广度优先搜索(Breadth-First-Search),平常我们简称 BFS。它其实就是一种 “地毯式” 层层推进的搜索策略,即先查找离起始顶点最近的,然后是次近的,依次往外搜索。

广度优先搜索原理挺简单,但是代码实现也有点复杂。所以,我们重点讲一下它的代码实现。

下面写的 bfs() 函数就是基于之前定义的,图的广度优先搜索的代码实现。其中 s 表示起始顶点,t 表示终止顶点。我们搜索一条从 s 到 t 的路径。实际上,这样求得的路径就是从 s 到 t 的最短路径。

js
// LinkedList js 实现

function Node (val) {
  this.val = val;
  this.next = null;
}

function LinkedList (values) {
  if (values) {
    this.head = new Node(values.shift());
    
    currNode = this.head;

    values.forEach(val => {
      currNode.next = new Node(val);
      currNode = currNode.next;
    });
  }
}

LinkedList.prototype.add = function (val) {
  if (this.head) {
    let currNode = this.head;

    while (currNode.next) {
      currNode = currNode.next;
    }

    currNode.next = new Node(val);
  } else {
    this.head = new Node(val);
  }
}

LinkedList.prototype.size = function () {
  if (this.head) {
    let current = this.head;
    let count = 0;

    while (current) {
      current = current.next;
      count++;
    }

    return count;
  }

  return 0;
}

LinkedList.prototype.poll = function () {
  const current = this.head;
  this.head = this.head.next;
  return current.val;
}

LinkedList.prototype.get = function (idx) {
  let current = this.head;
  let count = 0;
  
  while (current) {
    if (count === idx) {
      return current.val;
    }

    current = current.next;
    count++;
  }

  return 0;
}
js
// Graph 实现

class Graph {
  v;
  agj;

  constructor (v) {
    this.v = v;
    this.agj = [];

    for (let i = 0; i < v; i++) {
      this.agj[i] = new LinkedList();
    }
  }

  addEdge (s, t) {
    this.agj[s].add(t);
    this.agj[t].add(s);
  }

  bfs (s, t) {
    if (s === t) return;
    
    const visited = [];
    const queue = new LinkedList();
  
    visited[s] = true;
    queue.add(s);

    const prev = new Array(this.v);

    for (let i = 0; i < this.v; i++) {
      prev[i] = -1;
    }
  
    while (queue.size() != 0) {
      const w = queue.poll(); 

      for (let i = 0; i < this.agj[w].size(); i++) {
        const q = this.agj[w].get(i);

        if (!visited[q]) {
          prev[q] = w;

          if (q === t) {
            this.print(prev, s, t);
            return;
          }

          visited[q] = true;
          queue.add(q);
        }
      }
    }
  }

  print (prev, s, t) {
    if (prev[t] != -1 && t != s) {
      this.print(prev, s, prev[t]);
    }
    
    console.log(t + ' ');
  }
}
js
// 测试用例

const graph = new Graph(8);

graph.addEdge(0, 1);
graph.addEdge(0, 3);
graph.addEdge(1, 2);
graph.addEdge(1, 4);
graph.addEdge(4, 5);
graph.addEdge(4, 6);
graph.addEdge(3, 4);
graph.addEdge(2, 5);
graph.addEdge(5, 7);
graph.addEdge(6, 7);

graph.bfs(0, 7);

图的 bfs 不是很好理解,里面有三个重要的辅助变量 visited,queue,prev 。只要理解了这三个变量,读懂这段代码就没有什么问题了。

visited 是用来记录已经被访问的顶点,用来避免顶点被重复访问。如果顶点 q 被访问,那相应的 visited 会被设置为 true。

queue 是一个队列,用来存储已经被访问,但相连的顶点还没有被访问的顶点。因为广度优先搜索是逐层访问的,也就是说,我们只有把第 k 层的顶点都访问完成之后,才能访问第 k + 1 层的顶点,我们只需要把第 k 层的顶点记录下来,稍后才能通过第 k 层来找第 k + 1 层的顶点。所以,我们用这个队列来实现记录的功能。

prev 用来记录搜索路径。当我们从顶点 s 开始,广度优先搜索到顶点 t 后,prev 数组中存储的就是搜索的过程。不过,这个路径是反向存储的,prev[w] 存储的是,顶点 w 是从哪个前驱节点遍历过来的。比如,我们通过顶点 2 的邻接表访问到顶点 3,那 prev[3] 就等于 2。为了正向打印出路径,我们需要递归来打印,这也就是 print 函数的作用。

下面有一个广度优先搜索的分解图。

明白广度优先搜索算法的原理之后,我们再来看下,广度优先搜索的时间、空间复杂度是多少。

最坏情况下,终止顶点 t 离起始顶点 s 很远,需要遍历完整个图才能找到。这个时候,每个顶点都要进出一遍队列,每个边都会被访问一次,所以,广度优先搜索的时间复杂度是 O(V + E),其中,V 表示顶点的个数,E 表示边的个数。当然,对于一个连通图来说,也就是说一个图中的所有顶点都是连通的,E 肯定要大于等于 V - 1,所以,广度优先搜索的时间复杂度也可以简写为 O(E)。

树的特性是E = V - 1,树是边最少的联通图,因此一般而言E >= V -1

广度优先搜索的空间消耗主要在几个辅助变量 visited 数组、queue 队列、prev 数组上。这三个存储空间的大小都不会超过顶点的个数,所以空间复杂度是 O(V)。

深度优先搜索(DFS)

深度优先搜索(Depth-First-Search),简称 DFS。最直观地例子就是 “走迷宫”。

假设你站在迷宫的某个岔路口,想找到出口。你随意选择一个岔路口来走,走着走着发现走不通的时候,你就会回退到上一个岔路口,重新选择一条路继续走,直到最终找到出口。这种走法就是一种深度优先搜索策略。

走迷宫的例子很容易就能看懂,我们来看下,如果在图中应用深度优先搜索,来找某个顶点到另一个顶点的路径。

你可以参考下图。搜索的起点顶点是 s,终止顶点是 t,我们希望在图中寻找一条从顶点 s 到顶点 t 的路径。如果映射到迷宫那个例子,s 就是你起始所在的位置,t 就是出口。

用深度递归算法,把整个搜索的路径标记出来。这里面实线箭头表示遍历,虚线箭头表示回退。从图中我们可以看出,深度优先搜索找出的路径,并不是顶点 s 到到顶点 t 的最短路径。

实际上,深度优先搜索用的是一种比较著名的算法思想,回溯思想。这种思想解决问题的过程,非常适合用递归来实现。

把上面的过程用递归翻译出来,就是下面这个样子。深度优先搜索代码实现也用到了 prev、visited 变量以及 print() 函数,它们跟广度优先搜索代码实现里的作用是一样的。不过,深度优先搜索代码实现里,有个比较特殊的变量 found,它的作用是,当我们已经找到终点 t 之后,我们就不再递归地继续查找了。

js
class Graph {
  v;
  agj;

  // ...

  found = false;

  dfs (s, t) {
    this.found = false;

    const visited = [];
    const prev = new Array(this.v);

    for (let i = 0; i < this.v; i++) {
      prev[i] = -1;
    }

    this.recurDfs(s, t, visited, prev);
    this.print(prev, s, t);
  }

  recurDfs (w, t, visited, prev) {
    if (this.found === true) return;

    visited[w] = true;

    if (w == t) {
      this.found = true;
      return;
    }

    console.log(w, t);

    for (let i = 0; i < this.agj[w].size(); i++) {
      const q = this.agj[w].get(i);

      if (!visited[q]) {
        prev[q] = w;
        this.recurDfs(q, t, visited, prev);
      }
    }
  }

  // ...
}

理解深度优先搜索算法之后,我们来看,深度优先搜索的时间、空间复杂度是多少呢?

从前面的图来看,每条边最多会被访问两次,一次是遍历,一次是回退。所以,图上的深度优先搜索算法的时间复杂度是 O(E),E 表示边的个数。

深度优先搜索算法的消耗内存主要是 visited、prev 数组和递归调用栈。visted、prev 数组的大小跟顶点的个数 V 成正比,递归调用栈的最大深度不会超过顶点的个数,所以总的空间复杂度就是 O(V)。

总结

广度优先搜索和深度优先搜索是图上的两种最常用、最基本的搜索算法,比起其他高级的搜索算法,比如 A*IDA* 等,要简单粗暴,没有什么优化,所以,也被叫做暴力搜索算法。所以,这两种搜索算法仅适用于状态空间不大,也就是说图不大的搜索。

广度优先搜索,通俗的理解就是,地毯式层层推进,从起始顶点开始,依次往外依次遍历。广度优先搜索需要借助队列来实现,遍历得到的路径就是,起始顶点到终止顶点的最短路径。深度优先搜索用的是回溯思想,非常适合用递归实现。换种说法,深度优先搜索是借助栈来实现的。在执行效率方面,深度优先和广度优先搜索的时间复杂度都是 O(E),空间复杂度是 O(V)。

技术拓展

如何找出社交网络中某个用户的三度好友关系?

社交网络可以用图来表示。这个问题非常适合用图的广度优先搜索算法来解决,因为广度优先搜索算法是层层往外推进的。首先,遍历与起始顶点最近的一层顶点,也就是用户的一度好友,然后再遍历与用户距离的边数为 2 的顶点,也就是二度好友关系,以及与用户距离的边数为 3 的顶点,也就是三度好友关系。

我们只需要稍微改造一下广度优先搜索代码,用一个数组来记录每个顶点与起始顶点的距离,非常容易就可以找出三度好友关系。

广度优先搜索的问题,深度优先搜索是否可以解决?

可以。

如何将迷宫抽象成一个图?

可以将迷宫抽象成图,每个分叉路口作为顶点,顶点之间连成边,构成一张无向图,可以存储在邻接矩阵或邻接表中。

二十、字符串匹配:哈希算法

字符串匹配这样一个功能,对于任何一个开发工程师来说,都不会陌生。我们用的最多的就是编程语言提供的字符串查找函数,比如 Java 中的 indexOf(),Python 中的 find() 函数等,它们底层就是依赖接下来要讲的字符串匹配算法。

字符串匹配算法很多。今天先讲两种简单的,好理解的,它们分别是:BF 算法和 PK 算法。后面会讲两种比较难理解,但更加高效的,它们是:BM 算法和 KMP 算法。

PK 算法是 BF 算法的改进,它巧妙借助了我们前面讲过的哈希算法,让匹配的效率有了很大的提升。

BF 算法(Brute Force)

BF 算法中的 BF 是 Brute Force 的缩写,中文叫做暴力匹配算法,也叫朴素匹配算法。从名字可以看出,这种算法的字符串匹配很 “暴力”,当然也就会比较简单、好懂,但相应的性能也不高。

了解这个算法之前,先定义两个概念。它们分别是主串和模式串。

比方说,我们在字符串 A 中查找字符串 B,那字符串 A 就是主串,字符串 B 就是模式串。我们把主串的长度记作 n,模式串的长度记作 m。因为我们是在主串中查找模式串,搜索 n > m。

作为最简单、最暴力的字符串匹配算法,BF 算法的思想可以用一句话来概括,那就是,我们在主串中,检查起始位置分别是 0,1,2 ... n - m 且长度为 m 的 n - m + 1 个字串,寻找有没有跟模式串匹配的。

从上面的算法思想和例子,我们可以看出,在极端情况下,比如主串是 “aaaaa...aaaaa”,模式串是 "aaaaab"。我们每次都比对 m 个字符,要比对 n - m + 1 次,所以,这种算法的最坏情况时间复杂度是 O(n * m)。

尽管理论上,BF 算法的时间复杂度很高,是 O(n * m),但是在实际的开发中,它是一个比较常用的字符串匹配算法。

第一,实际的软件开发中,大部分情况下,模式串和主串的长度都不会太长。而且每次模式串与主串匹配的时候,当中途遇到不能匹配的字符的时候,就可以停止了,不需要把 m 个字符都比对一下。所以,尽管理论上的最坏情况时间复杂度是 O(n * m),但是,统计意义上,大部分情况下,算法执行效率要比这个高很多。

第二,朴素字符串匹配算法思想很简单,代码实现也非常简单。简单意味着不容易出错,如果有 bug 也容易暴露和修复。在工程中,在满足性能要求的前提下,简单是首选。这也是我们常说的 KISS(Keep it Simple and Stupid)设计原则。

所以,在实际的软件开发中,绝大部分情况下,朴素的字符串匹配算法就够用了。

RK 算法(Rabin-Karp)

RK 算法的全称叫做 Rabin-Karp 算法,是由它的两位发明者 Rabin 和 Karp 的名字来命名的。这个算法理解起来也不是很难。

刚才在说 BF 算法的时候讲过,如果模式串长度为 m,主串长度为 n,那在主串中,就会有 n - m + 1 个长度为 m 的子串,我们只需要暴力地对比这 n - m + 1 个子串与模式串,就可以找出主串与模式串匹配的字串。

但是,每次检查主串与模式串是否匹配,需要依次对比每个字符,所以 BF 算法的时间复杂度就比较高,是 O(n * m)。我们对朴素的字符串匹配算法,稍加改造,引入哈希算法,时间复杂度立刻就会降低。

RK 算法的思路是这样的:我们通过哈希算法对主串中的 n - m + 1 个子串分别求哈希值,然后逐个与模式串的哈希值比较大小。如果某个字串的哈希值与模式串相等,那就说明对应的子串和模式串匹配了(先不考虑哈希冲突的问题)。因为哈希值是一个数字,数字之间比较是否相等是非常快速的,所以模式串和字串比较的效率就提高了。

不过,通过哈希值计算字串的哈希值的时候,我们需要遍历子串中的每个字符。尽管模式串与子串比较的效率提高了,但是算法整体的效率并没有提高。有没有方法可以提高哈希算法计算子串哈希值的效率呢?

这就需要哈希算法设计的非常巧妙了。我们假设要匹配的字符串的字符集中只包含 K 个字符,我们可以用一个 K 进制数来表示一个子串,这个 K 进制转化成十进制数,作为子串的哈希值。

比如要处理的字符串只包含 a ~ z 这 26 个小写字母,那我们就用二十六进制来表示一个字符串。我们把 a - z 这 26 个字符映射到 0 - 25 这 26 个数字,a 就表示 0,b 就表示 1,以此类推,z 表示 25。

在十进制的表示法中,一个数字的值是通过下面的方式计算出来的。对应到二十六进制,一个包含 a 到 z 这 26 个字符的字符串,计算哈希的时候,我们只需要把进位从 10 改成 26 就可以。

上面的哈希算法你应该已经看懂了。为了方便解释,在下面的的讲解中,假设字符串中只包含 a ~ z 这 26 个小写字符,我们用二十六进制来表示一个字符串,对应的哈希值就是二十六进制数转换为十进制的结果。

这种哈希算法有一个特点,在主串中,相邻两个子串的哈希值的计算公式有一定关系。

从这个例子中,我们很容易就得出这样的规律:相邻两个子串 s[i - 1] 和 s[i](i 表示子串在主串中的其实位置,子串的长度都为 m),对应的哈希值计算公式有交集,也就是说,我们可以使用 s[i - 1] 的哈希值很快的计算出 s[i] 的哈希值。如果用公式表示的话,就是下面这个样子:

不过,这里有一个小细节需要注意,那就是 26^(m - 1) 这部分的计算,我们可以通过查表的方法来提高效率。我们事先计算好 26^0、26^1、26^2 ... 26^(m - 1),并且存储在一个长度为 m 的数组中,公式中的 “次方” 就对应数组的下标。当我们需要计算 26 的 x 次方的时候,就可以从数组的下标为 x 的位置取值,直接使用,省去了计算的时间。

RK 算法的效率要比 BF 算法高,那 RK 算法的时间复杂度到底是多少?

整个 RK 算法包括两部分,计算子串哈希值和模式串哈希值与子串哈希值之间的比较。 第一部分,可以通过设计特殊的哈希算法,只需要扫描一遍主串就能计算出所有子串的哈希值,所以这部分时间复杂度是 O(n)。

模式串哈希值与每个子串哈希值之间的比较的时间复杂度是 O(1),总共需要比较 n - m + 1个子串的哈希值,所以,这部分的时间复杂度也是 O(n)。所以,RK 算法整体的时间复杂度就是 O(n)。

这里还有一个问题就是,模式串很长,相应的主串中的子串也会很长,通过上面的哈希算法计算得到的哈希值就可能很大,如果超过了计算机整型数据可以表示的范围,那该如何解决呢?

刚刚我们设计的哈希算法是没有散列冲突的,也就是说,一个字符串与一个二十六进制数一一对应,不同的字符串的哈希值肯定不一样。因为我们是基于进制来表示一个字符串的,你可以类比成十进制、十六进制来思考一下。实际上,我们为了能将哈希值落在整型数据范围内,可以牺牲一下,允许哈希冲突。这个时候哈希算法该如何设计呢?

哈希算法的设计方法有很多,举个例子说明一下。假设字符串中只包含 a - z 这 26 个英文字母,那我们把每个字母对应一个数字,比如 a 对应 1,b 对应 2,以此类推,z 对应 26。我们可以把字符串中每个字母对应的数字相加,最后得到的和作为哈希值。这种哈希算法产生的哈希值的数据范围就相对小很多。

不过,你也应该发现,这种哈希算法的哈希冲突概率也是挺高的。这里只是举了一个最简单的设计方法,还有很多更加优化的方法,比如将一个字母从小到大对应一个素数,而不是 1,2,3 ... 这样的自然数,这样冲突的概率就会降低一些。

那现在新的问题就来了。之前我们只需要比较一下模式串和子串的哈希值,如果两个值相等,那么子串就一定可以匹配模式串。但是,当存在哈希冲突的时候,有可能存在这样的情况,子串和模式串的哈希值虽然是相同的,但是两者本身并不匹配。

实际上,解决方法很简单。当我们发现一个子串的哈希值跟模式串的哈希值相等的时候,我们只需要再对比一下子串和模式串本身就好了。当然,如果子串的哈希值与模式串的哈希值不相等,那对应的子串和模式串肯定也是不匹配的,就不需要比对子串和模式串本身了。

所以,哈希算法的冲突概率要相对控制得低一些,如果存在大量冲突,就会导致 RK 算法的时间复杂度退化,效率下降。极端情况下,如果存在大量的冲突,每次都要再对比子串和模式串本身,那时间复杂度就会退化成 O(n * m)。但也不能太悲观,一般情况下,冲突不会很大,RK 算法的效率还是比 BF 算法高的。

总结

今天我们学习了两种字符串匹配算法,BF 算法和 RK 算法。

BF 算法是最简单、粗暴的字符串匹配算法,它的实现思路是,拿模式串与主串中的所有子串匹配,看是否有能匹配的子串。所以,时间复杂度也比较高,是 O(n * m),n、m 表示主串和模式串的长度。不过,在实际的软件开发中,因为这种算法实现简单,对于处理小规模的字符串匹配很好用。

RK 算法是借助哈希算法对 BF 算法进行改造,即对每个子串分别求哈希值,然后拿子串的哈希值与模式串的哈希值比较,减少了比较的时间。所以,理想情况下,RK 算法的时间复杂度是 O(n),跟 BF 算法相比,效率提高了很多。不过这样的效率取决于哈希算法的设计方法,如果存在冲突的情况下,时间复杂度可能会退化。极端情况下,哈希算法大量冲突,时间复杂度就退化为 O(n * m)。

技术拓展

如何使用 BF 和 RK 查找二维字符串矩阵?

。。。

二十一、字符串匹配:BM 算法

文本编辑器的查找替换功能,你应该并不陌生。比如,我们在 Word 中把一个单词统一替换成另一个,用的就是这个功能。不过,你有没有想过它是怎么实现的?

当然,你也可以通过 BF 算法和 RK 算法实现这个功能,但是在某些极端情况下,BF 算法性能会退化的比较严重,而 RK 算法需要用到哈希算法,设计一个可以应对各种类型字符的哈希算法并不简单。

对于工业级的软件开发来说,我们希望算法尽可能的高效,并且在极端情况下,性能也不要退化的太严重。比如一些文本编辑器,它们的查找功能都是用哪种算法来实现的呢?有没有比 BF 算法和 RK 算法更加高效的字符串匹配算法呢?

今天,我们就来学习 BM(Boyer-Moore)算法。它是一种非常高效的字符串匹配算法,它的性能是著名的 KMP 算法的 3 到 4 倍。

BM 算法核心思想

我们把模式串和主串的匹配过程,看作模式串在主串中不停地往后滑动。当遇到不匹配的字符时,BF 算法和 PK 算法的做法是,模式串往后一位,然后从模式串的第一个字符开始重新匹配。

在这个例子中,主串里的 c,在模式串中是不存在的,所以,模式串向后滑动的时候,只要 c 与模式串没有重合,肯定无法匹配。所以,我们可以一次性把模式串往后多滑动几位,把模式串移动 c 的后面。

通过现象找规律,当遇到不匹配的字符时,是否有什么固定的规律,可以将模式串往后多滑动几位呢?这样一次性往后滑动好几位,那匹配的效率是不是就提高了?

今天要讲的 BM 算法,本质上其实就是在寻找这种规律。借助这种规律,在模式串与主串匹配的过程中,当模式串和主串某个字符不匹配的时候,能够跳过一些肯定不会匹配的情况,将模式串往后多滑动几位。

BM 算法原理分析

BM 算法包括两部分,分别是坏字符(bad character rule)和好后缀规则(good suffix shift)。下面我们依次来看,这两个规则分别都是怎么工作的。

坏字符规则

之前讲的字符串匹配算法,在匹配的过程中,都是按照模式串的下标从小到大的顺序,依次与主串中的字符进行匹配的。这种匹配顺序比较符合我们的思维习惯,而 BM 算法的匹配顺序比较特别,它是按照模式串下标从大到小的顺序,倒着匹配的。

从模式串的末尾往前倒着匹配,当发现某个字符没法匹配的时候,我们把这个没有匹配的字符叫做坏字符(主串中的字符)。

我们拿坏字符 c 在模式串中查找,发现模式串并不存在这个字符,也就是说,字符 c 与模式串的中的任何字符都不可能匹配。这个时候,我们可以将模式串直接往后滑动三位,将模式串滑动到 c 后面的位置,再从模式串的末尾字符开始比较。

这个时候,我们发现,模式串中最后一个字符,还是无法跟主串中的 a 匹配,这个时候,还能将模式串向后滑动三位吗?答案是不行的。因为在这个时候,坏字符 a 在模式串中是存在的,模式串下标是 0 的位置也是字符 a。这种情况下,我们可以将模式串往后滑动两位,让两个 a 上下对齐,然后再从模式串的末尾字符开始,重新匹配。

第一次不匹配的时候,我们滑动了三位,第二次不匹配的时候,我们将模式串后移两位,那具体滑动多少位,到底有没有规律?

当发生不匹配的时候,我们把坏字符对应的模式串中的字符下标记作 si。如果坏字符在模式串中存在,我们把这个坏字符在模式串中的下标记作 xi。如果不存在,我们把 xi 记作 -1。那模式串往后移动的位数就等于 si - xi(这里说的下标,都是字符在模式串中的下标)。

这里要特别说明一点,如果坏字符在模式串中多处出现,那我们在计算 xi 的时候,选择最靠后的那个,因为这样不会让模式串滑动过多,导致本来可能匹配的情况被滑动略过。

利用坏字符规则,BM 算法在最好情况下的时间复杂度非常低,是 O(n/m)。比如,主串是 aaabaaabaaabaaab,模式串是 aaaa。每次比对,模式串都可以直接后移四位,所以,匹配具有类似特点的模式串和主串的时候,BM 算法非常高效。

不过,单独使用坏字符规则还是不够的。因为根据 si-xi 计算出来的移动次数,有可能是负数,比如主串是 aaaaaaaaaaaaaaaa,模式串是 baaa。不但不会向后滑动模式串,还有可能倒退。所以,BM 算法还需要用到 “好后缀规则”。

好后缀规则

好后缀规则实际上跟坏字符规则的思路类似。当模式串滑动到图中的位置的时候,模式串和主串有 2 个字符是匹配的,倒数第 3 个字符发生了不匹配的情况。

这个时候该如何滑动模式串呢?当然,我们还可以利用坏字符规则来计算模式串的滑动位数,不过,我们也可以使用好后缀处理规则。现在我们来看,好后缀规则是怎么工作的。

我们把已经匹配的 bc 叫做好后缀,记作 {u}。我们拿它在模式串中查找,如果找到了另一个跟 {u} 相匹配的子串 {u*}。那我们就将模式串滑动到子串 {u*} 与主串中 {u} 对齐的位置。

如果在模式串中找不到另一个等于 {u} 的子串,我们就直接将模式串,滑动到主串中 {u} 的后面,因为之前的任何一次往后滑动,都没有匹配主串中 {u} 的情况。

不过,当模式串中不存在等于 {u} 的子串时,我们直接将模式串滑动到主串 {u} 的后面。这样做是否不大合理?我们来看下下面这个例子。这里面 bc 是好后缀,尽管在模式串中没有没有另外一个相匹配的 {u} ,但是如果我们将模式串移动到好后缀的后面,如果所示,那就会错过模式串和主串可以匹配的情况。

如果好后缀在模式串中不存在可匹配的子串,那在我们一步一步地往后滑动模式串的过程中,只要主串中的 {u} 与模式串有重合,那肯定就无法完全匹配。但是当模式串滑动到前缀与子串中 {u} 的后缀有部分重合的时候,并且重合的部分相等的时候,就有可能存在完全匹配的情况。

所以,针对这种情况,我们不仅要看好后缀在模式串中,是否还有另一个匹配的子串,我们还要考察后缀的后缀子串,是否存在跟模式串的前缀子串匹配的。

所谓某个字符串 s 的后缀子串,就是最后一个字符跟 s 对齐的子串,比如 abc 的后缀子串就包括 c, bc。所谓前缀子串,就是起始字符跟 s 对齐的子串,比如 abc 的前缀子串就包括 a, ab。我们从好后缀的后缀字符串中找到一个最长的并且能跟模式串的前缀子串匹配的,假设是 {v},然后将模式串滑动到如图所示的位置。

坏字符和好后缀的基本原理都讲完了。现在我们来看下两种规则如何选择的问题。当模式串和主串中的某个字符不匹配的时候,如何选择用好后缀规则还是坏字符规则,来计算模式串往后滑动的位数?

我们可以分别计算好后缀和坏字符往后滑动的位数,然后取两个数中最大的,作为模式串往后滑动的位数。这种处理方法还可以避免我们前面提到的,根据坏字符规则,计算得到的往后滑动的位数,有可能是负数的情况。

BM 算法代码实现

学习完基本原理,我们再来看,如何实现 BM 算法。

“坏字符规则” 本身不难理解。当遇到坏字符,要计算往后移动的位数 si-xi,其中 xi 的计算是重点,我们如何求得 xi 呢?或者说,如何查找坏字符在模式串中出现的位置呢?

如果我们拿坏字符,在模式串中顺序遍历查找,这样就会比较低效,势必影响这个算法的性能,有没有更加高效的方式呢?我们可以使用散列表,将模式串中的每个字符及其下标都存到散列表中。这样就可以快速找到坏字符在模式串的位置下标。

关于这个散列表,我们只实现一种最简单的情况,假设字符串的字符集不是很大,每个字符长度是 1 字节,我们用大小为 256 的数组,来记录每个字符在模式串中出现的位置。数组的下标对应字符的 ASCII 值,数组中存储这个字符在模式串中出现的位置。

如果将上面的过程翻译成代码,就是下面这个样子。其中,变量 b 是模式串,m 是模式串的长度,bc 表示刚刚讲的散列表。

js
const SIZE = 256;

function generateBC(b, m, bc) {
  for (let i = 0; i < SIZE; i++) {
    bc[i] = -1;
  }
  
  for (let i = 0; i < m; i++) {
    const ascii = b[i].charCodeAt();
    bc[ascii] = i;
  }
}

掌握了坏规则之后,我们先把 BM 算法代码的大框架写好,先不考虑后缀规则,仅用坏字符规则,并且不考虑 si-xi 计算得到的移动位数可能会出现负数的情况。

js
function bm (a, n, b, m) {
  const bc = new Array(SIZE); // 记录模式串每个字符最后出现位置

  generateBC(b, m, bc); // 构建坏字符哈希表

  let i = 0; // 表示主串与模式串对齐的第一个字符

  while (i <= n - m) {
    let j;

    // 模式串从后往前匹配
    for (j = m - 1; j >= 0; j--) {
      if (a[i + j] != b[j]) break; // 坏字符对应模式串中的下标是 j
    }

    if (j < 0) {
      return i; // 匹配成功,返回主串与模式串第一个匹配的字符的位置
    }


    // 这里等同于将模式串往后滑动 j - bc(a[i + j].carCodeAt()) 位
    i = i + (j - bc[a[i + j].charCodeAt()]);
  }

  return -1;
}

为了方便理解,这里有一张图,将其中的一些关键变量标注在上面了,结合图,代码应该更好理解。

至此,我们已经实现了包含坏规则的框架代码,只剩下往框架代码中填充好后缀规则了。现在,我们就来看看,如何实现好后缀规则。它的实现要比坏字符规则复杂一些。

好后缀的处理规则中最核心的内容:

  • 在模式串中,查找跟好后缀匹配的另一个字符;
  • 在好后缀的后缀子串中,查找最长的、能跟模式串前缀子串匹配的后缀子串。

在不考虑效率的情况下,这两个操作都可以用很 “暴力” 的匹配查找方式解决。但是,如果想要 BM 算法的效率很高,这部分就不能太低效。如何来做呢?

因为好后缀也是模式串本身的后缀子串,所以,我们可以在模式串和主串正式匹配之前,通过预处理模式串,预先计算好模式串的每个后缀子串,对应的另一个可匹配子串的位置。

我们先来看,如何表示模式串中不同的后缀子串呢?因为后缀子串的最后一个字符的位置是固定的,下标为 m - 1,我们只需要记录长度就可以了。通过长度,我们可以确定一个唯一的后缀子串。

现在,我们要引入最关键的 suffix 数组。suffix 数组的下标 k,表示后缀子串的长度,下标对应的数组值存储的是,在模式串中跟好后缀{u} 相匹配的子串 {u*} 的起始下标值。

但是,如果模式串中有多个(大于 1 个)子串跟后缀子串 {u} 匹配,那 suffix 数组中该存储哪一个子串的起始位置呢?为了避免模式串往后滑动得过头了,我们肯定要存储模式串中最靠后的那个子串的起始位置,也就是下标最大的那个子串的起始位置。

实际上,仅仅是选最靠后的子串片段来存储是不够的。我们再回忆一下好后缀规则。

我们不仅要在模式串中,查找跟好后缀匹配的另一个子串,还要在好后缀的后缀子串中,查找最长的能跟模式串前缀子串匹配的后缀子串。

如果我们只记录刚刚定义的 suffix,实际上,只能处理规则的前半部分,也就是,在模式串中,查找和好后缀匹配的另一个子串。所以,除了 suffix 数组之外,我们还需要另外一个 boolean 类型的 prefix 数组,来记录模式串的后缀子串是否能匹配模式串的前缀子串。

现在,我们来看下,如何来计算并填充这两个数组的值?

我们拿下标从 0 到 i 的子串(i 可以是 0 到 m - 2)与整个模式串,求公共后缀子串。如果公共后缀子串的长度是 k,那我们就记录 suffix[k] = j(j 表示公共后缀子串的起始下标)。如果 j 等于 0,也就是说,公共后缀子串也是模式串的前缀子串,我们就来记录 prefix[k] = true。

我们把 suffix 数组和 prefix 数组的计算过程,用代码实现出来:

js
/**
 * @param {array} b 
 * @param {number} m 
 * @param {array} suffix 
 * @param {array} prefix 
 */
function generateGS (b, m, suffix, prefix) {
  for (let i = 0; i < m; i++) {
    suffix[i] = -1;
    prefix[i] = false;
  }

  for (let i = 0; i < m - 1; i++) {
    let j = i;
    let k = 0; // 公共后缀子串长度

    // 与 b [0, m - 1] 求公共后缀子串
    while (j >= 0 && b[j] === b[m - 1 - k]) {
      j--;
      k++;
      suffix[k] = j + 1; // j + 1 表示公共后缀子串在 b [0, i] 中的起始下标
    }

    if (j == -1) {
      prefix[k] = true; // 如果公共后缀子串也是模式串的前缀子串
    }
  }
}

有了这两个数组之后,我们现在来看,在模式串跟主串匹配的过程中,遇到不能匹配的字符,如何根据好后缀规则,计算模式串往后滑动的位数?

假设好后缀的长度是 k。我们先拿好后缀,在 suffix 数组中查找其匹配的子串。如果suffix[k] 不等于 -1(-1 表示不存在匹配的子串),那我们就将模式串往后移动 j - suffix[k] + 1 位(j 表示一个坏字符对应的模式串的字符下标)。如果 suffix[k] 等于 -1,表示模式串中不存在另一个跟好后缀匹配的子串片段。我们可以用下面这条规则来处理。

好后缀的后缀子串 b[r, m - 1] (其中,r 取值从 j + 2 到 m - 1) 的长度 k = m - r,如果 prefix[k] 等于 true,表示长度为 k 的后缀子串,有可匹配的前缀子串,这样我们可以把模式串后移 r 位。

如果两条规则都没有找到可以匹配好后缀及其后缀子串的子串,我们就将整个模式串后移 m 位。

至此,好后缀规则的代码实现我们也讲完了。代码如下。

js
const SIZE = 256;

function generateBC(b, m, bc) {
  for (let i = 0; i < SIZE; i++) {
    bc[i] = -1;
  }
  
  for (let i = 0; i < m; i++) {
    const ascii = b[i].charCodeAt();
    bc[ascii] = i;
  }
}

/**
 * @param {array} b 
 * @param {number} m 
 * @param {array} suffix 
 * @param {array} prefix 
 */
 function generateGS (b, m, suffix, prefix) {
  for (let i = 0; i < m; i++) {
    suffix[i] = -1;
    prefix[i] = false;
  }

  for (let i = 0; i < m - 1; i++) {
    let j = i;
    let k = 0; // 公共后缀子串长度

    // 与 b [0, m - 1] 求公共后缀子串
    while (j >= 0 && b[j] === b[m - 1 - k]) {
      j--;
      k++;
      suffix[k] = j + 1; // j + 1 表示公共后缀子串在 b [0, i] 中的起始下标
    }

    if (j == -1) {
      prefix[k] = true; // 如果公共后缀子串也是模式串的前缀子串
    }
  }
}

function moveByGs (j, m, suffix, prefix) {
  let k = m - 1 - j;

  if (suffix[k] != -1) {
    j - suffix[k] + 1;
    return;
  }

  for (let r = j