第七章 图

一、图的基本概念

  • 完全图:任意两个顶点之间有边相连的图(有向图有n(n-1)条边、无向图有n(n-1)/2条边)
  • 连通分量:不连通的图中的极大连通图
  • 强连通(有向图):任意两个顶点之间都有双向的路径(路径不一定直接相连)
  • 度:是出度和入度的总和
  • 生成树:连通图的生成树是包含图中全部顶点的一个极小连通子图。在非连通图中,连通分量的生成树构成了非连通图的生成森林
  • 极大连通子图和极小连通子图

二、图的存储结构

1、图的顺序存储结构

(1)邻接矩阵

  •     邻接矩阵的思想是,用一个二维矩阵来代表顶点和边,用矩阵的下标来代表顶点,矩阵edges[i][j]的值来代表i到j是否存在一条边。存在边记为1,不存在记为∞。
  •     邻接矩阵中的一行1的个数就是该顶点的出度,一列1的个数就是该顶点的入度。
①邻接矩阵的结构体定义
typedef struct
{
    int no;    //顶点的编号
    char info;    //顶点的其他信息 一般情况下不用写
}VertexType
②用邻接矩阵表示的图的定义
typedef struct    //图的定义
{
    int edges[maxSize][maxSize];
    //邻接矩阵定义,如果是有权图,把int换成char
    int n,e;      //分别是顶点数目和边的条数
    VertexType vex[maxSize];//存放节点信息
}MGraph;            //图的邻接矩阵类型
③使用instance
void f(MGraph G)    //参数是表示图的结构体变量G
{
    int a=G.n;    //取图的顶点数赋值给a
    int b=G.e;    //取图的边数赋值给b
    if(G.edges[i][j]==1)    //判断i和j之间是否有边
}

2、链式存储结构

(2)邻接表(有点像树的孩子表示法)

  • 邻接表的思想是把每个顶点和每个顶点的边存储成一个单链表的形式;图的邻接表分为两个部分,一是顶点表:用来存放顶点信息(一般是顶点的编号)和指向第一个边结点的指针(即指向直接相邻的顶点);二是边表,其中的结点存放的也是该顶点的信息(编号),指针指向头结点的其他相邻接结点。(注意!)
  • 需要注意的是,这样的存储结构每条边都会出现两次 
①邻接表存储表示的定义
typedef struct ArcNode    //边表节点结构体
{
    int adjV;    //边结点的序号
    struct ArcNode*nextarc;    //指向下一个邻接结点的指针
    /*int info*/    //存储结点的一些信息
}ArcNode;
typedef struct    //头顶点的结构体
{
    int data;    //头结点的标号
    ArcNode*first;    //指向第一个邻接结点的指针
}VNode;
typedef struct    //图的邻接表结构体
{
    VNode adjList[maxSize];    //头结点数组
    int n,e;        //节点数量和边的条数
}AGraph
  • 在有向表中,有邻接表也有逆邻接表。

(3)十字链表(难点

(4)邻接多重表(难点

三、图的遍历算法操作

一、深度优先搜索遍历(DFS---Depth-First-Search)

  • 类似于树的先序遍历

  1. 算法思想:一直往深处走,走不动就退回一个看能否接着往深处走
  2. 算法执行过程:任取一个顶点,访问它,检查这个顶点的所有邻接顶点,递归的访问其中未被访问过的顶点。
  3. 以邻接表为存储结构的图的深度优先搜索遍历算法
  • 先复习一下邻接表的结构体表示吧~
  • typedef struct ArcNode    //边结点、类似于树的孩子存储方式中的分支
    {
        int adjvex;    //这条边所指的顶点、即邻接顶点
        struct ArcNode*nextarc;    //指向下一个边结点的指针
    }ArcNode;
    typedef struct    //图的顶点结构体设计,与树的结点一样
    {
        int data;    //该顶点的编号
        ArcNode*firstarc;    //指向该顶点第一条边的指针
    }Vnode;
    typedef struct    //邻接表的结构体定义
    {
        VNode adjlist[maxSize];    //元素是顶点VNode类型的数组,下标包含了所有顶点的下标
        int n,e;    //图的顶点数目n和边的数目e
    }AGraph;

  • DFS具体的代码实现

int visit[maxSize];
/*v是起点编号,visit[]是一个全局数组,用来标记顶点是否已经被访问过了,
visit的初始值为全0,当访问到一个节点是把visit的值置为1*/
void DFS(AGraph*G,int v)    //传入的参数是邻接表表示的图G和起点元素编号
{
    ArcNode*p;    //设置一个边指针p
    visit[v]=1;    //把第一个顶点标记为“已访问”
    Visiti[v];    //指的是各种访问操作
    p=G->adjlist[v].firstarc;    //通过定点元素编号取到这个顶点VNode型,再取
                                 //该顶点的第一条邻接的边指针(ArcNode型)
    while(p!=NULL)        //边指针不为空即顶点有邻接的顶点时
    {
        if(visit[p->adjvex]==0)        //若邻接的顶点没有被访问
                                       //这里的p->adjvex是取了边指向的顶点
            DFS(G,p->adjvex);          //就对此顶点再用DFS(递归的思想)
        p=p->nextarc;           //访问完一个边再访问下一条边
    }
}

  • 这里如果把图的深度遍历中所经过的边留下,其余的边删掉就形成了图的深度优先搜索生成树

二、广度优先搜索遍历(BFS------Breadth-First-Search)

类似于树的层次遍历
算法思想:先访问一个顶点,然后访问这个顶点邻接的所有顶点,再依次访问这些顶点邻接的所有顶点(有一种一层一层访问的感觉,像剥洋葱!)
算法执行过程:先访问
例题7-1


int BFS(AGraph *G,int v)
{
    ArcNode*p;
    int visit[maxSize];
    int que[maxSize],front=0,rear=0;
    int j;
    int i;
    for(i=0;i<G->n;++i)
        visit[i]=0;
    visit[v]=1;
    rear=(rear+1)%maxSize;
    que[rear]=v;
    while(front!=rear)
    {
        front=(front+1)%maxSize;
        j=que[front];
        p=G->adjlist[j].firstarc;
        while(p!=NULL)
        {
            if(visit[p->adjvex]==0)
            {
                rear=(rear+1)%maxSize;
                que[rear]=p->adjvex;
                visit[p->adjvex]=1
             }
             p=p->nextarc;
        }
    }
    return j;
}


四、最小代价生成树

  • 贪心算法“贪”:每一步都要最好的;“好”:权重最小的边

  • 需要约束:只能用图里有的边、只能正好用掉V-1条边、不能有回路

1、prim算法----让一颗小树长大(适合稠密图)

(1)算法思想

    先构造一个根节点,然后一个一个收录顶点,从未收录的顶点中收录dist最小者;每收录进一个顶点,检查它的每个邻接点W,是否V的收录使W的dist变小了?如果变小则更新dist的值。这样的V不存在时,跳出循环。跳出循环有两种情况:
  • 顶点全部收录完毕----------算法执行成功

  • 未收录的顶点里面全都是dist=无穷大的点,即图不连通

  • 而什么叫收录一个顶点到生成树呢? 就是把结点到树的距离dist[V]设置成0*

Q:dist要怎么初始化呢?

  • A:dist的初始值是顶点到根节点的距离,那么,若dist和根节点有直接相连的边,初始化为边的权重;否则应该初始化为正无穷。*

(2)伪码实现

(3)时间复杂度 T=O(|V|2) 对于稠密图比较合算

2、Kruskal算法---------将森林合并成树(适合稀疏图)

(1)算法思想

    把图的每一个结点都看作是一棵树,每次收录不是收录结点,而是收录权值最小的边。只要这条边符合要求(不构成回路、没有超过V-1条),就把他收录,并把收录进的边和它所连接的结点看作生成树。

(2)伪码实现

(3)时间复杂度-----------T=O(|E|log|E|)


1、普利姆(Prim)算法

        ① 普里姆算法思想:先任取一个顶点,把他当成一棵树,然后取与这棵相连的边中权值最小的,把这条边及其所连接的顶点并入生成树、以此类推,树慢慢长大;直到没有与树相连的顶点时,最小生成树构建完成

在这个算法中,需要两个数组,一个是vset[ ],用来标记当前顶点v是否已经被收入生成树中,1为已经收入生成树,0为未收入。一个是lowcost[ ](可以理解为最小开销-即最短的权值),用来存放未收入的顶点到当前的树的最短边的权值。
             从图中某一个顶点v开始,构造生成树 
             Ⅰ.将v0的所有邻接边当成候选边;
           Ⅱ.重复以下步骤n-1次,直到所有的定点都被纳入生成树中。
                    A.从候选边中选出权值最小的边输出,并把与之相连的顶点并入生成树
                    B.更新所有剩余顶点的lowcost[ ]
        ③代码
void Prim(MGraph g,int v0,int &sum)
{
    int i,j,k;
    int v,min;
    v=v0;
    int vset[maxSize],lowcost[maxSize];
    for(i=0;i<g.n;++i)
    {
        vset[i]=0;
        lowcost[i]=g[v0][i];
    }
    vset[v0]=1;
    sum=0;
    for(i=0;i<g.n-1;++i)
    {
        min=INF;        //INF是一个已经定义的比图中所有边的权值都大的数
        for(j=0;j<g.n;++j)
            if(vset[j]==0&&lowcost[j]<min)
            {
                min=lowcost[j];
                k=j;
            }
        vset[k]=1;
        sum+=min;
        v=k;
        for(j=0;j<g.n;++j)
            if(vset==0&&g.edges[v][j]<lowcost[j])
                lowcost[j]=g.edges[v][j];
    }
}
④时间复杂度 O(n2
⑤适用于稠密图

2、克鲁斯卡尔(Kruskal)算法

①算法思想:每次取权值最小的边及这条边连接的两个顶点到生成树中。(如果取了这条边但是构成了一个回路那么这条边不能取)
②算法执行过程:把图中所有的边按照权值从小到大排序,从小到大扫描各条边,检测是否为候选边,如果是候选边,则并入生成树中,直到所有的边被检测完成。检测是否是候选边需要用到并查集。
③克鲁斯卡尔算法代码:
typedef struct    //图的边结点结构体定义
{
    int a,b;    //这条边连接的两个顶点
    int w;    //边的权值
}Road;
Road road[maxSize];    //    构建一个边结构体数组来存储图(新的数据结构!)
int v[maaxSize];    //定义一个并查集
int getRoot(int a)
{
    while(a!=v[a])
        a=v[a];
    return a;
}
void Kruskal(MGraph g,int &sum,Road road[])
{
    int i;
    int N,E,a,b;
    E=g.e;
    N=g.n;
    for(i=0;i<N;++i)
        v[i]=i;
    
    sort(road,E);
    for(i=0;i<E;++i)
    {
        a=getRoot(road[i].a);
        b=getRoot(road[i].b);
        if(a!=b)
        {
            v[a]=b;
            sum+=road[i].w;
        }
    }
}
④算法时间复杂度
取决于排序算法的选取,与图的边数E有关,与N无关;所以适用于稀疏图!
注:Prim和Kruckal都适用于无向图

五、最短路径

1、迪杰斯特拉算法(重点)(求图中某一个顶点到其余顶点的最短路径)

(1)算法思想:
    ①将所有的顶点分成两部分:S,已确定最短路径的顶点集合,T:剩余结点。
    ②从v0选一条与其邻接的边的权值最小的顶点,这个顶点就是最短路径确定的顶点;然后再从没有确定最短路径的顶点里面选出路径最短的点并入确定了最短路径的顶点集合S;
    ③需要注意的是,每并入一个新的顶点,可能会对未并入的顶点的最短路径产生影响:具体影响是,并入的点提供了一条到达未并入顶点j的新的路径,而这条路径可能比先前记录的短,所以每并入一个顶点,要看是否更改最短路径的值
    ④这个算法需要引进三个数组
        Ⅰ.dist[ ]用来记录当前已经找到的从起始到vi的最短路径长度;它初始值为:若与v0有边,则为边的权值,若没有边,则为正无穷。(就是邻接矩阵中的g[v0][vi])
        Ⅱ.path[ ]用来记录v0到vi的最短路径上vi的前一个顶点
        Ⅲ.set[ ]用来记录顶点是否已经并入了最短路径。1为已经并入,0为没有并入。
(2)算法执行过程:
    ①从当前的dist[ ]数组中选出dist[ ]最小的顶点,并入。即set[v]=1;
    ②循环扫描图中顶点,做如下检测:
        若set[vj]=1,则不操作;若set[vj]==0,则比较dist[vj]与dist[vu]+<vu,vj>的大小,如果后者小就更新dist[ ],并且把uv存为path[vj]。
    ③对①和②步循环n-1次
(3)算法代码
    ①输出path(用栈实现逆序)
void printfPath(int path[],int a)    //输出到某个顶点a的路径
{{
    int stack[maxSize],top=-1;
    while(path[a]!=-1)
    {
        stack[++top]=a;
        a=path[a]
    }
    stack[++top]=a;
    while(top!=-1)
        cout<<stack[top--]<<" ";
    cout<<endl;
}
    ②迪杰斯特拉算法代码
 
void Dijkstra(MGraph g,int v,int&path[],int &dist[])
{
    int i,j;
    int set[maxSize];
    int min,u;
    for(i=0;i<g.n;++i)
    {
        dist[i]=g.edges[v][i];
        if(dist[i]<INF)
            path[i]=v;
        else
            path[i]=-1;
        set[v]=0;
    }
    set[v]=1;path[v]=-1;
    /*初始化结束*/
    /*开始关键操作*/
    for(i=0;i<g.n-1;++i)
    {
        min=INF;
        /*找出一个dist最小的点,加入到最短路径里*/
        for(j=0;j<g.n;++j)
            if(set[j]==0&&dist[j]<min)
            {
                min=dist[j];
                u=j;
            }
        set[u]=1;
        /*看新加入的顶点对j的最短路径有没有影响*/
        for(j=0;j<g.n;++j)
            if(set[j]==0&&dist[u]+g.edges[u][j]<dist[j])
            {
                dist[j]=dist[u]+g.edges[u][j];
                path[j]=u;
            }
    }
}

(4)时间复杂度 O(n2

2、弗洛伊德算法Floyd(求任意两个顶点之间的最短路径)

(1)算法思想:用邻接矩阵来表示图;对图中的每一对顶点对,依次以图中所有的顶点为中间点,检测最短路径长度有无更新,如果有的话也要同时更新path数组。
(2)实现代码
    ①递归的输出最短路径
void printfPath(int u,int v,int path[][max],int A[][max])
{
    if(A[u][v]==INF)
        cout<<"NONE"<<endl;
    else
    {
        if(path[u][v]==-1)
            直接输出边<u,v>;
        else
        {
            mid=path[u][v];
            printfPath(u,mid,A);
            printfPath(mid,v,A);
        }
    }
}
    ②弗洛伊德算法
void Floyd(MGraph *g,int Path[][maxSize],int A[][maxSize])
{
    int j,k,i;
    for(i=0;i<g->n;++i)
    {
        for(j=0;j<g->n;++j)
        {
            Path[i][j]=-1;
            A[i][j]=g->edges[i][j];
        }
    }
    for(k=0;k<g->n;++k)
        for(i=0;i<g->n;++i)
            for(j=0;j<g->n;++j)
                if(A[i][j]>A[i][k]+A[k][j])
                {
                    A[i][j]=A[i][k]+A[k][j];
                    Path[i][j]=k;
                }
}
③时间复杂度O(n3

六、拓扑排序

1、拓扑排序问题

(1)拓扑序:如果图中的v到w有一条有向路径,则v一定排在w之前。满足这种条件的有顶点序列成为一个拓扑序。
获得一个拓扑序的过程就是拓扑排序;要获得一个合理的拓扑序,则图一定是有向无环图DAG
(2)在一个有向图中找到一个拓扑排序的过程如下:
        ①选择一个没有前驱(入度为0)的顶点输出
        ②删除①中的顶点,并且删除从该顶点发出的全部边
        ③重复上述两步,直到剩余的图中不存在没有前驱的顶点为止(为空或者为环)

2、关键路径问题

AOE(Activity On Edge)网络:活动在边上的网络
关键路径:绝对不允许延误的活动组成的路径

1、AOV网(Activity On Vertex network

     以顶点表示活动,以边表示活动的先后次序且没有回路的有向图

2、拓扑排序核心算法(时间复杂度O(n+e))
(1)对邻接表表头结构定义修改,加上一个统计结点入度的计数器
typedef struct
{
    char data;
    int count;
    ArcNode *firstarc;
}VNode;
(2)拓扑排序的代码如下
int topsort(AGraph*G)    //用邻接表,因为邻接表很容易查到顶点引出的边
{
    int i,j,n=0;
    int stack[maxSize];int top=-1;    //定义并初始化栈
    ArcNode *p;
    for(i=0;i<G->n;++i)    //这个循环将图中所有入度为0的顶点入栈
    {
        if(G->adjlist[i].count==0)
            stack[++top]=i;
    }
/*关键操作开始*/
    while(top!=-1)    //栈中的元素逐个出栈
    {
        i=stack[top--];
        ++n;
        cout<<"i"<<" ";    //输出拓扑序列
        p=G->adjlist[i].firstarc;
        while(p!=NULL)    //这个循环实现了将所有由顶点引出的边所指向的顶点的入度减一,并将这个过程中入度变成0的顶点入栈
        {
            j=p->adjvex;
            --G->adjlist[j].count;
            if(G->adjlist[j].count==0)
                stack[++top]=j;
            p=p->nextarc;
        }
    }
    if(n==G->n)
        return 1;
    else
        return 0;
}
注意:
(1)拓扑排序序列可能不唯一;
(2)若AOV网中考察各顶点的出度并以下列步骤进行排序,则将这种排序称为逆拓扑排序,输出的结果称为逆拓扑有序序列
        ①在网中选择一个没有后继的顶点(出度为0)输出
        ②在网中删除该顶点,并删除所有到达该顶点的边
        ③重复上述两部,直到AOV网中已无出度为0的顶点为止
(3)当图中无环的时候,还可以采用DFS的方法进行拓扑排序,由于图中无环,当由图中某顶点出发进行深度优先遍历时,最先退出算法的顶点为出度为0的顶点,他是拓扑有序序列中的最后一个顶点,因此,按照DFS算法的先后次序记录下的顶点序列即为逆向的拓扑有序序列
(4)拓扑排序算法的时间复杂度为O(n+e),每个顶点都入栈一次,每条边都被删除一次

七、关键路径

1、AOE网(Activity On Edge network)

    活动在边上的网:边表示活动,有权值。顶点表示事件,事件是图中新活动开始或者旧活动结束的标志。

    对于一个工程的AOE网,只有一个入度为0的顶点(源点)表示工程的开始,一个出度为0的顶点(汇点),表示工程的结束。

2、关键路径核心算法

    在AOE网中,从源点到汇点的所有路径中,具有最大路径长度的路径称为关键路径。完成整个工期的最短时间就是关键路径长度所代表的时间,关键路径上的活动称为关键活动。
    关键路径的概念代表了一个最长(图中最长路径),一个最短(工程要完成需要的最短时间)
3、手工求解关键路径的一般方法
(1)根据图求出拓扑有序序列a和逆拓扑有序序列b
(2)根据序列a和b分别求出每个事件的最早发生时间和最迟发生时间
(3)求出每个活动的最早发生时间和最迟发生时间
(4)最早发生时间和最迟发生时间相同的活动即为关键活动,关键活动所连成的路径即为关键路径 

王道补充

有向无环图DAG可以用来描述含有公共子式的表达式

第八章 排序

一、插入排序

1、直接插入排序

①执行流程

②算法思想

void InsertSort(int R[],int n)
{
    int i,j;    //两层循环
    int temp;        //暂存当前的待排元素
    for(i=1;i<n;++i)    //对每个元素
    {
        temp=R[i];       //暂存当前待排元素,防止元素被覆盖
        j=i-1;
    /*这个循环完成了从待排关键字之前的关键字开始扫描,如果大于待排关键字,则后移一位*/
        while(j>=0&&temp<R[j])    //比当前元素大的元素后移
        {
            R[j+1]=R[j];
            --j;
        }
        R[j+1]=temp;
    }
}

③算法复杂度分析

Ⅰ时间复杂度

    最坏O(n²);最好O(n);平均O(n²)

Ⅱ空间复杂度

       O(1)

2、折半插入排序

①执行流程

②算法思想

    只是在查找插入位置的时候使用了二分法来比较,移动元素的次数还是不变的,所以最坏的时间复杂度还是n2

③算法复杂度分析

Ⅰ时间复杂度

    最好情况O(nlogn) 最坏情况O(n²)

Ⅱ空间复杂度

O(1)

3、希尔排序

①执行流程

②算法思想

    缩小增量排序。增量序列的选择影响着排序算法的效率。
void ShellSort(int R[],int n)
{
    int i,j,gap;
    int temp;
    for(gap=n/2;gap>0;gap=gap/2)    //对每一组增量进行操作,直到增量减小为1
    {
        for(i=gap;i<n;++i)    //对每一个分组的第二个元素开始进行插入排序
        {            //第一个元素认为是有序的了,是分组进行的先第一组,然后第二组*/
            temp=R[i];//先暂存序列的第一个元素因为要为这个元素找到合适的位置插入;
            for(j=i;j>=gap&&R[j-gap]>R[j];j-=gap)    //比较待排元素i元素前面的
                                        //元素值是不是比i位置的小,注意这里的步长是gap
                R[j]=R[j-gap];    //把数值更大的往后移,因为用temp暂存了初始j(即i)位置上的值,所以覆盖掉也没关系
            R[j]=temp;    //把temp即待插入的元素插入到循环跳出(即要么j<gap,已经没有
                          //元素可比或前面的有序序列中已经前面的比后面的小
        }
    }
}

③算法复杂度分析

    算法复杂度与增量的选取有关
    希尔排序增量的选取应该注意的地方:
        ①增量序列的最后一个值一定取1
        ②增量序列中的值应尽量没有除1以外的公因子(即要互质)

Ⅰ时间复杂度

    eg.n/2、n/4、...2、1:复杂度O(n²)
    eg.帕培尔诺夫和斯塔舍维奇提出的2k-1、...、65、33、17、9、5、3、1:复杂度O(n1.5)

Ⅱ空间复杂度 

    O(1)

二、选择排序

1、简单选择排序

①执行流程

②算法思想:从头到尾扫描序列,找出最小的关键字和第一个关键字交换;在剩下的关键字中反复进行这种选择和交换。

viod SelectSort(int R[],int n)
{
    int i,j,k;
    int temp;
    for(i=0;i<n;++i)
    {
        k=i;
        for(j=i+1;j<n;++j)
            if(R[j]<R[k])
                k=j;        //找出了序列中的最小值是第k个元素
        temp=R[i];
        R[i]=R[k];
        R[k]=temp;
    }
}

③算法复杂度分析

Ⅰ时间复杂度

    没有好坏,一定是两层循环O(n²)

Ⅱ空间复杂度

    o(1)

2、堆排序

(1)堆

    ①堆的逻辑结构:是一个完全二叉树,它的任何一个非叶子结点的值都不大于(或不小于)其左右孩子结点的值。根据父结点的是大还是小,可以分成最大堆(大顶堆)、最小堆(小顶堆)。
    ②堆的存储结构:用一个顺序数组来存储,父结点位置为i时,左孩子结点位置为2i+1,右孩子结点位置为2i+2(结点下标在数组中从0开始;如果从1开始,就是2i和2i+!)最后一个非叶结点的编号为[n/2」-1(向下取整)
    ③建堆
    ④插入节点
    ⑤删除结点:删掉头结点,然后用最后一个结点A替代头结点,对A进行调整
    ⑥时间复杂度O(h)是堆的高度!

(2)堆排序

①调整函数

/*这个函数是调整堆成为一个最大堆的函数*/
void Shift(int R[],int low,int high)//这里规定数组下标从1开始
{
    int i=low,j=2*i;    //R[j]是R[i]的孩子结点
    int temp=R[i];    //待调整的结点
    while(j<=high)
    {
        if(j<high&&R[j+1]>R[j])    //有右孩子并且右孩子更大
        ++j;        //使j指向两个孩子中更大的那个
        if(R[j]>temp)
        {
           R[i]=R[j];
           i=j;        //换完之后必须向下走,看看下面是不是又有要调整的地方。
           j=2*i;
        }
        else
           break;
    }
    R[i]=temp;        //被调整结点的值放入最终位置
}

②堆排序函数


/*这个函数是堆排序函数*/
void heapSort(int R[],int n)    //根据最大堆进行排序的代码
{
    int i;
    int temp;
    for(i=n/2;i>=1;--i)
        Shift(R,i,n);
    for(i=n;i>=2;--i)    //只需要进行n-1次循环即可,最后一个不用再排
    {
        temp=R[1];
        R[1]=R[i];
        R[i]=temp;
        Shift(R,1,n-1);
    }
} 


(3)算法性能分析

    ①时间复杂度:O(nlogn) 
    ②空间复杂度:O(1)

三、交换排序

1、冒泡排序

①执行流程

②算法思想:若前面的数比后面的大,就进行一次交换;每一趟排序都是最大的那个数被排到了尾部。

void BubbleSort(int R[],int n)
{
    int i,j,flag;
    int temp;
    for(i=n-1;i>=1;--i)    //每次排序前i个元素,因为后面的元素已经有序啦
    {
        flag=0;    //这一句初始化的定义不可以放到循环外面!!! 
        for(j=0;j<i;++j)
        {
            if(R[j]<R[j+1])
            {
                temp=R[j+1];
                R[j+1]=R[j];
                R[j]=temp;
                flag=1;
            }
        }
        if(flag==0)
            return;    //表示没有发生关键字交换,即序列已经是有序的了
    }
}

③算法复杂度分析

Ⅰ时间复杂度

    最好情况:本来就是有序,只扫描了一趟,比较了一趟O(n),否则O(n²)

Ⅱ空间复杂度

    O(1)

2、快速排序

①算法概述

分而治之:选一个主元,分成两大块(一块大于主元,一块小于主元),递归地处理两块的数据。处理时候的排序是通过“交换”来实现的,交换的是i和j的位置,不是相邻的元素,所以一次可能消除多对逆序对,并且经过一趟排序以后,主元的位置就是它最后在有序序列中的位置,不用再移动了。
void quickSort(int R[],int low,int high)
{
    int i=low,j=high;    //low和high作为传参是全局变量,定义i,j做函数内的循环变量
    int temp;
    while(low<high)    //递归函数的出口,当待排序列等于一时就退出递归
    {
        temp=R[low];    //这里选取第一个元素为主元,暂存他的值
        while(i<j)    //i<j时,进行i、j指针的移动和元素比较
        {
            while(i<j&&R[j]>=temp)
                --j;    //j顺次前移,直到找到比主元小的值(非正常)
            if(i<j)
            {
                R[i]=R[j];    //把找到的这个比主元小的值换到i的位置(即左边
                i++;    //i就往右边再移一位
            }
            while(i<j&&R[i]<=temp)
                ++i;    //i顺次后移,直到找到比主元temp大的值(非正常)
            if(i<j)
            {
                R[j]=R[i];    //把找到的这个比主元大的值换到左边(即刚刚非正常的j的位置
                --j;
            }
        }
        R[i]=temp;    //把temp放在排好序的地方。
        quickSort(R,low,i-1);    //递归
        quickSort(R,i+1,high);
        }
    }
}

②关键细节

Ⅰ.选主元:eg.取头、中、尾的中位数

③适用于大规模的数据 

④算法复杂度分析

Ⅰ时间复杂度

    最好情况O(nlogn) 最坏情况O(n²)

Ⅱ空间复杂度

    因为是递归的,所以占用了系统栈:O(log2n)

四、归并排序(稳定) NlogN 

(1)有序子列的归并

五、基数排序

(1)桶排序

(2)基数排序(进制的基数)

①次位优先LSD(可以实现从小到大的排序);

    主位优先MSD

②多关键字的排序

eg:把次关键字看作是个位,主关键字看作是更高位

次位优先,先按照个位数放进桶里,再按照十位数放进桶里,最后就是有序的

另:表排序;复杂度分析 
物理排序:N个数字的排列由若干独立的环组成;所以可以对每个环进行操作。判断table[i]=i是,环结束
                复杂度:最坏T=(mN),m是每个元素的复制时间。

六、外部排序

1、概念与流程

(1)基本概念:对外存中的记录进行排序(外存中记录的规模太大了),将内存作为工作空间来辅助外存数据的排序。

(2)流程解析:归并排序

    k路归并:假设待排序的序列有n个记录,把它分成m个规模比较小的记录段(记录段的长度不一定相同),然后每k个记录段为一组(一共m/k组),进行k路归并排序。然后得到m/k组有序的记录段,再按照每k个为一组,进行k路归并排序,直到排序完成。用了k个内存空间。(若k=2,就是二路归并排序啦)

(3)重要子算法

    1)置换-选择排序

        ①概念:适用于初始归并段规模、高效、不受内存空间限制的排序算法。
        ②算法思想、操作流程:根据缓存区的大小,从外存读入记录;(eg.缓存区的大小是4,那就先读入4个记录)
            然后从缓存区中选出一个最小的输出(假设要进行升序排序)写回外存,用下一个记录替代这条记录,输出的部分就是当前生成的归并段的一部分(需要注意的是,归并段应该是递增的)
            因此,如果新输入的记录比现在输出生成的归并段的最大值要小的话,就不能输出,要选第二小的输出,如果还是小,选第三小的,以此类推重复进行上述操作
            直到缓冲区的所有元素都比归并段的最大值小,那么这一段归并段生成完毕,开始生成下一段归并段。
        ③注意:限制排序算法的内存空间,就是指这里的缓冲区。
                      由上述算法不难发现,得到的归并段的长度不一定相同,再用最佳归并树来让归并操作的次数最少。

     2)最佳归并树

        ①概念:每个记录在每趟归并中需要两次I/O操作(读一次,写一次),读写操作是很慢的,因此尽可能的减少归 并  次数可以有效的提高排序的速度。这里用到的用来减少归并次数的数据结构是“最佳归并树”。
        ②算法思想、操作流程:
            树的结点代表当前归并段的长度;树的带权路径长度的两倍就是整个归并操作需要的I/O次数;对于初始归并段,其结点的路径长度就等于其参加归并的次数。
            因此,为了使I/O操作最少,要生成最佳归并树,可以用赫夫曼树的构造方法。对于k路归并算法,构造k叉赫夫曼树。

    3)败者树

        ①概念:用来在当前的k个值中选出最值,败者树可以提高这一过程的效率;是选择树(类似于堆)
        ②算法思想、操作流程:
            A.建立败者树    (时间复杂度是k)
                Ⅰ.用每个结点的编号值作为他的父结点,构成了k个树,再把这些树合并;
                Ⅱ.合并的规则(以构建最小值败者树为例:关键值大的是败者):比较序号所指的关键字的大小,然后以败者的序号为根节点组成新树,胜者的序号作为这棵新树的父结点。以此类推,当所有结点都并入树中的时候,树的根结点指的记录值是最小的。
                Ⅲ.需要注意的是,这里是以序号为根节点来建立败者树的,不是用记录值。
            B.调整败者树    (时间复杂度只有树的高度,即log
                Ⅰ.就是局部重新构造败者树的过程,所以时间复杂度只有树的高度;
败者树中含有两种不同类型的结点
第一种:叶子结点,其值为从当前参与归并的归并段中读入的段首记录。叶子结点的个数为当前参与归并的归并段数,即k路归并叶子数为k
第二种:非叶结点,都是度为2的结点,其值为叶子结点的序号,同时也指示了当前参与选择的记录所在的归并段
由序号构造结点的好处是不仅可以找到记录值,还可以找到其所在的归并段,以便下一个记录读入内存取代刚选出的值
外部排序的归并排序有两个基本阶段,分别是生成初始归并段和从归并序列中选出最小值;m个归并段进行k路归并需要的归并趟数是[logkm]向上取整
k路归并败者树高度为[logk]+1因此利用败者树从k个记录里选出最值需要进行[log2k]次比较,即时间复杂度是O(log2k),k路归并败者树的时间复杂度为O(klog2k)

七、排序算法的比较

复杂的排序算法 最好时间复杂度 平均时间复杂度 最坏时间复杂度 空间复杂度 稳定性
希尔排序(插入排序)
O(nlog2n)

不稳定
堆排序 O(nlog2n)
O(nlog2n)
O(nlog2n)

不稳定
归并排序 O(nlog2n)
O(nlog2n)
O(nlog2n)
O(n)
快速排序 O(nlog2n)
O(nlog2n)
O(n2)
O(log2n)
不稳定
基数排序
O(d(n+rd))
O(rd)

简单的排序算法





插入类排序
直接插入排序
O(n) O(n2)


交换类排序
冒泡排序
O(n) O(n2)


选择类排序
简单选择排序

O(n2)

不稳定

快希归堆是平均时间复杂度为nlogn
快希选堆是不稳定的排序算法
经过一趟排序,能够保证一个关键字达到了最终的位置:交换类:起泡排序,快速排序;选择类:简单选择,堆排序
关键字比较次数与原始序列无关:简单选择排序,折半插入排序
排序算法的排序趟数与原始序列有关:交换类的排序
借助于“比较”进行排序的算法,在最坏情况下的时间复杂度至少为O(nlog2n)

选择排序算法小结

(1)若待排元素数目较少,则可以用简单选择排序/直接插入排序
(2)若文件的初始状态已按关键字基本有序,则选用直接插入排序/冒泡排序
(3)若待排元素n较大,则应采用时间复杂度为O(nlog2n)的 快、希、归、堆;堆排序需要的辅助空间少于快速排序,且不会出现快速排序可能出现的最坏情况;
若又要求采用稳定的时间复杂度为O(nlog2n)的排序,则只能用归并排序;可以先用直接插入排序求得较长的有序子文件,再两两归并,直接从单个记录开始进行两两归并的而算法不是很好
(4)基于比较的排序方法中,每次比较两个关键字的大小之后,仅出现两种可能的转移,因此可以用一棵二叉树来描述比较判定过程,由此可以证明,当文件的n个关键字随机分布时,任何借助于“比较”的排序算法,至少需要O(nlog2n)的时间
(5)若待排元素的数目n很大,记录的关键字位数较少且可以分解时,采用基数排序好
(6)当记录本身信息量较大时,为避免耗费大量时间移动记录,可用链表作为存储结构


第九章 查找

一、查找的概念、顺序查找、折半查找

1、查找的基本概念

(1)查找

            用记录的关键字来进行查找;查找有两个要素:一是用什么样的数据结构来表示查找表;二是查找表中关键字的次序,即是有序查找还是无序查找。

(2)查找的性能比较

            平均查找长度ASL(平均比较次数):基本操作是关键字的比较次数,ASL把对每个关键字的查找次数加权平均,就得到了最终的ASL。ASL分为成功ASL和失败ASL。此时单独查找操作的时间复杂度就是ASL。

2、顺序查找法

(1)算法思想:

        从表的一端开始,顺序扫描线性表,依次将扫描到的值与关键字进行比较,如果相等则查找成功,返回此时的位置;若扫描结束后仍没有发现和k相等的元素,查找失败。

(2)采用的数据结构:顺序表或链表都可以。

int Search(int R[],int n,int key)    //一维数组进行查找
{
    int i;
    for(i=0;i<n;++i)
        if(intR[i]==key)
            return i;
    return 0;
}
LNode *Search(LNode *head,int key)    //基于链表的查找
{
    LNode*p=head;
    while(p!=NULL)
    {
        if(p->data==key)
            return p;
        else
            p=p->next;
    }
    return NULL;
}

3、折半查找法(Binary)

(1)算法思想:递归

(2)数据结构:必须是顺序表(为了实现随机存取)、描述折半查找的判定树(要会画!!!)

(3)实现代码:
int Bsearch(int R[],int low,int high,int key)
{
    int mid;
    while(low<=high)    //子表长度大于等于1的时候进行循环
    {
        mid=(low+high)/2;
        if(R[mid]==key)
            return mid;
        else if(R[mid]>key)
            high=mid-1;
        else
            low=mid+1;
    }
    return -1;
}

一般不把和空指针的比较计算进ASL的步数里面

(4)时间复杂度 O(logn),折半查找的ASL只与关键字的个数有关,相同数量关键字的折半查找的ASL相同。判定树高度的公式log2n+1(向下取整)

4、分块查找

(1)数据结构:对线性表再建立一个索引表,表中的每一项对应线性表中的一块;索引表的每一项包括两个分量,一个是键值,用来存放对应块中的最大元素;一个是链值,存放指向本地第一个元素和最后一个元素的指针。索引表中的所有项都是按照其关键字递增顺序排列的。

typedef struct    //结构体定义
{
    int key;
    int low,high;
}indexElem;
indexElem index[maxSize];

(2)算法描述:先在索引表进行二分查找到在哪一块;再在一个块中进行顺序查找。时间复杂度为两步的和。

二、二叉排序树与平衡二叉树

1、二叉排序树

(1)定义与存储结构

    ①定义:左小右大。中序遍历序列是非递减有序的。

    ②存储结构:二叉链表
typedef struct BTNode
{
    int key;    //这里把data改成了key,表示关键字
    struct BTNode*lchild;
    struct BTNode*rchild;
}BTNode;

(2)基本算法(查找,插入,构造,删除) 

    ①查找关键字(折半查找,折半查找的判定树就是一棵二叉排序树 )
/*BTNode*BSTSearch(BTNode*bt,int key)
{
    BTNode*p=head;
    while(p!=NULL)
    {
        if(p->key==key)
            return p;
        else if(p->key>key)
            p=p->lchild;
        else
            p=p->rchile;
    }
    return NULL;
}*/    //不知对错??????对啦!!!!!!!!!!好牛牛!!!!但不怎么考,常考是下面的递归
BTNode*BSTSearch(BTNode *bt,int key)
{
    if(bt==NULL)
        return NULL;
    else
    {
        if(bt->key==key)
            return bt;
        else if(key<bt->key)
            return BSTSearch(bt->lchild,key);    //因为是递归地,所以必须逐层的返回值给上一层
        else
            return BSTSearch(bt->rchile,key);
    }
}
   return只能结束当层的函数!
     ②插入关键字
        插入关键字之前先找到插入位置,对不存在于二叉排序树中的一个关键字,其查找不成功的位置即为该关键字的插入位置。若带插入关键字已经存在,则返回0,表示查找不成功。
int BSTInsert(BTNode *&bt,int key)    //这里的要用引用型!
{
    if(bt==NULL)
    {
        bt=(BTNode*)malloc(sizeof(BTNode));
        bt->key=key;
        bt->lchild=rchild=NULL;
        return 1;
    }
    else
    {
        if(bt->key==key)
            return 0;
        else if(bt->key>key)
            return BSTInsert(bt->lchild,key);
        else
            return BSTInsert(bt->rchild,key);
        }
}
    ③构造排序二叉树(假设元素已经存在了数组key[ ]中)
void CreateBST(BTNode*&bt,int key[],int n)
{
    int i;
    bt=NULL;
    for(i=0;i<n;++i)
        BSTInsert(bt,key[i]);
}
    ④删除关键字(手工操作,代码不是重点)
        caseⅠ:要删除的结点是叶子结点:直接删除
        caseⅡ:要删除的结点只有右子树或只有左子树:删除之后把子树直接链接到删除结点的父节点的指针上。
        caseⅢ:要删除的结点既有左子树又有右子树:变换成caseⅠ或caseⅡ,具体方法是:沿着要删除的结点p的左子树的根结点的右指针一路向右,找到其左子树的最右边一个结点r(即比它小的结点中最大的那个)(或者沿着它右子树的根节点的额左指针一路向左,即比它大的所有结点在最小的那个),然后将p中的关键字用r中的关键字代替,然后删除r(按照caseⅠ或caseⅡ)

        二叉排序树与二分查找的异同
            从查找过程看,二叉排序树与二分查找相似;
            就平均时间性能而言,二叉排序树上的查找和二分查找差不多。但二分查找的判定树唯一,而二叉排序树的查找不唯一,相同的关键字其插入顺序不同可能生成不同的二叉排序树
            就维护表的有序性而言,二叉排序树无需移动结点,只需修改指针即可完成插入删除操作,平均执行时间为O(log2n),二分查找的对象是有序顺序表,若有插入和删除结点的操作,所花的代价是O(n)
            当有序表是静态查找表是,宜用顺序表作为其存储结构,而采用二分查找实现其查找操作;若有序表是动态查找表,则应选择二叉排序树作为其逻辑结构

2、平衡二叉树(AVL树)

(1)平衡二叉树的概念

    ①一种特殊的二叉排序树。所有结点的左右子树的高度之差不超过1。
    ②平衡因子:结点左右子树高度的差。平衡树的所有的结点平衡因子是0,1,-1三种取值;斐波那契数列;
    ③平衡二叉树是排序二叉树的改进:因为对于一颗查找树,树越矮查找的效率越高。
    ④平衡二叉树的平均查找长度为O(log2n)

(2)平衡二叉树的建立

    就是建立排序二叉树的过程:不断插入结点。与之不同的是要检测新插入的结点是否让树中产生了平衡因子大于1的结点,重点是进行平衡调整

(3)平衡调整🧐重点!!!!

先找到最小不平衡子树;再根据最小不平衡子树的根结点和引起不平衡结点产生的矛盾结点的相对位置进行平衡二叉树的调整:①矛盾结点在不平衡结点的右子树的右边:RR旋转(右单旋)、左子树的左边:LL旋转(左单旋)、左子树的右边:LR旋转、左右旋转、右子树的左边:RL旋转,右左旋转
我发现一个规律,就是选待调整的三个结点中中间值的结点作为根结点,另两个结点按照平衡二叉树(排序二叉树、查找二叉树、搜索二叉树,怎么叫都行)的构造规则挂在这个结点两边,就完成了这次的调整。然后剩下的子树就按顺序挂上就可以
在做题的时候,要记住的是调整的名称到不平衡状态的一一映射
LL调整:对应新插入节点落在最小不平衡子树根结点的左(L)孩子左(L)子树
RR                                                                                 右(R)孩子右(R)子树
RL                                                                                  右(R)孩子左(L)子树
LR                                                                                  左(L)孩子右(R)子树

关于平衡二叉树的结点数目的问题https://blog.csdn.net/weixin_38233103/article/details/107771272

三、B树

1、B-树的基本概念

    树的阶是树中各个结点孩子结点数目的最大值,用m表示(这里的阶就相当于树的度),查找效率要高,m>=3。可以看作平衡二叉树的一个扩展,是平衡n叉树。而且B树限制更强,还要求所有的叶子结点都在同一层。
    m阶的B-树是空树或满足下列要求的m叉树:
    (1)每个结点最多有m个分支(子树);最少分支数视情况而定,如果是根结点则最少有2各分支,非根非叶结点最少有m/2(向上取整)个分支;
    (2)有n个分支的结点有n-1个关键字,按递增的顺序排列。其中k<=n<=m,最少分支k=2(根结点)或m/2(非根结点)
    (3)每个结点的结构为

    其中pi所指向的结点关键字小于keyi+1大于keyi(跟二叉平衡树一样,也是一棵排序树、搜索树,满足左小右大)由此可见的B-树的一个重要特性就是,下层结点内的关键字取值总是落在由上层结点关键字所划分的区间内的,具体落在哪个区间可由指向它的指针看出。
    (4)结点内各关键字互不相同且从小到大排列
    (5)叶结点都在同一层,并且用空指针表示,是查找失败的标志(实际应用中这里的指针指向叶子结点,这个结点里标志查找失败的位置和其他有用的信息等)
    B树的阶数是人为规定的,不以树中存储的最大关键字的个数变化,实际上应该是每个结点中可以存放的关键字的个数加一是阶数。  

2、B-树基本操作

(1)查找
(2)插入
    根据阶数确定每个结点最多的关键字数。插入,结点拆分:把当前结点m/2(向上取整)上的节点拿出来单独作为一个结点(或并入上一层结点中),他左边和右边的作为这个结点的孩子结点分别挂在左右两边。
    插入就是查找插入的位置,然后再插入。成最底层的非叶结点为终端结点,易得,新插入的结点总是落在终端结点上的,若插入过程中破坏了B树的特性,就要进行结点的拆分。若插入一个关键字之后出现多次拆分的情况成为连锁反应。
(3)删除
    ①要删除的结点是终端结点:A.若删除关键字不会引起B树的性质变化(关键字数目仍然满足k<=n<=m),直接删除;
    ②要删除的关键字不在终端结点(既有左子树又有右子树):类比二叉排序树既有左子树又有右子树的删除方法:向左走一步再不断向右走或向右走一步再不断向左走。(即找比它小的关键字(左子树)里最大的或者比它大的关键字(右子树)中最小的)来替代这个关键字,然后删除找到的这个终端结点上的关键字。
    ③在删除关键字的时候可能会出现破坏B树结构的情况发生,在这个时候要进行适当的调整:Ⅰ向兄弟结点借关键字或者和其他孩子结点进行关键字的交换节点的合并
    相邻关键字:对于不在终端结点上的关键字来说,它的相邻关键字是其左子树上的最大的关键字(最右边)或右子树上最小的关键字(最左边) 

3、B+树的基本概念

    只要与B树进行区分记忆即可。
    (1)B+树是关键字引出指针,所以B+树中,有n个关键字就有n个分支;而B树中有n个关键字的结点有n+1个分支。
    (2)因此每个结点中关键字的数量取值范围也不相同。
    (3)B+树中叶子结点包含信息,或者说,非叶结点都是起索引作用的,没有什么实际的含义,叶子结点中包含了所有的关键字,只有叶子结点引出的指针指向了所有待查找的记录。
    (4)B树上有一个指针指向关键字最小的结点,并把所有的叶子结点链接成一个线性链表。

四、散列表     

1、散列表概念

    根据关键字,通过哈希函数就可以算出哈希地址。哈希表的关键就是怎么构造哈希函数,如何解决哈希函数中的冲突,怎么通过哈希函数来找到关键字的位置.
    又叫哈希表,可以实现动态查找,查找的复杂度是O(1);生成哈希表包括两个部分,一是构造哈希函数(常常是取余函数),二是解决冲突。
    装填因子,指示散列表的空间利用率。设散列表空间大小为m,填入表中的元素个数是n,则称n/m是散列表的装填因子。
    哈希表可以是一维数组,也可以设计成二维数组(一个位置就可以放两个元素,减少冲突的可能)   

2、散列表的建立方法以及冲突解决方法

(1)哈希表的建立方法(构造哈希函数)

     要求:计算简单,把关键词尽量均匀的分配在一组空间中,减少冲突。
        Ⅰ.数字关键词:①直接定址法法(线性函数)②除留余数法:(key)mod(p)一般p取表的长度小的最大的素数,如果取的p超过表长,则映射之后的地址可能超出表的范围;而取素数可以使哈希地址尽可能地散落均匀,减少冲突,因此p就取素数。③数字分析法:把变化比较随机的几位组合起来,用这些来构造哈希函数。④折叠法。⑤平方取中法:大数平方之后取最中间的几位(可以使 这个数中的大部分位都能对key产生影响。
        Ⅱ.字符关键字:①ASCII码加和法(冲突严重)②前三个字符移位法(空间浪费严重,而且还会有冲突)③:移位法,把n位的字符串看成n位的32进制的数字,再除留余数法。这个大数计算的时候用级数计算,或直接左移五位!(左移五位就是乘了32)

(2)冲突解决方法

    当关键字不同,而由哈希函数求得的地址相同时,称发生了冲突。也称此时的两个关键字时Hash函数的同义词。
    Ⅰ.换个位置:开放地址法
                若发生了第i次冲突,就在哈希函数的基础上加一个偏移量;根据这个(偏移量)增量序列开放地址法可以分为以下几种:
                    ①线性探测:以增量序列1,2,...,TableSize-1,循环试探下一个存储地址;问题:会出现冲突聚集的情况。查找性能分析:成功平均查找长度&不成功平均查找长度
                    ②平方探测:有可能有一些空间永远找不到;只有当散列表长度是4k+3的素数是,平方探测发才可以探查到整个散列表空间。
                    ③双散列:设计两个散列函数
                    ④再散列:散列表中元素太多的时候查找效率会很低(最大装填因子一般取0.5-0.85,超过0.5就可以再散列了(加倍扩大散列表)
        Ⅱ.同一位置的冲突对象用一个链表串起来:链地址法

3、散列表的性能分析

    平均查找长度:分为查找成功的ASL1和查找不成功的ASL2
    需要注意的是!!查找不成功时与空指针的比较不算在查找次数里面!!!
    哈希表的平均查找长度与表的长度无关,而是与装填因子a有关的