算法以及数据结构(上)

一、算法

1、算法定义

      所谓算法,就是在特定计算模型下,在信息处理过程中为了解决某一类问题而设计的一个指令序列。一个算法还必须具备以下要素:

      (1)输入:待处理的信息,即对问题的描述。

      (2)输出:经过处理后得到的信息,即问题的答案。

      (3)确定性:任一算法都可以描述为由若干种基本操作组成的序列,即处理该问题的基本操作步骤。

      (4)可行性:在相应的计算模型中,每一个基本操作都可以实现,且能够在常数时间内完成。

      (5)有穷性:对于任何输入,按照算法,经过有穷次基本操作都可以得到正确的输出。

2、算法性能

     衡量算法性能的两个方面:

      (1)时间复杂度及其度量,包括问题规模、运行时间及时间复杂度(冒泡排序举例,问题规模指数组大小,运行时间即为完成排序所需时间)、渐变复杂度、机器差异与基本操作

       时间复杂度:某一算法为了处理估摸为n的问题所需的时间记作T(n),那么随着问题规模n的增长,运行时间T(n)的增长方式称为算法的时间复杂度(渐进复杂度就是当n趋于无穷大时,T(n)取得的极限值),其分为:

      ①常数的时间复杂度:在一个子集中取非极端元素,不管子集有多大,都可以去集合的前三个元素,比较去中间元素,进行的算法操作完全一样,我们称之为常数的时间复杂度σ(1)

      ②对数的时间复杂度:对一个十进制转换成n进制,每次十进制都至少缩小为之前的1/n,所以经过以n为底,十进制数为真数的对数函数的值加1次循环,算法完成,我们称这类算法具有对数的时间复杂度σ(㏒n)

      ③线性的时间复杂度:对于求和,多少个元素就就行多少个算法步骤,我们称这类算法具有线性的时间复杂度σ(n)

      以上三种复杂度上升,算法的效率不断下降,但就实际应用而言这类算法的效率还在允许的范围内,但以下多项式时间复杂度和指数的时间复杂度,我们认为无法应用于实际问题之中,它们不是有效的算法,甚至不能称作算法

      ④平方时间的复杂度:冒泡排序即为平方时间复杂度,对于其它一些算法,n的次数可能更高,但只要其次数为常数,我们统称为多项式时间复杂度σ(幂函数)

      ⑤指数的时间复杂度:对于求2的n次幂的值,称为具有指数的时间复杂度(σ(指数函数))

      (2)空间复杂度,即算法所需使用的存储空间量。对于同样的输入规模,在时间复杂度相同的前提下,算法所占用的空间越少越好。

3、递归算法

      递归是高级程序设计语言的一个重要特征,它允许程序中的函数或过程自我调用。同时最后一次递归调用被称作递归的基底,简称递归基,所以所有递归算法必须要有递归基。
      (1) 线性递归:线性递归是最简单的递归形式,这类方法的每个实例只能递归地调用自己至多一次。
      尾递归是线性递归的一种形式,在线性递归算法中,若递归调用恰好出现在算法的最后一次操作,我们称之为尾递归(该方法最后一次执行是以递归调用结束)。 通常尾递归会改为迭代形式,因为利用递归可以编写出简洁而优美的算法,但代价是计算机需要使用更多的空间,花费额外的时间以跟踪递归的调用过程。
      (2)递归算法的复杂度
      ①递归跟踪法:所谓递归跟踪法,就是将递归方法的执行过程表示为图形的形式,如下图:
        ②递推方程法

       (3)二分递归:有的时候,算法需要将一个大问题分解为两个子问题,然后分别通过递归调用来求解,这种情况称作二分递归。

       (4)多分支递归:有的时候,一个问题可能需要分解为不止两个子问题,此时就需要采用多分支递归,这类递归的典型运用,就是在求解组合游戏问题时,枚举各种可能的排列。

二、数据结构之线性结构

        在各式各样的数据结构中,栈与队列是最简单,最基本的,但也绝对是最重要的,这两种基本数据结构的应用非常广泛,也是复杂数据结构的基础。

      1、栈

       栈是存放对象的特殊容器,在插入与删除对象时,这种结构遵循后进先出的原则,也就是说,对象可以任意插入栈中,但每次取出的都是此前插入的最后一个对象,插入和取出的操作分别称作入栈和退栈。
       栈ADT(抽象数据类型的缩写)作为一种抽象数据类型,必须支持以下方法:push(入栈)、pop(返回栈顶对象并移除)、getSize(栈大小)、isEmpty(栈是否为空)、top(返回栈顶对象但不移除),java中专门为栈结构建了一个类:java.util.Stack,任何Java对象都可以作为该内建类的栈元素。
       任一运行中的Java线程都会配备一个私有的栈,称作Java方法栈,用于记录各个方法在被调用过程中的局部变量等重要信息,这些被调用的方法实例的描述符,称作帧,比如方法N调用了方法M,则在M的这个实例所对应的帧中,记录了该实例的调用参数以及其中的局部变量,还有关于N的信息,以及在M结束时应该返回给N的东西,JVM还设置了一个称作程序计数器的变量PC,负责记录程序在JVM中运行到的当前位置,当方法N调用方法M时,程序计数器当前的数值就会被存放在N的实例所对应的帧中。

      2、队列

       队列也是对象的一种容器,在插入和删除对象遵循先进先出的原则,也就是说,每次删除的只能是最先插入的对象,因此,我们可以想象成将对象追加在队列的后端,而从其前段摘除对象。
       队列ADT也是抽象数据类型,作为一个容器,它需要支持以下方法:enqueue(将对象加入队列末)、dequeue(若队列非空,返回队列首对象并从队列移除)、getSize(队列大小)、isEmpty(队列是否为空)、front(若队列非空,返回首对象但不移除),Java中的队列通用接口为Queue。

      3、链表

       所谓链表,就是按线性次序排列的一组数据节点,每个节点都是一个对象,它通过一个引用element指向对应的数据元素,同时还通过一个引用next指向下一节点。
        (1)单链表链表的第一个节点和最后一个节点,分别称为链表的首节点和末节点,末节点的特征是其next引用为空,如此定义的链表,称作单链表。
       ①首节点的插入和删除:我们只需要通过首节点的引用找到首对象,进行操作(插入:创建新的节点,设置节点next为当前首节点,将队列的head引用指向新节点,删除:通过head引用找到首节点,复制next指向对象,设置next为空,head引用指向复制的引用, 时间复杂度都是常数的时间复杂度
       ②末节点的插入和删除:对于插入,我们通过末节点的引用找到末对象,创建新的对象,设置末节点对象的next为新对象,将末节点引用指向新对象,是 常数的时间复杂度
对于删除,我们通过末节点的引用找到莫对象,需要从链表的首节点逐个遍历找到next指向末节点对象的对象,设置其next为空,删除原末节点对象,将末节点引用指向新末节点对象,是 线性的时间复杂度因为栈的操作仅限于栈顶元素,所以基于链表结构实现栈结构要把首节点作为栈顶。

      4、位置

       抽象出位置这一概念,使我们即能够保持链表结构的高效性,而又不致违背面向对象的设计原则。
       所谓位置ADT,就是支持以下方法的数据类型:getElem(返回存放于该位置的元素)、setElem(将元素存放于当前位置并返回此处原先存放的元素)。在列表中,各个位置都是相对而言的,不管该位置内的元素如何别替换或者互换,位置都不会改变。

      5、双端队列

        双端队列简称Deque[dek],顾名思义,就是前段和后端都支持插入和删除操作的队列。
       相比于栈和队列,双端队列的抽象数据类型要复杂很多,基本方法如下:
       ①insertFirst(将对象作为首元素插入)   ②insertLast(将对象作为末元素插入)  ③removeFirst(若队列非空,删除首元素并返回)  ④removeLast(若队列非空,删除末元素并返回)  ⑤first(若队列非空,返回首元素)  ⑥last(若队列非空,返回末元素)  ⑦getSize(队列大小)  ⑧isEmpty(队列是否为空)

 三、序列

       所谓序列(ADT),就是依次排列的多个对象,就是一组对象之间的后续和前驱关系,在实际问题中, 序列可以用来实现很多种数据结构,因此被认为是数据结构设计的基础。

      1、向量ADT

       假定集合 S 由 n 个元素组成,它们按照线性次序存放,于是我们就可以直接访问其中的第一个元素、第二个元素、第三个元素......。也就是说,通过[0, n-1]之间的每一个整数,都可以直接访问到唯一的元素 e,而这个整数就等于 S 中位于 e 之前的元素个数⎯⎯在此,我们称之为该元素的秩(Rank)。不难看出,若元素 e 的秩为 r,则只要 e 的直接前驱(或直接后继)存在,其秩就是 r-1(或 r+1)。这一定义与 Java、C++之类的程序语言中关于数组元素的编号规则是一致的,支持通过秩直接访问其中元素的序列,称作向量(Vector)或数组列表(Array List)。 
      利用数组实现向量或者数组列表,一个很大的缺陷是数组容量固定,在向量规模小时预留过多空间浪费,在向量规模大时可能过超过数组容量,解决方法是 采用可扩充数组策略,当规模大于数组容量时创建新的二倍于之前数组大小的数组,将之前数组元素全部插入新的数组,用新的数组代替之前的数组。与简单的数组实现相比,基于可扩充数组的实现可以更高效的利用存储空间,因为在扩充数组时需要将原数组的内容复制到新数组,所以需要线性的时间复杂度的时间,但扩容不是每次都执行,所以我们引入分摊复杂度的概念, 所谓分摊运行时间就是指在连续执行的足够多次操作中,每次操作所需的平均运行时间,分摊时间与平均时间有本质的区别,一个算法的平均运行时间,指的是对于出现概率符合某种分布的所有输入,算法所需运行时间的平均值,因此也称作期望运行时间(Expected running time)。而这里的分摊运行时间,指的是在反复使用某一算法及其数据结构的过程中,连续的足够多次运行所需时间的平均值。通过分摊运行时间可以得出结论,基于可扩充数组实现的向量,每次数组扩容的分摊运行时间是常数的时间复杂度。 Java中的java.util.ArrayList和java.util.Vector类就是基于可扩充数组实现的向量结构。

      2、列表ADT

       向量ADT是基于秩的实现(基于数组实现的序列),而列表ADT是基于节点的实现(基于链表节点实现的序列)。
        对于一个链表,我们如果使用秩的概念,对链表的访问会很慢,因为为了确定链表结构中特定元素的秩,我们需要顺着元素间的next或者prev引用逐一扫描各个元素,这需要线性的时间,因此我们在对基于链表实现的序列ADT进行操作时,我们直接以链表节点作为参数,找到所需节点并对其实施操作,这样可以在常数时间内完成。

      3、序列ADT

       序列ADT是向量ADT和列表ADT的继承

      4、迭代器ADT

四、数据结构之树结构

        上述的 数据结构,根据其实现方式,可以划分为两种类型:基于数组的实现与基于链表的实现,基于数组实现的结构允许我们通过秩在常数时间内找到目标对象进行改查操作,但一旦进行增删操作,就需要耗费线性的时间,而基于链表实现的结构允许我们借用引用或者位置对象,在常数时间内增删对象,但进行改查操作则需要耗费线性时间。当这两种线性就够都存在明显缺陷后,我们引入了树结构,树结构中的元素不存在天然的直接后继和直接前驱关系,因此属于非线性结构,同时,如果附加上某种约束(比如遍历),也可以在树结构中的元素之间确定某种线性关系,因此也可称之为半线性结构, 树结构是一种分层结构

       1、树ADT

        作为一种抽象数据类型,树可以用来对一组元素进行层次化的组织,树中的元素也称作节点,同时每个节点都被赋予了特殊的指标—深度,基座depth(b).树结构的特点如下:
        (1)每个节点的深度都是一个非负整数
        (2)深度为0的节点有且仅有一个,称作 树根(Root)
        (3)对于深度为k(k>=1)的每个节点,都有且仅有一个深度为k-1的节点与之对应,称为父亲或者父节点
        (4)若节点v是节点u的父亲,则u称作v的孩子,并在二者之间建立一条数边。尽管每个节点至多有只有一个父亲,但却可能有多个孩子,同一节点的孩子互称兄弟。
        (5) 树中所有节点的最大深度,称作 树的深度或高度。树中节点的数目,总是等于边数加一。
        (6) 任一节点的孩子数目,称作它的 "度"。请注意:节点的父亲不计入度数。
        (7)至少拥有一个孩子的节点称作 内部节点,没有任何孩子的节点称作 外部节点或者叶子,换言之,当且仅当一个节点的度数为零,则为外部节点。
        (8)由树中k+1节点通过树边首尾衔接而构成的序列{ (v0, v1), (v1, v2), ..., (vk-1, vk) | k ≥ 0}称作树中长度为k的一条 路径(Path)。
 
        (9) 树中任何两个节点之间都存在唯一的一条路径。推论: 从树根通往任意节点的路径长度,恰好等于该节点的深度
        (10)每个节点都是自己的祖先,也是自己的后代,若v是u的父节点,则v也是u的祖先,若u的父节点的v的后代,则u也是v的后代。
        (11)除节点本身以外的祖先(后代),称作真祖先(后代)。
        (12)任一节点的深度,等于其真祖先的数目, 任一节点的祖先,在每一个深度上最多有一个
        (13)树中每一个节点的所有后代也构成一棵树,称作当前树的 以该节点为根的子树。注意:空节点(null)本身也构成一棵树,称作空树,空树虽然不含任何节点,但却是任何数的子树。
        (14) 以某节点为根的子树的深度(高度),称为该 节点的高度。根节点的高度就是整棵树的深度。
        (15)在树结构中,若两个节点都是同一个节点的后代,则称该节点是两个节点的共同祖先。 根节点是所有节点的共同祖先,每一对节点至少存在一个共同祖先,同时一对节点可以有多个共同祖先
        (16)在一对节点的所用共同祖先中, 深度最大者称为它们的最低共同祖先,每一对节点的最低共同祖先必存在且唯一
        (17)在树结构中,若每个节点的所有孩子之间可以定义某一线性次序,则称之为 有序树,即我们可以明确每个节点的每个孩子的位置。
        (18)每个内部节点均为m(m未知)度的有序树,称作 m叉树
        (19)每个内部节点均不超过2度的有序树,称作 二叉树。二叉树是最简单的非平凡m叉树,在二叉树中,每个节点的孩子可以用左右区分,分别称作左孩子和右孩子,如果左右孩子同时存在,则左孩子的次序优先于右孩子。
        (20)不含1度节点的二叉树,称作 真二叉树,否则称作非真二叉树。即树中每个节点都是二度,称作真二叉树。
        (21)在二叉树中,深度为k的节点不超过 2 k 个,高度为h的二叉树最多包含2 h+1-1 个节点,由n个节点构成的二叉树,高度至少为⎣log 2n⎦。
        (22) 在二叉树中,叶子总是比二度节点多一个。
        (23)若真二叉树中所有叶子的深度完全相同,则称之为 满二叉树。高度为h的二叉树是满的,当且仅当它拥有2 h匹叶子、2 h+1-1 个二度节点。
        (24)在一个满二叉树中,从最右侧起将相邻的若干匹叶子节点摘除掉,则得到的二叉树称作完全二叉树。由n个节点构成的完全二叉树,高度h=⎣log 2n⎦。  
        (25)在由固定数目的节点所组成的所有二叉树中,完全二叉树的高度最低。

       2、树ADT的实现

        树ADT中,每个节点的所有后代均构成一颗子树,故从数据类型的角度来看,树、子树以及树节点都是等同的,我们采用" 父亲-长子-弟弟"的模型来定义树,树中每个节点都记录自己的父亲、长子以及最大弟弟。
        树结构的基本算法:
        (1)获取(子)树的规模:一棵树的规模,等于根节点下所有子树规模之和再加一,也等于根节点的后代总数(节点的后代包括节点本身)。
        (2)计算节点高度:若u是v的孩子,则height(v)>=height(u)+1,可通过节点长子,并沿着最大弟弟顺次找出其余的孩子,递归计算出各子树的高度,最后找出最大高度再计入根节点本身,就得到了根节点的高度。
        (3)计算节点的深度:若u是v的孩子,则depth(u)=depth(v)+1。

       3、树遍历算法

        所谓树的遍历,就是按照某种次序访问树中的节点,且每个节点恰好访问一次,也就是说,按照被访问的次序,可以得到由树中所有节点排成的一次序列。
        (1) 前序遍历:对任一(子)树的前序遍历,将首先访问其跟节点,然后再递归地对其下的各棵子树进行前序遍历,对于同一根节点下的各棵子树,遍历的次序通常是任意的,但若换成有序树,则可以按照兄弟间响应的次序对它们实施遍历,由前序遍历生成的节点序列,称作 前序遍历序列

        (2) 后序遍历:对任一(子)树的后序遍历将首先递归地对根节点下的各棵子树进行后续遍历,最后才访问根节点,由后序遍历生成的节点序列,称作后序遍历序列。
        (3) 中序遍历:中序遍历是对于二叉树定义的遍历方法,在访问每个节点之前,首先遍历其左子树,待该节点被访问过后,才遍历其右子树,由中序遍历确定的节点序列,称作中序遍历序列。

        中序遍历中,直接前驱、直接后继的定位算法:在二叉树中,除中序遍历序列中的首节点外,任一节点v的直接前驱u不外乎三种情况:
               ①v没有左孩子,同时v是右孩子,此时u是v的父亲节点。
               ②v没有左孩子,同时v是左孩子,此时,从v出发沿parent引用逆行向上,直到第一个是右孩子的节点w,则u就是w的父亲节点。
               ③v有左孩子,此时,从v的左孩子出发,沿右孩子引用不断下行,最后一个(没有右孩子的)节点就是u。
        任一节点v的直接后继u的情况如下:
               ①v没有右孩子,同时v是左孩子,此时u是v的父亲节点。
               ②v没有右孩子,同时v是右孩子,此时,从v出发沿parent引用逆行向上,知道第一个是左孩子的节点w,则u就是w的父亲节点。
               ③v有右孩子,此时从v的右孩子出发,沿左孩子引用不断下行,最后一个(没有左孩子的)节点就是u。
        (4) 层次遍历:除了上述两种最常见的遍历算法,层次遍历也是一种,在这种遍历算法中,各节点被访问的次序取决于它们各自的深度,其策略可以总结为"深度小的节点优先访问",对于同一深度的节点,访问的次序可以是随机的,通常取决于它们的存储次序,即首先访问长子,则访问最大弟弟,一次进行。
        
public class IteratorTree implements Iterator {
private List list;//列表
private Position nextPosition;//当前(下一个)元素的位置
//默认构造方法
public IteratorTree() { list = null; }
//前序遍历
public void elementsPreorderIterator(TreeLinkedList T) {
if (null == T) return;//递归基 list.insertLast(T);//首先输出当前节点
TreeLinkedList subtree = T.getFirstChild();//从当前节点的长子开始 while (null != subtree) {//依次对当前节点的各个孩子
this.elementsPreorderIterator(subtree);//做前序遍历 subtree = subtree.getNextSibling();
} }
//后序遍历
public void elementsPostorderIterator(TreeLinkedList T) {
if (null == T) return;//递归基
TreeLinkedList subtree = T.getFirstChild();//从当前节点的长子开始 while (null != subtree) {//依次对当前节点的各个孩子
this.elementsPostorderIterator(subtree);//做后序遍历 subtree = subtree.getNextSibling();
}
list.insertLast(T);//当所有后代都访问过后,最后才访问当前节点 }
//层次遍历
public void levelTraversalIterator(TreeLinkedList T) { if (null == T) return;
Queue_List Q = new Queue_List();//空队
Q.enqueue(T);//根节点入队
while (!Q.isEmpty()) {//在队列重新变空之前
TreeLinkedList tree = (TreeLinkedList) (Q.dequeue());//取出队列首节点 list.insertLast(tree);//将新出队的节点接入迭代器中
TreeLinkedList subtree = tree.getFirstChild();//从tree的第一个孩子起 while (null != subtree) {//依次找出所有孩子,并
Q.enqueue(subtree);//将其加至队列中 subtree = subtree.getNextSibling();
}
}
}
//检查迭代器中是否还有剩余的元素
public boolean hasNext() { return (null != nextPosition); }
//返回迭代器中的下一元素
public Object getNext() throws ExceptionNoSuchElement {
if (!hasNext()) throw new ExceptionNoSuchElement("No next position"); Position currentPosition = nextPosition;
if (currentPosition == list.last())//若已到达尾元素,则
nextPosition = null;//不再有下一元素 else//否则
nextPosition = list.getNext(currentPosition);//转向下一元素 return currentPosition.getElem();
}}

      4、完全二叉树的Java实现

       不难发现,只要给定规模n,完全二叉树的就够就已完全确定,因此我们可以从0开始到n-1按照层次遍历的次序对各个节点进行编号,这种基于向量的实现,是线性的时间复杂度,若将节点v的这种编号记做i(v),则根节点的编号i(root)=0,i(lchild(root))=1,i(rchild(root))=2...则可通过如下定理确定父子关系:
       (1)若节点v有左孩子,则i(lchild(v))=2xi(v)+1。
       (2)若节点v有右孩子,则i(rchild(v))=2xi(v)+2。
       (3)若节点v有父节点,则i(parent(v))=(i(v)-1)/2(v为左节点)=i(v)/2-1(v为右节点)。

五、数据结构之优先队列结构

       存放数据只是数据结构的基本功能之一,数据结构的另一方面的典型用途就是按照次序将数据组织起来,优先队列结构在很多应用领域都有很大的用途,比如各种事件队列的模拟,操作系统中多任务的调度及中断机制、采用词频调整策略的输入法等,另外优先队列也是很多高级算法的基础,比如Huffman编码、堆排序算法等,在采用空间扫描策略的算法中,优先队列是组织事件队列的最佳形式。优先队列之所以具有广泛的用途,是得益于其高效率以及实现的简捷性。

      1、相关名词

       (1) 关键码:优先队列结构中各对象之间的次序是由它们共同的某个特征、属性或指标决定的,我们称之为关键码(key),关键码本身也是一个对象, 作为优先队列结构的一个基本要求,在关键码之间必须能够定义某种全序关系,具体来说,任何两个关键码都必须能够比较大小。
       (2) 条目:所谓一个条目,就是由一个对象及其关键码合成的一个对象,它反映和记录了二者之间的关联关系,通过将条目对象作为优先队列的元素,即可记录和维护原先对象及其关键码之间的关联关系。
       (3) 比较器:我们可以通过实现某个接口实现一个关键码类,并将所有通常的比较方法封装起来,以支持关键码之间的比较,采用这一策略,我们只需编写一个队列类即可处理各种类型的关键码,然而按照这一策略,关键码的比较方式完全取决于关键码本身的类型,而在很多情况下这并不能最终决定关键码的具体比较方式,因此我们可以 基于这个借口实现一个独立于关键码之外的比较器类,由它来确定具体的比较规则,在创建每个优先队列时,只要指定这样一个比较器对象,即可按照该比较器确定的规则,在此后进行关键码的比较,这一策略的另一个优点在于,一旦不想继续使用原先的比较器对象,可以随时用另一个比较器对象将其替换掉,而不用重写优先队列本身。

      2、优先队列ADT实现

       我们可以基于向量实现,若用无序的向量实现优先队列,我们是将未通过比较器排序的条目直接存放,需要耗费常数的时间复杂度,但在我们获取最大优先条目时,我们需要逐一检查每个向量对象中的条目并做比较,这需要线性的时间复杂度,而如果采用有序的向量,即先通过比较器确定条目顺序进行向量存储,那么我们在获取最大优先条目时需要常数的时间复杂度,但在存储时则需要线性的时间复杂度。
       同样的,我们也可以基于列表实现优先队列,基于无序列表和有序列表,时间复杂度同基于向量实现相同。

六、数据结构之堆(Heap)结构

        堆结构是实现优先队列最高效的方式,堆的高效,在于它 放弃了列表结构而转向树形结构,基于向量和列表实现的优先队列,之所以效率不高,原因在于时刻保持了整个集合的全序关系,所有的元素都是按照全序排列的,但我们只需要知道最小条目(优先级最大)即可,堆结构就是利用了优先序列这一特点,在任何时候 只保存了整个集合的偏序关系,从而这一结构在时间复杂度方面有了实质性的改进。

      1、堆结构定义

        堆结构就是满足一下两条性质的二叉树
       (1)结构性:Heap中各元素的联结关系应符合二叉树的结构要求(其根节点称作堆顶)
       (2)堆序性:就其关键码而言,除堆顶外的任何条目都不小于其父亲。
       (3)完全性:二叉树的搜索效率在很大程度上取决于树的高度,为了降低堆的高度以提高操作的效率,所以要求堆必须是一颗完全二叉树。
       由此可见堆结构中,最小条目必处于堆顶。实际上,上述定义可以推广至 m叉树形结构,相应的堆结构称作m叉堆,而如上定义的堆也称作二叉堆

      2、堆结构算法

       实现堆结构的完全二叉树基于向量实现,以此进行堆结构的算法计算:
       (1) 插入与上滤
       通过基于向量实现的完全二叉树的addLast方法,直接将条目作为末尾节点插入Heap中,除非Heap原先是空的,否则新插入的条目必定有一个父亲,就该条目与其父亲节点所保存的元素的关键码进行比较,如果该条目的关键码大,则没有破坏Heap的堆序性,如果小,则只需要更换节点内保存的元素,同时要继续与新的父节点进行比较,如有必要,重复交换操作,最终堆序性必将恢复。新插入的节点通过与父亲交换不断向上移动的这一过程,称作 上滤,若利用向量实现完全二叉树,则二叉树的插入操作可以在对数的时间复杂度内完成。
       (2) 删除与下滤
       当我们将堆顶的最小元素取出,堆结构将不再完整,不再满足结构性,为了恢复结构性,最简单的办法是将最末尾的节点移至堆顶位置,然而,除非此时堆中只剩下单个节点,否则移至堆顶的节点必然拥有后代,一般而言它与其后代将不满足堆序性,我们可以挑选两个孩子中的更小者将其交换,交换后若仍违背堆序性,则重复上述操作直到堆序性恢复。节点的高度逐层下降,我们称之为 下滤,二叉堆的删除操作也可以在对数的时间复杂度内完成。
       (3)建堆
       ①蛮力算法:从空堆开始依次插入,或者将所有的条目按照关键码进行全序排序然后加入二叉树,这两种方式都能成功建堆,但缺点是需要花费很多的时间。
       ②Robert Floyd算法:我们将蛮力建堆策略的处理方向与次序颠倒过来,首先将所有节点存储为一颗完全二叉树,从而满足结构性和完整性,为了恢复堆序性,可以从下而上对各个节点实施下滤操作。    

      3、Huffman树

       二叉编码树:我们将"父亲-左孩子"关系对应二进制位"0","父亲-右孩子"对应二进制位"1",令每个字符对应一匹叶子,则从根节点通往每匹叶子的路径,就对应于相应字符的二进制编码,这样一棵树也称作 二叉编码树。解码时,我们根据接收到的信息流,从根节点依次扫描直到对应有字符串的叶子节点,读出对应字符,然后继续从根节点出发进行扫描,全部读取完成获得原始字符串。

七、数据结构之映射(Map)和词典(Dictionary)结构

       与优先队列结构一样,映射和词典中存放的元素也是一组由关键码和数据组成的条目,映射要求不同条目的关键码互异,而词典则允许多个条目拥有相同的关键码。

      1、映射

       映射(Map)也是一种存放一组条目的容器,与优先队列一样,映射中的条目也是含有key和value(关键码和数据对象),需要注意的是,映射中的关键码不允许重复,因为映射结构必须能够比较任意一对关键码是否相等, Java中的Map内建了判等方法equals,这种办法通用性不好,因为一旦默认的equals方法对map中存放的对象不适用,我们就需要去修改关键码类本身,这样有悖于面向对象的封装原则,同时这种方法的灵活性也欠佳,即使是同一类对象,在不同场合的判等标准也可能不同
       (1) 散列表
       如果基于列表实现映射结构,那么不论是增删改查,都需要扫描整个列表,是线性的时间复杂度,效率不高,如果我们将条目的关键码视作其在映射中的存放位置,则可以散列表的形式来实现映射结构,散列表由两个要素构成:桶数组与散列函数。
       ① 桶数组:其实就是一个容量为N的普通数组,在这里,我们将其中的每一个单元都想象为一个"桶",每个桶单元里都可以存放一个条目。
       ② 散列函数:桶中的条目,如果关键码是整数,可以直接放到以关键码为角标的桶数组中的位置,但是,首先我们无法确定数组的最佳容量,会造成空间的巨大浪费,其次,我们不可能保证所有的关键码都是整数,所以,我们需要一个函数, 将任意关键码转换为介于0与N-1之间的整数,这个函数就是所谓的散列函数,同时这个转换后的整数也被称作其对应条目的散列地址。
       对于不同的关键码,必须要有不同的散列地址,如果不同关键码的散列地址相同,我们就说散列发生了冲突。一个好的散列函数,必须要兼顾以下两个基本条件:必须尽可能的单射(即不同关键码转换成不同的散列地址),另外,散列地址的计算必须在常数的时间复杂度内完成。
       Java关于散列函数的计算,有其特有的习惯,首先将一般性的关键码转换成一个称作散列码的整数,然后在通过所谓的"压缩函数"将该整数映射至相应的整数区间。Java可以帮助我们将任意类型的关键码key转换为一个整数,称作key的散列码(Hash code),Java通用类Object提供了默认的散列码转换方法hashCode(),利用它可以将任意对象实例映射为"代表“该对象的某个整数,具体来说,hashCode()方法的返回值是一个32bit位int型整数,实际上,这个方法返回的不过就是对象在内存中的存储地址,但该方法也存在着严重的缺陷,比如对于字符串类型的关键码,对于基本数据类型,内存地址相同,作为对象,两个完全相同的字符串对象,内存地址不同,所以本应转换成同一散列码却转换成了不同的散列码,而实际String类也对hashCode方法进行了改写。而压缩函数,就是将32位的int型整数压缩至我们希望的区间内,通常采用的压缩方法有: 模余法和MAD法,模余法是最简单的压缩办法,就是取一个素数,然后取模(之所以选取素数,是为了最大程度地将散列码均匀的映射至指定区间内),MAD法是一种将乘法、加法、除法结合起来的方法,通过对散列码i进行|a*i+b| mod N的处理,其中N仍为素数,a>0,b>0,a mod N≠0,它们都是在确定压缩函数时随机选取的常数。
       (2)散列表的基本思想,是采用一个桶数组,借助一个散列函数得到桶单元编号,从而快速地完成访问和修改,然而遗憾的是很难保证不同关键码所对应的桶编号不致冲突。
解决冲突的方法有:
       ① 分离链:解决冲突最直接了当的方法,将所有相互冲突的条目组成一个映射结构,存放在他们共同对应的桶单元中,也就是说该桶单元对应映射结构。
       ② 冲突池:在散列表之外另设一个映射结构,一旦发生冲突,就将冲突的条目放入该映射结构中,从效果上看,这相当于将所有冲突的条目存入一缓冲池,该方法也因此得名。
       ③ 开放定址:分离链策略可以非常便捷地实现映射结构的各种操作算法,但是就数据结构本身而论,这一策略需要借助列表作为附加结构,将相互冲突的条目分组存放,这不仅会增加代码出错的可能,而且也需要占用更多的空间,开放定址是一种不借助附加结构解决散列冲突的策略,这一策略可以导出一系列的变型,比如线性探测法、平方探测法以及双散列法等。
       ④ 线性探测:采用开放定址策略,最简单的一种形式就是线性探测法,即当发现桶单元被占用,转而尝试该桶单元后面的一个桶单元,若仍被占用,继续尝试,直到发现一个可以利用的桶单元。若条目在长度为N的散列表A中被存放于位置i,则i+1 mod N,i+2 mod N ...位置的桶单元称作该条目的查找前驱桶单元(即i位置存在桶单元,i+1、i+2...直到该条目存放位置之前的单元)。使用线性探测策略,需要满足查找前驱桶单元均非空的条件,因为当我们删除一个桶单元时,可能会使该桶单元之后的某个桶单元的查找前驱桶单元为空从而查找失败,解决办法一是将删除位置后的桶单元依次前移,这种方法增加了remove操作的时间复杂度,另一种方法是将删除位置的空桶做特殊的标记,查找时遇到该标记继续后继查找,插入时记录最靠前的带标记的空桶,如果之后没有可插入位置,则将新条目放入其中。
       线性探测法可以节约空间,但是各操作要复杂的多, 最大的缺陷是基于这一策略的散列表往往会存在大量的条目堆积,因为不能使用附加空间,每次解决冲突就会占用空桶,会使发生冲突的可能性随之增加,克服这一缺点的有效方法是平方探测法
       ⑤ 平方探测平方探测法是对线性探测法的改进,它是通过对i,i+1,i+2²,i+3²...位置进行探测,知道防线空桶,这个策略很好的解决了条目堆积的问题,随着冲突次数的增加,其探测的步长将以线性的速度增长,而不是线性探测法的固定步长1,因为一旦发生冲突,这一方法可以使待掺入条目快速跳离条目聚集的区域。
       平方探测法的缺陷是回出现二阶聚集现象,条目虽然不会连续聚集成片,但是会在多个位置多次反弹,同时如果散列表容量不是素数,则可能出现循环反弹,以至于无法插入的情况,即使N为素数,也可能出现即使有空桶,也找不到插入位置。
       ⑥双散列:双散列也是克服条目堆积现象的一种有效方法。
        综上,分离链策略算法简单,但耗费更多的空间,开放定址策略正好相反,可以尽可能的节省空间,但是算法需做复杂的调整,分离链的时间效率要远远高于其他方法,因此,除非在存储你空间非常紧张的场合,我们都建议采用分离链策略解决冲突。

      2、词典

       词典结构也是用来存放条目对象的一种容器,它对其中条目的类型没有限制,词典和映射之间一个非常重要的差别是词典不再要求其中各条目的关键码互异,我们往往将词典中的条目直接称作词条。词典分为两大类:有序词典和无序词典,前一种词典所存放的条目之间定义了某种全序关系,因此也相应的支持first()、last()、prev()和succ()之类的方法,而后一种词典存放的条目无所谓次序,我们只能利用某一判等器比较一对条目的关键码是否相等。

八、查找树

      1、二分查找树

       所谓一颗二分查找树,要么是一颗空树,要么是以某个条目为根节点的二叉树,而且其左、右子树都是二分查找树,同时,在其左子树中,所有节点的关键码均不大于根节点的关键码,在其右子树中,所有节点的关键码均不小于根节点的关键码, 二叉树为二分查找树,当且仅当其中序遍历序列是单调非降的(可能存在相同的关键码)。 二分查找树查找算法的构思是:从根节点开始,以递归的形式不断缩小查找范围,知道发现目标条目(查找成功)或查找范围缩小至空树(查找失败)。这里定义的二分查找树允许多个几点拥有相等的关键码。在二分查找中,获取到的节点,是这些相同关键码的节点中深度最小者(深度最小者必然唯一)。
       插入算法:通过要插入的节点的关键码确定插入的位置和方向,在二分查找树中插入一个节点需要线性的时间复杂度。
       ② 删除算法首先冲入查找算法判断树中是否有该节点,如果有确定位置,如果该节点有左子树,则在左子树中找到其直接前驱,将其交换位置,然后删除该节点,如果该节点没有左子树,那么直接删除该节点,并用其右节点取代该几点的位置

      2、平衡二分查找树

       二分查找树在最后的情况下,会退化为链表,即对一个有序条目组组成的二叉查找树即为链表,此时查找效率会聚降,在节点数目固定的前提下,二分查找树的高度越低越好,因此,尽可能使二叉查找树平衡才能达到更高的效率。

      3、等价二分查找树

       中序遍历序列相同的任意两颗二叉树,称作相互等价的,即由n个节点组成的任意一颗二分查找树,都与某一颗高度不超过log 2n 的二分查找树等价,所以,每一颗二分查找树都与某一颗平衡二分查找树相互等价,由此,为了提高效率,我们可以通过等价二分查找树的定义,将普通的二分查找树向平衡二分查找树转换,我们需要若干种重平衡策略。
       ①zig旋转:假设节点v是节点p的左孩子,x和y分别是v的左、右子树,z为p的右子树,所谓围绕节点p的zig旋转操作,就是重新调整这些节点的位置,将p作为v的右孩子,将x作为v的左孩子,将y和z分别作为p的左、右子树。
     
        ②zag旋转:假定节点v是节点p的右孩子,z和y分别是v的右、左子树,x为p的左子树,所谓围绕节点p的zag旋转操作,就是将p作为v的左孩子,将z作为v的右子树,将y和x分别作为p的右、左子树。
        zig和zag旋转操作都可以在常数时间内完成。

      4、AVL树

       在二分查找树中,任一节点v的平衡因子都定义为其左右子树的高度差, 空树的高度差定义为-1,根据平衡因子,我们可以定义一种特殊的二分查找树, 在二分查找树中,若所有节点的平衡因子的绝对值均不超过1,则称之为一颗AVL树。AVL树的该特性在任意局部都满足,所以AVL树的任一子树也必是AVL树,同时,完全二叉树节点的平衡因为非0即1,固完全二叉树必是AVL树,但反之不然。
       与一般的二分查找树一样,AVL树也不是静态的,也应支持插入、删除等动态修改操作,然而,在经过这类操作之后,某些节点的高度可能发生变化,以至于不再满足AVL树的条件,这种情况下我们需要进行处理使其重新恢复平衡。一般地,若在插入新节点之后使AVL树失去平衡,则可以将失衡的节点(以该节点为根节点的子树失衡)组成集合,该集合中的每个节点都是新节点的祖先,我们为了将其重新恢复平衡,可采取以下方法:
       ①单旋:只进行一次旋转操作就可以恢复平衡。
       ②双旋:进行两次单旋恢复平衡。
删除节点时同样也会使AVL树失去平衡,将失衡的节点(以该节点为根节点的子树失衡)组成集合,同样的该集合中的每个节点都是删除节点的祖先,然后通过单旋或者双旋恢复平衡,最深失衡节点的深度必然降低。

      5、伸展树

       AVL树是平衡二分查找树的一种完美实现方式,但实际上,平衡二分查找树的实现方式还远不止于此,伸展树就是另外一种形式,相对于AVL树,伸展树更为简单,首先,与AVL树不同,伸展树无需对节点实施显式的平衡化操作,而是代之以一种直观而方便的操作——将最近被访问的节点推至树根,这一操作称作伸展,因此,在伸展树中,各节点不需要记录高度、深度和平衡因子之类的信息,故节点本身也相对简单。引入伸展树的最初动机,在于利用平衡二分查找树的数据局部性的一种极端情况:刚被访问过的节点极有可能就是下一将被访问的节点,因此每次访问过一个节点,都通过某种方式将其移至树根处。
       ①简易伸展树:通过对最近访问节点的父节点进行zig或者zag旋转操作。
       ②双层伸展:两层两层的伸展,也就是说每次从当前节点v出发上溯两层

      6、B-树

       所谓m阶B-树,即满足一下条件的m路平衡查找树:其中的每一内部节点,都存在n个关键码{K 1< K 2< ... < K n}和n+1 个引用{A 0, A 1, A 2, ..., A n},n+1 ≤ m,对于每一非根内部节点,都有n+1 ≥ ⎡m/2⎤,对于根节点,除非它同时也是叶子,否则必有n+1 ≥ 2 ,每个引用 A i分别指向一棵子树 T i,而且若 i ≥ 1,则 T i中的每一关键码 key 都满足 key > K i;若i ≤ n-1,则 T i中的每一关键码 key 都满足 key < K i+1。与一般的查找树不同,为了简化叙述,这里我们假定 B-树中的所有关键码互异,另外,所有叶子节点的深度相等,即它们都处于同一层。
  • 1
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值