大话数据结构总结

  • 数据结构分类:
  1. 逻辑结构:集合结构、线性结构、树形结构、图形结构
  2. 物理结构:顺序存储结构、链接存储结构
  • 算法:
  1. 算法是解决特定问题求解步骤的描述,在计算机中表现为指令的有限序列,并且每条指令表示一个或多个操作
  2. 算法具有五个基本特性:输入、输出、有穷性、确定性和可以行性
  3. 循环的复杂度:等于循环体的复杂度乘以该循环运行的次数
  • 推导大O阶方法:
  1. 用常数1取代运行时间中的所有加法常数
  2. 在修改后的运行函数中,只保留最高阶项
  3. 如果最高阶项存在且不是1,则去除与这个项相乘的常数
  • 时间复杂度所耗时间大小排列:

时间复杂度:指运行时间的需求

空间复杂度:指空间需求

线性表(数组)

  • 线性表(List):零个或多个数据元素的有限序列
  1. 线性表的顺序存储结构,指的是用一段地址连续的存储单元依次存储线性表的数据元素。描述顺序存储结构需要三个属性:
  2. 存储空间的起始位置:数组data,它的存储位置就是存储空间的存储位置。
  3. 线性表的最大存储容易:数组长度MaxSize。
  4. 线性表的当前长度:length。
  5. 线性表的顺序存储结构,在存、读数据时,时间复杂度是O(1),而插入或删除时,时间复杂度都是O(n)。说明适合元素个数不太变化,而更多是存储数据的应用。

单链表

  1. 插入和删除算法:由两部分组成,第一部分是遍历查找第i个元素;第二部分是插入和删除元素。
  2. 在插入和删除操作上,与线性表的顺序存储结构是没有太大优势,但是如果插入多个,比如10个时对顺序存储意味着,第一次插入要移动(n-i)个元素,每次都是O(n);而链表只需要在第一次找到第i个位置指针,此时为O(n),接下来移动指针就是O(1)。所以对于插入和删除数据越频繁的操作,单链表的效率优势就越明显。
  • 静态链表:用数组描述的链表叫做静态链表,数组元素由两个数据域组成,data和next指针,存放该元素后继在数组的下标。

栈和队列

  • 栈(stack)是限定公在表尾进行插入和删除操作的线性表。
  1. 我们把允许插入和删除的一端称为栈顶(top),另一端称为栈底(bottom),不含任何数据元素的栈称为空栈。(先进后出)

  • 递归定义:在高级语言中,调用自己和其它函数并没有本质的不同。我们把一个直接调用自己或通过一系列的调用语句间接地调用自己的函数,称做递归函数。有以下用途:
  • 递归是用了栈原理实现:在前行阶段,对于每一层递归,函数的局部变量、参数值以及返回地址都被压入栈中。在退回阶段,位于栈顶的局部变量、参数和返回地址被弹出,用于返回调用层次中执行代码的其余部分,也就是恢复了调用的状态。
  • 乘除加减(四则运算)也是运用栈原理:有左括号就进栈,而后面有右括号就出栈,期间让数字运算。
  • 后缀(逆波兰):从左到右遍历表达式的每个数字和符号,遇到数字就进栈,遇到符号就将处于栈顶的两个数字出栈,进行运算,运算结果进栈,一直到获得结果。
  • 结果:要想让计算机具有处理我们通常的标准(中缀)表达式的能力,最重要的就是两步:
  • 将中缀表达式转化为后缀表达式(栈用来进出运算的符号)
  • 将后缀表达式进行运算得出结果(栈用来进出运算的数字)
  • 迭代和递归的区别是:迭代使用的是循环结构,递归使用的是选择结构。递归能使程序结构清晰、简洁、容易理解。但是大量递归调用会建立函数的副本,会耗费大量的时间和内存。迭代则不需要反复调用函数和占用额外的内存。

 队列

  • 队列(queue)是只允许在一端进行插入操作、而在另一端进行删除操作的线性表。
  • 循环队列和链队列比较,基本操作都是常数时间,即O(1),不过循环队列是事先申请好空间,使用期间不释放;而对于链队列,每次申请和释放结点也会存在一些时间开销,如果入队出队频繁,则还是循环队列好点。对于空间上来说,循环队列必须有一个固定长度,所以有了存储元素个数和空间浪费的问题;而链队列不存在这个问题,尽管需要一个指针域会产生一些空间的开销,但可以接受。所以在空间上,链队列更加灵活。

字符串

  • 串(string):是由零个或多个字符组成的有限序列,又名叫字符串。
  • 线性表更关注的是单个元素的操作,比如查找一个元素,插入或删除一个元素,但串中更多的是查找子串位置、得到指定位置子串、替换子串等操作。

  • 字符串的比较:KMP模式匹配算法

 https://www.cnblogs.com/zhangtianq/p/5839909.html

  树(Tree):用多个单链表来表示结构

  • 是n(n>=0)个结点的有限集。n=0时称为空树。在任意一棵非空树中:有且仅有一个特定的称为根(Root)的结点;当n>1时,其余结点可分为m(m>0)个互不相交的有限集T1、T2、....、Tn,其中每一个集合本身又是一棵树,并且称为根的子树(SubTree)。

二叉树:

  • 五种基本形态:空二叉树;只有一个根结点;根结点只有左子树;根结点只有右子树;根结点既有左子树又有右子树; 线性表可以理解成树的一种特殊形态,斜树,即所有子树都是左节点或右节点。
  • 完全二叉树:用顺序存储,不是完全二叉树,因为有些空结点,会造成数组中有空间没用到。
  • 二叉树:存储结构用二叉链表,一个数据域和两个指针域。
  • DLR--前序遍历(根在前,从左往右,一棵树的根永远在左子树前面,左子树又永远在右子树前面 )
  • LDR--中序遍历(根在中,从左往右,一棵树的左子树永远在根前面,根永远在右子树前面)
  • LRD--后序遍历(根在后,从左往右,一棵树的左子树永远在右子树前面,右子树永远在根前面)
  • 已知前中和中后可以确定唯一二叉树,已知前后不能确定唯一二叉树。

 

 

  • 线索二叉树:存储结构双向链表,空的左子节点指向前驱节点,空的右子节点指向后继节点。另外加两个bool确定是不是子节点。
    如果二叉树需要经常遍历或查找结点时,需要遍历序列中的前驱和后继,用线索二叉链表的存储结构就是不错的选择。

    赫夫曼树:最基本的压缩编码方法-赫夫曼编码。一般地,设需要编码的字符集为{d1,d2,...dn},各个字符在电文中出现的次数或频率集合为{w1,w2,...,wn},以d1,d2,...,dn作为叶子结点,以w1,w2,...,wn作为相应叶子结点的权值来构造一棵赫夫曼树。规定赫夫曼树的左分支代表0,右分支代表1,则从根结点到叶子结点所经过的路径分支组成的0和1的序列便为该结点对应字符编码,这就是赫夫曼编码。

查找(Searching)

  • 就是根据给定的某个值,在查找表中确定一个其关键字等于给定值的数据元素(或记录)。
  • 从逻辑上说,查找基于数据结构是集合,集合中的记录之间没有本质关系。在存储时可以将查找集合组织成表、树等结构。
  • 静态查找表,可以用线性结构来组织数据,可以使用顺序查找算法,如果对主关键字排序,则可以应用折半查找等技术进行高效查找。
  • 动态查找,可以考虑二叉排序树的查找技术。还可以用散列表结构来查找。
  • 静态查找:从第一个或最后一个开始,遍历查找每一个元素。用for会每次判断是否越界,可以设置一个最小的值(哨兵)然后用while循环判断值是否相等。

  • 折半查找(Binary Search):前提是线性表记录的是关键码有序,线性表必须采用顺序存储。思想:在有序表中,取中间记录作为比较对象,若相等则查找成功;若定值小于中间记录的关键定,则在左半区继续查,若大于则在右半区继续查。
  1. 缺点:对频繁插入和删除的表来说,维护有序的排序会带来不小的工作量,不建议使用。
  • 插值查找:根据查找的关键字key与查找表中最大最小记录的关键字比较后的查找方法,计算定值不是中间,而且按插值比较出来。
  1. 缺点:分布不均匀的数据,不适合用插值查找。
  • 斐波那契查找:按黄金分割比例。
  • 折半查找进行加法与除尘运算(mid=(low+hight)/2),插值查找进行复杂四则运算(mid=low+(high-low)*(key-a[low])/(a[high]-a[low])),而斐波那契查找进行加减运算(mid=low+F[k-1]-1),在海量数据的查找过程中,这种细微的差别可能会影响最终的查找效率。

如果查找的数据集是有序线性表,并且是顺序存储,查找可以用折半、插值、斐波那契查找算法实现,可惜因为有序,在插入和删除操作上,就需要耗费大量的时间。

  • 二叉排序树:按中序排序,查找和插入容易,删除难。
  • 平衡二叉树:比较理想的一种动态查找表算法,查找和插入和删除时间复杂度为O(logN)。适合集合本身没有顺序,频繁查找的同时也需要经常插入和删除的操作。
  • B树:为了内存和硬盘数据交互准备的。

哈希表(散列表)

  •  

     

  • 顺序表查找是a[i]与key的值是“==”,直到相等才算是查找成功,返回i。有序表查找时,可以复用a[i]与key的"<"或">"来折半查找,直到找到下标i。最终目的都是为了找到i,其实也就是相对的下标,再通过顺序存储的位置计算方法,Loc(ai)=Loc(a1)+(i-1)*c,也就是通过第一个元素内存存储位置加上i-1个单元位置,得到最后的内存地址。
  • 哈希表定义:散列技术是记录在存储位置和它的关键字之间建立一个确定的对应关系f,使得每个关键字key对应一个存储位置f(key)。我们把对应关系f称为散列函数,又称为哈希(Hash)函数。按这个思想,采用散列技术将记录存储在一块连续的存储空间中,这块连续存储空间称为散列表或哈希表(Hash table)。
  • 散列技术既是一种存储方法,也是一种查找方法。
  • 散列技术最适合的求解问题是查找与给定值相等的记录。
  • 哈希函数构造原则:计算简单;散列地址分布均匀
  1. 计算散列地址所需的时间
  2. 关键字的长度
  3. 散列表的大小
  4. 关键字的分布情况
  5. 记录查找的频率
  • 直接定址法:f(key)=a*key+b(a,b为常数)
  • 数字分析法:如取手机号码后四位作为关键字,因为前三位对应品牌,中间四位对应归属地,重复机率大。
  • 平方取中法:将关键字平方后取中间的几位作地址。适合不知道关键字分布,位数不大的情况。
  • 折叠法:将关键字从左到右分割成位数相等的几部分,求和后到后几们作散列地址。适合关键字位数较多的情况。
  • 随机数法:取关键字的随机函数为散列地址。f(key)=random(key)。适合关键字长度不等时。
  • 除留余数法:最常用的构造函数方法。公式为f(key)=key mod p(p<=m)(散列表长为m,通常p为小于或等于表长的最小质数或不包含小于20质因子的合数)
  • 处理散列冲突:
  1. 开放定址法:就是一旦发生冲突,就去寻找下一个空的散列地址,只要散列表足够大,空的散列地址总能找到,并将记录存入。公式:fi(key)=(f(key)+di)MOD m(di=1,2,3,...,m-1)。也叫线性探测法。
  2. 随机探测法:即线性探测法的di不是线性加1,而是用随机函数。公式:fi(key)=(f(key)+di)MOD m(di=1平方,-1平方,2平方,-2平方,...,q平方,q<=m/2)fi(key)=(f(key)+di) MOD m(di是一个随机数列)
  3. 再散列函数法:fi(key) = RHi(key)(i=1,2,...,k),这里的RHi就是不同的散列函数,可以把前面说的除留余数、折叠、平方取中全用上,当散列地址冲突时,就换一个散列函数计算。缺点:相应增加了计算时间。
  4. 链地址法:将所有关键字为同义词的记录存储在一个单链表,有冲突就增加结点。如果冲突多,查找时需要遍历单链表的性能损耗。
  5. 公共溢出区法:分为基本表和溢出表,如果基本表找不到就去溢出表找。冲突数据很少的情况下,性能还是非常高的。

  • 哈希表查找性能分析:在所有的查找中效率是最高的,因为时间复杂度是O(1),不过是在没冲突的情况下。所以平均查找复杂度取决于下面几种:
  1. 散列函数是否均匀
  2. 处理冲突的方法,像链地址不会产生堆积,因而具有更佳的平均查找性能。
  3. 散列表的装填因子,装填因子a=填入表的记录个数/散列表长度。越大就越容易冲突,通常将散列表空间设置比查找集合大,虽然浪费了一定的空间,但换来的是查找效率的大提升。

线性探测:

 应当优先考虑散列表, 当需要有序性操作时使用红黑树

排序

  • 内排序和外排序:内排是排序过程中,待排序的所有记录全部被放置在内存中。外排序是由于排序记录个数太多,不能同时放置在内存,需要在内外存之间多次交换数据才能进行。
  1. 内排序:插入排序、交换排序、选择排序、归并排序。
  2. 简单算法:冒泡排序、简单选择、直接插入
  3. 改进算法:希尔排序、堆排序、归并排序、快速排序
  • 冒泡排序(Bubble Sort):两两比较相郊记录的关键字,如果反序则交换,直到没有反序的记录为止。
  • 选择排序(Simple Selection Sort):通过n-i次关键字间的比较,从n-i+1个记录中选出关键字最小的记录,并和第i(1<=i<=n)个记录交换。(选择一个最小的来交换)
  • 直接插入(Straight Insertion Sort):将一个记录插入到已经排好序的有序中,从而得到一个新的、记录数增1的有序表。
  • 希尔排序(Shell Sort):将相距某个"增量"的记录组成一个子序列,这样保证子序列内分别直接插入排序后得到的结果是基本有序而不是局部有序。
  • 堆排序(Heap Sort):完全二叉树,每个结点的值大于或等于左右孩子称大顶堆,小于或等于左右孩子称小顶堆。
  • 归并排序(Merging Sort):像一个倒立的树。假设初始序列含有n个记录,则可以看成是n个有序的子序列,每个子序列的长度为1,然后两两归并,得到[n/2]([x]表示不小于x的最小整数)个长度为2或1的有序子序列;再两两归并。有比较if(SR[i]<SR[j])所以是需要两两比较,不存在跳跃,所以是稳定的排序,是一种比较占用内存(需要复制子序列,然后进行合并),但却效率高且稳定的算法。
  • 快速排序(Quick Sort):通过一趟排序将待排记录分割成独立的两部分,其中一部分记录的关键字均比另一部分记录的关键字小,则可分别对这两部分记录继续里德排序,以达到整个序列有序的目的。
  1. 希尔相当直接插入,同属于插入类;堆排序相当选择排序,同属于选择类;快速排序相当于最慢的冒泡排序,属于交换类。
  2. 如果数组非常小,快速排序反而不如直接插入排序来得更好(直接插入是简单排序中性能最好的)。因为快速排序用到了递归操作。
  3. 快速排序>>希尔排序>直接插入>选择排序>冒泡排序

数据元素的存储(顺序存储和链式存储)和数据元素的操作(查找、插入和删除)是数据结构的重要部分。线性结构可分为线性表、栈和队列,对于三种结构不同的存储方式在插入和删除的效率上也有所不同。

查找频繁和固定大小的用数组,如地图障碍区的数据保存成二维数组,易于查找。
插入和删除多的用单链表(List),如玩家的装备列表。

总结:

一、存储方式:

数据结构的存储方式只有两种:数组(顺序存储)和链表(链式存储)

这句话怎么理解,不是还有散列表、栈、队列、堆、树、图等等各种数据结构吗?

我们分析问题,一定要有递归的思想,自顶向下,从抽象到具体。你上来就列出这么多,那些都属于「上层建筑」,而数组和链表才是「结构基础」。因为那些多样化的数据结构,究其源头,都是在链表或者数组上的特殊操作,API 不同而已。

比如说「队列「栈」这两种数据结构既可以使用链表也可以使用数组实现。用数组实现,就要处理扩容缩容的问题;用链表实现,没有这个问题,但需要更多的内存空间存储节点指针。

「图」的两种表示方法,邻接表就是链表,邻接矩阵就是二维数组。邻接矩阵判断连通性迅速,并可以进行矩阵运算解决一些问题,但是如果图比较稀疏的话很耗费空间。邻接表比较节省空间,但是很多操作的效率上肯定比不过邻接矩阵。

「散列表」就是通过散列函数把键映射到一个大数组里。而且对于解决散列冲突的方法,拉链法需要链表特性,操作简单,但需要额外的空间存储指针;线性探查法就需要数组特性,以便连续寻址,不需要指针的存储空间,但操作稍微复杂些。

「树」,用数组实现就是「堆」,因为「堆」是一个完全二叉树,用数组存储不需要节点指针,操作也比较简单;用链表实现就是很常见的那种「树」,因为不一定是完全二叉树,所以不适合用数组存储。为此,在这种链表「树」结构之上,又衍生出各种巧妙的设计,比如二叉搜索树、AVL 树、红黑树、区间树、B 树等等,以应对不同的问题。

了解 Redis 数据库的朋友可能也知道,Redis 提供列表、字符串、集合等等几种常用数据结构,但是对于每种数据结构,底层的存储方式都至少有两种,以便于根据存储数据的实际情况使用合适的存储方式。

综上,数据结构种类很多,甚至你也可以发明自己的数据结构,但是底层存储无非数组或者链表,二者的优缺点如下

数组由于是紧凑连续存储,可以随机访问,通过索引快速找到对应元素,而且相对节约存储空间。但正因为连续存储,内存空间必须一次性分配够,所以说数组如果要扩容,需要重新分配一块更大的空间,再把数据全部复制过去,时间复杂度 O(N);而且你如果想在数组中间进行插入和删除,每次必须搬移后面的所有数据以保持连续,时间复杂度 O(N)。

链表因为元素不连续,而是靠指针指向下一个元素的位置,所以不存在数组的扩容问题;如果知道某一元素的前驱和后驱,操作指针即可删除该元素或者插入新元素,时间复杂度 O(1)。但是正因为存储空间不连续,你无法根据一个索引算出对应元素的地址,所以不能随机访问;而且由于每个元素必须存储指向前后元素位置的指针,会消耗相对更多的储存空间。

二、基本操作:

对于任何数据结构,其基本操作无非遍历 + 访问,再具体一点就是:增删查改。

数据结构种类很多,但它们存在的目的都是在不同的应用场景,尽可能高效地增删查改。话说这不就是数据结构的使命么?

如何遍历 + 访问?我们仍然从最高层来看,各种数据结构的遍历 + 访问无非两种形式:线性的和非线性的。

线性就是 for/while 迭代为代表,非线性就是递归为代表。再具体一步,无非以下几种框架:

数组遍历框架,典型的线性迭代结构:

void traverse(int[] arr) {
    for (int i = 0; i < arr.length; i++) {
        // 迭代访问 arr[i]
    }
}

链表遍历框架,兼具迭代和递归结构:

/* 基本的单链表节点 */
class ListNode {
    int val;
    ListNode next;
}

void traverse(ListNode head) {
    for (ListNode p = head; p != null; p = p.next) {
        // 迭代访问 p.val
    }
}

void traverse(ListNode head) {
    // 递归访问 head.val
    traverse(head.next)
}

二叉树遍历框架,典型的非线性递归遍历结构:

/* 基本的二叉树节点 */
class TreeNode {
    int val;
    TreeNode left, right;
}

void traverse(TreeNode root) {
    traverse(root.left)
    traverse(root.right)
}

你看二叉树的递归遍历方式和链表的递归遍历方式,相似不?再看看二叉树结构和单链表结构,相似不?如果再多几条叉,N 叉树你会不会遍历?

二叉树框架可以扩展为 N 叉树的遍历框架:

/* 基本的 N 叉树节点 */
class TreeNode {
    int val;
    TreeNode[] children;
}

void traverse(TreeNode root) {
    for (TreeNode child : root.children)
        traverse(child)
}

N 叉树的遍历又可以扩展为图的遍历,因为图就是好几 N 叉棵树的结合体。你说图是可能出现环的?这个很好办,用个布尔数组 visited 做标记就行了,这里就不写代码了。

首先要明确的是,数据结构是工具,算法是通过合适的工具解决特定问题的方法。也就是说,学习算法之前,最起码得了解那些常用的数据结构,了解它们的特性和缺陷。

递归模型一: 在递去的过程中解决问题

function recursion(大规模){
  if (end_condition){ // 明确的递归终止条件
  end; // 简单情景
  }else{ // 在将问题转换为子问题的每一步,解决该步中剩余部分的问题
  solve; // 递去
  recursion(小规模); // 递到最深处后,不断地归来
  }
}

递归模型二: 在归来的过程中解决问题

function recursion(大规模){
  if (end_condition){ // 明确的递归终止条件
    end; // 简单情景
  }else{ // 先将问题全部描述展开,再由尽头“返回”依次解决每步中剩余部分的问题
  recursion(小规模); // 递去
  solve; // 归来
  }
}

注意事项

1. 大常数
在求近似时, 如果低级项的常数系数很大, 那么近似的结果就是错误的。

2. 缓存
计算机系统会使用缓存技术来组织内存, 访问数组相邻的元素会比访问不相邻的元素快很多。

3. 对最坏情况下的性能的保证
在核反应堆、 心脏起搏器或者刹车控制器中的软件, 最坏情况下的性能是十分重要的。

4. 随机化算法
通过打乱输入, 去除算法对输入的依赖。

5. 均摊分析  
将所有操作的总成本除于操作总数来将成本均摊。 例如对一个空栈进行 N 次连续的 push() 调用需要访问数组的元
素为 N+4+8+16+…+2N=5N-4N 是向数组写入元素, 其余的都是调整数组大小时进行复制需要的访问数组操作),
均摊后每次操作访问数组的平均次数为常数。

posted @ 2020-03-26 20:04  学习使我进步  阅读(900)  评论(0编辑  收藏  举报