数据结构(未完)

目录

前言

一、抽象数据类型

二、线性结构

1.顺序表

2.单向链表

3.循环链表

4.双向循环链表

5.栈

6.队列

7.双端队列

三、树

1.树的基本存储结构

2.递归

3.树,二叉树,森林之间的转换

4.二叉树基础知识

5.二叉树的遍历

6.二叉排序树

7.二叉平衡树(AVL树)

8.并查集

9.线索二叉树

10.哈夫曼树

四、图

1.图的基本概念

2.邻接矩阵

3.邻接表

4.十字链表

5.临接多重表

6.边集数组

7.深度优先搜索(DFS)与广度优先搜索(BFS)  

总结


前言

  有一种说法是程序是由数据结构和算法组成的,这很能体现出数据结构在编码中的重要性。而代码优化的能力也是区别有基础的程序员和码农的重要标准,所以对于这一块的学习一定要稳重与细致,每一个章节都要实打实敲出能够实现该种结构的代码才算完成。  

  数据结构的学习本质上是让我们能见到很多前辈在解决一些要求时间和空间的难点问题上设计出的一系列解决方法,我们可以在今后借鉴这些方法,也可以根据这些方法在遇到具体的新问题时提出自己的解决方法。(所以各种定义等字眼就不用过度深究啦,每个人的表达方式不一样而已)在此以下的所有代码都是仅供参考,绝对不是唯一的答案,任何一种操作能达到相同的结果,只要逻辑上能行的通,复杂度上差不多,是无所谓怎么去实现最后的功能的。


一、抽象数据类型

  在一开始学习数据结构的时候不用关注复杂度的问题,等到基础的数据结构学习完之后再去了解复杂度不迟。

  学习数据结构之前先了解什么是抽象数据类型。我们现在认识的数据类型就是一些类似整形字符型等表示数据取值范围的类型,而最初的程序员使用语言的是机器语言,即以0和1存储来实现功能,这样数据没有类型,我们每次在对他进行操作的时候都不清楚这个数据可以做的具体操作方式(是加减乘除还是合并),每次存储的时候也要去找相对于的空间。为了解决这个问题后来的程序员引入了数据类型的概念,这样我们再存储的时候我们就不需要再去寻找存储器的地址了,直接定义变量之后赋值就行。

  抽象数据类型的用途与普通的数据类型类似,他的本质就是一组数学模型,里面存放着这个模型的具体数值和这个模型能做的具体操作。(比如int能实现加减乘除,字符串能实现字符串的拼接)。而他与数据类型的区别就是,数据类型是前人给我们定义好的,而抽象数据类型是我们自己定义的。

  这就可以引出下一章线性表,线性表的属性是什么,操作是什么,他的本质就是一个抽象数据类型。

二、线性结构

定义:除了第一个和最后一个元素,其他每个元素只有一个前和一个后的结构

线性结构又分为两种:顺序表链表

1.顺序表

特性:一组地址连续的存储单元(数组)

主要用到的操作:增删改查,初始化

以下是对应的抽象数据类型 :

parr 用来存放数据  size  当前顺序表内数据个数  length 顺序表容量

typedef struct slist
{
    int* parr;//用来存放数据
    int size;//当前顺序表内数据个数
    int length;//当前顺序表的容量
}slist;

①在最后一位增加

这个操作非常的简单,只要知道当前的顺序表内元素个数,然后在+1的位置执行插入操作就好了。

void add_slist(slist* array, int key)
{
    if (array->size == array->length)//先判断是否需要空间
    {
        array->length += UNIT;
        int* newp = (int*)realloc(array->parr, sizeof(int) * (array->length));//第二个数不得等于0
        if (!newp)//判断是否扩容失败
        {
            printf("顺序表扩容失败");
            system("pause");
        }
        array->parr = newp;
    }
    array->parr[array->size] = key;
    array->size++;
}

  那么这里的扩容操作是什么意思呢,这里我们通过数组去实现的顺序表,那众所周知数组的长度是固定的,那么一旦我们添加进去的数据超过了原来的容量,就只能扩容了,这里通过realloc来实现扩容。UNIT是每次扩容的长度,这根据具体的情况进行修改,不过多阐述。

 ②在最后一位之前插入

  从对这个数组的影响上来看,这一个操作与1的增加都是往数组里赋值了一个数据。但从函数的操作上和函数接口的设计上(这个具体看下面的代码)与增加是不同的。

  从操作上看,这个数据插入的时候要把所有这个数据以后的数据都向后移动一位,来给这个要赋进去的数据腾出一个位置,然后再插入这个数据。从函数的接口设计上,因为与在最后一位增加的操作不同,插入的操作需要知道你插入的位置,所以还需要往函数里给一个位置的参数这时候会不会觉得那干脆把增加的操作想象成往最后一个位置做插入,把这两个操作写成一个函数会不会更简单一些?在一些时间有限的竞赛中是推荐这么做的,但在开发项目里是不建议这么做的。(比如你只是想往里面一直放元素,难道你每次还要再指定你的位置吗?而且到后面我们接触的项目规模逐渐变大的时候,每一种非常细小差别的操作都是需要分开的)

void iadd_slist(slist* array, int key, int index)
{
    if (array->size == array->length)//先判断是否需要空间
    {
        array->length += UNIT;
        int* newp = (int*)realloc(array->parr, sizeof(int) * (array->length));//第二个数不得等于0
        if (!newp)//判断是否扩容失败
        {
            printf("顺序表扩容失败");
            system("pause");
        }
        array->parr = newp;
    }
    if (index > array->length)
    {
        printf("顺序表查找下标超出最大值");
        system("pause");
    }
    array->size++;
    for (int i = 0; i < array->size - index - 1; i++)
    {
        *(array->parr + array->size - i - 1) = *(array->parr + array->size - i - 2);
    }
    *(array->parr + index) = key;
}

③修改和查找一个数据 

  修改和查找的操作非常的相似且简单,是给定一个下标返回对应该下标对应的值,或者是修改改下标里的值,那这里就只举一个查找的例子了。

int inquiry_slist(slist* array, int index)
{
    if (index > array->length)
    {
        printf("顺序表查找下标超出最大值");
        system("pause");
    }
    return(*(array->parr + index));
}

④删除一个数据

这里只要把该数据后面的全部数据一个一个前移就能实现删除的操作了。那这里最后的4要不要清除掉呢?视具体情况而定,一般有统计当前元素个数的变量就不用清除最后4的数据了。

void delete_slist(slist* array, int index)
{
    if (index > array->size)
    {
        printf("顺序表删除下标超过最大值");
        system("pause");
    }
    else
    {
        for (int i = 0; i < array->size - 1 - index; i++)
        {
            *(array->parr + index + i) = *(array->parr + index + i + 1);
        }
    }
}

这里便能体现出一个顺序表的缺点,虽然进行了删除数据的操作,但是在内存上我并没有减少内存的使用,我删除的那一个内存只是数据消失了但仍然是一个被占有的状态。

⑤初始化顺序表

当程序刚开始的时候是不存在这样的数据类型的,所以我们要创建该类型并完成初始化,具体代码如下。

slist init_slist()
{
    slist arr;
    arr.length = UNIT;
    arr.parr = (int*)malloc(sizeof(int) * UNIT);
    if (!arr.parr)//判断初始化分配内存失败  
    {
        printf("顺序表初始化失败");
        system("pause");
    }
    arr.size = 0;
    arr.length = UNIT;
    return arr;
}

⑥顺序表的其他操作

其实顺序表主要的操作在上面都已经实现完成了,写这一块的意义是想说明学习数据结构和写项目都一样,代码是死的人是活的,不要按照书上写的生搬硬套,哪个代码能更好的实现功能,哪个代码效率高,那他就是优秀的代码。学习数据结构的时候也是,主要功能写完之后为了测试或是为了提高熟练度,可以自行的增加或删除一些操作(像上面的顺序表就没有写释放空间的操作,因为学习顺序表不需要)。下面就是补充的一些操作 清空顺序表(指数据)  销毁顺序表(指内存) 打印顺序表     还有测试以上所有功能的main函数。

#include <stdio.h>
#include <stdlib.h>

#define UNIT 5

void clear_slist(slist* array)//清空顺序表
{
    if (array->size == 0)
    {
        ;
    }
    else
    {
        for (int i = 0; i < array->size; i++)
        {
            *(array->parr + i) = NULL;
        }
    }
}

void destory_slist(slist* array)//销毁顺序表
{
    if (array->parr != NULL)
    {
        free(array->parr);
        array->parr = NULL;
    }
}

void print_slist(slist* array)//打印顺序表
{
    for (int i = 0; i < array->size; i++)
    {
        printf("%d", *(array->parr + i));
    }
}

int main()
{
    slist slist1 = init_slist();
    slist* pslist1 = &slist1;
    int a = 0;
    while (a < 10)
    {
        add_slist(pslist1, a);
        a++;
    }
    print_slist(pslist1);
    iadd_slist(pslist1, a, 2);
    print_slist(pslist1);
    clear_slist(pslist1);
    print_slist(pslist1);
    destory_slist(pslist1);

    system("pause");
    return 0;
}

⑦顺序表的优缺点

顺序表有一个特别明显的优点,就是知道下标之后查找和修改的操作效率都非常的高。但同时他的缺点也比较明显,就是在中间插入数据和中间删除数据的时候需要把后面的所有数据全部移动一遍,虽然这次事例中的数据不多,但是在之后的项目里可能会遇到千万条的数据,这时候每一次添加和删除的代价就非常大了;还有一个缺点就是顺序表的长度是固定的,这就导致肯定会有浪费的空间和肯定需要扩容的操作;再还有一个缺点就是对于无序的数组,他的搜索效率底的。

2.单向链表

特性:物理上不连续,逻辑上连续,大小不固定

主要的操作:增(此章还增加了头插)删改查,初始化

在了解他的抽象数据类型之前,我们先要了解几个概念。头指针,就是一个指向链表第一个节点的指针。首元节点,是真正存放数据的第一个节点带头节点单向链表不带头节点单向链表,这是有一点区别的两种单向链表,具体的操作区别和实用性后面会说。

这是以上概念的图示,上半部分是带头节点的,下半部分是不带头节点的。

接下来了解一个链表的具体组成。链表是怎么通过逻辑来让物理上不连续的内存变得连续的呢?答案是通过指针,我们在每一个数据的节点里在放一个变量来存储下一个节点所在的地址,就可以一个接着一个的去连接所有的数据了,具体组成如下。

pnext 指向下一个节点的地址     data 存放数据

typedef struct linklist
{
    linklist* pnext;//指向下一个节点的地址
    int date;//存放数据
}linklist;

①增加节点

那具体要怎么实现数据的插入呢,首先我们需要执行一个操作,就是定位到我们需要插入的位置,这一步只要一直p = pnext直到到了想要的位置停止就行了。然后我们新建一块内存用来存放我们要插入的数据和pnext,pnext的值等于我们所要插入位置的前一个位置的pnext,然后我们再把前一个位置的pnext指向我插入的这个数据的地址就行了,要是觉得绕看图一下就懂了。

  接下来我以不带头节点来举例,增加数据的操作会比带头结点的麻烦,为什么这么说呢?因为如果在不带头结点的第一个位置去插入数据,那头指针指向的位置就必须是新添加进来的这个节点,则需要我们再去改变头指针的值,而带头结点的链表在第一个位置插入数据也是在头节点的下一个位置插入节点,所以是不需要改变头指针的位置的。那么就说明一个正常的插入操作,不带头结点的可能需要改变两个变量(新的节点和头指针),需要两种操作才能实现,而带头结点的一种(新的节点)就行了,这也印证了为什么带头结点的链表使用率更高一点。(带头结点的头节点内存不能说是浪费,一个节点和庞大的项目里的数据比起来实在是太微小了)

void headadd_linklist(linklist** p, int key)
{
    linklist* head = (linklist*)malloc(sizeof(linklist));
    if (!head)
    {
        printf("链表初始化失败");
        system("pause");
    }
    else
    {
        head->pnext = (*p)->pnext;
        head->date = key;
        (*p)->pnext = head;
    }

}

void add_linklist(linklist* head, int key, int num)
{
    if (num == 0)
    {
        headadd_linklist(&head, key);
    }
    else
    {
        linklist* p = (linklist*)malloc(sizeof(linklist));
        if (!p)
        {
            printf("链表初始化失败");
            system("pause");
        }
        else
        {
            linklist* temp = head;
            for (int i = 0; i < num; i++)
            {
                if (!temp->pnext)
                {
                    printf("增加位置下标为空指针");
                    system("pause");
                }
                temp = temp->pnext;
            }
            p->pnext = temp->pnext;
            p->date = key;
            temp->pnext = p;
        }
    }
}

从这我们能发现链表的一个坏处,他不如顺序表能够一下到我们想要的位置,就比如如果我们要在链表的最后一个位置插入一个值,那就得先遍历前面全部的数据再去插入操作。从这个方面来讲,如果我们要经常性的进行尾插的操作,我们就可以再写一个尾指针(功能和头指针一样)。

②删除节点

删除操作就更加简单了。我们要做的就是让这一组数据不会在链表中出现就好了。

 这里有一个要注意的地方,只改变了前一个节点指向的下一个节点后,要删除的节点的确不存在链表里了,但他在内存里还是处于被占用的状态,所以我们要提前记录要被删除节点的地址(因为不在链表里之后我们就找不到这个节点了),再通过这个地址去free那一块内存

void deletenum_linklist(linklist* headp, int num)
{
    linklist* temp = headp;
    for (int i = 0; i < num; i++)
    {
        if (!temp->pnext)
        {
            printf("删除下标为空指针");
            system("pause");
        }
        temp = temp->pnext;
    }
    linklist* tempfree = temp->pnext;
    temp->pnext = (temp->pnext)->pnext;
    free(tempfree);
}

③初始化

这里因为举的是无头节点的例子,所以初始化的时候只要建一个头指针就够了,当然也可以提前创建一个第一个节点的内存。有头结点的则需要创建一个头指针和一个头节点

void init_linklist(linklist** p)
{
    *p = (linklist*)malloc(sizeof(linklist));
    if (!(*p))
    {
        printf("链表初始化失败");
        system("pause");
    }
    (*p)->pnext = NULL;
}

这里有个小tip,c和c++里所有的指针,在初始化的时候如果没有具体指向的值,一定要指向null,如果什么都不指那他就是一个野指针,后面是有可能会用不了的。

④其他的操作即测试main函数

改和查的操作比较简单且相似,相信在逻辑理顺和上述代码有自己去敲过之后都能够写的出来的。这里就给一个打印列表和main函数。

void printf_linklist(linklist* p)
{
    linklist* temp = p->pnext;
    while (temp->pnext)
    {
        printf("%d", temp->date);
        temp = temp->pnext;
    }
    printf("%d", temp->date);
}

int main()
{
    linklist* t1;
    init_linklist(&t1);
    for (int i = 0; i < 13; i++)
    {
        add_linklist(t1, i, i);
    }
    printf_linklist(t1);

    deletenum_linklist(t1, 0);
    printf_linklist(t1);

    system("pause");
    return 0;
}

3.循环链表

环链表与一般的单项链表的所有其他操作都是相同的,唯一不同的地方是单向链表的尾结点的指针指向的是NULL,而循环链表的尾结点指向的是他的头节点。那么怎么去实现这种操作呢?增删改查的操作不变,只要在链表初始化的时候让他的第一个节点指针指向自己就好了

这里因为操作与单向链表大同小异,就不放测试代码了。 

4.双向循环链表

双向循环链表的实现与单向链表相比稍微复杂一些,但是原理上也是大同小异。双向循环链表在单向链表的基础上实现了一个节点指向前一个节点的功能,我们就可以通过后一个节点去找到前一个节点的位置(这里也能够说明数据结构具体的实现不是一成不变的,而是根据我们想要达到的功能去自由的改动)。双向循环链表的循环部分上一节已经有了,这里主要围绕他的双向的操作来解析

双向循环链表的具体组成看下图,以下是他的数据类型。

 pprior  指向前一个节点             pnext  指向后一个节点              data 存放数据

typedef struct double_loop_linklist
{
    double_loop_linklist* pprior;
    double_loop_linklist* pnext;
    int date;
}double_loop_linklist;

双向循环链表与普通链表区别较大的地方是插入和删除操作,下面也会主要围绕这两个操作解释

①插入操作

 双向链表的插入操作原理与单向链表相同,操作稍微复杂一点,具体操作看下图。

这里的编号表示插入操作的顺序,这个顺序最好不要变动

void add_double_loop_linklist(double_loop_linklist* head,int key,int num)
{
     double_loop_linklist* p=(double_loop_linklist*)malloc(sizeof(double_loop_linklist));
        if(!p)
        {
            printf("链表初始化失败");
            system("pause");
        }
        else
        {
            double_loop_linklist* temp=head;
            for(int i=0;i<num;i++)
            {
                if(!temp->pnext) 
                {
                    printf("增加位置下标为空指针");
                    system("pause");
                }
                temp=temp->pnext;
            }
            p->pnext=temp->pnext;
            (temp->pnext)->pprior=p;
            p->date=key;
            p->pprior=temp;
            temp->pnext=p;
            head->date++;
        }
}

②删除操作

删除也相似,只是在单链表删除操作的基础上多一个指针的操作,具体步骤如下。

void deleteplace_double_loop_linklist(double_loop_linklist* place)
{
    if(!place)
    {
        printf("链表传入空指针");
        system("pause");
    }
    double_loop_linklist* temp=place->pnext;
    place->pnext=(place->pnext)->pnext;
    (place->pnext)->pprior=place;
    free(temp);
}

③其他操作即main函数

这里就是一些初始化 打印 等调试的函数  还有main函数

#include <stdio.h>
#include <stdlib.h>

void init_double_loop_linklist(double_loop_linklist **p)//初始化链表
{ 
    *p=(double_loop_linklist*)malloc(sizeof(double_loop_linklist));
     if(!(*p))
    {
        printf("链表初始化失败");
        system("pause");
    }
    (*p)->pnext=*p;
    (*p)->pprior=*p;
    (*p)->date=0;
}
//查找每个数据的位置
double_loop_linklist* findplace_double_loop_linklist(double_loop_linklist* p,int key)
{
    double_loop_linklist* temp=p->pnext;
    while(temp!=p)
    {
        if((temp->pnext)->date==key)
        {
            return temp;
        }
        temp=temp->pnext;
    }
    return NULL;
}

void printf_double_loop_linklist(double_loop_linklist* p)//打印双向链表
{
    double_loop_linklist* temp=p->pnext;
    for(int i=0;i<p->date;i++)
    {
        printf("%d",temp->date);
        temp=temp->pnext;
    }
    printf("%d",temp->date);
}

int main()
{
    double_loop_linklist* t1;
    init_double_loop_linklist(&t1);
    for(int i=0;i<13;i++)
    {
        add_double_loop_linklist(t1,i,i);
    }
    printf_double_loop_linklist(t1);

    deleteplace_double_loop_linklist(findplace_double_loop_linklist(t1,3));
    printf_double_loop_linklist(t1);

    printf_double_loop_linklist(t1);
    
    system("pause");
    return 0;
}

5.栈

栈的设计是模仿的是内存里的堆栈。实现的功能也与堆栈相同,即先进后出,后进先出(很多的内存也是这么产生和销毁的)直观一点看,栈就是加了一定约束条件的链表,在基础的链表上多了数据只能先进后出,后进先出的约束条件栈的应用随便举个例子:像是函数的调用,项目里总是函数套函数,函数套函数,那我进入最里面的函数时编译器怎么回到原先正在执行的函数呢,这时候就是通过栈来实现(所有的数据结构的用途都要在实际的问题或者是项目里去自己思考,这里就不多说了)

为了实现这种功能我们先了解他的数据类型。

top 栈顶                end 栈底                maxsize当前栈的容量

①插入数据(压栈)

一般的栈有固定大小的内存,通过两个指针来实现增和删的操作(也就是说不论是增加还是删除,实际上程序所占内存大小是不会变得,只是指针指向的起始和结束位置在改变而已)。既然内存大小是固定的,那么在增加的时候我们就需要一个判断栈是否满的操作(top == maxsize);在删除的时候我们就需要一个判断栈是否空的操作(top == 0)

这时候插入的操作就是给top所在的位置赋值,并把top++,代码如下。

void push_shun_stack(int key)
{
    if (top - end == maxsize)
    {
        printf("栈满了");
        system("pause");
    }
    else
    {
        top++;
        shun_stack[top] = key;
    }
}

②删除操作(出栈)

删除的操作也是通过移动我们的栈顶指针来实现的,我们让这个数据结构取数据的时候只从end取到top。所以我们删除的操作只要让top--就好了,这里删除位置的内存没有消失,甚至连要删除位置存储的数据都没有改变,因为最后取数据只要从end取到top,所以我们根本不用知道top之后的数据是什么样的。

③其他操作和main函数

#include <stdio.h>
#include <stdlib.h>

void init_shun_stack()
{
    shun_stack = (int*)malloc(maxsize * sizeof(int));
    end = -1;
    top = -1;
}

int main()
{
    init_shun_stack();
    push_shun_stack(1);
    push_shun_stack(2);
    for (int i = 0; i < 2; i++)
    {
        printf("%d\n", shun_stack[i]);
    }
    pop_shun_stack();
    printf("%d\n", shun_stack[0]);
    system("pause");
    return 0;
}

6.队列

栈是先进后出的数据结构,那队列刚好与他相反,是先进先出,后进后出队列的应用也挺多,比如双十一的抢票(多用户资源竞争),打印机(主机和外部设备速度不匹配)。这里的队列是单向队列,也就是最普通的队列,在单向链表的基础上实现了先进先出,后进后出。下面看他的数据类型。

front  头指针                end  尾指针                maxsize  当前队列容量

int* shun_queue;//数组用来表示队列
int front;//头指针
int end;//尾指针
int maxsize = 5;//目前栈最大存储数量

①插入操作

队列的插入操作与栈完全相同,这里直接放代码。

void insert_shun_queue(int key)
{
    if (isfull_shun_queue())
    {
        printf("队列满了");
        system("pause");
    }
    else
    {
        shun_queue[front] = key;
        front = (front + 1) % maxsize;
    }
}

为什么这里的else的最后一句要这么写呢?为什么判断队满的操作不是直接front == maxsize呢?(这是唯一和栈的写法不同的地方),这里我们先跳过,在后面删除的操作里再细讲。

②删除操作

删除的操作通过移动end指针来实现,每次执行删除的时候把end++就可以了(这里删除位置的内存没有消失,删除位置存储的数据也没有改变,因为最后取数据只要从end取到top,所以我们根本不用知道front之后的数据是什么样的),代码如下。

void delete_shun_queue()
{
    if (front == end)
    {
        printf("队列空了");
        system("pause");
    }
    else
    {
        end = (end + 1) % maxsize;
    }
}

这时候如果仅仅是将end++就会出现一个问题,当我一直删除数据,直到end到了maxsize的位置的时候,明明这时候的队列还有空的位置,但不论我们怎么存数据都已经取不出我们存进去的数据了,这时候的front应该也是在maxsize的位置,我们再给front++的时候甚至会出现假溢出的情况(有溢出报错但是队列其实并没有满)。这种情况怎么解决呢?我们要把队列从一个单向的顺序表改成一个环形的顺序表,这样就不会出现刚刚的情况了。但随之而来的是第二个问题,我怎么去区分队列满和队列空呢?如果队列是环形的,我们就不能用maxsize == front这种操作来判断队的状态,而front == end时既有可能是队满也有可能是队空,这种问题怎么解决?解决的方法是少使用一个空间。在这里我们判定front == end 为队空的情况 而(front + 1) % maxsize == end 为队满的情况,这样就能解决无法区分两种状态的问题。

③其他操作即mani函数

#include <stdio.h>
#include <stdlib.h>

void init_shun_queue()
{
    shun_queue = (int*)malloc(maxsize * sizeof(int));
    front = end = 0;
}

int isfull_shun_queue()
{
    if ((front + 1) % maxsize == end)
    {
        return 1;
    }
    else
    {
        return 0;
    }
}

int isempty_shun_queue()
{
    if (end == front)
    {
        return 1;
    }
    else
    {
        return 0;
    }
}

int main()
{
    init_shun_queue();
    insert_shun_queue(1);
    insert_shun_queue(2);
    for (int i = end; i < front; i++)
    {
        printf("%d\n", shun_queue[i]);
    }
    delete_shun_queue();
    printf("%d\n", shun_queue[end]);
    system("pause");
    return 0;
}

7.双端队列

在说双端队列的操作之前先认识一下什么是双端队列。从功能上来描述,双端队列像是栈和队列的集合,他同时有着这两个数据结构的功能, 一般用于各种需要灵活使用栈或队列的底层,比如c++stl的栈这些。双向队列在实际应用中只有两种,一种是数据只能从一段加入而可以从两端取数据,另一种是可以从两端加入但从一段取数据,从而同时实现栈和队列的功能。我这次举得事例是一端插入,两端删除的以顺序表实现的双端队列(方式上能实现两端增和删但是实际中不这么用)

他的抽象数据类型如下:

double_shun_queue  数组用来存放数据               left  左指针                 right  右指针   

maxsize 目前最大存储数量                        size  当前元素个数

int* double_shun_queue;//数组用来表示队列
int left;//左指针
int right;//右指针
int maxsize=5;//目前队列最大存储数量
int size;//当前元素个数

①插入操作

由上面对双端队列的操作可知,插入操作一共分为左插和右插两个。因为我这次举得例子是一端插入的,所以代码会和两端插入的不同,不过原理都是一样的。

这里的插入与栈里的一样,比如进行一个右插,我们就把数据插在right所在的位置,并把right++,有一个需要注意的点是当我们执行右插操作的时候,因为是要从右边插入,所以我们初始化的时候要把两个指针的初始位置放在顺序表的最左边,同理,如果我们要执行左插我们就要在初始化的时候把两个指针都放在顺序表的最右边(以上都是针对一端插入两端输出的操作。两端插入一段输出的原理相似自己思考一下就行)还有一个要注意的地方是,因为后面要进行删除的操作,所以会出现和之前的队列一样的问题,所以这里的双端队列必须是环形的,并且判满和判空的操作也不一样。代码如下。

void insert_left_double_shun_queue(int key)//左插操作
{
    if (isfull())
    {
        printf("队列满了");
        system("pause");
    }
    else
    {
        if (isempty())
        {
            left = right = maxsize;
            double_shun_queue[--left] = key;
        }
        else
        {
            if (left == 0)
            {
                left = maxsize;
            }
            double_shun_queue[--left] = key;
        }
        size++;
    }
}

void insert_right_double_shun_queue(int key)//右插操作
{
    if (isfull())
    {
        printf("队列满了");
        system("pause");
    }
    else
    {
        if (isempty())
        {
            left = right = 0;
            double_shun_queue[right++] = key;
        }
        else
        {
            if (right == maxsize)
            {
                right = 0;
            }
            double_shun_queue[right++] = key;
        }
        size++;
    }
}

②删除操作

这里我们举的例子是一端输入两端输出(也就是两端删除),所以两端的删除操作也是都要实现的。之前不论是左插还是右插,不变的一点就是我们的左指针永远是我需要数据的起始位,右指针永远是我们需要数据的结束位。所以不管之前我们进行的是什么操作,我们的左删都是直接将left++(并还要考虑循环操作防止假溢出)。右删也同理。这里与栈队列相似的地方就是我们的删除操作既不删除内存也不删除数据,仅仅是移动了我们需要内容的指针而已。代码如下。

void delete_left_double_shun_queue()//左删
{
    if (isempty())
    {
        printf("队列空了");
        system("pause");
    }
    else
    {
        if (left == maxsize - 1)
        {
            left = 0;
            size--;
        }
        else
        {
            left++;
            size--;
        }
    }
}

void delete_right_double_shun_queue()//右删
{
    if (isempty())
    {
        printf("队列空了");
        system("pause");
    }
    else
    {
        if (right == 0)
        {
            right = maxsize - 1;
            size--;
        }
        else
        {
            right--;
            size--;
        }
}

③其他操作即main函数

接下来我们只要进行一个初始化操作,并把之前写的插入删除组合在一起,我们就能实现双端队列。如此事例左插左删是栈,左插右删是队列(右插左删是队列,右插右删是栈)。代码如下

#include <stdio.h>
#include <stdlib.h>

void init_double_shun_queue()
{
    double_shun_queue = (int*)malloc(maxsize * sizeof(int));
    size = 0;
}

int isfull()
{
    if (size == maxsize)
    {
        return 1;
    }
    else
    {
        return 0;
    }
}

int isempty()
{
    if (size == 0)
    {
        return 1;
    }
    else
    {
        return 0;
    }
}

int main()
{
    init_double_shun_queue();
    insert_right_double_shun_queue(1);
    insert_right_double_shun_queue(2);
    insert_right_double_shun_queue(3);
    delete_left_double_shun_queue();
    insert_right_double_shun_queue(4);
    delete_left_double_shun_queue();
    insert_right_double_shun_queue(5);
    insert_right_double_shun_queue(6);
    insert_right_double_shun_queue(7);
    for (int i = 0; i < 5; i++)
    {
        printf("%d\n", double_shun_queue[i]);
    }
    system("pause");
    return 0;
}

三、树

树形结构和线性结构最大的区别就是:线性结构是一对一的关系,而树是一对多的关系

每个树的第一个节点是根节点,最下面一行的节点是叶子结点。上面的节点是下面节点的父节点,下面节点是上面节点的子节点。节点的度就是该节点子节点的个数。树的度是这颗树里拥有子节点数量最多的父节点所拥有的子节点数。树的深度就是现在这个树有几层(比如上面树的深度是3)

1.树的基本存储结构

接下来认识一下树的基本存储结构,一共有三个:双亲表示法,孩子表示法,孩子兄弟表示法

①双亲表示法

这里的每个节点通过存储他的父节点去寻找他的上一个节点任何一种算法脱离不开两种书写的方式,一种是顺序存储,一种是链式存储那么上面这个用顺序存储怎么实现呢?非常简单,具体看下图。

这里由于各种初始化,插入,删除的操作都和链表顺序表等之前讲过的一样,这里就不细说了直接放代码。

#include <stdio.h>
#include <stdlib.h>

/*
    双亲表示法
*/

typedef struct treenode//(若要字符记得修改parr Int)
{
    int date;
    int parent;//该节点父节点的下标
}node;

node* nod[5];
int size;//当前元素个数
int maxsize;//当前最大元素个数

void init_parent_tree();//初始化
void add_root(int key);//创建父节点并插入数据,根节点的父节点下标默认为-1
void insert_kid(int key,int parent);
int find_parent(int parent);

void init_parent_tree()
{ 
   maxsize=5;
   size=0;
}

void add_root(int key)
{
    node*new_node=(node*)malloc(sizeof(node));
    new_node->date=key;
    new_node->parent=-1;
    nod[size]=new_node;
    size++;
}

void insert_kid(int key,int parent)
{
    if(size==maxsize)
    {
        printf("树满了");
        system("pause");
    }
    else
    {
        int parent_index=find_parent(parent);
        if(parent_index==-1)
        {
            printf("父节点不存在");
            system("pause");
        }
        else
        {
            node*new_node=(node*)malloc(sizeof(node));
            new_node->date=key;
            new_node->parent=parent_index;
            nod[size]=new_node;
            size++;
        }
    }
}

int find_parent(int parent)
{
    for(int i=0;i<size;i++)
    {
        if(parent==nod[i]->date)
        {
            return i;
        }
    }
    return -1;
}

int main()
{
    init_parent_tree();
    add_root(1);
    insert_kid(2,1);
    system("pause");
    return 0;
}

这一种表示方法能很好的找到父亲节点,但是他是没有办法找到孩子节点和兄弟节点的(同一父节点的几个节点互为兄弟节点)。但这时候就一定要用到我们下面的孩子兄弟表示法吗?其实只要再给我的每个节点加一块内存使他指向自己最近的一个兄弟就好了

下面的各种表示方法也就是这么演化过来的,只是看你需要的侧重点是哪一个你就加上哪一部分的功能,具体的表示方法并没有这么死板。

②孩子表示法

从图来看孩子表示法和双亲表示法只是一个箭头方向的差别,但是这就会造成一个问题,一个节点要同时指向多个节点。这样我们每个节点就不知道要放多少固定内存去存放指向下一个节点的内存了,那这里就用到了链表的特性,于是孩子表示法的存储方式是在双亲表示法的顺序表上加了链表,具体如下。

实现代码如下

#include <stdlib.h>
#include <stdio.h>

/*
    孩子表示法
*/

typedef struct linklist
{
    int data;//存放下标
    struct linklist* next;
}linknode;

typedef struct child
{
    int data;
    struct linklist* next;
}child;

child* node_array[20];
int size;
int maxsize;

void init(int key);
int find_parent(int parent, int key);
void great_tree(int parent, int key);//创造新节点

void init(int key)
{
    size = 1;
    maxsize = 20;
    node_array[0] = (child*)malloc(sizeof(child));
    node_array[0]->data = key;
    node_array[0]->next = NULL;
}

int find_parent(int parent, int key)
{
    for (int i = 0; i < size; i++)
    {
        if (node_array[i]->data == parent)
        {
            return i;
        }
    }
    return -1;
}

void great_tree(int parent, int key)//创造新节点
{
    int index = find_parent(parent,key);
    if (index == -1)//不存在这个父节点
    {

    }
    else
    {//这里懒得判断是否满了
        node_array[size] = (child*)malloc(sizeof(child));//在顺序表里存放这个节点的值
        node_array[size]->data = key;
        node_array[size]->next = NULL;


        linknode* new_node = (linknode*)malloc(sizeof(linknode));//在父节点的链表里存放该节点的下标
        new_node->data = size;
        new_node->next = node_array[index]->next;
        node_array[index]->next = new_node;
        size++;
    }
}

int main()
{
    init(1);
    great_tree(1, 2);
    great_tree(1, 3);
    great_tree(2, 4);
    great_tree(2, 5);
    linknode* temp = node_array[1]->next;
    while (1)
    {
        printf("%d ", node_array[temp->data]->data);
        temp = temp->next;
        if (temp == NULL) break;
    }
    system("pause");
    return 0;
}

ABCDE是我在该节点存放的数据,而后面箭头指向的是该节点的子节点的下标,这就是孩子表示法。

③孩子兄弟表示法

其实孩子兄弟表示法就是在孩子表示法加上一个兄弟节点,原理上都是一样的。

代码如下

#include <stdlib.h>
#include <stdio.h>

/*
    孩子兄弟表示法
*/
typedef struct childbro
{
    int key;//数据
    struct childbro* child;//孩子指针
    struct childbro* sibling;//兄弟指针
}childbro;

childbro* root = NULL;

void init(int key)
{
    root = (childbro*)malloc(sizeof(childbro));
    root->key = key;
    root->child = NULL;
    root->sibling = NULL;
}

childbro* find_node(childbro* node, int key)
{
    static childbro* temp = NULL;
    if (node->key == key)
    {
        temp = node;
    }

    if (node->sibling != NULL)
    {
        find_node(node->sibling, key);//这里只改变temp所以不用接受返回值
    }

    if (node->child != NULL)
    {
        find_node(node->child, key);
    }

    return temp;
}

void insert(int key, int parent)//插入数据
{
    childbro* temp = find_node(root, parent);//定位节点 递归
    if (temp == NULL)
    {
        printf("没有这个节点");
        system("pause");
    }
    else
    {
        if (temp->child == NULL)//判断要插入的父节点有没有孩子,有则插在孩子的兄弟节点上
        {
            childbro* node = (childbro*)malloc(sizeof(childbro));
            node->key = key;
            node->child = NULL;
            node->sibling = NULL;
            temp->child = node;
        }
        else
        {
            temp = temp->child;
            childbro* node = (childbro*)malloc(sizeof(childbro));
            node->key = key;
            node->child = NULL;
            node->sibling = temp->child;
            temp->sibling = node;
        }
    }
}

int main()
{
    init(1);
    insert(2, 1);
    insert(3, 1);
    childbro* temp = root;
    printf("%d %d", (temp->child)->key, ((temp->child)->sibling)->key);
    return 0;
}

2.递归

递归一般的由一个递归函数体递归函数出口组成。

这里就以最简单的一个累加来举例子

 这里就实现了一个5一直加到1的累加,具体的实现过程是下面这样的。

如果一个递归写的不好,是有可能会有效率低和占内存的问题的,但一个好的递归能让代码可读性非常的高并且容易书写,在写项目时可以大胆的使用。

为了优化递归占用内存大的问题,出现了尾递归来优化递归操作,之前递归占用内存大的主要原因就是我运行一个函数进入下一个函数时,在运行的函数其实并没有结束,只是挂起作为等待的状态,但是尾递归,进入下一个函数时此函数将结束,于是不会有挂起的函数占用内存

int add(int x,int sum)//尾递归
{
    if(x == 1) return sum;
    else add(x - 1,sum + x);
}

不是所有的递归都能用尾递归优化,也不是所有的编译器都能对尾递归做优化,大部分老的编译器是不能优化尾递归的

3.树,二叉树,森林之间的转换

这一部分的内容主要运用于考试

①普通树转成二叉树:

比如我们要将下面的树转化为二叉树

 我们先要连接所有的兄弟节点,如下

第二步,去掉除了节点与第一个孩子的连线和兄弟之间的连线,处理完就会变成二叉树。

②森林转化成二叉树:

第一步,先把每一颗小树转化成二叉树

第二步,第一棵二叉树不动,每后一个二叉树作为前一个二叉树根节点的右子节点。

 ③二叉树转化成树、森林:

第一步,如果节点x是其双亲节点y的左孩子,就把x的右孩子,右孩子的右孩子和节点y连起来

第二步,去掉右孩子之间的连线

4.二叉树基础知识

先讲一些基本概念。度小于等于2的树是二叉树只有左子树或只有右子树的叫斜树满二叉树: 只存在度为0的节点和度为2的节点。完全二叉树:所有节点必须按照从上到下,从左到右来排,也就是说如果有节点的上层存在空的位置,或者有节点的左边存在空的位置,那这个树就都不是完全二叉树。扩展二叉树:把一个普通的树补成满二叉树(补null等不可能存在的符号即可)。

还有就是一些考试要用的计算性质,这里就直接放这里。(下面所有第一个节点的下标假设是从1开始的)

①二叉树第i层最多有 2 ^ (i - 1)个节点

②深度为k的二叉树最多有2 ^ k - 1个节点

③这里我们设度为0的节点为n0,度为1的节点为n1,度为2的节点为n2,因为节点数等于节点之间连接线的数量+1,所以n2 + n1 + n0 = 2 * n2 +n1 + 1,所以最终得出最后一个结果 n0 = n2 + 1

④具有n个节点完全二叉树的深度为(log2n) + 1向下取整

⑤一个树有n个节点,且为完全二叉树,那根据性质④我们就知道他的深度是log2n+1向下取整,我们将所有的节点按层次编号为i(编号大小自上而下递增,自左向右递增),对于任意一个节点,如果i = 1则为该树的根节点,如果i > 1,那么他的双亲节点的编号一定为i / 2。如果2i > n,那么节点i没有左孩子(当然也就没有右孩子),如果2i + 1 > n,那么节点i一定没有右孩子

到现在不论是什么数据结构说到底其实都是由链式存储和顺序存储来实现的,链式存储表示二叉树的方法比较容易想到,也是我们平常用的最多的,这里稍微再介绍一下顺序存储。怎么实现顺序存储二叉树呢?用一个二维数组,如下。

 这里我们既然用顺序存储,我们可以把所有的二叉树都补成一个一直到最后一层都有数据的二叉树,像下图这样。

 为什么要这么操作呢,从前面的学习我们可以知道顺序存储最大的优势就是查找迅速 ,所以我们可以通过快速的知道子节点的下标去实现二叉树快速的查找,上面那种二维数组的方法便可以改成如下图存储的方法。

 那这个怎么能找到子节点的下标呢?我们之前对二叉树进行了一个处理,将原来的二叉树补成了一个满二叉树,把原先没有的地方制NULL就可以,这时候根据我们之前学到的性质,当我们存储的第一个下标从0开始的时候(之前是从1开始),假设节点为i,那他的子节点下标就分别为2i+1 和2i+2。这种方法能快的实现查找操作,但缺点是比较的浪费空间,接下来主要介绍链式的存储。

5.二叉树的遍历

这里介绍二叉树的三种遍历方式 :前序遍历,中序遍历,后序遍历。这是用来遍历二叉树的方式,就像我们通过i++遍历数组一样,三种不同的遍历方式在不同的时候会起到不同的作用,这里我们只需记住操作,具体有什么作用后面用到了就知道了。

前序遍历:先访问根结点,然后前序遍历左子树,再前序遍历右子树。

中序遍历:根结点开始,中序遍历根结点的左子树,然后访问根结点和右节点。

后序遍历:从左到右先叶子后结点的方式遍历访问左右子树,最后访问根结点。

上面三句看了其实不能很好的帮助我们理解这三种遍历,主要结合代码,一下就能看出前中后遍历的区别和实现,所以上面那三句看不懂可以放过。

①前序遍历

void In_Order(BTnode *root)
{
    if (root == NULL)
    {
        printf("NULL ");
        return;
    }
    printf("%d ", root->data);
    In_Order(root->left);
    In_Order(root->right);
}

②中序遍历

void In_Order(BTnode *root)
{
    if (root == NULL)
    {
        printf("NULL ");
        return;
    }
    In_Order(root->left);
    printf("%d ", root->data);
    In_Order(root->right);
}

③后序遍历

void In_Order(BTnode *root)
{
    if (root == NULL)
    {
        printf("NULL ");
        return;
    }
    In_Order(root->left);
    In_Order(root->right);
    printf("%d ", root->data);
}

从上面的三种遍历就很容易看出,printf在两个遍历语句的(前中后)位置就说明该函数是(前中后)遍历。

④层序遍历

这个遍历实现二叉树一层一层向下遍历(从上到下,从左到右)的操作,想要实现这种操作只靠递归是不行的,还需要用到之前队列的知识

一开始我们先存一个根节点进去,比如我们现在的树如下

 现在我们的队列里只有1,然后我们就做出队操作,并且我们以后每次的出队操作,我们都会把该出队节点的两个子节点入队,重复以上操作我们就能够实现层序遍历。最后看是否遍历完毕是通过判断队列是否为空完成的。(这一块的代码就不放了,原理差不多,偷个小懒)

6.二叉排序树

性质:只要左子树不空,则左子树所有节点的值小于根节点,右子树如果不空,右子树所有节点必须大于根节点。

二叉排序树被创造的初衷是方便于我们的查找,下面是他的抽象数据类型

typedef int BTDatatype;
typedef struct BinaryTreeNode
{
    BTDatatype data;
    BTDatatype size; //身份证,如果有相等的值,size+1
    struct BinaryTreeNode *left;
    struct BinaryTreeNode *right;
} BTnode;

①插入操作

下面我们模拟二叉排序树是如何形成的,这里我们假设有一组数据如下,我们要把他放到二叉排序树后里面 

先把第一个数据放在根节点,然后看第二个数据3,比8小,放在8的左节点,10比8大放在8右节点,1比8小放在8左节点,8左节点已经有数据3,1比3小,于是1放在3的左节点,以此类推。

最后得到的二叉树是长这样的,但是这样的二叉树有什么用呢?这样的树看起来确实非常混乱,但是用中序遍历遍历这个树之后,得到的数据是1 3 4 6 7 8 10 13 14  是有序的

void Push(BTnode **proot, BTDatatype x) //插入
{
    BTnode *prev = NULL; //指向前一个节点
    BTnode *temp = *proot;

    while (temp != NULL) //寻找要插入的位置
    {
        prev = temp;
        if (x < (temp)->data) //如果插入的数据小于root,就往左边插入
        {
            temp = (temp)->left;
        }
        else if (x > (temp)->data) //如果插入的数据大于root,就往右边插入
        {
            temp = (temp)->right;
        }
    }

    if (prev == NULL)
    {
        BTnode *newnode = (BTnode *)malloc(sizeof(BTnode));
        newnode->data = x;
        newnode->left = NULL;
        newnode->right = NULL;

        *proot = newnode;
    }
    else if (x < prev->data)
    {
        BTnode *newnode = (BTnode *)malloc(sizeof(BTnode));
        newnode->data = x;
        newnode->left = NULL;
        newnode->right = NULL;

        prev->left = newnode;
    }
    else if (x > prev->data)
    {
        BTnode *newnode = (BTnode *)malloc(sizeof(BTnode));
        newnode->data = x;
        newnode->left = NULL;
        newnode->right = NULL;

        prev->right = newnode;
    }
}

 ②删除操作

对于二叉排序树的删除,我们一般分为三种情况

第一种情况:删除的节点是叶子节点,那么这时候直接删除就好,因为他删除之后对整个树不会造成任何其他的影响。

第二种情况:删除的节点只有一个孩子,这时候我们只要把他的孩子移动到被删除的位置就行了

第三种情况:删除的节点有两个孩子,从二叉排序树实现的功能上来说我们最终要实现的是在最后有序数列里把那个数据给删掉,所以我们其实需要的是一个能够代替原来那个节点的数据,该数据需要满足左边的数全都小于他,右边的数都大于他,那这个树怎么找呢,这里直接放结论:先进入到被删除的节点的左节点,这时候一直进入右节点直到右节点为NULL为止。现在所停留的节点就是那个可以替换原来删掉内容的节点,然后我们你把这个节点替换上去,而这个替换上去节点原来的位置我们就当做情况一、二来删除就行了。

 像这里7就是替换被删除的8的数据。

void Del(BTnode *node) //node是要删除的节点
{
    BTnode *tmp = NULL;

    //删除只有一个孩子的节点,间接的吧叶子节点删除
    if (node->left == NULL)
    {
        tmp = node;
        node = node->right;//这句话其实应该是改变他前一个节点的子节点,这里写错了,下面同,大家理解意思就行
    }
    else if (node->right == NULL)
    {
        tmp = node;
        node = node->right;
    }

    else //左右子树都不为空
    {
        tmp = node;
        BTnode *s = node;
        //找左子树的最大值
        s = s->left;
        while (s->right != NULL)
        {
            tmp = s; // tmp是s的上一个节点,解决删完后还有子树的情况
            s = s->right;
        }

        //先替换
        node->data = s->data;
        if (tmp != node)
        {
            tmp->right = s->left;
        }
        else
        {
            tmp->left = s->left;
        }
    }
}

③其他操作即main函数

void FindNode(BTnode *root, BTDatatype x) //找到待删除的节点
{
    if (root == NULL)
        return;
    else
    {
        if (x < root->data)
        {
            return FindNode(root->left, x); //递归
        }

        else if (x > root->data)
        {
            return FindNode(root->right, x); //递归
        }

        else if (x == root->data)
        {
            Del(root);
        }
    }
}

void In_Order(BTnode *root) //中序
{
    if (root == NULL)
    {
        printf("NULL ");
        return;
    }

    In_Order(root->left);
    printf("%d ", root->data);
    In_Order(root->right);
}

int main()
{
    BTnode* A = (BTnode*)malloc(sizeof(BTnode));
    A->data = 10;
    A->left = NULL;
    A->right = NULL;

    /*BTnode* B = (BTnode*)malloc(sizeof(BTnode));
    B->data = 8;
    B->left = NULL;
    B->right = NULL;

    BTnode* C = (BTnode*)malloc(sizeof(BTnode));
    C->data = 14;
    C->left = NULL;
    C->right = NULL;

    BTnode* D = (BTnode*)malloc(sizeof(BTnode));
    D->data = 7;
    D->left = NULL;
    D->right = NULL;

    BTnode* E = (BTnode*)malloc(sizeof(BTnode));
    E->data = 9;
    E->left = NULL;
    E->right = NULL;

    A->left = B;
    A->right = C;
    B->left = D;
    B->right = E;
    C->left = NULL;
    C->right = NULL;
    D->left = NULL;
    D->right = NULL;
    E->left = NULL;
    E->right = NULL;*/

    Push(&A, 12);
    Push(&A, 4);
    Push(&A, 3);
    Push(&A, 2);
    Push(&A, 1);
    Push(&A, 5);
    FindNode(A,4);
    In_Order(A);

    return 0;
}

二叉排序树有一个不好的问题,就是比如插入1 2 3 4 5 6,那我没吃插入都要遍历之前全部的数据,也就是说在某些条件下二叉排序树效率低,这就引出了二叉平衡术来解决这个问题。

7.二叉平衡树(AVL树)

二叉平衡树是为了解决某些特殊情况下二叉排序树效率低下的问题(比如斜树),二叉排序树查找的效率取决于树的高度,所以二叉平衡树会较好的控制树的高度,但相反的二叉平衡树是通过牺牲插入和删除的效率去实现查找效率的提升。

二叉平衡树性质:1、可以是空树   2、任意一个节点的左右子树高度之差不超过1(叶子节点高度为0)

二叉平衡树对于二叉排序树,主要是在插入和删除上增加了一些控制平衡的操作去使任意一个节点的左右子树高度之差不超过1,高度具体是什么意思?

 如上图,叶子节点的高度为0,而非叶子节点的高度是他左右子树的最大值+1。AVL树的代码和逻辑相对于之前难度会有一点的上升,这里我就不以插入删除等顺序来讲述,而是按照改进二叉排序树的顺序来写。

以下是他的抽象数据类型,这里比二叉排序树多了一个height来记录当前节点的高度

key  数据域               left  左子树               right  右子树              height  当前节点高度

typedef struct Node {
	int key;//数据域
	struct Node* left;
	struct Node* right;
	int height;//存储当前结点的高度
}avlnode, *avltree;

下面所有插入的数据将不会有key相同的情况,因为这会让代码变得复杂,如有这方面的需求自行进行修改。

①调节平衡

我们先不用管怎么判断是否调节平衡的代码怎么写,我先说调节平衡的操作。以下的操作都是对于失衡节点的操作(失衡节点:左子树高度减右子树高度的绝对值大于1的)

这里先写两个后面会用到的宏,意思很简单就不多介绍了。

#define HEIGHT(node) ((node==NULL) ? 0 : (((avlnode*)(node))->height))
#define MAX(a,b) ((a) > (b) ? (a) : (b))

int getNode_height(avlnode* node)
{
	return HEIGHT(node);
}

第一种:左子树的左节点过高(LL)

 由图可知现在的失衡节点是根节点,我们只需对失衡节点右旋就可以解决现在的失衡情况,那具体怎么执行右旋操作呢?以这张图为例,二的右子树更新为3,而2原来的右子树更新为3的左子树,最后通过返回节点的方法替换原来的失衡节点在其父节点位置的指针。

 这里我们可以发现此树已经变成一个平衡的树了,并且如果原来的树是按照二叉排序树的规则插入的话,旋转之后的树也是遵循二叉排序树的规则的。代码如下。

//LL 左子树的左子树
avltree left_left_rotation(avltree tree)
{
	avlnode* k2 = tree->left;
	tree->left = k2->right;
	k2->right = tree;
	//所有的旋转操作重新调整树的高度 
	tree->height = MAX(getNode_height(tree->left), getNode_height(tree->right)) + 1;
	k2->height = MAX(getNode_height(tree->left), getNode_height(tree->right)) + 1;
	return k2;
}

这里的还一个难点就是如何去确定每一个节点的高度,总体的思路是每个节点自下往上分别+1,而叶子节点高度为0 ,这样我们在确定一个树的节点时只需要知道他子节点的最大高度并+1就好

第二种:右子树的右节点过高(RR)

 这与第一种情况非常的相似,以这张图为例,失衡节点为根节点1,我们对其进行左旋操作,把1的右子树更新为1右子树的左子树2,把3的左子树更新为1,最后通过返回节点的方法替换原来的失衡节点在其父节点位置的指针。

 原来的树是符合二叉排序树逻辑的,所以这里旋转之后的树也是符合二叉排序树的。代码如下

//RR 右子树的右子树
avltree right_right_rotation(avltree tree)
{
	avlnode* k2 = tree->right;
	tree->right = k2->left;
	k2->left = tree;
	//所有的旋转操作重新调整树的高度 
	tree->height = MAX(getNode_height(tree->left), getNode_height(tree->right)) + 1;
	k2->height = MAX(getNode_height(tree->left), getNode_height(tree->right)) + 1;
	return k2;
}

第三种:左子树的右节点过高(LR)

 虽然都是左子树导致的失衡,但是我们可以发现这个树通过对失衡节点的右旋没有办法使树平衡,这里直接讲操作,对失衡节点的左子树先进行左旋,再对失衡节点进行右旋就能完成操作

 代码如下

avltree left_right_rotation(avltree tree)
{
	tree->left = right_right_rotation(tree->left);
	tree = left_left_rotation(tree);
	//所有的旋转操作重新调整树的高度 
	return tree;
}

第四种:右子树的左节点过高

理解了第三种情况那这个与第三种很相似,对失衡节点的右子树先进行右旋,再对失衡节点进行左旋就能完成操作,代码如下。

//RL 右孩子的左子树
avltree right_left_rotation(avltree tree)
{
	tree->right = left_left_rotation(tree->right);
	tree = right_right_rotation(tree);
	//所有的旋转操作重新调整树的高度 
	return tree;
}

②插入操作

插入的逻辑与二叉排序树一样,不一样的是在于如何检测插入是否造成失衡并调整失衡。这里我选择用递归去寻找插入的位置,在递归返回阶段自下而上改变插入位置以上的节点的高度,并检验经过的节点是否失衡,检验方法是子节点之差是否等于2,检测出失衡时再有上面的调节平衡操作来调节。

 如上图,我们向往里面插入一个3,那么操作完成之后便如下

这时候我们通过递归可以检测出(代码在下面)根节点4为失衡节点,处理方法为调节平衡里的情况四(LR),具体的插入代码如下。

//创建结点的方法
avlnode* create_node(int key, avlnode* left, avlnode* right) {
	avlnode* node = (avlnode*)malloc(sizeof(avlnode));
	//记得做判断
	node->key = key;
	node->left = left;
	node->right = right;
	node->height = 0;
	return node;
}

avltree avltree_insertNode(avltree tree, int key)
{
	if (tree == NULL)
	{
		avlnode* node = create_node(key, NULL, NULL);
		tree = node;
	}
	else if (key < tree->key)//在左子树中插入结点
	{
		//递归寻找插入结点的位置
		tree->left = avltree_insertNode(tree->left, key);
		//插入引起的二叉树失衡
		if (HEIGHT(tree->left) - HEIGHT(tree->right) == 2)
		{
			if (key < tree->left->key)
			{
				tree = left_left_rotation(tree);
			}
			else
			{
				tree = left_right_rotation(tree);
			}
		}

	}
	else if (key > tree->key)
	{
		//递归寻找插入结点的位置
		tree->right = avltree_insertNode(tree->right, key);
		//插入引起的二叉树失衡
		if (HEIGHT(tree->right) - HEIGHT(tree->left) == 2)
		{
			if (key < tree->right->key)
			{
				tree = right_left_rotation(tree);
			}
			else
			{
				tree = right_right_rotation(tree);
			}
		}
	}

	//重新调整二叉树的深度
	tree->height = MAX(getNode_height(tree->left), getNode_height(tree->right)) + 1;

	return tree;

}

③删除操作

删除操作通过传入根节点和想删除的值来实现,删除我同样通过递归来实现,如果想判断想删除的这个值存不存在的话可以另外写一个函数判断一下,代码如下。

avlnode* search_node(avltree tree, int key)
{
	if (tree == NULL || tree->key == key)
	{
		return tree;
	}
	else if (key < tree -> key)
	{
		search_node(tree->left, key);
	}
	else {
		search_node(tree->right, key);
	}
}

上面这个函数返回的结果判断是不是NULL,就能表示要删除的这个值存不存在。下面就可以进行主要的删除步骤。先通过递归我们可以找到要被删除的节点,找到节点删除的时候我们面临着二叉树删除的两种情况,被删除的节点有两个子树和被删除节点有一个或没有子树:如果只有一个子树或者没有子树时,我们只需要把子树覆盖被删除节点的位置就行,这一点与之前二叉树的删除一样,但是如果有两个子树,我们不光要像之前找到可以替换被删除的节点(具体看之前二叉树的删除),还要再写一步把用来替换现在删除节点的节点删除的操作,因为我们递归返回时要更新所有节点的高度,所以我们要把最底下改动过位置的节点当成新的删除节点。(这里用画图不能很好的解释,等下具体操作看代码)

遍历返回的时候我们要做两步操作,第一是检测是否失衡,第二是更新当前节点的高度,第二个比较简单我主要解释第一个。删除的检测平衡和添加的不同,删除的失衡节点不好定位,添加在哪那就高,但是删除不能知道哪个高,这也是这里我选择递归寻找失衡节点的原因。首先和插入一样,我们用  if (HEIGHT(tree->right) - HEIGHT(tree->left) == 2)  寻找失衡节点,这里以在左子树删除为例子,那么一定是右子树偏高,那是用RR还是RL呢?如果要用RL就是右子树的左子树导致的失衡(但是对右子树的右子树失衡也能用RL,不理解用手画一下),而RR是右子树的右子树,比较暴力的方法就是直接判断右子树的左子树存不存在时,就肯定可以用RR,其他的情况都有RL。当然也可以继续优化,在判定存在右子树的左子树之后, if (HEIGHT(tree->right->left) - HEIGHT(tree->left) == 1) 就一定用RL,其他情况RR。下面代码用暴力一点的方法。

avlnode* search_node(avltree tree, int key)
{
	if (tree == NULL || tree->key == key)
	{
		return tree;
	}
	else if (key < tree->key)
	{
		search_node(tree->left, key);
	}
	else {
		search_node(tree->right, key);
	}
}

//寻找最小值
avlnode* mininum_node(avltree tree)
{
	if (tree == NULL)
	{
		return NULL;
	}
	while (tree->right)
	{
		tree = tree->right;
	}
	return tree;
}

avltree avltree_deleteNode(avltree tree, int key)
{
	avlnode* node = search_node(tree, key);
	if (tree == NULL || node == NULL)
	{
		return tree;
	}

	if (key < tree->key)//要删除的结点在左子树
	{
		tree->left = avltree_deleteNode(tree->left, key);
		if (HEIGHT(tree->right) - HEIGHT(tree->left) == 2)
		{
			if (tree->right->left)
			{
				tree = right_left_rotation(tree);
			}
			else
			{
				tree = right_right_rotation(tree);
			}
		}
	}
	else if (key > tree->key)
	{
		tree->right = avltree_deleteNode(tree->right, key);
		if (HEIGHT(tree->left) - HEIGHT(tree->right) == 2)
		{
			if (tree->left->left)
			{
				tree = left_left_rotation(tree);
			}
			else
			{
				tree = left_right_rotation(tree);
			}
		}
	}
	else//找到待删除的结点
	{
		if (tree->left && tree->right)
		{
			avlnode* min_node = mininum_node(tree->left);
			tree->key = min_node->key;
			tree->left = avltree_deleteNode(tree->left, min_node->key);
		}
		else
		{
			tree = tree->left ? tree->left : tree->right;//独子 或者无子的情况
		}
	}

	if (tree)
	{
		tree->height = MAX(getNode_height(tree->left), getNode_height(tree->right)) + 1;
	}

	return tree;
}

④其他操作

下面放一些遍历等测试函数

#include<stdio.h>
#include<stdlib.h>

void pre_order(avltree tree)
{
	if (tree)
	{
		printf("%d ", tree->key);
		pre_order(tree->left);
		pre_order(tree->right);
	}
}

void order(avltree tree)
{
	if (tree)
	{

		order(tree->left);
		printf("%d ", tree->key);
		order(tree->right);
	}
}

int main()
{
	avltree tree = NULL;
	int a[] = { 12,9,17,6,11,13,18,4,15 };
	int lenght = sizeof(a) / sizeof(a[0]);
	for (int i = 0; i < lenght; i++)
	{
		tree = avltree_insertNode(tree, a[i]);
	}

	pre_order(tree);
	printf("\n");
	order(tree);
	avltree_deleteNode(tree, 9);
	printf("\n");
	order(tree);

}

8.并查集

看到这个名字就很容易理解,并查集是对一组集合做合并与查找操作的数据结构(集合是不相交的)。但是按照什么标准对数据合并(即分类)与查找取决于业务里需要进行什么样的分类。下面介绍一下并查集的抽象数据类型和其查找的一个核心思维。

node    每个节点的父节点                                rank     树的高度

int node[100];//每个结点
int rank[100];//树的高度

虽然并查集在抽象理解上是一个树形的结构,可是实际应用的时候一般是用数组实现的,实际应用时会给每个元素一个数字,node数组用来存储该节点的父节点数字,如下图 node[1] = 4,根节点的指针要指向自己。

 ①初始化操作

这里的初始化非常简单,上面说过根节点的指针指向自己就行,再把每个根节点的高度rank赋值为0就可以了,代码如下。

void makeSet(int size)
{
	for (int i = 0; i < size; i++)
	{
		node[i] = i;
		rank[i] = 0;
	}
}

②合并操作

合并操作必须要在查找操作之前解释,因为查找操作里的大部分操作是为了优化合并操作的复杂度而存在的,在刚开始每个元素都是一个独立的树,我们需要把这些元素按照自己想要的规则合并成各种集合,我们只要先判断需要合并的两个树那个树的高度大,就把那个小的树合并到大树里,将小树的根节点设置为大树,将大树的rank增加,具体代码如下(find是查找操作的函数,返回值是输入值所在树的根节点的值)

void Unite(int x, int y)
{
	x = find(x);
	y = find(y);
	if (x == y)
	{
		return;//这两个元素本身就已经在一个集合里面了
	}
	//判断两棵树的高度 决定谁是谁的子树 (针对集合和集合之间的合并)
	if (rank[x] < rank[y])
	{
		node[x] = y;
		rank[y] += rank[x];
	}
	else 
	{
		node[y] = x;
		rank[x] += rank[y];
	}
}

但这样的代码其实还是有问题的,因为如果按照上面的方式合并,我们最终得到的是一个如下图的单链表,和上面展示的最终并查集的样子不一样,而将这种单链表形态转变为并查集的最后操作我们其实是在查找里完成的。

 ③查找操作

除了递归找到父节点之外,为了让单链表形态的集合改变,我们在递归的同时要返回递归找到的根节点,并让递归路径上所有的节点都直接指向该节点,画图比较难表达这种操作,大家就直接看代码吧。

int find(int x)
{
	if (x == node[x])
	{
		return x;
	}

	return node[x] =  find(node[x]);//在第一次查找时 将结点直接连接到根节点

}

 由于对于不同的需求并查集分类的方式是不同的,这里的并查集学习也只能给出合并和查找的操作,main函数即测试用例需要带到具体的环境里运用体会,这里就不演示了。

9.线索二叉树

线索二叉树是在普通二叉树上利用指针域为空(之后叫做空链域)的一些节点做改进,形成的遍历更加快捷的一种树。之前我们对二叉树进行的遍历操作主要都是用中序遍历去发现里面树的规律,线索二叉树用空链域记录了每个节点的前驱和后继,说着比较抽象,下面看图片。

 上图是原来不做任何处理的二叉树,线索二叉树要先把上图变成下图这样。

 这样我们做第二次遍历的时候,我们就可以通过一种单链表的方式去遍历完这个二叉树,这就是线索二叉树的原理。但这个时候会出现一个问题,之前我们遍历结束的点都是当遇到NULL时停止,现在我们如何判断结束的点呢,下面通过解释他的抽象数据类型来解决这个问题。

data   值域      left right  左右指针域      left_type  right_type  标志位(0表示孩子,1表示线索)

typedef struct ThreadTree {
	int data;
	struct ThreadTree* left, * right;
	int lefy_type, right_type;//标志位 0代表孩子 1代表线索
}Node;

与普通二叉树的抽象数据类型相比可以发现这里多了两个标志位,这个的作用就是用来表示这时候的左右指针域究竟表示的是孩子还是线索,这样就可以解决我们上面说的问题,下面我们以0表示孩子,1表示线索书写代码。

①线索化

二叉树前面插入操作核心思想与与普通二叉树无差别,线索二叉树的线索化都是在第一次遍历之后形成的而不是插入时形成的,下面的代码以中序遍历为例来写一遍中序线索化。我们在这里引入一个pre变量来记录node节点上一次到达的位置。这时候我们就可以在遍历过的位置上用pre表示node的前驱,node表示pre的后继,如果之前排序二叉树的中序遍历理解的比较透彻的话那下面遍历实现的线索化应该是很容易看懂的。

Node* pre;//设定一个跟随的指针
//中序线索化
void inOrderThreadTree(Node* node)
{
	//如果当前结点为NULL 直接返回
	if (node == NULL)
	{
		return;
	}
	inOrderThreadTree(node->left);
	//线索化过程 先处理前驱结点
	//如果结点的左子树为NULL
	if (node->left == NULL)
	{
		 //设置前驱结点
		node->lefy_type = 1;
		node->left = pre;
	}
	//如果右子节点为NULL 处理前驱的右指针
	if (pre!=NULL && pre->right == NULL)
	{
		pre->right_type = 1;
		pre->right = node;
	}
	//每处理完一个节点 当前结点就是下一个结点的前驱
	pre = node;
	//处理右子树
	inOrderThreadTree(node->right);
}

②链式遍历

在我们经过一次中序遍历之后线索化就已经完成了,这时候我们就可以对二叉树实现链式的遍历,这里的代码也不难,代码如下。

void inOrderTraverse(Node* node)
{
	//得到根节点
	if (node == NULL)
	{
		return;
	}
	//先找到最左边的结点
	while (node!= NULL && node->lefy_type == 0)
	{
		node = node->left;
	}
	//向右不断遍历
	while (node != NULL)
	{
		printf("%d", node->data);
		node = node->right;
	}
}

线索二叉树的应用场景不多,而且数据量不大的时候线索二叉树是表现不出他的优势的,现在已知的应用场景有路由器CIDR地址划分时就用到了线索化。

10.哈夫曼树

哈夫曼编码是已知的最早用于数据压缩的方案,在解释哈夫曼树之前先要再介绍几个概念。

节点的路径长度:从根节点到某一个节点的路径上的连接数

树的路径长度:所有叶子节点的路径长度之和

节点的带权路径长度:某一个节点的权重乘以这个节点的路径长度

树的带权路径长度(WPL):每个叶子节点的带权路径长度之和,树形压缩编码性能的体现

在通讯当中,数据都是通过二进制对每个字符进行编码的,对于编码的要求是长度要尽可能的短,且不可产生二异性(同一串编码有两种不同的含义)。哈夫曼编码的作用就是能够构造出相对较短的二进制树并保证不产生二义性,WPL越小,构造出的树性能就越优。哈夫曼编码是变长编码,我们比价熟悉的定长编码有ASCII码,这里的哈夫曼为什么不用定长编码呢?因为每次输入的数据种类是不确定的,这时候如果用定长编码,数据超出范围了就会无法编码,数据量过少又会造成编码空间的浪费,所以变长空间更适合哈夫曼编码。变长就意味着字符的编码后长度是不一样的,那怎么调节每个字符的编码后长度呢?这是根据这个字符整体出现的频率来调节的,出现的频率高的字符,我们就让他的编码长度短。

①哈夫曼编码实现原理

按照二叉树的形状,每一个叶子结点都代表一个字符,从节点到左孩子的路径标记为0,到右孩子的路径标记为1。光是用语言描述比较抽象,比如我们现在要对ABCD四个字符进行编码,A的使用频率为7,B的使用频率为5,C的使用频率为2,D的使用频率为4,那么对应的哈夫曼树如下。

 下面是哈夫曼树的抽象数据类型

weight   叶子结点的权值          lChild   rChild   指向孩子节点的指针        parent     父节点的指针

typedef struct {
	//叶子结点的权值
	int weight;
	//需要指向孩子的指针
	int lChild, rChild;
	int parent;//父节点的指针
}Node, * HuffmanTree;

②创建哈夫曼树

这里我们使用数组去存储哈夫曼树的节点,相比链表数组这里能够更好地实现哈夫曼树频繁的查找,因为对于哈夫曼树的创建操作的量是比较大的,需要我们先进行查找出使用频率最小的两个节点(包括合并后的节点),再依次往上构建哈夫曼树。这里我们就先已知查找最小的两个节点的函数select(下面会细讲其实现),书写创造哈夫曼树的操作。

比如我们要对m个数据进行哈夫曼编码,根据二叉树的性质,m个子节点的二叉树一共有2m+1个节点。接下来我们要对其做初始化操作,分别是对叶子结点的初始化:将每个节点的权值变成该节点原有的权值,将其父子节初始化为0,非叶子节点的初始化:将每个节点的权值,父子节点全都变成0。接下去我们就开始构建整个哈夫曼树,通过select函数找到使用频率最小的两个节点(包括非叶子节点),并对这两个节点进行合并,修改他们的父子节点,具体代码如下。

void creatHuffmanTree(HuffmanTree* huffmanTree, int w[], int n)
{
	//需要有变量 来算哈夫曼树全部的结点数
	int m = 2 * n - 1;
	int s1, s2;//为当前结点中。选取的权值最小的两个结点
	//创建哈夫曼树的结点所需要的空间 m+1 
	//free还是delete 那个空间的长度坚决不能变 会直接跑死
	*huffmanTree = (HuffmanTree)malloc((m + 1) * sizeof(Node));
	//1---n号元素全部存放叶子 初始化叶子结点
	for (int i = 1; i <= n; i++)
	{
		(*huffmanTree)[i].weight = w[i];
		(*huffmanTree)[i].lChild = 0;
		(*huffmanTree)[i].parent = 0;
		(*huffmanTree)[i].rChild = 0;
	}
	//n ---- 最后一个元素是存放非叶子 初始化非叶子结点
	for (int i = n + 1; i <= m; i++)
	{
		(*huffmanTree)[i].weight = 0;
		(*huffmanTree)[i].lChild = 0;
		(*huffmanTree)[i].parent = 0;
		(*huffmanTree)[i].rChild = 0;
	}
	//开始构建哈夫曼树 够造的次数是确定的
	for (int i = n + 1; i <= m; i++)
	{
		//先选两个最小的 在从1 ~~ i-1的范围内选择两个父节点为0 并且权值最小的
		select(huffmanTree, i - 1, &s1, &s2);
		//选出的两个权值最小的叶子结点 组成一颗新的二叉树 根节点为i
		(*huffmanTree)[s1].parent = i;
		(*huffmanTree)[s2].parent = i;
		(*huffmanTree)[i].lChild = s1;
		(*huffmanTree)[i].rChild = s2;
		(*huffmanTree)[i].weight = (*huffmanTree)[s1].weight + (*huffmanTree)[s2].weight;
	}

}

③查找使用频率最小的节点

上面我们通过select去查找使用频率最小的节点,即权值最小的节点,这里我们通过地址传递的方式把两个最小节点给到创建树的函数,具体实现的方式是通过遍历的方式找到找到最小节点,是非常好理解的,具体代码如下。

//先选两个最小的 在从1 ~~ i-1的范围内选择两个父节点为0 并且权值最小的
void select(HuffmanTree* huffmanTree, int n, int* s1, int* s2)
{
	int min;//需要有个变量记录最小值
	//第一遍遍历 找出单节点
	for (int i = 1; i <= n; i++)
	{
		//如果此节点的父节点没有 那么把结点的序号赋值给min 跳出
		if ((*huffmanTree)[i].parent == 0)
		{
			min = i;
			break;
		}

	}
	//继续遍历全部结点 找到权值最小的
	for (int i = 1; i <= n; i++)
	{
		//判断父节点为空 进入下一个判断
		if ((*huffmanTree)[i].parent == 0)
		{
			//判断权值大小
			if ((*huffmanTree)[i].weight < (*huffmanTree)[min].weight)
			{
				min = i;
			}
		}
	}
	*s1 = min;

	for (int i = 1; i <= n; i++)
	{
		//如果此节点的父节点没有 那么把结点的序号赋值给min 跳出
		if ((*huffmanTree)[i].parent == 0 && i != (*s1))
		{
			min = i;
			break;
		}

	}
	//继续遍历全部结点 找到权值最小的
	for (int i = 1; i <= n; i++)
	{
		//判断父节点为空 进入下一个判断
		if ((*huffmanTree)[i].parent == 0 && i != (*s1))
		{
			//判断权值大小
			if ((*huffmanTree)[i].weight < (*huffmanTree)[min].weight)
			{
				min = i;
			}
		}
	}
	*s2 = min;
}

④通过哈夫曼树实现哈夫曼编码

创建完哈夫曼树之后我们要通过此树来生成每一个数据具体对应的哈夫曼编码。首先我们要分配求出当前编码的工作空间,通过逆向求每个叶子结点对应的哈夫曼编码,再分配这些编码原先数据的存储位置,因为我们是通过从叶子节点一直往根节点走的方式去编码所以这里我们要从右向左逐位存放编码,具体代码如下。

//从n个叶子结点到跟 逆向求每个叶子结点对应的哈夫曼编码
void creatHuffmanCode(HuffmanTree* huffmanTree, HuffmanCode* huffmanCode, int n)
{
	int c;//当做遍历n个叶子结点的指示标记
	int p;//指向当前结点的父节点
	int start;//当做编码的起始指针
	//分配n个编码的头指针
	huffmanCode = (HuffmanCode*)malloc((n + 1) * sizeof(char*));
	//分配求当前编码的工作空间
	char* cd = (char*)malloc(n * sizeof(char));
	//从右向左逐位存放编码 先写好编码的结束符
	cd[n - 1] = '\0';
	//求编码
	for (int i = 1; i <= n; i++)
	{
		start = n - 1;
		//从叶子到根节点求编码
		for (c = i, p = (*huffmanTree)[i].parent; p != 0; c = p, p = (*huffmanTree)[p].parent)
		{
			if ((*huffmanTree)[p].lChild == c)
			{
				cd[--start] = '0';
			}
			else
			{
				cd[--start] = '1';
			}
		}
		//为第i个编码分配空间
		huffmanCode[i] = (char*)malloc((n - start) * sizeof(char));

		strcpy(huffmanCode[i], &cd[start]);
	}

	for (int i = 1; i <= n; i++)
	{
		printf("%s", huffmanCode[i]);
	}
}

四、图

1.图的基本概念

线性表能表示一对一的关系,树形结构能表示一对多的关系,图就是表示多对多的关系。

图是由两个集合组成:(V)顶点集合(E)边集合。下面介绍一些基本图的概念。

简单图:在图结构中,如果不存在顶点到其自身的边,且同一条边不会重复出现。

无向图:边没有方向的图

完全图:任意两个顶点间都存在一条边。

端点、邻接点:无向图中一条边两边的顶点为这个边的端点,这两个点互为邻接点,若为有向图则为起点合终点。

无向图中顶点具有的边的数目就叫做顶点的度,有向图中边向外的数目是出度数目,边向里的数目是入度数目。

子图:如果一个图是另一个图的子集(顶点和边都得是),那这个图就是他的子图

路径(顶点序列):从任一顶点开始,由边或弧的邻接至关系构成的有限长顶点序列称为路径

路径长度:从顶点到另一个顶点经过边的数目

回路和环:开始节点和结束节点相同

欧拉回路:经过图中各边一次且恰好一次

哈密顿回路:经过图中各顶点一次且恰好一次

连通:无向图中两个顶点间如果有路径,就是连通的,如果任意两个顶点间都连通,就叫做连通图,在一个无向图中的一个极大连通子图,叫做那个图的连通分量。

强连通图、强连通分量:即上面的调节里无向图改为有向图。

稠密图、稀疏图:一个图接近完全图是稠密图,一个图边较少的为稀疏图(具体数据人为定义)

权和网:带权的图就是网

连通图的生成树:一个图的极小连通子树就是他得连通图的生成树

2.邻接矩阵

 对于这样一个图,我们需要存储他的顶点数据和边的数据,邻接矩阵分别通过一个以为数组和一个二维数组来存储,具体如下。

 第一行表示的是每个顶点的数据,下面的表格行与列表示着每个顶点,如果中间的数据为1则表示那两个顶点是连通的,这张表存储加权图的时候,顶点之间的权重写在连通位置取代1就行。这样的存储方式在边较少时会浪费掉很大的内存,里面很多边不存在的情况我们也要用0去表示,为了解决这一问题出现了邻接表存储方式。下面是他的抽象数据类型。

Vertices    存储顶点          Edge   存储边                 numV  numE 顶点数,边数

typedef struct {
	//一个一维数组 代表顶点信息 二维数组代表边的信息
	int Vertices[MaxVertices];
	int Edge[MaxVertices][MaxVertices];
	int numV, numE;//顶点数,和边数
}AdjMatrix;

①存储操作

存储操作是通过两个数组实现的,书写的逻辑非常简单,主要是初始化和输入数据两个操作,要注意的是初始化边的时候上下顶点相同的点要以一个特殊数去区分,这里就直接放代码了。

#include<stdio.h>
#include<stdlib.h>
#define MaxVertices 100

void CreateGraph(AdjMatrix * G) 
{
	int n, e;//n代表顶点数 e代表边数
	int vi, vj;//输入边的时候 要读取的顶点对
	printf("输入顶点数和边数");
	scanf_s("%d%d", &n, &e);
	G->numV = n; G->numE = e;
	//图的初始化操作
	for (int i = 0; i < n; i++)
	{
		for (int j = 0; j < n; j++) {
			if (i == j)
			{
				G->Edge[i][j] = 0;
			}
			else {
				G->Edge[i][j] = 32767;
			}
		}

	}
	//将顶点存入数组
	for (size_t i = 0; i < G->numV; i++)
	{
		printf("请输入第%d个顶点的信息",i + 1);
		scanf_s("%d", &G->Vertices[i]);
	}
	//输入边的信息
	for (int i = 0; i < G->numE; i++)
	{
		printf("请输入边的信息i,j");
		//如果输入的顶点对是值 那还要从顶点集里面查找相对应的下标
		//如果只是输入顶点的个数,直接算下标就可以了
		scanf_s("%d%d", &vi, &vj);
		//如果是无向图 直接等于1
		//如果是带权图 等于权值
		//如果有向图 那就不对称
		G->Edge[vi - 1][vj - 1] = 1;
		G->Edge[vj - 1][vi - 1] = 1;
	}

}

3.邻接表

邻接矩阵是通过二维数组存储的边,而邻接表是通过在顶点后面用链表来存储边的。

 像上面这张图就表示0顶点和1 2 3顶点是想通的。这能非常明确的表现一个顶点的出度,还有一种表为逆邻接表,作用即为表现一个顶点的入度。

下面先介绍邻接表的抽象数据类型,这里主要分为存储边的和存储顶点的,最后集合于GraphAdjList中实现邻接表的所有功能。

#define MAXVEX 100//表示顶点的数目
//边表的结构
typedef struct EdgeNode {
	int adjvex;//邻接的点所对应的下标
	struct EdgeNode* next;//指向下一个的指针
	//int weight//权值
}EdgeNode;
//顶点表的结构
typedef struct VertexNode {
	char data;//存放信息的值
	EdgeNode* first;//边表的头指针
}VertexNode , AdjList[MAXVEX];
//邻接表的抽象结构
typedef struct GraphAdjList {
	AdjList adjlist;//顶点集合数组
	int numVertexes, numEdge;//顶点的数量和边的数量
}GraphAdjList;

①存储操作

这里我们以无向图举例,在最开始我们还是要输入所有顶点的信息,然后再依次输入边的信息,唯一不同到的地方就是边的存储格式变为链条存储而已,具体代码如下。

//无向图构建邻接表
void CreateAlGraph(GraphAdjList* G) 
{
	printf("输入顶点数和边数");
	scanf_s("%d%d", &G->numVertexes, &G->numEdge);
	int vi, vj;//接收边的顶点对的信息
	//输入顶点信息
	for (int i = 0; i < G->numVertexes; i++)
	{
		scanf_s("%c",&G->adjlist[i]);
		getchar();
		G->adjlist[i].first = NULL;
	}
	//输入图中边的信息
	for (int i = 0; i < G->numEdge; i++)
	{
		scanf_s("%d%d", &vi, &vj );//就当他是下标
		getchar();
		EdgeNode* e = (EdgeNode*)malloc(sizeof(EdgeNode));//建立新的边表结点
		//接下来就是头插
		e->adjvex = vj;
		e->next = G->adjlist[vi].first;
		G->adjlist[vi].first = e;
		EdgeNode* e1 = (EdgeNode*)malloc(sizeof(EdgeNode));//建立新的边表结点
		//接下来就是头插
		e1->adjvex = vi;
		e1->next = G->adjlist[vj].first;
		G->adjlist[vj].first = e1;
	}
}

4.十字链表

邻接矩阵的优点在于能同时表现顶点的入度和出度,邻接表的优点在于能通过链式存储用较少的空间,将这两种结构合并就会成为十字链表

上图我们通过十字链表连接之后会生成下图的表,但由于还没有介绍他的抽象数据类型,一些连接方面的细节在下面会细讲。

 下面是他的抽象数据类型,由于十字链表的基础结构与邻接表很相似,都是通过数组与链表实现的,只不过数组与链表的节点表示的不同而已,所以这里也是分为边集与顶点集,最后合并实现的十字链表,下面的图左边的是顶点集,右边的是边集。

#define MAX 200
//边集
typedef struct ArcBox {
	int tailvex, headvex;//弧尾、弧头对应的顶点在数组中的下标
	struct ArcBox* hlink, * tlink;//弧头、弧尾相同的下一个边(弧)
}ArcBox;

//顶点集
typedef struct VexNode 
{
	int data;//数据域
	ArcBox* firstIn, * firstOut;//以该节点为弧头或弧尾的链表的首节点
}VexNode;

typedef struct {
	VexNode xlist[MAX];//存储顶点的一维数组
	int vexnum, arcnum;//顶点数,和边数
}OLGraph;

①存储操作

这里的存储操作的第一步还是建立顶点表,方法与之前都一样这里就不多说了,主要不同的步骤在于构建边表,构建边表的初始化同样是输入存在到的边,但是我们要实现通过输入的边建立相对应的弧变成了两个,就是不论是入度操作还是出度操作我们都要建立对应的弧,具体代码如下。

void CreatDG(OLGraph* G)
{
	int vi, vj;//输入的是下标
	//输入有向图的顶点数和边数
	scanf_s("%d%d", &G->vexnum, &G->arcnum);
	//先输入顶点集的数据
	for (int i = 0; i < G->vexnum; i++)
	{
		scanf_s("%d", &G->xlist[i].data);
		G->xlist[i].firstIn = NULL;
		G->xlist[i].firstOut = NULL;
	}
	//构建十字链表
	for (int i = 0; i < G->arcnum; i++)
	{
		//就当直接输入的下标
		scanf_s("%d%d", &vi, &vj);
		//建立弧的节点
		ArcBox* p = (ArcBox*)malloc(sizeof(ArcBox));
		//存储弧头和弧尾所对应的下标的位置
		p->tailvex = vi;
		p->headvex = vj;
		//头插法插入新的边表结点p
		p->hlink = G->xlist[vj].firstIn;//指向弧头相同的下一个弧
		p->tlink = G->xlist[vi].firstOut;//指向弧尾相同的下一个弧
		G->xlist[vi].firstOut = G->xlist[vj].firstIn = p;
	}
}

 十字链表的缺点是对于边的操作不方便,如果对边的增删改查很多的时候就不建议用他。

5.临接多重表

邻接多重表是无向图的一种存储结构。如果在无向图中我们的侧重点在顶点上,那么使邻接表是很合适的,当我们的侧重点在边上,也就是需要对边增删查改的时候,用邻接多重表就更加合适了。

临接多重表具体是怎么实现的?这里直接看他的抽象数据类型,左边的图是顶点集,右边的图是边集。顶点集VexNode由顶点的数据域data和指向顶点所连接的边节点的指针firstEdge构成。边集里iVexjVex是一条边连接的两个节点(Vi,Vj)在顶点集中的下标,headEdge和tailEdge分别是指向有着相同头、尾节点的边节点的指针,代码与图如下

#define MAX 100
//确定边的类型
typedef struct node {
	int ivex, jvex;
	struct node* Vi;
	struct node* Vj;
}ArcNode;
//确定顶点表中的属性
typedef struct {
	char vertex;
	ArcNode* first;
}VNode;
//邻接多重表的抽象类型
typedef struct
{
	VNode Dvex[MAX];
	int vexnum, arcnum;
}Grap;

 ①存储操作

前面的初始化操作都是一样的,先接收顶点再接收边的信息,唯一不同的地方还是在于每个边域顶点连接的关系,这里的关系有代码表示最清晰,我就不多说了,代码如下。

//建立邻接多重表
void creat(Grap* G)
{
	//接收两端的值和相对应的下标 我们就当接收的是下标
	int vi, vj;
	//先输入顶点数和边数
	scanf_s("%d%d", &G->vexnum, &G->arcnum);
	//输入顶点数组的值
	for (int i = 0; i < G->vexnum; i++)
	{
		//输入顶点数组的值
		scanf_s("%c", &G->Dvex[i].vertex);
		//scanf注意清空缓冲区
		G->Dvex[i].first = NULL;
	}
	for (int  i = 0; i < G->arcnum; i++)
	{
		//先找到边对应的结点对的下标
		scanf_s("%d%d", &vi, &vj);
		ArcNode* e = (ArcNode*)malloc(sizeof(ArcNode));
		e->ivex = vi;
		e->jvex = vj;
		e->Vi = G->Dvex[vi].first;
		G->Dvex[vi].first = e;
		e->Vj = G->Dvex[vj].first;
		G->Dvex[vj].first = e;
	}
}

6.边集数组

边集数组由两个一维数组构成,一个存储顶点的信息,一个存储边的信息。

 如上图,如果我们要存储这样的信息,顶点的信息将会直接存储在一维数组里,边的信息会存储他两边的顶点与该边的权值。

这个的实现后面会用到,这里就不实现了。

7.图的遍历

对于图的遍历我们能发现与二叉树的会略有不同,因为通过二叉树的方法我们是没法很好的遍历一个图的,这里我们主要用的是深度优先搜索(DFS)与广度优先搜索(BFS)。

①深度优先搜索

在树中,树的中序、后序遍历方法都是深度优先思想,但是由于图相对于树的性质来说,一个图“子节点”的个数是不确定的,所以肯定会出现遗漏的点,这时候就体现深度优先搜索的另一个思想“回溯”,在递归返回的时候检验经过的节点所有的子节点是否有被遍历过(因为递归前没法确定有一个子节点去依次进入而递归后判断是可以的,这里不理解可以自己实现一下),若没有遍历过则在回溯时遍历。能满足这种操作的,很显然一个是递归,还有栈的压栈和出栈也能很好的实现这个功能。

这里我们先通过DFS实现一次图的遍历,分别是遍历的操作和回溯查找未遍历过得节点,代码如下。

#include <stdio.h>
#include <stdlib.h>
/*
创建无向图并进行深度优先搜索
*/
#define MAXN 100
typedef struct ArcCell {
    char vexnum[MAXN];         //顶点
    int arcnum[MAXN][MAXN];    //弧
    int n, e;                  //顶点数, 弧数
}Graph;

void CreateGraph(Graph* G) {        //创建图 ,此处注意&G 
    int s, t;
    scanf("%d %d", &G->n, &G->e);
    getchar();
    for (int i = 0; i < G->n; i++) {
        scanf("%c", &G->vexnum[i]);
    }
    for (int i = 0; i < G->n; i++) {              //初始化数据 
        for (int j = 0; j < G->n; j++) {
            G->arcnum[i][j] = 0;
        }
    }
    for (int i = 0; i < G->e; i++) {              //创建图的邻接矩阵
        scanf("%d %d", &s, &t);
        G->arcnum[s][t] = 1;
        G->arcnum[t][s] = 1;
    }
}
//需要有个东西来判断顶点是否被访问
int Visit[MAXN] = { 0 };//定义一个数组并进行初始化操作 1代表结点被访问

void DFSTraverse(Graph G, int i)//对于连通分量进行深度搜索
{
    printf("%c", G.vexnum[i]);
    for (int j = 0; j < G.n; j++)
    {
        if(G.arcnum[i][j] && !Visit[j] )
        {
            Visit[j] = 1;
            DFSTraverse(G, j);
        }
    }
}

void DFS(Graph G)//对整个图进行深度搜索
{
    //思考邻接矩阵和无向图来思考整个遍历过程
    for (int i = 0; i < G.n; i++)
    {
        if (!Visit[i])//如果相对应的结点没有被访问过
        {
            Visit[i] = 1;
            DFSTraverse(G, i);//连通分量
        }

    }
}

int main()
{
    Graph* g = (Graph*)malloc(sizeof(Graph));
    CreateGrahp(g);
    DFS(g);

    return 0;
}

 ②广度优先搜索

广度优先搜索在实现的方面与我们之前的层序遍历完全一致,这一块可以用递归或者队列去实现,具体实现的操作是将出队的节点遍历,并将出队节点的连接的节点都入队,每个节点都只能遍历一次所以还需要知道该节点是否出队过,具体代码如下。

#include <stdio.h>
#include <stdlib.h>
#define MAX_VERtEX_NUM 20                   //顶点的最大个数
#define VRType int                          //表示顶点之间的关系的变量类型
#define InfoType char                       //存储弧或者边额外信息的指针变量类型
#define VertexType int                      //图中顶点的数据类型
typedef enum { false, true }bool;               //定义bool型常量
bool visited[MAX_VERtEX_NUM];               //设置全局数组,记录标记顶点是否被访问过
typedef struct Queue {
    VertexType data;
    struct Queue* next;
}Queue;
typedef struct {
    VRType adj;                             //对于无权图,用 1 或 0 表示是否相邻;对于带权图,直接为权值。
    InfoType* info;                        //弧或边额外含有的信息指针
}ArcCell, AdjMatrix[MAX_VERtEX_NUM][MAX_VERtEX_NUM];
typedef struct {
    VertexType vexs[MAX_VERtEX_NUM];        //存储图中顶点数据
    AdjMatrix arcs;                         //二维数组,记录顶点之间的关系
    int vexnum, arcnum;                      //记录图的顶点数和弧(边)数
}MGraph;

//构造无向图
void CreateDN(MGraph* G) {
    scanf_s("%d,%d", &(G->vexnum), &(G->arcnum));
    for (int i = 0; i < G->vexnum; i++) {
        scanf_s("%d", &(G->vexs[i]));
    }
    for (int i = 0; i < G->vexnum; i++) {
        for (int j = 0; j < G->vexnum; j++) {
            G->arcs[i][j].adj = 0;
            G->arcs[i][j].info = NULL;
        }
    }
    for (int i = 0; i < G->arcnum; i++) {
        int v1, v2;
        scanf_s("%d,%d", &v1, &v2);
        int n = LocateVex(G, v1);
        int m = LocateVex(G, v2);
        if (m == -1 || n == -1) {
            printf("no this vertex\n");
            return;
        }
        G->arcs[n][m].adj = 1;
        G->arcs[m][n].adj = 1;//无向图的二阶矩阵沿主对角线对称
    }
}

//初始化队列
void InitQueue(Queue** Q) {
    (*Q) = (Queue*)malloc(sizeof(Queue));
    (*Q)->next = NULL;
}
//顶点元素v进队列
void EnQueue(Queue** Q, VertexType v) {
    Queue* element = (Queue*)malloc(sizeof(Queue));
    element->data = v;
    element->next = NULL;
    Queue* temp = (*Q);
    while (temp->next != NULL) {
        temp = temp->next;
    }
    temp->next = element;
}
//队头元素出队列
void DeQueue(Queue** Q, int* u) {
    (*u) = (*Q)->next->data;
    (*Q)->next = (*Q)->next->next;
}
//判断队列是否为空
bool QueueEmpty(Queue* Q) {
    if (Q->next == NULL) {
        return true;
    }
    return false;
}

int LocateVex(MGraph* G,int v)//根据结点本身的数据 判断顶点在二维数组当中的位置
{
    int i = 0; //遍历一维数组
    for ( ;  i< G->vexnum; i++)
    {
        if (G->vexs[i] == v)
        {
            break;
        }
    }
    return i;
}

int FirstAdjVex(MGraph G, int v)
{
    for (int i = 0; i < G.vexnum; i++)
    {
        if (G.arcs[v][i].adj)//只要有边 返回下标
        {
            return i;
        }
    }
    return -1;
}

int  NextAdjVex(MGraph G, int v, int w) 
{
    //从前一个访问位置w的下一个位置开始 查找与之右边的结点
    for (int i = w + 1; i < G.vexnum; i++)
    {
        if (G.arcs[v][i].adj)//只要有边 返回下标
        {
            return i;
        }
    }
    return -1;
}



int visited[20] = {0};

void BFSTraverse(MGraph G)
{
    //对于每一个未被访问的顶点调用广搜
    Queue* Q;
    InitQueue(&Q);
    for (int i = 0; i < G.vexnum; i++)
    {
        if (!visited[i])
        {
            visited[i] = 1;
            printf("%d", G.vexs[i]);//输出已被访问的结点
            EnQueue(&Q, G.vexs[i]);
            while (!QueueEmpty(Q))//判断队列是否为空
            {
                int u;//看好你的队列取出的是下标还是元素
                DeQueue(&Q, &u);//元素出队
                //如果是元素 还要定位下标 遍历
                u = LocateVex(&Q, u);
                //查找与数组下标u的顶点之间有边的顶点
                for ( int w = FirstAdjVex(G,u); w >= 0; w = NextAdjVex(G,u,w))
                {
                    //与之相邻的所有边 入队
                    if (!visited[w])
                    {
                        visited[w] = 1;
                        printf("%d", G.vexs[i]);//输出已被访问的结点
                        EnQueue(&Q, G.vexs[w]);
                    }
                }
            }
        }
    }
}

 8.最小生成树

对于一个连通图而言,他的生成树是一个极小连通子图,而针对带权图来说的边的权值之和最小的生成树就是最小生成树,下面介绍一些生成树的性质。

①对于一个包含n个顶点的完全图有n个生成树

②而对于有n个顶点的连通图而言,生成树有n-1条边

③一个连通图的生成树包含相同的顶点数和边数

④生成树不存在环

⑤生成树的基础上删除任意一条边都会导致图的不连通,而如果添加任意一条边都会形成环

①克鲁斯卡尔算法实现最小生成树逻辑

这个算法的核心思想与贪心算法相同,即每一步都作出当下最优解,通过局部最优解来实现整体最优。克鲁斯卡尔算法就是将每一个顶点都看作一个单独的树,不断地去找与之相邻的权值最小的点,同时不能成环,最后得到的生成树就是一个最小生成树,这里我是通过将权值升序排列来实现找到相邻权值最小的节点的。

已知我们要对权值进行升序排序,并且要在排序后将该边两边的顶点用于加入生成树,从这些我们就可以决定用边集数组去作为我们存储这个图的存储结构,下图就是图经过边集数组存储的样子

最后我们只需要从上往下依次把边与节点添加到树里,当遇到会导致树成环的边将其舍弃,就能形成最小生成树,这里具体怎么实现的判断树是否成环呢?我们通过并查集将所有使用过的边的顶点放在一个集合里,这时候我们要让本就在一条线里的数据有一个共同的标志,比如让一条线里的所有数据都指向该线里最大的数据,这时候如果添加进一个新边,而这条边两边的顶点已经有了共同的最大数据了,那这条边一定会造成树成环,便判断出了我们要舍弃的边。

 ②克鲁斯卡尔算法实现最小生成树代码实现

#include<stdio.h>
#include<stdlib.h>
#define MAXVEX 200
#define INFINTIY 65535
//克鲁斯卡尔
//邻接矩阵
typedef struct AdjacentMatrix
{
    //顶点集
    int Vertices[MAXVEX];
    //边集
    int Arc[MAXVEX][MAXVEX];
    //顶点数 边数
    int numV, numE;
}AdjMatrix;
//用带权无向邻接矩阵生成图
 
//边集数组
typedef struct 
{
    int begin;
    int end;
    int weihgt;
}Edges;
 
int Find(int* parent, int f)
{
    while(parent[f] > 0)
    {
        f = parent[f];
    }
    return f;
}
 
void sort(Edges e[], AdjMatrix* G)
{
    int i, j;
    for(i = 0; i < G->numE; i++)
    {
        for(j = 0; j < G->numE; j++)
        {
            if(e[i].weihgt > e[j].weihgt)
            {
                //交换函数,暂时没学后面会讲
                Swapn(e, i, j);
            }
        }
    }
    printf("按权排序后的为:\n");
    for(i = 0; i < G->numE; i++)
    {
        printf("(%d %d)%d\n", e[i].begin, e[i].end, e[i].weihgt);
    }
}
 
void Kruskal(AdjMatrix* G)
{
    Edges e[20];//边集数组
    //判断两边之间是否存在环 并查集
    int parent[MAXVEX];
    int k = 0;
    //根据图的存储来构建边集数组
    for(int i = 0; i < G->numV; i++)
    {
        for(int j = 0; j < G->numV; j++)
        {
            //带权图邻接矩阵小于最大值 说明有边
            if(G->Arc[i][j] < INFINTIY)
            {
                e[k].begin = i;//开始节点
                e[k].end = j;//结束节点
                e[k].weihgt = G->Arc[i][j];
                k++;
            }
        }
    }
 
    //针对边集数组排序(按照权值排序)
    sort(e, G);
    //由于算法没学 这里构建图的时候按权值排序构建
 
    //初始化辅助数组
    for(int i = 0; i < G->numV; i++)
    {
        parent[i] = 0;
    }
    //构建最小生成树
    for(int i = 0; i < G->numE; i++)//循环遍历每一条边 直到放进了所有的边
    {
        //并查集 查
        //在边集数组中 查找每一条边的起点 和 终点
        int n = Find(parent, e[i].begin);
        int m = Find(parent, e[i].end);
        //n m没有构成环 或m n指向自己
        if(n != m || n == m == 0)
        {
            //把这个节点的尾节点的下标放入parent中
            parent[n] = m;
            printf("(%d,%d)--%d这两点属于最小生成树的一部分", e[i].begin, e[i].end, e[i].weihgt);
        }
    }
}
 

 ③普里姆算法实现最小生成树逻辑

这个算法的核心思路是,从一个节点开始,每次放入以放入节点里权最小的那个节点,重复上述操作直到所有顶点都被放入,光说有点抽象下面看例子。

 ①把V0放进

②再找V0相邻的所有权值最小的点放进,从带权图中我们可以得到与V0相邻的节点有V1和V5,而它们之间的边的权值分别是3和4,毫无疑问V1就是我们这一步要找的节点

 第③步,接着再找V0和V1所有相邻的点有V2 V8 V5 V6,同理可以得出V5为顶点的边权值最小,所以把V5放进A类中

第④步再找V0 V1 V5所有相邻的点有V2 V8 V6 V4,V8是最小权值的边的顶点,放入V8

第⑤步再找V0 V1 V5 V8的所有邻接点V2 V3 V6 V4,同理放入V2

第⑥步找V0 V1 V5 V8 V2的所有邻接点V3 V6 V4,放入V6

第⑦步重复上述的操作,先后放入V7 V4 V3

至此,所有的节点都放进了A类当中,回顾上述的这些操作,根据放入A类的节点的先后顺序,我在带权图上也标记了相对应的边,如下图所示,这些顶点和边就是我们要找的最小生成树

④普里姆算法实现最小生成树代码实现

这里用的是邻接矩阵构建带权图,那我要先将图存入到邻接矩阵中,代码如下

void creategrahp(AdjMatrix* G)
{
    int n, e;//n代表顶点数 e代表边数
    int vi, vj;//vi vj代表边的两个顶点对
    int w;//表示边的权值
    printf("要输入的顶点数和边数\n");
    scanf("%d%d",&n,&e);
    G->numV = n; 
    G->numE = e;
    //图的初始化
    for(int i = 0; i < n; i++)
    {
        for(int j = 0; j < n; j++)
        {
            if(i == j)
            {
                //一个非带权的图 0代表没有边 1代表有边
                //边不指向自己 即对角线为0
                G->Arc[i][j] = 0;
            }
            else
            {
                //如果是带权的图 初始化为0或者为一个不可能的值
                G->Arc[i][j] = 65535;
            }
        }
    }
    //将顶点存入数组
    for(int i = 0; i < G->numV; i++)
    {
        printf("请输入第%d个节点的信息\n",i + 1);
        scanf("%d", &G->Vertices[i]);
    }
    //输入边的信息
    for(int i = 0; i< G->numE; i++)
    {
        //如果输入的是顶点的值 需要从顶点集中查找对应的下标 
        //如果是带权图 还要输入权的信息
        printf("请输入边的信息Vi,Vj和权值w\n");
        scanf("%d%d%d",&vi,&vj,&w);
        G->Arc[vi][vj] = w;
        //如果是带权图 等于权值
        //如果是有向图 就不对称
        //如果是无向图 矩阵对称
        G->Arc[vj][vi] = w;
    }
}

接下来我们初始化两个数组adjvex和lowcast,其中adjvex是表示用来保存节点的下标的(存入到树里的节点),lowcast用来保存相关边的权值;这里我们仍用V0为例:开始节点为V0,那么在应该在lowcast数组中存入所有与V0有关的边的权值,如果不存在边就用inf表示。adjvex数组中,第 i 号元素的下标对应连接的另一个节点。

这里我们先把adjvex中的所有元素赋值为0,因为具体不方便确定哪个元素与V0相连,而后面每次新加入节点时又会对该节点相连的节点(即adjvex)做一次更新,方便起见我们就全部初始化为0

void Prim(AdjMatrix* G)
{
    int adjvex[MAXVEX];//用来保存相关节点的下标
    int lowcast[MAXVEX];//用来保存相关边的权值
    lowcast[0] = 0;//初始化第一个权值为0 表示v0加入最小生成树
    adjvex[0] = 0;//第一个顶点下标为0
    for(int i = 1; i <= G->numV; i++)
    //循环除了0以外的全部顶点
    {
        //将与v0有关的边的权值全部存入数组
        lowcast[i] = G->Arc[0][i];
        adjvex[i] = 0;
    }

接下来就是要去找权值最小的边,并记录这条边的结束节点K,边(V0,Vk)就是最小生成树的一条边,把k放进生成树中,并做上标记表示已经访问过该节点

//找寻最小权值
    for(int i = 1; i < G->numV; i++)
    {
        int min = INFINTIY;
        int k = 0;//返回最小值的下标
        int j = 1;
        for(; j <= G->numV; j++)//Vj是除V0以外的顶点
        {
            //如果Vi和Vj有边或这条边没有被找到,且边的权值最小
            if(lowcast[j] != 0 && lowcast[j] < min)
            {
                min = lowcast[j];//就让当前权值成为最小值
                k = j;
            }
        }
        //打印当前找到的顶点的边中 权值最小的边
        printf("(%d %d)--%d ", adjvex[k], k, lowcast[k]);
        //将当前顶点的权值设置为0 代表加入了生成树中
        lowcast[k] = 0;

接下来我们要找的是V0节点和Vk节点的所有邻接点,继续找权值最小的边,所以我们应该更新lowcast和adjvex数组,把与Vk有关的边(之前没被访问过的)的权值放进lowcast中,并把这个边的另一个节点Vj在adjvex中所对应的下标的值改为k,重复上述步骤,直到所有点都加入。

for(j = 1; j <= G->numV; j++)
        {
            //如果下标为k的顶点相邻的各边的权值小于此前未被加入的顶点的权值 则加入生成树中
            if(lowcast[j] != 0 && G->Arc[j][k] < lowcast[j])
            {
                //更新lowcast和adjvex数组
                lowcast[j] = G->Arc[j][k];
                adjvex[j] = k;
            }
        }
}

下面是全部的代码

#include<stdio.h>
#include<stdlib.h>
#define MAXVEX 200
#define INFINTIY 65535
//prim算法
//邻接矩阵
typedef struct AdjacentMatrix
{
    //顶点集
    int Vertices[MAXVEX];
    //边集
    int Arc[MAXVEX][MAXVEX];
    //顶点数 边数
    int numV, numE;
}AdjMatrix;
//用带权无向邻接矩阵生成图
 
void creategrahp(AdjMatrix* G)
{
    int n, e;//n代表顶点数 e代表边数
    int vi, vj;//vi vj代表边的两个顶点对
    int w;//表示边的权值
    printf("要输入的顶点数和边数\n");
    scanf("%d%d",&n,&e);
    G->numV = n; 
    G->numE = e;
    //图的初始化
    for(int i = 0; i < n; i++)
    {
        for(int j = 0; j < n; j++)
        {
            if(i == j)
            {
                //一个非带权的图 0代表没有边 1代表有边
                //边不指向自己 即对角线为0
                G->Arc[i][j] = 0;
            }
            else
            {
                //如果是带权的图 初始化为0或者为一个不可能的值
                G->Arc[i][j] = 65535;
            }
        }
    }
    //将顶点存入数组
    for(int i = 0; i < G->numV; i++)
    {
        printf("请输入第%d个节点的信息\n",i + 1);
        scanf("%d", &G->Vertices[i]);
    }
    //输入边的信息
    for(int i = 0; i< G->numE; i++)
    {
        //如果输入的是顶点的值 需要从顶点集中查找对应的下标 
        //如果是带权图 还要输入权的信息
        printf("请输入边的信息Vi,Vj和权值w\n");
        scanf("%d%d%d",&vi,&vj,&w);
        G->Arc[vi][vj] = w;
        //如果是带权图 等于权值
        //如果是有向图 就不对称
        //如果是无向图 矩阵对称
        G->Arc[vj][vi] = w;
    }
}
 
void Prim(AdjMatrix* G)
{
    int adjvex[MAXVEX];//用来保存相关节点的下标
    int lowcast[MAXVEX];//用来保存相关边的权值
    lowcast[0] = 0;//初始化第一个权值为0 表示v0加入最小生成树
    adjvex[0] = 0;//第一个顶点下标为0
    for(int i = 1; i <= G->numV; i++)
    //循环除了0以外的全部顶点
    {
        //将与v0有关的边的权值全部存入数组
        lowcast[i] = G->Arc[0][i];
        adjvex[i] = 0;
    }
    //找寻最小权值
    for(int i = 1; i < G->numV; i++)//用来循环生成边的次数
    {
        int min = INFINTIY;
        int k = 0;//返回最小值的下标
        int j = 1;
        for(; j <= G->numV; j++)//Vj是除V0以外的顶点
        {
            //如果Vi和Vj有边或这条边没有被找到,且边的权值最小
            if(lowcast[j] != 0 && lowcast[j] < min)
            {
                min = lowcast[j];//就让当前权值成为最小值
                k = j;
            }
        }
        //打印当前找到的顶点的边中 权值最小的边
        printf("(%d %d)--%d ", adjvex[k], k, lowcast[k]);
        //将当前顶点的权值设置为0 代表加入了生成树中
        lowcast[k] = 0;
        for(j = 1; j <= G->numV; j++)//从k之后节点开始,进入下一轮迭代
        {
            //如果下标为k的顶点相邻的各边的权值小于此前未被加入的顶点的权值 则加入生成树中
            if(lowcast[j] != 0 && G->Arc[j][k] < lowcast[j])
            {
                //更新lowcast和adjvex数组
                lowcast[j] = G->Arc[j][k];
                adjvex[j] = k;
            }
        }
    }
}
 
int main()
{
    AdjMatrix G;
    creategrahp(&G);
    Prim(&G);
    system("pause");
    return 0;
}

8.最短路径

①最短路径基本概念

在一条路径中,起始的第一个节点叫做源点,在一条路径中,最后一个的节点叫做终点。源点和终点都只是相对于一条路径而言,每一条路径都会有相同或者不相同的源点和终点。 

最短路径:在图中,对于非带权无向图而言,从源点到终点边最少的路径(可以用BFS实现),而对于带权图而言,从源点到终点权值之和最少的路径叫最短路径

实现最短路径有两种算法:Dijkstra迪杰斯特拉算法和Floyd弗洛伊德算法

②迪杰斯特拉(Dijkstra)最短路径算法

Dijkstra迪杰斯特拉是一种处理单源点的最短路径算法,就是求从某一个节点到其他所有节点的最短路径算法。在原来的存储基础上,我们还要引入三个变量来帮助我们实现最短路径,D变量:标志着后面的数据与原点到下标为B的变量有关,P变量:记录使该处D发生改变的最后一个节点(这个看不懂可以跳过),Final变量:用来标记是否已经求出源点到该点的最短路径。

 对于上图我们要求他的最短路径,现列出那几个变量组成的数组,初始化0节点相连的变量并记录其权值,若不与0相连则写inf,具体如下。

 在辅助向量D中,与源点V0有边的就填入边的权值,没边就是无穷大,构建了D、P和Final,那么我们要开始遍历V0,找V0的所有边中权值最短的的边,把它在D、P、Final中更新。

比如在上述带权无向图中,我们可以得到与源点有关的边有(V0,V1)和(V0,V2),它们的权值分别是1和5,那么我们要找到的权值最短的的边,就是权值为1 的(V0,V1),所以把Final[1]置1,表示这个边已经加入到最短路径之中了。而原本从V0到V2的距离是5,现在找到了一条更短的从V0 -> V1 -> V2距离为4,所以D[2]更新为4,P[2]更新为1,表示源点到V2经过了V1的中转

 继续遍历,找到从V0出发,路径最短并且final的值为0的节点。因为经过节点V1的中转,源点到V3和V4有了路径,从源点到V3的距离是1+7==8,到V4的距离是1+5==6,把它们在D中更新;再以V1为中心,去找与V1有关的边是否有能更新的边,可以得到此时V0到V2的距离为4,比原来的5小,于是把V2的三个变量更新

 重复此步骤直到我们除8以外的节点Final都为1,此时表格如下。

至此,源点和终点都被加入到了最短路径当中,Dijkstra算法结束;我们从P[8]开始从后往前推,就可以得到这个带权无向图的从V0到V8的最短路径;

如图,所示从P[8]开始从后往前推算,数组P中的值就是在最短路径中该节点的上一个节点。可以得到:V8<-V7<-V6<-V3<-V4<-V2<-V1<-V0;即如下图所示:

 

 ③迪杰斯特拉算法代码实现

因为是带权的无向图,所以这里是以邻接矩阵去构建图。

void creategrahp(AdjMatrix* G)
{
    int n, e;//n代表顶点数 e代表边数
    int vi, vj;//vi vj代表边的两个顶点对
    int w;//表示边的权值
    printf("要输入的顶点数和边数\n");
    scanf("%d%d",&n,&e);
    G->numV = n; 
    G->numE = e;
    //图的初始化
    for(int i = 0; i < n; i++)
    {
        for(int j = 0; j < n; j++)
        {
            if(i == j)
            {
                //一个非带权的图 0代表没有边 1代表有边
                //边不指向自己 即对角线为0
                G->Edge[i][j] = 0;
            }
            else
            {
                //如果是带权的图 初始化为0或者为一个不可能的值
                G->Edge[i][j] = 65535;
            }
        }
    }
    //将顶点存入数组
    for(int i = 0; i < G->numV; i++)
    {
        printf("请输入第%d个节点的信息\n",i + 1);
        scanf("%d", &G->Vertices[i]);
    }
    //输入边的信息
    for(int i = 0; i< G->numE; i++)
    {
        //如果输入的是顶点的值 需要从顶点集中查找对应的下标 
        //如果是带权图 还要输入权的信息
        printf("请输入边的信息Vi,Vj和权值w\n");
        scanf("%d%d%d",&vi,&vj,&w);
        G->Edge[vi][vj] = w;
        //如果是带权图 等于权值
        //如果是有向图 就不对称
        //如果是无向图 矩阵对称
        G->Edge[vj][vi] = w;
    }
}

 

 ④弗洛伊德(Floyd)最短路径算法

我们现在只能通过Dijkstra求从某一个节点到其他所有节点的最短路径,如果我们想要求出任意两个节点之间的最短路径,就需要执行 N 次的 Dijkstra 算法。在Dijkstra的实现中,我们用到了长度为 N 的辅助向量和路径向量,这时候如果要想知道任意两个节点之间的最短路径,就需要用到 N * N 个大小的二维数辅助向量和路径向量。这也是Floyd的算法核心和出现的原因,但是Floyd只是思想巧妙,在复杂度上没有提升多少。

总结

未完待续

  • 15
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

康来个程

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

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

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

打赏作者

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

抵扣说明:

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

余额充值