本文主要用于记录学习过程中的一些总结;适用于一些刚学习数据结构和算法的同学,能够给予一些概括性认识,而且从下面的一些算法题中能够获得一些对于算法题目常用解题思路。如果能够对你有些帮助,是我之幸!
接下来,将一共分为三部分来介绍如下内容:
数据结构通常是用来描述数据之间的关系,E - element元素,R-relation,用二元组(E,V)可以代表数据结构的一种抽象,描述的是一种元素集合和元素之间的关系组成的一个数据结构,依据不同的数据关系,便产生我们所熟知的数组,栈,队列,线性表,非线性表等数据结构,接下来按照各个维度将这些数据结构进行进一步的划分。
线性表,逻辑上元素的构成是呈线性,即一个元素只有一个前驱一个后继,所有元素用这样的关系连接成一条线,即线性关系;比如数组,数组在物理内存上连续,在逻辑上也是下标相连,例如索引【i】位置的直接前驱是【i-1】位置,其直接后继是【i+1】;介绍完基础概念,会补充对于该数据结构的常见操作以及操作带来的影响;
顺序表,属于线性表,逻辑上已经是连续了的,如果还具备顺序的特点,在物理内存上也是连续,例如数组,我们直到数组的元素在物理内存上是紧挨着相连的,所以对于数组的每个元素的内存地址,只需要直到数组的首位元素的地址,然后根据下标推算出来。例如数组
// jvm向堆区申请长度为10的int类型的元素空间地址,
// 然后在栈里创建一个局部变量array,将地址赋值给array,引用该数组
int[] array = new int[10];
如果首地址是 location(a0)每个元素长度为4,那么array[i]的地址有:
location(a[i]) = location(a0) + (i-1)*4;
所以在C语言里,数组传递都用&array来传递,其传递的是数组首地址,直到首地址便可直到整个数组的元素分布;
常用操作:增加元素,删除元素,查询元素,修改元素;
新增,由于数组维护了其顺序性,所以如果新增即插入一个元素,为了维护该数据结构物理上连续的特性,需要将插入位置后的元素全部向后移动;
注意:对于数组的查询操作,根据下标来查询访问o(1)时间复杂度,但是对于无规律分布数组的值的查询时,则需要逐个遍历比较是o(n)的时间复杂度。显然对于这种数据结构根据下标查询有较好的优势,对于插入和删除操作,都需要大量移动元素以保证连续的特性,需要o(n)的复杂度。则适用于查询不适用于新增、删除的场景。
不同于顺序表,链式表主要在物理内存上不连续,顺序表可以由首地址和下表来推算后续所有元素的地址,而链式表则只能由直接前驱元素来推算下一个元素的地址。那么对于链式表来说,每个元素不仅要记录本身元素所包含的值还要记录下一个元素的位置,这样一个普通的单向链表,应该是如下这样的结构:
public class Node{
// 链表元素本身的数据,也叫数据域
private Integer data;
// 元素要记录下一个节点的地址,所以也叫指针域,指向下一个元素的地址
private Node next;
// 构造方法
public Node(Integer data,Node next) {
this.data = data;
this.next = next;
}
// 空参构造
public Node(){}
}
可以看到,链式表里元素只和前驱和后继相关联;前驱元素指向自己,自己指向后继元素,对于链表的常用操作,插入元素,删除元素的时间复杂度为o(1)仅需要修改指针指向,对于查询操作则需要遍历链表,时间复杂度为o(n);
示例如下:
注:即使对每个插入的元素赋予了索引下标,但是也无法直接通过下标推算出地址,因为链表元素间地址不连续,当前元素的地址只能由上一个元素的指针域所指向的地址来获得,因此需要得到链表末尾的元素值,必须从头部元素逐个遍历到末尾才能获得具体的值。
小结
顺序表和链式表都是线性表,有直接的前驱后继,顺序表在地址上连续,链表则在地址上不连续,这样的特性也决定了,顺序表更适合做查询操作;而链表则更适合做新增和删除操作。
栈是一种操作受限的特殊线性表,在逻辑上元素间仍然是连续保持前后继关系,不同于数组和链表可以在任意位置进行元素插入删除,栈只能从栈顶进行元素新增删除操作;从栈顶压入元素,压入到栈底。具备后进先出特点,如下所示:
可以看到栈是一种只能在一端栈顶操作的数据结构,元素具备后进先出的特点;利用栈的这一特性,我们能用这一数据结构能够解决的问题包括,表达式的转换,方法递归调用等场景。
栈延申:显然是顺序表我们可以其存储方式上的问题,如果限制了栈的大小,随着栈顶元素增加会出现溢出的情况,那么初始设置的栈的空间太大,但是压入栈的元素太小,将导致空间的浪费,因为栈初始化必须申请空间,然而却没有用到必然是浪费;因此出现了一个双向缓冲栈;如下所示:
这样如果其中一个栈插入元素较少,申请的空间可以供另外的栈来使用,减少了空间浪费;利用两个栈来形成特殊的数据结构的场景同样的包括,用两个栈模拟队列实现,实现单调栈来完成数据的有序存取;
队列同样是一种特殊的线性表,操作受限,限于一端插入操作一端删除操作;因此其结构式先进先出的一种数据结构;常见操作如下:
对于队列的问题,如果初始化限制了队列大小,每次插入一个元素导致头尾指针不断增加,造成假溢出的问题,循环队列则解决了这一问题;比如固定头部,每次从头部删除后,剩下的元素全部往前移动,头部被删除的空间得以被重用。
根据这一特性,我们可以用队列来做BFS等算法实现,将每一层的元素放到队列里然后逐个处理元素,处理元素将元素的下一层元素放到队列里。待上一层处理完成之后,将继续处理下一层元素。合理使用这一数据结构将有助于解决BFS的问题;
延申数据结构:循环队列,双端队列,甚至可以自定义一种一端插入受限,两端都可以删除的数据结构。
首先树,只有一个根,并且可以有多个后继,也就是一个节点可能有多个后继节点,比如二叉树最多可以有两个节点,多叉树则更多;
先对树涉及到的基本概念做统一的描述:
二叉树特指代表任何一个结点的度的最大值为2,即最多只能有两个子节点,子树数量为2;下面描述二叉树所共有的一些性质;
基础性质
性质1:二叉树的第 i 层上至多2^(i-1)个结点;
性质2:深度为k的二叉树至多有2^k - 1个结点(k>=1)
性质3:终端结点树n0,度为2的结点数为n2,则有n0=n2 + 1;
性质4:具有n个结点的完全二叉树的深度为[log2(n)] + 1;
性质5:一颗有n个结点的完全二叉树,如果按层序从左到右编号,结点 i 的左孩子结点为2i,右孩子2i + 1,相应地,如果2i+1>n那么代表结点i无右孩子;
二叉树遍历
通常对于数据结构的访问,我们经常需要遍历其所包含的内容,对于线性表的遍历,无论是数组,链表还是队列栈,因为有直接的前后继只需要遍历从头到尾直到空为止即可;
对于树这种非线性表的遍历,主要包含三种方式:
1. 满二叉树
满二叉树表示所有的非叶子节点都有两个子节点,可以简单理解成树是满的,完整的,所有可以存放节点的位置都填满了。形如下图
2. 完全二叉树
在满二叉树的基础上,可以放松对叶子节点的状态,按照每一层左至右的顺序可以允许后面的叶子节点没有叶子节点
可以看到左侧是满二叉树,右侧是完全二叉树;注意,如果右侧图中节点4是节点2的右侧,那么便不满足从左至右的填充顺序,不算一个完全二叉树,只能称之为普通二叉树;
4. 搜索树
对于树的高度差无限制,但是需要树具备搜索的功能,每一层节点的叶子节点如果存在的话,必然左节点<根节点<右节点;搜索树也叫排序树、BST树;因为排序的特点,可以看到按照左、根、右的中序遍历顺序可以得到排序树的所有数字的一个排序的结果。也叫二叉排序树,具有如下性质:
1,若左子树不为空,左子树上所有节点的值均小于根节点的值;
2,若右子树不为空,则右子树所有结点的值均大于根节点的值;
3,左、右子树也分别为二叉排序树;
5. 平衡树
平衡二叉树也叫AVL树,具备如下性质:
1,它的左子树和右子树也都是平衡二叉树,左子树和右子树的深度只差的绝对值不超过1;
2,结点的平衡因子只可能是-1,0,1;
因此二叉排序树如果满足任何结点的平衡因子都是-1,0,1那么就是一个二叉平衡树,因为排序的特性,也就知道它的平均查找长度和logn是同数量级。
小结:根据上述两种树的不同特征,可以知道对于需要对数据进行排序和查找的情形,可以采用搜索树的数据结构,这样查找一个数字,只需要遍历树的高度就好,每一次和根部比较判断如果小于则向左子节点寻找,反之向右子节点寻找,可以知道每一次都能排除掉当前样本量的一半的样本;形如二分,和二分搜索类似;同样缺点是,如果我要插入或者删除数据,因为搜索树不维持平衡特性,可能会导致搜索树退化成链表,比如上图中删除节点4,就退化成了链表,极端情形下,链表会越来越长,查找效率演变成o(n);
再来考虑平衡树,由于每次新增和删除操作,平衡树会通过自旋来完成树的平衡,所以任意次数的调整,平衡树的特性,使得对平衡树的访问具备稳定性;
7. 红黑树
考虑到AVL平衡树高度平衡性,不利于结点的插入和删除操作,衍生出的一种综合性数据结构,红黑树基础特性;每个节点都有一个颜色属性,要么红色要么黑色,并且根节点是黑色,叶子节点为空则也是黑色,如果一个节点是红色则它的子节点也必须是黑色,从一个节点出发到叶子节点所有路径上经过的黑色节点数目是相同的。譬如jdk8里的解决hash冲突时在链长度达到8时转成红黑树;因此,红黑树理解成一种牺牲了强平衡性换取插入和删除性能的综合性数据结构;具备弱平衡性,稳定的查找效率和插入删除效率;
8. 哈夫曼树
哈夫曼树用于构造最优二叉树,如果给每条路径上都赋予一个权值;每个叶子节点的带权路径即从根结点出发到叶子节点路径上权值的乘积,每条路径的权值相加构成带权路径长度之和WPL,这样最小的带权路径长度最小也称为哈夫曼树;一般应用于一些编码问题的场景,典型的例子有用于字典树,将各个单词的前缀编码存储,可以极大程度上缩减存储空间;
1. B树
B树是一个多叉树,并且是具备搜索性质的,也就是每个节点按照从左、根、右是有序的;通常被用来数据库系统的数据存储。常见mysql的索引等实现;首先介绍树的阶的概念,任意节点的拥有的最大数量的子节点的数量叫阶。下面将以一颗三阶的B树。来说明B树的结构;
可以看到三阶B树,关键字数量是2,且每个节点了维护了三个后续指针,分别指向左侧,中间,右侧,m阶则有m个关键字,将指向m个后继子节点;B树的非叶子节点维护了关键字的同时,还维护了数据项;B树的性质如下:
1,树中每个结点至多有m棵子树;
2,若根结点不是叶子结点,则至少有两棵子树;
3,所有非叶子结点至少有m/2棵子树;
4,非叶子结点维护了的数据包括数据和关键字信息;而叶子结点只维护了数据信息;
5,所有的叶子结点都出现在同一个层次;
B树通常用于磁盘,文件系统的查找,从磁盘上将每个结点读入到内存,然后对某个结点包含的所有数据进行折半查找找到指定的记录,这一思想应用于数据库系统的索引设计;
2. B+树
相较于B树,B+树是一种变种形式的B树,主要差异有如下:
1,有n棵子树的结点中含有n个关键字;
2,所有的叶子结点中包含了全部关键字信息,而且叶子结点本身依关键字大小顺序连接;
3,所有非叶子结点都可以看成索引部分,结点包含最小或者最大关键字;
可以看到所有的非叶节点的关键字信息都包含在叶子节点上,且叶子节点是顺序连接的;并且结点有几个关键字就对应几棵子树;
3. B*树
B*树则是B+树的变体,其差异在于,在非叶子结点部分在用指针进行连接,也就是非叶子结点能够连接到兄弟结点上,因此,非叶子结点关键字个数至少为(2/3)*M,其中M为树的阶;出现这种变体主要应对B+树分裂造成的空间利用率低的问题,关于树结构平衡调整,页分裂合并等操作,待下一篇再详细描述;
森林的介绍,仅描述基础概念森林是树的一种集合,森林的每个元素是一棵树,可以将多棵树形成森林,也可将森林每棵树的根节点进行连接,转化成一棵树,相应地,也有森林的遍历方法,包括先序遍历森林,和中序遍历森林;
上面从数组,链表等有直接前后继关系的线性表,到没有直接前后继关系,但是有层序关系的二叉树,再到现在的图更为复杂的非线性数据结构,每一个结点可能有多个后继,也有多个前驱;任意结点间都可能相关;图可以理解成一系列顶点和边的组合关系。以下列出图的一些基础概念:
图的遍历
继了解基础线性表,树,森林的遍历后,了解下图的遍历;图的遍历包括两种,即DFS深度优先遍历、BFS广度优先遍历;
BFS: 假设从图中某个顶点v出发,依次访问v的邻接点,然后再从这些邻接点出发,依次访问v未访问过的邻接点,并且保障先被访问的顶点的邻接点 先于 后被访问访问的顶点的邻接点;
关于图的遍历,有两个很重要的思想,利于在面对解题时的一些常用技巧;对BFS来说,通常可以维护一个队列来存储初始结点,然后处理将结点的后继加入到队列,处理完当前结点,依次从队列取出结点进行处理并且结点的后继继续放入队列;
对于DFS则常应用于回溯的场景,从某个结点向下不断搜索,当搜索的到尽头时则回溯当前结点到上一层,继续尝试另一个分支进行搜索,对于大多数的场景,我们需要在回溯的过程中进行剪枝处理以缩小样本量,加快搜索;
最小生成树
在图的数据结构里,多个顶点之间通常具备一种最小生成树的子图形式;这样的树保证了每个顶点是连通的,并且是最小的连通子图,加上每条边上的权值,这样的最小生成树,累计加总的权值是最小的情况,对于解决实际应用中通信网络的连接,管道的铺设等问题有参考意义;
拓扑排序
对于有向无环图,各个顶点之间保留了箭头指向性特征,可以映射为顶点之间的先手顺序,弧尾指向弧头表示弧尾侧的顶点是弧头顶点的前置条件,再解决一系列前后依赖的问题时,可借助于图的拓扑排序来表达这样的关系;其中全序表示拓扑结构中的任意两个结点间有明确的先后顺序关系;偏序则指的是存在部分子图间结点存在先后序列关系;从偏序都全序的过程叫拓扑排序;
这一部分主要介绍上述数据结构的应用场景。
1、数组结构,拿leetcode两数之和的题目距离,题目链接两数之和
/**给定一个整数数组 nums 和一个整数目标值 target,请你在该数组中找出
和为目标值 target 的那 两个 整数,并返回它们的数组下标。
你可以假设每种输入只会对应一个答案。但是,数组中同一个元素在答案里不能重复出现。
你可以按任意顺序返回答案。*/
public int[] twoSum(int[] nums, int target) {
// 做备忘录,保证每次遍历的结果记录下来,因为返回两数的下标,所以把 下标也放进来。
Map<Integer,Integer> memo = new HashMap<>();
// 遍历nums数组每个元素,逐一判断
for(int i=0;i<nums.length;i++) {
// 当前元素nums[i],只需要判断备忘录里是不是已经遍历过temp值了,如果是说明找到了
// 否则,把当前值加到备忘录,继续往后遍历
int temp = target - nums[i];
if(!memo.containsKey(temp)) {
memo.put(nums[i],i);
} else {
return new int[]{i,memo.get(temp)};
}
}
return null;
}
其实一个题目会涉及到多种数据结构,两数之和涉及到了备忘录的思想,hash表的数据结构;
2、栈结构的使用;有效括号
/** 给定一个只包括 '(',')','{','}','[',']' 的字符串 s ,判断字符串是否有效。
有效字符串需满足:
左括号必须用相同类型的右括号闭合。
左括号必须以正确的顺序闭合。*/
public boolean isValid(String s) {
if(s == null || s.length() == 0) return false;
Map<Character,Character> map = new HashMap<>();
map.put('{','}');
map.put('(',')');
map.put('[',']');
if(s.length() > 0 && !map.containsKey(s.charAt(0))) return false;
LinkedList<Character> stack = new LinkedList<Character>() {{ add('?'); }};
// 用于括号判断时,将左括号放入,如果遇到不包含左括号的将栈顶的元素
// 取出看是否于当前元素匹配,如果匹配则继续向下判断,反之则代表括号顺序有误,结束
for(Character c : s.toCharArray()){
if(map.containsKey(c)) stack.addLast(c);
else if(map.get(stack.removeLast()) != c) return false;
}
return stack.size() == 1;
}
}
3、队列结构应用
下面用两个队列来实现栈的题目来举例,两个队列实现栈
class MyStack {
// queue1队列用来存放元素
Queue<Integer> queue1;
// queue2队列用来转移元素
Queue<Integer> queue2;
public MyStack() {
// java队列和数据结构都可以用linkedList来模拟
queue1 = new LinkedList<Integer>();
queue2 = new LinkedList<Integer>();
}
// 每次插入一个元素都插入到queue2里,然后将queue1的元素全部转移到queue2里;
// 然后将队列交换,这样每次从queue1里取出的元素就是最后进去的,满足后进先出的规则
public void push(int x) {
queue2.offer(x);
while (!queue1.isEmpty()) {
queue2.offer(queue1.poll());
}
Queue<Integer> temp = queue1;
queue1 = queue2;
queue2 = temp;
}
// 从queue1弹出栈顶元素
public int pop() {
return queue1.poll();
}
// 从queue1顶部获取元素
public int top() {
return queue1.peek();
}
// 从queue1判断元素是否为空
public boolean empty() {
return queue1.isEmpty();
}
}
4、二叉树结构应用
题目链接参照leetcode 二叉树遍历
1、第一种实现,是使用递归来完成,代码结构简单
public List<Integer> inorderTraversal2(TreeNode root) {
List<Integer> res = new ArrayList<Integer>();
dfs(res,root);
return res;
}
private void dfs(List<Integer> res,TreeNode root) {
if (root == null) return;
// 递归调用左树
dfs(res,root.left);
// 处理根节点
res.add(root.val);
// 处理右子树
dfs(res,root.right);
}
2、第二种实现通过迭代,而且使用栈的后进先出特性来维护结点数据,可以先让指针移动到最左的叶子结点,依次向上遍历,按照左根右的顺序依次遍历到根节点,再向右
public List<Integer> inorderTraversal(TreeNode root) {
// 最后用于结果返回的容器
List<Integer> result = new ArrayList<>();
// 用于存放子结点数据
Stack<TreeNode> stack = new Stack<>();
while(!stack.empty()||root!=null) {
// 将根节点入栈,搜索到左子结点
while(root!=null) {
stack.push(root);
root = root.left;
}
// 出栈,将左子结点弹出,然后处理元素值
root = stack.pop();
result.add(root.val);
root = root.right;
}
return result;
}
5、哈夫曼树应用
举例题目,字典树 - 词典中最长的单词
class Solution {
// 字典树
Trie root = new Trie();
private int maxLength = 0;
private String res = "";
public String longestWord(String[] words) {
for(String word : words) {
insert(word);
}
getMaxLengthWord(root,0);
return res;
}
private void insert(String word) {
int n = word.length();
Trie node = this.root;
for(int i=0;i<n;i++) {
char c = word.charAt(i);
//
if(node.children[c-'a'] == null) {
node.children[c-'a'] = new Trie();
}
node = node.children[c-'a'];
}
node.isEnd = true;
node.word = word;
}
// 递归判断最大深度和字符串。在递归代码里,
// 将需要拿到的最优值提升到最外层
private void getMaxLengthWord(Trie node,int deep) {
if (deep > 0 && !node.isEnd) {
return ;
}
if(deep > maxLength) {
maxLength = deep;
res = node.word;
}
for(int i=0;i<26;i++) {
if(node.children[i] != null) {
getMaxLengthWord(node.children[i],deep+1);
}
}
}
// 定义好字典树的数据结构,每个结点维护好下一个结点的引用数组
class Trie{
Trie[] children;
boolean isEnd;
String word;
public Trie() {
// 26个字母,维护好26个结点
children = new Trie[26];
isEnd = false;
}
}
}
基础篇主要介绍基础的数据结构,但应对更多复杂的题型需要依赖基础数据结构设计出更复杂的数据结构,譬如单调栈,字典树,优先级队列,前缀和,备忘录等综合型数据结构。这一部分将放在进阶篇来介绍并针对相应的数据结构列出便于解决的常见问题。