数据结构

前言

本文是基于王道考研 数据结构所记的笔记。如有侵权,请告知删除。

版本号:v1.0.0

文章目录

第一章 绪论

1.1 数据结构的基本概念

  1. 数据:信息的载体,是描述客观事物属性的数、字符及所有能输入到计算机中并被计算机程序识别和处理的符号的集合。

    • 数据对象:具有相同性质的数据元素的集合,是数据的子集。
    • 数据元素:数据的基本单位,通常作为整体进行考虑和处理。
    • 数据项:构成数据元素的不可分割的最小单位。
    • 例:人和螃蟹组成的数据集,其中人是数据对象,每个人是数据元素,人的身高是数据项。
  2. 数据类型(集合+操作):

    • 原子类型:(值的集合+操作)int、char、float等。
    • 结构类型:(结构的集合+操作)list、map、set等。
    • 抽象数据类型ADT:(数据对象+数据关系+操作)所有可以抽象出的模型。人、汽车。
  3. 数据结构:

    • 结构:数据不是孤立的,存在某种关系,这种相互关系我们叫做结构。
    • 数据结构:是相互之间存在一种或多种特定关系的数据元素的集合。
  4. 数据结构三要素:逻辑结构、物理结构、数据的运算

    • 逻辑结构:逻辑关系上来描述数据,独立于计算机,与如何存储无关。
      • 线性结构:线性表、栈、队列
      • 非线性结构:集合、树形结构、图状结构
    • 存储结构:
      • 顺序存储:逻辑上相邻,物理上也相邻。
      • 链式存储:逻辑上相邻,通过指针链接。
      • 索引存储:建立索引表(索引项:关键字+对应地址),消耗内存资源。
      • 散列存储:(哈希),通过散列函数计算存储地址。
    • 数据的运算:运算包含运算的定义和实现,运算的定义针对逻辑结构,运算的实现针对存储结构。

1.2 算法和算法评价

  1. 算法的基本概念:

    • 对特定问题求解步骤的一种描述,它是指令的有限序列,其中的每条指令表示一个或多个操作。
  2. 算法的五个特性:

    • 有穷性:有穷步,有穷时间。
    • 可行性:每部操作可以实现。
    • 确定性:每部操作含义确定,相同输入产生相同输出,无二义性。
    • 输入:0-多个输入
    • 输出:0-多个输出
  3. 算法 VS 程序

    • 算法是解决问题的指导者,程序是实施者。算法是解决问题的一种方法或一个过程,考虑如何将输入转换成输出,一个问题可以有很多个算法。程序是某种程序设计语言对算法的具体实现。
    • 有穷性:算法必须是有穷的,程序可以是无穷的。
    • 正确性:算法必须是正确的,程序可以是错误的。
    • 算法可以用伪代码、程序语言等描述,程序只能用程序语言编写并可以运行。
  4. 算法效率的度量:

    • 如何设计一个“好”算法

      • 正确性:能正确解决求解问题。
      • 可读性:具有良好的可读性,以帮助人们理解。
      • 健壮性:输入非法数据时,算法能适应的作出反应或进行处理。
      • 效率与存储量:效率是指算法执行时间,存储量需求是指算法执行过程中所需最大存储空间。
    • 语句频度:该条语句可能重复执行的次数。

    • T(n):所有语句的频度之和,其中n为问题的规模。

    • 时间复杂度:T(n) = O(f(n)),其中O表示T(n)与f(n)在n->正无穷时为同阶无穷大。

      • T(n) = n + 1 = O(n) = O(f(n))
      • 最坏时间复杂度,最好时间复杂度,平均时间复杂度
      • 加法规则:T(n) = T1(n) + T2(n) = O(f(n)) + O(g(n)) = O(max(f(n), g(n)))
      • 乘法规则:T(n) = T1(n) * T2(n) =O(f(n)) + O(g(n)) = O(f(n) * g(n))
      • 通常采用基本运算频度(最深层循环语句的执行频度)来分析算法时间复杂度。
      • 常见时间复杂度:O(1) < O(log2n) < O(n) < O(nlog2n)< O(n2) <O(n3) < O(2n) < O(n!) < O(nn)
    • 空间复杂度:算法消耗的存储空间,记S(n) = O(g(n))

      • 除本身所用的指令、常数、变量和输入数据外,还需要对数据进行操作的工作单元和存储为实现算法所需的一些信息的辅助空间。
      • 算法原地工作时指算法所需辅助空间为常量,O(1)。

第二章 线性表

线性表是一种逻辑结构。顺序表和链表是存储结构。

2.1 线性表的定义和基本操作

  1. 线性表的定义:

    • 线性表是具有相同类型的n (n>=0) 个元素的有限序列,其中n为表长,当n = 0时,该表为空表。
    • 若L命名为线性表,则一般表示为L= (a1,a2,a3,…,ai,…,an),a1为表头元素,an为表尾元素。前驱节点、后继节点
  2. 线性表的特性:

    • 有限性
    • 顺序性,元素排序有先后次序
    • 表中元素都是数据元素,每个元素都是单个元素
    • 数据类型相同,占用相同大小的存储空间。
    • 元素具有抽象性,即讨论元素间的一对一的逻辑关系,而不考虑元素究竟表示的内容。
    • 线性表是一种逻辑结构,表示元素之间一对一相邻的关系。
  3. 线性表的九种基本操作:

    • InitList(&L):初始化表。构造一个空的线性表。
    • DestroyList(&L):销毁操作。销毁线性表,并释放线性表L所占用的内存空间。
    • LocateElem(L,e):按值查找。在表L中查找具有给定关键字值的元素。
    • GetElem(L,i):按位查找。获取表L中第i个位置的元素的值。
    • ListInsert(&L,i,e):插入操作。在表L中的第i个位置前面插入指定元素。
    • ListDelete(&L,i,&e):删除操作。删除表L中第i个位置的元素,并用e返回删除元素的值。
    • PrintList(L):输出操作。按前后顺序输出线性表L的所有元素值。
    • Empty(L):判空操作。若K为空表,则返回TRUE,否则返回FALSE
    • Length(L):求表长。返回线性表L的长度,即L中数据元素的个数。

2.2 线性表的顺序表示

2.2.1 顺序表的定义

  1. 顺序表的定义:

    • 线性表的顺序存储又称顺序表。
    • 一组地址连续存放的存储单元依次存放线性表的元素,从而使得逻辑上相邻的两个元素在物理位置上也相邻。
  2. 顺序表的描述:

    • 数组静态分配:

      #define MaxSize 50
      typedef struct{
          ElemType data[MaxSize];
          int length;
      }SqList;
      
    • 数组动态分配:

      #define MaxSize 50
      typedef struct{
          ElemType *data;
          int length;
      }SqList;
      //动态分配语句
      //C		L.data=(Elemtype*)malloc(sizeof(ElemType)*InitSize);
      //C++	L.data=new ElemType[InitSize];
      

2.2.2 顺序表的基本操作

  1. 插入操作

    bool ListInsert(SqList &L,int i,ElemType e){
        //i对应顺序表的标号(1 - n),而非(0 - n-1)
     if(i<1||i>L.length+1)
            return false;
     if(L.length>=MaxSize)
            return false;
        for(int j=L.length;j>=i;j--)
            //插入前最后一个元素数组下标为n-1,插入后最后一个元素数组下标为n
            L.data[j]=L.data[j-1];
        L.data[i-1]=e;
        L.length++;
        return true;
    }
    /****时间复杂度****
    **最好:O(1)
    **平均:O(n)
    **最坏:O(n)
    ****************/
    
  2. 删除操作

    bool ListDelete(SqList &L,int i,ElemType &e){
        if(i<1||i>L.length)
            return false;
        e=L.data[i-1];
        for (int j=i;j<L.length;j++)
            L.data[j-1]=L.data[j];
        L.length--;
        return true;
    }
    /****时间复杂度****
    **最好:O(1)
    **平均:O(n)
    **最坏:O(n)
    ****************/
    
  3. 按值查找

    int LocateElem(SqList L,ElemType e){
        int i;
        for (int i=0;i<L.length;i++)
            if(L.data[i]==e)
                return i+1;
        return 0;
    }
    /****时间复杂度****
    **最好:O(1)
    **平均:O(n)
    **最坏:O(n)
    ****************/
    

2.3 线性表的链式表示

2.3.1 单链表的定义

  1. 单链表的定义:

    • 线性表的链式存储又称单链表。
    • 通过一组任意的存储单元来存储线性表中的数据元素。通过指针实现线性逻辑关系。
  2. 单链表的描述:

    typedef struct LNode{
        ElemType data;
        struct LNode *next;
    }LNode,*LinkList
    
  3. 使用技巧:

    • 1) head指针直接指向开始结点,head为NULL时,单链表为空
    • 2) 引入一个头结点,head->next为NULL时,单链表为空
      • 优点:链表的第一个位置和其他位置的操作统一,空表和非空表的操作统一。

2.3.2 单链表的基本操作

  1. 头插法建立单链表

    LinkList List_HeadInsert(LinkList &L){
        LNode *s;int x;
        //初始化头结点
        L=(LinkList)malloc(sizeof(LNode));
        L->next=NULL;
        scanf("d",&x)
        while(x!=9999){
            s=(LNode*)malloc(sizeof(LNode));
            s->data=x;
            //插入到头结点后面
            s->next=L->next;
            L->next=s;
            scanf("%d",&x);
        }
        return L;
    }
    /****时间复杂度(最坏):O(n)****/
    
  2. 尾插法建立单链表

    LinkList List_TailInsert(LinkList &L){
        int x;
        //初始化头结点
        L=(LinkList)malloc(sizeof(LNode));
        LNode *s;
        LNode *r=L;//尾结点指针
        scanf("%d",&x);
        while(x!=9999){
            s=(LNode*)malloc(sizeof(LNode));
            s->data=x;
            //插入到尾结点后面
            r->next=s;
            r=s;
            scanf("%d",&x);
        }
        r->next=NULL;//注意不能忘记。
        return L;
    }
    
  3. 按序号查找&按值查找

    LNode *GetElem(LinkList L,int i){
        //时间复杂度:O(n)
        int j=1;
        LNode *p=L->next;
        if(i==0)
            return L;
        if(i<1)
            return NULL;
        while(p&&j<i){
            p=p->next;
            j++;
        }
        return p;
    }
    LNode *LocateElem(LinkList L,ElemType e){
        LNode *p=L->next;
        while(p!=NULL&&P->data!=e)
            p=p->next;
        return p;
    }
    
  4. 插入节点

    /* 前插法 */
    p=GetElem(L,i-1)
    s->next=p->next;
    p->next=s;
    /* 后插法(第i个节点未知) */
    p=GetElem(L,i)
    s->next=p->next;
    p->next=s;
    /* 后插法(给定第i个节点*p) */
    s->next=p->next;
    p->next=s;
    
  5. 删除节点

    /* 删除节点未知 */
    p=GetElem(L,i-1);
    q=p->next;
    p->next=q->next;
    free(q);
    /* 删除给定节点*p */
    q=p->next;
    p->data=p->next->data;//数据前移
    p->next=q->next;
    free(q);
    
  6. 求表长

    //无头节点,多了一个判空操作
    int count=0;
    p=head;
    if(p==NULL){
        return count;
    }
    while(p->next!=NULL){
        count++;
        p=p->next;
    }
    //有头节点
    int count=-1;
    p=head;
    while(p->next!=NULL){
        count++;
        p=p->next;
    }
    

2.3.3 几种常用的链表

  1. 双链表

    • 双链表结点:prior,data,next
    typedef struct DNode{
        ElemType data;
        struct DNode *prior,*next;
    }DNode,*DinkList
    
    • 基本操作变化

      • 插入操作:

        前插法和后插法在O(1)时间内完成。

        在表头、表中插和在表尾插不同。

        //将*s结点插入到*p节点后面
        s->next=p->next;
        p->next->prior=s;
        s->prior=p;
        p->next=s;//该操作会失去第i+1节点的位置,应放在后面
        
      • 删除操作:

        //删除*q节点,*p为*q的前驱节点
        p->next=q->next;
        q->next->prior=p;
        free(q);
        //时间复杂度为O(1)
        //在表尾删除不太一样
        
  2. 循环链表

    • 循环单链表

      • 仅设尾指针操作效率会更高
      • 每个节点的插入和删除都一样
    • 循环双链表

      • 每个节点的插入和删除都一样
    • 判空操作:

      • 循环单链表:L->next==L;

      • 循环双链表:L->next==L;

        ​ L->prior==L;

  3. 静态链表

    • 将地址改成下标(游标),通过数组实现

      #define MaxSize 50
      typedef struct DNode{
          ElemType data;
          int next;
      }SLinkList[MaxSize];
      

2.4 顺序表 VS 链表

  1. 顺序表和链表的区别

    • 1)存取方式:

      • 顺序表可以实现顺序存取和随机存取(LOC(A)+(n-1)*sizeof(ElemType))。
      • 单链表只能实现顺序存取。
    • 2)逻辑结构和物理结构:

      • 顺序表上逻辑相邻物理上也相邻,通过相邻表示逻辑关系。
      • 单链表上逻辑相邻物理上不一定相邻,通过指针表示逻辑关系。
    • 3)基本操作:

      • 插入&删除:单链表为O(1)(节点指针已知)、O(n)(节点指针未知),但操作时只需修改指针。顺序表为O(n)且需要大量移动元素。
      • 查找:按值查找中单链表和顺序表(无序)都为O(n).按序查找中单链表为O(n),顺序表为O(1).
    • 4)内存空间:

      • 顺序存储:无论静态分配还是非静态都需要预先分配合适的内存空间。静态分配时预分配空间太大会造成浪费,太小会造成溢出;动态分配时虽不会溢出,但是扩充需要大量移动元素,操作效率低。
      • 链式存储:在需要时分配结点空间即可,高效方便,但指针要使用额外空间。
  2. 怎样选择线性表的存储结构

顺序表(稳定)单链表(动态)
存储规模难以估计Y
存储密度Y
效率按序号访问Y
频繁插入和删除Y
环境基于数组Y
基于指针Y
  1. 三个常用操作

    • 最值:最大值、最小值

      • 利用两个变量保存最值,然后遍历一遍顺序表或链表。
      • O(n)的复杂度
    • 逆置:线性表元素顺序逆置

      • 顺序表:用两个标记来标记头(i)和尾(j),交换对应元素,当i>=j时结束。O(n)

      • 单链表:用r指向尾结点,不进行更新,每次将头节点后面的节点插入到r后面。

        //L指向头节点
        p=L;
        r=L;
        while(r->next){
            r=r->next;
        }
        while(p->next!=r){
            //将第一个结点从头节点后面拿出去
            temp=p->next;
            p->next=temp->next;
            //将第一个结点插入到r后面
            temp->next=r->next;
            r->next=temp;
        }
        
    • 归并:归并有序线性表

      • 顺序表:

        //O(n),n为合并后数组大小
        int i=0,j=0,k=0;
        for(;i<L1_Size&&j<L2_Size;k++){
            if(L1[i]<L2[j])
                L[k]=L1[i++];
            else
                L[k]=L2[j++];
        }
        while(i<L1_Size)
            L[k++]=L1[i++];
        while(j<L2_Size)
            L[k++]=L2[j++];
        
      • 单链表:

        //L1,L2分别指向头节点,r指向新创建的头节点
        while(p->next!=NULL&&q->next!=NULL){
            if(p->next->data<q->next->data){
                r->next=p->next;
                p->next=p->next->next;
                r=r->next;
            }
            else{
                r->next=q->next;
                q->next=q->next->next;
                r=r->next;
            }
        }
        if(p->next!=NULL) r->next=p->next;
        if(q->next!=NULL) r->next=q->next;
        free(p);free(q);
        

第三章 栈和队列

3.1 栈

3.1.1 基本概念

  1. 栈的基本概念
    • 栈(Stack) 只允许在一端进行插入和删除操作的线性表。
    • 栈顶(top),栈底(bottom)
    • 后进先出(LIFO)
  2. 栈的基本操作:
    • InitStack(&S):初始化一个空栈S.
    • StackEmpty(S):判断一个栈是否为空,若栈为空则返回true,否则返回false
    • Push(&S,x):进栈,若栈S未满,则将x加入使之成为新栈顶。
    • Pop(&S,&x):出栈,若栈非空,则弹出栈顶元素,并用x返回。
    • GetTop(S,&x):读栈顶元素,若栈非空则用x返回栈顶元素。
    • ClearStack(&S):销毁栈,并释放S所占用的内存空间。

3.1.2 栈的存储结构

  1. 栈的顺序存储

    • 顺序栈:采用顺序存储的栈

      #define MaxSize 50
      typedef struct{
          ElemType data[MaxSize];
          int top;
      }SqStack;
      
    • 栈空条件:S.top == -1

    • 栈长:S.top+1

    • 栈满条件:S.top == MaxSize-1

    • 进栈:

      bool Push(SqStack &S,ElemType x){
          if(S.top == MaxSize-1)
              return false;
          S.data[++S.top] = x;
          return true;
      }
      
    • 出栈:

      bool Pop(SqStack &S,ElemType &x){
          if(S.top == -1)
              return false;
          x=S.data[S.top--];
          return true;
      }
      
    • 共享栈:将两个栈底设置在共享空间的两端,栈顶向空间中间延伸。

      • 判空:0号栈 top == -1,1号栈 top == MaxSize
      • 栈满:top1-top0 == 1
      • 优点:存取时间复杂度仍为O(1),但空间利用更加有效。
  2. 栈的链式存储

    • 链栈:采用链式存储的栈。表头结点为栈顶结点,表尾结点为栈底结点。

      typedef struct Linknode{
          ElemType data;
          struct Linknode *next;
      }*LiStack;
      //所有操作都在表头进行,与链表相似
      
    • 出栈和入栈类似于单链表在头部插入和删除节点。

3.1.3 栈的应用

  1. 输出序列问题

    • 连续输入和输出:

      • 输入序列:1,2,3,…,n

        栈的输出序列:n,…,3,2,1

        队列的输出序列:1,2,3,…,n

    • 非连续输入和输出:

      • 输入序列:1,2,3,4

        栈的输出序列:3124,3142不可以,1324,3214,4321可以

      • 输入序列:1,2,3,…,k,…,n

        特点:出栈序列中每一个元素后面所有比它小的元素组成一个递减序列。

        合法出栈序列的个数:f(n)=C(2n,n)/(n+1) C是组合数

        最后一个出栈元素为k的序列:

        1~k-1k+1~nk
        f(k-1)f(n-k)

        总共有f(k-1)*f(n-k)个序列

        f(n)=f(0)*f(n-1)+f(1)*f(n-2)+...+f(n-2)*f(1)+f(n-1)*f(0)f(0)=f(1)=1

  2. 括号匹配

    • [(A+B)*C]-[E-F]

    • 算法思想:

      1)初始一个空栈,顺序读入括号。

      2)若是右括号,则与栈顶元素进行匹配。

      -若匹配,则弹出栈顶元素并进行下一个元素;

      -若不匹配,则该序列不合法;

      3)若是左括号,则压入栈中。

      4)若全部元素遍历完毕,栈中非空则序列不合法。

  3. 表达式求值

    • [(A+B)*C]-[E-F]

    • 前缀表达式:+ AB

    • 中缀表达式:A + B

    • 后缀表达式:AB +

    • 中缀转后缀算法思想:

      数字直接加入后缀表达式

      运算符时:

      a.若为’(’,入栈;

      b.若为’)’,则依次把栈中的运算符加入后缀表达式,直到出现’(’,并从栈中删除’(’;

      c.若为’+’,’-’,’*’,’/’,

      -栈空,入栈;

      -栈顶元素为’(’,入栈;

      -高于栈顶元素优先级,入栈;

      -否则,一次弹出栈顶运算符,直到一个优先级比它低的运算符或’('为止;

      d.遍历完成,若栈非空依次弹出所有元素。

    • ((A+B)*C)-(E-F) 转为后缀表达式

      表达式后缀表达式
      ((**((**A+B)*C)-(E-F)
      ((+((A+B)*C)-(E-F)AB
      (**((A+B)***C)-(E-F)AB+
      (**((A+B)C)-(E-F)AB+C
      ((A+B)*C)-(E-F)AB+C*
      -(-*((A+B)C)-(E-F)AB+C*EF
      -*((A+B)C)-(E-F)AB+C*EF-
      遍历完成,弹出栈中元素AB+C*EF–
  4. 递归

    • 递归:若在一个函数,过程或数据结构的定义中又应用了它自身,则称它为递归定义的,简称递归。

    • 斐波那契数列:0,1,1,2,3,5,…
      fib(n)=fib(n-1)+fib(n-2), n>1 //递归表达式
      =1, n=1 //递归出口
      =0, n=0 //递归出口

    • int Fib(int n){
          if(n==0)
              return 0;//递归出口
          else if(n==1)
              return 1;//递归出口
          else
              return Fib(n-1)+Fib(n-2);
      }
      
    • 递归的精髓在于能否将原始问题转换为属性相同但规模较小的问题。

    • 递归产生的问题:

      • 在递归调用过程中,系统为每一层的返回点、局部变量、传入参数等开辟了递归工作栈来进行数据存储,递归次数过多容易造成栈溢出。
      • 通常情况下递归的效率并不高。(存在重复调用子过程的情况)
    • 将递归算法转换为非递归算法,往往需要借助栈来进行。

3.2 队列

3.2.1 基本概念

  1. 队列的基本概念
    • 队列(Queue) 只允许在表的一端进行插入,在表的另一端进行删除操作的线性表。
    • 先进先出(FIFO)
  2. 队列的基本操作
    • InitQueue(&Q):初始化队列,构造一个空队列Q。
    • QueueEmpty(Q):判队列空,若队列Q为空返回true,否则返回false.
    • EnQueue(&Q,x):入队,若队列Q未满,则将x加入使之成为新的队尾。
    • DeQueue(&Q,&x):出队,若队列Q非空,则删除队头元素,并用x返回。
    • GetHead(Q,&x):读队头元素,若队列Q非空则用x返回队头元素。
    • ClearQueue(&Q):销毁队列,并释放队列Q占用的内存空间。

3.2.2 队列的存储结构

  1. 队列的顺序存储

    • 顺序队采用顺序存储的队列。

    • 循环队列:把存储队列的顺序队列在逻辑上视为一个环。取余(%MaxSize)

    • #define MaxSize 50
      typedef struct{
          ElemType data[MaxSize];
          int front,rear;
      }SqQueue;
      
    • front指向队首元素,rear指向队尾元素的下一个位置。(或front指向队首元素的前一个位置,rear指向队尾元素。)

    • 初始时front == rear == 0

    • 队长:(Q.rear + MaxSize- Q.front)%MaxSize

    • 队满和队空存在冲突的解决方法:

      • 方法一:牺牲一个存储单元
        • 队空条件:front == rear
        • 队满条件Q.front == (Q.rear+1)%MaxSize
      • 方法二:增加一个变量代表元素的个数:
        • 队空条件:Q.size == 0
        • 队满条件:Q.size == MaxSize
      • 方法三:增加一个tag标识表示插入和删除
        • 队空是由删除操作(tag置0)引起,队满是由插入操作(tag置1)引起。
        • 队空条件:Q.front == Q.rear && tag == 0
        • 队满条件:Q.front == Q.rear && tag == 1
    • 入队

    • bool EnQueue(SqQueue &Q,ElemType x){
          if((Q.rear+1)%MaxSize == Q.front)
              return false;
          Q.data[Q.rear] = x;
          Q.rear = (Q.rear+1)%MaxSize;
          return true;
      }
      
    • 出队

    • bool DeQueue(SqQueue &Q,ElemType &x){
          if(Q.rear == Q.front)
              return false;
          x=Q.data[Q.front];
          Q.front = (Q.front+1)%MaxSize;
          return true;
      }
      
  2. 队列的链式存储

    • 链队:采用链式存储的队列。

    • typedef struct{
          ElemType data;
          struct LinkNode *next;
      }LinkNode;
      typedef struct{
          LinkNode *front,*rear;
      }LinkQueue;
      
    • 初始化:

    • void InitQueue(LinkQueue &Q){
          Q.front = (LinkNode*)malloc(sizeof(LinkNode));//头结点
          Q.rear = Q.front;
          Q.front->next = NULL;
      }
      
    • 判空:Q.front == Q.rear

    • 入队:尾插法

    • void EnQueue(LinkQueue &Q,ElemType x){
          LinkNode *s = (LinkNode *)malloc(sizeof(LinkNode));
          s->data = x;
          s->next = NULL;
          Q.rear->next = s;
          Q.rear = s;
      }
      
    • 出队:头部删除

    • bool DeQueue(LinkQueue &Q,ElemType &x){
          if(Q.front == Q.rear)
              return false;
          LinkNode *p = Q.front->next;
          x = p->data;
          Q.front->next = p->next;
          if(Q.rear == p)//当只有一个数据元素节点时,还要修改rear
              Q.rear=Q.front;
          free(p);
          return true;
      }
      

3.2.3 双端队列

  • 双端队列:允许两端都可以进行入队以及出队操作的队列。
  • 输出受限的双端队列:只能在一端出队。
  • 输入受限的双端队列:只能在一端入队。

3.2.4 队列的应用

  1. 树的层次遍历

3.3 数组

3.3.1 数组的定义

  1. 数组的基本概念(逻辑结构)

    • 数组:是由 n(n>=1) 个相同类型的数据元素构成的有限序列,每个数据元素称为一个数组元素,每个元素受 n 个线性关系的约束,每个元素在 n 个线性关系中的序号称为该元素的下标,并称该数组为 n 维数组。
    • 数组是线性表的推广。
  2. 数组的特点:

    • 数组是有维度的:一维,二维;
    • 数组的维度和维界不可变。
    • 数组一旦被定义,其维度和维界不可变,数组除初始化和销毁外,只有存取元素和修改元素的操作。

3.3.2 数组的存储结构

  • 一维数组顺序存储:LOC(ai) = LOC(a0) + (i)*L (0<=i<n)
  • 二维数组:
    • 按行优先:LOC(ai,j) = LOC(a0,0) + i * (n * L) + j * L
    • 按列优先:LOC(ai,j) = LOC(a0,0) + j * (n * L) + i * L

3.3.3 矩阵的压缩存储

  • 压缩存储:指多个值相同的元素只分配一个存储空间,对零元素不分配存储空间。
  • 特殊矩阵:指具有许多相同矩阵元素或零元素,并且这些相同矩阵元素或零元素的分布有一定规律性的矩阵。
  • 特殊矩阵的压缩存储:找出特殊矩阵中值相同的矩阵元素的分布规律,把那些呈现规律性分布、值相同的多个矩阵元素压缩存储到一个存储空间上。
  • 矩阵区域分布:
    • 下三角区:i>j
    • 上三角区:i<j
    • 主对角线:i=j
  • 对称矩阵:若对一个n阶方阵A[1...n][1...n]中的任意元素ai,j,都有ai,j=aj,i(i<=i,j<=n),则称其为对称矩阵。
    • 只需要存下三角区和主对角线。
    • 存放数组B[n(n+1)/2]
    • 矩阵中元素:A[i][j]数组下标:k=1+2+…+(i-1)+j-1+1-1
    • k= i(i-1)/2+j-1 i>=j
      j(j-1)/2+i-1 i<j
  • 三角矩阵:若对一个n阶方阵A[1...n][1...n]中上(下)三角区元素均为同一常量,则称为下(上)三角矩阵。
    • 存放数组B[n(n+1)/2+1]
    • 下三角矩阵:
      k= i(i-1)/2+j-1 i>=j
      n(n+1)/2 i<j 常量存放在最后一个存储单元
    • 上三角矩阵:
      k= (i-1)(2n-i+2)/2+(j-i) i<=j
      n(n+1)/2 i>j
  • 三对角矩阵:若对一个n阶方阵A中的任意元素ai,j,当|i - j|>1,有ai,j=0(i<=i,j<=n),则成为三对角矩阵。
    • 按行优先存储:
    • 数组下标:k = 3*(i-1)-1 + j - i + 1 + 1 - 1 = 2i + j - 3
    • 若k已知,i = (k+1)/3+1,j=k-2i+3
  • 稀疏矩阵:矩阵元素个数 s 相对于矩阵中非零元素的个数 t 来说非常多,即 s>>t 的矩阵称为稀疏矩阵。
    • 三元组(行标i,列标j,值value)
    • 稀疏矩阵压缩存储后失去了随机存储的特性。

第四章 串

4.1 串的基本概念

  1. 串的基本概念
    • 串,即字符串(String)是由零个或多个字符组成的有限序列。一般记为S = ‘a1a2a3 … an’(n>=0)
      其中,S是串名,单引号括起来的字符序列是串的值;ai可以是字母、数字或其他字符;串中字符的个数n称为串的长度。n=0时的串称为空串(用Φ表示)
    • 子串:串的任意个连续的字符组成的子序列。
    • 主串:包含子串的串。
    • 字符在主串中的位置:字符在串中的序号。
    • 子串在主串中的位置:子串的第一个字符在主串中的位置。
    • 'iphone’是’iphone 11pro’的子串。
      'iphone 11pro’是’iphone’的主串。
      ’1“在’iphone 11pro’中的位置是8(第一次出现)
    • 空串’’,空格串’ ’
    • 串 V.S 线性表
      • 串是一种特殊的线性表,数据元素之间呈线性关系。
      • 串的数据对象限定为字符集(如中文字符、英文字符、数字字符、标点字符等)
      • 串的基本操作,如增删改查等通常以子串为操作对象。
  2. 串的基本操作
    • StrAssign(&T,chars):赋值操作。把串T赋值为chars。
    • StrCopy(&T,S):复制操作。由串S复制得到串T。
    • StrEmpty(S):判空操作。
    • StrLength(S):求串长。
    • ClearString(&S):清空操作。将S清为空串。
    • DestroyString(&S):销毁串。将串S销毁(回收存储空间)
    • Concat(&T,S1,S2):串联接。用T返回由S1和S2联接而成的新串。存储空间扩展。
    • SubString(&Sub,S,pos,len):求子串。用Sub返回串S的第pos个字符起长度为len的子串。
    • Index(S,T):定位操作。若主串S中存在与串T值相同的子串,则返回它在主串S中第一次出现的位置;否则返回函数值为0。
    • StrCompare(S,T):比较操作。若S>T,则返回值>0;若S=T,则返回值=0;若S<T,则返回值<0。
  3. 字符集编码
    • 字符集:
      • 英文字符——ASCII字符集
      • 中英文——Unicode字符集
        • UTF-8,UTF-16
        • 不同编码方案每个字符所占空间不同

4.2 串的存储结构

  1. 顺序存储

    • #define MAXLEN 255//预定义最大串长为255
      //静态数组实现(定长顺序存储)
      typedef struct{
          char ch[MAXLEN];//每个分量存储一个字符
          int length;//串的实际长度
      }SString;
      //动态数组实现(堆分配存储
      typedef struct{
          char *ch;
          int length;
      }HString;
      HString S;
      S.ch=(char*)malloc(MAXLEN*sizeof(char));//需要 手动free
      S.length=0;
      
  2. 链式存储

    • typedef struct StringNode{
          char ch;//每个结点的存一个字符
          struct StringNode *next;
      }StringNode,*String;
      //存储密度低,每个字符(实际有用信息)1B,每个指针(辅助信息)4B
      
    • //改进一下
      typedef struct StringNode{
          char ch[4];//每个节点存多个字符
          struct StringNode *next;
      }StringNode,*String;
      
  3. 基于顺序存储实现基本操作

    • 比较操作

    • int StrCompare(SString S,SString T){
          for(int i=1;i<=S.length&&i<=T.length;i++){
              if(S.ch[i]!=T.ch[i])
                  return S.ch[i]-T.ch[i];
          }
          return S.length-T.length;
      }
      
    • 定位操作

      • Index(S,T)
      • 从头到尾依次取子串进行比较。

4.3 模式匹配

4.3.1 串的朴素模式匹配算法

  1. 什么是模式匹配

    • 在主串中找到与模式串相同的子串,并返回其所在位置。

    • int Index(SString S,SString T){
          int k=1;//k指从S串的第k个位置开始匹配
          int i=k,j=1;
          while(i<=S.length&&j<=T.length){
              if(S.ch[i]==T.ch[j]){
                  ++i;++j;
              }else{
                  k++;//检查下一个子串
                  i=k;j=1;
              }
          }
          if(j>T.length){
              return k;
          }else{
              return 0;
          }
      }
      
    • 性能分析:

      • 若模式串长度为m,主串长度为n。则
        • 匹配成功的最好时间复杂度:o(m)
        • 匹配失败的最好时间复杂度:o(n-m+1)= o(n-1m)=o(n)(长度为n的主串有n-m+1个长度为m的子串)
        • 若模式串长度为m,主串长度为n,则直到匹配成功/匹配失败最多需要(n-m+1)*m次比较。O(nm)
    • 朴素模式匹配算法的缺点:当某些子串与模式串能部分匹配时,主串的扫描指针i经常回溯,导致时间开销增加

4.3.2 KMP算法

  1. 改进思路:

    主串指针不回溯,只有模式串指针回溯

  2. 算法实现

    • int Index_KMP(SString S,SString T,int next[]){
          int i=1,j=1;
          while(i<=S.length&&j<=T.length){
              if(j==0||S.ch[i]==T.ch[j]){
                  ++i;++j;
              }
              else{
                  j=next[j];//模式串向右移动
              }
          }
          if(j>T.length)
              return i-T.length;
          else
              return 0;
      }
      void get_next(SString T,int next[],int nextval[]){
        int i=1,j=0;
          next[1]=0;
          while(i<T.length){
              if(j==0||T.ch[i]==T.ch[j]){
                  ++i;++j;
                  //若pi==pj,则next[j+1]==next[j]+1
                  next[i]=j;
              }
              else{
                  //否则令j=next[j],循环继续
                  j=next[j];
              }
          }
          //nextval数组求法:
          nextval[1]=0;
          for(int j=2;j<+T.length;j++){
              if(T.ch[next[j]]==T.ch[j])
                  nextval[j]=nextval[next[j]];
              else
                  nextval[j]=next[j];
          }
      }
      
    • 串的前缀:包含第一个字符,且不包含最后一个字符的子串;

    • 串的后缀:包含最后一个字符,且不包含第一个字符的子串

    • KMP算法:当子串和模式串不匹配时,主串指针i不回溯,模式串指针 j=next[j]算法平均时间复杂度:O(n+m)

    • next数组手算方法:当第j个字符匹配失败,由前1~j-1个字符组成的串记为s,则:next[j]= S的最长相等前后缀长度+1

    • 当第一个字符就匹配失败时,如果依然写成0+1,j依然是1,也依然是1,进入死循环,所以要设置next[1]=0,也可以得出next[2]=1.

    • 例:模式串:ababaa

    • 序号j123456
      模式串ababaa
      aababaababababa
      next[j]011234
      nextval[j]010104
    • next[j]=

      • 0 当j=1时
      • Max{k|1<k<j且p1…pk-1 = pj-K+1…pj-1} 当此集合不空时
      • 1 其他情况
  3. 算法性能分析

    • get_next(T,next):O(m)
    • KMP算法中的循环:O(n)
    • 整个为:O(n+m)

第五章 树与二叉树

5.1 树和森林

5.1.1 树的基本概念

  1. 树的定义

    **树(逻辑结构)**是n(n≥0)个结点的有限集合,n=0时,称为空树。

    ​ 而任意非空树应满足:

    ​ 1)有且仅有一个特定的称为根的结点。

    ​ 2)当n>1时,其余节点可分为m(m>0)个互不相交的有限集合,其中每一个集合本身又是一棵树,成为根节点的子树。

    n个结点的树有n-1条边。

  2. 基本术语:

    祖先结点和子孙结点

    双亲结点和孩子结点

    兄弟结点

    树中一个结点的子结点的个数称为该结点的

    树中最大度数称为树的度

    度大于0的结点称为分支节点

    度为0的结点称为叶子结点

    结点的层次:根节点为第一层。

    结点的高度:从最低的叶子节点到根经历的层数(包括叶子结点所在层)

    结点的深度:从根节点开始到对应结点经历层数(包括根节点所在层)

    **树的高度(深度)**是树中结点的最大层数

    有序树与无序树:子结点是否存在先后顺序

    路径:树中两个结点之间的路径是由这两个结点之间所经过的结点序列构成的。

    树中的分支是有向的,即从双亲结点指向孩子结点,所以路径一定是自上而下的。

    路径长度:路径上所经历边的个数。

    森林:m (m≥0)棵互不相交的树的集合

  3. 树的性质

    1. 树中的结点数等于所有结点的度数加1

    2. 度为m的树中第i层上至多有mi-1个结点( i≥1)

    3. 高度为h的m叉树至多有(mh -1)/(m-1)=m0+m1+mh+…+mh-1个结点

    4. 具有n个结点的m叉树的最小高度为[logm(n(m -1)+1)],[]是取上界

5.1.2 树的存储结构

  1. 双亲表示法

采用一组连续的存储空间来存储每个结点,同时在每个节点中增设一个伪指针,指示双亲结点在数组中的位置。根结点的下标为0,其伪指针域为-1。

#define MAX_TREE_SIZE 100
typedef struct{
    ElemType data;
    int parent;
}PTNode;
typedef struct{
	PTNode nodes[MAX_TREE_SIZE];
    int n;
}PTree;
  1. 孩子表示法

    将每个结点的孩子结点都用单链表连接起来形成一个线性结构,n个结点具有n个孩子链表。
    在这里插入图片描述

    #define MAX_TREE_SIZE 100
    typedef struct{
        int child;
        struct CNode *next;
    }CNode;
    typedef struct{
        ElemType data;
        struct CNode *child;
    }PNode;
    typedef struct{
    	PNode nodes[MAX_TREE_SIZE];
        int n;
    }CTree;
    
  2. 孩子兄弟(左孩子右兄弟)表示法

    以二叉链表作为树的存储结构,又称二叉树表示法。

    结点第一个孩子结点指针

    结点值

    结点下一个兄弟结点指针

    在这里插入图片描述

    typedef struct CSNode{
        ElemType data;
        struct CSNode *firstchild,*nextsibling;
    }CSNode,CSTree;
    
  3. 对比

    优点缺点
    双亲表示法寻找结点的双亲结点效率高寻找结点的孩子结点效率低
    孩子表示法寻找结点的孩子结点效率高寻找结点的双亲结点效率低
    孩子兄弟表示法寻找结点的孩子结点效率高
    方便实现树转换为二叉树
    寻找结点的双亲结点效率低

5.1.3 树和森林及二叉树的转换

  1. 树与二叉树的转换(左孩子右兄弟)

    规则:每个结点左指针指向它的第一个孩子结点,右指针指向它在树中相邻兄弟结点。
    在这里插入图片描述

  2. 森林与二叉树的转换

    规则:将每一棵树转换为二叉树,将每棵二叉树的根依次作为上一棵二叉树的右子树
    在这里插入图片描述

5.1.4 树和森林的遍历

  1. 树的遍历
    按照某种方式访问树中的每个结点,且仅访问一次先根遍历

    先根遍历:若树非空,则先访问根结点,再按从左到右的顺序遍历根结点的每棵子树。

    • 树的先根遍历序列与这棵树对应二叉树的先序遍历序列相同

    后根遍历:若树非空,则先按从左到右的顺序遍历根结点的每棵子树,再访问根结点。

    • 树的后根遍历序列与这棵树对应二叉树的中序遍历序列相同

    层次遍历

  2. 森林的遍历

    先序遍历

    若森林非空,则,

    ​ 访问森林中第一棵树的根结点

    ​ 先序遍历第一棵树的子树森林

    ​ 先序遍历除去第一棵树之后剩余的树构成的子树森林.

    森林的先序遍历序列与森林对应二叉树的先序遍历序列相同

    中序遍历

    若森林非空,则,

    ​ 中序遍历第一棵树的根结点的子树森林

    ​ 访问第一棵树的根结点

    ​ 中序遍历除去第一棵树之后剩余的树构成的子树森林

    森林的中序遍历序列与森林对应二叉树的中序遍历序列相同

  3. 遍历序列的对应关系

    森林二叉树
    先根遍历先序遍历先序遍历
    后根遍历中序遍历中序遍历

5.1.5 树的应用–并查集

  1. 并查集一种简单的集合表示。

    通常用树的双亲表示法作为并查集的存储结构。

    通常用数组元素的下标代表元素名,用根结点的下标代表子集合名,根结点的双亲结点为负数。

  2. 基本操作

    • lnitial(S)
      将集合S中的每个元素都初始化为只有一个单元素的子集合。
    • Union(S,Root1,Root2)
      把集合S中的子集合(互不相交)Root2并入子集合Root1。
    • Find(S, x)
      查找集合S中单元素x所在子集合,并返回该子集合的名字。
  3. 示例

    • 初始化

      在这里插入图片描述

    • 三个集合

      在这里插入图片描述

  4. 实现

    #define SIZE 100
    int UFSets[SIZE];
    void Initial(int S[]){
        for(int i=0;i<size;i++)
            s[i]=-1;
    }
    int Fine(int S[],int x){
        while(S[x]>=0)
            x=S[x];
        return x;
    }
    void Union(int S[],int Root1,int Root2){
        S[Root1]+=S[Root2];
        S[Root2]=Root1;
    }
    

5.2 二叉树

5.2.1 二叉树的基本概念

  1. 二叉树的定义

    二叉树是n (n≥0)个结点的有限集合。

    ​ 1)n=0时,二叉树为空;
    ​ 2)n>0时,由根结点和两个互不相交的被称为根的左子树和右子树组成。左子树和右子树也分别是一棵二叉树。

  2. 二叉树 VS 度为2有序树
    1)二叉树可以为空,而度为2的有序树至少有三个结点。
    2)二叉树的孩子结点始终有左右之分,而度为2有序树的孩子结点次序是相对的。

  3. 特殊二叉树

    1)满二叉树一棵高度为h,且含有2h-1个结点的二叉树为满二叉树。对于编号为i的结点,若存在,其双亲的编号为[i/2]取下界,左孩子为2i,右孩子为2i+1。

    2)完全二叉树:设一个高度为h、有n个结点的二叉树,当且仅当其每个结点都与高度为h的满二叉树中编号1~n的结点——对应时,称为完全二叉树。

    • 若i≤[n/2]取下界,则结点i为分支结点,否则为叶子结点。
    • 叶子结点只可能在层次最大的两层上出现。对于最大层次的叶子结点,都依次排在最左边的位置上。
    • 度为1的结点若存在,则可能有一个,且是编号最大的分支节点,,并且孩子结点一定是左结点。

    3)二叉排序树:—棵二叉树,若树非空则具有如下性质:
    对任意结点若存在左子树或右子树,则其左子树上所有结点的关键字均小于该结点,右子树上所有结点的关键字均大于该结点。

    4)平衡二叉树:树上任意结点的左子树和右子树的深度只差不超过1。

  4. 二叉树的性质

    1)非空二叉树上的叶子结点数等于度为2的结点数加1,即n0= n2+1

    ​ ni表示所有度为i的结点。n=n0+n1+n2,n=n1+2n2+1

    2)非空二叉树上第k层上至多有2k-1个结点(k≥1)

    3)高度为h的二叉树至多有2h-1个结点(h≥1)

    4)结点i所在层次为[log2i]取下界+1。

    5)具有n个(n>0)结点的完全二叉树的高度为[log 2n]取下界+1或[log 2(n+1)]取上界

5.2.2 二叉树的存储结构

  1. 二叉树的顺序存储

    用一组连续的存储单元依次自上而下、自左至右存储完全二叉树上的结点元素。

在完全二叉树中依次编号,对于结点i:

若存在左孩子,则编号为2i;若存在右孩子,则编号为2i+1。

  • 顺序存储最坏情况下会非常浪费存储空间,比较适合完全二叉树
  1. 二叉树的链式存储

    用链表来存放一棵二叉树,二叉树中每个结点用链表的一个链结点来存储。

    typedef struct BiTNode{
        ElemType data;
        struct BiTNode *lchild,*rchild;
    }BiTNode,*BiTree;
    

    含有n个结点的二叉链表中,有n+1个空链域=2n-(n-1)

5.2.3 二叉树的遍历

  1. 二叉树的遍历

按某条搜索路径访问树中的每个结点,树的每个结点均被访问一次,而且只访问一次。

左子树 根 右子树

先序遍历 中序遍历 后序遍历

  1. 先序遍历:O(n)

    若二叉树非空:

    1)访问根结点

    2)先序遍历左子树

    3)先序遍历右子树

    void PreOrder(BiTree T){
        if(T!=NULL){
            visit(T);
            PreOrder(T->lchild);
            PreOrder(T->rchild);
        }
    }
    
  2. 中序遍历:O(n)

    若二叉树非空:

    1)中序遍历左子树

    2)访问根结点

    3)中序遍历右子树

    void InOrder(BiTree T){
        if(T!=NULL){
            InOrder(T->lchild);
            visit(T);
            InOrder(T->rchild);
        }
    }
    
  3. 后序遍历:O(n)

    若二叉树非空:

    1)后序遍历左子树

    2)后序遍历右子树

    3)访问根结点

    void PostOrder(BiTree T){
        if(T!=NULL){
            PostOrder(T->lchild);
            PostOrder(T->rchild);
            visit(T);
        }
    }
    
  4. 中序遍历非递归算法:

    借助栈算法思想:

    1)初始时依次扫描根结点的所有左侧结点并将它们——进栈;

    2)出栈一个结点,访问它;

    3)扫描该结点的右孩子结点并将其进栈;

    4)依次扫描右孩子结点的所有左侧结点并——进栈;

    5)反复该过程直到栈空为止。

    void InOrder2(BiTree T){
        InistStack(S);
        BiTree p=T;
        while(p||IsEmpty(S)){
            if(p){
                Push(S,p);
                p=p->lchild;
            }
            else{
                Pop(S,p);
                visit(p);
                p=p->rchild;
            }
        }
        
    }
    
  5. 层次遍历:

    借助队列算法思想:

    1)初始将根入队并访问根结点;

    2)若有左子树,则将左子树的根入队;

    3)若有右子树,则将右子树的根入队;

    4)然后出队,访问该结点;

    5)反复该过程直到队列空为止。

    void levelOrder(BiTree T){
        InitQueue(Q);
        Bitree p;
        EnQueue(Q,T);
        while(!isEmpty(Q)){
            DeQueue(Q,p);
            visit(p);
            if(p->lchild!=NULL)
                EnQueue(Q,p->lchild);
            if(p->rchild!=NULL)
                EnQueue(Q,p->rchild);
        }
    }
    
  6. 由遍历序列构造二叉树

    ★(后)先序遍历序列和中序遍历序列可以确定一棵二叉树,而后序遍历序列和先序遍历序列不可以确定一棵二叉树。

    • 中序遍历序列和先序遍历序列

      1)在先序序列中,第一个节点是根结点;

      2)根结点将中序遍历序列划分为两部分;

      3)然后在先序序列中确定两部分的结点,并且两部分的第一个结点分别为左子树的根和右子树的根;

      4)在子树中递归重复该过程,便能唯一确定一棵二叉树。

5.2.4 线索二叉树

  1. 线索化

若无左子树,则将左指针指向其前驱结点;若无右子树,则将右指针指向其后继结点。

  • 先序线索化(先序线索二叉树)

    在这里插入图片描述

  • 中序线索化(中序线索二叉树)

    在这里插入图片描述

  • 后序线索化(后序线索二叉树)

    在这里插入图片描述

  1. 线索二叉树结点结构

    ltaglchilddatarchildrtag

    标志域l®tag: 0,l®chlid域指示结点的左(右)孩子

    ​ 1,l®child域指示结点的前(后)继

    typedef struct ThreadNode{
        ElemType data;
        struct ThreadNode *lchild,*rchild;
        int ltag,rtag;
    }ThreadNode,*ThreadTree;
    

    这种结点结构构成的二叉链表作为二叉树的存储结构,称为线索链表

  2. 中序线索二叉树

    前驱结点

    若左指针为线索,则其指向结点为前驱结点

    若左指针为左孩子,则其左子树的最右侧结点为前驱结点

    后驱结点

    若右指针为线索,则其指向结点为后驱结点

    若右指针为右孩子,则其右子树的最左侧结点为后驱结点

    void InThread(ThreadTree &p,ThreadTree &pre){
        if(p!=NULL){
            InThread(p->lchild,pre);
            if(p->lchild==NULL){
                p->lchild=pre;
                p->ltag=1;
            }
            if(pre!=NULL && pre->rchild==NULL){
                pre->rchild=p;
                pre->rtag=1;
            }
            pre=p;
            InThread(p->rchild,pre);
        }
    }
    void CreateThread(ThreadTree T){
        ThreadTree pre=NULL;
        if(T!=NULL){
            InThread(T,pre);
            pre->rchild=NULL;
            pre->rtag=1;
        }
    }
    

    存在两个空指针:

    在这里插入图片描述

    增加一个头结点:

    在这里插入图片描述

  3. 中序线索二叉树的遍历:

    ThreadNode *Firstnode(ThreadNode *p){
        while(p->ltag==0)
            p=p->lchild;
        return p;
    }
    ThreadNode *Nextnode(ThreadNode *p){
        if(p->rtag==0)
            return Firstnode(p->rchild);
        else
            return p->rchild;
    }
    void Inorder(ThreadNode *T){
        for(ThreadNode *p=Firstnode(T);p!=NULL;p=Nextnode(p))
            visit(p);
    }
    

5.2.5 二叉排序树

  1. 二叉排序树BST,也称二叉查找树。

    二叉排序树或者为空树,或者为非空树,当为非空树时有如下特点:

    1)若左子树非空,则左子树上所有结点关键字值均小于根结点的关键字。

    2)若右子树非空,则右子树上所有结点关键字值均大于根结点的关键字。

    3)左、右子树本身也分别是一棵二叉排序树。

    左子树结点值<根结点值<右子树结点值

  2. 查找

    二叉树非空时,查找根结点,若相等则查找成功;

    若不等,则当小于根结点值时,查找左子树;当大于根结点的值时,查找右子树。当查找到叶节点仍没查找到相应的值,则查找失败。

    BSTNode *BST_Search(BiTree T,ElemType key,BSTNode *&p){
        p=NULL;//保存查找结点的双亲结点
        while(T!=NULL&&key!=T->data){
            p==T;
            if(key<T->data)
                T=T->lchild;
            else
                T=T->rchild;
        }
        return T;
    }
    
  3. 插入

    若二叉排序树为空,则直接插入结点;

    若二叉排序树非空,当值小于根结点时,插入左子树;当值大于根结点时,插入右子树;当值等于根结点时不进行插入。

    int BST_Insert(BiTree &T,KeyType k){
        if(T==NULL){
            T=(BiTree)malloc(sizeof(BSTNode));
            T->key=k;
            T->lchild=T->rchild=NULL;
            return 1;
        }
        else if(k==T->key)
            return 0;
        else if(k<T->key)
            return BST_Insert(T->lchild,k);
        else
            return BST_Insert(T->rchild,k);
    }
    
  4. 构造二叉排序树

    读入一个元素并建立结点,若二叉树为空将其作为根结点;

    若二叉排序树非空,当值小于根结点时,插入左子树;当值大于根结点时,插入右子树;当值等于根结点时不进行插入。

    void Create_BST(BiTree &T,KeyType str[],int n){
        T=NULL;
        int i=0;
        while(i<n){
            BST_Insert(T,str[i]);
            i++;
        }
    }
    
  5. 删除

    1)若被删除结点z是叶结点,则直接删除;

    2)若被删除结点z只有一棵子树,则让z的子树成为z父结点的子树,代替z结点。

    3)若被删除结点z有两棵子树,则让z的中序序列直接后继代替z,并删去直接后继结点(要么没有子结点,直接删除;要么只有一个子结点,按第二种情况删除)。

  6. 查找效率

    平均查找长度(ASL)取决于树的高度。

    如果是完全二叉树,则为O(log2n);最坏情况为O(n)。

5.2.6 平衡二叉树

  1. 平衡二叉树AVL,任意结点的平衡因子(左子树高度-右子树高度)的绝对值不超过一。

    高度为h的最小平衡二叉树的结点数Nh
    Nh=Nh-1+Nh-2+1

    N0=0
    N1=1

  2. 平衡二叉树的判断

    利用递归的后序遍历过程:

    1)判断左子树是一棵平衡二叉树

    2)判断右子树是一棵平衡二叉树

    3)判断以该结点为根的二叉树为平衡二叉树

    • 判断条件
      若左子树和右子树均为平衡二叉树
      且左子树与右子树高度差的绝对值小于等于1,则平衡。
    void Judge_AVL(BiTreebt,int &balance,int &h){
        //balance表示平衡性,h表示高度
        int bl=0,br=0,hl=0,hr=0;
        if(bt==NULL){
            h=0;
            balance=1;
        }
        else if(bt->lchild==NULL&&bt->rchild==NULL){
            h=1;
            balance=1;
        }
        else{
            Judge_AVL(bt->lchild,bl,hl);
            Judge_AVL(bt->rchild,br,hr);
            if(hl>hr)
                h=hl+1;
            else
                h=hr+1;
            if(abs(hl-hr)<2&&bl==1&&br==1)
                balance=1;
            else
                balance=0;
        }
    }
    
  3. 平衡二叉树的插入

    先插入再调整

    每次调整最小不平衡子树。

    • LL平衡旋转(右单旋转)

      原因:在结点A的左孩子的左子树上插入了新结点

      调整方法:右旋操作:将A的左孩子B代替A,将A结点作为B的右子树根结点,而B的原右子树则作为A的左子树。

      在这里插入图片描述

    • RR平衡旋转(左单旋转)

      原因:在结点A的右孩子的右子树上插入了新结点

      调整方法:左旋操作:将A的右孩子B代替A,将A结点作为B的左子树根结点,而B的原左子树则作为A的右子树。

      在这里插入图片描述

    • LR平衡旋转(先左后右双旋转)

      原因:在结点A的左孩子的右子树上插入了新结点

      调整方法:先左旋后右旋操作:将A的左孩子B的右孩子结点C代替B,然后再将C结点向上代替A的位置。

      在这里插入图片描述

    • RL平衡旋转(先右后左双旋转)

      原因:在结点A的右孩子的左子树上插入了新结点

      调整方法:先右旋后左旋操作:将A的右孩子B的左孩子结点C代替B,然后再将C结点向上代替A的位置。

      在这里插入图片描述

5.2.7 哈夫曼树及哈夫曼编码

  1. 基本概念

    • 带权路径长度:

      路径长度:路径上所经历边的个数。

      结点的权:结点被赋予的数值。

    • 树的带权路径长度WPL:

      树中所有叶节点的带权路径长度之和,记为,WPL=∑wili

    • 哈夫曼树也称最优二叉树,含有n个带权叶子结点带权路径长度最小的二叉树

  2. 哈夫曼树的构造算法

    1)将n个结点作为n棵仅含有一个根结点的二叉树,构成森林F;

    2)生成一个新结点,并从F中找出根结点权值最小的两棵树作为它的左右子树,且新结点的权值为两棵子树根结点的权值之和;

    3)从F中删除这两个树,并将新生成的树加入到F中;

    4)重复2,3步骤,直到F中只有一棵树为止。

  3. 哈夫曼树的性质

    1)每个初始结点都会成为叶节点,双支结点都为新生成的结点

    2)权值越大离根结点越近,反之权值越小离根结点越远

    3)哈夫曼树中没有结点的度为1

    4)n个叶子结点的哈夫曼树的结点总数为2n-1,其中度为2的结点数为n-1。

    5)哈夫曼树并不唯一,所以每个字符对应的哈夫曼编码也不唯一,但带权路径长度相同且最优。

  4. 二叉树的应用——编码

    编码:对于一个字符串序列,用二进制来表示字符。

    1)固定长度编码&可变长度编码

    在这里插入图片描述

    2)前缀编码没有一个编码是另一个编码的前缀

    在这里插入图片描述

    3)根据字母出现次数得到哈夫曼树然后进行编码:

    在这里插入图片描述

第六章 图

6.1 图的基本概念

  1. 图的定义:

    G由顶点集V和边集E组成,记为G=(V,E),

    其中V(G)表示图G中顶点的有限非空集;E(G)表示图G中顶点之间的关系(边)集合。

    |V|表示图G中顶点的个数,也称图G的阶;|E|表示图G中边的条数

  2. 无向图&有向图

    无向边:无序对(v, w) = (w,v),v,w互为邻接点

    有向边:有序对<v, w>,v邻接到w,或w邻接自v

  3. 简单图&多重图

    简单图:无重复边,不存在结点到自身的边

    多重图:存在重复边,或存在结点到自身的边

  4. 完全图

    无向完全图:任意两个顶点之间都存在边,n个顶点有n(n-1)/2边

    有向完全图:任意两个顶点之间都存在方向相反的弧,n个顶点有n(n-1)边

  5. 子图

    设有两个图G=(V,E)和G’=(V,E’),若V是V的子集,且E’是E的子集,则称G’为G的子图

    且若V(G)= V(G’)则称G’为G的生成子图

  6. 连通

    无向图有向图
    连通
    若从顶点v到顶点w有路径存在,则称v和w是连通。
    强连通
    若从顶点v到顶点w和顶点w到顶点v都有路径存在,则称v和w是强连通。
    连通图:任意两个结点之间都是连通的
    最少有n-1条边。
    强连通图:任意两个结点之间都是强连通的
    最少有n条边,构成一个环状结构
    连通分量
    极大连通子图
    强连通分量
    极大强连通子图
    对于G的一个连通子图G’,如果不存在G的另一个连通子图G",使得G’⊆G”,则称G’为G的连通分量。对于G的一个强连通子图G’,如果不存在G的另一个强连通子图G",使得G’⊆G”,则称G’为G的(强)连通分量。
    1.连通图只有一个极大连通子图,就是它本身。(是唯一的)
    2.非连通图有多个极大连通子图。(非连通图的极大连通子图叫做连通分量,每个分量都是一个连通图)
    3.称为极大是因为如果此时加入任何一个不在图的点集中的点都会导致它不再连通。
    1.强连通图的极大强连通子图为其本身。(是唯一的)
    2.非强连通图有多个极大强连通子图。(非强连通图的极大强连通子图叫做强连通分量)
    极小连通子图无这个概念
  7. 生成树、生成森林

    • 生成树

      对连通图进行遍历,过程中所经过的边和顶点的组合可看做是一棵普通树。

      连通图中的生成树必须满足以下 2 个条件:

      1)包含连通图中所有的顶点;

      2)任意两顶点之间有且仅有一条通路;

      因此,连通图的生成树具有这样的特征,即生成树中边的数量 = 顶点数 - 1

    • 生成森林

      生成树是对应连通图来说,而生成森林是对应非连通图来说的。非连通图可分解为多个连通分量,而每个连通分量又各自对应多个生成树(至少是 1 棵),因此与整个非连通图相对应的,是由多棵生成树组成的生成森林。

    • 极小连通子图

      1)一个连通图的生成树是该连通图顶点集确定的极小连通子图。(同一个连通图可以有不同的生成树,所以生成树不是唯一的)

      (极小连通子图只存在于连通图中)

      2)用边把极小连通子图中所有节点给连起来,若有n个节点,则有n-1条边。

      3)之所以称为极小是因为此时如果删除一条边,就无法构成生成树,也就是说给极小连通子图的每个边都是不可少的。

      4)如果在生成树上添加一条边,一定会构成一个环。
      也就是说只要能连通图的所有顶点而又不产生回路的任何子图都是它的生成树

  8. 顶点的度:以该顶点为一个端点的边的数目

    无向图:顶点v的为以v为端点的边的个数,记为TD(v)。
    n顶点,e条边的无向图中度的总数为2e

    有向图:顶点v的为出度入度之和,TD(v) = OD(v)+ID(v)

    出度:以v为起点的有向边的条数,记OD(v)

    入度:以v为终点的有向边的条数,记ID(v)

    n顶点,e条边的有向图中出度,入度为e

  9. :边有权值

  10. 稠密图&稀疏图

    边多VS边少

    稠疏稠密的界定:|E|<|V|log|V|

  11. 有向树:一个顶点的入度为0、其余顶点的入度均为1的有向图

  12. 路径:图中顶点v到顶点w的顶点序列,序列中顶点不重复的路径称为简单路径

    路径长度:路径上边的数目,若该路径最短则称其为距离。

    回路:第一个顶点和最后一个顶点相同的路径

6.2 图的存储结构及操作

6.2.1 邻接矩阵法

  1. 邻接矩阵法

    结点数为n的图G = (V, E)的邻接矩阵A是n×n的。将G的顶点编号为v1,v2,…,vn(数组下标),若< vi,vj>∈E,则A[i][j]=1,否则A[i][j]=0

    当所存图边上有权值时:若< vi,vj>∈E,则A[i][j]=Wi,j,否则A[i][j]=∞

    #define MaxVertexNum 100
    typedef char VertexType;
    typedef int EdgeType;
    typedef struct{
        VertexType Vex[MaxVertexNum];//结点集
        EdgeType Edge[MaxVertexNum][MaxVertexNum];//边集
        int vexnum,arcnum;//结点数量,边的数量
    }Mgraph;
    
  2. 性质

    1)邻接矩阵法的空间复杂为O(n2),适用于稠密图

    2)无向图的邻接矩阵为对称矩阵

    3)无向图中第i行(或第i列)非0元素(非正无穷)的个数为第i个顶点的度;
    有向图中第i行(第i列)非0元素(非正无穷)的个数为第i个顶点的出度(入度)

  3. An的含义

    An[ i ] [ j ] 表示从顶点vi到顶点vj长度为n的路径的条数

    在这里插入图片描述

6.2.2 邻接表法

  1. 邻接表法:为每一个顶点建立一个单链表存放与它相邻的边

    顶点表

    采用顺序存储,每个数组元素存放顶点的数据和边表的头指针

    边表(出边表)

    采用链式存储,单链表中存放与一个顶点相邻的所有边,一个链表结点表示一条从该顶点到链表结点顶点的边

    在这里插入图片描述

    #define MaxVertexNum 100
    typedef struct ArcNode{
        int adjvex;//该边所连另一个结点
        struct ArcNode *next;//下一条边
        //InfoType info;//边权重
    }ArcNode;//边表结点
    typedef struct VNode{
        VertexType data;
        ArcNode *first;//边表头指针
    }VNode;//顶点表结点
    typedef struct{
        VNode vertices[MaxVertexNum];//顶点表
     int vexnum,acnum;//顶点数,边数
    }ALGraph;//邻接表
    
  2. 特点

    1)若G为无向图,存储空间为O (|V|+2|E|);若G为有向图,存储空间为O(|v|+|E|)

    2)邻接表更加适用于稀疏图

    3)若G为无向图,则结点的度为该结点边表的长度;

    若G为有向图,则结点的出度为该结点边表的长度,计算入度则要遍历整个邻接表

    4)邻接表不唯一,边表结点的顺序根据算法和输入的不同可能会不同。

  3. 邻接矩阵VS邻接表

    邻接矩阵邻接表
    适用性适用于稠密图适用于稀疏图
    存储方式顺序存储顺序存储+链式存储
    判断两顶点间是否存在边效率高效率低
    找出某顶点相邻的边效率低效率高

6.2.3 十字链表

  1. 十字链表有向图的一种链式存储

    在这里插入图片描述

    #define MaxVertexNum 100
    typedef struct ArcNode{
        int tailvex,headvex;//边的首位结点编号
        struct ArcNode *hlink,*tlink;//下一条弧尾相同的边和下一条弧头相同的边
        //InfoType info;
    }ArcNode;//边
    typedef struct VNode{
        VertexType data;
        ArcNode *firstin,*firstout;//第一条入边,第一条出边
    }VNode;//顶点
    typedef struct{
        VNode xlist[MaxVertexNum];
        int vexnum,arcnum;
    }GLGraph;
    

6.2.4 邻接多重表

  1. 邻接多重表无向图的一种链式存储结构

    在这里插入图片描述

    #define MaxVertexNum 100
    typedef struct ArcNode{
        int ivex,jvex;//边的两个端点
        struct ArcNode *ilink,*jlink;//两个端点所连接的下一条边
        //InfoType info;//保存权重
        //bool mask;//标志域,用于标记此节点是否被操作过,例如在对图中顶点做遍历操作时,为了防止多次操作同一节点,mark 域为 0 表示还未被遍历;mark 为 1 表示该节点已被遍历;
    }ArcNode;//边
    typedef struct VNode{
        VertexType data;
        ArcNode *firstedge;//顶点所连第一条边
    }VNode;
    typedef struct{
        VNode adjmulist[MaxVertexNum];//顶点表
        int vexnum,arcnum;
    }AMLGraph;
    
  2. 十字链表&邻接多重表

    十字链表邻接多重表
    链式存储结构链式存储结构
    有向图无向图
    解决了无法快速查找某一顶点所有入边这一弊端解决了每一条边都需要两个边结点来存放这一弊端

6.2.5 图的基本操作

  • Adjacent(G, x, y)判断图G是否存在边<x, y>或(x y)
    • 邻接矩阵效率更高
  • Neighbors(G, x)列出图G中与结点x邻接的边
    • 使用邻接表,若是无向图效率更高,若是有向图查找入边时效率太低
  • lnsertVertex(G, x)在图G中插入顶点x
  • DeleteVertex(G,x)从图G中删除顶点x
  • AddEdge(G, x, y)若无向边(x, y)或者有向边<x, y>不存在,则向图G中添加该边
  • RemoveEdge(G, x, y)若无向边(x, y)或者有向边<x, y>存在,则在图G中删除该边
  • FirstNeighbor(G,x)求图G中顶点X的第一个邻接点,若有则返回顶点号。若没有邻接点或图不存在x,则返回-1。
  • NextNeighbor(G,x)假设图G中顶点y是顶点x的一个邻接点,返回除y之外顶点x的下一个邻接点的顶点号,若y是x的最后一个邻接点,则返回-1。
  • Get_edge_value(G,x, y)获取图G中边(x, y)或<x, y>对应的权值v。
  • Set_edge_value(G,x,y)设置图G中边(x, y)或<x,y>对应的权值为v。

6.3 图的遍历

图的遍历从图中某一顶点出发,按照某种搜索方法沿着图中的边对图中的 所有顶点访问一次且仅访问一次。

6.3.1 广度优先遍历

  1. 广度优先搜索

    ·首先访问起始顶点v;

    ·接着由出发依次访问v的各个未被访问过的邻接顶点Wi,W2 ,…i;

    ·然后依次访问w,W……,的所有未被访问过的邻接顶点;

    ·在从这些访问过的顶点出发,访问它们所有未被访问过的邻接顶点;

    ·…,以此类推;

  2. 队列+辅助标记数组

    bool visited[MAX_TREE_SIZE];
    void BFSTraverse(Graph G){
        for(int i=0;i<G.vexnum;++i){
            visited[i]=FALSE;
        }
        InitQueue(Q);
        for(int i;i<G.vexnum;++i)
            if(!visited[i])
                BFS(G,i);//每次只能遍历一个连通子图
    }
    void BFS(Graph G,int v){
        visit(v);
        visited[v]=TRUE;
        EnQueue(Q,v);
        while(!isEmpty(Q)){
            DeQueue(Q,v);
            for(w=FirstNeighbor(G,v);w>=0;w=NextNeighbor(G,u,w)){
                if(!visited[w]){
                    visit(w);
                    visited[w]=TRUE;
                    EnQueue(Q,w);
                }
            }
        }
    }
    
  3. BFS算法的性能分析

    • 空间复杂度:O(|V|)(V是顶点数量)

    • 时间复杂度:

      • 邻接矩阵法:O(|V|2)
      • 邻接表法:O(|V|+|E|)
  4. 无权图单源最短路径问题

    定义从顶点u到顶点v的最短路径d(u,v)为从u到v的任何路径中最少的边数;

    若从u到v没有通路,则d(u,v)=∞。

    void BFS_MIN_Distance(Graph g,int u){
        for(int i=0;i<G.vexnum;++i)
            d[i]=MAX;
        visited[u]=TRUE;
        d[u]=0;
        EnQueue(Q,u);
        while(!isEmpty(Q)){
            DeQueue(Q,u);
            for(w=FirstNeighbor(G,u);w>=0;w=NextNeighbor(G,u,w))
                if(!visited[w]){
                    visited[w]=TRUE;
                    d[w]=d[u]+1;
                    EnQueue(Q,w);
                }
        }
    }
    
  5. 广度优先生成树

    在广度遍历过程中,我们可以得到一棵遍历树,称为广度优先生成树(生成森林)。

    邻接矩阵法的广度优先生成树唯一,邻接表法的不唯一。

6.3.2 深度优先遍历

  1. 深度优先搜索DFS

    ·首先访问起始顶点v;

    ·接着由v出发访问v的任意一个邻接且未被访问的邻接顶点w;

    ·然后再访问与w邻接且未被访问的任意顶点y;

    ·若w没有邻接且未被访问的顶点时,退回到它的上一层顶点v;

    ·重复上述过程,直到所有顶点被访问为止。

  2. 递归(栈)+辅助标记数组

    bool visited[MAX_TREE_SIZE];
    void DFSTraverse(Graph G){
        for(int i=0;i<G.vexnum;++i){
            visited[i]=FALSE;
        }
        for(int i=0;i<G.vexnum;++i){
            if(!visited[i])
                DFS(G,i);
        }
    }
    void DFS(Graph G,int v){
     visir(v);
        visited[v]=TRUE;
        for(w=FirstNeighbor(G,v);w>=0;w=NextNeighbor(G,v,w))
            if(!visited[w])
                DFS(G,w);
    }
    

    邻接矩阵法的DFS (BFS)序列唯一,邻接表法的不唯一

  3. DFS算法的性能分析

    • 空间复杂度:O(|V|) 工作栈
    • 时间复杂度:
      • 邻接矩阵法:O(|V|2)
      • 邻接表法:O(|V|+|E|)
  4. 深度优先生成树

    在深度遍历过程中,我们可以得到一棵遍历树,称为深度优先生成树(生成森林)。
    ★邻接矩阵法的深度优先生成树唯一,邻接表法的不唯一

6.3.3 遍历与连通性

  1. ★在无向图当中,在任意结点出发进行一次遍历(调用一次BFS或DFS),若能访问全部结点,说明该无向图是连通的。

  2. ★在无向图中,调用遍历函数(BFS或DFS)的次数为连通分量的个数。

6.4 图的应用

6.4.1 最小生成树

  1. 最小生成树

    生成树:对连通图进行遍历,过程中所经过的边和顶点的组合可看做是一棵普通树。

    最小生成树:对于带权无向连通图G=(V,E),G的所有生成树当中边的权值之和最小的生成树为G的最小生成树(MST)。

  2. 最小生成树的性质

    1)最小生成树不一定唯一,即最小生成树的树形不一定唯一。当带权无向连通图G的各边权值不等时或G只有结点数减1条边时,MST唯一。

    2)最小生成树的权值是唯一的,且是最小。

    3)最小生成树的边数为顶点数减1

  3. 算法

    GENRIC_MST(G){
        T=NULL;
        while T 未形成一棵生成树:
        	do 找到一条最小代价边(u,v)并且加入T后不会产生回路;
        	T=T∪(u,v);
    }
    
  4. Prim算法

    • 初始化:向空的结果树T=(VT,ET)中添加图G=(V,E)的任一顶点u0,使VT={u0},E为空集;

      循环(直到VT=V):从图G中选择满足{(u, v)[u∈VT,v∈V-VT}且具有最小权值的边(u,v),并置VT=VT∪{v}, ET=ET∪{(u,v)}。

      void Prim(G,T){
          T=空集;
          U={w};
          while((v-u)!=空集){(u,v)是使u∈U,,v∈V-U,且权值最小的边;
              T=T∪{(u,v)};
              U=U∪{v};
          }
      }
      
    • 辅助数组:

      • min_weight[n]:顶点集V到未挑选顶点的最短距离。

        • 每次从这里面挑选最小值作为新加入顶点。
        • 然后要根据新加入顶点到他相邻顶点的距离更新该数组。
      • adjvex[n]

        • adjvex[v]=u,表示加入边为(u,v)
      • 在这里插入图片描述

    • void MST_Prim(Graph G){
          int min_weight[G.vexnum];
          int adjvex[G.vexnum];
          for(int i=0;i<G.vexnum;i++){
              min_weight[i]=G.Edge[0][i];
              adjvex[i]=0;
       }
          int min_arc;//当前挑选边的权重
          int min_vex;//当前挑选边的另一个端点
          for(int i=1;i<G.vexnum;i++){
              //寻找距离集合V最近的一个顶点
              min_arc=MAX;
              for(int j=1;j<G.vexnum;j++){
                  if(min_weight[j]!=0&&min_weight[j]<min_arc){
                      min_arc=min_weight[j];
                      min_vex=j;
                  }
              }
              //将该顶点加入到集合V,并更新集合V到其他顶点的最短距离
              min_weight[min_vex]=0;
              for(int j=0;j<G.vexnum;j++){
                  if(min_weight[j]!=0&&G.Edge[min_arc][j]<min_weight[j]){
                      min_weight[j]=G.Exdge[min_arc][j];
                      adjvex[j]=min_vex;
                  }
              }
          }
      }
      
    • O(|V2|),适用于稠密图

  5. Kruskal算法

    • 初始化:点集V =VT,边集ET =空集。即是每个顶点构成一棵独立的树,T是一个仅含|V|个顶点的森林;

      循环(直到T为树):按图G的边的权值递增的顺序依次从E-ET中选择一条边,若这条边加入后不构成回路,则将其加入E,否则舍弃。

      void Kruskal(V,T){
          T=v;
          numS=n;
          while(numS>1){
              从E中取出权值最小的边(v,u);
              if(v和u属于T中不同的连通分量){
                  T=T∪{(v,u)};
                  numS--;
              }
          }
      }
      
    • 先对边进行堆排序Sort()

      然后初始化一个并查集,通过合并操作向V中加入新顶点,通过查找操作判断加入新顶点后是否产生回路。

    • typedef struct Edge{
          int a,b;
          int weight;
      };
      void MST_Kruskal(Graph G,Edge* edges,int* parent){
          heap_sort(edges);//对边进行堆排序log|E|
          Initial(parent);
          for(int i=0;i<G.arcnum;i++){
              int a_root=Find(parent,edges[i].a);
              int b_root=Find(parent,edges[i].b);
              if(a_root!=b_root)
                  Union(parent,a_root,b_root);
          }
      }
      
    • O(|E|log|E|),适用于稀疏图

6.4.2 最短路径

  1. 最短路径:两个顶点之间带权路径长度最短的路径为最短路径。

    在带权图当中,把从一个顶点v到另一个顶点u所经历的边的权值之和称为,路径的带权路径长度

  2. Dijkstra 带权图单源最短路径

    • 辅助数组

      • s[]:标记已经计算完成的顶点。

        数组中的值全部初始化为0。源点下标的值初始化为1。

      • dist[]:记录从源点v0到其他各顶点当前的最短路径长度。

        数组中的值初始化为源点到各个顶点边的权值,即dist[i]=arcs[0][i]

      • path[]:记录从最短路径中顶点的前驱顶点,即path[i]为v到vi最短路径上vi的前驱顶点。

        数组中的值初始化:

        若源点v0到该顶点vi有一条有向边(无向边),则令path[i]=0;否则path[i]=-1;

    • 算法思想

      1)初始化数组,并将集合S初始为{0};

      2)从顶点集合V-S中选出Vj,满足dist[j]=Min{dist[i]|[ vi∈V-S}, Vj就是当前求得的最短路径的终点,并令SU{ j };

      3)修改此时从v0出发到集合V-S上任一顶点v最短路径的长度:

      ​ 若dist[j]+arcs[j][k]<dist[k]
      则令dist[k]=dist[j]+arcs[j][k]; path[k]=j;

      4)重复2、3操作n-1次,直到S中包含全部顶点;

      在这里插入图片描述

      在这里插入图片描述

    • void Dijkstra(Graph G,int v){
          int s[G.vexnum];
          int path[G.vexnum];
          int dist[G.vexnum];
          for(int i=0;i<G.vexnum;i++){
              dist[i]=G.edge[v][i];
              s[i]=0;
              if(G.edge[v][i]<MAX)
                  path[i]=v;
              else
                  path[i]=-1;
          }
          s[v]=1;
          path[v]=-1;
          //寻找距离最近的顶点u
          for(i=0;i<G.vexnum;i++){
              int min=MAX;
              int u;
              for(it j=0;j<G.vexnum;j++){
                  if(s[j]==0&&dist[j]<min){
                      min=dist[j];
                      u=j;
                  }
              }
          }
          //将顶点u加入集合v,并更新距离
          s[u]=1;
          for(int j=0;j<G.vexnum;j++){
              if(s[j]==0&&dist[u]+G.Edge[u][j]<dist[j]){
                  dist[j]=dist[u]+G.Edge[u][i];
                  path[j]=u;
              }
          }
      }
      
    • 时间复杂度:O(V2)

    • Dijkstra算法并不适用于含有负权边的图

      在这里插入图片描述

  3. Floyd 各顶点之间的最短路径

    • 算法思想

      递推产生一个n阶方阵序列A (-1),A (0),…,A(k),…,A(n-1)

      A(k)[i][j]:顶点vi到vj的最短路径长度,且该路径经过的顶点编号不大于k

    • 递推公式

      初始化: A(-1)[i][j]=arcs[i] [j]
      递推方法:A(k)[i] [j]=Min{A(k-1) [i] [j] , A(k-1)[i] [k]+A(k-1)[k] [j]}, k =0,1,…,n-1

      在这里插入图片描述

    • void Floyd(Graph G){
          int A[G.vexnum][G.vexnum];
          for(int i=0;i<G.vexnum;i++)
              for(int j=0;j<G.vexnum;j++)
                  A[i][j]=G.Edge[i][j];
          for(int k=0;k<G.vexnum;k++)
              for(int i=0;i<G.vexnum;i++)
                  for(int j=0;j<G.vexnum;j++)
                      if(A[i][j]>A[i][k]+A[k][j])
                          A[i][j]=A[i][k]+A[k][j];
      }
      
    • 时间复杂度:O(V3)

6.4.3 拓扑排序

  1. 拓扑排序

    • 有向无环图:不存在环的有向图,简称DAG图
    • AOV网:若用一个DAG图表示一个工程,其顶点表示活动,用有向边<vi,vj>表示活动vi先于活动vj进行的传递关系,则将这种DAG称为顶点表示活动网络,记为AOV网
    • 拓扑排序:对DAG所有顶点的一种排序,使若存在一条从顶点A到顶点B的路径,在排序中B排在A的后面。
  2. 算法思想

    1)从DAG图中选择一个没有前驱的顶点并输出

    2)从图中删除该顶点和所有以它为起点的有向边

    3)重复1、2直到当前的DAG图为空或当前图中不存在无前驱的顶点为止。后一种情况说明图中有环。

  3. 性质

    ★算法结束时没有访问所有顶点,则存在以剩下顶点组成的环。

    ★拓扑排序的结果不一定唯一

  4. 代码实现

    bool TopologicalSort(Graph G){
        InitStack(S);
        //将所有入度为0的顶点入栈
        for(int i=0;i<G.vexnum;i++)
            if(indegree[i]==0)
                Push(S,i);
        int count=0;
        while(!isEmpty(S)){
            //将入度为零的顶点添加到输出数组中
            Pop(S,i);
            print[count++]=i;
            //遍历该顶点的所有后继,将他们的入度减一,若入度变成了0则入栈
            for(p=G.Vertices[i].firstarc;p;p=p->nextarc){
                v=p->adjvex;
                if(!(--indegree[v]))
                    Push(S,v);
            }
        }
        if(count<G.vexnum)
            return false;
        else
            returntrue;
    }
    

    时间复杂度:O(|V|+|E|)

  5. 特点

    • 若邻接矩阵为三角矩阵(上三角/下三角),则存在拓扑排序;反之不一定成立。
      • 上三角中每条边vi<vj,起始端点一定小于中止端点,一定不存在环状结构。

6.4.4 关键路径

  1. AOE网在有向带权图中,以顶点表示事件,以有向边表示活动,以边上权值表示完成该活动的开销(如完成活动所需要的时间),则称这种有向图为用边表示活动的网络,简称AOE网。

    有且仅有一个顶点入度为0,叫源点;有且仅有一个顶点出度为0,叫汇点

    关键路径:从源点到汇点最大路径长度的路径称为关键路径,关键路径上的活动为关键活动

  2. 关键路径求解算法

    1)求事件Vk的最早发生时间Ve(k),按照拓扑排序顺序求解

    ​ Ve(源点)=0
    ​ Ve(k)=Max{Ve(j) + Weight (Vj, Vk)}

    2)求事件Vk的最迟发生时间Vl(k),按照逆拓扑排序顺序求解

    ​ Vl(汇点)=Ve(汇点)
    ​ Vl(j)=Min{Ve(k) - Weight (Vj, Vk)}

    3)活动ai的最早开始时间e(i)
    若存在<Vj, Vk>表示活动ai,则e(i)=Ve(j)

    4)活动ai的最迟开始时间l(i)
    若存在<Vj, Vk>表示活动ai,则l(i)=Vl(k)-Weight(Vj, Vk)

    5)活动ai的差额d(i)=l(i)-e(i)

    关键活动:d(i)为0的活动,关键路径:由部分活动构成

    在这里插入图片描述

    ★缩短关键活动时间可以加快整个工程,但缩短到一定大小时关键路径会发生改变。

    ★当网中关键路径不唯一时,只有加快的关键活动或关键活动组合包括在所有的关键路径上才能缩短工期。

第七章 查找

7.1 查找基础

7.1.1 查找基本概念

  1. 查找

    在数据集合中寻找满足某种条件的数据元素的过程。查找结果分为查找成功和查找失败。

  2. 查找表

    用于查找的数据集合,由同一种数据类型(或记录)的组成,可以是一个数组或链表等数据类型

  3. 操作:

    1)查询某个特定的数据元素是否在查找表中

    2)检索满足条件的某个特定的数据元素的各种属性

    3)在查找表中插入一个数据元素

    4)从查找表中删除一个数据元素

  4. 静态查找表:无插入删除操作

    动态查找表:可以动态添加删除

  5. 关键字

    数据元素中唯一标识该元素的某个数据项的值,使用基于关键字的查找,查找结果应该是唯一的

  6. 平均查找长度

    查找时,关键字比较次数的平均值:ASL=ΣPiCi

7.1.2 顺序查找

  1. 顺序查找又称线性查找,主要用于在线性表中进行查找。

  2. 无序查找

    对无序线性表进行顺序查找,查找失败时要遍历整个线性表

    typedef struct{
        ElemType *elem;
        int TableLen;
    }SSTable;
    int Search_Seq(SStable ST,ElemType key){
        ST.elem[0]=key;//哨兵,找到哨兵时,返回的是0
        for(int i=St.TableLen;ST.elem[i]!=key;i--);
        return i;
    }
    

    平均查找长度:成功:(n+1)/2;失败:n+1

  3. 有序查找

    对关键字有序线性表进行顺序查找,查找失败时不一定要遍历整个线性表

    判定树:描述查找过程的二叉排序树

    黄色为失败节点

    在这里插入图片描述

7.1.3 二分查找

  1. 二分查找 又称折半查找,仅适用于有序的顺序表

  2. 算法思想

    1)首先将给定值key与表中中间位置元素的关键字比较,

    ​ 若相等,则返回该元素的位置;

    ​ 若不等,则在前半部分或者是后半部分进行查找。

    2)查找序列升序时,

    ​ 若key小于中间元素,则查找前半部分;

    ​ 若key大于中间元素,则查找后半部分。

    3)重复该过程,直到找到查找的元素为止;或查找失败。

  3. 代码实现

    时间复杂度:O(log2n)

    int Binary_Search(SeqList L,ElemType key){
        int low=0,high=L.TableLen-1,mid;
        while(low<=high){
            min=(low+high)/2;
            if(L.elem[mid]==key)
                return mid;
            else if(L.elem[mid]>key)
                high=mid-1;
            else
                low=mid+1;
        }
        return -1;
    }
    
  4. 判定树

    在这里插入图片描述

    在这里插入图片描述

  5. 顺序查找适用于顺序存储和链式存储,序列有序无序皆可;折半查找只适用于顺序存储,且要求序列一定有序。

7.1.4 分块查找

  1. 分块查找又称索引顺序查找,它吸取了顺序查找和折半查找各自的优点,既有动态结构,又适于快速查找。

  2. 如何分块(块内无序块间有序)

    1)将查找表分为若干子块。块内的元素可以无序,但块间是有序的,即对于所有块有第 i 块的最大关键字小于第i+1块的所有记录的关键字。

    2)建立索引表,索引表中的每个元素含有各块的最大关键字和各块中的第一个元素的地址,索引表按关键字有序排列。

    在这里插入图片描述

  3. 如何查找

    1)在索引表中确定待查记录所在的块,可以顺序查找或折半查找索引表。

    2)在块内进行顺序查找

  4. 在这里插入图片描述

7.2 B树&B+树

7.2.1 B树

  1. **B-树(B树)**的基本概念

    B-树又称多路平衡查找树,B-树中所有结点中孩子结点个数的最大值成为B-树的阶,通常用m表示,从查找效率考虑,一般要求m>=3。

    一棵m阶B-树或者是一棵空树,或者是满足以下条件的m叉树。

    1)每个结点最多有m个分支(子树);而最少分支数要看是否为根结点,如果是根结点且不是叶子结点,则至少要有两个分支,非根非叶结点至少有ceil(m/2)个分支,这里ceil代表向上取整。

    2)如果一个结点有n-1个关键字,那么该结点有n个分支。这n-1个关键字按照递增顺序排列。

    3)非叶结点的结构为:

    nk1k2kn
    p0p1p2pn

    其中,n为该结点中关键字的个数;ki为该结点的关键字且满足ki<ki+1;pi为该结点的孩子结点指针且满足pi所指结点上的关键字大于ki且小于ki+1,p0所指结点上的关键字小于k1,pn所指结点上的关键字大于kn。

    4)结点内各关键字互不相等且按从小到大排列。

    5)叶子结点处于同一层;可以用空指针表示,是查找失败到达的位置。

    注:平衡m叉查找树是指每个关键字的左侧子树与右侧子树的高度差的绝对值不超过1的查找树,其结点结构与上面提到的B-树结点结构相同,由此可见,B-树是平衡m叉查找树,但限制更强,要求所有叶结点都在同一层。

  2. 示例

    在这里插入图片描述

    上面的图片显示了一棵B-树,最底层的叶子结点没有显示。我们对上面提到的5条特点进行逐条解释:

    1)结点的分支数等于关键字数+1,最大的分支数就是B-树的阶数,因此m阶的B-树中结点最多有m个分支,所以可以看到,上面的一棵树是一个5-阶B-树。

    2)因为上面是一棵5阶B-树,所以非根非叶结点至少要有ceil(5/2)=3个分支。根结点可以不满足这个条件,图中的根结点有两个分支。

    3)如果根结点中没有关键字就没有分支,此时B-树是空树,如果根结点有关键字,则其分支数比大于或等于2,因为分支数等于关键字数+1.

    4)上图中除根结点外,结点中的关键字个数至少为2,因为分支数至少为3,分支数比关键字数多1,还可以看出结点内关键字都是有序的,并且在同一层中,左边结点内所有关键字均小于右边结点内的关键字,例如,第二层上的两个结点,左边结点内的关键字为15,26,他们均小于右边结点内的关键字39和45.

    B-树一个很重要的特征是,下层结点内的关键字取值总是落在由上层结点关键字所划分的区间内,具体落在哪个区间内可以由指向它的指针看出。例如,第二层最左边的结点内的关键字划分了三个区间,小于15,15到26,大于26,可以看出其下层中最左边结点内的关键字都小于15,中间结点的关键字在15和26之间,右边结点的关键字大于26.

    5)上图中叶子结点都在第四层上,代表查找不成功的位置。

  3. 查找

    1)在B树中找结点(磁盘)

    2)在结点中找关键字(内存)

  4. 插入

1)定位

​ 查找插入该关键字的位置,即最底层中的某个非叶子结点(规定一定是插入在最底层的某个非叶子结点内)

2)插入

​ 若插入后,不破会m阶B树的定义,即插入后结点关键字个数在属于区间[ ceil(m/2)-1, m-1],则直接插入;

​ 若插入后,关键字数量大于m-1,则对插入后的结点进行分裂操作;

分裂:

​ 插入后的结点中间位置(ceil(m/2))关键字并入父结点中,

​ 中间结点左侧结点留在原先的结点中,右侧结点放入新的节点中,

​ 若并入父节点后,父结点关键字数量超出范围,继续想上分裂,直到符合要求为止。(直到根节点,若依然超出范围,则继续分裂,创造一个新的根节点)

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

  1. 删除
  • 终端结点:最底层的非叶子结点。

    1)直接删除

    若被删除关键字所在结点关键字总数>ceil(m/2)-1,表明删除后仍满足B树定义,直接删除

    2)兄弟够借

    若被删除关键字所在结点关键字总数=ceil(m/2)-1,且与此结点邻近的兄弟结点的关键字个数>ceil(m/2),则需要从兄弟结点借一个关键字,此过程需要调整该结点、双亲结点和兄弟结点的关键字

    在这里插入图片描述

    3)兄弟不够借

    若被删除关键字所在结点关键字总数=ceil(m/2)-1,且与此结点邻近的兄弟结点的关键字个数=ceil(m/2),则删除关键字,并与一个不够借的兄弟结点和双亲结点中两兄弟子树中间的关键字合并。

    合并后若双亲结点因减少一个结点导致不符合定义,则继续执行2、3步骤。

    在这里插入图片描述

    在这里插入图片描述

  • 非终端结点:终端结点和叶子结点之外的的结点。

    1)若小于k的子树中关键字个数>ceil(m/2)-1,则找出k的前驱值k’,并用k’来取代k,再递归地删除k"即可。

    在这里插入图片描述

    在这里插入图片描述

    2)若大于k的子树中关键字个数>ceil(m/2)-1,则找出k的后继值k’,并用k’来取代k,再递归地删除k’即可。

    3)若前后两子树关键字个数均为ceil(m/2)-1,则直接两个子结点合并,然后删除k即可。

    在这里插入图片描述

    在这里插入图片描述

7.2.2 B+树

  1. B+树

    一棵m阶B+树满足如下特性:

    1)每个分支结点最多有m棵子树(子结点)

    2)若根结点不是终端结点,则至少有两棵子树

    3)除根结点外的所有非叶结点至少有ceil(m/2)棵子树,子树和关键字个数相等

    4)所有叶结点包含全部关键字及指向相应记录的指针,叶结点中将关键字按大小顺序排列,并且相邻结点按大小顺序连接起来

    5)所有分支结点(可视为索引的索引)中仅包含他的各个子结点(下一级索引块)中关键字的最大值及指向其子结点的指针

  2. 示例

    在这里插入图片描述

  3. B+树 VS B树

    1)在B+树中,具有n个关键字的结点值含有n棵子树,即每个关键字对应一棵子树;在B树中,具有n个关键字的结点含有n+1棵子树

    2)在B+树中,叶结点包含信息,所有非叶结点仅起索引作用,非叶结点中的每个索引项只含有对应子树的最大关键字和指向该子树关键字的指针,不含有该关键字对应记录的存储地址

    3)在B+树中,叶结点包含全部关键字,即在非叶结点中出现的关键字也会出现在叶结点中;在B树中,叶结点包含的关键字和其他结点包含的关键字是不重复的

  4. B+树中有两种查找方式:多路查找&顺序查找

    在这里插入图片描述

    ★在B+树中查找时,无论查找成功还是失败一定是查找到叶结点当中的值为止。

7.3 散列表

  1. 散列函数:一个把查找表中的关键字映射成该关键字对应的地址的函数。Hash(key)=Addr

    散列表:根据关键字而直接进行访问的数据结构。他建立了关键字与存储地址之间的一种直接映射关系。

    冲突:散列函数可能会把多个不同的关键字映射到同一地址下的情况。

  2. 散列函数的构造方法

    • 要求:

      1)散列函数的定义域必须包含全部需要存储的关键字,而值域的范围则依赖于散列表的大小或地址范围。

      2)散列函数计算出来的地址应该能等概率、均匀分布在整个地址空间中,从而减少冲突的发生。

      3)散列函数应尽量简单,能够在较短时间内计算出任一关键字对应的散列地址。

    • a. 直接定址法:直接取关键字的某个线性函数值为散列地址。

      Hash(key)=a*key + b,

      其中a,b为常数

      方法简单,不会产生冲突,若关键字分布不连续,则会浪费空间。

    • b. 除留取余法

      Hash(key)=key % p,

      假定散列表表长为m,取一个不大于m但最接近或等于m的质数p

      选好p是关键,可以减少冲突的可能。

      对于取模运算,由模数N决定了一个工作集合X={1,2,3,…,N-1},令g,x是X中的任意元素,令f(g,x)是定义在集合X上的一个函数(加法/乘法),则有,如果N为质数,f(g,x)的值域Y=X,**如果N不是质数,则对于符合某些条件的g元素,值域Y不等于X,而是X的一个真子集。**原本预期y=f(g,x)在集合X上具有平均分布特性,每个元素出现的机会均等,如果Y<X,则破坏了这个假定,增加了特定元素的出现概率,在编码系统中这种情况是增加了碰撞概率,在加密系统中,这种情况是降低了暴力搜索的难度,降低了密码系统的强度。表面上看你用了一个很大的数,但实际上强度很低。

      比如N=12, g=4, f(g,x)=g*x mod N

      4*1 mod N = 4

      4*2 mod N = 8

      4*3 mod N = 0

      4*4 mod N = 4

      4*5 mod N = 8

      4*6 mod N = 0

      4*7 mod N = 4

      4*8 mod N = 8

      4*9 mod N = 0

      4*10 mod N = 4

      4*11 mod N = 8

    • c. 数字分析法

      分析数字关键字在各位上的变化情况,取比较随机的位作为散列地址

      比如:取11位手机号码key的后4位作为地址:

      适用于关键字已知的集合,若更换关键字则需要重新构造散列函数。

      在这里插入图片描述

    • d. 平方取中法

      这种方法取关键字的平方值的中间几位作为散列地址

      适用于关键字的每位取值不均匀或均小于散列地址所需要的位数。

      521->平方:271441->取中:7144

    • e. 折叠法

      将关键字分割成位数相同的几部分,然后取这几部分的叠加和作为散列地址

      5211252 -> 521+125+2=648

      适用于关键字的位数多,而且关键字中的每位上数字分布大致均匀

  3. 冲突处理

    为产生冲突的关键字寻找下一个“空”的Hash地址

    • a. 开放定址法

      是指可存放新表项的空闲地址既向它的同义词表项开放,又向它的非同义词表项开放。

      Hi=(H(key)+di)%m, i=0,1,2…K(k <= m-1);

      m为散列表表长,di为增量序列

      如何计算增量序列:

      • 线性探查法

        即di=0,1,2,3…m-1

        会产生堆积现象(大量相邻的关键字元素存放在相邻的位置)

        堆积现象会大大降低查找效率

      • 平方探测法

        即di=02,12,-12,22,-22 … k2,-k2,其中k ≤m / 2

        避免堆积问题,缺点是不能探测到散列表上的所有单元(至少可以探测到一般单元)

      • 再散列法

        即di=i*Hash2(key)

      • 伪随机序列法

        即di=伪随机序列

      • 开放定址法的缺点

        ★在开放定址法中不能随便删除某个元素。(一旦删除,后面通过增量序列所存的元素将无法查找)

    • b. 拉链法

      是指把所有同义词存放在一个线性链表中,这个线性链表由地址唯一标识,即散列表中每个单元存放该链表头指针。

      ★拉链法适用于经常进行插入和删除的情况

  4. 查找

    在这里插入图片描述

    初始化: Addr=Hash(key);

    1)检测查找表中地址为Addr的位置上是否有记录,若无记录,则返回查找失败;若有记录,则比较它与key值,若相等则返回成功,否则执行步骤2

    2)用给定的处理冲突方法计算“下一散列地址”,把Addr置为此地址,转入步骤1

  5. 查找效率

    散列函数、处理冲突的方法和填装因子

    填装因子一般记为α,表示表的装满程度

    α=(表中记录数n) / (散列表长度m)

    ★散列表的平均查找长度依赖于散列表的填装因子

第八章 排序

8.1 基本概念

  1. 排序

    重新排列表中的元素,使表中的元素满足按关键字递增或递减

  2. 排序算法的稳定性

    若待排序表中有两个元素Ri和Rj,其对应的关键字ki=kj,且在排序前Ri在Rj前面,若使用某排序算法后,Ri仍然在Rj前面。则称这个排序算法是稳定的,否则称排序算法不稳定。

    ★算法的稳定性是算法的性质,并不能衡量一个算法的优劣

  3. 内部排序指在排序期间元素全部存放在内存中的排序

    外部排序指在排序期间元素无法全部同时存放在内存中,必须在排序的过程中根据要求不断地在内、外存之间进行移动

  4. 时空复杂度决定内部排序算法的性能

8.2 内部排序

8.2.1 插入排序

插入排序:每次将一个待排序的序列插入到一个前面已排好序的子序列当中。

8.2.1.1 直接插入排序
  1. 算法思想

    ·初始L[1]是一个已经排好序的子序列

    ·对于元素L(i)(L(2)~L(n))插入到前面已经排好序的子序列当中:

    1)查找出L(i)在L[1…i-1]中的插入位置k

    2)将L[k…i-1]中的所有元素全部后移一个位置

    3)将L(i)复制到L(k)

  2. 时空复杂度:

    空间复杂度:O(1)

    时间复杂度:最坏:O(n2),最好:O(n)

  3. 代码实现:

    void InsertSort(ElemType A[],int n){
        int i,j;
        for(i=2;i<=n;i++){
            A[0]=A[i];
            for(j=i-1;A[0].key<A[j].key;j--)
                A[j+1]=A[j];
            A[j+1]=A[0];
        }
    }
    
  4. 稳定!适用于顺序存储和链式存储。

8.2.1.2 折半插入排序
  1. 代码实现

    void BInsertSort(ElemType A[],int n){
        init i,j;
        int low,high,mid;
        for(i=2;i<=n;i++){
            A[0]=A[i];
            /****折半查找O(logn)****/
            low=1;high=i-1;
            while(low<=high){
                mid=(low+high)/2;
                if(A[mid].key>A[0].key)
                    high=mid-1;
                else
                    low=mid+1;
            }
            /****移动O(n)****/
            //要插入的位置是high+1或者low
            for(j=i-1;j>=high+1;j--)
                A[j+1]=A[j];
            A[high+1]=A[0];
        }
    }
    
  2. 时空复杂度:

    空间复杂度:O(1)

    时间复杂度:O(n2)

  3. 稳定的!只适用于顺序存储。

8.2.1.3 希尔排序
  1. 希尔排序:缩小增量排序

    先将排序表分割成d个形如L[i, i+d, i+2d… i+kd]的“特殊”子表,分别进行直接插入排序,当整个表中的元素已呈“基本有序时”,再对全体记录进行一次直接插入排序。

    Shell’s idea d1=取下界n/2, di+1=取下界di/2,直到最后一个dk=1

  2. 代码实现:

    void ShellSort(ElemType A[],int n){
        for(int dk=n/2;dk>=1;dk=dk/2){
            //所有的组同时进行插入排序
            for(int i=dk+1;i<=n;++i){
                if(A[i].key<A[i-dk].key){
                    //将A[i]插入到自己所在组的对应位置:i-dk,i-2dk,...
                    A[0]=A[i];//哨兵
                    for(int j=i-dk;j>0&&A[0].key<A[j].key;j-=dk)
                        A[j+dk]=A[j];
                    A[j+dk]=A[0];
                }
            }
        }
    }
    

    在这里插入图片描述

  3. 时空复杂度:

    时间复杂度:最坏情况下:O(n2)无法证明,一般情况下效率比较高

    空间复杂度:O(1)

  4. 不稳定!适用于顺序存储。

8.2.2 交换排序

8.2.2.1 冒泡排序
  1. 基本思想:

    假设待排序表长为n,从后往前(从前往后)两两比较相邻元素的值,若为逆序(即A[i-1]>A[i]),则交换他们直到序列比较结束。

    ★一次冒泡会将一个元素放置到它最终的位置上

  2. 代码实现:

    void BubbleSort(ElemType A[],int n){
        for(int i=0;i<n-1;i++){
            //i之前的元素是当前已经排好的元素
            bool flag=false;
            //从右往前,找出最小的元素沉底
            for(int j=n-1;j>i;j--){
                if(A[j-1].key>A[j].key){
                    swap(A[j-1],A[j]);
                    flag=true;
                }
            }
            if(flag==false)//说明这波操作没发生交换
                return;
        }
    }
    
  3. 时空复杂度:

    时间复杂度:最好:O(n),最坏:O(n2)

    空间复杂度:O(1)

  4. 稳定!适用于顺序存储和链式存储。

8.2.2.2 快速排序
  1. 快速排序

    在待排序表L[1…n]中任取一个元素pivot作为基准,通过一趟排序将待排序表划分为两部分,一部分比他大,一部分比他小。

    ★一次划分会将一个元素pivot放置到它最终的位置上

  2. 基本思路:

    初始化标记low为划分部分第一个元素的位置, high为最后一个元素的位置,然后不断地移动两标记并交换元素:

    1)high向前移动找到第一个比pivot小的元素

    2)low向后移动找到第一个比pivot大的元素

    3)交换当前两个位置的元素

    4)继续移动标记,执行1) ,2),3)的过程,直到low大于等于high为止。

  3. 代码实现:

    int Partition(ElemType A[],int low,int high){
        ElemType pivot=A[low];
        while(low<high){
            while(low<high && A[high]>=pivot)
                high--;
            A[low]=A[high];
            while(low<high && A[low]<=pivot)
                low++;
            A[high]=A[low];
        }
        A[low]=pivot;
        return low;
    }
    void QuickSort(ElemType A[],int low,int high){
        if(low<high){
            int pivotpos=Partition(A,low,high);
            QuickSort(A,low,pivotpos-1);
            QuickSort(A,pivotpos+1,high);
        }
    }
    
  4. 时空复杂度

    最好、平均空间复杂度O(log2n )

    最好、平均时间复杂度O(nlog2n)

    初始基本有序或逆序:

    ​ 最坏空间复杂度:O(n)

    ​ 最坏时间复杂度:O(n2)

  5. 不稳定。顺序存储(链式存储)

8.2.3 选择排序

8.2.3.1 直接选择排序
  1. 选择排序

    每一趟在后面n-i+1 (i=1,2,n-1)个待排序元素中选取关键字最小的元素,作为有序子序列的第i个元素,直到n-1趟做完,待排序元素只剩下1个。

    一趟排序会将一个元素放置在最终的位置上。

  2. 代码实现

    void SelectSort(ElemType A[],int n){
        for(int i=0;i<n-1;i++){
            //找到i之后元素中的最小值
            int min=i;
            for(int j=i+1;j<n;j++){
                if(A[j]<A[min])
                    min=j;
            }
            //不同于冒泡排序,选择排序每轮最多交换一个值。
            if(min!=i)
                swap(A[i],A[min]);
        }
    }
    
  3. 时空复杂度

    时间复杂度:O(n2) (时间复杂度与初始序列无关)

    空间复杂度:O(1)

  4. 不稳定!(有的值可能会被直接交换到后面),适用于顺序存储和链式存储。

8.2.3.2 堆排序
  1. n个关键字序列L[1…n]称为堆,当且仅当该序列满足:

    1)若L(i)<=L(2i)且L(i)<=L(2i+1),则称该堆为小根堆

    2)若L(i)>=L(2i)且L(i)>=L(2i+1),则称该堆为大根堆

    (1<=i<=取下界n/2)

    在排序过程中将L[1…n]视为一棵完全二叉树的顺序存储结构。

  2. 堆的初始化——大根堆

    对所有具有双亲结点按照编号从大到小(取下界(n/2)~1)做出如下调整:

    1)若孩子结点皆小于双亲结点,则该结点的调整结束

    2)若存在孩子结点大于双亲结点,则将最大的孩子结点与双亲结点交换,并对该孩子结点进行1)、2),直到出现1)或到叶结点为止

    void BuildMaxHeap(ElemType A[],int len){
        for(int i=len/2;i>0;i--){
            AdjustDown(A,i,len)
        }
    }
    void AdjustDown(ElemType A[],int k,int len){
        A[0]=A[k];
        //依次取出当前结点的子结点,孙子节点
        for(int i=2*k;i<=len;i*=2){
            //选出较大的一个子结点
            if(i<len && A[i]<A[i+1])
                i++;
            if(A[0]>A[i])
                break;
            else{
                //若子结点较大
                A[k]=A[i];//交换
                k=i;//继续检测子节点符号要求
            }
        }
        A[k]=A[0];
    }
    

    经数学推导,初始建堆时间复杂度:O(n)

  3. 堆排序:不断地输出堆顶元素,并向下调整

    void HeapSort(ElemType A[],int len){
        BuildMaxHeap(A,len);
        for (int i=len;i>1;i--){
            Swap(A[i],A[1]);//将最大元素放在最后
            AdjustDown(A,1,i-1);
        }
    }
    

    时间复杂度:O(n*log2n)

    空间复杂度:O(1)

    不稳定!适用于顺序存储(链式存储)

  4. 堆的插入:将新结点放置在末端然后进行向上调整

    void AdjustUp(ElemType A[],int len){
        //向上调整用于堆的插入
        A[0]=A[k];
        int i=k/2;
        while(i>0&&A[i]<A[0]){
            A[k]=A[i];
            k=i;
            i=k/2;
        }
        A[k]=A[0];
    }
    

8.2.4 归并排序

  1. 分治的思想

    归并排序的核心思想是分治,把一个复杂问题拆分成若干个子问题来求解。

  2. 归并排序的算法思想

    把数组从中间划分成两个子数组;

    一直递归地把子数组划分成更小的子数组,直到子数组里面只有一个元素;

    依次按照递归的返回顺序,不断地合并排好序的子数组,直到最后把整个数组的顺序排好。

  3. 二路归并排序代码实现:

    //合并两个有序线性表
    ElemType *B=(ElemType *)malloc((n+1)*sizeof(ElemType));
    void Merge(ElemType A[],int low,int mid,int high){
        for(int k=low;k<=high;k++)
            B[k]=A[k];
        for(int i=low,j=mid+1,k=i;i<=mid&&j<=high;k++){
            if(B[i]<B[j])
                A[k]=B[i++];
            else
                A[k]=B[j++];
        }
        while(i<=mid)
            A[k++]=B[i++];
        while(j<=high)
            A[k++]=B[j++];
    }
    void MergeSort(ElemType A[],int low,int high){
        if(low<high){
            int mid=(low+high)/2;
            MergeSort(A,low,mid);
            MergeSort(A,mid+1,high);
            Merge(A,low,mid,high);
        }
    }
    
  4. 时空复杂度:

    时间复杂度:O(nlog2n)

    空间复杂度:O(n)

  5. 稳定!适用于顺序存储和链式存储。

8.2.5 基数排序

  1. 基数排序(不基于比较)

    借助“分配”和“收集”两种操作对单逻辑关键字进行排序,

    分为最高位优先(MSD)和最低位优先(LSD)。

  2. 以r为基数的最低位优先基数排序的过程:

    假设线性表由结点序列a0,a1,…,an-1构成,

    每个结点aj的关键字由d元组(kjd-1,kjd-2,…,kj1,kj0)组成

    0<=kji<=r-1(0<=j<n,0<=i<=d-1)

    例如:324 768 270 121 962 666 857 503 768,n = 9, d = 3, r = 10

    • 第i轮分配和收集(总共有d轮):

      在排序时使用r个队列Q0,Q1,…,Qr-1

      分配:开始时,把Q0,Q1,…,Qr-1各个队列置空,然后依欠考察每一个结点的关键字,若aj的关键字中kji=k,就把aj放入队列Qk当中

      收集:把Q0,Q1,…,Qr-1各个队列中的结点依次首位相接,得到一个新的结点序列,组成线性表
      d次分配收集后,序列会排成有序的序列

    • 第一轮分配

      在这里插入图片描述

      第一轮收集&第二轮分配

      在这里插入图片描述

      第二轮收集&第三轮分配&第三轮收集

      在这里插入图片描述

  3. 时空复杂度:

    时间复杂度:O(d(n+r))

    空间复杂度:O®

  4. 稳定!不基于比较

8.2.6 内部排序的比较和应用

  1. 比较

    排序算法时间复杂度空间复杂度稳定性
    最好平均最坏
    直接插入排序O(n)O(n2)O(n2)O(1)稳定
    冒泡排序O(n)O(n2)O(n2)O(1)稳定
    简单快速排序O(n2)O(n2)O(n2)O(1)不稳定
    希尔排序O(1)不稳定
    快速排序O(nlog2n)O(nlog2n)O(n2)O(log2n)不稳定
    堆排序O(nlog2n)O(nlog2n)O(nlog2n)O(1)不稳定
    二路归并排序O(nlog2n)O(nlog2n)O(nlog2n)O(n)稳定
    基数排序O(d(n+r))O(d(n+r))O(d(n+r))稳定
  2. 应用

    考虑因素:

    元素数目、元素大小、关键字结构及分布、稳定性、存储结构、辅助空间等

    1)若n较小时(n≤50),可采用直接插入排序或简单选择排序;若n较大时,则采用快排、堆排或归并排序。

    2)若n很大,记录关键字位数较少且可分解,采用基数排序

    3)当文件的n个关键字随机分布是,任何借助于“比较”的排序,至少需要O(nlog2n)的时间

    4)若初始基本有序,则采用直接插入或冒泡排序

    5)当记录元素比较大,应避免大量移动的排序算法,尽量采用链式存储

8.3 外部排序

8.3.1 外部排序的方法

  1. 外部排序通常采用归并排序方法

    首先,根据缓冲区的大小将外存上含有n个记录的文件分成若干长度为h的子文件,依次读入内存并利用有限的内部排序算法对它们进行排序,并将排序后得到的有序子文件重新写回外存,通常称这些有序子文件为归并段顺串

    然后,对这些归并段进行逐趟归并,使归并段逐渐由小到大直至得到整个有序文件

  2. 外部排序的总时间=内部排序所需时间+外存信息读写时间+内部归并所需时间

    tES = r * tIS + d * tIO +S(n-1)tmg

    r表示初始划分归并段的个数,tIS 表示每个归并段排序时间;

    d表示磁盘IO次数,tIO每次读写的时间

    S表示归并趟数,每一趟需要n-1,tmg一个记录取出最小关键字的时间

    20000个记录,初始归并段5000个记录:tES = 4* tIS + 3 * (4+4) * tIO +2 * 20000 * tmg

    归并趟数=取上界(logmr)。增加归并路数可以减少磁盘读写次数。

8.3.2 败者树——优化m

  1. S趟归并总共需要比较的次数:

    S(n-1)(m-1)=取上界(logmr)*(n-1)(m-1)

    n个关键字,m路归并段

  2. 失败树

    树形选择排序的一种变体,可视为一棵完全二叉树。

    每个叶结点存放各归并段在归并过程中当前参加比较的记录,内部结点用来记忆左右子树中的“失败者”,胜利者向上继续进行比较,直到根结点。

    在这里插入图片描述

  3. 将归并时比较次数缩减成:取上界(log2m)

    S(n-1)(m-1)=取上界(logmr) * (n-1) * 取上界(log2m)=取上界(log2r) * (n-1)

    通过失败树进行比较时,增加归并路数可以减少磁盘读写次数,同时不影响比较次数。但m过大时,每个缓冲区会减少,读写外存次数会增多,因此m并非越大越好。

8.3.3 置换-选择排序——优化r

  1. 置换-选择排序

    设初始待排序文件为FI,初始归并段文件为FO,内存工作区为WA,内存工作区可容纳w个记录。

    1)从待排序文件FI输入w个记录到工作区WA;

    2)从内存工作区WA中选出其中关键字取最小值的记录,记为MINIMAX;

    3)将MINIMAX记录输出到FO中;

    4)若FI未读完,则从FI输入下一个记录到WA中;

    5)从WA中所有关键字比MINIMAX记录的关键字大的记录中选出最小的关键字记录,作为新的MINIMAX;

    6)重复3)~5),直到WA中选不出新的MINIMAX记录位置,由此得到一个初始归并段,输出一个归并段的结束标志到FO中;

    7)重复2) ~6),直到WA为空。由此得到全部初始归并段。

  2. 示例

    在这里插入图片描述

8.3.4 最佳归并树

  1. m路归并排序可用一棵m叉树描述。

    归并树用来描述m归并,并只有度为0和度为m的结点的严格m叉树。

  2. 带权路径长度之和为归并过程中的总读记录数

    在这里插入图片描述

  3. 构造哈夫曼树:带权路径长度之和最小

    在这里插入图片描述

  4. 当叶子节点数不够时,增加权值为0的结点用来构造哈夫曼树。

  5. 补充的虚段个数

    设度为0的结点有n0个,度为m的结点有nm个,

    则对严格m叉树有n0=(m-1) nm+1,即得nm=(n0-1)/(m-1)

    ·若(n0-1)%(m-1)==0,则说明对于这个n0个叶结点(初始归并段)可以构造m叉树归并树

    ·若(n0-1)%(m-1)=u≠0,则说明对于这个n0个叶结点(初始归并段)其中有u个叶结点是多余的

    在这里插入图片描述

    多出u个结点,需要补充m-u-1个结点。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值