数据结构与算法(408)

原文转自:https://blog.8hfq.com/2018/08/21/%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84%E4%B8%8E%E7%AE%97%E6%B3%95.html

一、栈(Stack)、队列(Queue)和向量(Vector)

线性表的基本概念和实现

线性表的存储结构有顺序存储和链式存储结构两种。前者被称为顺序表,后者被称为链表。

顺序表

顺序表就是把线性表中所有的元素按照其逻辑顺序,依次存储到从指定的存储位置开始的一块连续的存储空间,这样线性表的第一个元素的存储位置就是指定的存储位置,第i+1个的存储位置紧跟着第i个元素的位置

链表

在链表存储中,每个结点不仅包含所存元素的信息,还包含元素之间的逻辑关系的信息,如单链表
中前驱结点包含后继结点的地址信息,这样就可以通过前驱结点的位置找到后继结点的位置

两种存储结构的比较

顺序表

  • 随机访问特性,位置是确定的
  • 要求占用连续的存储空间,存储分配只能预先进行,即静态分配
  • 插入操作要移动多个元素

链表

  • 不支持随机访问
  • 结点中的存储精简利用率稍低一些
  • 支持存储空间的动态分配
  • 插入操作无需移动元素

多种链表形式

单链表

带头结点的单链表

头指针head 指向头结点,头指针值域不包含任何信息,从头结点的后继结点开始存储信息,头指针始终不为null,当head->next 等于NULL的时候,链表为空

不带头结点的单链表

头指针head直接指向开始结点,当head为NULL的时候,链表为空

注意:不论是带有节点的链表还是不带头结点的链表。头指针都指向链表中的第一个结点,;而头结点是带头结点的链表中的第一个结点,只作为链表存在的标志

双链表


双链表就是在单链表的结点上增添一个指针域,指向当前结点的前驱
同样 双链表也分为带头结点的双链表和不带头结点的双链表

循环单链表

将单链表的最后一个指针域指向链表中的第一个结点即可

循环双链表


循环双链表的构造源自双链表,即将终端结点的next指针指向链表中第一个结点,将链表中第一个结点的prior指针指向终端结点。

带头结点的循环双链表当head->next和heaad->prior两个指针都等于head时链表为空。
不带头结点的循环双链表当head等于null的时候为空。

静态链表

静态链表借助一位数组来表示,结构体数组中的每一个结点含有两个分量:一个是数据结构元素分量data,另一个是指针分量,指示了当前结点的直接后继结点在数组中的位置

链表的操作

单链表的操作

单链表删除结点


删除”节点30”
删除之前:”节点20” 的后继节点为”节点30”,而”节点30” 的后继节点为”节点40”。
删除之后:”节点20” 的后继节点为”节点40”。

单链表增加结点


在”节点10”与”节点20”之间添加”节点15”
添加之前:”节点10” 的后继节点为”节点20”。
添加之后:”节点10” 的后继节点为”节点15”,而”节点15” 的后继节点为”节点20”。

单链表的特点是:节点的链接方向是单向的;相对于数组来说,单链表的的随机访问速度较慢,但是单链表删除/添加数据的效率很高。

双链表操作

双链表删除节点


删除”节点30”
删除之前:”节点20”的后继节点为”节点30”,”节点30” 的前继节点为”节点20”。”节点30”的后继节点为”节点40”,”节点40” 的前继节点为”节点30”。
删除之后:”节点20”的后继节点为”节点40”,”节点40” 的前继节点为”节点20”。

双链表添加节点


在”节点10”与”节点20”之间添加”节点15”
添加之前:”节点10”的后继节点为”节点20”,”节点20” 的前继节点为”节点10”。
添加之后:”节点10”的后继节点为”节点15”,”节点15” 的前继节点为”节点10”。”节点15”的后继节点为”节点20”,”节点20” 的前继节点为”节点15”。

栈的基本概念

  • 栈是一种只能在一端进行插入或删除操作的线性表。其中允许进行插入或者删除操作的一端称为栈顶(TOP)。栈顶由一个称谓栈顶指针的位置指示器(其实就是一个变量),对于顺序栈,就是记录栈顶元素所在数组位置标号的一个整形变量,对于链式栈就是记录栈顶元素所在的结点地址的指针。另一端被称为栈底,栈底是固定不变的。
  • 栈的特点,先进后出(FILO)
  • 栈的存储结构,可用顺序表和链表来存储栈,分为顺序栈和链式栈。 即栈的本质上是线性表
  • 当n各元素以某种顺序进栈,并且可以在任意时刻出栈,所获得的元素排列的数目N满足函数
    N=1/(n+1)Cn2nN=1/(n+1)C2nn
  • 栈通常包括的三种操作:push、peek、pop。
    push – 向栈中添加元素。
    peek – 返回栈顶元素。
    pop – 返回并删除栈顶元素的操作。

顺序栈(栈的数组实现)

顺序栈的特殊状态和操作

  1. 栈空状态
    st.top==-1 有的书上规定st.top ==0为栈空条件
  2. 栈满状态
    st.top==maxSize-1 maxSize为栈中最大元素的个数,则maxSize-1 为栈满时栈顶元素在树组中的位置
  3. 非法状态(上溢和下溢)栈满继续进入栈就会出现上溢状态,栈空继续出栈就会出现下溢状态
  4. 进栈操作 ++(st.top);st.data[st.top]=x;
  5. 出栈操作 x=st.data[st.top];–(st.top);

链栈

链栈的特殊状态和操作

  1. 栈空状态 lst->next==NULL
  2. 栈满状态 不存在栈满的情况
  3. 元素进栈操作 p->next=lst->next; lst->next=p
  4. p=lst->next;x=p->data;lst->next=p->next;free(p)

队列

队列的基本概念

  • 队列也是一种受限制的线性表,其显示为仅允许在表的一端进行插入,在表的另一端进行删除,可进行插入的一端是队尾,颗进行删除的一头是队头。
  • 队列的特点,先进先出(FIFO)
  • 可用顺序表和链表来存储队列,队列按存储结构颗分为顺序队和链队两种。

顺序队

循环队列

为了深刻体会到循环队列这个结构优于非循环队列的地方,我们将首先介绍数组实现的非循环队列结构。队列这种数据结构,无论你是用链表实现,还是用数组实现,它都是要有两个指针分别指向队头和队尾。在我们数组的实现方式中,用两个int型变量用于记录队头和队尾的索引。

一个队列的初始状态,head和tail都指向初始位置(索引为0处)。head永远指向该队列的队头元素,tail则指向该队列最后一个元素的下一位置,当有入队操作时:


当有出队操作时:

当遇到出队操作时,head会移向下一元素位置。当然,对于这种方式入队和出队,队空的判断条件显然是head=tail,队满的判断条件是tail=array.length(数组最后一个位置的下一位置)。显然,这种结构最致命的缺陷就是,tail只知道向后移动,一旦到达数组边界就认为队满,但是队列可能时刻在出队,也就是前面元素都出队了,tail也不知道。例如:

此时tail判断队满,我们暂时认为资源利用是可以接受的,但是如果接下来不断发生出队操作:

此时tail依然通过判断,认为队满,不能入队,这时数组的利用率我们是不能接受的,这样浪费很大。所以,我们引入循环队列,tail可以通过mode数组的长度实现回归初始位置,下面我们具体来看一下。
按照我们的想法,一旦tail到达数组边界,那么可以通过与数组长度取模返回初始位置,这种情况下判断队满的条件为tail=head

循环队列的特殊状态和操作

  1. 队空状态 qu.rear=qu.front
  2. 队满状态 (qu.rear+1)%maxSize == qr.front
  3. 元素x进队操作(移动队尾指针)
    qu.rear =(qu.rear+1) %maxSize;qu.date[qu.rear]=x;
  4. 元素x出队操作(移动队首指针)
    qu.front =(qu.front+1)%maxSize;x=qu.data[qu.front];

链队

链队的特殊状态和操作

  1. 队空状态 lqu->rear=NULL 或者 lqu->front=NULL
  2. 队满状态 不存在队满状态 (假设内存无限大的情况下)
  3. 元素进队操作 lqu->rear->next->p;lqu->rear=p;
  4. 元素出队操作 p=lqu->front;lqu->front=p->next;x=p->data;free(p)

向量

向量(Vector)是一个封装了动态大小数组的顺序容器(Sequence Container)。跟任意其它类型容器一样,它能够存放各种类型的对象。可以简单的认为,向量是一个能够存放任意类型的动态数组。

  1. 顺序序列
    顺序容器中的元素按照严格的线性顺序排序。可以通过元素在序列中的位置访问对应的元素。
  2. 动态数组
    支持对序列中的任意元素进行快速直接访问,甚至可以通过指针算述进行该操作。操供了在序列末尾相对快速地添加/删除元素的操作。
  3. 能够感知内存分配器的(Allocator-aware)
    容器使用一个内存分配器对象来动态地处理它的存储需求。

    vector的扩充机制:按照容器现在容量的一倍进行增长。vector容器分配的是一块连续的内存空间,每次容器的增长,并不是在原有连续
    的内存空间后再进行简单的叠加,而是重新申请一块更大的新内存,并把现有容器中的元素逐个复制过去,然后销毁旧的内存。这时原有指向旧内存空
    间的迭代器已经失效,所以当操作容器时,迭代器要及时更新。

抽象数据结构(ADT)

一个抽象数据结构(Abstract Data type)可以看做一些数据对象以及附加在这些对象上的操作的集合。
对于栈来说,数据对象集为存储在栈内的数据元素
操作集为元素进栈,元素出栈,判断栈是否为空等操作

ADT 栈(stack)

1
2
3
4
5
6
7
8
9
10
11
12
Data
同线性表。元素具有相同的类型,相邻元素具有前驱和后堆关系。
Operation
InitStack ( *S ):初始化操作.建立一个空栈S。
DestroyStack ( *S ):若栈存在,則销毁它。
ClearStack (*S):将栈清空。
StackEmpty ( S ):若栈为空,返回true,否則返回 false。
GetTop (S,*e):若栈存在且非空,用e返回S的栈顶元素。
Push (*S,e):若栈S存在,插入新元素e到栈S中并成为栈頂元素。
Pop (*S,*e):删除栈S中栈顶元素,并用e返回其值。
StackLength (S):返回回栈S的元素个数。
endADT

 

ADT 队列(Queue)

1
2
3
4
5
6
7
8
9
10
11
12
13
Data
	同线性表。元素具有相同的类型,相邻元素具有前驱和后继关系。

Operation
	InitQueue(*Q):初始化操作,建立一个空队列Q。
	DestroyQueue(*Q):若队列Q存在,則销毀它。
	ClearQueue(*Q):将队列 Q 清空。
	QueueEmpty(Q):若队列Q为空,送回true,否則退回false。
	GetHead(Q, *e):若队列Q存在且非空,用e返因队列Q的队头元素。
	EnQueue(*Q,e):若队列Q存在,插入新元素e到队列Q中并成为队尾元素。 
	DeQueue(*Q, *e):刪除队列Q中队头元素,并用e返回其值。	
	QueueLength(Q):送回队列Q的元素个教。
endADT

 

二、树

树的基本概念和术语


树是一种非线性的数据结构
他是若干结点(A,B,C…等都是结点)的集合,是由唯一的根A和若干颗互不相交的子树组成的。其中每一棵子树又是一棵树,也是由唯一的根结点和若干颗互不相交的子树组成的。
由此可知,树的定义是递归的。

树的结点包含一个数据元素以及若干指向其子树的分支。
结点拥有的子树数目称为结点的度。
度为0的结点称为叶结点或终端结点;度不为0的结点称为非终端结点或分支结点。
除根结点之外,分支结点也称为内部结点。
树的度是树内各结点的度的最大值。
结点的子树的根称为该结点的孩子。相应地,该结点称为孩子的双亲。
同一个双亲的孩子之间互称为兄弟(Sibling)。
结点的祖先(Ancestor)是从根到该结点所经分支上的所有结点。
结点的层次:结点的层次从根开始定义起,根为第一层,根的孩子为第二层。若某结点在第L层,则其子树的根就在第L+1层。其双亲在同一层的结点互为堂兄弟。
树中结点的最大层次称为树的深度或高度(Depth)。
如果将树中结点的各子树看成从左至右是有次序的,不能互换的,则称该树为有序树,否则称为无序树。
森林是m(m>=0)棵互不相交的树的集合。对树中每个结点而言,其子树的集合即为森林。

二叉树的先序,中序,后序,层次序遍历;

先序遍历

操作过程如下
如果二叉树为空树,则什么都不做,否则:
1)访问根节点
2)先序遍历左子树
3)先序遍历右子树
对应的算法描述如下

1
2
3
4
5
6
7
8
void preorder(BTNode *p){
    if(p!=NULL){
        Visit(p);
        preorder(p->lchild);
        preorder(p->rchild);
        
    }
}

中序遍历

操作如下
如果二叉树为空树,则什么都不做,否则:
1)中序遍历左子树。
2)访问根节点。
3)中序遍历右子树。
对应的算法描述如下

1
2
3
4
5
6
7
void inorder(BTNode *p){
    if(p!=NULL){
        preorder(p->lchild);
        Visit(p);
        preorder(p->rchild);
    }
}

后序遍历

操作如下
如果二叉树为空树,则什么都不做,否则:
1)后序遍历左子树。
2)后序遍历右子树。
3)访问根节点。
对应的算法描述如下

1
2
3
4
5
6
7
void postorder(BTNode *p){
    if(p!=NULL){
        preorder(p->lchild);
        preorder(p->rchild);
        Visit(p);
    }
}

层次遍历


如图所示为二叉树的层次遍历,即按照箭头所示方向,按照1,2,3,4的层次顺序对二叉树中的各个结点访问。
要进行层次遍历需要建立一个循环队列,先将二叉树头结点入队列,然后出队列,访问该结点,如果他有左子树,则将左子树的根节点入队,如果他有右子树,则将右子树的根节点入队,然后出队列,对出对结点访问,如此反复,直到队列为空为止。
得到的算法如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
void level(BTNode *p) {
    int front, rear;
    BTNode *que[maxSize];
    front = rear = 0;
    BTNode *q;
    if (p != NULL) {
        rear = (rear + 1) % maxSize;
        que[rear] = p;
        while (front != rear) {
            front = (front + 1)
            maxSize;
            q = que[front];
            Visit(q);
            if (q->lchild != NULL) {
                rear = (rear + 1) % maxSize;
                que[rear] = q->lchild;
            }
            if (q->rchild != NULL) {
                rear = (rear + 1) % maxSize;
                que[rear] = q->rchild;
            }

        }
    }
}

二叉树遍历算法的改进

二叉树深度优先遍历算法的非递归实现

  1. 先序遍历非递归算法

    出栈时判断是否有孩子,右孩子先入栈,左孩子后入栈,因为对左孩子的访问要先于右孩子

  • 结点1入栈
  • 1出栈,输出结点1,并将1的左右孩子2,4入栈,右孩子先入栈,左孩子后入栈。因为对左孩子的访问要先于右孩子,后入栈的会先出栈访问
  • 2出栈,并将2的左右孩子3和5入栈
  • 3出栈,3无叶子节点
  • 5出栈
  • 4出栈,此时栈空,进入终态
  • 遍历顺序为1,2,3,4,5
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public List<Integer> preorderTraversal(TreeNode root){
     List<Integer> resultList = new ArrayList<>();
     Stack<TreeNode> treeNodeStack = new Stack<>();
     if(root ==null)
         return resultList;
     treeNodeStack.push(root);
     while (!treeNodeStack.isEmpty()){
         TreeNode tempNode = treeNodeStack.pop();
         if (tempNode!=null){
             resultList.add(tempNode.val);
             treeNodeStack.push(tempNode.right);
             treeNodeStack.push(tempNode.left);
         }
     }
     return resultList;
 }
  1. 中序遍历非递归算法

    入栈即考虑左孩子是否存在,存在则入,出栈考虑其右孩子是否存在,存在则入。

  • 1入栈 1的左孩子2存在
  • 2入栈 2的左孩子3存在
  • 3入栈 3的左孩子不存在
  • 3出栈 3的右孩子不存在
  • 2出栈,2的右孩子5存在,故5入栈 5的左孩子不存在
  • 5出栈,5的右孩子不存在
  • 1出栈,1的右孩子4存在,4 入栈
  • 4出栈,此时栈空
    综上步骤得知:
  1. 开始根节点入栈
  2. 循环进行如下操作:如果栈顶的左孩子存在,则左孩子入栈,如果栈顶的左孩子不存在,则出栈并且输出栈顶结点,然后检查其右孩子是否存在,如果存在,则右孩子入栈。
  3. 当栈空时,算法结束

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    
    public List<Integer> inorderTraversal(TreeNode root){
        List<Integer> resultList = new ArrayList<>();
        Stack<TreeNode> treeNodeStack = new Stack<>();
        TreeNode cur = root;
        while(cur!=null || !treeNodeStack.empty()){
            while (cur!=null){
                treeNodeStack.add(cur);
                cur= cur.left;
            }
            cur = treeNodeStack.pop();
            resultList.add(cur.val);
            cur = cur.right;
        }
        return resultList;
    }
    
  4. 后序遍历非递归算法

    非递归先序遍历算法中的对左右子树的遍历顺序交换就可以得到逆后序遍历序列,然后将逆后序遍历序列逆序就得到了后序遍历。因此我们需要两个栈

线索二叉树

中序线索二叉树

线索二叉树(引线二叉树) 的定义如下:一个二叉树通过如下的方法“穿起来”:

所有原本为空的右(孩子)指针改为指向该节点在中序序列中的后继,所有原本为空的左(孩子)指针改为指向该节点的中序序列的前驱。

线索二叉树能线性地遍历二叉树,从而比递归的 中序遍历更快。使用线索二叉树也能够方便的找到一个节点的父节点,这比显式地使用父亲节点指针或者栈效率更高。这在栈空间有限,或者无法使用存储父节点的栈时很有作用(对于通过深度优先搜索来查找父节点而言)。 考虑这样的例子:一个节点k有一个右孩子r,那么r的左指针可能是指向一个孩子节点,或是一个指回k的线索。如果r有左孩子,这个左孩子同样也应该有一个左孩子或是指回k的线索。对于所有的左孩子同理。因此沿着这些从r发出的左指针,我们最终会找到一个指回k的线索。这种特性是对称的:当q是p的左孩子时,我们可以沿着q的右孩子找到一个指回p的线索。
传统的二叉树一般都是以链式存储的结构来表示。这样,二叉树中的每个节点都可以用链表中的一个链节点来存储,每个链节点就包含了若干个指针。但是,这种传统的链式存储结构只能表现出二叉树中节点之间的父子关系,而且不能利用空余的指针来直接得到某个节点的在特定的遍历顺序(先序,中序,后序)中的直接前驱和直接后继。通过分析传统的二叉树链式存储结构表示的二叉树中,存在大量的空闲指针。若能利用这些空指针域来存放指向该节点的直接前驱或是直接后继的指针,则可以进行某些更方便的运算。这些被重新利用起来的空指针就被称为线索,加上了这些线索的二叉树就是线索二叉树。

二叉树及其性质

二叉树的定义
1)每个结点最多只有2颗子树,即二叉树中节点的度只能为0,1,2
2)子树有左右顺序之分,不能颠倒。

性质1
非空二叉树上叶子结点数等于双分支结点数加1
性质2
二叉树的第i层上最多有2i−12i−1个结点
性质3
高度或深度为k的二叉树最多有2k−12k−1个结点。换句话说,满二叉树中前k层的结点个数为2k−12k−1
性质4
有n个结点的完全二叉树,对各结点从上到下,从左到右依次编号(编号范围1~n)则结点之间有如下的关系
若i为某结点a的编号则:
如果i≠1,则a双亲结点的编号为 ⌊i/2⌋.
如果2i≤n,则a左孩子的编号为2i;如果2i>n,则a无左孩子。
如果2i+1≤n,则a右孩子的编号为2i+1;如果2i+1>n,则a无右孩子。
性质5
函数Catalan():给定n个结点,能够构成h(n)中不同的二叉树 h(n)=Cn=C(2n,n)/(n+1)h(n)=Cn=C(2n,n)/(n+1)
注:C(n,r)=n!/[r!(n−r)!]C(n,r)=n!/[r!(n−r)!]
性质6
具有n(n>0)个结点的完全二叉树的高度为log2nlog2n

普通树与二叉树的转换

树转换成二叉树

树转换成二叉树的过程如下
1)将同一结点的各孩子结点用线串起来
2)将每个结点的分支从左到右除了第一个外,其余的都剪掉,整理即可得到

二叉树转换成树

二叉树转换为树是树转换为二叉树的逆过程,其步骤是:
1)若某结点的左孩子结点存在,将左孩子结点的右孩子结点、右孩子结点的右孩子结点……都作为该结点的孩子结点,将该结点与这些右孩子结点用线连接起来;
2)删除原二叉树中所有结点与其右孩子结点的连线;
3)整理(1)和(2)两步得到的树,使之结构层次分明。

森林转换成二叉树

森林是由若干棵树组成,可以将森林中的每棵树的根结点看作是兄弟,由于每棵树都可以转换为二叉树,所以森林也可以转换为二叉树。

将森林转换为二叉树的步骤是:
1)先把每棵树转换为二叉树;
2)第一棵二叉树不动,从第二棵二叉树开始,依次把后一棵二叉树的根结点作为前一棵二叉树的根结点的右孩子结点,用线连接起来。当所有的二叉树连接起来后得到的二叉树就是由森林转换得到的二叉树。

二叉树转换成森林

二叉树转换为森林比较简单,其步骤如下:
1)先把每个结点与右孩子结点的连线删除,得到分离的二叉树;
2)把分离后的每棵二叉树转换为树;
3)整理第(2)步得到的树,使之规范,这样得到森林。

森林和树的遍历

根据树与二叉树的转换关系以及二叉树的遍历定义可以推知,
树的先序遍历与其转换的相应的二叉树的先序遍历的结果序列相同;树的后序遍历与其转换的二叉树的中序遍历的结果序列相同;树的层序遍历与其转换的二叉树的后序遍历的结果序列相同。
由森林与二叉树的转换关系以及森林与二叉树的遍历定义可知,
森林的先序遍历和中序遍历与所转换得到的二叉树的先序遍历和中序遍历的结果序列相同。

树的存储结构,标准形式

顺序存储结构

树的顺序存储结构最简单直观的是双亲存储结构,用一维数组即可实现。将所有结点存到一个数组中。每个结点都有一个数据域data和一个数值parent指示其双亲在数组中存放的位置。根结点由于没有父结点,parent用-1表示。
int tree[maxSize]

链式存储结构

1)孩子存储结构
孩子存储结构实质上就是图的邻接表存储结构
树就是一种特殊的图,把图中的多对多关系删减成一对多关系即可的得到树
2)孩子兄弟存储结构
树转换成二叉树的过程

完全树(complete tree)的数组形式存储

树的应用

二叉排序树和平衡二叉树与查找关系密切,因为放到查找一章讲解

Huffman树的定义与应用

Huffman树相关的概念

1)路径:是指在一棵树中,从一个节点到另一个节点之间的分支构成的通路,如从节点8到节点1的路径如下图所示:
2)路径的长度:是指路径上的分支数目,在上图中,路径长度为2。
3)树的路径长度:是指从根到每个结点的路径长度之和。
4)带权路径长度:结点具有权值,从该结点到根之间的路径长度乘以结点的权值,就是该结点的带权路径长度。
5)树的带权路径长度:是指树中所有叶子节点的带权路径之和。
6 节点的权:指的是为树中的每一个节点赋予的一个非负的值,如上图中每一个节点中的值

有了如上的概念,对于Huffman树,其定义为:
给定n权值作为n个叶子节点,构造一棵二叉树,若这棵二叉树的带权路径长度达到最小,则称这样的二叉树为最优二叉树,也称为Huffman树。

Huffman树的构建

给定n个权值,用这n个权值来构建赫夫曼树的算法描述如下:
1)将这n个权值分别看成只有根节点的n棵二叉树,将这些二叉树的集合记为F。
2)从F中选出两颗根结点的权值最小的树,作为左右子树,构建一棵新的二叉树,新的二叉树的根结点的权值为左右子结点的权值之和。
3)从F中删去a,b,加入新构建的树C
4)重复2,3步,直到F中只剩下一棵树为止,这棵树就是赫夫曼树。

对于树中节点的结构为:

1
2
3
4
5
6
7
struct huffman_node{
        char c;
        int weight;
        char huffman_code[LEN];
        huffman_node * left;
        huffman_node * right;
};

对于Huffman树的构建过程为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
int huffman_tree_create(huffman_node *&root, map<char, int> &word){
        char line[MAX_LINE];
        vector<huffman_node *> huffman_tree_node;

        map<char, int>::iterator it_t;
        for (it_t = word.begin(); it_t != word.end(); it_t++){
                // 为每一个节点申请空间
                huffman_node *node = (huffman_node *)malloc(sizeof(huffman_node));
                node->c = it_t->first;
                node->weight = it_t->second;
                node->left = NULL;
                node->right = NULL;
                huffman_tree_node.push_back(node);
        }


        // 开始从叶节点开始构建Huffman树
        while (huffman_tree_node.size() > 0){
                // 按照weight升序排序
                sort(huffman_tree_node.begin(), huffman_tree_node.end(), sort_by_weight);
                // 取出前两个节点
                if (huffman_tree_node.size() == 1){// 只有一个根结点
                        root = huffman_tree_node[0];
                        huffman_tree_node.erase(huffman_tree_node.begin());
                }else{
                        // 取出前两个
                        huffman_node *node_1 = huffman_tree_node[0];
                        huffman_node *node_2 = huffman_tree_node[1];
                        // 删除
                        huffman_tree_node.erase(huffman_tree_node.begin());
                        huffman_tree_node.erase(huffman_tree_node.begin());
                        // 生成新的节点
                        huffman_node *node = (huffman_node *)malloc(sizeof(huffman_node));
                        node->weight = node_1->weight + node_2->weight;
                        (node_1->weight < node_2->weight)?(node->left=node_1,node->right=node_2):(node->left=node_2,node->right=node_1);
                        huffman_tree_node.push_back(node);
                }
        }

        return 0;
}

Huffman树的特点

1)权值越大的结点,距离根结点越近。
2)树中没有度为1的结点,这类树又叫做正则(严格)二叉树。
3)树的带权路径长度最短

Huffman编码

常见的.zip压缩文件和.jpeg图片文件的底层技术都用到了赫夫曼编码。
例如字符串S=AAABBACCCDEEA

ABCDE
5次2次3次1次2次

以出现的次数为权值,构建一个赫夫曼树,对每个结点的左右分支进行编号,左0右1,从根到每个结点的路径的数字序列即为每个字符的编码,对A~E的赫夫曼编码规则

ABCDE
01101011101111

则H(S)=00011011001010101110111111110
上述有赫夫曼树导出每个字符的编码,进而得到整个字符串的编码的过程称为赫夫曼编码。
在前缀码中,任一字符的编码串都不是另一字符编码串的前缀,赫夫曼编码产生的是最短前缀码。

赫夫曼n叉树

赫夫曼二叉树是赫夫曼n叉树的一种特例,当对于结点数目大于等于2的待处理序列,都可以构造赫夫曼二叉树,但却不一定能构建赫夫曼n叉树,但无法构建时。需要补上权值为0的结点让整个序列凑成可以构造赫夫曼n叉树的序列。

查找的基本概念

查找的定义:给定一个K值,在含有N个记录的表中找出关键字等于K的记录。若找到,则查找成功,返回该记录的信息或者该记录在表中的位置;否则查找失败,返回相关的指示记录。
由于查找算法的基本操作是关键字的比较,并且关键字比较次数与待查找关键字有关(对于一个查找表来说,对其中不同的关键字查找,关键字比较的次数一般不同),因此通常把查找过程中对关键字的平均比较次数作为衡量一个算法优劣的标准,平均查找长度用ASL来表示。

对线性关系结构的查找

顺序查找

基本思路:从表的一端开始,顺序扫描线性表,一次将扫描到的关键字和给定的K值比较。
顺序查找法对于顺序表和链表都是适用的,对于顺序表,可以通过数组下标递增来顺序扫描数组中的各个元素;对于链表,则可以通过表结点指针反复执行p=p->next来扫描表中的各个元素。

1
2
3
4
5
6
7
8
int Search(int a[], int n ,int t){
    int i;
    for (int i = 1; i <=n; ++i) {
        if(a[i]==k)
            return i;
        return 0;
    }
}

时间复杂度为0(n)

二分查找

基本思路:
① 首先确定整个查找区间的中间位置 mid = ( left + right )/ 2
② 用待查关键字值与中间位置的关键字值进行比较;
  若相等,则查找成功
  若大于,则在后(右)半个区域继续进行折半查找
  若小于,则在前(左)半个区域继续进行折半查找
③ 对确定的缩小区域再按折半公式,重复上述步骤。

二分查找必须要求
1.存储在数组中
2.有序排列
时间复杂度O(logn)O(logn)

分块查找(索引顺序表查找)

基本思路:对顺序表进行分块查找需要额外建立一个索引表,表中的每一项对应线性表中的一块,每个索引项都由键值分量和链值分量组成,键值分量存放对应快的最大关键字,链值分量存放指向本地第一个元素和最后一个元素的指针。

二叉排序树与平衡二叉树

二叉排序树(BST)

二叉排序树(BST)的定义和存储结构

二叉排序树或者是空树,或者是满足一下性质的二叉树:
1)若他的左子树不为空,则左子树上的关键字的值均小于根关键字的值
2)若他的右子树不空,则右子树上所有关键字的值均大于根关键字的值
3)左右子树有格式一棵二叉排序树

通常采用二叉链表进行存储,其结点类型定义与一般的二叉树类似

1
2
3
4
5
typedef struct BTNode{
    int key;
    struct BTNode *lchild;
    struct BTNode *rchild;
}BTNode;

二叉排序树的基本算法

在二元排序树b中查找x的过程为:

1.若b是空树,则搜索失败,否则:
2.若x等于b的根节点的数据域之值,则查找成功;否则:
3.若x小于b的根节点的数据域之值,则搜索左子树;否则:
4.查找右子树。

在二叉排序树中删去一个结点,分三种情况讨论:

1.若*p结点为叶子结点,即PL(左子树)和PR(右子树)均为空树。由于删去叶子结点不破坏整棵树的结构,则只需修改其双亲结点的指针即可。

2.若p结点只有左子树PL或右子树PR,此时只要令PL或PR直接成为其双亲结点f的左子树(当p是左子树)或右子树(当p是右子树)即可,作此修改也不破坏二叉排序树的特性。

3.若p结点的左子树和右子树均不空。在删去p之后,为保持其它元素之间的相对位置不变,可按中序遍历保持有序进行调整。比较好的做法是,找到p的直接前驱(或直接后继)s,用s来替换结点p,然后再删除结点s。

二叉排序树的性能介绍

每个结点的Ci为该结点的层次数。最好的情况是二叉排序树的形态和折半查找的判定树相同,其平均查找长度和lognlogn成正比O(log2(n))O(log2(n))。最坏情况下,当先后插入的关键字有序时,构成的二叉排序树为一棵斜树,树的深度为n,其平均查找长度为(n+1)/2(n+1)/2。也就是时间复杂度为O(n)O(n),等同于顺序查找。因此,如果希望对一个集合按二叉排序树查找,最好是把它构建成一棵平衡的二叉排序树(平衡二叉树)。

平衡二叉树

二叉查找树不是严格的O(logN)O(logN),当有很多数据灌到我的树中时,我肯定会希望最好是以“完全二叉树”的形式展现,这样我才能做到“查找”是严格的O(logN)O(logN)

平衡二叉树(AVL)定义

父节点的左子树和右子树的高度之差不能大于1,也就是说不能高过1层,否则该树就失衡了,此时就要旋转节点,在
编码时,我们可以记录当前节点的高度,比如空节点是-1,叶子节点是0,非叶子节点的height往根节点递增,比如在下图
中我们认为树的高度为h=2。

平衡调整

AVL树的调整过程很类似于数学归纳法,每次在插入新节点之后都会找到离新插入节点最近的非平衡叶节点,然后对其进行旋转操作以使得树中的每个节点都处于平衡状态。

Left Rotation:左旋,右子树右子节点

当新插入的结点为右子树的右子结点时,我们需要进行左旋操作来保证此部分子树继续处于平衡状态。
我们应该找到离新插入的结点最近的一个非平衡结点,来以其为轴进行旋转,下面看一个比较复杂的情况:

Right Rotation:右旋,左子树左子节点

当新插入的结点为左子树的左子结点时,我们需要进行右旋操作来保证此部分子树继续处于平衡状态。


下面看一个比较复杂的情况:

Left-Right Rotation:先左旋再右旋,左子树右子节点

在某些情况下我们需要进行两次旋转操作,譬如在如下的情况下,某个结点被插入到了左子树的右子结点:

我们首先要以A为轴进行左旋操作:
然后需要以C为轴进行右旋操作:


最终得到的又是一棵平衡树:

Right-Left Rotation:先右旋再左旋,右子树左子节点






B-树和B+树的基本概念

B-树(B树)的基本概念

B-树中所有结点中孩子结点个数的最大值成为B-树的阶,通常用m表示,从查找效率考虑,一般要求m>=3。一棵m阶B-树或者是一棵空树,或者是满足以下条件的m叉树。

  1. 每个结点最多有m个分支(子树);而最少分支数要看是否为根结点,如果是根结点且不是叶子结点,则至少要有两个分支,非根非叶结点至少有ceil(m/2)个分支,这里ceil代表向上取整。
  2. 如果一个结点有n-1个关键字,那么该结点有n个分支。这n-1个关键字按照递增顺序排列。
  3. 每个结点的结构为:n k1 k2 … kn
    p0 p1 p2 … pn
    其中,n为该结点中关键字的个数;ki为该结点的关键字且满足ki<ki+1;pi为该结点的孩子结点指针且满足pi所指结点上的关键字大于ki且小于ki+1,p0所指结点上的关键字小于k1,pn所指结点上的关键字大于kn。
  4. 结点内各关键字互不相等且按从小到大排列。
  5. 叶子结点处于同一层;可以用空指针表示,是查找失败到达的位置。
    注:平衡m叉查找树是指每个关键字的左侧子树与右侧子树的高度差的绝对值不超过1的查找树,其结点结构与上面提到的B-树结点结构相同,由此可见,B-树是平衡m叉查找树,但限制更强,要求所有叶结点都在同一层。

光看上面的解释可能大家对B-树理解的还不是那么透彻,下面我们用一个实例来进行讲解。

上面的图片显示了一棵B-树,最底层的叶子结点没有显示。我们对上面提到的5条特点进行逐条解释:
1)结点的分支数等于关键字数+1,最大的分支数就是B-树的阶数,因此m阶的B-树中结点最多有m个分支,所以可以看到,上面的一棵树是一个5-阶B-树。
2)因为上面是一棵5阶B-树,所以非根非叶结点至少要有ceil(5/2)=3个分支。根结点可以不满足这个条件,图中的根结点有两个分支。
3)如果根结点中没有关键字就没有分支,此时B-树是空树,如果根结点有关键字,则其分支数比大于或等于2,因为分支数等于关键字数+1.
4)上图中除根结点外,结点中的关键字个数至少为2,因为分支数至少为3,分支数比关键字数多1,还可以看出结点内关键字都是有序的,并且在同一层中,左边结点内所有关键字均小于右边结点内的关键字,例如,第二层上的两个结点,左边结点内的关键字为15,26,他们均小于右边结点内的关键字39和45.
B-树一个很重要的特征是,下层结点内的关键字取值总是落在由上层结点关键字所划分的区间内,具体落在哪个区间内可以由指向它的指针看出。例如,第二层最左边的结点内的关键字划分了三个区间,小于15,15到26,大于26,可以看出其下层中最左边结点内的关键字都小于15,中间结点的关键字在15和26之间,右边结点的关键字大于26.
5)上图中叶子结点都在第四层上,代表查找不成功的位置。

B-树的查找操作

B-树的查找很简单,是二叉排序树的扩展,二叉排序树是二路查找,B-树是多路查找,因为B-树结点内的关键字是有序的,在结点内进行查找时除了顺序查找外,还可以用折半查找来提升效率。B-树的具体查找步骤如下(假设查找的关键字为key):
1)先让key与根结点中的关键字比较,如果key等于k[i](k[]为结点内的关键字数组),则查找成功
2)若key<k[1],则到p[0]所指示的子树中进行继续查找(p[]为结点内的指针数组),这里要注意B-树中每个结点的内部结构。
3)若key>k[n],则道p[n]所指示的子树中继续查找。
4)若k[i]<key<k[i+1],则沿着指针p[I]所指示的子树继续查找。
5)如果最后遇到空指针,则证明查找不成功。

拿上面的二叉树进行举例,比如我们想要查找关键字42,下图加粗的部分显示了查找的路径:

B-树的插入

与二叉排序树一样,B-树的创建过程也是将关键字逐个插入到树中的过程。
在进行插入之前,要确定一下每个结点中关键字个数的范围,如果B-树的阶数为m,则结点中关键字个数的范围为ceil(m/2)-1 ~ m-1个。
对于关键字的插入,需要找到插入位置。在B-树的查找过程中,当遇到空指针时,则证明查找不成功,同时也找到了插入位置,即根据空指针可以确定在最底层非叶结点中的插入位置,为了方便,我们称最底层的非叶结点为终端结点,由此可见,B-树结点的插入总是落在终端结点上。在插入过程中有可能破坏B-树的特征,如新关键字的插入使得结点中关键字的个数超过规定个数,这是要进行结点的拆分。
接下来,我们以关键字序列{1,2,6,7,11,4,8,13,10,5,17,9,16,20,3,12,14,18,19,15}创建一棵5阶B-树,我们将详细体会B-树的插入过程。
(1)确定结点中关键字个数范围
由于题目要求建立5阶B-树,因此关键字的个数范围为2~4
(2)根结点最多可以容纳4个关键字,依次插入关键字1、2、6、7后的B-树如下图所示:

(3)当插入关键字11的时候,发现此时结点中关键字的个数变为5,超出范围,需要拆分,去关键字数组中的中间位置,也就是k[3]=6,作为一个独立的结点,即新的根结点,将关键字6左、右关键字分别做成两个结点,作为新根结点的两个分支,此时树如下图所示:

(4)新关键字总是插在叶子结点上,插入关键字4、8、13之后树为:

(5)关键字10需要插入在关键字8和11之间,此时又会出现关键字个数超出范围的情况,因此需要拆分。拆分时需要将关键字10纳入根结点中,并将10左右的关键字做成两个新的结点连在根结点上。插入关键字10并经过拆分操作后的B-树如下图:

(6)插入关键字5、17、9、16之后的B-树如图所示:

(7)关键字20插入在关键字17以后,此时会造成结点关键字个数超出范围,需要拆分,方法同上,树为:

(8)按照上述步骤依次插入关键字3、12、14、18、19之后B-树如下图所示:

(9)插入最后一个关键字15,15应该插入在14之后,此时会出现关键字个数超出范围的情况,则需要进行拆分,将13并入根结点,13并入根结点之后,又使得根结点的关键字个数超出范围,需要再次进行拆分,将10作为新的根结点,并将10左、右关键字做成两个新结点连接到新根结点的指针上,这种插入一个关键字之后出现多次拆分的情况称为连锁反应,最终形成的B-树如下图所示:

B-树的删除

对于B-树关键字的删除,需要找到待删除的关键字,在结点中删除关键字的过程也有可能破坏B-树的特性,如旧关键字的删除可能使得结点中关键字的个数少于规定个数,这是可能需要向其兄弟结点借关键字或者和其孩子结点进行关键字的交换,也可能需要进行结点的合并,其中,和当前结点的孩子进行关键字交换的操作可以保证删除操作总是发生在终端结点上。

我们用刚刚生成的B-树作为例子,一次删除8、16、15、4这4个关键字。
(1)删除关键字8、16。关键字8在终端结点上,并且删除后其所在结点中关键字的个数不会少于2,因此可以直接删除。关键字16不在终端结点上,但是可以用17来覆盖16,然后将原来的17删除掉,这就是上面提到的和孩子结点进行关键字交换的操作。这里不能用15和16进行关键字交换,因为这样会导致15所在结点中关键字的个数小于2。因此,删除8和16之后B-树如下图所示:

(2)删除关键字15,15虽然也在终端结点上,但是不能直接删除,因为删除后当前结点中关键字的个数小于2。这是需要向其兄弟结点借关键字,显然应该向其右兄弟来借关键字,因为左兄弟的关键字个数已经是下限2.借关键字不能直接将18移到15所在的结点上,因为这样会使得15所在的结点上出现比17大的关键字,所以正确的借法应该是先用17覆盖15,在用18覆盖原来的17,最后删除原来的18,删除关键字15后的B-树如下图所示:

(3)删除关键字4,4在终端结点上,但是此时4所在的结点的关键字个数已经到下限,需要借关键字,不过可以看到其左右兄弟结点已经没有多余的关键字可借。所以就需要进行关键字的合并。可以先将关键字4删除,然后将关键字5、6、7、9进行合并作为一个结点链接在关键字3右边的指针上,也可以将关键字1、2、3、5合并作为一个结点链接在关键字6左边的指针上,如下图所示:

显然上述两种情况下都不满足B-树的规定,即出现了非根的双分支结点,需要继续进行合并,合并后的B-树如下图所示:

有时候删除的结点不在终端结点上,我们首先需要将其转化到终端结点上,然后再按上面的各种情况进行删除。在讲述这种情况下的删除方法之前,要引入一个相邻关键字的概念,对于不在终端结点的关键字a,它的相邻关键字为其左子树中值最大的关键字或者其右子树中值最小的关键字。找a的相邻关键字的方法为:沿着a的左指针来到其子树根结点,然后沿着根结点中最右端的关键字的右指针往下走,用同样的方法一直走到叶结点上,叶结点上的最右端的关键字即为a的相邻关键字(这里找的是a左边的相邻关键字,我们可以用同样的思路找到a右边的相邻关键字)。可以看到下图中a的相邻关键字是d和e,要删除关键字a,可以用d来取代a,然后按照上面的情况删除叶子结点上的d即可。

B+树的基本概念


可以看到,m阶B+树和B-树的差别主要体现在:

  1. 在B+树中,具有n个关键字的结点有n个分支,而在B-树中,具有n个关键字的结点含有n+1个分支。
  2. 在B+树中,每个结点(除根结点外)中的关键字个数n的取值为ceil(m/2) <= n <=m,根结点的取值范围为1<=n<=m,他们的取值范围分别是ceil(m/2) -1<= n <=m-1和1<=n<=m-1。
  3. 在B+树中叶子结点包含信息,并且包含了全部关键字,叶子结点引出的指针指向记录。
  4. 在B+树中的所有非叶子结点仅起到一个索引的作用,即结点中的每个索引项只含有对应子树的最大关键字和指向该子树的指针,不含有该关键字对应记录的存储地址,而在B-树中,每个关键字对应一个记录的存储地址。

B+树的优势:

  • 单一节点存储更多的元素,使得查询的IO次数更少。
  • 所有查询都要查找到叶子节点,查询性能稳定。
  • 所有叶子节点形成有序链表,便于范围查询。

散列表(哈希表)

哈希表(Hash table,也叫散列表),是根据关键码值(Key value)而直接进行访问的数据结构。也就是说,它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做散列函数,存放记录的数组叫做散列表。
根据给定的关键字来计算出关键字在表中的地址

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

设置一个表长为key的hash表,当有多个关键字共用一个地址,这种情况就称之为冲突,这种情况是不允许存在的,因此需要做一些处理来解决冲突,使得每一个地址对应一个关键字。
冲突解决办法:
在发生冲突的时候,对i属于1~m-1,从冲突地址d开始依次进行H(key)−(H(key)+i)MODmH(key)−(H(key)+i)MODm运算,直到没有冲突为止,算出此时的地址为H(key)
加入了冲突处理的Hash表在查找时,不能只根据Hash函数来计算地址,还要结合冲突解决方法。

上述Hash表的关键字key查找过程:先用Hash函数来计算地址,然后用KEY和这个地址上的关键字进行比较,如果当前地址上为空,则证明查找失败;如果和当前地址上的关键字相同,则证明查找成功;如果不相同,则根据冲突解决办法到下一个地址继续比较,直到相同为止,证明查找成功。

常用的Hash函数的构建方法

1)直接定址法
取关键字或关键字的某个线性函数为Hash地址,即H(key)=key 或者H(key)=a*key+b 其中a和b为常数
2)数字分析法
假设关键字是r进制数(如十进制),并且Hash表中可能出现的关键字都是事先知道的,则可选取关键字的若干数位组成Hash地址,选取的原则是使得到的Hash地址尽量减少冲突,即所选数位上的数字尽可能是随机的。
3)平方取中法
取关键字平方后的中间几位作为Hash地址。通常在选定Hash函数的时候不一定能知道关键字的全部情况,仅取其中的几位为地址不一定适合,而一个数平方后的中间即为数和数的每一位都相关,由此得到的Hash地址随机性更大,取的位数由表长决定
4)除留余数法
去关键字被某个不大于hash表长m的数p除后所得到的余数为Hash地址,即H(key)=keyMODpH(key)=keyMODp
在本方法中,p的选择很重要,一般p选择小于或者等于表长的最大素数,这样可以减少冲突。

处理冲突的方法

(1)开放定址法
1) 线性探查法
H(key)−(H(key)+i)MODmH(key)−(H(key)+i)MODm
2)平方探查法
设发生冲突的地址为d,则用平方探测法所得到的新地址为d+12d+12,d−12d−12,d+22d+22,平方探测法是一种较好的处理冲突的方法,可以减少出现堆积问题,他的缺点不能探查到Hash表上的所有单元,但至少能探查到一半单元。
此外,开放定制法的探查方法还有伪随机序列法以及双hash函数法

(2)链地址法
链地址法是把所有的同义词用单链表连接起来的方法,在这种方法中,Hash表每个单元中存放的不再是记录本身,而是相同同义词单链表的表头指针。

散列表的性能分析

散列表的查找过程基本上和造表过程相同。一些关键字可通过散列函数转换的地址直接找到,另一些关键字在散列函数得到的地址上产生了冲突,需要按处理冲突的方法进行查找。在处理冲突的方法中,产生冲突后的查找仍然是给定值与关键码进行比较的过程。所以,对散列表查找效率的量度,依然用平均查找长度来衡量。
查找过程中,关键字的比较次数,取决于产生冲突的多少,产生的冲突少,查找效率就高,产生的冲突多,查找效率就低。因此,影响产生冲突多少的因素,也就是影响查找效率的因素。影响产生冲突多少有以下三个因素:

  1. 散列函数是否均匀;
  2. 处理冲突的方法;
  3. 散列表的装填因子。

散列表的装填因子定义为:α = 填入表中的元素个数 / 散列表的长度
α是散列表装满程度的标志因子。由于表长是定值,α与“填入表中的元素个数”成正比,所以,α越大,填入表中的元素较多,产生冲突的可能性就越大;α越小,填入表中的元素较少,产生冲突的可能性就越小。
通常会将散列表的空间设置的比查找集合大,此时虽然浪费了一定的空间,但会降低产生冲突的可能性,以提升查找效率。

四、排序

插入类排序

直接插入排序

直接插入排序的思想

是每一步将一个带排序的记录,插入到前面已经排序好的有序序列中去,直到插完所有元素为止。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public static void insertSort(int[] a, int n) {
	int i, j, k;
	for (i = 1; i < n; i++) {
		// 为a[i]在前面的a[0...i-1]有序区间中找一个合适的位置
		for (j = i - 1; j >= 0; j--) {
			if (a[j] < a[i]) {
				break;
			}
		}
		// 如找到了一个合适的位置
		if (j != i - 1) {
			// 将比a[i]大的数据向后移
			int temp = a[i];
			for (k = i - 1; k > j; k--) {
				a[k + 1] = a[k];
			}
			// 将a[i]放到正确位置上
			a[k + 1] = temp;
		}
	}
}

算法性能分析

时间复杂度分析 选取最内层循环R[j+1]=R[j];作为基本操作
1)考虑最坏情况,整个序列是逆序的,基本操作次数为n(n-1)/2
时间复杂度为O(n2)O(n2)

空间复杂度分析 算法所需的辅助储存空间不随待排序规模的变化二变化,是个常量,因此空间复杂度为O(1)O(1)

折半插入排序

折半插入排序思想

顺序地把待排序的序列中的各个元素按其关键字的大小,通过折半查找插入到已排序的序列的适当位置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void BinaryInsertSort(int R[], int n) {
    int i, j, temp, m, low, high;
    for (i = 1; i < n; i++) {
        temp = R[i];
        low = 0;
        high = i - 1;
        while (low <= high) {
            m = (low + high) / 2;
            if (R[m] > temp)
                high = m - 1;
            else
                low = m + 1;
        }
    }
    for (j = i - 1; j >= high + 1; j--)
        R[j + 1] = R[j];
    R[j + 1] = temp;
}

算法性能分析

折半查找只是减少了比较次数,但是元素的移动次数不变。折半插入排序平均时间复杂度为O(n2)O(n2);空间复杂度为O(1);是稳定的排序算法。

希尔排序

希尔排序思想

希尔排序(Shell Sort)是插入排序的一种,它是针对直接插入排序算法的改进。该方法又称缩小增量排序,因DL.Shell于1959年提出而得名。

希尔排序实质上是一种分组插入方法。它的基本思想是:对于n个待排序的数列,取一个小于n的整数gap(gap被称为步长)将待排序元素分成若干个组子序列,所有距离为gap的倍数的记录放在同一个组中;然后,对各组内的元素进行直接插入排序。 这一趟排序完成之后,每一个组的元素都是有序的。然后减小gap的值,并重复执行上述的分组和排序。重复这样的操作,当gap=1时,整个数列就是有序的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
/**
 * 希尔排序
 * 参数说明: a待排序的数组; n数组的长度
 */
public static void shellSort2(int[] a, int n) {
	// gap为步长,每次减为原来的一半。
	for (int gap = n / 2; gap > 0; gap /= 2) {
		// 共gap个组,对每一组都执行直接插入排序
		for (int i = 0; i < gap; i++)
			groupSort(a, n, i, gap);
	}
}
	/**
 * 对希尔排序中的单个组进行排序
 * 参数说明: a待排序的数组; n数组总的长度; i组的起始位置; gap组的步长
 * 组是"从i开始,将相隔gap长度的数都取出"所组成的!
 */
public static void groupSort(int[] a, int n, int i, int gap) {
	for (int j = i + gap; j < n; j += gap) {
		// 如果a[j] < a[j-gap],则寻找a[j]位置,并将后面数据的位置都后移。
		if (a[j] < a[j - gap]) {
			int tmp = a[j];
			int k = j - gap;
			while (k >= 0 && a[k] > tmp) {
				a[k + gap] = a[k];
				k -= gap;
			}
			a[k + gap] = tmp;
		}
	}
}

算法性能分析

希尔排序时间复杂度
希尔排序的时间复杂度与增量(即,步长gap)的选取有关。例如,当增量为1时,希尔排序退化成了直接插入排序,此时的时间复杂度为O(n²)O(n²),而Hibbard增量的希尔排序的时间复杂度为O(N1.5)O(N1.5)。
空间复杂度同直接插入排序一样为O(1)。

希尔排序稳定性
希尔排序是不稳定的算法,它满足稳定算法的定义。对于相同的两个数,可能由于分在不同的组中而导致它们的顺序发生变化。
算法稳定性 – 假设在数列中存在a[i]=a[j],若在排序之前,a[i]在a[j]前面;并且排序之后,a[i]仍然在a[j]前面。则这个排序算法是稳定的!

交换类排序

冒泡排序

冒泡排序思想

冒泡排序(Bubble Sort),又被称为气泡排序或泡沫排序。

它是一种较简单的排序算法。它会遍历若干次要排序的数列,每次遍历时,它都会从前往后依次的比较相邻两个数的大小;如果前者比后者大,则交换它们的位置。这样,一次遍历之后,最大的元素就在数列的末尾! 采用相同的方法再次遍历时,第二大的元素就被排列在最大元素之前。重复此操作,直到整个数列都有序为止!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void BubbleSort(int R[],int n) {
    {
        int i, j;
        int flag;                 // 标记
        for (i = n - 1; i > 0; i--) {
            flag = 0;
            // 将a[0...i]中最大的数据放在末尾
            for (j = 0; j < i; j++) {
                if (R[j] > R[j + 1])
                    swap(R[j], R[j + 1]);
                    flag = 1;
            }
            if (flag==0)
                break;
        }
    }
}

算法性能分析

冒泡排序时间空间复杂度
冒泡排序的时间复杂度是O(n2)O(n2)。
假设被排序的数列中有N个数。遍历一趟的时间复杂度是O(N),需要遍历多少次呢?N-1次!因此,冒泡排序的时间复杂度O(n2)O(n2)。
额外的辅助空间只有一个flag,因此空间复杂度为O(1)O(1)。

冒泡排序稳定性
冒泡排序是稳定的算法,它满足稳定算法的定义。
算法稳定性 – 假设在数列中存在a[i]=a[j],若在排序之前,a[i]在a[j]前面;并且排序之后,a[i]仍然在a[j]前面。则这个排序算法是稳定的!

快速排序

快速排序思想

快速排序(Quick Sort)使用分治法策略。
它的基本思想是:选择一个基准数,通过一趟排序将要排序的数据分割成独立的两部分;其中一部分的所有数据都比另外一部分的所有数据都要小。然后,再按此方法对这两部分数据分别进行快速排序,整个排序过程可以递归进行,以此达到整个数据变成有序序列。

快速排序流程:
(1) 从数列中挑出一个基准值。
(2) 将所有比基准值小的摆放在基准前面,所有比基准值大的摆在基准的后面(相同的数可以到任一边);在这个分区退出之后,该基准就处于数列的中间位置。
(3) 递归地把”基准值前面的子数列”和”基准值后面的子数列”进行排序。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
void QuickSort(int R[],int low,int high){
    int temp;
    int i =low,j=high;
    if(low<high){
        temp=R[low];
        while(i!=j){
            while(j>i&&R[j]>=temp) --j;
            if(i<j)
            {
                R[i] = R[j];
                ++i;
            }
            while (i<j&&R[i]<temp) ++i;
            if(i>j){
                R[j]=R[i];
                --j;
            }
        }
        R[i] = temp;
        QuickSort(R,low,i-1);
        QuickSort(R,i+1,high);
    }
}

算法性能分析

快速排序稳定性
快速排序是不稳定的算法,它不满足稳定算法的定义。
算法稳定性 – 假设在数列中存在a[i]=a[j],若在排序之前,a[i]在a[j]前面;并且排序之后,a[i]仍然在a[j]前面。则这个排序算法是稳定的!

快速排序时间复杂度
快速排序的时间复杂度在最坏情况下是O(n2)O(n2),平均的时间复杂度是O(nlog2n)O(nlog2n)。
这句话很好理解:假设被排序的数列中有N个数。遍历一次的时间复杂度是O(N),需要遍历多少次呢?至少lg(N+1)次,最多N次。
(01) 为什么最少是lg(N+1)次?快速排序是采用的分治法进行遍历的,我们将它看作一棵二叉树,它需要遍历的次数就是二叉树的深度,而根据完全二叉树的定义,它的深度至少是lg(N+1)。因此,快速排序的遍历次数最少是lg(N+1)次。
(02) 为什么最多是N次?这个应该非常简单,还是将快速排序看作一棵二叉树,它的深度最大是N。因此,快读排序的遍历次数最多是N次。

空间复杂度为o(log2n)o(log2n),快速排序是递归进行的,递归需要栈的辅助,因此他需要的辅助空间比前几类排序算法大。

选择类排序

简单选择排序

从头到尾顺序扫描序列,找出一个最小的一个关键字和第一个关键字交换,接着从剩下的关键字中继续这种选择和交换,最终使序列有序。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void SelectSort(int R[],int n){
    int i,j,k;
    int temp;
    for (int i = 0; i < n; ++i) {
        k=i;
        for (j = i+1; j <n ; ++j)
            if(R[k]>R[j])
                k=j;
        temp=R[i];
        R[i]=R[k];
        R[k]=temp;
        }
    }
}

性能分析
时间复杂度O(n2)O(n2) 空间复杂度O(1)O(1)

堆排序

最大堆进行升序排序的基本思想:
① 初始化堆:将数列a[1…n]构造成最大堆。
② 交换数据:将a[1]和a[n]交换,使a[n]是a[1…n]中的最大值;然后将a[1…n-1]重新调整为最大堆。 接着,将a[1]和a[n-1]交换,使a[n-1]是a[1…n-1]中的最大值;然后将a[1…n-2]重新调整为最大值。 依次类推,直到整个数列都是有序的。

堆排序时间复杂度
堆排序的时间复杂度是O(nlog2n)O(nlog2n)。
假设被排序的数列中有N个数。遍历一趟的时间复杂度是O(N),需要遍历多少次呢?
堆排序是采用的二叉堆进行排序的,二叉堆就是一棵二叉树,它需要遍历的次数就是二叉树的深度,而根据完全二叉树的定义,它的深度至少是lg(N+1)。最多是多少呢?由于二叉堆是完全二叉树,因此,它的深度最多也不会超过lg(2N)。因此,遍历一趟的时间复杂度是O(N),而遍历次数介于lg(N+1)和lg(2N)之间;因此得出它的时间复杂度是O(nlog2n)O(nlog2n)。

空间复杂度 为O(1).

堆排序稳定性
堆排序是不稳定的算法,它不满足稳定算法的定义。它在交换数据的时候,是比较父结点和子节点之间的数据,所以,即便是存在两个数值相等的兄弟节点,它们的相对顺序在排序也可能发生变化。

归并排序

归并排序思想

  1. 从下往上的归并排序:将待排序的数列分成若干个长度为1的子数列,然后将这些数列两两合并;得到若干个长度为2的有序数列,再将这些数列两两合并;得到若干个长度为4的有序数列,再将它们两两合并;直接合并成一个数列为止。这样就得到了我们想要的排序结果。(参考下面的图片)

  2. 从上往下的归并排序:它与”从下往上”在排序上是反方向的。它基本包括3步:
    ① 分解 – 将当前区间一分为二,即求分裂点 mid = (low + high)/2;
    ② 求解 – 递归地对两个子区间a[low…mid] 和 a[mid+1…high]进行归并排序。递归的终结条件是子区间长度为1。
    ③ 合并 – 将已排序的两个子区间a[low…mid]和 a[mid+1…high]归并为一个有序的区间a[low…high]。

性能分析

归并排序时间复杂度
归并排序的时间复杂度是O(nlog2N)O(nlog2N)
假设被排序的数列中有N个数。遍历一趟的时间复杂度是O(N),需要遍历多少次呢?
归并排序的形式就是一棵二叉树,它需要遍历的次数就是二叉树的深度,而根据完全二叉树的可以得出它的时间复杂度是O(nlog2N)O(nlog2N)。
空间复杂度
因归并排序需要转存整个待排序列,因此空间复杂度为O(n)
归并排序稳定性
归并排序是稳定的算法,它满足稳定算法的定义。

基数排序

基数排序思想

基数排序(Radix Sort)是桶排序的扩展,它的基本思想是:将整数按位数切割成不同的数字,然后按每个位数分别比较。
具体做法是:将所有待比较数值统一为同样的数位长度,数位较短的数前面补零。然后,从最低位开始,依次进行一次排序。这样从最低位排序一直到最高位排序完成以后, 数列就变成一个有序序列。

算法性能

时间复杂度 O(d(n+rd))O(d(n+rd))
空间复杂度(rd)(rd)

排序算法性能对比

五、图

图的基本概念

一个图(G)定义为一个偶对(V,E) ,记为G=(V,E) 。其中: V是顶点(Vertex)的非空有限集合,记为V(G);E是无序集V&V的一个子集,记为E(G) ,其元素是图的弧(Arc)。

1.图

图由结点的有穷集合V和边的集合E组成,为了与树形结构进行区别,在图结构中常常将结点称为顶点,边是顶点的有序偶对。若两个顶点之间存在一条边,则表示这两个顶点具有相邻关系。

2.有向图和无向图

有向图(Digraph): 若图G的关系集合E(G)中,顶点偶对<v,w>的v和w之间是有序的,称图G是有向图。

无向图(Undigraph): 若图G的关系集合E(G)中,顶点偶对<v,w>的v和w之间是无序的,称图G是无向图。

3.弧

在有向图中,若 <v,w>(G) ,表示从顶点v到顶点w有一条弧。 其中:v称为弧尾(tail)或始点(initial node),w称为弧头(head)或终点(terminal node) 。

4.顶点的度,入度和出度

无向图中,顶点 v 的度是指和 v 相关联的边的数目
有向图中,以顶点 v 为弧头的弧的数目称为顶点 v 的入度,以顶点 v 为弧尾的弧的数目称为顶点 v 的出度

5.有向完全图和无向完全图

有向图中每两个顶点之间都有两条方向相反的边连接的图称为有向完全图。弧数为 n(n -1)(结点为 n)
无向图中每一对不同顶点恰有一条边相连的图称为无向完全图。边数为n(n−1)2(结点数为 n)

6.路径和路径长度

从顶点 v 经过一系列的边或弧到达顶点 w ,则称这一系列的边或弧为顶点v 到顶点 w 的路径。路径长度是指路径上边的数目。

7.简单路径

序列中顶点不重复出现的路径称为简单路径

8.回路

若一条路径中第一个顶点和最后一个顶点相同,则这条路径是一条回路

9.连通,连通图和连通分量

在无向图中,如果从顶点 v 到顶点 w 有路径,则称顶点 v 和 顶点 w 是连通的。如果对于图中的任意两个顶点都是连同的,则称为连通图。无向图中的极大连通子图为其连通分量。

10.强连通图和强连通分量

在有向图中,如果对每一对顶点 v 、w 从v 和 从w到v都有路径,则称该有向图是强连通图。有向图中的极大强连通子图称为有向图的强连通分量。

11.权和网

图中每条边都可以附带一个数,这种与边相关的数称为权,权可以表示从一个顶点到另一个顶点的距离或者花费的代价。边上带权的图称为带权图。

图的储存结构

邻接矩阵

邻接矩阵是表示顶点之间相邻关系的矩阵,存储方式是用两个数组来表示图。一个一维数组存储图中顶点信息,一个二维数组(称为邻接矩阵)存储图中的边或弧的信息。

从上面可以看出,无向图的边数组是一个对称矩阵。所谓对称矩阵就是n阶矩阵的元满足aij = aji。即从矩阵的左上角到右下角的主对角线为轴,右上角的元和左下角相对应的元全都是相等的。
矩阵中“1”的个数为图中总边数的两倍,矩阵图中第i行和第i列元素之和即为顶点i的度
对于有向图,矩阵中“1”的个数即为图的边数,矩阵中第i行的元素之和即为顶点i的出度,第j列的元素之和即为顶点j的入度

有权有向图中,无边的话0变成无限大,1变成权值

邻接表

邻接矩阵是不错的一种图存储结构,但是,对于边数相对顶点较少的图,这种结构存在对存储空间的极大浪费。因此,找到一种数组与链表相结合的存储方法称为邻接表。
邻接表的处理方法是这样的:
(1)图中顶点用一个一维数组存储,当然,顶点也可以用单链表来存储,不过,数组可以较容易的读取顶点的信息,更加方便。
(2)图中每个顶点vi的所有邻接点构成一个线性表,由于邻接点的个数不定,所以,用单链表存储,无向图称为顶点vi的边表,有向图则称为顶点vi作为弧尾的出边表。
例如,下图就是一个无向图的邻接表的结构。

图的遍历

深度优先搜索遍历

图的深度优先搜索遍历(DFS)类似于二叉树的先序遍历。
基本思路:假设初始状态是图中所有顶点均未被访问,则从某个顶点v出发,首先访问该顶点,然后依次从它的各个未被访问的邻接点出发深度优先搜索遍历图,直至图中所有和v有路径相通的顶点都被访问到。 若此时尚有其他顶点未被访问到,则另选一个未被访问的顶点作起始点,重复上述过程,直至图中所有顶点都被访问到为止。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int visit[maxSize];
/* V是起点编号,visit[]是一个全局数组,作为顶点的访问标记,初始时所有元素均为0,
 * 表示所有顶点都未被访问,因为图中存在回路,当前经过的点在将来还有可能再次经过,
 * 所以要对每个顶点进行标记,以免重复访问,*/
void DFS(AGraph *G,int v){
    ArcNode *p;
    visit[v]=1;
    Visit(v);
    p = G->adjlist[v].firstarc;//p指向顶点v的第一条边
    while(p!=NULL){
        if(visit[p->adjvex]==0)
            DFS(G,p->adjvex);
            p=p->nextarc;
    }
}

无向图的深度优先搜索

下面以”无向图”为例,来对深度优先搜索进行演示。
对上面的图进行深度优先遍历,从顶点A开始。

因此访问顺序是:A -> C -> B -> D -> F -> G -> E

有向图的深度优先搜索

下面以”有向图”为例,来对深度优先搜索进行演示。
对上面的图进行深度优先遍历,从顶点A开始。

因此访问顺序是:A -> B -> C -> E -> D -> F -> G

广度优先搜索遍历

广度优先搜索算法(BFS)类似于树的层次遍历
基本思想:从图中某顶点v出发,在访问了v之后依次访问v的各个未曾访问过的邻接点,然后分别从这些邻接点出发依次访问它们的邻接点,并使得“先被访问的顶点的邻接点先于后被访问的顶点的邻接点被访问,直至图中所有已被访问的顶点的邻接点都被访问到。如果此时图中尚有顶点未被访问,则需要另选一个未曾被访问过的顶点作为新的起始点,重复上述过程,直至图中所有顶点都被访问到为止。
换句话说,广度优先搜索遍历图的过程是以v为起点,由近至远,依次访问和v有路径相通且路径长度为1,2…的顶点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
void BFS(AGraph *G, int v ,int visit[maxSize])
{
    ArcNode * p;
    int que[maxSize],front=0,rear=0;
    itn j;
    Visit(v);
    visit[v]=1;
    rear=(rear+1)%maxSize;
    que[rear]=v;
    while(front!=rear)
    {
        front=(front+1)%maxSize;
        j=que[front];
        p = G->adjlist[j].firstarc;
        while (p!=NULL){
            if (visit[p->adjvex]==0)
            {
                Visit(p->adjvex);
                visit[p->adjvex]=1;
                rear = (rear+1)%maxSize;
                que[rear] = p->adjvex;
            }
            p = p->nextarc;
        }
    }
}

无向图的广度优先搜索


因此访问顺序是:A -> C -> D -> F -> B -> G -> E

有向图的广度优先搜索


因此访问顺序是:A -> B -> C -> E -> F -> D -> G

最小(代价)生成树

最小生成树:在含有n个顶点的连通图中选择n-1条边,构成一颗极小连通子图,并使该连通子图中n-1条边上权值之和达到最小,则称其为连通网的最小生成树。

例如,对于如上图G4所示的连通网可以有多棵权值总和不相同的生成树。

普里姆(Prim)算法

普里姆(Prim)算法思想

是用来求加权连通图的最小生成树的算法。
基本思想
对于图G而言,V是所有顶点的集合;现在,设置两个新的集合U和T,其中U用于存放G的最小生成树中的顶点,T存放G的最小生成树中的边。 从所有uЄU,vЄ(V-U) (V-U表示出去U的所有顶点)的边中选取权值最小的边(u, v),将顶点v加入集合U中,将边(u, v)加入集合T中,如此不断重复,直到U=V为止,最小生成树构造完毕,这时集合T中包含了最小生成树中的所有边。

此时,最小生成树构造完成!它包括的顶点依次是:A B F E D C G。

基本定义代码

1
2
3
4
5
6
7
8
public class MatrixUDG {

    private char[] mVexs;       // 顶点集合
    private int[][] mMatrix;    // 邻接矩阵
    private static final int INF = Integer.MAX_VALUE;   // 最大值

    ...
}

MatrixUDG是邻接矩阵对应的结构体。mVexs用于保存顶点,mEdgNum用于保存边数,mMatrix则是用于保存矩阵信息的二维数组。例如,mMatrix[i][j]=1,则表示”顶点i(即mVexs[i])”和”顶点j(即mVexs[j])”是邻接点;mMatrix[i][j]=0,则表示它们不是邻接点。

普里姆算法代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
/*
 * prim最小生成树
 *
 * 参数说明:
 *   start -- 从图中的第start个元素开始,生成最小树
 */
public void prim(int start) {
    int num = mVexs.length;         // 顶点个数
    int index=0;                    // prim最小树的索引,即prims数组的索引
    char[] prims  = new char[num];  // prim最小树的结果数组
    int[] weights = new int[num];   // 顶点间边的权值

    // prim最小生成树中第一个数是"图中第start个顶点",因为是从start开始的。
    prims[index++] = mVexs[start];

    // 初始化"顶点的权值数组",
    // 将每个顶点的权值初始化为"第start个顶点"到"该顶点"的权值。
    for (int i = 0; i < num; i++ )
        weights[i] = mMatrix[start][i];
    // 将第start个顶点的权值初始化为0。
    // 可以理解为"第start个顶点到它自身的距离为0"。
    weights[start] = 0;

    for (int i = 0; i < num; i++) {
        // 由于从start开始的,因此不需要再对第start个顶点进行处理。
        if(start == i)
            continue;

        int j = 0;
        int k = 0;
        int min = INF;
        // 在未被加入到最小生成树的顶点中,找出权值最小的顶点。
        while (j < num) {
            // 若weights[j]=0,意味着"第j个节点已经被排序过"(或者说已经加入了最小生成树中)。
            if (weights[j] != 0 && weights[j] < min) {
                min = weights[j];
                k = j;
            }
            j++;
        }

        // 经过上面的处理后,在未被加入到最小生成树的顶点中,权值最小的顶点是第k个顶点。
        // 将第k个顶点加入到最小生成树的结果数组中
        prims[index++] = mVexs[k];
        // 将"第k个顶点的权值"标记为0,意味着第k个顶点已经排序过了(或者说已经加入了最小树结果中)。
        weights[k] = 0;
        // 当第k个顶点被加入到最小生成树的结果数组中之后,更新其它顶点的权值。
        for (j = 0 ; j < num; j++) {
            // 当第j个节点没有被处理,并且需要更新时才被更新。
            if (weights[j] != 0 && mMatrix[k][j] < weights[j])
                weights[j] = mMatrix[k][j];
        }
    }

    // 计算最小生成树的权值
    int sum = 0;
    for (int i = 1; i < index; i++) {
        int min = INF;
        // 获取prims[i]在mMatrix中的位置
        int n = getPosition(prims[i]);
        // 在vexs[0...i]中,找出到j的权值最小的顶点。
        for (int j = 0; j < i; j++) {
            int m = getPosition(prims[j]);
            if (mMatrix[m][n]<min)
                min = mMatrix[m][n];
        }
        sum += min;
    }
    // 打印最小生成树
    System.out.printf("PRIM(%c)=%d: ", mVexs[start], sum);
    for (int i = 0; i < index; i++)
        System.out.printf("%c ", prims[i]);
    System.out.printf("\n");
}

普里姆算法时间复杂度分析

观察代码发现,普里姆算法主要部分是一个双重循环,外层循环内有两个并列的单层循环,单层循环内的操作都是常量级别的,其执行次数为n的平方,因此普里姆算法的时间复杂度为O(n22)

克鲁斯卡尔(Kruskal)算法

克鲁斯卡尔算法思想

克鲁斯卡尔(Kruskal)算法,是用来求加权连通图的最小生成树的算法。
基本思想:按照权值从小到大的顺序选择n-1条边,并保证这n-1条边不构成回路。
具体做法:首先构造一个只含n个顶点的森林,然后依权值从小到大从连通网中选择边加入到森林中,并使森林中不产生回路,直至森林变成一棵树为止。

此时,最小生成树构造完成!它包括的边依次是:<E,F> <C,D> <D,E> <B,F> <E,G> <A,B>。

根据前面介绍的克鲁斯卡尔算法的基本思想和做法,我们能够了解到,克鲁斯卡尔算法重点需要解决的以下两个问题:
问题一 对图的所有边按照权值大小进行排序。
问题二 将边添加到最小生成树中时,怎么样判断是否形成了回路。

问题一很好解决,采用排序算法进行排序即可。

问题二,处理方式是:记录顶点在”最小生成树”中的终点,顶点的终点是”在最小生成树中与它连通的最大顶点”(关于这一点,后面会通过图片给出说明)。然后每次需要将一条边添加到最小生存树时,判断该边的两个顶点的终点是否重合,重合的话则会构成回路。 以下图来进行说明:

(01) C的终点是F。
(02) D的终点是F。
(03) E的终点是F。
(04) F的终点是F。
关于终点,就是将所有顶点按照从小到大的顺序排列好之后;某个顶点的终点就是”与它连通的最大顶点”。 因此,接下来,虽然<C,E>是权值最小的边。但是C和E的重点都是F,即它们的终点相同,因此,将<C,E>加入最小生成树的话,会形成回路。这就是判断回路的方式。

基本定义代码

1
2
3
4
5
6
7
8
9
10
11
12
// 边的结构体
private static class EData {
    char start; // 边的起点
    char end;   // 边的终点
    int weight; // 边的权重

    public EData(char start, char end, int weight) {
        this.start = start;
        this.end = end;
        this.weight = weight;
    }
};

EData是邻接矩阵边对应的结构体。

1
2
3
4
5
6
7
8
9
public class MatrixUDG {

    private int mEdgNum;        // 边的数量
    private char[] mVexs;       // 顶点集合
    private int[][] mMatrix;    // 邻接矩阵
    private static final int INF = Integer.MAX_VALUE;   // 最大值

    ...
}

 

MatrixUDG是邻接矩阵对应的结构体。mVexs用于保存顶点,mEdgNum用于保存边数,mMatrix则是用于保存矩阵信息的二维数组。例如,mMatrix[i][j]=1,则表示”顶点i(即mVexs[i])”和”顶点j(即mVexs[j])”是邻接点;mMatrix[i][j]=0,则表示它们不是邻接点。

克鲁斯卡尔算法代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
/*
 * 克鲁斯卡尔(Kruskal)最小生成树
 */
public void kruskal() {
    int index = 0;                      // rets数组的索引
    int[] vends = new int[mEdgNum];     // 用于保存"已有最小生成树"中每个顶点在该最小树中的终点。
    EData[] rets = new EData[mEdgNum];  // 结果数组,保存kruskal最小生成树的边
    EData[] edges;                      // 图对应的所有边

    // 获取"图中所有的边"
    edges = getEdges();
    // 将边按照"权"的大小进行排序(从小到大)
    sortEdges(edges, mEdgNum);

    for (int i=0; i<mEdgNum; i++) {
        int p1 = getPosition(edges[i].start);      // 获取第i条边的"起点"的序号
        int p2 = getPosition(edges[i].end);        // 获取第i条边的"终点"的序号

        int m = getEnd(vends, p1);                 // 获取p1在"已有的最小生成树"中的终点
        int n = getEnd(vends, p2);                 // 获取p2在"已有的最小生成树"中的终点
        // 如果m!=n,意味着"边i"与"已经添加到最小生成树中的顶点"没有形成环路
        if (m != n) {
            vends[m] = n;                       // 设置m在"已有的最小生成树"中的终点为n
            rets[index++] = edges[i];           // 保存结果
        }
    }

    // 统计并打印"kruskal最小生成树"的信息
    int length = 0;
    for (int i = 0; i < index; i++)
        length += rets[i].weight;
    System.out.printf("Kruskal=%d: ", length);
    for (int i = 0; i < index; i++)
        System.out.printf("(%c,%c) ", rets[i].start, rets[i].end);
    System.out.printf("\n");
}

克鲁斯卡尔算法时间复杂度分析

算法时间花费主要在函数sort()和单层循环上。循环是线性级的,可以认为算法时间主要花费在sort()上,因为排序算法时间复杂度一般大于常量级,因此,克鲁斯卡尔算法的时间复杂度主要由选取的排序算法决定,排序算法所处理的数据的规模由图的边e决定,与顶点无关,因此克鲁斯卡尔算法适用于稀疏图。

最短路径

迪杰斯特拉算法(Dijkstra)

迪杰斯特拉(Dijkstra)算法是典型最短路径算法,用于计算一个节点到其他节点的最短路径。
它的主要特点是以起始点为中心向外层层扩展(广度优先搜索思想),直到扩展到终点为止。

迪杰特斯拉算法基本思想

通过Dijkstra计算图G中的最短路径时,需要指定起点s(即从顶点s开始计算)。此外,引进两个集合S和U。S的作用是记录已求出最短路径的顶点(以及相应的最短路径长度),而U则是记录还未求出最短路径的顶点(以及该顶点到起点s的距离)。初始时,S中只有起点s;U中是除s之外的顶点,并且U中顶点的路径是”起点s到该顶点的路径”。然后,从U中找出路径最短的顶点,并将其加入到S中;接着,更新U中的顶点和顶点对应的路径。 然后,再从U中找出路径最短的顶点,并将其加入到S中;接着,更新U中的顶点和顶点对应的路径。 … 重复该操作,直到遍历完所有顶点。

操作步骤

(1) 初始时,S只包含起点s;U包含除s外的其他顶点,且U中顶点的距离为”起点s到该顶点的距离”[例如,U中顶点v的距离为(s,v)的长度,然后s和v不相邻,则v的距离为∞]。
(2) 从U中选出”距离最短的顶点k”,并将顶点k加入到S中;同时,从U中移除顶点k。
(3) 更新U中各个顶点到起点s的距离。之所以更新U中顶点的距离,是由于上一步中确定了k是求出最短路径的顶点,从而可以利用k来更新其它顶点的距离;例如,(s,v)的距离可能大于(s,k)+(k,v)的距离。
(4) 重复步骤(2)和(3),直到遍历完所有顶点。
单纯的看上面的理论可能比较难以理解,下面通过实例来对该算法进行说明。

基本定义代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 邻接矩阵
typedef struct _graph
{
    char vexs[MAX];       // 顶点集合
    int vexnum;           // 顶点数
    int edgnum;           // 边数
    int matrix[MAX][MAX]; // 邻接矩阵
}Graph, *PGraph;

// 边的结构体
typedef struct _EdgeData
{
    char start; // 边的起点
    char end;   // 边的终点
    int weight; // 边的权重
}EData;

Graph是邻接矩阵对应的结构体。
vexs用于保存顶点,vexnum是顶点数,edgnum是边数;matrix则是用于保存矩阵信息的二维数组。例如,matrix[i][j]=1,则表示”顶点i(即vexs[i])”和”顶点j(即vexs[j])”是邻接点;matrix[i][j]=0,则表示它们不是邻接点。
EData是邻接矩阵边对应的结构体。

迪杰斯特拉算法代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
/*
 * Dijkstra最短路径。
 * 即,统计图(G)中"顶点vs"到其它各个顶点的最短路径。
 *
 * 参数说明:
 *        G -- 图
 *       vs -- 起始顶点(start vertex)。即计算"顶点vs"到其它顶点的最短路径。
 *     prev -- 前驱顶点数组。即,prev[i]的值是"顶点vs"到"顶点i"的最短路径所经历的全部顶点中,位于"顶点i"之前的那个顶点。
 *     dist -- 长度数组。即,dist[i]是"顶点vs"到"顶点i"的最短路径的长度。
 */
void dijkstra(Graph G, int vs, int prev[], int dist[])
{
    int i,j,k;
    int min;
    int tmp;
    int flag[MAX];      // flag[i]=1表示"顶点vs"到"顶点i"的最短路径已成功获取。

    // 初始化
    for (i = 0; i < G.vexnum; i++)
    {
        flag[i] = 0;              // 顶点i的最短路径还没获取到。
        prev[i] = 0;              // 顶点i的前驱顶点为0。
        dist[i] = G.matrix[vs][i];// 顶点i的最短路径为"顶点vs"到"顶点i"的权。
    }

    // 对"顶点vs"自身进行初始化
    flag[vs] = 1;
    dist[vs] = 0;

    // 遍历G.vexnum-1次;每次找出一个顶点的最短路径。
    for (i = 1; i < G.vexnum; i++)
    {
        // 寻找当前最小的路径;
        // 即,在未获取最短路径的顶点中,找到离vs最近的顶点(k)。
        min = INF;
        for (j = 0; j < G.vexnum; j++)
        {
            if (flag[j]==0 && dist[j]<min)
            {
                min = dist[j];
                k = j;
            }
        }
        // 标记"顶点k"为已经获取到最短路径
        flag[k] = 1;

        // 修正当前最短路径和前驱顶点
        // 即,当已经"顶点k的最短路径"之后,更新"未获取最短路径的顶点的最短路径和前驱顶点"。
        for (j = 0; j < G.vexnum; j++)
        {
            tmp = (G.matrix[k][j]==INF ? INF : (min + G.matrix[k][j])); // 防止溢出
            if (flag[j] == 0 && (tmp  < dist[j]) )
            {
                dist[j] = tmp;
                prev[j] = k;
            }
        }
    }

    // 打印dijkstra最短路径的结果
    printf("dijkstra(%c): \n", G.vexs[vs]);
    for (i = 0; i < G.vexnum; i++)
        printf("  shortest(%c, %c)=%d\n", G.vexs[vs], G.vexs[i], dist[i]);
}

迪杰斯特拉算法时间复杂度分析

由算法代码可知,本算法主要部分为一个双重循环,外层循环内部有两个并列的单层循环,可以任取一个循环内的操作为基本操作,基本操作执行的总次数为n2n2 因此时间复杂度为O(n2)O(n2).

弗洛伊德(Floyd)算法

弗洛伊德算法是解决任意两点间的最短路径的一种算法,可以正确处理有向图或有向图或负权(但不可存在负权回路)的最短路径问题,同时也被用于计算有向图的传递闭包。

弗洛伊德算法思想

通过Floyd计算图G=(V,E)中各个顶点的最短路径时,需要引入两个矩阵,矩阵S中的元素a[i][j]表示顶点i(第i个顶点)到顶点j(第j个顶点)的距离。矩阵P中的元素b[i][j],表示顶点i到顶点j经过了b[i][j]记录的值所表示的顶点。

假设图G中顶点个数为N,则需要对矩阵D和矩阵P进行N次更新。初始时,矩阵D中顶点a[i][j]的距离为顶点i到顶点j的权值;如果i和j不相邻,则a[i][j]=∞,矩阵P的值为顶点b[i][j]的j的值。 接下来开始,对矩阵D进行N次更新。第1次更新时,如果”a[i][j]的距离” > “a[i][0]+a[0][j]”(a[i][0]+a[0][j]表示”i与j之间经过第1个顶点的距离”),则更新a[i][j]为”a[i][0]+a[0][j]”,更新b[i][j]=b[i][0]。 同理,第k次更新时,如果”a[i][j]的距离” > “a[i][k-1]+a[k-1][j]”,则更新a[i][j]为”a[i][k-1]+a[k-1][j]”,b[i][j]=b[i][k-1]。更新N次之后,操作完成!

弗洛伊德算法过程

1)设置两个矩阵A 和PATH,初始时将图的邻接矩阵赋值给A,将矩阵Path中元素全部设置为01.
2)以顶点K为中间顶点,k取0~n-1(n为图中顶点个数),对图中所有顶点对{i,j}进行如下检测:
如果A[i][j]>A[i][k]+A[k][j],则将A[i][j]改成A[i][k]+A[k][j]的值,将Path[i][j]改成K,否则什么也不做

弗洛伊德算法代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void Floyd(MGraph g,int Paht[][maxSize]){
    int i,j,k;
    int A[maxSize][maxSize];
    //这个双循环对数组A和Path进行了初始化
    for(i=0;i<g.n;++i){
        A[i][j]=g.edges[i][j];
        Path[i][j]=-1;
    }
    for (int k = 0; k < g.n; ++k) {
        for (int i = 0; i < g.n; ++i) {
            for (int j = 0; j < g.n; ++j) {
                if(A[i][j]>A[i][k]+A[k][j]){
                    A[j][j] = A[i][k]+A[k][j];
                    Paht[i][j]=k;
                }
            }
        }
    }
}

弗洛伊德算法时间复杂度分析

由算法可知,主要是有三层循环,基本操作执行次数为n3n3,所以时间复杂度为O(n3)O(n3)

拓扑排序

AOV网

活动在顶点上的网(Activity On Vertex network,AOV)是一种可以形象的反映出整个工程中各个活动之间的先后关系的有向图。

拓扑排序介绍

拓扑排序(Topological Order)是指,将一个有向无环图(Directed Acyclic Graph简称DAG)进行排序进而得到一个有序的线性序列。
这样说,可能理解起来比较抽象。下面通过简单的例子进行说明!
例如,一个项目包括A、B、C、D四个子部分来完成,并且A依赖于B和D,C依赖于D。现在要制定一个计划,写出A、B、C、D的执行顺序。这时,就可以利用到拓扑排序,它就是用来确定事物发生的顺序的。
在拓扑排序中,如果存在一条从顶点A到顶点B的路径,那么在排序结果中B出现在A的后面。

拓扑排序算法思路

  1. 构造一个队列Q(queue) 和 拓扑排序的结果队列T(topological);
  2. 把所有没有依赖顶点的节点放入Q;
  3. 当Q还有顶点的时候,执行下面步骤:
    3.1 从Q中取出一个顶点n(将n从Q中删掉),并放入T(将n加入到结果集中);
    3.2 对n每一个邻接点m(n是起点,m是终点);
    3.2.1 去掉边<n,m>;
    3.2.2 如果m没有依赖顶点,则把m放入Q;
    注:顶点A没有依赖顶点,是指不存在以A为终点的边。

    以上图为例,来对拓扑排序进行演示

第1步:将B和C加入到排序结果中。
顶点B和顶点C都是没有依赖顶点,因此将C和C加入到结果集T中。假设ABCDEFG按顺序存储,因此先访问B,再访问C。访问B之后,去掉边<B,A>和<B,D>,并将A和D加入到队列Q中。同样的,去掉边<C,F>和<C,G>,并将F和G加入到Q中。
(01) 将B加入到排序结果中,然后去掉边<B,A>和<B,D>;此时,由于A和D没有依赖顶点,因此并将A和D加入到队列Q中。
(02) 将C加入到排序结果中,然后去掉边<C,F>和<C,G>;此时,由于F有依赖顶点D,G有依赖顶点A,因此不对F和G进行处理。
第2步:将A,D依次加入到排序结果中。
第1步访问之后,A,D都是没有依赖顶点的,根据存储顺序,先访问A,然后访问D。访问之后,删除顶点A和顶点D的出边。
第3步:将E,F,G依次加入到排序结果中。

因此访问顺序是:B -> C -> A -> D -> E -> F -> G

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值