为了提前准备今年的秋招,差不多开始复习算法与数据结构了,以前学算法的时候是以C语言为基础的,现在很久没碰C语言了,决定这次复习用Java实现,基本上是参考的Java算法神书:《数据结构与算法分析——Java语言描述》。
排序
时间复杂度O(n2)的排序
选择排序
首先在未排序序列中找到最小(大)元素,存放到排序序列的起始位置,然后,再从剩余未排序元素中继续寻找最小(大)元素,然后放到已排序序列的末尾。以此类推,直到所有元素均排序完毕。
public class SelectionSort {
//将数组array中的元素从小到大排序
private static void selectionSort(int[] array) {
for (int i = 0; i < array.length; i++) {
//每完成一次大循环都要找出[i, array.length)中的最小值
int minIndex = i;//每次大循环开始之前将i位置的值默认为最小值
for (int j = i + 1; j < array.length; j++) {
if (array[j] < array[minIndex]) {
//找到了比minIndex位置更小的值
minIndex = j;
}
}
swap(array, minIndex, i);
}
}
//交换数组array中两个位置的值
private static void swap(int[] array, int indexOne, int indexTwo) {
int temp = array[indexOne];
array[indexOne] = array[indexTwo];
array[indexTwo] = temp;
}
}
插入排序
插入排序(以从小到大为例):从index为1的元素开始(即数组里的第二个元素),依次向前判断,若该元素比前一个元素小,则将该元素与前一个元素交换位置,直到该元素大于前一个元素,说明该元素已经处在了正确的位置(比前者大后者小),接着是从index为2的元素开始,以此类推。
为什么是从数组里第二个元素开始?答:因为第一个元素前面没有元素了,不需要进行判断。
public class InsertSort {
//插入排序
private static void insertSort(int[] array) {
for (int i = 1; i < array.length; i++) {
for (int j = i; j > 0; j--) {
if (array[j] < array[j - 1]) {
//如果array[j]比array[j-1]小,说明array[j]应该向前移动
swap(array, j, j - 1);
}else {
//若array[j]比array[j-1]大说明array[j]已经在正确的位置,可以提前结束这次大循环
break;
}
}
}
}
//交换数组中两个元素的位置
private static void swap(int[] array, int indexOne, int indexTwo) {
int temp = array[indexOne];
array[indexOne] = array[indexTwo];
array[indexTwo] = temp;
}
}
可以看到,插入排序和选择排序有一个很大的区别,插入排序可以提前终止内层循环(当array[j] > array[j-1]时),这样在数组是近乎有序的情况下,可以通过提前终止内层循环大幅度提高算法的效率。
对于上面这种插入排序的算法实际上还有优化的空间,因为上面这种算法每当这一轮要排序的元素比前一个元素要小的时候就要进行一次元素的交换,一次元素的交换相当于三次元素的赋值,因此如果我们将交换改成赋值操作的话,算法的速度就将得到提升,那么如何转变思想,让赋值操作能够替代交换操作呢?
我们可以复制一份当前这一轮要进行排序的元素,然后让该元素与前一个元素比较,若该元素小,那么就让前一个元素向后移动一次(移动到了该元素的位置),再将该元素和前前元素比较,若该元素依然小,那么就让前前元素向后移动一次(移动到了正常排序下该元素的前一个元素的位置),以此类推,直到该元素比其之前的某一个元素(取名为temp)大,那么就不再改变位置,直接将该元素赋值给temp的后一个元素即可(后一个元素在之前的比较中已经移动到了其原本位置的后面的位置)。
这样就将交换的操作转变为了赋值的操作,具体代码如下:
//插入排序
private static void insertSort(int[] array) {
for (int i = 1; i < array.length; i++) {
int element = array[i]; //将这一轮要排序的元素保存起来
int j;//j用来保存这一轮要排序的元素的正确位置
for (j = i; j > 0; j--) {
if (array[j - 1] > element) {
//array[j-1]比这一轮要排序的元素大,那么将array[j-1]向后移动一个位置
array[j] = array[j - 1];
}else {
//array[j-1]比这一轮要排序的元素小,那么当前位置j就是元素的正确位置
array[j] = element;
break;
}
}
}
}
冒泡排序
冒泡排序是所有排序中最简单的一种,以升序为例,冒泡的思想是每一轮比较都从头开始,依次比较相邻两个元素的大小,如果前一个元素比后一个元素大,就交换两个元素的位置。
public class BubbleSort {
private static void bubbleSort(int[] array) {
for (int i = 0; i < array.length; i++) {
for (int j = 1; j < array.length; j++){
if (array[j] < array[j - 1]) {
swap(array, j, j-1);
}
}
}
}
//交换数组中两个元素的位置
private static void swap(int[] array, int indexOne, int indexTwo) {
int temp = array[indexOne];
array[indexOne] = array[indexTwo];
array[indexTwo] = temp;
}
}
时间复杂度O(nlogn)的排序
归并排序
归并排序的思想:将数组元素划分为前后两个部分(再将前面半部分划分为两个部分,将后面半部分划分为两个部分,依次类推,因此需要使用递归),对前后两个部分分别排序,完成后再将两个部分归并。
归并排序递归形式的实现思路大体分成三步,这三步在递归调用的方法mergeSort(array,left,right)
中体现出来:
- 对前半部分进行归并排序
- 对后半部分进行归并排序
- 将前、后两部分进行归并
在最后一步归并的过程中,我们需要创建一个临时数组temp将array[left,right]中的数据备份出来,之后让i指向temp的左边界,让j指向temp的中间元素的下一个位置,让k指向array[left];之后通过判断temp[i]和temp[j]的大小来决定array[k]的元素应该是temp[i]还是temp[j],若是temp[i],则在进行赋值后需要进行i的自增,反之进行j的自增;还需要注意的一点就是在这些步骤之前需要判断i和j是否越界(即i是否超出了mid,j是否超出了right),若i越界则无需再判断temp[i]和temp[j]的大小,直接将temp[j]赋值给array[k]即可,之后j自增。
归并排序源代码:
//归并排序
public class MergeSort {
//算法类不允许产生实例
private MergeSort() {
}
//对数组array进行归并排序(从小到大)
public static void mergeSort(int[] array) {
//对array数组的0~length-1位置的元素进行归并排序
mergeSort(array, 0, array.length - 1);
}
//递归调用,对array数组的[left,right]区间的元素进行归并排序
private static void mergeSort(int[] array, int left, int right) {
//递归停止条件:左边界大于等于右边界
if (left >= right) {
return;
}
//获取[left,right]的中间位置
int mid = left + (right - left) / 2;
//对[left,mid],[mid + 1,right]的元素分别进行归并排序
mergeSort(array, left, mid);
mergeSort(array, mid + 1, right);
//对排好序的两部分进行归并
merge(array, left, mid, right);
}
//将array[left,mid]和array[mid+1,right]进行归并
private static void merge(int[] array, int left, int mid, int right) {
//创建一个临时数组
int[] temp = new int[right - left + 1];
//将array中要进行归并的元素放入临时数组中
for (int i = left; i <= right; i++) {
temp[i - left] = array[i];
}
//将[left,mid],[mid+1,right]前后两个部分进行归并
int i = left;
int j = mid + 1;
for (int k = left; k <= right; k++) {
if (i > mid) {
array[k] = temp[j - left];
j++;
} else if (j > right) {
array[k] = temp[i - left];
i++;
} else if (temp[i - left] > temp[j - left]) {
array[k] = temp[j - left];
j++;
} else {
array[k] = temp[i - left];
i++;
}
}
}
}
堆排序
这里的堆排序使用的数据结构二叉堆基于本篇博客下的最大二叉堆实现,详细实现过程请参考博客对应位置,这里不再复述堆的相关内容。
堆排序实际上就是将一个数组中的数据放入最大(最小)二叉堆中,根据最大(最小)二叉堆的性质(父节点大于(小于)左右孩子节点)来获得从大到小(从小到大)的排序。
下面陈述两种基于最大二叉堆的排序,第一种是将数组中的元素调用insert方法逐一插入到堆中,由于insert方法本身就实现了插入完成依然满足堆性质,因此插入完成后数组元素就自然形成了一个最大二叉堆,我们只要调用extractMax方法依次取出堆中最大值就可以实现降序排序
//利用最大二叉堆heap对数组array进行从大到小的排序
private static void heapSort(int[] array) {
//创建最大二叉堆对象
MaxHeap heap = new MaxHeap();
//首先将数组元素全部放入二叉堆
for (int i = 0; i < array.length; i++) {
heap.insert(array[i]);
}
//再依次从二叉堆中取出最大的元素放回到数组中,完成从大到小的堆排序
int heapSize = heap.size();//由于extractMax方法是出队操作,会对size造成影响,因此需要提前保存二叉堆的size
for (int i = 0; i < heapSize; i++) {
array[i] = heap.extractMax();
}
}
第二种方法与第一种方法的主要不同点在于堆的构建方法,第一种方法是调用insert方法将数组元素一个个放入堆中(入队),但是第二种方式在构建堆时调用了堆的带参构造,这个参数就是待排序的数组,为了更好理解我将对应的构造方法放出来:
//构造方法,传入参数为一个整型数组,这个构造方法是将传入的整型数组构造成一个最大二叉堆
public MaxHeap(int[] array) {
theItems = new int[array.length + 1];
capacity = array.length;
//将数组中的元素放入堆中,注意堆从index为1的位置开始存放元素
for (int i = 0; i < array.length; i++) {
theItems[i + 1] = array[i];
size++;
}
//将堆中的元素放到正确的位置,思想是从最后一个有叶子节点的父节点往前调用shiftDown, size/2就是最后一个有叶子结点的父节点的index
for (int i = size / 2; i >= 1; i--) {
shiftDown(i);
}
}
可以看到这种构造堆的方式是先将数组中所有元素放入堆中(这里只是单纯的无序放入,并没有满足堆的性质),然后从最后一个带有叶子结点的父节点开始往前依次向下放入到正确的位置,最终完成堆的构建,最终排序算法如下:
//利用最大二叉堆heap对数组array进行从大到小的排序
private static void heapSort(int[] array) {
//创建最大二叉堆对象
MaxHeap heap = new MaxHeap(array);
//再依次从二叉堆中取出最大的元素放回到数组中,完成从大到小的堆排序
int heapSize = heap.size();//由于extractMax方法是出队操作,会对size造成影响,因此需要提前保存二叉堆的size
for (int i = 0; i < heapSize; i++) {
array[i] = heap.extractMax();
}
}
第二种堆排序比第一种堆排序的速度更快。
快速排序
基本的快速排序
快速排序的思想:选数组中的第一个元素作为标准,放到数组中合适的位置,这个位置的左边所有元素小于该元素,右边的所有元素大于该元素,之后再分别对其左边和右边的元素进行相同的操作(partition)。
partition的实现:取数组给定范围中的第一个元素(假设位置为l)为标准元素v,规定[l+1,j]中的所有元素均小于v,[j+1,i-1]中的所有元素均大于v,i为当前检查的元素所在位置,若array[i] > v,则将i向右移动一位;若array[i] < v,则将array[i]和array[j+1]更换位置。
快速排序源代码:
public class QuickSort {
//不允许生成实例化对象
private QuickSort() {
}
//快速排序
public static void quickSort(int[] array) {
quickSort(array, 0, array.length - 1);
}
//对[left,right]范围内的元素进行快速排序
private static void quickSort(int[] array, int left, int right) {
//先进行递归终止判断
if (left >= right) {
return;
}
/**
* 一次快速排序分成三步:
* 1、对[left,right]进行partition操作
* 2、对[left,p]进行快速排序
* 3、对[p+1,right]进行快速排序
* 其中p是在[left,right]中选中的标准元素所在位置
*/
int p = partition(array, left, right);//对[left,right]进行partition操作
quickSort(array, left, p);//对[left,p]进行快速排序
quickSort(array, p + 1, right);//对[p+1,right]进行快速排序
}
//对数组中[left,right]内的元素进行partition,返回值为标准元素所在位置
private static int partition(int[] array, int left, int right) {
//一般选取left,即选定数组范围内的第一个元素作为标准元素
int v = array[left];
//需要满足array[left+1,j] < array[v],array[j+1,i-1] > array[v]
int j = left;//开始时不存在array[left+1,j]这样的区间小于p,因此j初始值为left,不为left + 1,因为array[left+1,left+1]就表示已经有小于p的区间了
for (int i = left + 1; i <= right; i++) {
if (array[i] < v) {
swap(array, i, j + 1);
j++;
}
}
swap(array, left, j);
return j;
}
//交换数组中两元素的位置
private static void swap(int[] array, int index1, int index2) {
int temp = array[index1];
array[index1] = array[index2];
array[index2] = temp;
}
}
随机化优化
基本的快速排序解决大多数情况下的无序数据的排序问题,但如果遇见了一个基本有序的数组,快速排序会退化为O(n2)级别的排序。这是因为基础的快速排序是取选定数组范围内的第一个元素作为标准元素,但若是数组是[1,2,3,4,5,6,7,8,9,10]这样的有序数组的话,第一次取1为标准元素,这时1所在的位置就已经是正确的位置,那么就需要取出其右边所有元素再进行一次快速排序,这样递归下去,整个递归树的高度就为n,而在无序的数组中递归树的高度为logn,因此快速排序的时间复杂度就会从O(n2)退化到O(nlogn)。
对于这种情况的优化也十分简单,我们只要每次随机取出一个元素作为标准元素就可以了。但是为了不重写partition操作的太多代码,因此我们会将随机选择的元素和数组选定范围内的第一个元素进行交换,这样就不用改动partition的逻辑了。
//对数组中[left,right]内的元素进行partition,返回值为标准元素所在位置
private static int partition(int[] array, int left, int right) {
//随机选择[left,right]内的一个元素作为标准元素,并将这个元素和选定范围内第一个元素交换位置
swap(array, left, (int)(Math.random()*(right-left+1)+left));
int v = array[left];
//需要满足array[left+1,j] < array[v],array[j+1,i-1] > array[v]
int j = left;//开始时不存在array[left+1,j]这样的区间小于p,因此j初始值为left,不为left + 1,因为array[left+1,left+1]就表示已经有小于p的区间了
for (int i = left + 1; i <= right; i++) {
if (array[i] < v) {
swap(array, i, j + 1);
j++;
}
}
swap(array, left, j);
return j;
}
表
ArrayList的实现
ArrayList是Java是常用的一个数据结构,这是一种可扩充容量的数组,所以其内部实现就是基于数组,只不过在添加元素时若数组已满则自动扩容,下面这是我自己实现的一个简单的ArrayList,各位可以参考一下。由于List类型的数据结构都实现了Collection接口,而Collection接口又继承了Iterable接口,因此在此我也实现了Iterable接口。
public class MyArrayList<AnyType> implements Iterable{
//List默认容量
private static final int DEFAULT_CAPACITY = 10;
//内部用来存放数据的数组
private AnyType[] theItems;
//当前ArrayList元素个数
private int theSize;
//实例化ArrayList时进行初始化
public MyArrayList() {
init();
}
//ArrayList初始化
private void init() {
theItems = (AnyType[]) new Object[DEFAULT_CAPACITY];
theSize = 0;
}
//在ArrayList末尾添加元素
public boolean add(AnyType element) {
add(theSize, element);
return true;
}
//在指定位置添加元素
public boolean add(int idx, AnyType element) {
if (theSize == theItems.length) {
//若当前ArrayList已满,则首先进行扩容
AnyType[] theOldItems = theItems;
theItems = (AnyType[]) new Object[theSize * 2];
for (int i = 0; i < theSize; i++) {
theItems[i] = theOldItems[i];
}
}
//将要插入位置及其之后的的所有元素向后移动一位,将idx位置空出来
for (int i = theSize; i > idx; i--) {
theItems[i] = theItems[i - 1];
}
//插入到idx位置
theItems[idx] = element;
//元素个数加一
theSize++;
return true;
}
//得到指定位置的元素
public AnyType get(int idx) {
if (idx >= theSize || idx < 0) {
throw new ArrayIndexOutOfBoundsException();
}
return theItems[idx];
}
//删除指定位置的元素
public AnyType remove(int idx) {
if (idx >= theSize || idx < 0) {
throw new ArrayIndexOutOfBoundsException();
}
//保存要删除的元素
AnyType removedItem = theItems[idx];
//从指定位置开始依次将后一个元素向前移动
for (int i = idx; i < theSize - 1; i++) {
theItems[i] = theItems[i + 1];
}
return removedItem;
}
//返回一个自定义迭代器
@Override
public Iterator iterator() {
return new MyArrayListIterator();
}
//自定义迭代器类
private class MyArrayListIterator implements Iterator<AnyType>{
//当前位置表示要被查看的下一个元素的数组下标
private int current = 0;
//判断是否还有下一个元素
@Override
public boolean hasNext() {
return current < theSize;
}
//返回下一个元素
@Override
public AnyType next() {
if (!hasNext()) {
throw new NoSuchElementException();
}
return theItems[current++];
}
//删除由next()方法返回的最新的项
@Override
public void remove() {
MyArrayList.this.remove(--current);
}
}
}
LinkedList的实现
LinkedList本质上是一个双向(不循环)链表,其在表头和表尾分别有两个空节点。在进行LinkedList的设计过程中有几点比较重要的知识点:
- LinkedList对外提供的方法永远是基于索引idx(类似于数组下标),不允许用户直接传一个Node的对象,对于Node对象的操作必须声明为private,在LinkedList内部进行,如getNode()
- LinkedList中的增加、删除操作的关键是找到对应的节点对象,因此不论是add还是remove方法,都是通过传入的idx找到对应的节点对象进行操作。
- modCount的作用是保护LinkedList的线程安全,任何通过Iterator修改LinkedList结构的行为都会同时更新expectedModCount和modCount,使这两个值相等。通过LinkedList对象修改其结构的方法只更新modCount。所以假设有两个线程A和B。A通过Iterator遍历并修改LinkedList,而B,与此同时,通过对象修改其结构,那么Iterator的相关方法就会抛出异常。这是相对容易发现的由线程竞争造成的错误。
- 由于所有的操作实质最终都是找到节点对象,即最终都要调用getNode方法,因此在空表时调用aad方法时需要特殊处理,因为此时getNode不能得到任何节点。
public class MyLinkedList<AnyType> implements Iterable{
//LinkedList节点个数
private int theSize;
//LinkedList的头节点和尾节点
private Node<AnyType> head;
private Node<AnyType> tail;
//记录LinkedList结构被修改的次数
private int modCount = 0;
//LinkedList构造方法
public MyLinkedList() {
//初始化
init();
}
//初始化一个空的LinkedList
private void init() {
//创建头节点和尾节点
head = new Node<>(null, null ,null);
tail = new Node<>(null, head, null);
head.next = tail;//空表时头节点的下一个节点就是尾节点
//空表size为0
theSize = 0;
//修改了一次LinkedList结构
modCount++;
}
//返回LinkedList节点个数
public int size() {
return theSize;
}
//判断LinkedList是否为空
public boolean isEmpty() {
return theSize == 0;
}
//向表尾插入节点
public void add(AnyType data) {
if (theSize == 0) {
//插入的第一个元素需要特殊处理
Node<AnyType> node = new Node<>(data, head, tail);
head.next = node;
tail.prev = node;
modCount++;
theSize++;
return;
}
add(theSize, data);
}
//向指定位置插入节点
public void add(int idx, AnyType data) {
addBefore(getNode(idx), data);
}
//向指定节点的前面插入一个新节点,此方法不对外开放
private void addBefore(Node<AnyType> node, AnyType data) {
//新节点的前驱就是指定节点的前驱,后继就是指定节点
Node<AnyType> newNode = new Node<>(data, node.prev, node);
//修改新节点的前驱节点和后继节点的后继节点和前驱结点
newNode.prev.next = newNode;
newNode.next.prev = newNode;
//节点数自增
theSize++;
//修改了一次结构
modCount++;
}
//得到指定位置节点的数据
public AnyType get(int idx) {
return getNode(idx).data;
}
//得到指定位置的节点,此方法不对外开放
private Node<AnyType> getNode(int idx) {
return getNode(idx, 0, theSize - 1);
}
//得到lower和upper这个区间内的指定位置的节点,此方法不对外开放
private Node<AnyType> getNode(int idx, int lower, int upper) {
Node<AnyType> node;
if (idx < lower || idx > upper) {
throw new IndexOutOfBoundsException();
}
//根据idx在链表的前半部分还是后半部分决定是从头遍历还是从尾遍历
if (idx < theSize / 2) {
//在前半部分,从表头的下一个元素(即第一个有数据的节点)开始遍历
node = head.next;
for (int i = 0; i < idx; i++) {
node = node.next;
}
} else {
//在后半部分,从表尾开始遍历
node = tail;
for (int i = theSize; i > idx; i--) {
node = node.prev;
}
}
return node;
}
//移除指定位置的节点
public AnyType remove(int idx) {
return remove(getNode(idx));
}
//移除指定节点,此方法不对外开放
private AnyType remove(Node<AnyType> node) {
node.prev.next = node.next;
node.next.prev = node.prev;
theSize++;
modCount++;
return node.data;
}
//节点类 静态内部类
private static class Node<AnyType>{
//前一个节点和下一个节点
public Node<AnyType> prev;
public Node<AnyType> next;
//该节点存放的数据
public AnyType data;
public Node(AnyType data, Node<AnyType> prev, Node<AnyType> next) {
this.data = data;
this.prev = prev;
this.next = next;
}
}
@Override
public Iterator iterator() {
return new LinkedListIterator();
}
//自定义迭代器
private class LinkedListIterator implements Iterator<AnyType>{
//要访问的下一个节点
private Node<AnyType> current = head.next;
//创建迭代器时默认和modCount相等
private int expectedModCount = modCount;
//
private boolean okToRemove = false;
@Override
public boolean hasNext() {
return current != tail;
}
@Override
public AnyType next() {
if (modCount != expectedModCount) {
throw new ConcurrentModificationException();
}
if (!hasNext()) {
throw new NoSuchElementException();
}
AnyType nextData = current.data;
current = current.next;
okToRemove = true;
return nextData;
}
@Override
public void remove() {
if (modCount != expectedModCount) {
throw new ConcurrentModificationException();
}
if (!okToRemove) {
throw new IllegalStateException();
}
//移除前一次调用next时返回的节点
MyLinkedList.this.remove(current.prev);
expectedModCount++;
okToRemove = false;
}
}
}
Stack栈的实现
栈比起List更为简单,其主要特点就是只能对栈顶元素进行操作,因此可以看做是ArrayList或LinkedList的简化版,Stack主要有两种实现方式,一种是顺序栈,一种是链栈。顺序栈就是以数组为基础进行设计的栈,链栈则是以单链表为基础进行设计的栈。
顺序栈
public class MyArrayStack<AnyType>{
//默认容量为10
private static final int DEFAULT_CAPACITY = 10;
//存储数据的数组
private AnyType[] theItems;
//元素数量
private int theSize;
//栈顶指针 -1代表空栈
private int top;
//栈的构造方法,内部进行初始化
public MyArrayStack() {
init();
}
//初始化空栈
private void init() {
theItems = (AnyType[]) new Object[DEFAULT_CAPACITY];
theSize = 0;
top = -1;
}
//判断是否为空栈
public boolean isEmpty() {
return theSize == 0;
}
//获取栈顶元素
public AnyType peek() {
if (theSize == 0) {
throw new EmptyStackException();
}else {
return theItems[top];
}
}
//向栈内添加元素(只能向栈顶加入)
public void push(AnyType element) {
//判断是否满栈
if (theSize == theItems.length) {
//满栈则扩容
AnyType[] newItems = (AnyType[]) new Object[2 * theSize + 1];
for (int i = 0; i < theSize; i++) {
newItems[i] = theItems[i];
}
theItems = newItems;
}
theItems[++top] = element;
theSize++;
}
//弹出栈顶元素
public AnyType pop() {
if (theSize == 0) {
//空栈则抛出异常
throw new EmptyStackException();
}
AnyType popedItem = theItems[top--];
theSize--;
return popedItem;
}
}
Queue队列的实现
参考博客:java数据结构与算法之(Queue)队列设计与实现
队列是一种先进先出的数据结构,出队的操作对应的是队头,入队的操作对应的是队尾。和Stack一样,Queue的实现也分成顺序队列和链队列,其底层实现分别是数组和链表。
顺序队列
关于顺序队列(底层都是利用数组作为容器)的实现,我们将采用顺序循环队列的结构来实现,在给出实现方案前先来分析一下为什么不直接使用顺序表作为底层容器来实现。实际上采用顺序表实现队列时,入队操作直接执行顺序表尾部插入操作,其时间复杂度为O(1),出队操作直接执行顺序表头部删除操作,其时间复杂度为O(n),主要用于移动元素,效率低,既然如此,我们就把出队的时间复杂度降为O(1)即可,为此在顺序表中添加一个头指向下标front和尾指向下标,出队和入队时只要改变front、rear的下标指向取值即可,此时无需移动元素,因此出队的时间复杂度也就变为O(1)。其过程如下图所示:
从图的演示过程,(a)操作时,是空队列此时front和rear都为-1,同时可以发现虽然我们通过给顺序表添加front和rear变量记录下标后使用得出队操作的时间复杂度降为O(1),但是却出现了另外一个严重的问题,那就是空间浪费,从图中的(d)和(e)操作可以发现,20和30出队后,遗留下来的空间并没有被重新利用,反而是空着,所以导致执行(f)操作时,出现队列已满的假现象,这种假现象我们称之为假溢出,之所以出现这样假溢出的现象是因为顺序表队列的存储单元没有重复利用机制,而解决该问题的最合适的方式就是将顺序队列设计为循环结构,接下来我们就通过循环顺序表来实现顺序队列。
顺序循环队列就是将顺序队列设计为在逻辑结构上收尾相接的循环结构,这样我们就可以重复利用存储单元,其过程如下所示:
简单分析一下:
其中采用循环结构的顺序表,可以循环利用存储单元,因此有如下计算关系(其中capacity为队列容量):
//其中front、rear的下标的取值范围是0~capacity-1,不会造成假溢出。
front=(front + 1) % capacity;//队头下标
rear=(rear + 1) % capacity;
- front为队头元素的下标,rear则指向下一个入队元素的下标
- 当front=rear时,我们约定队列为空。
- 出队操作改变front下标指向,入队操作改变rear下标指向,size代表队列容量。
- 约定队列满的条件为front=(rear+1)%capacity,注意此时队列中仍有一个空的位置,此处留一个空位主要用于避免与队列空的条件front=rear相同。
- 队列内部的数组可扩容,并按照原来队列的次序复制元素数组
public class MyArrayQueue<AnyType> {
//队列默认容量
private static final int DEFAULT_CAPACITY = 10;
//指向队头和队尾的下一个位置的指针
private int front;
private int rear;
//存储元素的数组
private AnyType[] theItems;
//队列内元素个数
private int theSize;
//构造方法,初始化一个空队列
public MyArrayQueue() {
init();
}
//初始化空队列
private void init() {
front = 0;
rear = 0;
theItems = (AnyType[]) new Object[DEFAULT_CAPACITY];
theSize = 0;
}
//入队,该方法在队满时会自动扩容
public boolean add(AnyType element) {
//首先判断是否满队
if (front == ((rear + 1) % theItems.length)) {
//满队则扩容
ensureCapacity(2 * theSize + 1);
}
//向队尾插入元素
theItems[rear] = element;
//让rear指向队尾的下一个位置
rear = (rear + 1) % theItems.length;
//队列元素个数加一
theSize++;
return true;
}
//入队,该方法在队满时会抛出异常
public boolean offer(AnyType element) {
if (element == null) {
throw new NullPointerException();
}
if (front == (rear + 1) % theItems.length) {
throw new IllegalArgumentException();
}
//执行入队操作
theItems[rear] = element;
rear = (rear + 1) % theItems.length;
theSize++;
return true;
}
//出队,若队列为空返回null
public AnyType poll() {
//判断是否为空队列
if (front == rear) {
return null;
}
//将要出队的元素保存起来
AnyType polledElement = theItems[front];
//执行出队操作,不用将出队的元素赋值为null,只需改变队头指针即可,因为在以后的一系列入队和出队操作中会自动将其赋值为入队的元素
front = (front + 1) % theItems.length;
theSize--;
return polledElement;
}
//出队,若队列为空抛出异常
public AnyType remove() {
if (front == rear) {
throw new NoSuchElementException();
}
return poll();
}
//返回队头元素,队列为空返回null
public AnyType peek() {
if (front == rear) {
return null;
}
return theItems[front];
}
//返回队头元素,队列为空时抛出异常
public AnyType element() {
if (front == rear) {
throw new NoSuchElementException();
}
return theItems[front];
}
//返回队列中元素个数
public int size() {
return theSize;
}
//判断队列是否为空
public boolean isEmpty() {
return front == rear;
}
//清空队列
public void clearQueue() {
//将所有位置都置null
for (int i = 0; i < theItems.length; i++) {
theItems[i] = null;
}
//修改队头和队尾指针
front = 0;
rear = 0;
theSize = 0;
}
//扩容,注意扩容后的数组元素会从0位置处开始排列
private boolean ensureCapacity(int capacity) {
if (capacity <= theItems.length) {
//若扩容后的容量比当前队列容量还小则无需扩容
return false;
}
AnyType[] newItems = theItems;
theItems = (AnyType[]) new Object[capacity];
int j = 0;
for (int i = front; i != rear; i = (i + 1) % newItems.length ) {
theItems[j++] = newItems[i];
}
//改变队头和队尾指针的位置
front = 0;
rear = theSize;
return true;
}
}
链队列
对于链式队列,将使用带头指针front和尾指针rear的单链表实现,front直接指向队头的第一个元素,rear指向队尾的最后一个元素,其结构如下:
之所以选择单链表(带头尾指针)而不采用循环双链表或者双链表主要是双链表的空间开销(空间复杂度,多前继指针)相对单链表来说大了不少,而单链表只要新增头指针和尾指针就可以轻松实现常数时间内(时间复杂度为O(1))访问头尾结点。下面我们来看看如何设计链式队列:
- 以上述的图为例分别设置front和rear指向队头结点和队尾结点,使用单链表的头尾访问时间复杂度为O(1)。
- 设置初始化空队列,使用front=rear=null,并且约定条件front==null&&rear==null成立时,队列为空。
- 出队操作时,若队列不为空获取队头结点元素,并删除队头结点元素,更新front指针的指向为front=front.next
- 入队操作时,使插入元素的结点在rear之后并更新rear指针指向新插入元素。
- 当第一个元素入队或者最后一个元素出队时,同时更新front指针和rear指针的指向。
这一系列过程如下图所示:
public class MyLinkedQueue<AnyType> {
//队头元素和队尾元素
private Node<AnyType> front;
private Node<AnyType> rear;
//队列元素个数
private int theSize;
//队列最大容量
private int maxSize = 128;
//构造方法,初始化空队列
public MyLinkedQueue() {
init();
}
//初始化空队列
private void init() {
front = rear = null;
theSize = 0;
}
//判断是否为空队列
public boolean isEmpty() {
return front == null && rear == null;
}
//返回队列元素个数
public int size() {
return theSize;
}
//入队,队满则扩容
public boolean add(AnyType element) {
Node<AnyType> node = new Node<>(element, null);
//首先判断是否是空队列
if (isEmpty()) {
//空队列插入第一个元素需要特殊处理
//修改队列头元素和队尾元素
front = node;
rear = node;
theSize++;
return true;
}
//不是空队列则只需修改队尾元素即可
rear.next = node;
rear = node;
theSize++;
return true;
}
//入队,队满不进行扩容
public boolean offer(AnyType element) {
//传入参数不允许为空
if (element == null) {
throw new NullPointerException();
}
//判断是否队满
if (theSize == maxSize) {
throw new IllegalArgumentException();
}
//队列还有位置才进行入队操作
return add(element);
}
//返回队头元素,若队列为空则返回null
public AnyType peek() {
if (isEmpty()) {
return null;
}
return front.data;
}
//返回队头元素,若队列为空则抛出异常
public AnyType element() {
if (isEmpty()) {
throw new NoSuchElementException();
}
return front.data;
}
//出队,若为空队列则返回null
public AnyType poll() {
if (isEmpty()) {
return null;
}
//保存出队元素
Node<AnyType> polledNode = front;
//进行出队操作
front = front.next;
//出队,若为空队列则返回null
public AnyType poll() {
if (isEmpty()) {
return null;
}
//保存出队元素
Node<AnyType> polledNode = front;
//进行出队操作
front = front.next;
if (front == null) {
//最后一个元素出队后需要将队尾也置null
rear = null;
}
theSize--;
return polledNode.data;
}
theSize--;
return polledNode.data;
}
//出队,若队列为空则抛出异常
public AnyType remove() {
if (isEmpty()) {
throw new NoSuchElementException();
}
return poll();
}
//清空队列
public boolean clearQueue() {
if (isEmpty()) {
throw new NoSuchElementException();
}
front = rear = null;
theSize = 0;
return true;
}
//节点类
private static class Node<AnyType> {
private AnyType data;
private Node<AnyType> next;
public Node(AnyType data, Node<AnyType> next) {
this.data = data;
this.next = next;
}
}
}
树
二叉树
参考博客:java数据结构与算法之树基本概念及二叉树(BinaryTree)的设计与实现
二叉查找树
二叉查找树的特性:任意根节点的左子树上的所有节点都小于根结点,右子树上的所有节点都大于根结点。
contains方法、findMin和findMax方法就不再赘述了,都只是比较简单的递归实现,我们主要讨论插入和删除节点的算法(递归)。
二叉查找树的插入算法实现(递归):
插入算法比较简单,总体上来说就是先和当前节点元素比较,若比当前节点元素小则说明应该在当前节点的左子树,反之在右子树,直到当前节点为null时说明这就是要插入的正确位置。
//向树中插入一个元素
public void insert(AnyType element) {
insert(element, root);
}
//向传入的根节点对应的树中找到合适的位置插入元素,此方法不对外开放
private BinaryNode<AnyType> insert(AnyType element, BinaryNode<AnyType> node) {
//若节点为null说明当前位置可以插入
if (node == null) {
node = new BinaryNode<>(element, null, null);
return node;
}
//否则先和当前节点比较
int compareResult = element.compareTo(node.element);
if (compareResult < 0) {
//比当前节点小则应该在其左子树上
node.left = insert(element, node.left);
} else if (compareResult > 0) {
//比当前节点大则应该在其右子树上
insert(element, node.right);
} else {
//和当前节点一样大说明不应该插入到任何位置
}
return node;
}
二叉查找树的删除算法实现(递归):
对于二叉树来说,删除是一种比较麻烦的操作,因为涉及到了多种情况(设要删除的结点为q,其父母结点为p):
- 如果要删除的结点q恰好是叶子结点,那么它可以立即被删除
- 如果要删除的结点q拥有一个孩子结点,则应该调整要被删除的父结点(p.left 或 p.right)指向被删除结点的孩子结点(q.left 或 q.right)
- 如果要删除的结点q拥有两个孩子结点,则删除策略是用q的右子树的最小的数据替代要被删除结点的数据,并递归删除用于替换的结点(此时该结点已为空),此时二叉查找树的结构并不会被打乱,其特性仍旧生效。
为了更新清晰描述这个过程,我们可以借助下图来理解:
//删除树中对应元素的节点
public void remove(AnyType element) {
remove(element, root);
}
//删除指定节点对应的树中包含与element相同的元素的节点,此方法不对外开放
private BinaryNode<AnyType> remove(AnyType element, BinaryNode<AnyType> node) {
if (node == null) {
//若当前节点为空,说明整个树中不含对应节点
return null;
}
//否则和当前节点比较
int compareResult = element.compareTo(node.element);
if (compareResult < 0) {
//比当前节点小则应该在其左子树上
node.left = remove(element, node.left);
} else if (compareResult > 0) {
//比当前节点大则应该在其右子树上
node.right = remove(element, node.right);
} else if (node.left != null && node.right !=null) {
//和当前节点相同说明应该删除当前节点
//若当前节点同时拥有左右孩子节点,则需要用该节点的右子树上最小的节点替换之,再递归的删除最小的节点原来的位置上的节点
node.element = findMin(node.right);//找到该节点右子树最小的节点并用最小的节点替换该节点
node.right = remove(node.element, node.right);//递归的删除
} else {
//和当前节点相同说明应该删除当前节点
//若当前节点不同时拥有左右孩子节点
if (node.left != null) {
//只有左孩子节点
node = node.left;
} else {
//只有右孩子节点或没有孩子节点
node = node.right;
}
}
return node;
}
二叉查找树源码:
//二叉查找树
public class BinarySearchTree<AnyType extends Comparable<? super AnyType>> {
//二叉查找树的根结点
private BinaryNode<AnyType> root;
//构造方法,初始化一棵空树
public BinarySearchTree() {
root = null;
}
//判断是否为空树
public boolean isEmpty() {
return root == null;
}
//让树成为空树
public void makeEmpty() {
root = null;
}
//判断当前树中是否有一个节点包含了传入的数据
public boolean contains(AnyType element) {
return contains(element, root);
}
//判断当前节点上是否包含传入的数据,该方法不对外开放
private boolean contains(AnyType element, BinaryNode<AnyType> node) {
//先判断是否为空节点,空节点则返回false
if (node == null) {
return false;
}
//不是空节点则先判断该节点是否包含对应数据
int compareResult = element.compareTo(node.element);
if (compareResult < 0) {
//比该节点小说明在其左子树上
return contains(element, node.left);
} else if (compareResult > 0) {
//比该节点大说明在其右子树上
return contains(element, node.right);
} else {
//在根结点上
return true;
}
}
//返回树中最小项
public AnyType findMin() {
return findMin(root);
}
//返回树中最大项
public AnyType findMax() {
return findMax(root);
}
//返回指定根结点对应的树中最小的一项,此方法不对外开放
private AnyType findMin(BinaryNode<AnyType> node) {
if (node == null) {
return null;
}
while (node.left != null) {
node = node.left;
}
return node.element;
}
//返回指定根结点对应的树中最大的一项,此方法不对外开放
private AnyType findMax(BinaryNode<AnyType> node) {
//如果传入的树的根结点为null则返回null
if (node == null) {
return null;
}
//如果当前节点没有右子树了,说明当前节点就是最大的
if (node.right == null) {
return node.element;
}
//否则递归遍历右子树
return findMax(node.right);
}
//向树中插入一个元素
public void insert(AnyType element) {
insert(element, root);
}
//向传入的根节点对应的树中找到合适的位置插入元素,此方法不对外开放
private BinaryNode<AnyType> insert(AnyType element, BinaryNode<AnyType> node) {
//若节点为null说明当前位置可以插入
if (node == null) {
node = new BinaryNode<>(element, null, null);
return node;
}
//否则先和当前节点比较
int compareResult = element.compareTo(node.element);
if (compareResult < 0) {
//比当前节点小则应该在其左子树上
node.left = insert(element, node.left);
} else if (compareResult > 0) {
//比当前节点大则应该在其右子树上
insert(element, node.right);
} else {
//和当前节点一样大说明不应该插入到任何位置
}
return node;
}
//删除树中对应元素的节点
public void remove(AnyType element) {
remove(element, root);
}
//删除指定节点对应的树中包含与element相同的元素的节点,此方法不对外开放
private BinaryNode<AnyType> remove(AnyType element, BinaryNode<AnyType> node) {
if (node == null) {
//若当前节点为空,说明整个树中不含对应节点
return null;
}
//否则和当前节点比较
int compareResult = element.compareTo(node.element);
if (compareResult < 0) {
//比当前节点小则应该在其左子树上
node.left = remove(element, node.left);
} else if (compareResult > 0) {
//比当前节点大则应该在其右子树上
node.right = remove(element, node.right);
} else if (node.left != null && node.right !=null) {
//和当前节点相同说明应该删除当前节点
//若当前节点同时拥有左右孩子节点,则需要用该节点的右子树上最小的节点替换之,再递归的删除最小的节点原来的位置上的节点
node.element = findMin(node.right);//找到该节点右子树最小的节点并用最小的节点替换该节点
node.right = remove(node.element, node.right);//递归的删除
} else {
//和当前节点相同说明应该删除当前节点
//若当前节点不同时拥有左右孩子节点
if (node.left != null) {
//只有左孩子节点
node = node.left;
} else {
//只有右孩子节点或没有孩子节点
node = node.right;
}
}
return node;
}
//二叉树中的节点类
private static class BinaryNode<AnyType> {
//节点中存放的数据
private AnyType element;
//左右孩子节点
private BinaryNode<AnyType> left;
private BinaryNode<AnyType> right;
//构造方法
private BinaryNode(AnyType element, BinaryNode<AnyType> left, BinaryNode<AnyType> right) {
this.element = element;
this.left = left;
this.right = right;
}
}
}
查找
二分查找法
常用二分查找法实现
二分查找是一个非常经典的查找算法,其步骤大致如下:
(以升序为例)
1. 根据数组中间元素将整个有序数组划分为左右两部分,左边界为0,右边界为数组长度-1
2. 将数组中间元素和要查找的元素target比较,若相等则直接返回中间元素的index,若target小于中间元素,就将右边界缩小到mid - 1(mid就是中间元素的index),否则将左边界增大到mid + 1
3. 循环第二步直到找到target为止,若左边界大于右边界说明数组中不存在要查找的元素
需要注意的是使用二分查找法是建立在两个前提之上的:
- 数组是有序的
- 必须采用顺序存储结构
二分查找法源代码如下:
//对传入的有序数组进行二分查找,查找值为target的元素,返回值为index
private static int binarySearch(int[] array, int target) {
//第一次二分查找的左右边界
int left = 0;
int right = array.length - 1;
//只要左边界小于右边界就说明需要继续查找
while (left <= right) {
//获取左右边界的中值
int mid = (left + right) / 2;
if (target == array[mid]) {
//中值恰好是要查找的target
return mid;
} else if (target < array[mid]) {
//中值比target大,说明target在左右边界的左半部分
right = mid - 1;
} else {
//中值比target小,说明target在左右边界的右半部分
left = mid + 1;
}
}
//左边界大于右边界了,说明target不在数组中,返回-1
return -1;
}
二分查找法优化
实际上上述的二分查找法是有bug的,当数组长度足够大时,我们计算中间元素index的代码会产生溢出:
int mid = (left + right) / 2;
因此我们需要将这一段代码修改成以下样式:
int mid = left + (right - left) / 2;
这样二分查找法就没有任何bug了。
堆(优先队列)
堆又称优先队列,普通的队列是先进先出,但是优先队列先出的是队内优先级最高的元素而不是队头元素。
二叉堆
二叉堆是一种基于二叉树的优先队列,这种堆有以下性质:
1. 必须是完全二叉树
2. 每一个节点都必须大于或小于其左右孩子节点,满足前者的二叉堆称为最大二叉堆,满足后者的二叉堆称为最小二叉堆
下图就是一个标准的最大二叉堆:
下面我们就来介绍一种基于数组实现的最大二叉堆。
最大二叉堆的实现基础
我们首先需要知道,为什么二叉堆会用数组实现而不用链表实现呢?之前的二叉树类似的数据结构都是用链表实现,数组这样的顺序存储结构要如何实现二叉性质呢?
使用数组实现是由二叉堆的性质决定的,二叉堆是一个完全二叉树,所有节点都是按照从上到下从左到右的顺序排列过来的,不存在某一个节点没有左孩子节点却有右孩子节点的情况。因此我们可以从整棵树的根结点开始,从上到下,从左到右,为每个节点依次标记1、2、3…的序号,这些序号就对应着数组中的索引(因此数组索引为0的位置是不存放节点元素的)。
从上图可以看到,数组中索引为index的节点的左孩子节点在数组中的索引为index * 2,右孩子节点为index * 2 + 1,父节点为index / 2(会向下取整),通过这个性质我们就能在数组中找到任意位置的节点的孩子节点或者其父节点。
最大二叉堆的结构
因为底层是使用数组进行存储,因此MaxHeap最大二叉堆类中肯定会有数组成员变量,包括数组中存储元素的个数size、数组的容量capacity。
public class MaxHeap {
//默认堆最大存储空间为10
private static final int DEFAULT_CAPACITY = 10;
//使用数组存储数据(从index为1开始存储)
private int[] theItems;
//数组中存储元素的数量
private int size;
//数组最大容量
private int capacity;
//构造方法,传入参数为二叉堆最大容量
public MaxHeap() {
}
//返回当前二叉堆元素数量
public int size() {
return size;
}
//判断当前二叉堆是否为空
public boolean isEmpty() {
return size == 0;
}
//向二叉堆中插入新元素
public void insert(int element) {
}
//取出二叉堆中最大的元素,也就是整棵树的根结点(出队)
public int extractMax() {
}
}
构造一颗空的最大二叉树
//构造方法
public MaxHeap() {
theItems = new int[DEFAULT_CAPACITY + 1];
size = 0;
capacity = DEFAULT_CAPACITY;
}
根据传入的数组构造最大二叉树
//构造方法,传入参数为一个整型数组,这个构造方法是将传入的整型数组构造成一个最大二叉堆
public MaxHeap(int[] array) {
theItems = new int[array.length + 1];
capacity = array.length;
//将数组中的元素放入堆中
for (int i = 0; i < array.length; i++) {
theItems[i + 1] = array[i];
size++;
}
//将堆中的元素放到正确的位置,思想是从最后一个有叶子节点的父节点往前调用shiftDown, size/2就是最后一个有叶子结点的父节点的index
for (int i = size / 2; i >= 1; i--) {
shiftDown(i);
}
}
插入新节点(入队)
插入新节点的思路:在树中最后一个节点(也就是数组中最后的节点)后插入新节点,这样就能保证操作完成后依然是一颗完全二叉树,之后将新节点依次上移到正确的位置即可。
//向二叉堆中插入新元素
public void insert(int element) {
//首先判断堆是否已满
if (size == capacity) {
//堆已满则扩容到原来的两倍
ensureCapacity(2 * capacity);
}
//先将元素插入到数组末尾
theItems[size + 1] = element;
size++;
//将插入的元素放到二叉堆中正确的位置
shiftUp(size);
}
//将index位置的元素上移放到正确的位置
private void shiftUp(int index) {
while (index > 1 && theItems[index] > theItems[index / 2]) {
//若index位置的节点大于其父节点则不满足二叉堆的性质,交换父节点与该节点的位置
swap(index, index / 2);
index /= 2;
}
}
//扩容,不对外开放
private void ensureCapacity(int capacity) {
int[] theOldItems = theItems;
theItems = new int[capacity + 1];
for (int i = 1; i <= this.capacity; i++) {
theItems[i] = theOldItems[i];
}
this.capacity = capacity;
}
获取最大节点(出队)
思路:将数组中最后一个元素放到数组第一个位置(也就是把树的最后一个元素放到根结点),这样就能保证操作完成后依然是一颗完全二叉树,这时将size减1,原来数组中最后的那个元素由于size的缩小就被排除在边界外,我们也不用再去将其赋值为null或者0,,最后将现在根结点的元素依次下移到正确的位置。
//取出二叉堆中最大的元素,也就是整棵树的根结点(出队)
public int extractMax() {
//先判断是否为空堆
if (isEmpty()) {
throw new NoSuchElementException();
}
//取出最大元素
int maxElement = theItems[1];
//将数组最后一个元素放到数组第一个位置,这样就能保证依然是一颗完全二叉树
theItems[1] = theItems[size];
size--;
//将新放到整棵树根结点的元素向下移动到适合的位置,保证二叉堆的性质
shiftDown(1);
return maxElement;
}
//将index位置的元素下移放到正确的位置
private void shiftDown(int index) {
//判断当前节点是否有孩子节点
while (index * 2 <= size) {
//因为是完全二叉树,所以有节点的话就肯定有左孩子节点
int maxIndex = index * 2;//maxIndex代表的是左右孩子节点中最大的节点位置
if (index * 2 + 1 <= size && theItems[maxIndex] < theItems[maxIndex + 1]) {
//右孩子存在并且右孩子比左孩子大
maxIndex = maxIndex + 1;
}
if (theItems[index] > theItems[maxIndex]) {
//根结点比两个孩子节点都大则不用交换
break;
}
//否则就将根结点和左右孩子节点中最大的那个节点交换
swap(index, maxIndex);
//修改index
index = maxIndex;
}
}