数据结构知识总结(陈越版教材)

目录

第三章  线性结构

一、顺序表

(1)顺序存储

(2)链式存储

二、堆栈Stack

(1)顺序存储

(2)链式存储

(3)应用

1.表达式求值

2.单调栈的应用

三、队列Queue

(1)顺序存储

(2)链式存储

(3)应用

第四章  树

一、顺序存储

二、链式存储

三、二叉树的操作

(1)二叉树的遍历

(2)输出二叉树的所有叶结点

(3)求二叉树的高度

(4)有n个结点的二叉树的个数

四、二叉搜索树

        五、平衡二叉树 

        六、树的应用

        (1)堆

        (2)哈夫曼树

第五章  查找

(1)静态查找

 (2)动态查找

 (3)散列查找

1.开放地址法

2.分离链表法

第六章  图

(1)图的存储

(2)图的遍历

(3)最小生成树

(4)最短路径

(5)拓扑排序

(6)关键路径

第七章  排序

(1)选择排序

        1.简单选择排序

        2.堆排序

(2)插入排序

        1.简单插入排序

        2.希尔排序

(3)交换排序

        1.冒泡排序

        2.快速排序(唯一真神)

(4)归并排序


第三章  线性结构

线性表是由同一类型的数据元素构成的有序序列的线性结构

一、顺序表

(1)顺序存储

1.概念:线性表的顺序存储是指在内存中用地址连续的一块存储空间顺序存放线性表的各元素,而一维数组在内存中刚好满足这一性质,所以用一维数组来表示顺序存储的数据区域;

2.数组从下标为0的地方开始存储(Date[0]);

3.一般用全局变量MAXSIZE来表示线性表的最大容量,MAXSIZE的大小根据实际情况来设定;

4.表的长度是动态可变的,一般用变量Last记录线性表中最后一个元素的位置。其始终指向线性表的最后一个元素,当表空时Last = -1;

5.表长 = Last + 1,数据元素存储在下标0~Last的位置;

6.查找元素

代码书写:

int i = 0;

while(i <= L-> last && L->Date[i] != x)            //x表示要查找的元素
    i++;

return i;

平均时间复杂度为O(n)

7.插入元素

在第 i 个位序上插入一个新元素x,原长度从n变为n + 1

等价于 在第 i 个元素之前插入新元素

(i = 1 时,代表插入到序列最前端;为n+1时,代表插入到序列最后)

可以按照以下3个步骤进行:

        (1)将a[i]到a[n]这几个元素往后移一个位置,让a[i]处于空闲状态

        (2)a[i] = x;

        (3)修改Last,让Last++;

时间复杂度为O(n)

8.删除元素

删除下标为i的元素,原长度从n变为n - 1

可以按照以下2个步骤进行:

        (1)将a[i + 1]到a[n]这几个元素往前移一个位置

        (2)修改Last,让Last--;

(2)链式存储

1.链式存储提出的背景:

顺序表的顺序存储在进行插入和删除时需要移动大量的数据,影响了运行的效率

一般利用结构体来定义:(我习惯这样写)

typedef struct node
{
    int date;
    struct node * next;
}Node;

为了访问链表,须找到链表的第一个数据单元,因此用一个指针head来指向链表的第一个单元

2.求表长

int get_length(Node * head)
{
    int length = 0;
    Node * p = haed;
    while(p != NULL)
    {
        length++;
        p = p->next;
    }
    return length;
}

3.查找

(1)查找第k个元素

思路:用p指针遍历链表,直到遍历到第k个结点并返回它的值

int find(int k,Node * head)
{
    int cnt = 1;
    Node * p = head;
    while (cnt < k)
    {
        p = p->next;
        cnt++;        
    }
    return p->date;
}

(2)按值查找

思路:用p指针遍历链表,直到遍历到date = x的结点,返回结点的位置

Node * find(int k,Node * head)
{
    Node * p = head;
    while (p != NULL)
    {
        p = p->next;
    }
    return p;
}

4.插入

插入是指在位序 i 前插入一个元素x

(i = 1 时,代表插入到链表的头;为n+1时,代表插入到链表的最后)

void insert(int x,int i,Node * head)
{
    Node * p = head;
    if(i == 1)
    {
        p = p->next;
        Node* temp = (Node*)malloc(sizeof(Node));
        temp -> date = x;
        head -> next = temp;
        temp -> next = p;
    }
    else
    {
        int cnt = 1;
        while (cnt < i)
        {
            p = p->next;
        }
        //循环结束时,p指向i的前一个元素
        Node* p1 = p->next;
        Node* temp = (Node*)malloc(sizeof(Node));
        temp -> date = x;
        p -> next = temp;
        temp -> next = p1;
    }
}

5.删除

插入是指在位序 i 删除一个结点

void delete(Node * head,int i)
{
    int cnt = 2;
    Node * p = head;
    if(i == 1)
    {
        head = head->next;
        free(p);
    }
    else
    {
        while (cnt < i)
        {
            p = p->next;
            cnt++;
        }
        Node * temp = p->next;
        p->next = p->next->next;
        free(temp);
    }
}

二、堆栈Stack

栈是一种先入后出的数据结构,类似于弹夹

进栈和出栈的顺序的排列组合有\frac{1}{n+1}\cdot \frac{n!}{2n\cdot 2n-1\cdot \cdot \cdot \cdot n+1}

堆栈最重要的应用为计算算术表达式、判断括号是否匹配 和 给定一个长度为 N 的整数数列,输出每个数左边第一个比它小的数(单调栈)

(1)顺序存储

栈的顺序存储结构由一个一维数组和一个记录栈顶位置的变量组成,此外用一个MAXSIZE来标记栈的最大容量,以来方便判断是否满栈:

int Date[MAXSIZE];

int top = -1;

top记录栈顶元素的下标值,当top = -1 时表示空栈,top = MAXSIZE-1时表示满栈。

当然也可以将top初始化为0,当top = 0 时表示空栈,我喜欢用后者。

栈的基本操作:

//插入
stk[++top] = x;

//弹出
top--;

//判断栈是否为空
if(top > -1) not empty
else empty

//栈顶元素
stk[top]

(2)链式存储

栈的链式存储基本用不到,这里不做过多解释

(3)应用

1.表达式求值

通过中缀表达式求值分成两步:

        方法一:

        ①将中缀表达式逐步放到数字栈和运算符栈中

        ②用栈对后缀表达式求值

        方法二:

        ①将中缀表达式转化为后缀表达式

        ②用栈对后缀表达式求值

        方法一代码:

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

const int N = 10000;
using namespace std;

int num[N];
char op[N];

int tt_num,tt_op;

void eval()
{
    int b = num[tt_num];
    tt_num--;
    int a = num[tt_num];
    tt_num--;
    char c = op[tt_op];
    tt_op--;
    int x;
    if (c == '+') x = a + b;
    else if (c == '-') x = a - b;
    else if (c == '*') x = a * b;
    else x = a / b;
    num[++tt_num] = x;
}

int priority(char x)
{
    if(x == '*' || x == '/')
    {
        return 2;
    }
    else return 1;
}

int main()
{
    char str[N];
    gets(str);
    for (int i = 0;i < strlen(str);i++)
    {
        char c = str[i];
        if (c >= '0' && c <= '9')
        {
            int x = 0, j = i;
            while (j < strlen(str) && (str[j] >= '0' && str[j] <= '9'))
            {
                x = x * 10 + str[j] - '0';
                j++;
            }   
            i = j - 1;
            num[++tt_num] = x;
        }
        else if (c == '(') 
        {
            op[++tt_op] = c;
        }
        else if (c == ')')
        {
            while (op[tt_op] != '(') 
            {
                eval();
            }
            tt_op--;
        }
        else
        {
            while (tt_op > 0 && op[tt_op] != '(' && priority(op[tt_op]) >= priority(c)) eval();
            op[++tt_op] = c;
        }
    }
    while (tt_op > 0) eval();
    printf("%d",num[tt_num]);
    return 0;
}

对代码的解释:将表达式作为字符串读入,遇到数字就放到数字栈中,遇到运算符就放到字符栈中去,如果目前字符栈非空且栈顶元素不是左括号且目前遇到的运算符优先级小于等于栈顶元素,就“计算”(即只有遇到乘除且栈顶为加减时不计算);如果遇到左括号也放到字符栈中,当遇到右括号时进行“计算”,直到遇到左括号;最后再对字符栈中剩下的符号依次进行计算。

“计算”的含义:将字符栈的栈顶元素弹出,记为op,将数字栈的栈顶元素弹出为a和b,将b op a  计算得到的结果c放到数字栈中。

        方法二说明:

        中缀转后缀规则:从左到右遍历中缀表达式,是数字就输出,成为后缀表达式的一部分;若是符号,判断与栈顶符号的优先级,是右括号或符号优先级不高于栈顶符号则栈顶元素依次出栈并输出,并将当前符号进栈;

        后缀计算规则:从左到右遍历后缀表达式,遇到数字就进栈,遇到符号就将栈顶两个数字出栈,进行运算,运算结果入栈,直到获得结果。

2.单调栈的应用

题目描述:AcWing 830. 单调栈

给定一个长度为 N 的整数数列,输出每个数左边第一个比它小的数,如果不存在则输出 −1。

输入格式

第一行包含整数 N,表示数列长度。

第二行包含 N 个整数,表示整数数列。

输出格式

共一行,包含 N 个整数,其中第 i 个数表示第 i 个数的左边第一个比它小的数,如果不存在则输出 −1。

数据范围

1≤N≤ 105

1≤数列中元素≤10^9

输入样例:

5
3 4 2 7 5

输出样例:

-1 3 -1 2 2

 代码实现:

#include <iostream>
using namespace std;
const int N = 100010;
int stk[N],tt;
int main()
{
    int n;
    cin>>n;
    while(n--)
    {
        int x;
        cin>>x;
        while(tt&&stk[tt] >= x)
        {
            tt--;//如果栈顶元素大于当前待入栈元素,则出栈
        }
        if(tt)
        {
            cout<<stk[tt]<<' ';//栈顶元素就是左侧第一个比它小的元素。
        }
        else
        {
            cout<<-1<<' ';//如果栈空,则没有比该元素小的值。
        }
        stk[++tt] = x;
    }
    return 0;
}

三、队列Queue

栈是一种先进先出的数据结构,只能在一端插入,在另一端删除

(1)顺序存储

队列最简单的表示方法就是用数组,用两个变量front和rear分别表示队列的头和尾,front和rear一般都初始化为-1,有元素入队时rear++,有元素出队时front++;

但是有时候会出现rear溢出数组范围的情况,因此引入循环队列的概念:当rear和front到达数组末尾时折返到数组开始处。公式:rear(front) %= 数组长度;

为了避免引起二义性,队满的条件为(rear + 1)% 数组长度 == front

                                    队列空的条件:rear == front

(2)链式存储

队列的链式存储基本用不到,这里不做过多解释

(3)应用

队列应用最多的地方当属BFS,这里举一个走迷宫的例子

此外还有单调队列的应用,滑动窗口(这里就不解释了,留个坑)

题目描述:AcWing 844. 走迷宫

给定一个 n×m的二维整数数组,用来表示一个迷宫,数组中只包含 00或 1,其中 0 表示可以走的路,1 表示不可通过的墙壁。

最初,有一个人位于左上角 (1,1) 处,已知该人每次可以向上、下、左、右任意一个方向移动一个位置。

请问,该人从左上角移动至右下角 (n,m)处,至少需要移动多少次。

数据保证 (1,1)处和 (n,m)处的数字为 0,且一定至少存在一条通路。

输入格式

第一行包含两个整数 n 和 m。

接下来 n 行,每行包含 m 个整数(0 或 1),表示完整的二维数组迷宫。

输出格式

输出一个整数,表示从左上角移动至右下角的最少移动次数。

数据范围

1≤n,m≤100

输入样例:

5 5
0 1 0 0 0
0 1 0 1 0
0 0 0 0 0
0 1 1 1 0
0 0 0 1 0

输出样例:

8

代码实现:

#include <iostream>
#include <cstring>
#include <queue>
using namespace std;

const int N = 110;
int dis[N][N];
int g[N][N];

int n,m;

typedef pair<int,int> PII;
PII a[N];

queue<PII>q;

#define x first
#define y second

int dx[4] = {0,1,0,-1},dy[4] = {1,0,-1,0};

int bfs(PII start)
{
    q.push(start);
    dis[start.x][start.y] = 0;
    while(q.size())
    {
        PII temp = q.front();
        q.pop();
        for(int i = 0;i < 4;i++)
        {
            int x = temp.x + dx[i];
            int y = temp.y + dy[i];
            
            if(x <= n && y <= m && x >= 1 && y >= 1 && dis[x][y] == -1 && g[x][y] == 0)
            {
                dis[x][y] = dis[temp.x][temp.y] + 1;
                q.push({x,y});
            }
        }
    }
    
    return dis[n][m];
}


int main()
{
    scanf("%d%d",&n,&m);
    memset(dis,-1,sizeof dis);
    
    for(int i = 1;i <= n;i++)
    {
        for(int j = 1;j <= m;j++)
        {
            scanf("%d",&g[i][j]);
        }
    }
    
    printf("%d",bfs({1,1}));
    return 0;
}

第四章  树

树是一种重要的非线性数据结构

一些基本概念:

1.结点的度:结点的度是它子树的个数;

2.树的度:树中结点的度的最大值;

3.叶结点:度为0的结点;

4.父结点:有子树的结点;

5.子结点:结点的子树的根结点;

6.兄弟结点:有共同父结点的结点彼此为兄弟结点;

7.祖先结点:沿着到根结点的路径上,所有的结点都是这个结点的祖先结点;

8.子孙结点:结点子树的所有结点称为这个结点的子孙结点;

9.结点的层数:规定根结点的层次为1,其余结点的层次等于父结点的层次+1;

10.树的深度:树中结点的最大值;

11.路径长度:边的个数(如A—B—C的长度为2);

12.空树、只有根结点的树也算树;

二叉树的性质:

1.对于两棵树,在一般树的定义下可能是相同的,但在二叉树的定义下可能是不同的树;

2.二叉树的深度小于等于结点个数N,平均深度是\sqrt{N}

3.二叉树第i层的最大结点数为2^{i-1}

4.深度为k的二叉树的最大结点总数为2^{k}-1

5.对于非空的二叉树,n0个叶结点,n2个度为2的非叶结点个数,则n0 = n2+1;

6.有n个结点的完全二叉树的深度为⌊\log_{2}n⌋+1;

一、顺序存储

用数组存储二叉树时,他们的父子关系通过数组的下标来反映

对于下标为i的结点,它具有如下性质:

        (1)当2i <= N时,2i是它的左孩子;

        (2)当2i + 1 <= N时,2i + 1是它的右孩子;

        (3)⌊i / 2⌋ >= 1时,i / 2 为他的父结点

        (4)若⌊i / 2⌋ == 0时,证明该结点为根结点;

注:起始下标都为1

二、链式存储

二叉树最常用的表示方式是链式存储;

typedef struct node
{
    int date;
    struct node * l;
    struct node * r;
}Node;

三、二叉树的操作

(1)二叉树的遍历

        1.先(前)序遍历

        规则:若二叉树为空,则空操作返回,否则先访问根结点,然后遍历左子树,最后遍历右子树;以上图为例:ABDGHCEIF

        2.中序遍历

        规则:若二叉树为空,则空操作返回,否则从根结点开始(注意并不是先访问根结点),中序遍历根结点的左子树,然后访问根结点,最后遍历右子树;以上图为例:GDHBAEICF

        3.后续遍历

        规则:若二叉树为空,则空操作返回,否则从左到右先叶子后结点的方式遍历访问左右子树,最后访问根结点;以上图为例:GHDBIEFCA

        4.层序遍历

        规则:若树为空,则空操作返回,否则从树的第一层,也就是根结点开始访问,从上而下逐层遍历,在同一层中,按从左到右的顺序对结点逐个访问;以上图为例:ABCDEFGHI

遍历的意义:对于人类来说图形更利于理解,但计算机只能处理线性序列,上述四种遍历方式都是在把树中的结点变成某种程度上的线性序列,给程序的实现带来了好处;

(2)输出二叉树的所有叶结点

以先序为例:

void Print_Tree(Node* root)
{
    if(root != NULL)
    {
        if(root->l == NULL && root ->r == NULL)
        {
            printf("%d ",root->date);
        }
        Print_Tree(root->l);
        Print_Tree(root->r);
    }
}

(3)求二叉树的高度

int Get_Length(Node * root)
{
    int hl,hr,res;
    if(root != NULL)
    {
        hl = Get_Length(root->l);
        hr = Get_Length(root->r);
        res = max(hl,hr);
        return res + 1;
    }
    else
    {
        return 0;
    }
}

(4)有n个结点的二叉树的个数

记个数为bn,则     {b_{n}}^{} = \frac{1}{n+1}\cdot C{_{2n}}^{n}

四、二叉搜索树

        由于二叉树搜索树具有左小右大的有序特性,因此对其进行中序遍历将得到一个从小到大的输出顺序

        (1)按值查找

        1.递归写法:

Node * find(Node * root,int x)
{
    if(root == NULL)
    {
        return NULL;
    }
    else
    {
        if(x > root->date)
        {
            find(root->r,x);
        }
        else if(x < root->date)
        {
            find(root->l,x);
        }
        else
        {
            return root;
        }
    }
}

        2.非递归写法:

Node * find(Node * root,int x)
{
    while (root != NULL)
    {
        if(x > root->date)
        {
            root = root->r;
        }
        else if(x < root->date)
        {
            root = root->l;
        }
        else
        {
            break;
        }
    }
    return root;
}

        (2)查找最大和最小元素

根据二叉搜索树的性质,最小元素在树的最左支的端结点上,最大元素在树的最右支的端结点上。

(即树的最左端和最右端元素)

下面分别用递归和非递归的写法来寻找最大值和最小值

        1.最大值(递归)

Node * find_max(Node * root)
{
    if(root == NULL)
    {
        return NULL;
    }
    else if(root ->l == NULL)
    {
        return root;
    }
    else
    {
        return find_max(root->l);
    }
}

        2.最小值(非递归)

Node * find_min(Node * root)
{
    if(root != NULL)
    {
        while (root->r != NULL)
        {
            root = root->r;
        }
    }
    return root;
}

        (3)插入新元素

插入操作建立在查找元素的基础上,如果在树中找到了这个元素,那么就不进行插入的操作;如果没有找到这个元素,那么查找停止的地方就是这个元素应该插入的地方。

void insert(Node * root,int x)
{
    if(root == NULL)
    {
        root->date = x;
        root->l = NULL;
        root ->r = NULL;
    }
    else
    {
        if(x < root ->date)
        {
            insert(root->l,x);
        }
        else if(x > root ->date)
        {
            insert(root->r,x);
        }
        else
        {
            return;
        }
    }
}

        (4)结点的删除

        1.如果要删除的结点是叶结点(度为0的结点)

        直接删除,让其父结点的指针为NULL即可;

        2.如果要删除的结点只有一个孩子结点(度为1的结点)

        让其父结点的指针指向这个结点的那个孩子即可;

        3.如果要删除的结点有两个孩子结点(度为2的结点)

        这时需要找一个元素来替代被删除的元素,可

以找右子树中的最小元素也可以找左子树中的最大元素,这两个元素一定是只有一个孩子结点的结点。(这两个结点也刚好是中序遍历的前/后一个结点),让被删除的结点的值等于我们选择的结点,然后按照2的规则来将我们选择的点进行删除即可。

        五、平衡二叉树 

        平衡二叉树是二叉查找树的另一种形式,其特点为:

        树中每个结点的左、右子树深度之差的绝对值不大于1;

        平衡二叉树又被称为AVL树;

        平衡二叉树可以为空树;

平衡因子的概念:每个结点附加一个数字,给出该结点左子树的高度减去右子树的高度所得的高度差,这个数字即为结点的平衡因子 bf

一棵平衡二叉树的任意一结点的平衡因子只可为-1,0,1;

每插入一个新结点时,AVL树中相关结点的平衡状态会发生改变。因此,在插入一个新结点后,需要从插入位置沿通向根的路径回溯,检查各结点的平衡因子。如果在某一结点发现高度不平衡,停止回溯。从发生不平衡的结点起,沿刚才回溯的路径取直接下两层的结点如果这三个结点处于一条直线上,则采用单旋转进行平衡化。单旋转可按其方向分为LL旋转和RR旋转,   其中一个是另一 个的镜像,其方向与不平衡的形状相关。如果这三个结点处于一条折线上,则采用双旋转进行平衡化。双旋转分为LR旋转和RL旋转两类

RR旋转又称为左单旋转,如下图所示。

LL旋转又称为右单旋转,如下图所示。

LR旋转又称为先左后右双旋转

 RL旋转又称为先右后左双旋转

 AVL树的最小高度为⌈ \log _{2}^{n+1} ⌉,最大高度为⌊ 2^{\frac{1}{2}}\cdot \log _{2}^{n} ⌋

        六、树的应用

        (1)堆

        堆又被称为优先队列

        高度为h的完全二叉树的结点数量为2^{h-1}2^{h} - 1个,因此用数组来实现堆的存储;

        对于下标为 i 的结点,父结点的下标为⌊i / 2⌋,左儿子为2i,右儿子为2i+1;

        与二叉搜索树不同的是,堆的兄弟结点之间没有任何的约束关系

这里以小根堆为例,展示一下建堆的过程:

        首先需要掌握的基本操作:

        1.down(x)x是数组的下标,down操作是往下调整结点

void down(int u)
{
    int t = u;
    if(2*u <= siz && heap[2*u] < heap[t])
    {
        t = 2 * u;
    }
    if(2*u + 1 <= siz && heap[2*u + 1] < heap[t])
    {
        t = 2 * u + 1;
    }
    //以上操作是让t来存储三个元素中值最小的结点的下标
    if(u != t)
    {
        swap(heap[u],heap[t]);
        down(t);
    }
}

         2.up(x)x是数组的下标,up操作是往上调整结点

void up(int u)
{
    while(u/2 && heap[u/2] > heap[u])
    {
        swap(heap[u/2],heap[u]);
        u /= 2;
    }
}

         3.插入一个数:

heap[++size] = x;
up(size);

         4.求集合中的min

heap[1];

        5.删除最小值

//把堆的最后一个元素覆盖掉堆顶的元素,然后size--,down(1)
//直接删除堆顶元素困难,故删除最后一个元素
heap[1] = heap[size];
size--;
down(1);

        6.删除一个元素

heap[k] = heap[size];
size--;
down(k);
up(k);
//up 和 down 都写,但程序只会执行其中的一个

        7.修改一个元素

heap[k] = x;
down(k);
up(k);

下面以堆排序为例,展示一下堆的实际运用

#include <iostream>
#include <algorithm>

using namespace std;

const int N = 100010;

int n,m;
int h[N],siz;

void down(int u);

int main()
{
    scanf("%d%d", &n, &m);
    siz = n;
    for(int i = 1;i <= n;i++)
    {
        scanf("%d",&h[i]);
    }
    for(int i = n/2;i;i--)
    {
        down(i);
    }
    while(m--)
    {
        printf("%d ",h[1]);
        h[1] = h[siz--];
        down(1);
    }
    
}

void down(int u)
{
    int t = u;
    if(u * 2 <= siz && h[u * 2] < h[t])
    {
        t = u * 2;
    }
    if(u * 2 + 1 <= siz && h[u * 2 + 1] < h[t])
    {
        t = u * 2 + 1;
    }
    if (u != t)
    {
        swap(h[u], h[t]);
        down(t);
    }
}

在循环读入完数据后,进行的操作即为建队,i = n/2 表示的是从最后一个有儿子的结点开始进行down的操作,书上把这个操作也称为向下过滤的操作;

然后再输出的时候,每次只需要输出堆顶元素就是我们需要顺序了,输出完一次堆顶后,进行删除堆顶元素的操作;

        (2)哈夫曼树

        在解决哈夫曼树之前要引入树的带权路径长度的概念(WPL):

        

        WPL为每个叶结点的带权路径长度之和,如上图的WPL = 7*2+5*2+2*3+4*3+9*2 = 60

        我们的哈夫曼树就是为了构造一棵WPL最小的树,它有如下规则:

        给定n个子树,在n棵子树中选择根结点权值最小和次小的两棵树二叉树作为左、右子树来构建一棵新的二叉树,这棵新二叉树的根结点的权值为这两个子树根结点的权值和,然后删除选择的两棵子树,并将新建立的树放到集合中,重复以上步骤,直到只剩下一棵树,这棵树便是哈夫曼树了。(至于选择的子树谁左谁右是无所谓的,因此哈夫曼树不是唯一的)

哈夫曼树最大的的应用是用于编码,我们让上面的各个结点的数值等于这个词语在全文中出现的次数,并且将每个结点的左分支记为0,右分支记为1,那么我们就得到了一棵最优前缀编码树。

一个字符的编码长度为该字符结点在其哈夫曼树中的深度d-1;

第五章  查找

(1)静态查找

        1.顺序查找

        基本思路:对于一个数组,让它从下标为1的位置开始存储,然后将下标为0的位置设置为要查找的对象key,然后从数组的尾部开始向前查找,最后如果找到元素或者指针移动到了下标为0的位置就停止,并返回指针的值,如果指针不等于0则证明查找成功,并且该指针指向的元素就是要查找的元素;如果指针的值为0则证明查找失败,这个表中不存在目标元素。

bool find(int a[],int length,int key)
{
    a[0] = key;
    int i;
    for(i = length;a[i] != key;i--);
    if(i == 0) return false;
    else return true;
}

         2.二分查找

        二分查找只适用于查找有序的数据

        基本思路:用左右指针来限制一个区域,然后通过比较中间的元素和要查找的元素的大小关系,然后对其左右指针进行移动,直到左指针大于右指针为止。此时的mid就是要查找的对象,如果其值等于key则证明查找成功。

bool check(int a[],int length,int key)
{
    int l = 1,r = length;
    while(l < r)
    {
        int mid = (l+r)/2;
        if(a[mid] >= key)
            r = mid -1;
        else l = mid + 1;
    }
    if(a[(l+r)/2] == key)
        return true;
    else return false;
}

 (2)动态查找

 (3)散列查找

散列的基本思想是:将数据的关键词key作为自变量,通过一个确定的函数hash(),计算出对应的函数值hash(key),然后把这个值解释为数据存储的地址,即“存储位置 = hash(key)”;

注意:一般将表长设定为素数,且距离2^{n}足够远,且将填装因子设定为0.5~0.8,可以尽量减少冲突,但冲突只可以减少不可以避免。

最常用的hash函数为hash(key) = key % p;

如果key是个负值,可以用这个公式:hash(key) = (key % p + p)% p;

(p一般为小于等于散列表表长的一个素数)

有的时候,会出现不同的key值映射到同一个值的情况,此时我们就应该处理冲突,常用的处理冲突的方法有:开放地址法(开放寻址法)和链地址法(拉链法);

1.开放地址法

基本思路为将hash(key)计算出来后看他对应的位置是否为空,如果为空则直接放入,否则向后寻找位置,如果到达了表尾还没有位置就从表头再开始找位置

void find(int x)
{
    int k = (x % N + N) % N;
    while(h[k] != NULL && h[k] != x)
    {
        k++;
        if(k == length) k = 0;
    }
    h[k] = x;
}

以上方法是线性探测法,书上还介绍了一种方法为平方探测法,即遇到冲突时不再是k++,而是增量选择为1^{2}-1^{2}2^{2}-2^{2},...,q^{2}-q^{2}需要满足q <= ⌊length / 2⌋,且length是形如 4i + 3的一个素数。

需要注意的是,开放寻址法不能直接删除某个元素,因此习惯用一个“删除标记”来进行标记状态是否被删除,具体实现可以见如下结构体

typedef struct hash
{
    int date;
    bool statue;
}Hash;

Hash h[N];

2.分离链表法

分离链表法的原理是,对于所有hash(key)后相同的key放到同一个链表中去,注意需要按照书上的解释用头插法

这里直接给出插入n个数据到表长为11的表中的代码

#include <stdio.h>
#include <stdlib.h>
typedef struct node
{
    int date;
    struct node * next;
}Node;

const int N = 11;
Node head[11];

void add_to_head(int k,int x)
{
    if(head[k].next == NULL)
    {
        Node * p = (Node*)malloc(sizeof(Node));
        p ->date = x;
        p->next = NULL;
        head[k].next = p;
    }
    else
    {
        Node * p = head[k].next;
        Node * temp = (Node*)malloc(sizeof(Node));
        temp->date = x;
        temp->next = p;
        head[k].next = temp;
    }

}

void insert(int x)
{
    int k = (x%N + N)%N;
    // printf("%d\n",k);
    add_to_head(k,x);
}

void Print()
{
    for(int i = 0;i < N;i++)
    {
        printf("%d ",i);
        Node * p = head[i].next;
        while (p != NULL)
        {
            printf("-> %d",p->date);
            p = p->next;
        }
        printf("-> ^\n");
    }
}

int main()
{  
    for(int i = 0;i < N;i++)
    {
        head[i].next = NULL;
    }
    int n;
    scanf("%d",&n);
    for(int i = 1;i <= n;i++)
    {
        int x;
        scanf("%d",&x);
        insert(x);
    }
    Print();
    return 0;
}

附带一张运行截图

第六章  图

在图中,至少要求有一个顶点,但边集可以为空

图的一些基本概念:

1.无向图:边没有方向,用(A,B)表示;

2.有向图:边有方向,有向边也称为弧,用<A,B>表示从A到B的边;

3.简单图:没有重边和自回路边的图;

4.邻接点:相连的顶点;

5.无向完全图:任意两点间都有边相连,这类图有n*(n-1)/ 2条边

6.有向完全图:任意两点之间都由方向互为相反的两条弧相连接,这类图有n*(n-1)条弧;

7.度 = 入度 + 出度;

8.连通图:任意两个点都可以找到一条路径到达;

9.强连通图:对于有向图,任意两个点A,B都可以找到一条路径从A到B,也可以找到一条路径从B到A;

10.强连通分量:有向图的极大强连通子图(类比极大线性无关组);

(不是强连通的图有多于一个的强连通分量);

(1)图的存储

        1.邻接矩阵

        直接用二维数组g[N][N]存储边,N为顶点的总数,顶点的编号为0~N - 1,如果顶点的编号为1~N,则用数组g[N+1][N+1]。如果i和j直接相连,用g[ i ][ j ]表示弧<i , j>,若不存在边相连,则令g[ i ][ j ] = 无穷大(INF)或者0。

        注:①适合稠密图;

                ②不能存储重边;

                ③主对角线上的元素都是无穷大或0;

                ④无向图的邻接矩阵是对称矩阵,所以只要存储上三角即可,元素个数是 V*(V - 1) / 2;

                ⑤要确定图中有多少条边要O(N^2)的时间复杂度;

        2.邻接表

        每个结点只存储它的直接邻居,一般用链表存储;

        上代码

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

typedef struct edge
{
    int from;
    int to;
    int w;
}Edge;

typedef struct node
{
    int date;
    int w;
    struct node * next;
}Node;

const int N = 10;
Node head[10];
int m;
int n;

void add_to_head(Edge e)
{
    if(head[e.from].next == NULL)
    {
        Node * p = (Node *)malloc(sizeof(Node));
        p->w = e.w;
        p->date = e.to;
        p->next = NULL;
        head[e.from].next = p;
    }
    else
    {
        Node * p = head[e.from].next;
        Node * temp = (Node *)malloc(sizeof(Node));
        temp->date = e.to;
        temp->w = e.w;
        temp->next = p;
        head[e.from].next = temp;
    }
}

void Print()
{
    for(int i = 1;i <= n;i++)
    {
        Node *p = head[i].next;
        while (p != NULL)
        {
            printf("(%d-%d) %d\n",head[i].date,p->date,p->w);
            p = p->next;
        }
    }
}

int main()
{
    
    scanf("%d",&n);
    for(int i = 1;i <= n;i++)
    {
        head[i].date = i;
        head[i].next = NULL;
    }
    
    scanf("%d",&m);
    while (m--)
    {
        Edge e;
        int f,r,w;
        scanf("%d%d%d",&f,&r,&w);
        e.from = f;
        e.w = w;
        e.to = r;
        add_to_head(e);
    }
    Print();
    return 0;
}

附带一张运行截图

(2)图的遍历

        1.DFS深度优先遍历

        基本思路:一条路走到黑,直到没有路可以走的时候回溯;

void dfs(int u)
{
    printf("is visiting %d\n",u);
    visited[u] = 1;
    Node * p = head[u].next;
    while (p != NULL)
    {
        if(visited[p->date] == 0)
        {
            dfs(p->date);
        }
        p = p->next;
    }
}

        2.BFS广度优先遍历

        基本思路:按照距离从小到远的遍历各个顶点,这个过程需要借助队列来实现;

void bfs(int u)
{
    queue[++rear] = u;
    while (rear >= front)
    {
        int a = queue[front];
        front++;
        printf("is visiting %d\n",head[a].date);
        visited[head[a].date] = 1;
        Node * p = head[a].next;
        while (p != NULL)
        {
            if(visited[p->date] == 0)
            {
                queue[++rear] = p->date;
            }
            p = p->next;
        }
    }
}

(3)最小生成树

最小生成树:将图蜕化为树,找出树的权值和最小的那棵

1.Kruskal算法

        基本思路:一直找整个图中的最短边,只要不构成回路就可以选择这条边;

2.Prim算法(效率更高)

        基本思路:从任何一个点出发,不断加入边和相关顶点到当前的树中,直到所有的顶点都在树中为止;

(4)最短路径

        1.单源最短路问题

        Dijkstra算法

        ①从起点s出发,用BFS扩展它的邻居顶点,把这些邻居点放到一个集合A中,并记录这些点到s的距离;

        ②在A中选择到s距离最短的点V,把V的邻居点放到A中,如果经过V中转,到s的距离更短,则更新这些邻居点到s的距离,从集合A中移走V,此后不再管A;

        ③重复步骤②,直到A空;

        2.多源最短路问题

        Floyd算法

        遍历每个顶点K,如果经过K能使得从I到J的距离更短就更新距离

void floyd()
{
    for(int k = 1;k <= n;k++)
    {
        for(int i = 1;i <= n;i++)
        {
            for(int j = 1;j <= n;j++)
            {
                dp[i][j] = min(dp[i][j],dp[i][k]+dp[k][j]);
            }
        }
    }
}

(5)拓扑排序

只有无环有向图(DAG)才有拓扑排序

思路:找到任何一个入度为0的顶点,然后输出该顶点,并从图中删除该顶点以及与它相连的所有边,对改变后的图继续重复进行这一操作,直到所有顶点都输出为止;

(6)关键路径

在一个AOV图中求解关键路径要先知道以下几个概念:

1.事件的最早发生事件ve(j)= 从源点到顶点 j 的最长路径

        ve(j)= max(ve(i)+ Cij);

2.事件的最迟发生事件vl(i)= 从源点到顶点 i 的最短路径

        vl(i)=  min(vl(j)-  Cij);

3.对于第 i 条弧<j,k>,i 的最早开始时间e(i)= ve(j);

4.对于第 i 条弧<j,k>,i 的最迟开始时间l(i)= vl(K)- Cjk;

5.其中,e(i) = l(i)的路径为关键路径;

6.vl(汇点)= ve(汇点);

第七章  排序

本章的排序默认都是从小到大排序

(1)选择排序

        1.简单选择排序

        思路:在未排序的序列中找出最小的元素和序列的首位元素交换,接下来在剩下的未排序序列中再选出最小的元素与序列中的第二个元素交换,以此类推,直至形成有序的序列;

void seclect_sort(int a[],int n)
{
    for(int i = 0;i < n-1;i++)
    {
        int k = i;
        for(int j = i+1;j < n;j++)
        {
            if(a[j] < a[k])
            {
                k = j;
            }
        }
        if(k!=i)
        {
            swap(&a[k],&a[j]);
        }
    }
}

 比较次数:\frac{n(n-1)}{2}           

交换的次数:最小值0,最大值(n - 1)       

移动记录的次数:最小值0,最大值3(n - 1) ,即一次交换需要3次移动

平均时间复杂度O(N^{2}),最坏时间复杂度O(N^{2}),额外空间复杂度O(1) 

不稳定

        2.堆排序

        正常来说,堆排序的原理是建好堆后每次将堆顶的元素输出,然后用最后一个元素来代替堆顶元素,然后down(1)并且size--;

        但是书上介绍了一种方法不需要输出元素,直接重复用最后一个元素来代替堆顶元素,然后down(1)并且size--的操作,直到堆里只剩一个元素时停止。此时我们原来的数组直接就变成了一个从小到大的有序序列(突然感觉好有道理);

        需要注意的点是:如果数组的下标是从0开始的话,对于下标为 i 的元素,它的左孩子不再是2i,而是2i + 1,右孩子是2i + 2,其父节点的编号为⌊(i - 1)/  2⌋;

void heap_sort(int a[],int n)
{
    for(int i = n/2 - 1;i >= 0;i--)
    {
        down(i);
    }
    
    for(int i = n-1;i > 0;i--)
    {
        swap(&a[0],&a[i]);
        down(0);
    }
}

void down(int u)
{
    int t = u;
    if(u * 2+1 <= siz && a[u * 2+1] < a[t])
    {
        t = u * 2 + 1;
    }
    if(u * 2 + 2 <= siz && a[u * 2 + 2] < a[t])
    {
        t = u * 2 + 2;
    }
    if (u != t)
    {
        swap(&a[u], &a[t]);
        down(t);
    }
}

平均时间复杂度O(N\cdot \log N),最坏时间复杂度O(N\cdot \log N),额外空间复杂度O(1 

        不稳定 

(2)插入排序

        1.简单插入排序

        思路:将待排序的一组序列分为已排好序的部分和未排序的部分,一开始已排序部分只包含第一个元素,未排序部分为除去第一个元素之外的N - 1个元素,此后将未排序部分中的元素逐一插入到已排序部分;

void insert(int a[],int n)
{
    for(int p = 1;p < n;p++)
    {
        int temp = a[p];
        for(int i = p; i > 0 && a[i-1] > temp;i--)
        {
            a[i] = a[i-1];
        }
        a[i] = temp;
    }
}

平均时间复杂度O(N^{2}),最坏时间复杂度O(N^{2}),额外空间复杂度O(1) 

稳定 

        2.希尔排序

        思路:将待排的数据按照一定的间隔分为若干的序列,先对这几个序列内部进行插入排序,间隔一开始可以很大,但需要逐步减小直至1,此时的最后一步就是简单插入排序。

当增量选择为{⌊N / 2⌋,⌊N /  2^{2}⌋,…,1}时有最差的时间复杂度O(N^{2});

当增量选择为{2^{k-1},…,7,3,1}时,最差的时间复杂度为O(N^{\frac{3}{2}});

平均时间复杂度O(N^{d}),最坏时间复杂度O(N^{2}),额外空间复杂度O(1

不稳定

(3)交换排序

        1.冒泡排序

        一共会进行N-1次排序,在第k次循环时,对从第1到第N-k个元素从前往后进行比较,每次比较相邻的两个元素,若前一个元素大于后一个元素,则两者交换位置;这样一次循环可以把第k大的元素移动到第N-k个位置上来,称为第k趟的冒泡。

void paopao_sort(int a[],int N)
{
    for(int j = N-1;j >= 0;j--)
    {
        bool flag = false;
        for(int i = 0;i < j;i++)
        {
            if(a[i] > a[j])
            {
                swap(&a[i],&a[j]);
                flag = true;
            }
        }
        if(flag == false) break;
    }
}

平均时间复杂度O(N^{2}),最坏时间复杂度O(N^{2}),额外空间复杂度O(1) 

稳定 

        2.快速排序(唯一真神)

        每次选取一个枢纽,一般选择当前序列最左边的那个元素,并设置左右指针进行移动,先让右指针向左移动直到指向的值比枢纽小,然后把这个值赋值给左指针指向的元素,然后移动左指针向右移动直到指向的值比枢纽大,然后把这个值赋值给右指针指向的元素,重复以上操作直至左右指针相遇时将枢纽的值赋值给这两个指针共同指向的值,从而将序列一分为二,分别递归执行枢纽的左半边和右半边;

快速排序一趟的定义:用第一个元素做枢轴,每一趟有一个关键字到达最后的位置,即左右指针相遇一次算一趟。

void quick_sort(int a[],int l,int r)
{
    int R = a[l];
    while(l < r)
    {
        while(l < r && a[r] >= R)
        {
            r--;
        }
        a[l] = a[r];
        while(l < r&& a[l] <= R)
        {
            l++;
        }
        a[r] = a[l];
    }
    a[r] = R;
    quick_sort(a,0,l-1);
    quick_sort(a,l+1,N);
}

平均时间复杂度O(N\cdot \log N),最坏时间复杂度O(N^{2}),额外空间复杂度O(\log N

不稳定

(4)归并排序

        归并排序的原理是“分而治之”,我们处理整个区间有一定的难度,但是我们处理小的区间相对而言要容易的多,因此我们一开始对区间进行划分,划分为一个个的小区间,先对这几个小的区间排好序,然后再把这几个小区间合并起来,并保持新区间的有序性即可。最终我们就得到了一个有序的序列了。

void merge_sort(int q[],int l,int r)
{
    if(l >= r) return;
    int mid = (l + r) / 2;
    merge_sort(q,l,mid),merge_sort(q,mid+1,r);
    int k = 0,i = l,j = mid + 1;
    while (i <= mid && j <= r)
        if(q[i] <= q[j]) tmp[k++] = q[i++];
        else tmp[k++] = q[j++];

    while (i <= mid)
    {
        tmp[k++] = q[i++];
    }
    while (j <= r)
    {
        tmp[k++] = q[j++];
    }
    for(i = l,j=0;i <= r;i++,j++)
    {
        q[i] = tmp[j];
    }
}

平均时间复杂度O(N\cdot \log N),最坏时间复杂度O(N\cdot \log N),额外空间复杂度O(N

稳定 

一共需要进行⌈\log N⌉趟排序

参考资料:

数据结构(第二版)        作者:陈  越

大话数据结构                   作者:陈  杰

算法竞赛                          作者:罗勇军,郭卫斌

  • 58
    点赞
  • 48
    收藏
    觉得还不错? 一键收藏
  • 4
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值