数据结构和算法
一、数据结构和算法概述
1.1 什么是数据结构和算法
数据结构是一门研究非数值计算的程序设计问题中的操作对象,以及他们之间的关系和操作等相关问题的学科。
就是把数据元素按照一定的关系组织起来的集合,用来组织和存储数据
1.2 数据结构分类
传统上,我们可以把数据结构分为逻辑结构和物理结构两大类。
**逻辑结构分类: **
**集合结构:**集合结构中数据元素除了属于同一个集合外,他们之间没有任何其他的关系。
**线性结构:**线性结构中的数据元素之间存在一对一的关系
树形结构:树形结构中的数据元素之间存在一对多的层次关系
图形结构:图形结构的数据元素是多对多的关系
物理结构分类:
逻辑结构在计算机中真正的表示方式(又称为映像)称为物理结构,也可以叫做存储结构。常见的物理结构有顺序存储结构、链式存储结构
顺序存储结构:
把数据元素放到地址连续的存储单元里面,其数据间的逻辑关系和物理关系是一致的 ,比如我们常用的数组就是顺序存储结构。
链式存储结构:
是把数据元素存放在任意的存储单元里面,这组存储单元可以是连续的也可以是不连续的。此时,数据元素之间并不能反映元素间的逻辑关系,因此在链式存储结构中引进了一个指针存放数据元素的地址,这样通过地址就可以找到相关联数据元素的位置
算法是指解题方案的准确而完整的描述,是一系列解决问题的清晰指令,算法代表着用系统的方法解决问题的策略机制。也就是说,能够对一定规范的输入,在有限时间内获得所要求的输出。
1.2 算法的时间和空间复杂度
算法函数中n最高次幂越小,算法效率越高
1.算法函数中的常数可以忽略;2.算法函数中最高次幂的常数因子可以忽略;3.算法函数中最高次幂越小,算法效率越高。
描述 | 增长的数量级 | 说明 | 举例 |
---|---|---|---|
常数级别 | 1 | 普通语句 | 两个数相加 |
对数级别 | logN | 二分策略 | 二分查找 |
线性级别 | N | 循环 | 找最大值 |
线性对数级别 | NlogN | 分治 | 归并排序 |
平方级别 | N^2 | 双层循环 | 检查所有元素对 |
立方级别 | N^3 | 三层循环 | 检查所有三元组 |
指数级别 | 2^N | 穷举查找 | 检查所有子集 |
复杂程度从低到高依次为:
O(1)<O(logn)<O(n)<O(nlogn)<O(n2)<O(n3)
空间复杂度:一个字节是8位
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-CpDxV763-1629866469951)(C:\Users\huo\AppData\Roaming\Typora\typora-user-images\image-20210817215917824.png)]
java中数组被被限定为对象,他们一般都会因为记录长度而需要额外的内存,一个原始数据类型的数组一般需要24字节的头信息(16个自己的对象开销,4字节用于保存长度以及4个填充字节)再加上保存值所需的内存。
二、排序算法
常用排序算法分析
时间复杂度 | 空间复杂度 | |||||
---|---|---|---|---|---|---|
类别 | 排序方法 | 平均情况 | 最好情况 | 最坏情况 | 辅助内存 | 稳定性 |
插入类 | 插入排序 | O(N^2) | O(N) | O(N^2) | O(1) | 稳定 |
希尔排序 | O(N^1.3-2) | O(N) | O(N^2) | O(1) | 不稳定 | |
选择类 | 选择排序 | O(N^2) | O(N^2) | O(N^2) | O(1) | 不稳定 |
堆排序 | O(NlogN) | O(NlogN) | O(NlogN) | O(1) | 不稳定 | |
交换类 | 冒泡排序 | O(N^2) | O(N) | O(N^2) | O(1) | 稳定 |
快速排序 | O(NlogN) | O(NlogN) | O(N^2) | O(logn) | 不稳定 | |
归并排序 | O(NlogN) | O(NlogN) | O(NlogN) | O(n) | 稳定 | |
基数排序 | O(d(r+n)) | O(d(n+rd)) | O(d(r+n)) | O(rd+n) | 稳定 |
r是关键字的基数,d是长度,n是关键字的个数
2.1.冒泡排序
public void bubbleSort(int[] arr) {
for (int i = 0; i < arr.length - 1; i++) {
boolean flag = true;
for (int j = 0; j < arr.length - 1 - i; j++) {
if (arr[j] > arr[j + 1]) {
int temp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = temp;
flag = false;
}
}
if (flag) break;
}
}
冒泡排序的时间复杂度分析
冒泡排序使用了双层for循环,其中内层循环的循环体是真正完成排序的代码,所以,我们分析冒泡排序的时间复杂度,主要分析一下内层循环体的执行次数即可。在最坏情况下,也就是假如要排序的元素为{6,5,4,3,2,1}逆序,那么:元素比较的次数为:
(N-1)+(N-2)+(N-3)+…+2+1=((N-1)+1)*(N-1)/2=N^2/2-N/2;
元素交换的次数为:
(N-1)+(N-2)+(N-3)+…+2+1=((N-1)+1)*(N-1)/2=N^2/2-N/2;
总执行次数为:
(N2/2-N/2)+(N2/2-N/2)=N^2-N;
按照大O推导法则,保留函数中的最高阶项那么最终冒泡排序的时间复杂度为O(N^2)
2.2 选择排序
public void selectSort(int[] arr) {
int index, min;
for (int i = 0; i < arr.length; i++) {
min = arr[i];
index = i;
for (int j = i + 1; j < arr.length; j++) {
if (min > arr[j]) {
min = arr[j];
index = j;
}
}
if (i != index) {
arr[index] = arr[i];
arr[i] = min;
}
}
}
2.3 插入排序
public void insertSort(int[] arr) {
for (int i = 1; i < arr.length; i++) {
int val = arr[i], j = i - 1;
for (; j >= 0 && arr[j] > val; j--) {
arr[j + 1] = arr[j];
}
arr[j + 1] = val;
}
}
2.4 希尔排序
排序原理:
1.选定一个增长量h,按照增长量h作为数据分组的依据,对数据进行分组;
- 对分好组的每一组数据完成插入排序;
3.减小增长量,最小减为1,重复第二步操作
public void shellSort(int[] arr) {
for (int gap = arr.length / 2; gap > 0; gap--) {
for (int i = gap; i < arr.length; i++) {
int j = i, temp = arr[j];
if (temp < arr[j - gap]) {
while (j - gap >= 0 && temp < arr[j - gap]) {
arr[j] = arr[j - gap];
j -= gap;
}
arr[j] = temp;
}
}
}
}
2.4 归并排序
public void mergeSort(int[] arr, int left, int right, int[] temp) {
if (left < right) {
int mid = left + (right - left) / 2;
mergeSort(arr, left, mid, temp);
mergeSort(arr, mid + 1, right, temp);
merge(arr, left, mid, right, temp);
}
}
public void merge(int[] arr, int left, int mid, int right, int[] temp) {
int i = left, j = mid + 1, t = 0, tempLeft;
while (i <= mid && j <= right) {
temp[t++] = arr[i] >= arr[j] ? arr[j++] : arr[i++];
}
while (i <= mid) {
temp[t++] = arr[i++];
}
while (j <= right) {
temp[t++] = arr[j++];
}
t = 0;
tempLeft = left;
while (tempLeft <= right) {
arr[tempLeft++] = temp[t++];
}
}
2.6 快速排序
public void quickSort(int[] arr, int left, int right) {
if (left >= right) return;
int i = left - 1, j = right + 1, mid = arr[(left + right) >> 1], temp;
while (i < j) {
do i++; while (i < j && arr[i] < mid);
do j--; while (i < j && arr[j] > mid);
if (i < j) {
temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
}
quickSort(arr, left, j);
quickSort(arr, j + 1, right);
}
三、线性表
3.1 线性表
get(i):不难看出,不论数据元素量N有多大,只需要一次eles[i]就可以获取到对应的元素,所以时间复杂度为O(1)
insert(int i,T t):每一次插入,都需要把i位置后面的元素移动一次,随着元素数量N的增大,移动的元素也越多,时间复杂为O(n);
remove(int i):每一次删除,都需要把i位置后面的元素移动一次,随着数据量N的增大,移动的元素也越多,时间复杂度为O(n);
由于顺序表的底层由数组实现,数组的长度是固定的,所以在操作的过程中涉及到了容器扩容操作。这样会导致顺序表在使用过程中的时间复杂度不是线性的,在某些需要扩容的结点处,耗时会突增,尤其是元素越多,这个问题越明显
3.2 链表
单向链表
单向链表是链表的一种,它由多个结点组成,每个结点都由一个数据域和一个指针域组成,数据域用来存储数据,
指针域用来指向其后继结点。链表的头结点的数据域不存储数据,指针域指向第一个真正存储数据的结点。
快慢指针可以解决链表是否有环问题,慢指针走一步,快指针走两步,如果相遇说明有环,当相遇后定义临时节点指向头节点,每次走一步,当与慢指针相遇时就是有环链表的入口
双向链表
双向链表也叫双向表,是链表的一种,它由多个结点组成,每个结点都由一个数据域和两个指针域组成,数据域用来存储数据,其中一个指针域用来指向其后继结点,另一个指针域用来指向前驱结点。链表的头结点的数据域不存储数据,指向前驱结点的指针域值为null,指向后继结点的指针域指向第一个真正存储数据的结点。
循环链表
循环链表,顾名思义,链表整体要形成一个圆环状。在单向链表中,最后一个节点的指针为null,不指向任何结点,因为没有下一个元素了。要实现循环链表,我们只需要让单向链表的最后一个节点的指针指向头结点即可。
3.3 栈
栈是一种基于先进后出(FIFO)的数据结构,是一种只能在一端进行插入和删除操作的特殊线性表。它按照先进后出的原则存储数据,先进入的数据被压入栈底,最后的数据在栈顶,需要读数据的时候从栈顶开始弹出数据(最后一个数据被第一个读出来)。我们称数据进入到栈的动作为压栈,数据从栈中出去的动作为弹栈.
代码实现(基于链表):
public class Stack<T>{
private Node head; //记录首结点
private int N; //当前栈的元素个数
private class Node {
public T item;
public Node next;
public Node(T item, Node next) {
this.item = item;
this.next = next;
}
}
public Stack() {
this.head = new Node(null, null);
this.N = 0;
}
public boolean isEmpty() {//判断栈是否为空,是返回true,否返回false
return N == 0;
}
public int size() {//获取栈中元素的个数
return N;
}
public void push(T t) { //向栈中压入元素;//记录首结点
Node newNode = new Node(t, null);//创建压入的节点
Node next = head.next; //存储头节点原来的下个节点
head.next = newNode; //头节点指向新节点
newNode.next = next; //新节点指向next
N++;
}
public T pop() { //弹出栈顶元素
if (head.next == null) {
return null;
}
Node first = head.next;
head.next = first.next;
N--;
return first.item;
}
}
括号匹配问题:
public boolean isValid(String s) {
if (s.length() % 2 != 0) return false;
char[] arr = s.toCharArray();
Stack<Character> stack = new Stack<>();
for (char c : arr) {
if (c == '(') stack.push(')');
else if (c == '{') stack.push('}');
else if (c == '[') stack.push(']');
else if (stack.isEmpty() || stack.pop() != c) return false;
}
return stack.isEmpty();
}
栈应用—逆波兰表达式求值:
中缀表达式:
中缀表达式就是我们平常生活中使用的表达式,例如:1+3*2,2-(1+3)等等,中缀表达式的特点是:二元运算符置于两个操作数中间。
中缀表达式是人们最喜欢的表达式方式,因为简单,易懂。但是对于计算机来说就不是这样了,因为中缀表达式的运算顺序不具有规律性。不同的运算符具有不同的优先级,如果计算机执行中缀表达式,需要解析表达式语义,做大量的优先级相关操作。
逆波兰表达式(后缀表达式)
逆波兰表达式是波兰逻辑学家J・卢卡西维兹(J・ Lukasewicz)于1929年首先提出的一种表达式的表示方法,后缀表达式的特点:运算符总是放在跟它相关的操作数之后
public int evalRPN(String[] tokens) {
Stack<Integer> stack = new Stack<>();
for (String token : tokens) {
if (!(token.equals("+") || token.equals("*")
|| token.equals("/") || token.equals("-"))) {
stack.push(Integer.parseInt(token));
} else {
int a = stack.pop(), b = stack.pop();
if (token.equals("-")) {
stack.push(b - a);
} else if (token.equals("+")) {
stack.push(a + b);
} else if (token.equals("*")) {
stack.push(b * a);
} else {
stack.push(b / a);
}
}
}
return stack.pop();
}
3.4 队列
队列是一种基于先进先出(FIFO)的数据结构,是一种只能在一端进行插入,在另一端进行删除操作的特殊线性表,它按照先进先出的原则存储数据,先进入的数据,在读取数据时先读被读出来。
public class Queue<T>{
private Node head; //记录首结点
private int N; //当前栈的元素个数
private Node last; //记录最后一个结点
private class Node {
public T item;
public Node next;
public Node(T item, Node next) {
this.item = item;
this.next = next;
}
}
public Queue() {
this.head = new Node(null, null);
this.last = null;
this.N = 0;
}
public boolean isEmpty() { //判断队列是否为空,是返回true,否返回false
return N == 0;
}
public int size() { //获取队列中元素的个数
return N;
}
public void add(T t) { //往队列中插入一个元素
Node node = new Node(t, null);
if (last == null) {
last = node;
head.next = last;
} else {
Node oldLast = last;
last = node;
oldLast.next = last;
}
N++;
}
public T pop() { //从队列中拿出一个元素
if (isEmpty()) {
return null;
}
Node next = head.next;
head.next = next.next;
N--;
if (isEmpty()) {
last = null;
}
return next.item;
}
}
四、树
4.1 树的相关定义
树是我们计算机中非常重要的一种数据结构,同时使用树这种数据结构,可以描述现实生活中的很多事物,例如家谱、单位的组织架构、等等。树是由n(n>=1)个有限结点组成一个具有层次关系的集合。把它叫做“树”是因为它看起来像一棵倒挂的树,也就是说它是根朝上,而叶朝下的。
树具有以下特点:
1.每个结点有零个或多个子结点;
2.没有父结点的结点为根结点;
3.每一个非根结点只有一个父结点;
4.每个结点及其后代结点整体上可以看做是一棵树,称为当前结点的父结点的一个子树;
树的相关术语
**结点的度:**一个结点含有的子树的个数称为该结点的度;
**叶结点:**度为0的结点称为叶结点,也可以叫做终端结点
**分支结点:**度不为0的结点称为分支结点,也可以叫做非终端结点
**结点的层次:**从根结点开始,根结点的层次为1,根的直接后继层次为2,以此类推
**结点的层序编号:**将树中的结点,按照从上层到下层,同层从左到右的次序排成一个线性序列,把他们编成连续的自然数。
**树的度:**树中所有结点的度的最大值
树的高度(深度):树中结点的最大层次
**森林:**m(m>=0)个互不相交的树的集合,将一颗非空树的根结点删去,树就变成一个森林;给森林增加一个统一的根
结点,森林就变成一棵树
4.2 二叉树
**基本定义:**二叉树就是度不超过2的树(每个结点最多有两个子结点)
**满二叉树:**一个二叉树,如果每一个层的结点树都达到最大值,则这个二叉树就是满二叉树
**完全二叉树:**叶节点只能出现在最下层和次下层,并且最下面一层的结点都集中在该层最左边的若干位置的二叉树
二叉查找树代码实现:
public class BinaryTree<Key extends Comparable<Key>, Value> {
private Node root;//记录根结点
private int N;//记录树中元素的个数
private class Node {
public Key key; //存储键
private Value value; // 存储值
public Node left; // 记录左子结点
public Node right; // 记录右子结点
public Node(Key key, Value value, Node left, Node right) {
this.key = key;
this.value = value;
this.left = left;
this.right = right;
}
}
public int size() {
return N;
}
public void put(Key key, Value value) { //向树中添加元素
root = put(root, key, value);
}
public Node put(Node x, Key key, Value value) { //向指定的树添加并返回
//如果x为空
if (x == null) { //树空
N++;
return new Node(key, value, null, null);
}
//x不为空
int cmp = key.compareTo(x.key);
if (cmp > 0) {
x.right = put(x.right, key, value);
} else if (cmp < 0) {
x.left = put(x.left, key, value);
} else {
x.value = value;
}
return x;
}
public Value get(Key key) {
return get(root, key);
}
public Value get(Node x, Key key) {
if (x == null) {
return null;//树空
}
int cmp = key.compareTo(x.key);
if (cmp > 0) {
return get(x.right, key);
} else if (cmp < 0) {
return get(x.left, key);
} else {
return x.value;
}
}
public void delete(Key key) {
delete(root, key);
}
public Node delete(Node x, Key key) {
if (x == null) {
return null;//树空
}
int cmp = key.compareTo(x.key);
if (cmp > 0) {
x.right = delete(x.right, key);
} else if (cmp < 0) {
x.left = delete(x.left, key);
} else {
N--;
//找到了要删除的节点
//如果要删除节点的左右树为空的话
if (x.right == null) {
return x.left;
}
if (x.left == null) {
return x.right;
}
//不为空 找到右子树中最小的节点 (即一直找右子树的左子树)
Node minNode = x.right;
while (minNode.left != null) {
minNode = minNode.left;
}
//找到后 删除右子树中最小的节点
Node n = x.right;
while (n.left != null) {
if (n.left.left != null) {
n.left = null;
} else {
n = n.left;
}
}
minNode.left = x.left;
minNode.right = x.right;
x = minNode;//要删除节点的父节点指向minNode
}
return x;
}
}
五、堆
5.1堆的定义
堆是计算机科学中一类特殊的数据结构的统称,堆通常可以被看做是一棵完全二叉树的数组对象。
堆的特性:
1.它是完全二叉树,除了树的最后一层结点不需要是满的,其它的每一层从左到右都是满的,如果最后一层结点不是满的,那么要求左满右不满。
2.它通常用数组来实现。
具体方法就是将二叉树的结点按照层级顺序放入数组中,根结点在位置1,它的子结点在位置2和3,而子结点的子结点则分别在位置4,5,6和7,以此类推
如果一个结点的位置为k,则它的父结点的位置为**[k/2],而它的两个子结点的位置则分别为2k和2k+1**。这样,在不使用指针的情况下,我们也可以通过计算数组的索引在树中上下移动:从a[k]向上一层,就令k等于k/2,向下一层就令k等于2k或2k+1。
3.每个结点都大于等于它的两个子结点。这里要注意堆中仅仅规定了每个结点大于等于它的两个子结点,但这两个子结点的顺序并没有做规定,跟我们之前学习的二叉查找树是有区别的。
5.2 代码实现
public class Heap<T extends Comparable<T>> {
private T[] items;
private int N;
public Heap(int capacity) {
this.items = (T[]) new Comparable[capacity + 1];
this.N = 0;
}
private boolean less(int i, int j) {
return items[i].compareTo(items[j]) < 0;
}
private void exch(int i, int j) {
T temp = items[i];
items[i] = items[j];
items[j] = temp;
}
public void insert(T t) { //往堆中插入元素
items[++N] = t;
swim(N);
}
private void swim(int k) { //上浮 使索k处元素位于正确位置
while (k > 1) {
//比较当前和节点和父节点
if (less(k / 2, k)) { //如果父节点比他小
exch(k / 2, k);
}
k = k / 2;
}
}
//删除堆中最大元素
public T delMax() {
T max = items[1];
exch(1, N); //交换索引1的元素和最大索引的元素
items[N] = null; //删除最大元素
N--;
sink(1); //通过下沉 让堆重新有序
return max;
}
private void sink(int k) {
///循环对比当前节点和两个子节点(2*k,2*k+1)
while (2 * k <= N) {
int max; //获取子节点的较大值索引
if (2 * k + 1 <= N) { //有右子节点
if (less(2 * k, 2 * k + 1)) {
max = 2 * k + 1;
} else {
max = 2 * k;
}
} else {
max = 2 * k;
}
if (!less(k, max)) {
break;
}
//获取后与当前节点交换
exch(k, max);
k = max;
}
}
}
5.3 堆排序
实现步骤:
1.构造堆;
2.得到堆顶元素,这个值就是最大值;
3.交换堆顶元素和数组中的最后一个元素,此时所有元素中的最大元素已经放到合适的位置;
4.对堆进行调整,重新让除了最后一个元素的剩余元素中的最大值放到堆顶;
5.重复2~4这个步骤,直到堆中剩一个元素为止。
堆构造过程
堆的构造,最直观的想法就是另外再创建一个和新数组数组,然后从左往右遍历原数组,每得到一个元素后,添加到新数组中,并通过上浮,对堆进行调整,最后新的数组就是一个堆。
上述的方式虽然很直观,也很简单,但是我们可以用更聪明一点的办法完成它。创建一个新数组,把原数组0length-1的数据拷贝到新数组的1length处,再从新数组长度的一半处开始往1索引处扫描(从右往左),然后对扫描到的每一个元素做下沉调整即可。
堆排序过程
对构造好的堆,我们只需要做类似于堆的删除操作,就可以完成排序。
1.将堆顶元素和堆中最后一个元素交换位置;
2.通过对堆顶元素下沉调整堆,把最大的元素放到堆顶(此时最后一个元素不参与堆的调整,因为最大的数据已经到了数组的最右边)
3.重复1~2步骤,直到堆中剩最后一个元素。
代码实现:
public class HeapSort {
private static boolean less(Comparable[] heap, int i, int j) {//判断heap堆中索引i处的元素是否小于索引j处的元素
return heap[i].compareTo(heap[j]) < 0;
}
private static void exch(Comparable[] heap, int i, int j) {//交换heap堆中i索引和j索引处的值
Comparable temp = heap[i];
heap[i] = heap[j];
heap[j] = temp;
}
private static void createHeap(Comparable[] source, Comparable[] heap) {//根据原数组source,构造出堆heap
System.arraycopy(source, 0, heap, 1, source.length);
for (int i = heap.length / 2; i > 0; i--) {
sink(heap, i, heap.length - 1);
}
}
public static void sort(Comparable[] source) {//对source数组中的数据从小到大排序
Comparable[] heap = new Comparable[source.length + 1];
createHeap(source, heap);
int N = heap.length - 1;
while (N != 1) {
exch(heap, 1, N);
N--;
sink(heap, 1, N);
}
System.arraycopy(heap, 1, source, 0, source.length);
}
private static void sink(Comparable[] heap, int target, int range) {
//在heap堆中,对target处的元素做下沉,范围是0~range。
while (2 * target <= range) {
//找出当前节点的较大子节点
int max;
if (2 * target + 1 <= range) {
if (less(heap, 2 * target, 2 * target + 1)) {
max = 2 * target + 1;
} else {
max = 2 * target;
}
} else {
max = 2 * target;
}
if (!less(heap, target, max)) {
break;
}
exch(heap, target, max);
target = max;
}
}
}
六. 优先队列
普通的队列是一种先进先出的数据结构,元素在队列尾追加,而从队列头删除。在某些情况下,我们可能需要找出队列中的最大值或者最小值,例如使用一个队列保存计算机的任务,一般情况下计算机的任务都是有优先级的,我们需要在这些计算机的任务中找出优先级最高的任务先执行,执行完毕后就需要把这个任务从队列中移除。普通的队列要完成这样的功能,需要每次遍历队列中的所有元素,比较并找出最大值,效率不是很高,这个时候,我们就可以使用一种特殊的队列来完成这种需求,优先队列。
优先队列按照其作用不同,可以分为以下两种:
最大优先队列
可以获取并删除队列中最大的值
最大的元素放在数组的索引1处。
每个结点的数据总是大于等于它的两个子结点的数据。
public class MaxPriorityQueue<T extends Comparable<T>> {
private T[] items;
private int N;
public MaxPriorityQueue(int capacity) {
this.items = (T[]) new Comparable[capacity + 1];
this.N = 0;
}
//获取队列中元素的个数
public int size() {
return N;
}
//判断队列是否为空
public boolean isEmpty() {
return N == 0;
}
//判断堆中索引i处的元素是否小于索引j处的元素
private boolean less(int i, int j) {
return items[i].compareTo(items[j]) < 0;
}
//交换堆中i索引和j索引处的值
private void exch(int i, int j) {
T tmp = items[i];
items[i] = items[j];
items[j] = tmp;
}
//往堆中插入一个元素
public void insert(T t) {
items[++N] = t;
swim(N);
}
//删除堆中最大的元素,并返回这个最大元素
public T delMax() {
T max = items[1];
//交换索引1处和索引N处的值
exch(1, N);
items[N] = null; //删除最后位置上的元素
N--; //个数-1
sink(1);
return max;
}
//上浮算法 使索引k处的元素能在堆中处于一个正确的位置
private void swim(int k) {
while (k > 1) {
if (less(k / 2, k)) {
exch(k / 2, k);
}
k = k / 2;
}
}
private void sink(int k) {
while (2 * k <= N) {
int max; //获取子节点的较大值索引
if (2 * k + 1 <= N) { //有右子节点
if (less(2 * k, 2 * k + 1)) {
max = 2 * k + 1;
} else {
max = 2 * k;
}
} else {
max = 2 * k;
}
if (!less(k, max)) {
break;
}
exch(k, max);
k = max;
}
}
}
最小优先队列
可以获取并删除队列中最小的值
最小的元素放在数组的索引1处。
每个结点的数据总是小于等于它的两个子结点的数据。
public class MinPriorityQueue<T extends Comparable<T>> {
private T[] items;
private int N;
public MinPriorityQueue(int capacity) {
this.items = (T[]) new Comparable[capacity + 1];
this.N = 0;
}
//获取队列中元素的个数
public int size() {
return N;
}
//判断队列是否为空
public boolean isEmpty() {
return N == 0;
}
//判断堆中索引i处的元素是否小于索引j处的元素
private boolean less(int i, int j) {
return items[i].compareTo(items[j]) < 0;
}
//交换堆中i索引和j索引处的值
private void exch(int i, int j) {
T tmp = items[i];
items[i] = items[j];
items[j] = tmp;
}
//往堆中插入一个元素
public void insert(T t) {
items[++N] = t;
swim(N);
}
//删除堆中最小的元素,并返回这个最小元素
public T delMin() {
T min = items[1];
//交换索引1处和索引N处的值
exch(1, N);
items[N] = null; //删除最后位置上的元素
N--; //个数-1
sink(1);
return min;
}
//上浮算法 使索引k处的元素能在堆中处于一个正确的位置
private void swim(int k) {
while (k > 1) {
if (less(k, k / 2)) {//如果当前节点比父节点小
exch(k, k / 2);
}
k = k / 2;
}
}
private void sink(int k) {
while (2 * k <= N) {
int min; //获取子节点的较小值索引
if (2 * k + 1 <= N) { //有右子节点
if (less(2 * k, 2 * k + 1)) {
min = 2 * k;
} else {
min = 2 * k + 1;
}
} else {
min = 2 * k;
}
if (less(k, min)) {
break;
}
exch(k, min);
k = min;
}
}
}
七、树的进阶
7.1 平衡树
2-3查找树
为了保证查找树的平衡性,我们需要一些灵活性,因此在这里我们允许树中的一个结点保存多个键。确切的说,我们将一棵标准的二叉查找树中的结点称为2-结点(含有一个键和两条链),而现在我们引入3-结点,它含有两个键和三条链。2-结点和3-结点中的每条链都对应着其中保存的键所分割产生的一个区间。
2-3查找树的定义
一棵2-3查找树要么为空,要么满足满足下面两个要求:
2-结点:
含有一个键(及其对应的值)和两条链,左链接指向2-3树中的键都小于该结点,右链接指向的2-3树中的键都大于该结点。
3-结点:
含有两个键(及其对应的值)和三条链,左链接指向的2-3树中的键都小于该结点,中链接指向的2-3树中的键都位于该结点的两个键之间,右链接指向的2-3树中的键都大于该结点。
一棵完全平衡的2-3树具有以下性质:
-
任意空链接到根结点的路径长度都是相等的。
-
4-结点变换为3-结点时,树的高度不会发生变化,只有当根结点是临时的4-结点,分解根结点时,树高+1。
-
2-3树与普通二叉查找树最大的区别在于,普通的二叉查找树是自顶向下生长,而2-3树是自底向上生长。
7.2 红黑树
红黑树主要是对2-3树进行编码,红黑树背后的基本思想是用标准的二叉查找树(完全由2-结点构成)和一些额外的信息(替换3-结点)来表示2-3树。我们将树中的链接分为两种类型:
**红链接:**将两个2-结点连接起来构成一个3-结点; **黑链接:**则是2-3树中的普通链接。
确切的说,我们将3-结点表示为由由一条左斜的红色链接(两个2-结点其中之一是另一个的左子结点)相连的两个2-结点。这种表示法的一个优点是,我们无需修改就可以直接使用标准的二叉查找树的get方法。
红黑树是含有红黑链接并满足下列条件的二叉查找树:
- 红链接均为左链接;
- 没有任何一个结点同时和两条红链接相连;
- 该树是完美黑色平衡的,即任意空链接到根结点的路径上的黑链接数量相同;
**左旋:**当某个结点的左子结点为黑色,右子结点为红色,此时需要左旋。
右旋:当某个结点的左子结点是红色,且左子结点的左子结点也是红色,需要右旋
public class RedBlackTree<Key extends Comparable<Key>, Value> {
private Node root;
private int N;
private static final boolean RED = true; //红色链接
private static final boolean BLACK = false; //黑色链接
private class Node {
public Key key;
private Value value;
public Node left;
public Node right;
public boolean color;
public Node(Key key, Value value, Node left, Node right, boolean color) {
this.key = key;
this.value = value;
this.left = left;
this.right = right;
this.color = color;
}
}
public int size() {
return N;
}
public boolean isRed(Node x) { //判断当前节点的父指向链接是否为红色
if (x == null) return false;
return x.color == RED;
}
//左旋转
private Node rotateLeft(Node h) {
Node x = h.right;
h.right = x.left;
x.left = h;
x.color = h.color;
h.color = RED;
return x;
}
//右旋转
private Node rotateRight(Node h) {
Node x = h.left;
h.left = x.right;
x.right = h;
x.color = h.color;
h.color = RED;
return x;
}
//颜色反转 相当于完成拆分4-节点
private void flipColors(Node h) {
h.color = RED;
h.left.color = BLACK;
h.right.color = BLACK;
}
//在整个树上完成插入操作
public void put(Key key, Value val) {
root = put(root, key, val);
root.color = BLACK;
}
public Node put(Node h, Key key, Value val) {
if (h == null) {
N++;
return new Node(key, val, null, null, RED);
}
//比较h节点的键和key的大小
int cmp = key.compareTo(h.key);
if (cmp < 0) { //继续往左
h.left = put(h.left, key, val);
} else if (cmp > 0) { //继续往右
h.right = put(h.right, key, val);
} else {
h.value = val;
}
//进行左旋
if (isRed(h.right) && !isRed(h.left)) {
h = rotateLeft(h);
}
//进行右旋
if (isRed(h.left) && isRed(h.left.left)) {
h = rotateRight(h);
}
//颜色反转
if (isRed(h.left) && isRed(h.right)) {
flipColors(h);
}
return h;
}
public Value get(Key key) {
return get(root, key);
}
public Value get(Node x, Key key) {
if (x == null) return null;
int cmp = key.compareTo(x.key);
if (cmp < 0) {
return get(x.left, key);
} else if (cmp > 0) {
return get(x.right, key);
} else {
return x.value;
}
}
}
7.3 B、B+ 树
B树是一种树状数据结构,它能够存储数据、对其进行排序并允许以O(logn)的时间复杂度进行查找、顺序读取、插入和删除等操作。
B树的特性
B树中允许一个结点中包含多个key,可以是3个、4个、5个甚至更多,并不确定,需要看具体的实现。现在我们选择一个参数M,来构造一个B树,我们可以把它称作是M阶的B树,那么该树会具有如下特点:
每个结点最多有M-1个key,并且以升序排列;
每个结点最多能有M个子结点;
根结点至少有两个子结点;
在实际应用中B树的阶数一般都比较大(通常大于100),所以,即使存储大量的数据,B树的高度仍然比较小,这样在某些应用场景下,就可以体现出它的优势。
B+树
B+树是对B树的一种变形树,它与B树的差异在于:
-
非叶结点仅具有索引作用,也就是说,非叶子结点只存储key,不存储value;
-
树的所有叶结点构成一个有序链表,可以按照key排序的次序遍历全部数据。
B+树和B树的对比
B+ 树的优点在于:
1.由于B+树在非叶子结点上不包含真正的数据,只当做索引使用,因此在内存相同的情况下,能够存放更多的key。
2.B+树的叶子结点都是相连的,因此对整棵树的遍历只需要一次线性遍历叶子结点即可。而且由于数据顺序排列并且相连,所以便于区间查找和搜索。而B树则需要进行每一层的递归遍历。
B树的优点在于:
由于B树的每一个节点都包含key和value,因此我们根据key查找value时,只需要找到key所在的位置,就能找到value,但B+树只有叶子结点存储数据,索引每一次查找,都必须一次一次,一直找到树的最大深度处,也就是叶子结点的深度,才能找到value。
八、并查集
并查集是一种树型的数据结构 ,并查集可以高效地进行如下操作:
查询元素p和元素q是否属于同一组、合并元素p和元素q所在的组
public class UF_Tree_Weighted {
private int[] eleAndGroup; //记录节点元素和该元素所在分组的标识
private int count;
private int[] sz;
public UF_Tree_Weighted(int N) {
this.count = N;
eleAndGroup = new int[N];
for (int i = 0; i < eleAndGroup.length; i++) {
eleAndGroup[i] = i;
}
this.sz = new int[N];
for (int i = 0; i < sz.length; i++) {
sz[i] = 1;
}
}
public int count() {
return count;
}
public int find(int p) {
while (true) {
if (p == eleAndGroup[p]) return p;
p = eleAndGroup[p];
}
}
public boolean connected(int p, int q) {
return find(p) == find(q);
}
public void union(int p, int q) {
int pRoot = find(p);
int qRoot = find(q);
if (pRoot == qRoot) return;
if (sz[pRoot] < sz[qRoot]) {
eleAndGroup[pRoot] = qRoot;
sz[qRoot] += sz[pRoot];
} else {
eleAndGroup[qRoot] = pRoot;
sz[pRoot] += sz[qRoot];
}
this.count--;
}
}
九、图
9.1 图的概述
**定义:**图是由一组顶点和一组能够将两个顶点相连的边组成的
图的分类:
按照连接两个顶点的边的不同,可以把图分为以下两种:
无向图:边仅仅连接两个顶点,没有其他含义;
有向图:边不仅连接两个顶点,并且具有方向;
图的相关术语
**相邻顶点:**当两个顶点通过一条边相连时,我们称这两个顶点是相邻的,并且称这条边依附于这两个顶点。
**度:**某个顶点的度就是依附于该顶点的边的个数
**子图:**是一幅图的所有边的子集(包含这些边依附的顶点)组成的图;
**路径:**是由边顺序连接的一系列的顶点组成
**环:**是一条至少含有一条边且终点和起点相同的路径
**连通图:**如果图中任意一个顶点都存在一条路径到达另外一个顶点,那么这幅图就称之为连通图
**连通子图:**一个非连通图由若干连通的部分组成,每一个连通的部分都可以称为该图的连通子图
存储方式: 1.邻接矩阵:
使用一个V*V的二维数组int[V] [V]adj,把索引的值看做是顶点;
如果顶点v和顶点w相连,我们只需要将adj[v] [w]和adj[w] [v]的值设置为1,否则设置为0即可
邻接表
1.使用一个大小为V的数组 Queue[V] adj,把索引看做是顶点;
2.每个索引处adj[v]存储了一个队列,该队列中存储的是所有与该顶点相邻的其他顶点
9.2 深度优先搜索
所谓的深度优先搜索,指的是在搜索时,如果遇到一个结点既有子结点,又有兄弟结点,那么先找子结点,然后找兄弟结点
//图的深度优先搜索
public class DepthFirstSearch {
private boolean[] marked; //索引代表顶点,值表示当前顶点是否已经被搜索
private int count; //记录图中有多少个顶点与s顶点相通
public DepthFirstSearch(Graph G, int s) {
this.marked = new boolean[G.V()];
this.count = 0;
dfs(G, s);
}
private void dfs(Graph G, int v) {//找到G图中V顶点的所有相通顶点
marked[v] = true;
for (Integer w : G.adj(v)) {
if (!marked[w]) {
dfs(G, w);
}
}
count++;
}
public boolean marked(int w) {
return marked[w];
}
public int count() {
return count;
}
}
9.3 广度优先搜索
所谓的深度优先搜索,指的是在搜索时,如果遇到一个结点既有子结点,又有兄弟结点,那么先找兄弟结点,然后找子结点。
//图的广度优先搜索
public class BreadthFirstSearch {
private boolean[] marked; //索引代表顶点,值表示当前顶点是否已经被搜索
private int count; // 记录有多少个顶点与s顶点相通
private Queue<Integer> waitSearch; // 用来存储待搜索邻接表的点
public BreadthFirstSearch(Graph G, int s) {
this.marked = new boolean[G.V()];
this.count = 0;
this.waitSearch = new Queue<>();
bfs(G, s);
}
private void bfs(Graph G, int v) {
marked[v] = true;
waitSearch.add(v);
while (!waitSearch.isEmpty()) {
Integer wait = waitSearch.pop();
for (Integer w : G.adj(wait)) {
if (!marked[w]) {
marked[w] = true;
waitSearch.add(w);
count++;
}
}
}
}
public boolean marked(int w) {
return marked[w];
}
public int count() {
return count;
}
}
9.4 有向图
定义:
有向图是一副具有方向性的图,是由一组顶点和一组有方向的边组成的,每条方向的边都连着一对有序的顶点。
出度:
由某个顶点指出的边的个数称为该顶点的出度。
入度:
指向某个顶点的边的个数称为该顶点的入度。
有向路径:
由一系列顶点组成,对于其中的每个顶点都存在一条有向边,从它指向序列中的下一个顶点。
有向环:
一条至少含有一条边,且起点和终点相同的有向路径。
public class Digraph {
private final int V; //顶点数目
private int E; //边的数目
private Queue<Integer>[] adj; //邻接表
public Digraph(int v) {
this.V = v;
this.E = 0;
this.adj = new Queue[V];
for (int i = 0; i < adj.length; i++) {
adj[i] = new Queue<>();
}
}
public int V() {
return V;
}
public int E() {
return E;
}
public void addEdge(int v, int w) {
adj[v].add(w);
E++;
}
public Queue<Integer> adj(int v) {
return adj[v];
}
private Digraph reverse() {
Digraph r = new Digraph(V);
for (int i = 0; i < V; i++) {
for (Integer w : adj[i]) {
r.addEdge(w, i);
}
}
return r;
}
}
9.5 拓扑排序
给定一副有向图,将所有的顶点排序,使得所有的有向边均从排在前面的元素指向排在后面的元素,此时就可以明确的表示出每个顶点的优先级
**检测有向图中的环 ** 当我们深度搜索时:
在如果当前顶点正在搜索,则把对应的onStack数组中的值改为true,标识进栈;
如果当前顶点搜索完毕,则把对应的onStack数组中的值改为false,标识出栈;
如果即将要搜索某个顶点,但该顶点已经在栈中,则图中有环;
public class DirectedCycle {
//索引代表顶点,值表示当前顶点是否已经被搜索
private boolean[] marked;
//记录图中是否有环
private boolean hasCycle;
// 索引代表顶点,使用栈的思想,记录当前顶点有没有已经处于正在搜索的有向路径上
private boolean[] onStack;
public DirectedCycle(Digraph G) {
this.marked = new boolean[G.V()];
this.hasCycle = false;
this.onStack = new boolean[G.V()];
for (int i = 0; i < G.V(); i++) {
if (!marked[i]) {
dfs(G, i);
}
}
}
public void dfs(Digraph G, int v) { //深度优先搜索判断图中是否有环
marked[v] = true;
onStack[v] = true;
for (Integer w : G.adj(v)) {
if (!marked[w]) {
dfs(G, w);
}
if (onStack[w]) {
hasCycle = true;
return;
}
}
onStack[v] = false;
}
public boolean hasCycle() {
return hasCycle;
}
}
基于深度优先的顶点排序
添加了一个栈reversePost用来存储顶点,当我们深度搜索图时,每搜索完毕一个顶点,把该顶点放入到reversePost中,这样就可以实现顶点排序
public class DepthFirstOrder {
//索引代表顶点,值表示当前顶点是否已经被搜索
private boolean[] marked;
// 使用栈,存储顶点序列
private Stack<Integer> reversePost;
public DepthFirstOrder(Digraph G) {
this.marked = new boolean[G.V()];
this.reversePost = new Stack<>();
for (int v = 0; v < G.V(); v++) {
if (!marked[v]) {
dfs(G, v);
}
}
}
private void dfs(Digraph G, int v) {
marked[v] = true;
for (Integer w : G.adj(v)) {
if (!marked[w]) {
dfs(G, w);
}
}
reversePost.push(v);
}
public Stack<Integer> reversePost() {
return reversePost;
}
}
拓扑排序实现
public class TopoLogical {
private Stack<Integer> order;
public TopoLogical(Digraph G) {
DirectedCycle cycle = new DirectedCycle(G);
if (!cycle.hasCycle()) {s
DepthFirstOrder depthFirstOrder = new DepthFirstOrder(G);
order = depthFirstOrder.reversePost();
}
}
private boolean isCycle() {
return order == null;
}
public Stack<Integer> order() {
return order;
}
}
9.6 最小生成树
图的生成树是它的一棵含有其所有顶点的无环连通子图,一副加权无向图的最小生成树它的一棵权值(树中所有边的权重之和)最小的生成树
public class PrimMST {
private Edge[] edgeTo;
private double[] disTo;
private boolean[] marked;
private IndexMinPriorityQueue<Double> pq;
public PrimMST(EdgeWeightedGraph G) {
this.edgeTo = new Edge[G.V()];
this.disTo = new double[G.V()];
for (int i = 0; i < disTo.length; i++) {
disTo[i] = Double.POSITIVE_INFINITY;
}
this.marked = new boolean[G.V()];
pq = new IndexMinPriorityQueue<>(G.V());
disTo[0] = 0.0;
pq.insert(0, 0.0);
while (!pq.isEmpty()) {
visit(G, pq.delMin());
}
}
private void visit(EdgeWeightedGraph G, int v) {
marked[v] = true;
for (Edge e : G.adj(v)) {
int w = e.other(v);
if (marked[w]) {
continue;
}
if (e.weight() < disTo[w]) {
edgeTo[w] = e;
disTo[w] = e.weight();
if (pq.contains(w)) {
pq.changeItem(w, e.weight());
} else {
pq.insert(w, e.weight());
}
}
}
}
public Queue<Edge> edges() {
Queue<Edge> edges = new Queue<>();
for (int i = 0; i < edgeTo.length; i++) {
if (edgeTo[i] != null) {
edges.add(edgeTo[i]);
}
}
return edges;
}
}
9.7 kruskal算法
在设计API的时候,使用了一个MinPriorityQueue pq存储图中所有的边,每次使用pq.delMin()取出权重最小的边,并得到该边关联的两个顶点v和w,通过uf.connect(v,w)判断v和w是否已经连通,如果连通,则证明这两个顶点在同一棵树中,那么就不能再把这条边添加到最小生成树中,因为在一棵树的任意两个顶点上添加一条边,都会形成环,而最小生成树不能有环的存在,如果不连通,则通过uf.connect(v,w)把顶点v所在的树和顶点w所在的树合并成一棵树,并把这条边加入到mst队列中,这样如果把所有的边处理完,最终mst中存储的就是最小生树的所有边。
public class KruskalMST {
//保存最小生成树的所有边
private Queue<Edge> mst;
//索引代表顶点,使用uf.connect(v,w)可以判断顶点v和顶点w是否在同一颗树中,使用uf.union(v,w)可以 把顶点v所在的树和顶点w所在的树合并
private UF_Tree_Weighted uf;
// 存储图中所有的边,使用最小优先队列,对边按照权重进行排序
private MinPriorityQueue<Edge> pq;
public KruskalMST(EdgeWeightedGraph G) {
this.mst = new Queue<>();
this.uf = new UF_Tree_Weighted(G.V());
this.pq = new MinPriorityQueue<>(G.E() + 1);
for (Edge e : G.edges()) {
pq.insert(e);
}
while (!pq.isEmpty() && mst.size() < G.V() - 1) {
Edge e = pq.delMin();
int v = e.either();
int w = e.other(v);
if (uf.connected(v, w)) {
continue;
}
uf.union(v, w);
mst.add(e);
}
}
public Queue<Edge> edges() {
return mst;
}
}
加权有向图:
public class EdgeWeightedDigraph {
//顶点总数
private final int V;
//边的总数
private int E;
//邻接表
private Queue<DirectedEdge>[] adj;
//创建一个含有V个顶点的空加权有向图
public EdgeWeightedDigraph(int V) {
//初始化顶点数量
this.V = V;
//初始化边的数量
this.E = 0;
//初始化邻接表
this.adj = new Queue[V];
for (int i = 0; i < adj.length; i++) {
adj[i] = new Queue<DirectedEdge>();
}
}
//获取图中顶点的数量
public int V() {
return V;
}
//获取图中边的数量
public int E() {
return E;
}
//向加权有向图中添加一条边e
public void addEdge(DirectedEdge e) {
//边e是有方向的,所以只需要让e出现在起点的邻接表中即可
int v = e.from();
adj[v].add(e);
E++;
}
//获取由顶点v指出的所有的边
public Queue<DirectedEdge> adj(int v) {
return adj[v];
}
//获取加权有向图的所有边
public Queue<DirectedEdge> edges() {
//遍历图中的每一个顶点,得到该顶点的邻接表,遍历得到每一条边,添加到队列中返回即可
Queue<DirectedEdge> allEdges = new Queue<>();
for (int v = 0; v < V; v++) {
for (DirectedEdge edge : adj[v]) {
allEdges.add(edge);
}
}
return allEdges;
}
}
9.8 Dijiestra
Disjstra算法的实现和Prim算法很类似,构造最短路径树的每一步都是向这棵树中添加一条新的边,而这条新的边
是有效横切边pq队列中的权重最小的边。
public class DijkstraSP {
//索引代表顶点,值表示从顶点s到当前顶点的最短路径上的最后一条边
private DirectedEdge[] edgeTo;
//索引代表顶点,值从顶点s到当前顶点的最短路径的总权重
private double[] distTo;
//存放树中顶点与非树中顶点之间的有效横切边
private IndexMinPriorityQueue<Double> pq;
//根据一副加权有向图G和顶点s,创建一个计算顶点为s的最短路径树对象
public DijkstraSP(EdgeWeightedDigraph G, int s) {
//初始化edgeTo
this.edgeTo = new DirectedEdge[G.V()];
//初始化distTo
this.distTo = new double[G.V()];
for (int i = 0; i < distTo.length; i++) {
distTo[i] = Double.POSITIVE_INFINITY;
}
//初始化pq
this.pq = new IndexMinPriorityQueue<>(G.V());
//找到图G中以顶点s为起点的最短路径树
//默认让顶点s进入到最短路径树中
distTo[s] = 0.0;
pq.insert(s, 0.0);
//遍历pq
while (!pq.isEmpty()) {
relax(G, pq.delMin());
}
}
//松弛图G中的顶点v
private void relax(EdgeWeightedDigraph G, int v) {
for (DirectedEdge edge : G.adj(v)) {
//获取到该边的终点w
int w = edge.to();
//通过松弛技术,判断从起点s到顶点w的最短路径是否需要先从顶点s到顶点v,然后再由顶点v到顶点w
if (distTo(v) + edge.weight() < distTo(w)) {
distTo[w] = distTo[v] + edge.weight();
edgeTo[w] = edge;
//判断pq中是否已经存在顶点w,如果存在,则更新权重,如果不存在,则直接添加
if (pq.contains(w)) {
pq.changeItem(w, distTo(w));
} else {
pq.insert(w, distTo(w));
}
}
}
}
//获取从顶点s到顶点v的最短路径的总权重
public double distTo(int v) {
return distTo[v];
}
//判断从顶点s到顶点v是否可达
public boolean hasPathTo(int v) {
return distTo[v] < Double.POSITIVE_INFINITY;
}
//查询从起点s到顶点v的最短路径中所有的边
public Queue<DirectedEdge> pathTo(int v) {
//判断从顶点s到顶点v是否可达,如果不可达,直接返回null
if (!hasPathTo(v)) {
return null;
}
//创建队列对象
Queue<DirectedEdge> allEdges = new Queue<>();
while (true) {
DirectedEdge e = edgeTo[v];
if (e == null) {
break;
}
allEdges.add(e);
v = e.from();
}
return allEdges;
}
}