数据结构学习总结

文章目录

大纲


基本概念

数据

描述客观事物属性的字符集合

数据元素

是数据的基本单位,由若干数据项组成。

数据项

是不可分割的最小单位。

数据对象

是具有相同性质的数据元素的集合,是数据的子集。

数据类型
  • 原子类型:其值不可再分的数据类型。
  • 结构类型:其值可以分解为若干分量的数据类型。
  • 抽象数据类型:抽象数据组织及与之相关的操作。
数据结构
  • 逻辑结构:数据元素间的逻辑关系(元素之间有什么特征)
    • 线性结构(一对一):元素之间呈线性关系,有先后顺序
      • 线性表
      • 队列
    • 非线性结构:相邻元素之间看不出其联系,不论先后顺序
      • 树(一对多)
      • 图(多对多)
      • 集合
  • 存储结构:数据结构在计算机中的表示(存在计算机中是什么样的)
    • 顺序存储:逻辑上相邻的元素,存储的物理位置也相邻
    • 链式存储:类似铁链般,每个“环”相扣在一起
    • 索引存储:依靠索引表检索元素
    • 散列存储:依靠关键字通过散列函数确定元素位置
  • 数据的运算
算法

是指令的有限序列,具有有穷性、确定性、可行性、输入、输出(有确可鼠鼠)共5个特性。

时间复杂度
  • 频度:一条语句的频度是该语句再算法中被重复执行的次数。

算法中所有语句的频度之和记作T(n),它是该算法问题规模n的函数,而时间复杂度研究的则是T(n)的数量级。算法中基本运算(最深层循环内的语句)的频度f(n)T(n)是同数量级,因此T(n)=O(f(n))

  • 加法规则
    当程序有多个深度相同的程序块时,这些程序块分别执行最深层循环内的语句的频度为f1(n)f2(n),…,fk(n),那么T(n) = O(max(f1(n), f2(n), …, fk(n)))
  • 乘法规则
    由首层循环往最深层循环延申的过程中,其每个循环的频度与最深层循环的语句的频度分别为f1(n)f2(n),…,fk(n)fs(n),那么T(n) = O(f1(n) * f2(n) * … * fk(n) * fs(n))

常见的渐进时间复杂度为

O(1) < O(log2n) < O(n) < O(nlog2n) < O(n2) < O(n3) < O(2n) < O(n!) < O(nn)


线性表

线性表是具有相同数据类型的若干个数据元素的有限序列。

顺序表

用一组地址连续的存储单元依次存储线性表的数据元素,从而逻辑相邻的两个元素在物理位置也相邻。最常见的是数组。

时间复杂度
插入操作删除操作按值查找
平均O(n)O(n)O(n)
最好O(1)O(1)O(1)
最坏O(n)O(n)O(n)

链表

链表通过分设值域和指针域来存储数据并连接前后结点。这种方式解决了顺序表对连续空间的硬性要求的缺点,但由于**附加了指针域,造成了存储空间的浪费,降低了数据的存储密度,**这种浪费在数据量较大时更为明显。并且,因为链表采用指针来链接,失去了顺序表的随机存取能力,寻找某个特定结点需要从头开始扫描。

链表在其第一个指针(即头指针)上有两种选择,一种是设立一个头结点,该结点与普通的指针结点无异,但其不存储数据,或者说不存储有效数据,一般这个结点存储的是空值,或是记录表的长度;另一种方案则是不设立头结点,表空时则表内无任何结点,做到了真正意义上的“空表”。

引入头结点有两个优点:①消除了第一个数据结点和其他位置结点处理时的差异性:在对第一个数据结点操作时,因其前面还有一个头结点,而这与其他结点是一样的,都有前驱节点,因而无需特殊处理,减少了编码复杂度。②链表的头指针始终可以指向非空的头结点,此时空表和非空表在处理上是统一的。

  • 单链表:指针域只有指向后继结点的指针
  • 双链表:指针域设有指向前驱结点和后继结点的指针
  • 循环链表:表尾结点的next指针指向头结点(判空判断头结点的next是否指向自身)
  • 静态链表:顺序表的变体,每一个数据项有值域和指针域,其指针域并非真正的指针,而是一个指向下一个结点的下标的“指针”。

栈和队列

栈是一种只允许一端进入,并只能在同一端取出的线性表。根据实现栈时采用的存储结构不同,栈分为两种——顺序栈和链栈。栈顶(Top)是栈结构允许进行插入和删除的一端,栈顶的位置会随着栈的增删操作而改变位置。栈底(Bottom)是栈结构不允许进行插入和删除的一端,栈底的位置不论栈如何变化总是固定的,它在一个栈生成的时候就确定了。

栈的数学性质:n个不同元素进栈,出栈元素不同排列的个数是C2nn / (n+1)

栈的基本操作

栈在定义时对栈顶指针可能有不同的定义方式,其一是将栈顶指针指向栈顶元素的对应存储单元,其二则是栈顶指针指向栈顶元素的下一个存储单元,对于这两种方式,在操作时有所不同。

  • 入栈:栈不满时,对于第一种栈顶指针,栈顶指针先加1,随后赋值;对于第二种栈顶指针,先赋值随后指针加1。
  • 出栈:栈非空时,对于第一种栈顶指针,先取栈顶元素随后栈顶指针减1;对于第二种栈顶指针,先栈顶指针减1随后去栈顶元素。
  • 判断栈空:对于顺序栈,第一种方式的栈顶指针判断是否等于-1,第二种方式的栈顶指针判断是否等于0。对于链栈,第一种方式的栈顶指针判断是否等于空指针;第二种方式的栈顶指针判断是否指向栈的起始位置。
  • 判断栈满:对于顺序栈,第一种方式的栈顶指针判断是否等于最大长度-1(下标从0开始),第二种方式的栈顶指针判断是否等于最大长度(下标从0开始)。对于链栈,理论上没有栈的数量限制。

队列

与栈类似,队列也是一种插入删除操作受限的线性表,栈要求表只能从固定的同一端进行插入删除,但队列则是只允许固定的某一端插入,与之相反的另一端进行删除,类似平时生活中的排队现象。队首(又称队头)指的是允许删除的一端,队尾指的是允许插入的一端。根据实现队列时采用的存储结构不同,队列有顺序存储和链式存储两种。

队列的基本操作

与栈一样,队列的队尾指针同样可以有两种定义方式,指向最后一个有效数据和指向最后一个有效数据的后一位,因此需要对其进行区分,但通常而言,会采用第二种方式,以下操作以第二种方式的队尾指针进行分析。

  • 入队:队列不满时,会首先赋值给队尾指针指向的元素,随后队尾指针进一。但“进一”不一定只是下标加1,为了防止队列的“假溢出”,增加数据存储密度,队尾指针rear的加1操作通常会在加1后对队列长度取模,从而使得队列“首尾相连”。(会进行取模的队列是循环队列)
  • 出队:队列不空时,会首先将队首指针所指的元素取值,随后队首指针进一。与入队操作的队尾指针类似,进一后需要对队列长度取模。
  • 判断队空和队满:在一般的队列中,队空直接可以判断队尾指针的下标是否等于最大长度,但这往往不准确,因为队列元素出队后前方还有空缺位置,一般的队列无法很好的利用解决这些空缺位置。但对于循环队列,若要判断队空,又很难对队空和队满进行区分;目前有三种主流的处理方式:①牺牲一个单元区分队空和队满,即队首元素的前一个位置不存储信息;②增设一个表示队列当前长度的数据成员;③增设一个布尔类型的变量记录是由入队操作导致的队首指针与队尾指针相等从而形成队满还是由出队操作引起的从而形成队空。

栈和队列的应用

  1. 栈在括号匹配中的应用
    • 在处理括号时,每次遇到左括号(包括‘(’、‘[’,‘{’),那么接下来的括号只有遇到左括号或是之前最近接受的与之对应的右括号时才能被接受,那么每当遇到左括号将其入栈,遇到右括号(包括‘)’、‘]’,‘}’)时,将这个右括号与出栈后得到的左括号进行比对,若二者对应则括号匹配,反之则不匹配。
  2. 栈在表达式求值中的应用
    • 表达式求值需要首先将中缀表达式(例如A+B*(C-D)-E/F)转化为对应的后缀表达式(又称波兰式,如ABCD-*+EF/)
    • 通过后缀表达式计算其值,从头开始扫描表达式中的字符,若遇到的字符是操作数,将操作数压入栈中;若遇到的字符是操作符<op>,则从栈中弹出两个操作数X和Y,并计算X<op>Y的值并压入栈中;不断重复这个过程直到表达式扫描并处理完毕,此时栈中仅存一个值,该值即计算结果。
  3. 栈在递归中的应用
  4. 队列在层次遍历中的应用
    • 对二叉树进行层次遍历,需要依赖队列。将树的根结点入队,在队列不空时重复队列元素出队并将这个元素的下一层树结点按序逐个入队的过程,即可完成树的层次遍历。
  5. 队列在计算机系统中的应用
    • 在计算机中有许多方面可以应用到队列;如解决主机与外部设备速度不匹配的问题;或是解决多用户引起的资源竞争问题。

数组

数组的存储结构

数组在直观上是一个二维的方格矩阵,可以按行和列对其进行编号,从而标记出每一个方格;但计算机是以一维的地址对每个方格进行分配空间的,因此根据分配时行和列的优先方式不同,有行优先方式和列优先方式。

假设矩阵是m行n列的[m×n]矩阵,那么对于这两种优先方式,其在内存中有不同的下标序号。

  • 行优先方式
    (a00->a01->…->a0(n-1)->a10->a11->…->a(m-1)(n-2)->a(m-1)(n-1))
    若矩阵的下标从0开始,则在行优先方式下,aij在内存中的下标为i*n+j,其实际存储地址还需要结合每个元素所占的内存空间决定;若矩阵下标从1开始,则下标应为(i-1)*n+(j-1)。
  • 列优先方式
    a00->a10->…->a(m-1)0->a01->a11->…->a(m-2)(n-1)->a(m-1)(n-1)
    若矩阵的下标从0开始,则在列优先方式下,aij在内存中的下标为i+j*m,其实际存储地址还需要结合每个元素所占的内存空间决定;若矩阵下标从1开始,则下标应为(i-1)+(j-1)*m。
特殊矩阵的压缩存储

常见的特殊矩阵有对称矩阵、三角矩阵、对角矩阵等。

压缩存储:多个值相同的元素只分配一个存储空间,零元素不分配空间。

  1. 对称矩阵

    • 对于n阶对称方阵A[1…n][1…n],因对于其每一个aij,都有aij=aji,因此可以只存储上(下)三角部分(含主对角线)的元素。对于存储为下三角部分的元素aij,可以根据i*(i-1)/2+j-1映射为下标从0开始的一维矩阵下标(一维矩阵下标从1开始的加1即可);对于存储为上三角部分的元素aij,可以根据(i-1)*(2*n-i+2)/2+(j-i)映射为下标从0开始的一维矩阵下标(一维矩阵下标从1开始的加1即可)。

    • 对于n阶方阵A[0…n-1][0…n-1],映射函数分别为i*(i+1)/2+j和i*(2*n-i+1)/2+(j-i)(对于一维数组下标从0开始而言)。

    • 一维矩阵的长度最终都是n*(n+1)/2

  2. 三角矩阵

    • 三角矩阵与对称矩阵颇为相似,区别在于最后需要在一维矩阵的第n*(n+1)/2+1个位置存储上(下)三角中均为同一常量的常量的值。
    • 一维矩阵的长度最终是n*(n+1)/2+1
  3. 稀疏矩阵

    • 稀疏矩阵可以用三元组(行标i,列标j,值)或十字链表表示

KMP算法[next数组及其求解方法]

KMP的时间复杂度是O(m+n)

这一板块主要介绍串的匹配算法中的KMP算法及其优化。假如要在主串‘aaabaaaaaab’中让你寻找模式串‘aaaab’,那相信你在开头的b发现匹配失败后,不会拿模式串的第一个‘a’去和主串第一个‘b’后的‘a’做匹配,你会试着拿模式串中的‘b’去和主串第一个‘b’后的第五个字符(前四个都是‘a’)作匹配,并且在你发现主串中的那个字符是‘a’后你会继续和这个字符的下一个字符与模式串中的‘b’作比较,如此往复直到最后。那么为什么你会这么做匹配呢?这就是我们即将学习的KMP算法。在了解KMP算法之前,首先了解一下前缀和后缀的概念。

  • 前缀:除最后一个字符外,字符串的所有头部子串。
  • 后缀:除首字符外,字符串的所有尾部子串。

了解前后缀有什么意义呢?首先,KMP算法会在求解过程中通过计算得出一个名为next的数组,next数组用以记录当模式串与主串发生不匹配时应该偏移校正的单位数,从而减少匹配次数,达到优化匹配的目的。而next数组如何计算生成呢?这就与模式串的部分匹配值有关了,所谓部分匹配值,是字符串的前缀和后缀相等的最大长度,如‘abab’的部分匹配值就是2,因为串‘abab’的前缀有‘aba’,‘ab’和‘a’共三个,后缀有‘bab’,‘ab’和‘b’三个,而前后缀相等,并且长度最大的只有长度为2的‘ab’。

那么说了这么多,到底怎么求next数组呢?首先,next数组是针对不同的模式串(即要查找的字符串)而言的,next数组的长度与模式串的长度一致,假设有模式串‘ABABCABAA’,那么next数组的长度就是9。

设想当我们对长度为p的模式串和主串进行匹配,匹配到模式串的第i个字符时,发现这个字符与主串的不一致,但此之前的i-1个字符是完全匹配的,那么由这i-1个字符组成的子串要移动几位才能使得移动的位数足够多,使得匹配的次数可以更大程度上地减少,但又能保证这前i-1位在移动后依旧能与主串对应的部分相互匹配上呢?答案就是(i-1)与前面所说的部分匹配值之差。模式串中的每一位字符都有可能遇到前面完全匹配,但该字符又正好不匹配的情况,因此求出每一位字符的部分匹配值,将其记录在数组中,如模式串‘ABABCABAA’的部分匹配值数组就是‘000120123(1)’,而模式串‘abcac’的部分匹配值数组是‘00001(0)’,与王道书有差异是因为我在计算第i个字符时截取的字串不包括第i个字符,因此最后的括号是整个模式串的部分匹配值,假如将数组整体左移一位即可看出与书上其实是等价的。而可以看出,当部分匹配值数组右移一位并在首项补-1后,就得到了next数组。

next[i]的含义是:在子串第i个字符与主串失配时,跳转到子串的next[i]位置重新与主串当前位置进行比较。

原理上我们已经理解next数组怎么求了,那代码如何实现呢?首先next数组的第一个元素我们已经知道其固定为-1了,那我们来分析一下后面的next[i+1]:

  • 我们知道,next[i+1]表示的是第i个字符的部分匹配值k,这表明前面i个字符的前k个字符和后k个字符相等(最大相等前后缀),那么接下来这第i+1个字符有两种情况,即与第k+1个字符相等,或不相等。

    1. 模式串的第k+1个字符与第i+1个字符相等
      根据上面所言,因为由模式串前i-1个字符组成的子串,其最大相等前后缀是k,因此如果第k+1个字符与第i+1个字符相等,那这个最大相等前后缀会在第i+1个字符加入后变成k+1,即next[i+1]=next[i]+1
    2. 模式串的第k+1个字符与第i+1个字符不相等
      二者若不相等,那其对应的next值就与模式串的第next[k+1]个字符与第i+1个字符是否相等有关了。那为什么是第next[k+1]个字符与第i+1个字符比较呢?我们知道虽然模式串的第k+1个字符与第i+1个字符不相等,但模式串的前k个字符和到第i个字符为止的后k个字符是匹配的,如此也就表明子串A‘p1…pk’和子串B‘pi-k+1…pi’是完全一致的子串。回过头来,next[k+1]表示的是子串A的最大相等前后缀,设为j,那子串B的最大相等前后缀同样为j;换句话说,子串A的前j个字符构成的子串与子串B的后j个字符构成的子串完全匹配,如此就回归到了判断‘模式串的第k+1个字符与第i+1个字符是否相等’的问题,只不过此时是模式串的第j+1个字符与第i+1个字符是否相等。
  • 通过上述分析,求next数组的代码如下

    int* getNext(char* patten, int len) {
        int j = 0, i = 1,* next = new int[len]();
        next[0] = -1;
    
        while (i < len)
            if (!j || patten[i] == patten[j])
                next[++i] =
                patten[i] == patten[j] ? ++j : j;
            else
                j = next[j];
    
        return next;
    }
    

next数组虽然已经求出来了,但在某些情况下还有缺陷,可以进一步优化。当模式串‘aaaab’与主串‘aaabaaaab’匹配时,当i=4,j=4时必然失配,如果用next数组还要进行i=4与j=3,j=2,j=1这3次比较,事实上这3次用同一个和j=4相同的字符与i=4比较毫无意义,必然失配。问题在于不应该出现模式串的第j个字符与第next[j]个字符相等。那么该如何处理呢?如果出现了,需要再次递归,将next[j]修正为next[next[j]],直到两者不相等为止,并将修正后的数组命名为nextval,其完整求解代码如下:

int* getNextVal(char* patten, int len) {
    int j = 0, i = 1;
    int* next = new int[len](), *nextVal = new int[len]();
    next[0] = nextVal[0] = -1;

    while (i < len)
        if (!j || patten[i] == patten[j])
            next[++i] = patten[i] == patten[j] ? ++j : j;
        else
            j = next[j];

    for (i = 1; i < len; i++)
        if (patten[i] == patten[next[i]])
            nextVal[i] = nextVal[next[i]];
        else
            nextVal[i] = next[i];

    delete[] next;
    return nextVal;
}

完成了next数组的求解,就是KMP算法的编写了。我们只需要考虑KMP算法与朴素匹配之间的差异,即失配后的模式串指针应该跳到哪,而这个问题next数组已经替我们回答了。因此只需要在失配时将模式串指针校正位置即可。KMP的代码如下:

int find_kmp(char* str, char* pattern) {
    int i = 0, j = 0, strLen = (int)strlen(str), ptLen = (int)strlen(pattern), *next = getNextVal(pattern, ptLen);

    while (i < strLen && j < ptLen)
        if (j == -1 || str[i] == pattern[j])
            ++i, ++j;
        else
            j = next[j];

    if (j >= strLen)
        return i - strLen;
    return -1;
}

知识框架

  • 树形结构
    • 二叉树
      • 概念:定义、存储结构
      • 操作
        • 三种遍历
        • 线索二叉树
      • 应用
        • 并查集
        • 哈夫曼树
    • 树和森林
      • 概念:定义、存储结构
      • 操作
        • 与二叉树的转换
        • 遍历
      • 应用:并查集

树的基本概念

树是n(n≥0)个结点的有限集,适用于表示具有层次结构的数据。

  • 结点类型
    • 祖先:从根节点到某个特定结点的唯一路径上的任意结点(不包括自身),都是这个特定结点的祖先。
    • 子孙:对于某个特定结点形成的子树,该结点为子树的根节点,子树中的其它所有结点均为这个特定结点的子孙。
    • 双亲:最接近某特定结点的祖先,即为该结点的双亲。
    • 孩子:某个特定结点即为其双亲结点的孩子结点。
    • 兄弟:某个特定结点的双亲结点除该特定结点外的其他孩子结点。
    • 堂兄弟:双亲结点处在同一层的结点互为堂兄弟。
    • 结点的度:该结点的孩子个数。
    • 树的度:树中所有结点的度的最大值。
    • 分支结点:度大于0的结点,又称非终端结点。
    • 叶子结点:度为0,即没有子女结点的结点,又称终端结点。
  • 结点的深度、高度和层次
    • 结点的深度是从根结点开始自顶向下逐层累加的。
    • 结点的高度是从叶子结点开始自底向上逐层累加的。
    • 结点的层次从根结点开始定义,根结点为第1层,其子结点为第2层,依次累加。
  • 路径和路径长度
    • 树中两个结点的路径是这两个结点之间所经过的结点序列构成的。同一双亲的两个孩子之间不存在路径。
    • 路径长度是路径上所经过的的条数。
  • 森林
    • 森林是m(m≥0)棵互不相交的树的集合。
树的性质
  1. 树中的结点数等于所有结点的度数之和加1。
    证明:因为结点的度是该结点的孩子个数,以二叉树为例,这棵树只有3类结点,即度分别为0,1,2的结点;对于一棵树而言,其除根结点外的其余所有结点必然是且只是其父结点的孩子结点,不可能存在第二个双亲结点,那么依据父结点的度来统计这些所有孩子结点的总和,得到的就是除根结点外的所有结点的个数,最后加上根结点的个数1即为树的总结点数。对于一棵二叉树T,设度为0的结点,即叶子结点有n0个;度为1和2的结点设有n1和n2个;那么所有结点的度数之和即为0*n0+1*n1+2*n2=n1+2*n2,那么T的结点数则为1+n1+2*n2
  2. 度为m的树中第i层上至多有mi-1个结点(i≥1)。
  3. 高度为h的m叉树至多有*(mh-1)/(m-1)*个结点。
    证明:高度为h的m叉树,第一层只能有1个结点,第二层至多为m个结点,第三层至多为m2个结点,依次类推,第h层至多为mh-1个结点。依据等比数列求和公式,sum=a1*(1-qn)/(1-q)可以得出高度为h的m叉树至多有1*(1-mh)/(1-m)个结点,亦即(mh-1)/(m-1)
  4. 具有n个结点的m叉树的最小高度为⌈logm(n(m-1)+1)⌉。
    证明:已知高度为h的m叉树至多有*(mh-1)/(m-1)个结点,设具有n个顶点的m叉树的最小高度为l*,那么要使得树的高度最小,则除最高层外每层应含有这一层所能容纳的最大结点数,即前l-1层的结点数应最多,有*(ml-1-1)/(m-1)个,也就是说n一定满足
    (ml-1)/(m-1) ≥ n > (ml-1-1)/(m-1)
    化简,得到
    ml ≥ n(m-1)+1 > ml-1
    式子两边再同时取以m为底的对数,可得
    l ≥ logm(n(m-1)+1) > l-1*,因此具有n个顶点的m叉树的最小高度得证。

二叉树

二叉树的每个结点至多只有两棵子树,并且有左右之分,次序不能颠倒。

  • 度为2的树和二叉树的区别在于:①度为2的树至少有3个结点,以表明它是一棵度为2的树,而二叉树可以为空;②度为2的树的左右孩子是相对而言的,如果只有一个孩子就无所谓左右了,但二叉树则不同,不论孩子个数,左孩子就是左孩子,右孩子就是右孩子,是完全确定的。
特殊二叉树
  • 满二叉树:树的每一层都容纳了这一层所能容纳的最大结点数的二叉树。一棵高度为h的满二叉树,结点数一定是2h-1个。
  • 完全二叉树:当且仅当含有n个结点的二叉树的每个结点都与高度一致的满二叉树中编号1~n的结点一一对应时,这棵二叉树才是完全二叉树。其特点如下:
    • 若i<⌊n/2⌋,则结点i为分支结点,否则为叶子结点。
    • 叶子结点只可能再层次最大的两层出现。
    • 只可能有一个度为1的结点,并且这个结点的孩子只会是左孩子。
    • 按层序编号结点,一旦有结点度为0或1,那大于这个结点编号的结点只会是叶子结点。
    • n为奇数的完全二叉树,其每个分支结点都有左右孩子;n为偶数的完全二叉树,编号最大的分支结点(编号为n/2)只有左孩子,没有右孩子,其余分支结点都有左右孩子。
  • 二叉排序树:左子树上所有结点的关键字均小于此结点的关键字;右子树上的所有结点的关键字均大于此结点的关键字。其左右子树同样是一棵二叉排序树。
  • 平衡二叉树:树上任一结点的左子树和右子树的深度之差不超过1。
二叉树的性质
  1. 非空二叉树的叶子结点数等于度为2的结点数加1,即n0=n2+1
  2. 非空二叉树的第k层至多有*2k-1*个结点(k ≥ 1)。
  3. 高度为h的二叉树至多有2h-1个结点(h ≥ 1)。
  4. 完全二叉树具有如下关系:
    a. 当i>1时,结点i的双亲编号是⌊i/2⌋,i为偶数时是双亲的左孩子,为奇数时是双亲的右孩子。
    b. 当2*i≤n时,结点i左孩子编号为2*i,否则无左孩子;
    当2*i+1≤n时,结点i右孩子编号为2*i+1,否则无右孩子。
  5. 结点i的深度为⌊log2i⌋+1。
  6. 具有n个结点的完全二叉树的高度为⌈log2(n+1)⌉或⌊log2n⌋+1。
    上述性质的证明在前面m叉树的性质已给出,此处不再赘述。点此查看
存储结构
顺序存储

顺序存储方式适合完全二叉树或满二叉树采用,并且在存储时下标应从1开始,否则上文描述的二叉树性质将不适用。对于一般的二叉树,若要采用顺序存储方式,应在一些空结点处填充0,使得每个结点能与完全二叉树的结点相对应,但在最坏情况下,一个高度为h且只有h个结点的单支树却需要2h-1个存储单元。

链式存储

为了提高存储空间有效信息的密度,更应该使用链式结构存储二叉树。结点结构至少包含3个域:数据域,左指针域和右指针域。

不难推出,在含有n个结点的二叉链表中,含有n+1个空链域,为了充分利用这些空链域,线索链表(线索二叉树)应运而生。

二叉树的遍历和线索二叉树

二叉树的遍历

对二叉树的每个结点N,其左子树为L,其右子树为R。

  • 对于前中后序三种遍历方式,递归工作栈的深度都恰好为树的深度。
先序遍历(preOrder)
  • 若二叉树为空,则什么也不做;否则,
    1. 访问结点N;
    2. 先序遍历左子树L;
    3. 先序遍历右子树R。
  1. 递归方式
void preOrder(Node* root) {
	if (root) {
		visit(root);
		preOrder(root->left);
		preOrder(root->right);
	}
}
  1. 非递归方式
void preOrder(Node* root) {
    Stack s;
    Node* n = root;
	while (n || !s.isEmpty()) {
        if(n){
            visit(n);
            s.push(n);
            n = n->left;
        }else{
            n = s.pop();
            n = n->right;
        }
	}
}
中序遍历(inOrder)
  • 若二叉树为空,则什么也不做;否则,
    1. 中序遍历左子树L;
    2. 访问结点N;
    3. 中序遍历右子树R。
  1. 递归方式
void iNOrder(Node* root) {
	if (root) {
		inOrder(root->left);
        visit(root);
		inOrder(root->right);
	}
}
  1. 非递归方式
void inOrder(Node* root) {
    Stack s;
    Node* n = root;
	while (n || !s.isEmpty()) {
        if(n){
            s.push(n);
            n = n->left;
        }else{
            visit(n);
            n = s.pop();
            n = n->right;
        }
	}
}
后序遍历(postOrder)
  • 若二叉树为空,则什么也不做;否则,
    1. 后序遍历左子树L;
    2. 后序遍历右子树R;
    3. 访问结点N。
  1. 递归方式
void postOrder(Node* root) {
	if (root) {
		postOrder(root->left);
		postOrder(root->right);
        visit(root);
	}
}
  1. 非递归方式
void postOrder(Node* root) {
	Stack s;
	Node* n = root, *last = nullptr;
	while (n || !s.isEmpty()) {
		if (n) {
//结点入栈,直到第一个没有左孩子的结点
			s.push(n);
			n = n->left;
		}else {
			n = s.top();
//判断栈顶元素是否存在右孩子,并通过last记录此前有没有访问过
            if (n->right && n->right != last)
                n = n->right;
            else {
			    visit(n);
			    s.pop();
                last = n;
                n = nullptr;
            }
		}
	}
}
层次遍历(levelOrder)

层次遍历的实现必须借助队列。先将根结点入队,随后在队列不空的情况下重复以下过程:访问队头结点;队头结点出队并将其左右孩子依次入队。

void levelOrder(Node* root){
    Queue q;
    Node* n = root;
    
    q.push(n);
    while(!q.isEmpty()){
        n = q.pop();
        visit(n);
        
        if(n->left)
	        q.push(n->left);
        if(n->right)
	        q.push(n->right);
    }
}

在中序遍历序列已知的情况下,加上对应的先序遍历序列、后序遍历序列或层次遍历序列可以唯一地还原一棵二叉树。

线索二叉树

线索二叉树根据给出的“线索”不同而不同,分为前序线索二叉树,中序线索二叉树和后序线索二叉树。诚如前面所言,在含有n个结点的二叉链表中,含有n+1个空链域,因此这些空链域可以根据某种特定的规则,使其能存储一定的信息,提高存储资源利用率。为此,线索二叉树规定 ,树中左孩子为空的结点,其原来存放左孩子的指针用以表示(前/中/后)序序列中该结点的前驱结点指针;其原来存放右孩子的指针用以表示(前/中/后)序序列中该结点的后继结点指针。为了区分左右孩子指针指向的是前驱后继还是真的孩子结点,还需另外设置两个标志位ltag和rtag。

二叉树的线索化是将二叉链表中的空指针改为指向前驱或后继的线索,而前驱和后继的信息只有在遍历时才能得到,因此线索化的实质就是遍历一次二叉树。

  • 前序线索二叉树
//tag为true是线索结点,tag为false是孩子结点
Node* preOrderPre = nullptr;
void threadTree::preOrder(Node* root) {
	if (root) {
		visit(root);
		if (root->ltag)
			root->left = preOrderPre;
		if (preOrderPre && preOrderPre->rtag)
			preOrderPre->right = root;
		preOrderPre = root;

		if (!root->ltag)
			this->preOrder(root->left);

		if (!root->rtag)
			this->preOrder(root->right);
	}
}
  • 中序线索二叉树
//tag为true是线索结点,tag为false是孩子结点
Node* inOrderPre = nullptr;
void threadTree::inOrder(Node* root) {
	if (root) {
		if (!root->ltag)
			this->inOrder(root->left);
		else
			root->left = inOrderPre;

		visit(root);
		if (inOrderPre && inOrderPre->rtag)
			inOrderPre->right = root;
		inOrderPre = root;

		if (!root->rtag)
			this->inOrder(root->right);
	}
}
  • 后序线索二叉树
//tag为true是线索结点,tag为false是孩子结点
Node* preOrderPre = nullptr;
void threadTree::postOrder(Node* root) {
	if (root) {
		if (!root->ltag)
			this->postOrder(root->left);

		if (!root->rtag)
			this->postOrder(root->right);
        root->left = root->ltag ? postOrderPre:root->left;

		if (postOrderPre && postOrderPre->rtag)
			postOrderPre->right = root;
		postOrderPre = root;
		visit(root);
	}
}

树和森林

树的存储结构

  1. 双亲表示法:如果说孩子表示法是父寻子的话,那双亲表示法用子寻父来形容最合适不过了。除存储数据的值域外,结点还有一个用于标识其父结点的父指针域,这样的结点构成的树即是采用双亲表示法表示的。
  2. 孩子表示法:即以左指针域、右指针域和值域形成的最基本结点构成的树,这种方式最为常见。
  3. 孩子兄弟表示法:又称为二叉树表示法。这种方法使每个结点包括三部分内容:结点值、指向第一个孩子结点的指针和指向后一个兄弟结点的指针。这种表示法的巧妙之处在于可以将任何一棵m叉树或一片森林转化为一棵二叉树的形式。

树和森林与二叉树间的转换

树转换为二叉树

从树的根结点开始,以树的根结点作为二叉树的根结点,并不断将所有结点的第一个孩子结点设为结点N的左指针域,其相邻的右兄弟设为右指针域。

森林转化为二叉树

将森林的每一棵树转为二叉树,随后,因为每棵树的根结点右指针一定是空的,选定一棵树作为主根,其右指针指向下一颗树的根结点;并对右指针指向的这棵树重复这个过程,直至将所有树添加到了由主根构成的二叉树中。

二叉树转化为森林

若二叉树非空,则二叉树的根及其左子树为第一棵二叉树的二叉树形式,故将根的右指针断开。这个断开的右指针重复这种过程直到最后只剩一棵没有右子树的二叉树位置,再将每颗二叉树依次转为树,就得到了原森林。

树和森林的遍历

树的遍历

对一棵m叉树进行遍历,可以根据根结点访问的时间先后分为两种方法:

  1. 先根遍历:树非空则先访问根结点,再依此规则依次遍历根结点的每棵子树。其遍历序列与该树对应的二叉树的先序序列相同。
  2. 后根遍历:树非空则先依此规则依次遍历根结点的每棵子树,再访问根结点。其遍历序列与该树对应的二叉树的中序序列相同。
  3. 层次遍历
森林的遍历
  1. 先序遍历森林(先根遍历):若森林非空,则
    • 访问森林中第一棵树的根结点;
    • 先序遍历第一棵树的根结点的子树森林;
    • 先序遍历除去第一棵树后剩余的树构成的森林。
  2. 中序遍历森林(中根遍历或后根遍历):若森林非空,则
    • 中序遍历第一棵树的根结点的子树森林;
    • 访问森林中第一棵树的根结点;
    • 中序遍历除去第一棵树后剩余的树构成的森林。
树和森林的遍历与二叉树遍历的对应关系
森林二叉树
先根遍历先序遍历先序遍历
后根遍历中序遍历中序遍历

树和二叉树的应用

哈夫曼树

  • 权:通常情况下,树的每个结点会有一个表示某种含义的值,称为该结点的权。
  • 带权路径长度WPL(Weight Path Length):从树的根结点到任意结点的路径长度(经过的边数)与该结点的权值的乘积,即为该结点的带权路径长度。树中所有叶结点的带权路径长度之和称为该树的带权路径长度,记为
    WPL= ∑ i = 1 n ( w i l i ) \sum_{i=1}^{n} ({w_{i}}{l_{i}}) i=1n(wili)
    其中,*wi*是第i个叶结点的权值,*li*是该叶结点到根结点的路径长度。
  • 哈夫曼树:在含有n个带权叶结点的二叉树中,其中带权路径长度(WPL)最小的二叉树称为哈夫曼树,也称最优二叉树。同样的n个叶结点构成的哈夫曼树可能不止一棵,但使得WPL最小的这样一棵树一定存在。
哈夫曼树的构造

对于给定的有各自权值的 n 个结点组成的序列s

  1. 在 序列s中选出两个最小的权值,对应的两个结点组成一棵新的二叉树,且新二叉树的根结点的权值为左右孩子权值的和;
  2. 在序列s中删除那两个最小的权值,同时将新构造的二叉树的权值加入到 序列s中,以此类推;
  3. 重复 1 和 2 ,直到序列s中只剩下唯一一个权值,此时构建成的二叉树就是哈夫曼树。

从这个构造过程不难发现,哈夫曼树的特点如下:

  1. 每个初始结点最终都成为了叶结点,且权值越小的,到根结点的路径长度越大。
  2. 构建过程共新建了n-1个结点,因此哈夫曼树的结点总数为2n-1个。
  3. 每次构造都选择2棵树作为新结点的孩子,因此不存在度为1的结点。
哈夫曼编码

将构造成的哈夫曼树从根结点开始,向左进一步记为0(或1),向右进一步记为1(或0),当最终走到某个叶结点时形成的01序列即该结点的哈夫曼编码。

通常,权重较大的叶结点的01序列会比权重较小的叶结点的01序列短,因此在某些情况下,利用哈夫曼编码将会有效提升程序的效率,节省内存空间。

并查集

并查集是一种用于管理元素所属集合的数据结构,实现为一个森林,其中每棵树表示一个集合,树中的节点表示对应集合中的元素。

顾名思义,并查集支持两种操作:

  • 合并(Union):合并两个元素所属集合(合并对应的树)
  • 查询(Find):查询某个元素所属集合(查询对应的树的根节点),这可以用于判断两个元素是否属于同一集合

并查集在经过修改后可以支持单个元素的删除、移动;使用动态开点线段树还可以实现可持久化并查集。可惜的是,并查集无法以较低复杂度实现集合的分离。


知识框架

    • 定义
    • 存储方式
      • 邻接矩阵
      • 邻接表
      • 邻接多重表
      • 十字链表
    • 图的遍历
      • 深度优先遍历
      • 广度优先遍历
    • 应用

图的定义

图G由顶点集V和边集E组成,|V|表示图G中的顶点数,|E|表示图G中的边数。边集可以是空集,但顶点集一定不空。

  • 简单图:一个图如果不存在重复边,不存在顶点到自身的边,那么这个图就是简单图,否则为多重图。
  • (简单)完全图:有n(n-1)/2条边的无向图为完全图;有*n(n-1)*条边的有向图为完全图。完全图中任意两个顶点之间都存在边。
  • 子图:如果一个图的顶点集和边集都是另一个图的子集,那么这个图就是另一个图的子集。如果这个图是另一个图的子集的同时,其顶点集又是相等的,那么这个图就是另一个图的生成子图。
  • 连通:如果无向图中的一个顶点到另一个顶点之间有路径存在,那这两个顶点是连通的。
  • 连通图:无向图中任意两个顶点都连通的图是连通图,否则图是非连通图。
  • 连通分量:无向图中的极大连通子图称为连通分量。
  • 强连通图:有向图中一对顶点之间互相有路径,则这对顶点强连通。有向图中任意一对顶点都是强连通的,则图为强连通图。
  • 强连通分量:有向图中的极大强连通子图称为强连通分量。
  • 生成树:包含图中所有顶点的极小连通子图。只有生成树可以满足这个极小条件。
  • 生成森林:在非连通图中,连通分量的生成树构成了非连通图的生成森林。
  • 顶点的度、入度和出度:无向图中顶点的度指依附于该顶点的边数,记作TD(v);有向图中顶点的入度ID(v)是以某顶点为终点的有向边的条数;出度OD(v)是以某顶点为起点的有向边的条数。
    • 无向图中,所有顶点的度之和是边数的2倍
    • 有向图中,所有顶点的入度之和等于其出度之和,也等于图的边数。
  • 边权、网:图中的每一条边都可以赋上某种含义的数值,称为该边的权值。边上带权的图称带权图,亦即网。
  • 路径:顶点vp到顶点vq间的一条路径是指顶点序列vp,vi1,vi2,……,vin,vq
  • 路径长度:路径上的边的数目称为路径长度。
  • 回路:第一个顶点和最后一个顶点相同的路径称为回路,或环。
    • n个顶点的图,若边数大于n-1则此图一定有环。
  • 简单路径:路径序列中,顶点不重复的路径即简单路径。
  • 简单回路:除首尾结点外其余顶点不重复出现的回路即简单回路。
  • 距离:若一对顶点间存在路径,则该路径的长度为这对顶点间的距离。若路径不存在,则记距离为无穷(∞)。
  • 有向树:只有一个入度为0的顶点,其余顶点入度均为1的有向图。

图的存储

邻接矩阵法

在图中,表示一条边通常用(v, w)或[v, w],为了记录n个顶点的图的每条边的权值,设置一个n×n矩阵;其行表示边以某个顶点为起点,其列表示边以某个顶点为终点;第i行第j列相交处即边(vi, vj)的权值,若顶点间没有边,则记权值为无穷。

  • 无向图的邻接矩阵是对称矩阵,当规模过大时可考虑特殊矩阵的压缩存储
  • 无向图的邻接矩阵的第i行(列)有效元素的个数即对应顶点的度。
  • 有向图的邻接矩阵的第i行有效元素的个数即对应顶点的出度,第ilie的有效元素的个数即对应顶点的入度。
  • A为图G的邻接矩阵,则An[i][j]表示由第i个顶点到第j个顶点的长度为n的路径的数目
  • 空间复杂度为O(|V|2)。
邻接表法

显然,邻接矩阵要求的存储空间的数量是非常大的,为此可以采用邻接表法节省空间的使用。

设立一个结点类型用以作为邻接表的数据项,其中包括id信息域和first指针域,其中信息域可以存储这个结点的标识信息,指针域用于存储第一个边尾结点,这个边尾结点的类型是链表结点,链表结点的数据域可以根据图是否需要记录权值而选择其个数。开始时我们为邻接表的每一个表项分配一个结点空间,并标注好其信息,此时这个表就是一个不包含任何边的子图了。依次将边添加到表中,期间遵循这个规则:选择一条未被添加的以某个顶点为起点的边,找到这条边的边首顶点在邻接表中对应的表项,在对应表项中指针域指向的链表的最后添加新结点,结点中记录边的边尾顶点和权值。

  • 无向图的邻接表的空间复杂度为O(|V|+2|E|);有向图的为O(|V|+|E|)。
十字链表

十字链表是有向图的一种链式存储结构。其表结点的结构与邻接表的类似,有标识顶点的id信息域,存储第一个弧尾为该顶点的firstIn指针域,和存储第一个弧头为该顶点的firstOut指针域。而弧结点则比较复杂,有头域(headvex)和尾域(tailvex),记录弧头和弧尾的标识信息;还有指针域headlink和taillink,指向弧头和弧尾相同的下一条弧;还有记录边权的信息域。这样一来,弧头相同的弧就可以通过headlink记录,弧尾相同的弧也可以通过taillink来记录了。

邻接多重表

邻接多重表是无向图的一种链式存储结构。与十字链表类似,邻接多重表的边结点有着这几个域:ivex和jvex记录边两端的标识信息;ilink和jlink指向与这一端顶点相同的下一条边,换言之就是这个指针构成的链表是以同一个顶点发出的边组成的,这个指针只是指向这个结点的下一条边;mark标志域用以标记该边是否被访问过;还有记录边权的信息域。

图的遍历

广度优先搜索-BFS (Breadth First Search)

类似于二叉树的层次遍历,选定一个起始顶点,从该顶点出发,依次访问与该顶点邻接且未被访问过的顶点;再从这些邻接结点出发,访问与它们邻接且未被访问过的顶点,不断重复这个过程直到从起始顶点出发能到达的所有顶点(包含起始顶点的连通分量)均已访问。若图中尚有未被访问过的顶点,则另选一个未被访问过的顶点作为起始顶点重复上述过程,直到图中所有顶点都已访问。

为实现逐层访问,算法必须借助队列实现,以记忆当前顶点的下一层顶点。

char* bfs(char start) {
    //算法只能对一个连通图产生对应的广度优先遍历序列
    //记录顶点是否访问;记录返回值(遍历序列)
	bool* visit = (bool*)malloc
        (sizeof(bool) * this->vnum);
	char* ret = (char*)malloc
        (sizeof(char) * this->vnum + 5);

    //辅助队列
	Queue bfsQueue(this->vnum + 5);
	int index = includingVertex(start), len = 0;
	if (visit && ret && index >= 0 && this->vnum > 0) {
		memset(visit, 0, sizeof(bool) * _msize(visit));
		memset(ret, 0, sizeof(char) * _msize(ret));
	}
	else
		return nullptr;

    //邻接顶点序列;起始顶点入队;
    //标记起始顶点为已访问;更新遍历序列
	int* neighbor = nullptr;
	bfsQueue.enqueue(index);
	visit[index] = true;
	ret[len++] = start;

    //从起始顶点开始遍历
	while (!bfsQueue.isEmpty()) {
        //队首顶点出队,并找到其邻接顶点的顶点序列
		index = bfsQueue.dequeue();
		neighbor = this->neighbors(this->adjacencyList[index].value);

        //将其邻接顶点的顶点序列逐个进行访问、标记、入队(顶点未被访问过的情况下)
		for (int i = 0; neighbor && neighbor[i] >= 0; i++)
			if (!visit[neighbor[i]] && len < this->vnum) {
				ret[len++] = this->adjacencyList[neighbor[i]].value;
				visit[neighbor[i]] = true;
				bfsQueue.enqueue(neighbor[i]);
			}//if
	}//while

    //释放辅助空间;返回遍历序列
	free(visit);
	free(neighbor);
	return ret;
}
  • 最坏情况下的空间复杂度是O(|V|)。
  • 时间复杂度在采用邻接表方式时为O(|V| + |E|);采用邻接矩阵时为O(|V|2)。
深度优先搜索-DFS (Depth First Search)

类似树的先序遍历,选定一个起始顶点,从该顶点出发,访问与该顶点邻接且未被访问过的第一个顶点;再从这个结点出发,标记这个顶点并访问与它邻接且未被访问过的第一个顶点,不断重复这个过程直到与该顶点的邻接的结点均已访问过。直到包含起始顶点在内的连通分量中的全部顶点访问完后,图中尚有未被访问过的顶点,则另选一个未被访问过的顶点作为起始顶点重复上述过程,直到图中所有顶点都已访问。

char* dfs(char start) {
    //记录顶点是否访问;记录返回值(遍历序列);起始顶点下标
	bool* visit = (bool*)malloc
        (sizeof(bool) * this->vnum);
	char* ret = (char*)malloc
        (sizeof(char) * this->vnum + 5);
	int index = includingVertex(start), len = 0;

    //合法性检查
	if (visit && ret && index >= 0 && this->vnum > 0) {
		memset(visit, 0, sizeof(bool) * _msize(visit));
		memset(ret, 0, sizeof(char) * _msize(ret));
	}
	else
		return nullptr;

    //标记起始顶点,更新遍历序列
	visit[index] = true;
	ret[len++] = start;

    //从起始顶点开始遍历
	dfsInstance(start, visit, ret, len);

    //释放辅助空间
	free(visit);
	return ret;
}

void	dfsInstance(char start, bool* visit,
                    char* str, int& len) {
	//更新邻接顶点序列
    int* neighbor = this->neighbors(start);

	if (neighbor) {
		for (int i = 0; neighbor[i] >= 0; i++)
            //找到第一个未被访问的邻接顶点
			if (!visit[neighbor[i]]) {
                //更新遍历序列;标记该顶点状态
				str[len++] = this->adjacencyList[neighbor[i]].value;
				visit[neighbor[i]] = true;
                //深度优先遍历该顶点
				dfsInstance(this->adjacencyList[neighbor[i]].value, visit, str, len);
			}//if
	}//for

    //释放辅助空间
	free(neighbor);
	return;
}
  • 空间复杂度为O(|V|)。
  • 时间复杂度在采用邻接表方式时为O(|V| + |E|);采用邻接矩阵时为O(|V|2)。

假如调用一次BFS/DFS可以遍历包括某个起始顶点在内的连通分量(该起始顶点所能到达的所有顶点和它们之间的边),那么按广度优先/深度优先方式==遍历一张图,其调用函数BFS/DFS的次数等于该图的连通分量数==。

图的应用

最小生成树

普里姆(Prim)算法

不断选最近的点壮大自身。

初始时从图中任取一点加入树T,此时只有一个顶点,不断选择一个与当前T的顶点集合相距最近的顶点,将顶点和边都加入树T,直到所有顶点都并入T。

  • 时间复杂度O(|V|2)。
克鲁斯卡尔(Kruskal)算法

优先选最小的边各自为战。

初始时是n个顶点且边集为空的图,随后把边按权值升序排列,不断选取权值最小且符合使得这条边上的两个顶点 从非连通的变为连通的 的边加入到图中,直到所有顶点都互相连通。

  • 时间复杂度O(|E|log|E|)。

最短路径

迪杰斯特拉(Dijkstra)算法

这是一个基于贪心策略的算法,每次可以找到源点到一个顶点间最短路径。

局限性

图有负权的边时不适用Dijkstra算法。

算法描述
  • 算法需要两个一维辅助数组distance[]和path[](以下简称d[]和p[]);
    • d[]用以记录从源点出发,到达其他顶点的最短距离。初始时,源点到其他顶点有边相连的,d[]中的对应项为边的权值,表示源点通过距离为权值的边到达目标顶点的最短距离。值得注意的是,尽管某些顶点可能与源点之间有边存在,但这并不意味着这条边是源点到这个顶点的最短距离,源点在通过其他顶点中转达到这个顶点的距离依旧有可能比直达的边的距离小。
    • p[]用以记录源点到某个顶点之间的最短路径的前驱结点。换句话说就是在从源点出发到特定顶点的过程中,会经过许多顶点,最终到达特定顶点;而p[]则记录在到达特定顶点前,途径的最后一个顶点。
  • 首先,算法将初始化d[]和p[],p[]全部重置为源点对应的下标值;d[]则按源点与当前下标对应的顶点是否有边,分别设置为无穷(∞)和边的权值。同时,可以设置一个标志数组,用以标记已经找到最短路径了的顶点,初始时只有源点被标记。
  • 从未被标记的顶点中找到距离源点最近的一个,标记该顶点;
  • 更新从源点出发到其他未被标记的顶点的距离,当(从源点出发)(通过上一条规则标记的顶点后)(到达目的顶点)的距离比d[]中对应项记录的值要小时,d[]更新,并将这个中转节点(上一条规则标记的顶点)的下标更新到p[]对应项。
  • 重复执行上两条规则,直到找到所有顶点的最短路径,共n-1次。
算法的复杂度分析

时间复杂度

  • 使用邻接矩阵或邻接表都为O(|V|2)。

空间复杂度

  • 分析算法不难看出为O(|V|)。
弗洛伊德(Floyd)算法
局限性

允许图中有负权的边,但不允许有负权的边构成回路。

算法分析

递归产生一个n阶方阵序列A(-1)A(0),···,A(k),···,A(n-1),其中A(k)[i][j]表示从顶点*vi到顶点vj*的长度不大于k的路径长度,k表示只允许在序号不大于k的顶点中转。

初始时,矩阵A(-1)即图的邻接矩阵;随后,k从0开始,到n-1结束,逐轮允许以顶点vk为中转;每一轮中,判断若以vk中转能否缩短两个顶点间的距离,即判断A(k-1)[i][j]和A(k-1)[i][k]+A(k-1)[k][j]的大小关系,较小的将赋值给A(k)[i][j]。

算法的复杂度分析

时间复杂度为O(|V|3)。

两个算法的区别

Dijkstra算法是找一个源点到其他所有顶点之间的最短路径,故其时间复杂度是O(|V|2),但Floyd算法是找所有点到其他所有顶点之间的最短路径,不能仅凭此就断定Dijkstra算法比Floyd算法优秀。

有向无环图(DAG)描述表达式

按照如下步骤进行计算:

  1. 把各个操作数不重复地排成一排
  2. 在表达式中标出各个运算符的生效顺序(同级情况下先后顺序无所谓,比如先算左边括号或者先算右边括号)
  3. 按顺序加入运算符,不同的运算级别层次不同,过程中如果已经存在某部分,则直接用
  4. 最后生成的图就是有向无环图

拓扑序列

  • AOV网(Activity On Vertex):若用DAG图表示一个工程,其顶点表示活动,用有向边<ViVj>表示活动*Vi必须先于Vj*的这样一种关系。
  • 拓扑序列:拓扑序列是对有向无环图的顶点的一种排序,它使得若有一条从顶点A到顶点B的路径,则在排序中顶点B在顶点A的后面。一个AOV网的拓扑序列不一定唯一。

生成拓扑排序序列,可以按如下步骤进行:

  1. 选择一个入度为0的顶点,将其添加到序列中,并将这个顶点及由其发出的边从图中删除;
  2. 重复上一步骤,直到所有顶点都从图中删除,此时生成的序列就是图的其中一个拓扑序列;或图中已经不存在入度为0的顶点,此时表明该图无法生成拓扑序列。
  • 生成拓扑排序序列,采用邻接表存储的图的时间复杂度为O(|V|+|E|);采用邻接矩阵存储的图的时间复杂度为O(|V|2)。

生成逆拓扑排序序列,可以按如下步骤进行:

  1. 选择一个出度为0的顶点,将其添加到序列中,并将这个顶点及由其作为终点的边从图中删除;
  2. 重复上一步骤,直到所有顶点从图中删除。
  • 生成逆拓扑排序序列的时间复杂度与拓扑排序的处于同一个量级。

关键路径

  • AOE(Activity On Edge)网:在带权有向图中,以顶点表示事件,以有向边表示活动,以边上的权值表示完成该活动的开销。
  • 源点:AOE网中仅有的一个入度为0的顶点。
  • 汇点:AOE网中仅有的一个出度为0的顶点。
  • 关键路径:从源点到汇点的所有路径中,具有最大路径长度的路径。
  • 关键活动:关键路径上的活动。
  • 事件vk的最早发生时间ve(k):事件*vk的最早发生时间决定了所有从vk*开始的活动能开始执行的最早时间。
  • 事件vk的最迟发生时间vl(k):在不推迟整个工程完成的前提下,该事件最迟必须发生的时间。
  • 活动ai的最早开始时间e(i):AOV网中由顶点表示事件,边表示活动,因此活动*ai*的最早开始时间与这条边的起点表示的事件的最早发生时间是相等的。
  • 活动ai的最迟开始时间l(i):该边的终点表示的事件的最迟发生时间与该活动所需的时间之差。
  • 活动ai的最迟开始时间与最早开始时间的差额d(i)=l(i)-e(i):指该活动完成的时间余量,即在不推迟整个工程完成的前提下,活动*ai*可以拖延的时间。

求关键路径的算法步骤如下:

  1. 求顶点的最早发生时间ve():从源点出发,令ve(源点)=0,按拓扑有序求其余顶点的ve();
  2. 求顶点的最迟发生时间vl():从汇点出发,令vl(汇点)=ve(汇点),按逆拓扑有序求其余顶点的ve();
  3. 根据各顶点的ve()求所有边的最早开始时间e();
  4. 根据各顶点的vl()求所有边的最迟开始时间l();
  5. 求AOE网中所有活动的差额d(),找出所有d()=0的活动构成关键路径。

求解关键路径,采用邻接表存储的图的时间复杂度为O(|V|+|E|);采用邻接矩阵存储的图的时间复杂度为O(|V|2+|E|)。


查找

知识框架

  • 查找
    • 基本概念
      • 静态查找
      • 动态查找
    • 线性结构
      • 顺序查找
      • 折半查找
      • 分块查找
    • 树形结构
      • 二叉排序树BST(Binary Search Tree)
      • 二叉平衡树BBT(Balanced Binary Tree)
      • 红黑树RBT(Red-Black Tree)
      • B数、B+树
    • 散列结构
      • 散列表(哈希表)
    • 效率指标
      • 平均查找长度
        • 成功
        • 失败

基本概念

  • 静态查找:查找的操作只涉及查询某个元素是否在表内和检索满足条件的某个特定数据元素,无需动态地修改查找表。
  • 动态查找:除了需要查询某个元素是否在表内和检索满足条件的某个特定数据元素外,还能插入一个数据元素到表中、和从表中删除某个数据元素。
  • 平均查找长度ASL(Average Search Length):查找过程中一次查找的长度是需要比较关键字的次数;而所有查找过程中进行的关键字比较次数的平均值就是平均查找长度,数学定义如下:
    A S L = ∑ i = 1 n P i C i ASL= {\textstyle \sum_{i=1}^{n}{P_{i}}{C_{i}}} ASL=i=1nPiCi
    其中n是表长;Pi是查找第i个元素的概率,一般认为查找每个元素的概率相等,即Pi=1/n;Ci是查找第i个元素的所需比较次数。

线性结构

顺序查找

又称线性查找。这是最基本的一种暴力查找的算法,通过逐个查找数据元素来匹配关键字,这种方法对于顺序表和链表都是适用的。

  1. 一般线性表的顺序查找
    • 成功:这种线性表通常表内元素无序,在查找成功时,若从头往后开始查找第i个元素,那么需要进行i次关键字的比较,故此时的平均查找长度为 A S L = ∑ i = 1 n ( i ∗ P i ) ASL= {\textstyle \sum_{i=1}^{n}({i*P_{i}})} ASL=i=1n(iPi);当每个元素的查找概率相等时,有 A S L = i ASL = i ASL=i
      此外,如果查找顺序为从最后一个开始往前找第i个元素,那么需要进行n-i次关键字的比较,此时 A S L = ∑ i = 1 n ( ( n − i ) ∗ P i ) ASL= {\textstyle \sum_{i=1}^{n}((n-i)*{P_{i}})} ASL=i=1n((ni)Pi),查找每个元素有同等概率时,有 A S L = 1 2 ASL=\frac{1}{2} ASL=21
    • 失败:在这种情况下失败,无论从前往后对比还是从后往前,都需要进行n次比较,故此时 A S L = n ASL=n ASL=n
  2. 有序表的顺序查找
    • 成功:这种情况下,虽然表内元素有序,但是当讨论的是全体元素的平均查找长度时,其实个体在表内有无序是不影响成功查找情况下的平均查找长度的,即与一般线性表的成功平均查找长度一样。
    • 失败:由于表内元素有序,因此当对比到关键字比当前元素大,但比下一个对比的元素小时,就可以断定表里没有这个元素了。考虑有n个元素的有序表,那么查找失败的情况就有n+1种,假设这几种失败的情况发生概率相等,那么平均查找长度
      A S L = 1 + 2 + 3 + . . . + n + n n + 1 = n 2 + n n + 1 ASL=\frac{1+2+3+...+n+n}{n+1}=\frac{n}{2}+\frac{n}{n+1} ASL=n+11+2+3+...+n+n=2n+n+1n

折半查找

又称二分查找,只有在有序的顺序表上可用,链表不能用折半查找。

算法描述

将给定的关键字key与表的中间位置的元素比较,相等则查找成功;不等,则根据表的排列方式可以断定key在表的前半部分还是后半部分;如此重复在缩小一半的空间内查找,直到找到key或确定表中没有这个元素。

算法平均查找长度分析

折半查找的过程可以用一棵二叉树描述,称为判定树,其根结点N中的值是查找表的中间位置的元素,根结点N下的每一棵子树的根结点L和R的值又是表中以N的值为界限,分割形成的两个子表的中间位置元素。如此推断,可以得出判定树的高度为h=⌈log2(n+1)⌉。

因此,用该方法查找到给定值的比较次数最多不超过树的高度。等概率查找下,查找成功的平均查找长度为
A S L = 1 n ∑ i = 1 n l i ASL = \frac{1}{n} {\sum_{i=1}^{n}l_{i}} ASL=n1i=1nli

= 1 n ( 1 ∗ 1 + 2 + 2 + ⋅ ⋅ ⋅ + h ∗ 2 h − 1 ) = \frac{1}{n}(1*1+2+2+···+h*2^{h-1}) =n1(11+2+2++h2h1)

= n + 1 n log ⁡ 2 ( n + 1 ) − 1 =\frac{n+1}{n}\log_{2}{(n+1)}-1 =nn+1log2(n+1)1

≈ log ⁡ 2 n ≈ \log_{2}{n} log2n

同理,当查找失败时,考虑最坏情况下的平均查找长度,即第h层全部都是关键字结点;此时有2h个查找失败结点,并且全部长度都是h,于是有
A S L = h ⋅ 2 h 2 h = h ASL=\frac{h·2^{h}}{2^{h}}=h ASL=2hh2h=h

算法代码
//折半查找,默认数组已按升序排列好
int binarySearch(int target, int arr[], int len) {
	int low = 0, high = len - 1, mid = (low + high) / 2;
	while (low <= high) {
		mid = (low + high) / 2;
		if (arr[mid] == target)
			return mid;
		else if (target < arr[mid])
			high = mid - 1;
		else
			low = mid + 1;
	}
	return -1;
}

分块查找

又称索引顺序查找,结合了顺序查找和折半查找的优点。

算法描述

把原本无序的查找表分成多个子表。子表内元素可以无序,但保证子表间有序,即第一个子表内的所有元素都比第二个子表内的小,第二个子表内的所有元素也都比第三个子表内的小,以此类推。同时再设立一个索引表,这个索引表中的每一项记录两个信息,每个子表中的最大元素,以及这个子表的首地址,并将这个索引表按子表的最大元素进行排序。

分块查找的过程有两步:①在索引表内确定给定值在哪个子表里;②在确定好的子表里顺序查找给定值。

算法平均查找长度分析

在第一步确定子表的过程中,可以选择采用顺序查找的方式或是折半查找的方式(子表之间有序);第二步子表内查找的过程,只能采用顺序查找(子表内的元素无序)。

因此分块查找的平均查找长度和两个步骤有关,将第一步的平均查找长度记为p1,第二步的记为p2,同时查找表分成n1个子表,子表内各有n2个元素,那么
A S L = p 1 + p 2 ASL=p_{1}+p_{2} ASL=p1+p2
首先我们可以确定,第二步的平均查找长度和每个子表的长度*n2*有关,第二步的
p 2 = 1 + 2 + ⋅ ⋅ ⋅ + n 2 n 2 = n 2 + 1 2 p_{2}= \frac {1+2+···+n_{2}} {n_{2}}=\frac{n_{2}+1}{2} p2=n21+2++n2=2n2+1
当第一步采用①顺序查找的方式时, p 1 = 1 + 2 + ⋅ ⋅ ⋅ + n 1 n 1 = n 1 + 1 2 p_{1}= \frac {1+2+···+n_{1}} {n_{1}}=\frac{n_{1}+1}{2} p1=n11+2++n1=2n1+1
当第一步采用②折半查找的方式时, p 1 = ⌈ log ⁡ 2 ( n 1 + 1 ) ⌉ p_{1}= ⌈\log_{2}{(n_{1}+1)}⌉ p1=log2(n1+1)1

所以有
A S L = p 1 + p 2 = n 1 + 1 2 + n 2 + 1 2 = n 1 + n 2 2 + 1 ASL=p_{1}+p_{2}=\frac{n_{1}+1}{2}+\frac{n_{2}+1}{2}=\frac{n_{1}+n_{2}}{2}+1 ASL=p1+p2=2n1+1+2n2+1=2n1+n2+1
A S L = p 1 + p 2 = n 1 + 1 2 + ⌈ log ⁡ 2 ( n 1 + 1 ) ⌉ ASL=p_{1}+p_{2}=\frac{n_{1}+1}{2}+⌈\log_{2}{(n_{1}+1)}⌉ ASL=p1+p2=2n1+1+log2(n1+1)

树形查找

二叉搜索树BST(Binary Search Tree)

特性

二叉搜索树中,根结点的值比左孩子子树的都大,比右孩子子树的都小。

查找

因此,在查找时,从根结点开始,关键字比根结点的小则往其左子树递归查找,比根结点的大则往其右子树递归查找。

binarySearchTree* binarySearchTree::findVal(int val) {
	binarySearchTree* cur = this;
	while (cur)
		if (cur->value == val)
			return cur;
		else
			cur = val < cur->value ?
            cur->left : cur->right;
	return nullptr;
}

插入

插入的过程首先是查找,根据查找的方式找到应该插入的位置,如果过程中发现有该关键字了,那么就无需继续插入;否则查找到叶子结点都没有匹配的话,可以在叶子结点的孩子结点挂载一个新结点,左右取决于二者间的大小关系。

bool	binarySearchTree::insert(int val) {
	binarySearchTree* cur = this,
    * newNode = new binarySearchTree(val);

	while (cur != nullptr) {
		if (cur->value == val)
			return false;
		else if (val < cur->value) {
			if (!cur->left) {
				cur->left = newNode;
				newNode->father = cur;
				return true;
			}
			cur = cur->left;
		}
		else {
			if (!cur->right) {
				cur->right = newNode;
				newNode->father = cur;
				return true;
			}
			cur = cur->right;
		}
	}
	return false;
}
删除

删除过程有三种需要面对的情况,

  • 删除结点是一个叶子结点,此时直接删除该结点即可;
  • 删除结点只有一棵子树,此时将其父结点的对应孩子指针指向将删除结点的孩子结点即可;
  • 删除结点有两棵子树,此时可以选择将待删除结点的直接后继(直接前驱)替代该待删除结点,并从二叉树中删除这个直接后继(直接前驱),此时相当于回到了前两种情况。
查找效率

二叉搜索树的查找效率取决于树的高度。当二叉搜索树的左右子树高度之差不大于1时,称为二叉平衡树,其平均查找长度为O(log2n)。若二叉搜索树每个非叶子结点只有一个孩子结点时,这时平均查找长度为O(n)。

二叉平衡树BBT(Balanced Binary Tree)

二叉搜索树的有一个致命缺点——过于依赖前面的输入,可能导致后续树的形成畸形。假设一个序列是从1到10的有序序列,那么二叉搜索树完全只会将元素插入到其右子树,这在结果上来说已经退化成了一个链表。为了避免这种情况的发生,二叉平衡树规定其左右子树的高度之差不大于1。仅仅是这么简单的一条规则,却增加了这棵树的各类操作的复杂程度。

插入

为了避免插入新结点后导致整棵二叉树不符合定义,我们需要对其进行平衡性检查。对于结点N,其左子树L和右子树R的高度分别为h(L)和h®,那么根据平衡二叉树的要求,其满足**|h(L)-h®|≤1**。对于N的左子树的左子树LL和其右子树LR,以及N的右子树的左子树RL和其右子树RR,同样满足上面的式子。

回到插入操作本身,插入导致的二叉树失衡分析起来无非就是因为|h(L)-h®|>1导致的,即某一边子树本就已经比另一边子树的高度高了1,却因为二叉搜索树的性质使得增加的新结点仍将在较高一边子树插入,插入的这个结点导致这棵子树高度增加,但另一边的子树高度却没有改变,最终使得这棵树失衡了。

那么这个问题如何解决呢?考虑这么一棵树N,根结点N的左子树L只有其本身这么一个叶子结点,根结点N的右子树R为空,如果要求你按二叉平衡树的性质去插入一个比这棵树的所有结点都要小的一个元素,你会怎么做呢?插入的结点的位置显而易见是插入到L的左孩子结点上的,关键在于如何去平衡这棵插入完结点后的树。有两个方案是比较容易想到的;其一是,既然树已经建立好了,那么按树的中序遍历可以得到一个有序的序列,我们只需要将这个序列从中间开始,最中间的元素作为根结点,再以中间元素分成左右两个序列,再把这两个子序列的中间元素作为根结点下一层左右子树的根结点,以此类推,重新构建出一棵二叉树,这样得到的二叉树左右子树高度之差绝对不大于1,并且符合二叉搜索树的性质,但这样的方法显然过于低效,在数据量较小时可能行得通,一旦数据量过大,插入一个元素所耗费的时间就不是我们可以接受的范围了;那么第二种就是考虑树这种数据结构,从这上面做文章了。如我们所知,树是通过左右孩子指针使得它们能组织在一起,从我们使用目的的角度上来说,哪个值是哪个值的孩子结点根本无关紧要,我们只要最后这棵树囊括了我们已经存储的关键字序列,同时又满足二叉平衡树的性质,使得最终我们能简化查找过程即可。第二种方法就是旋转。把一棵插入一个新结点后导致失衡的平衡树,把原来的根结点N、根结点的左子树L、右子树R通过旋转,使得它们恢复平衡。回到段首的插入结点这个问题,平衡树N在调整前是一个没有右子树的链状,如果我们把原来只有三个结点N,L,LL的平衡树,重新指定树的根结点,把原来的L结点定义成新根结点NN,原来的根结点则变成了NR,而原来的LL结点依旧是L的左孩子结点,即NN的左孩子NL,那么以新根结点NN为根结点的树就是一棵满足二叉平衡树的性质的树,问题也得到了解决,并且我们的操作对比第一种方法得到极大的简化。

既然已经确定了调整的方法,我们对所有的树都直接调整其根结点吗?我们考虑一棵树N,其根结点左子树L的高度为h+1,其中根结点左子树L的左子树LL高度是h+1,左子树L的右子树LR的高度是h,此时左子树L的左右子树高度差是1,并没有违背二叉平衡树的规则;同时根结点右子树R的高度也为h,根结点左右子树的高度差是1,同样满足平衡树的规则;若要在根结点左子树L的左子树LL上插入一个新结点,那么插入完这个新结点后,子树LL的高度变成了h+2,但子树LR的高度是h,这导致了子树L的左右子树差来到了2,已经不满足平衡树的规则了,连带着根结点的左右子树高度差也不符合平衡树的规则了,这就说明更低层结点的左右子树不平衡是由于这个结点的某棵子树也失衡了导致的,那么我们一层层寻找,直到找到层次最高的一个结点,其左右子树高度差大于1,但是这个结点左右孩子的各自的左右子树高度差却不大于1。对找到的这个结点作调整,使得其左右子树重新平衡即可。当然,我这么分析缺乏严谨的数学分析,仅仅只是为了达到帮助记忆的目的。

以下,对这种插入的几种情况分别介绍,记离根结点最远的一个不平衡结点为N,则(推荐阅读顺序1,4→2,3):

  1. 新插入结点在N的左子树L的左子树LL上(LL插入,右旋):
    对于这种插入方式,不妨假设插入前LL,LR,R的高度均为h,这时L的高度h+1是比R的h高1的,但随着新结点插入到了LL上,此时LL高度变为h+1,但R的高度还是h,L的高度却由h+1变成了h+2,这就导致了树的失衡。如果要用之前讲到的旋转方法,就应该把较高的一边子树偏向较低的一边。断开N的左孩子结点L,使L成为一棵独立二叉树,这棵树L高度是h+2,而断开后的N高度为h+1,显然,如果把L作为新的根结点,记为NN,在接上树N,那整棵树NN的高度就变成了h+2,其中NN的左子树高度是h+1不变,NN的右子树NNR高度是h,并且其中所有关键字比NN的大,又比原来N及其右子树NR的关键字小,同时N的左孩子因为与NN断开而空闲,可以把NNR设为N的新左孩子结点,使得NN的右孩子结点空出,由N补上。完成这系列操作后的二叉树NN,其左孩子是之前的左孩子LL不变,LL高度为h+1;NN右孩子是之前的根结点N,高度为h+1,并且N的左孩子是之前的L的右孩子,高度为h不变,N的右孩子是之前N的右孩子,高度同样为h,如此,新的根结点NN的高度差0符合平衡树的要求了,旧的根结点N的高度差为0也同样符合平衡树的要求,失衡问题也就解决了。这种方式的旋转称为右旋。
  2. 新插入结点在N的左子树L的右子树LR上(LR插入):
    对于这种插入方式,不妨假设插入前LL,LR,R的高度均为h,这时L的高度h+1是比R的h高1的,但随着新结点插入到了LR上,此时LR高度变为h+1,但R的高度还是h,L的高度则由h+1变成了h+2,这就导致了树的失衡。解决方法则是将N的左子树L作一次左旋操作,用原来的LR结点顶替原来的L结点,L结点顺位到原来的LL结点,此时问题就变成了第一种LL插入的情况,再对根结点N做一次右旋操作即可。
  3. 新插入结点在N的右子树R的左子树RL上(RL插入):
    对于这种插入方式,不妨假设插入前RL,RR,L的高度均为h,这时R的高度h+1是比L的h高1的,但随着新结点插入到了RL上,此时RL高度变为h+1,但L的高度还是h,R的高度则由h+1变成了h+2,这就导致了树的失衡。解决方法则是将N的右子树R作一次右旋操作,用原来的RL结点顶替原来的R结点,R结点顺位到原来的RR结点,此时问题就变成了第四种RR插入的情况,再对根结点N做一次左旋操作即可。
  4. 新插入结点在N的右子树R的右子树RR上(RR插入,左旋):
    对于这种插入方式,不妨假设插入前RL,RR,L的高度均为h,这时R的高度h+1是比L的h高1的,但随着新结点插入到了RR上,此时RR高度变为h+1,但L的高度还是h,R的高度则由h+1变成了h+2,这就导致了树的失衡。如果要用之前讲到的旋转方法,就应该把较高的一边子树偏向较低的一边。断开N的右孩子结点R,使R成为一棵独立二叉树,这棵树R高度是h+2,而断开后的N高度为h+1,显然,如果把R作为新的根结点,记为NN,在接上树N,那整棵树NN的高度就变成了h+2,其中NN的右子树高度是h+1不变,NN的左子树NNL高度是h,并且其中所有关键字比NN的小,又比原来N及其左子树NL的关键字大,同时N的右孩子因为与NN断开而空闲,可以把NNL设为N的新右孩子结点,使得NN的左孩子结点空出,由N补上。完成这系列操作后的二叉树NN,其右孩子是之前的右孩子RR不变,RR高度为h+1;NN左孩子是之前的根结点N,高度为h+1,并且N的左孩子是之前N的左孩子,高度为h不变,N的右孩子是之前的R的左孩子,高度同样为h,如此,新的根结点NN的高度差为0符合平衡树的要求了,旧的根结点N的高度差0也同样符合平衡树的要求,失衡问题也就解决了。这种方式的旋转称为左旋。
删除

删除操作和二叉搜索树的删除结点方式相同,只是在删除完成后需要对二叉平衡树进行平衡性检查。从删除结点往上回溯,找到第一个导致不平衡的结点,记作N;从N开始找其左右子树中最高的一棵,记这棵子树的根结点为C;从C开始找高度最高的叶子结点,记作H;对于这几个结点,存在这几种可能性:

  1. N的左孩子是C,C的左子树上有H:此时相当于在N的左子树C的左子树上插入了一个结点H,使得最终N不平衡,此时采用解决由LL插入导致的失衡的问题的方法即可(右旋)。
  2. N的左孩子是C,C的右子树上有H:此时相当于在N的左子树C的右子树上插入了一个结点H,使得最终N不平衡,此时采用解决由LR插入导致的失衡的问题的方法即可(先左旋,再右旋)。
  3. N的右孩子是C,C的左子树上有H:此时相当于在N的右子树C的左子树上插入了一个结点H,使得最终N不平衡,此时采用解决由RL插入导致的失衡的问题的方法即可(先右旋,再左旋)。
  4. N的右孩子是C,C的左子树上有H:此时相当于在N的右子树C的右子树上插入了一个结点H,使得最终N不平衡,此时采用解决由RR插入导致的失衡的问题的方法即可(左旋)。
查找

在二叉平衡树上查找与在二叉搜索树上的查找过程无异。在查找过程中,与给定值进行比较的关键字个数不超过树的深度,故其平均查找长度为O(log2n)。

红黑树RBT(Red-Black Tree)

为了保持AVL树的平衡性,插入和删除后会频繁的调整整棵树的整体拓扑结构,代价较大。因此在AVL树的平衡标准上放宽条件,引入红黑树的结构。

红黑树的定义

一棵红黑树应该符合如下定义:

  1. 每个结点都应该是红色或黑色的;
  2. 根结点一定是黑色的;
  3. 叶结点(虚构的外部结点、NULL结点)都是黑色的;
  4. 不存在相邻的两个红色结点;
  5. 对于每个结点,从根结点到任一个叶子结点的路径上一定有着同样数量的黑色结点。

依据这个定义得到的二叉树即是一棵红黑树,其具有如下性质:

  1. 从根结点到叶结点的最长路径不超过最短路径的2倍。
    证明:由定义5得知,最短路径上的结点应当全都是黑色结点;最长路径上的结点在满足红黑树定义的前提下,应该是红黑相间的,并且黑色结点的数量与最短路径上黑色结点的数量一致,因此最长路径上至多有2倍数量的最短路径上黑色结点的数量,由此得证。
  2. 有n个内部结点的红黑树高度h≤2log2(n+1)。
    证明:由定义4和5得知,根的高度至少为一条到叶子结点路径上黑色结点的数量,并且红色结点的数量必然不大于黑色结点的数量,因此n≥2h/2-1,化简即可得证。
红黑树的插入2

红黑树在插入一个结点时,应当考虑插入结点应该是什么颜色的。红黑树在插入前保持着根到每个叶子结点的黑色结点个数相同,这就意味着如果贸然将一个黑色结点插入树中,那么必然导致这个结点后的所有叶子结点不再符合这条性质,因此不能将新结点的颜色设为黑色;那如果插入红色结点呢?如果每一个新插入的结点颜色都是红色的,那为了整棵树符合红黑树的定义,我们势必要对每一个有可能影响到的结点检查一遍。

讨论新插入结点的几种情况:

  • 考虑其父亲结点的情况,有可能是黑色的:
    • 若插入结点的父结点是黑色的,那么我们就不用对红黑树进行调整,因为本次结点的插入并没有破坏红黑树的五点性质。
  • 其父亲结点是红色的:
    • 在新插入结点的父结点为红色时,其祖父结点一定存在并且是黑色的,这时如何调整这棵红黑树取决于其叔叔结点的颜色了。
      1. 插入结点的叔叔存在,且叔叔的颜色是红色:
        梳理以祖父结点为根的子树,根结点即祖父结点是黑色的,父结点是红色的,叔叔结点是红色的,此时一切正常;当插入新结点后,红色的父结点下又接了一个红色结点,此时两个连续的红色结点必然不符合条件。为了解决这种问题,我们可以把祖父结点染成红色,并把其两个子结点染成黑色,这样一来既保持了每条路径黑色结点的数目不变,也解决了连续红色结点的问题。但这么做就需要把祖父结点当作新插入的结点,向上调整防止出现祖父结点的父结点也是红色的,形成两个连续的红色结点的情况。若祖父结点本身就已经是根结点了,那么只需要把祖父结点染成黑色就可以了,相当于每条路径黑色结点的数目都增加了一个。
        在这里插入图片描述
      2. 插入结点的叔叔存在,且叔叔的颜色是黑色:
        这种情况一定是在情况一继续往上调整的过程中出现的,即这种情况下的新结点一定不是新插入的结点,而是上一次情况一调整过程中的祖父结点。
        将路径中祖父结点之上黑色结点数设为x,将叔叔结点之下黑色结点的数目设为y,则在插入结点前,图示两条路径黑色结点的数目分别为x+1和 x+2+y ,很明显x+2+y 是一定大于x+1的,因此在插入结点前就不满足红黑树的要求了,所以说叔叔结点存在且为黑这种情况,一定是由情况1往上调整过程中才会出现的一种情况。
        在这里插入图片描述
        在出现这种情况时,单纯变色已经不能解决问题了,应当旋转这棵子树并依据新结点、父结点、祖父结点之间的关系决定旋转的方向。
        如果三者之间形成了一条直线,即新结点是父结点的左(右)孩子,同时父结点也是祖父结点的左(右)孩子,那么此时对祖父结点作一次相反方向的单旋,并把旋转后的子树根结点染为黑色,左右孩子染为红色,无需继续向上调整。在这里插入图片描述
        如果三者之间形成的是一条折线,即新结点是父结点的左(右)孩子,同时父结点也是祖父结点的右(左)孩子,此时对父结点作一次单旋,单旋方向与新结点方位相反,再对祖父结点作一次单旋,方向与父结点方位相反。在这里插入图片描述
      3. 插入结点的叔叔不存在:
        在这种情况下的cur结点一定是新插入的结点,而不可能是由情况一变化而来的,因为叔叔不存在说明在parent的下面不可能再挂黑色结点了。
        此时的调整方式与情况2一致。
template <typename KTYPE, typename VTYPE>
RedBlackTree<KTYPE, VTYPE>* insert(
	KTYPE key,
	VTYPE value,
	RedBlackTree<KTYPE, VTYPE>*& root,
	RedBlackTree<KTYPE, VTYPE>* parent) {
    //传入结点是空指针或空树的根结点
	if (!root ||
        (!root->_key && !root->left && !root->right)){
		if (!root)
			root = new RedBlackTree(key, value, parent);
		else
			root->_key = key, root->_val = value;

		RedBlackTree* res = root;
        //只有当父结点存在且为红色时需要调整
		while (parent && parent->_color == RBT_Color::RED) {
			RedBlackTree* uncle = nullptr, * grandpa = parent->father, * cur = root;

			if (grandpa)
				uncle = parent == grandpa->left ?
                grandpa->right : grandpa->left;

			//grandpa的左孩子是parent
			if (parent == grandpa->left) {
                //情况1
				//uncle是红色(grandpa的右孩子)
				if (uncle &&
                    uncle->_color == RBT_Color::RED) {
					parent->_color = uncle->_color = RBT_Color::BLACK;
					grandpa->_color = RBT_Color::RED;

                    //循环验证祖父结点的父结点
					cur = grandpa;
					parent = cur->father;
				}
                //情况2和3
				//uncle不存在或为黑色
				else {
                    //前面已经确定父结点是祖父结点的左结点
                    //三者为一条直线
					if (cur == parent->left) {
						RedBlackTree* old = grandpa->father;
						if (old && grandpa == old->left)
							old->left = parent = LLinsert(grandpa);
						else if (old && grandpa == old->right)
							old->right = parent = LLinsert(grandpa);
						else
							parent = LLinsert(grandpa);
						grandpa = parent ?
                            parent->father : nullptr;

						parent->_color = RBT_Color::BLACK;
						parent->left->_color = parent->right->_color = RBT_Color::RED;
					}
                    //三者为一条折线
					else {
						RedBlackTree* old = grandpa->father;
						if (old && grandpa == old->left)
							old->left = parent = LRinsert(grandpa);
						else if (old && grandpa == old->right)
							old->right = parent = LRinsert(grandpa);
						else
                            //祖父结点就是根结点
							parent = LRinsert(grandpa);

						grandpa = parent ? parent->father : nullptr;

						parent->_color = RBT_Color::BLACK;
						parent->left->_color = parent->right->_color = RBT_Color::RED;
					}
					break;
				}
			}
			//grandpa的右孩子是parent
			else if (parent == grandpa->right) {
                //情况1
				//uncle是红色(grandpa的右孩子)
				if (uncle && uncle->_color == RBT_Color::RED) {
					parent->_color = uncle->_color = RBT_Color::BLACK;
					grandpa->_color = RBT_Color::RED;

					cur = grandpa;
					parent = cur->father;
					grandpa = parent ? parent->father : nullptr;
					uncle = grandpa ? grandpa->right : nullptr;
				}
                //情况2和3
				//uncle不存在或为黑色
				else {
					if (cur == parent->left) {
						RedBlackTree* old = grandpa->father;
						if (old && grandpa == old->left)
							old->left = parent = RLinsert(grandpa);
						else if (old && grandpa == old->right)
							old->right = parent = RLinsert(grandpa);
						else
							parent = RLinsert(grandpa);
						grandpa = parent ? parent->father : nullptr;

						parent->_color = RBT_Color::BLACK;
						parent->left->_color = parent->right->_color = RBT_Color::RED;
					}
					else {
						RedBlackTree* old = grandpa->father;
						if (old && grandpa == old->left)
							old->left = parent = RRinsert(grandpa);
						else if (old && grandpa == old->right)
							old->right = parent = RRinsert(grandpa);
						else
							parent = RRinsert(grandpa);
						grandpa = parent ? parent->father : nullptr;

						parent->_color = RBT_Color::BLACK;
						parent->left->_color = parent->right->_color = RBT_Color::RED;
					}
					break;
				}
			}
			else
				return nullptr;
		}

		this->_color = RBT_Color::BLACK;
		return res;
	}
	else
        //递归查找插入结点位置
		return insert(key, value, key < root->_key ? root->left : root->right, root);
}
红黑树的删除
  1. 找到待删除的结点
    找结点的过程与二叉搜索树寻找待删除结点的方法一样,若找到的待删除结点的左右子树均不为空,则需要使用替换法进行删除。因此我们最终需要删除的都是左右子树至少有一个为空的结点。
  2. 调整红黑树
    若实际删除的结点是红色结点,那么本次删除操作不会破坏红黑树的性质,因此我们不需要对红黑树进行调整。
    反之,若删除的结点是黑色结点,我们就需要对红黑树进行调整,因为黑色结点的删除将会使得一些路径中黑色结点的数目减少,此时便破坏了红黑树的性质。因此,考虑删除的结点是黑色结点。
    1. 待删除结点只有一个孩子为空的情况:
      这种情况下,这个孩子结点的颜色一定为红色的,因为如果这个结点为黑色,那其黑色结点的数量就比另一边孩子为空的数量多1。这时只需要把待删除结点用其唯一的一个孩子替换,并将这个孩子颜色调整为黑色即可。
    2. 待删除结点左右孩子均为空的情况(以待删除结点是其父结点的左孩子为例):
      1. brother为红色:
        当待删除结点的brother为红色时,我们先以parent为旋转点进行一次左单旋,再将brother的颜色变为黑色,将parent的颜色变为红色,此时我们再对待删除结点cur进行情况分析,情况一就转换成了情况二、三或四。在这里插入图片描述
      2. brother为黑色,且其左右孩子都是黑色:在该情况下,我们直接将brother的颜色变成红色,此时根据parent的颜色决定红黑树的调整是否结束,若parent的颜色是红色,则我们将parent变为黑色后即可结束红黑树的调整;若parent的颜色原本就是黑色,则我们需要将parent结点当作下一次调整时的cur结点进行情况分析,并且情况二在下一次调整时可能会是情况一、二、三、四当中的任何一种。在这里插入图片描述
      3. brother为黑色,且其左孩子是红色结点,右孩子是黑色结点或为空:
        出现该情况时,我们先以brother为旋转点进行一次右单旋,再将brother结点变为红色,将brotherLeft变为黑色,此时我们再对待删除结点cur进行情况分析,情况三就转换成了情况四。在这里插入图片描述
      4. brother为黑色,且其右孩子是红色结点:
        经过情况四的处理后,红黑树就一定调整结束了。在情况四当中,我们先以parent为旋转点进行一次左单旋,然后将parent的颜色赋值给brother,再将parent的颜色变为黑色,最后将brotherRight变为黑色,此时红黑树的调整便结束了。在这里插入图片描述
template <typename KTYPE, typename VTYPE>
bool remove(KTYPE key, RedBlackTree<KTYPE, VTYPE>* root){
	//查找关键字位置
    RedBlackTree* cur = !root ? this : root, * last;
	while (cur && key != cur->_key)
		cur = key < cur->_key ? cur->left : cur->right;

	if (!cur)
        //没有找到关键字结点
		return true;
	else if (cur == this) {
        //待删除结点为根结点
		if (!this->left && !this->right) {
			this->_key = 0;
			this->_val = 0;
			return true;
		}

        //用直接前驱或直接后继替代
		RedBlackTree* rep = this->right ? getMinNode(this->right) : getMaxNode(this->left);
		swap2(this->_key, rep->_key);
		swap2(this->_val, rep->_val);

		return remove(key, rep);
	}
	else if (!cur->left && !cur->right) {
		//叶子节点,cur保留位置不变
		;
	}
	else if (cur->left && !cur->right) {
		//只有左子树,用直接前驱替代
		last = cur;
		cur = getMaxNode(cur->left);

		swap2(last->_key, cur->_key);
		swap2(last->_val, cur->_val);

		return remove(key, cur);
	}
	else if (cur->right) {
		//有两棵子树,或只有右子树,用直接后继代替
		last = cur;
		cur = getMinNode(cur->right);

		swap2(last->_key, cur->_key);
		swap2(last->_val, cur->_val);

		return remove(key, cur);
	}

	RedBlackTree* parent = cur->father,
    * bro = cur == parent->left ?
        parent->right : parent->left;
	last = cur;

	if (cur->_color == RBT_Color::BLACK) {
		if (cur->left)
			cur->left->_color = RBT_Color::BLACK;
		else if (cur->right)
			cur->right->_color = RBT_Color::BLACK;
		else
			while (cur != this) {
				//bro存在且为红色
				if(bro && bro->_color == RBT_Color::RED){
					if (cur == parent->left) {
						parent = RRinsert(parent);
						bro = parent->left;
					}
					else {
						parent = LLinsert(parent);
						bro = parent->right;
					}
					parent->_color = RBT_Color::BLACK;
					bro->_color = RBT_Color::RED;
				}

				//bro为黑色
				if (bro) {
					//bro左右孩子都是黑色(或为空)
					if ((!bro->left || bro->left->_color == RBT_Color::BLACK) &&
                        (!bro->right || bro->right->_color == RBT_Color::BLACK)) {
						bro->_color = RBT_Color::RED;

						if (parent->_color == RBT_Color::RED) {
							parent->_color = RBT_Color::BLACK;
							break;
						}

						cur = parent;
						parent = parent->father;
					}
					//bro只有其中一个孩子为红色
					else {
						if (!bro->right || bro->right->_color == RBT_Color::BLACK) {
							bro->left->_color = RBT_Color::BLACK;
							bro->_color = RBT_Color::RED;

							bro = LLinsert(bro);
						}

						bro->_color = parent->_color;
						parent->_color = RBT_Color::BLACK;
						if (bro->right)
							bro->right->_color = RBT_Color::BLACK;
						parent = RRinsert(parent);

						break;
					}
				}
			}
	}

	cur = last;
	parent = cur->father;
	if (!cur->left) {
		if (parent && cur == parent->left) {
			parent->left = cur->right;

			if (cur->right)
				cur->right->father = parent;
		}
		else {
			parent->right = cur->right;

			if (cur->right)
				cur->right->father = parent;
		}
	}
	else {
		if (parent && cur == parent->left) {
			parent->left = cur->left;

			if (cur->left)
				cur->left->father = parent;
		}
		else {
			parent->right = cur->left;

			if (cur->left)
				cur->left->father = parent;
		}
	}

	delete cur;
	return true;
}
红黑树的查找

查找方法与二叉搜索树的查找方式一样。

template <typename KTYPE, typename VTYPE>
RedBlackTree<KTYPE, VTYPE>* find(KTYPE key) {
	RedBlackTree<KTYPE, VTYPE>* cur = this;

	while (cur && key != cur->_key)
		if (key < cur->_key)
			cur = cur->left;
		else
			cur = cur->right;

	return cur;
}

B树、B+树

B树

又称多路平衡查找树,树中所有结点的孩子个数的最大值称为B树的阶,常用m表示。一棵m阶B树或为空树,或应满足如下特性:

  1. 树中每个结点至多有m棵子树,即至多含有m-1个关键字;
  2. 若根结点不是终端结点,则至少有两棵子树
  3. 除根结点外的所有非叶结点至少有⌈m/2⌉棵子树,即至少有⌈m/2⌉-1个关键字。
  4. B树的每个结点中,假设其中关键字编号分别为 k 1 , k 2 , k 3 , . . . , k l , ( ⌈ m / 2 ⌉ − 1 ≤ l ≤ m − 1 ) k_{1},k_{2},k_{3},...,k_{l},(⌈m/2⌉-1\le l \le m-1) k1,k2,k3,...,kl,(m/21lm1),其指针分别为 p 1 , p 2 , p 3 , . . . , p k , ( k = l + 1 ) p_{1},p_{2},p_{3},...,p_{k},(k=l+1) p1,p2,p3,...,pk,(k=l+1),那么一定满足pi指向的结点中的每一个关键字都满足ki-1 <pi <ki
  5. 所有叶子结点都出现在同一高度,其中不包含任何信息。
B树的高度

B树的高度不包括不带任何信息的叶结点所处一层。

对于一棵含有n个关键字的高度为h的m阶B树:

  1. 由B树的特性,树中关键字应满足
    n ≤ ( m − 1 ) ( 1 + m + m 2 + . . . + m h − 1 ) = m h − 1 n \le (m-1)(1+m+m^{2}+...+m^{h-1})=m^{h}-1 n(m1)(1+m+m2+...+mh1)=mh1
    因此有
    h ≥ l o g m ( n + 1 ) h \ge log_{m}(n+1) hlogm(n+1)
    即含有n个关键字的m阶B树最小高度应满足上式。
  2. 若让每个结点中关键字个数达到最少,则可以使得树的高度达到最大。此时有 n + 1 ≥ 2 ( ⌈ m / 2 ⌉ ) h − 1 n+1 \ge 2(⌈m/2⌉)^{h-1} n+12(m/2)h1,即 h ≤ l o g ⌈ m / 2 ⌉ ( ( n + 1 ) / 2 + 1 ) h \le log_{⌈m/2⌉}((n+1)/2+1) hlogm/2((n+1)/2+1)
B树的查找

B树的查找方法与二叉树的类似,不过二叉树的查找是两路分支决定,B树的则根据该结点的子树作多路分支决定。

B树的插入

针对高度为h的m阶B树,插入一个元素时,首先在B树中检查其是否存在,如果不存在,即在叶子结点处结束,就在该叶子结点的父结点中插入新的元素,

  • 若该结点元素个数小于m-1,直接插入;
  • 若该结点元素个数等于m-1,则会引起结点分裂;
    以该结点中间元素为分界,取中间偶数个元素插入到父结点中;
  • 重复上述操作,直到所有结点符合B树的规则;
    最坏的情况一直分裂到根结点,生成新的根结点,最终高度增加1。
B树的删除

首先查找B树中需删除的元素,如果B树中存在该元素,则将该元素在其结点中进行删除;删除该元素后,首先判断该元素是否有左右孩子结点,如果有,则上移孩子结点中的某相近元素(“左孩子最右边的结点”或“右孩子最左边的结点”)到父结点中,然后是移动之后的情况;如果没有,直接删除。

  • 某结点中元素数目小于⌈m/2⌉-1,则要看其相邻兄弟结点是足够;
    • 如果足够(结点中元素个数大于⌈m/2⌉-1),则向兄弟结点借一个元素来满足条件;
    • 如果其相邻兄弟都不足够,即其结点数目等于⌈m/2⌉-1,则该结点与其相邻的某一兄弟结点进行合并成一个结点。
B+树

B+树只介绍一些基本概念。

一棵m阶的B+树,满足如下条件:

  • 每个分支结点最多有m棵子树(孩子结点);
  • 非叶根结点至少有两棵子树,其他分支结点至少有⌈m/2⌉棵子树;
  • 结点的子树数目与关键字数目相等;
  • 所有叶结点包含全部关键字及指向对应记录的指针,叶结点中关键字按大小顺序排列,且相邻叶结点按大小顺序相互链接;
  • 所有分支结点仅包含其各个子结点中关键字的最大值及指向其子结点的指针。

同样m阶的B树和B+树主要差异如下:

  1. B树中n个关键字的结点有n+1棵子树;
    B+树中n个关键字的结点有n棵子树。
  2. B树中每个结点关键字的个数n范围处于 ⌈ m / 2 ⌉ − 1 ≤ n ≤ m − 1 ⌈m/2⌉-1 \le n \le m-1 m/21nm1
    B+树中每个结点关键字的个数n范围处于 ⌈ m / 2 ⌉ ≤ n ≤ m ⌈m/2⌉ \le n \le m m/2nm(根结点则是 2 ≤ n ≤ m 2 \le n \le m 2nm)。
  3. B树中的叶子结点不包含信息;
    B+树中的叶子结点包含信息,所有非叶结点只起到索引作用,非叶结点的每个索引项只含有对应子树的最大关键字和指向该子树的指针,不含有该关键字对应记录的存储地址。
  4. B树叶结点包含的关键字和其他结点包含的关键字是不重复的;
    B+树中叶结点包含了全部关键字,即在非叶结点中出现的关键字也会出现在叶结点中。

散列表(哈希表)

  • 散列函数:一个把查找表中的关键字映射成该关键字对应的地址的函数。
  • 冲突:散列函数把两个或以上的不同关键字映射到同一地址的情况。
  • 同义词:发生冲突时的不同关键字互为同义词。
  • 散列表:根据关键字而直接进行访问的数据结构。它建立了关键字和存储地址之间的一种直接映射关系。

构造方法

直接定址法

将关键字通过线性函数映射为地址。

保留余数法

将关键字通过对某个数取模,以余数作为地址的映射方法。

数字分析法

在已知关键字集合时,根据关键字在其进制上每一位的各个值出现的频率不一样,通过映射将这些频率本不均等的值映射成频率尽可能接近的地址的方法。

平方取中法

取关键字平方值的中间几位作为地址的映射方法。

处理冲突的方法

开放定址法

散列表中的空闲地址可以向其同义词开放,又可以由其非同义词占用。

递推公式可以表示为

H i = ( H ( k e y ) + d i ) % m H_{i}=(H(key)+d_{i})\%m Hi=(H(key)+di)%m

其中H(key)是原本由散列函数确定出来的地址(但已经被占用了),di为增量序列,即应该偏移的位数,m是散列表的表长。

  1. 线性探测法:当某个关键字通过散列函数找到的地址已经被占用时,可以通过检查其随后的地址是否被占用,直到找到第一个空闲的位置时可以将其占用,其增量序列为di=1,2,···,m-1。
  2. 平方探测法:增量序列表现为di=0,12,-(12),22,-(22),···,m/22,-(m/2)2时,即平方探测法,但要求散列表长必须可以表示成4i+3的素数。
  3. 双散列法:通过两次散列函数确定最终关键字的存储地址,其散列函数形式如 H i = ( H ( k e y ) + i × F ( k e y ) ) % m H_{i}=(H(key)+i \times F(key))\%m Hi=(H(key)+i×F(key))%m。当出现冲突时,由i和F()决定关键字的存储地址,i表示出现冲突的次数,F为新的散列函数。
  4. 伪随机序列法:当增量序列为一个伪随机序列时即为伪随机序列法。
拉链法

散列函数映射的是一个线性表,表中的每一项都是一个链表,每次关键字通过散列函数确定其在散列表中的位置时,通过对应位置的链表结点找到其存储位置,形式上类似于邻接表。

效率分析

散列表的查找效率取决于三个因素:散列函数,处理冲突的方法和装填因子。其中装填因子 α = 表 中 记 录 数 n 散 列 表 长 度 m α=\frac{表中记录数n}{散列表长度m} α=mn,也就是说,散列表长度固定的情况下,表里的有效元素越多时,α会越大,发生冲突的可能性越大,反之则越小。


Note


排序

知识框架

  • 排序
    • 基本概念
      • 稳定性
      • 时空复杂度
    • 内部排序
      • 插入排序
        • 直接插入排序
        • 折半插入排序
        • 希尔排序
      • 交换排序
        • 冒泡排序
        • 快速排序
      • 选择排序
        • 简单选择排序
        • 堆排序
      • 归并排序
      • 基数排序
    • 外部排序
      • 多路归并排序

基本概念

  • 算法的稳定性:对于一个排序算法而言,在待排序表中有两个元素x和y其关键字相同,并且排序前x在y之前,若是使用该排序算法后x仍在y之前,那么这个排序算法是稳定的,反之则是不稳定的。
  • 时空复杂度:时间和空间复杂度是衡量一个算法好坏的基本指标。

内部排序

插入排序

直接插入排序

算法描述:

不难理解,这是一种从头扫描线性表,将第二个到最后一个元素依次插入到前面有序表中的算法,一共需要n-1趟。

时空复杂度:

因为这种方式只是把一个元素插入到原有的表中,执行过程中只有常数个辅助变量,因此空间复杂度为O(1)。同时,将第二个元素开始的所有元素依次排序,需要n-1趟;若不能将每次需要排序的元素放在有序子序列的后面,即待排序元素比前面的都大(最好情况),则每一趟需要和前面已经排序好的1,2,…,n-1个元素进行比较,则此时考虑最坏情况,即每个待排序元素都小于前面的有序子序列,需要插入到最前方,其每一轮比较和移动后方元素都需约 ∑ i = 2 n i \sum _{i=2} ^{n} {i} i=2ni 次。于是,最好情况下的时间复杂度为O(n);最坏情况下的为O(n2)。

算法代码:

void insertSort(int arr[], int len) {
    //从第二个元素开始进行插入排序
	for (int i = 1; i < len; i++) {
        //temp记录待插入的元素,将待插入元素前面比它大的往后放
		int temp = arr[i], j = i - 1;
		while (j >= 0 && temp < arr[j])
			arr[j + 1] = arr[j--];
		arr[j + 1] = temp;
	}
}
折半插入排序

算法描述:

折半插入排序同样是将从头往后将元素逐个插入到先前排序好的有序表中的一种算法。在将第k+1个元素插入到前k个元素组成的有序表中时,按照折半查找的思想,先与第⌊ k 2 \frac {k}{2} 2k⌋对比,随后决定第k+1个元素应该插入前半部分还是后半部分,并且在之后选择的那一半有序表中重复这种操作直到其最终的插入位置确定下来。

考虑序列1,2,2*。前两个元素在经过排序后其位置不会改变,到第三个元素2*时,会首先与第(0+1)/2=0个元素,即与1比较,因此确定插入到1的后半部分。再由后半部分的中间元素2与2*比较,二者相等,但考虑到算法应具有稳定性,我们应当注意当中间元素与待插入值大于或相等时,让待插入值插入到中间元素的后半部分,因此此时2*插入到2的后方,即最终是1,2,2*,排序完成。

时空复杂度:

分析折半插入排序的思想,我们不难看出,折半排序只是针对待插入值时与前面有序表的查找次数进行优化;但插入排序的痛点在于确定插入位置后,插入位置后方的元素的移动,折半插入排序并没有解决这个问题,这部分的时间复杂度依然是O(n)。也就是说,在对n个元素进行排序时,每个元素需要O(log2n)的时间找到它应该插入的位置,再用O(n)的时间去将后方的元素后移一位,以腾出空间将待插入值插入。总的时间复杂度为O(nlog2n+n2)),只保留最高阶,即该算法的时间复杂度为O(n2)。

空间复杂度上,由于只使用了常数个辅助空间记录信息,因此空间复杂度依然为O(1)。

算法代码:

void binarySort(int arr[], int len) {
	int low = 0, high = len, mid = 0, temp = 0;
	for (int i = 1; i < len; i++) {
		low = 0, high = i - 1, temp = arr[i];

        //除了这里找插入位置外都和直接插入排序的一致
		while (low <= high) {
            //待插入元素与中间元素比较,决定后续插入位置在中间元素的前半部分还是后半部分
			mid = (low + high) / 2;
			if (temp >= arr[mid])	low = mid + 1;
			else					high = mid - 1;
		}

		for (int j = i; j > low; j--)
			//j > low 保证了j > 0,即j-1 >= 0
			arr[j] = arr[j - 1];
		arr[low] = temp;
	}
}
希尔排序

算法描述:

希尔排序把待排序表根据某个特定的距离d划分为d个子表,即表中相隔d个元素的属于同一子表,把这些子表按直接插入排序的方式排序好;然后缩小这个距离不断重复这种操作,直到最后距离d=1,所有元素回归到同一个表中,再对这个表进行一次插入排序。

希尔排序在对表进行划分时,由于相同的关键字大概率没有分配到同一个子表,每个子表排序后可能将分属于不同子表但关键字相同的元素顺序打乱,导致最后的结果是该算法的稳定性欠佳。

时空复杂度:

空间上只有常数个辅助单元,因此空间复杂度是O(1)。时间复杂度较难分析,此处不予解释。

算法代码:

void shellSort(int arr[], int len) {
	for (int d = len / 2; d > 0; d /= 2)
		for (int i = d; i < len; i++)
            //从每个子表的第二个元素开始,每次都与前面一个元素比较,较小的放前面;不断往前比较,使得子表内元素都按序排列了
			for (int j = i; j >= d
                 && arr[j] < arr[j - d]; j -= d)
				swap2(arr[j], arr[j - d]);
}

交换排序

冒泡排序

算法描述:

在n个元素的表中,进行n-1轮排序;每一轮从未排序的序列中选出一个最小值,并将其通过交换的方式将这个最小值放到合适位置。

时空复杂度:

空间复杂度上,由于只使用了常数个辅助单元,因此空间复杂度为O(1)。

时间复杂度在最好情况下待排序序列已经是排序好了的,只需要进行一轮n-1次比较,时间复杂度为O(n)。最坏情况下,即待排序序列为目标序列的逆序,则要进行n-1轮排序,每一轮进行n-1,n-2,···,1次比较,比较次数有 ∑ i = 1 n − 1 ( n − i ) = n ( n − 1 ) 2 \sum _{i=1} ^{n-1} {(n-i)}=\frac {n(n-1)}{2} i=1n1(ni)=2n(n1),因此最坏时间复杂度和平均时间复杂度均为O(n2)。

算法代码:

void bubbleSort(int arr[], int len) {
	for (int i = 0; i < len; i++)
		for (int j = len - 1; j > i; j--) {
			if (arr[j] < arr[j - 1])
				swap2(arr[j], arr[j - 1]);
		}
}

void bubbleSort_Advenced(int arr[], int len){
    bool flag = false;
    for (int i = 0; i < len; i++)
        flag = false;
		for (int j = len - 1; j > i; j--){
			if (arr[j] < arr[j - 1]){
				swap2(arr[j], arr[j - 1]);
                flag = true;
            }
            
            if(!flag) return;
        }
}
快速排序

算法描述:

快速排序采用一种分治的思想,即把规模较大的问题,分成若干个同样的但规模较小的问题,再解决这些问题的过程。

排序算法中,排序表内元素的个数从来不是问题的关键,我们需要考虑的仅仅是怎么排才能更高效。快速排序首先在待排序表中选择一个基准pivot,通常会选择表首元素。随后逐个交替扫描表内元素,首先从后往前扫描,找到一个比基准值小的元素,交换二者位置并逆转搜索方向;当从前往后扫描时,找到一个比基准值大的元素,交换二者位置并再次逆转搜索方向;直到最后所有元素都经过了比较。这样就可以根据基准元素划分出两个子表left和right,按上面这种方法得到的left是比基准值小的子表,right是比基准值大的子表。对这两个子表采用同样的方法,分别选择它们的基准元素并重复这种操作,直到划分出的子表中只剩一个或零个元素。最后将这些划分出来的n个子表合并成一个表,此时得到的就是一个有序表。

有时候,选择表首元素作为基准值可能划分出的子表并不均衡,可能出现一边只有很少的元素,其余元素都集中在另一侧,为了避免这种问题,可以选择将表首元素、表尾元素和中间元素比较,选择最恰当的值作为基准值。

时空复杂度:

快速排序的空间复杂度主要体现在每次递归时需要的递归栈,每进入一次更深层的递归需要栈记录这些信息,因此递归时的最深层次决定了栈的深度。因此递归栈的深度应为O(log2n),即空间复杂度为O(log2n)。

排序中,每次划分都可以确定基准元素的最终位置,也就是说最多n轮就能把所有元素排序完。考虑最坏情况下的时间复杂度,即待排序序列是逆序的,假设选择的基准元素是表首元素,那么这时候跟冒泡算法无异,每轮比较都需要交换,都把较大的元素放到了表尾,时间复杂度为O(n2)。但最好情况下时,每次划分都可以得到最均匀的子表,此时时间复杂度与这一次划分的时间开销以及左右两个子表排序的时间复杂度之和,总而言之,此时的时间复杂度为O(nlog2n)。

当待排序表个数为n时,快速排序的时间复杂度由以下公式给出,

O ( n ) = { D ( 1 ) , n = 1 D ( n ) + O ( S 1 ) + O ( S 2 ) , n > 1 O(n)=\left\{\begin{matrix} D(1)&,n = 1 \\ D(n)+O(S_{1})+O(S_{2})&,n > 1 \end{matrix}\right. O(n)={D(1)D(n)+O(S1)+O(S2),n=1,n>13

其中,D(n) = n-1,表示这次划分中的比较次数,一次划分后会分成*S1S2*两部分。

算法代码:

int Partition(int arr[], int low, int high) {
	int pivot = arr[low], start = low, end = high;
	while (low <= high) {
		while (low <= high && pivot <= arr[high])
            high--;
		if (low <= high)
            swap2(arr[low], arr[high]);
		while (low <= high && arr[low] <= pivot)
            low++;
		if (low <= high)
            swap2(arr[high], arr[low]);
	}
	return pivot == arr[high] ? high : low;
}

void quickSort(int arr[], int low, int high) {
	if (low < high) {
		int mid = Partition(arr, low, high);
		quickSort(arr, low, mid - 1);
		quickSort(arr, mid + 1, high);
	}
}

选择排序

简单选择排序

算法描述:

选择排序的思想是,在第i(0<i<n)轮排序中,从n+1-i个元素中选择一个最小的,将其与第i个位置交换,最终完成所有元素的排序。

时空复杂度:

空间上只使用了常数个辅助单元,其时间复杂度为O(1)。

时间上,由于算法的主要时间开销在元素的比较上,而n个元素的排序需要进行n-1轮,并且每轮需要n-i次比较,则时间复杂度 O ( ∑ i = 1 n − 1 ( n − i ) ) = O ( n ( n − 1 ) 2 ) ≈ O ( n 2 ) O(\sum _{i=1} ^{n-1} {(n-i)})=O(\frac {n(n-1)}{2}) \approx O(n ^{2}) O(i=1n1(ni))=O(2n(n1))O(n2)

算法代码:

void selectSort(int arr[], int len) {
	for (int i = 0; i < len; i++) {
		int index = i;
		for (int j = i + 1; j < len; j++)
			if (arr[index] > arr[j])
				index = j;

		swap2(arr[i], arr[index]);
	}
}
堆排序

算法描述:

回顾将二叉树用数组存储的内容,当数组长度为n时,下标小于等于⌊ n 2 \frac {n}{2} 2n⌋的都是拥有一个以上叶子结点的结点。现对堆作如下定义,n个关键字序列称为堆,当序列满足父结点的值都比其子树下的结点的值都大(小)。

由堆的定义可知,每次将堆构造出来,其根结点都会得到一个最大(小)值,取出这个最值放到序列末端,并且下次不参与堆的构造;并且之后对剩余元素重新调整这个堆,再次得到一个最值,取出并放到剩余元素序列的末端,下次不参与堆的构造,不断重复这个过程直到所有元素都取出,此时得到的就是一个有序序列。

时空复杂度:

空间上,只使用了常数个辅助单元,因此空间复杂度为O(1)。

时间上,对n个元素排序需要进行n轮建堆、取顶的操作,每轮构造堆要比较不超过4n次,因此建堆的时间复杂度为O(n),之后调整时每次调整的时间复杂度为O(log2n),因此所有情况下的时间复杂度均为O(nlog2n)。在调整堆时可能会把相同的关键字靠后的放到前面,因此堆排序是不稳定的。

算法代码:

void subHeapAdjust(int arr[], int root, int len, bool mode = 1) {
	int temp = arr[root], i = root << 1;

	for (; i <= len; i <<= 1) {
		if (mode && (i + 1) <= len
            && arr[i] < arr[i + 1])
			i++;
		else if (!mode && (i + 1) <= len
                 && arr[i] > arr[i + 1])
			i++;

		if ((mode && temp >= arr[i])
            || (!mode && temp <= arr[i]))
			break;
		else {
			arr[root] = arr[i];
			root = i;
		}
	}
	arr[root] = temp;
}

void heapSort(int arr[], int len, bool mode) {
	for (int i = len / 2; i > 0; i--)
		subHeapAdjust(arr, i, len, mode);

	for (int i = len; i > 0; i--) {
		swap2(arr[1], arr[i]);
		subHeapAdjust(arr, 1, i - 1, mode);
	}
}

归并排序

算法描述:

归并排序的核心问题,是把2个长度为 n 2 \frac{n}{2} 2n的有序表合并成1个长度为n的有序表,显然这也是一个基于分治思想的算法。假设有一个含有n个元素的待排序表,那么归并排序要做的就是,首先把这n个元素视作n个只包含一个元素的子表,然后两两合并,使得它们变成⌈ n 2 \frac{n}{2} 2n⌉个包含1或2个元素的有序子表;再两两合并成⌈ n 4 \frac{n}{4} 4n⌉个子表,最终变成1个有序表。

时空复杂度:

空间上,merge需要用到一个长度为n的辅助数组,因此空间复杂度为O(n)。

时间上,明显可以看出两两合并n个表最少需要⌈log2n⌉轮,每一轮的时间复杂度是O(n),因此总的时间复杂度是O(nlog2n)。

算法代码:

int* brr = (int*)malloc(sizeof(int) * (size_t)(n + 1));
void merge(int arr[], int low, int mid, int high) {
	if (low > high)
		return;

	int i = 0, j = 0, k = 0;
	memset(brr, 0, sizeof(int) * (size_t)(n + 1));

	for (int i = low; i <= high; i++)
		brr[i - low] = arr[i];

	for (i = 0, j = mid + 1 - low, k = low; i <= mid - low && j <= high - low; )
		if (brr[i] <= brr[j])
			arr[k++] = brr[i++];
		else
			arr[k++] = brr[j++];

	while (i <= mid - low)	arr[k++] = brr[i++];
	while (j <= high - low)	arr[k++] = brr[j++];
}

void mergeSort(int arr[], int low, int high) {
	if (low < high) {
		int mid = (low + high) / 2;
		mergeSort(arr, low, mid);
		mergeSort(arr, mid + 1, high);
		merge(arr, low, mid, high);
	}
}

基数排序

算法描述:

基数排序的思想非常巧妙,这是我们大脑在对待排序表进行排序时最常用的方法:我们会首先看看每个元素最高有多少位,显然位数越多的值越大(不考虑负数时),而确定好最高位数后,我们排序会首先对最高位进行排序,排好后按次高位、次次高位一路排序,直到排到个位,此时就得到了一个有序表。基数排序的思想与此如出一辙。基数排序首先是确定一次完整的排序要进行的轮次,即最高位是多少位;随后从最低位开始逐位排序,将待排序表中的元素按最低位的值放入对应的辅助队列中,然后每个辅助队列按序将其中元素逐个出队,覆盖之前的待排序表,此时待排序表内可以得到一个最低位有序的序列;重复这个过程,直到将最高位排序好,此时得到的待排序表就是有序的了。

时空复杂度:

空间上,需要用到b(与进制有关)个辅助队列。辅助队列中所有元素加起来最多和待排序表的元素个数n相等,因此总的空间复杂度为O(b+n)。

时间上,设待排序表中元素的最高位数为f,也就是说需要进行f轮排序,每一轮遍历一遍待排序表,复杂度为O(n),同时每轮进行收集,复杂度O(b)与进制有关,因此总的时间复杂度是O(f × (b+n))。

算法代码:

int getMaxFigure(int arr[], int len) {
	int m = arr[0], figure = 0;
	for (int i = 1; i < len; i++) 
		if (arr[i] > m)
			m = arr[i];

	while (m)
		m /= 10, figure++;

	return figure;
}

inline int numAt(int num, int index) {
	while (--index && num)
		num /= 10;
	return num % 10;
}

void radixSort(int arr[], int len) {
    //辅助队列;获取最高位数
	Queue ct[10];
	int times = getMaxFigure(arr, len);
    
    //依据位数来确定排序轮数
	for (int i = 1; i <= times; i++) {
		int k = 0;
        //逐个把待排序表中的元素按这个元素的关键字在第j位的值来插入对应的辅助队列中
		for (int j = 0; j < len; j++)
			ct[numAt(arr[j], i)].enqueue(arr[j]);

        //一轮入队完成后,从具有最低权值的辅助队列开始逐个出队并覆盖原有的待排序表,此时的待排序表是按第i位的权值排序的
		for (int j = 0; j < 10; j++)
			while (!ct[j].isEmpty())
				arr[k++] = ct[j].dequeue();
	}
}

排序算法的比较

算法时间复杂度空间复杂度稳定性
最好情况平均情况最坏情况
直接插入排序O(n)O(n²)O(n²)O(1)
冒泡排序O(n)O(n²)O(n²)O(1)
简单选择排序O(n²)O(n²)O(n²)O(1)
希尔排序O(1)
快速排序O(nlogn)O(nlogn)O(n²)O(nlogn)
堆排序O(nlogn)O(nlogn)O(nlogn)O(1)
2路归并排序O(nlogn)O(nlogn)O(nlogn)O(1)
基数排序O(d(n+r))O(d(n+r))O(d(n+r))O(r)

外部排序

多路归并排序

Note


代码

已经实现了的代码可以在这里看到:数据结构代码


  1. 如果是向下取整,会出现元素没有对比完的情况。 ↩︎

  2. 文章参考2021dragon红黑树(C++实现) ↩︎

  3. 参考https://zhuanlan.zhihu.com/p/341201904 ↩︎

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值