数据结构和算法(java)

148 阅读15分钟

数据结构和算法(java)

数据结构和算法概述

数据结构和算法的关系

  1. 数据 data 结构(structure)是一门研究组织数据方式的学科,有了编程语言也就有了数据结构.学好数据结构可以编写出更加漂亮,更加有效率的代码。
  2. 要学习好数据结构就要多多考虑如何将生活中遇到的问题,用程序去实现解决.
  3. 程序 = 数据结构 + 算法
  4. 数据结构是算法的基础, 换言之,想要学好算法,需要把数据结构学到位。

线性结构和非线性结构

线性结构

  1. 线性结构作为最常用的数据结构,其特点是数据元素之间存在一对一的线性关系
  2. 线性结构有两种不同的存储结构,即顺序存储结构(数组)和链式存储结构(链表)。顺序存储的线性表称为顺序表,顺序表中的存储元素是连续
  3. 链式存储的线性表称为链表,链表中的存储元素不一定是连续的,元素节点中存放数据元素以及相邻元素的地址信息
  4. 线性结构常见的有:数组、队列、链表和栈

非线性结构

非线性结构包括:二维数组,多维数组,广义表,树结构,图结构

稀疏数组和队列

稀疏数组(sparsearray)

概述

当一个数组中大部分元素为0,或者为同一个值的数组时,可以使用稀疏数组来保存该数组。

处理方式

  1. 记录数组一共有几行几列,有多少个不同的值
  2. 把具有不同值的元素的行列及值记录在一个小规模的数组中,从而缩小程序的规模 示例:

原始数据:

(0002200150110001700006000000003909100000000280000)\begin{pmatrix} 0 & 0 & 0 & 22 & 0 & 0 & 15 \\ 0 & 11 & 0 & 0 & 0 & 17 & 0 \\ 0 & 0 & 0 & -6 & 0 & 0 & 0 \\ 0 & 0 & 0 & 0 & 0 & 39 & 0 \\ 91 & 0 & 0 & 0 & 0 & 0 & 0 \\ 0 & 0 & 28 & 0 & 0 & 0 & 0 \\ \end{pmatrix}

二维数组转稀疏数组步骤:

  1. 遍历二维数组,得到有效数据的个数sum
  2. 根据sum就可以创建稀疏数组sparseArray[sum+1][3]
  3. 将二维数组的有效数据存入到稀疏数组

稀疏数组保存数据:

row(行)col(列)value(值)
0678
10322
20615
31111
41517
523-6
63539
74091
85228

稀疏数组还原成二维数组:

  1. 先读取稀疏数组的第一行,根据第一行的数据,创建原始的二维数组
  2. 再读取稀疏数组后几行数据,并赋值给原始的二维数组
/**
 * @Classname sparsearray
 * @Description TODO
 * @Data 2022/2/21 1:22
 * @Created by xmg
 */
public class Sparsearray {

    static int[][] originData = {
            {0, 0, 0, 22, 0, 00, 15},
            {0, 11, 0, 0, 0, 17, 0},
            {0, 0, 0, -6, 0, 0, 0},
            {0, 0, 0, 0, 0, 39, 0},
            {91, 0, 0, 0, 0, 0, 0},
            {0, 0, 28, 0, 0, 0, 0}
    };

    public static void main(String[] args) {

        int sum = 0;
        //遍历二维数组,得到有效数据的个数sum
        for (int i = 0; i < originData.length; i++) {
            for (int j = 0; j < originData[i].length; j++) {
                if(originData[i][j] != 0){
                    sum++;
                }
            }
        }

        //根据sum就可以创建稀疏数组sparseArray[sum+1][3]
        int[][] sparseArray = new int[sum+1][3];

        //稀疏数组的第一行存储的是原始数组的行数和列数,以及有效数据的个数
        sparseArray[0] = new int[]{originData.length, originData[0].length, sum};

        //将二维数组的有效数据存入到稀疏数组
        int step = 0;
        for (int i = 0; i < originData.length; i++) {
            for (int j = 0; j < originData[i].length; j++) {
                if(originData[i][j] != 0){
                    step++;
                    sparseArray[step] = new int[]{i,j,originData[i][j]};
                }
            }
        }

        for (int i = 0; i < sparseArray.length; i++) {
            for (int j = 0; j < sparseArray[i].length; j++) {
                System.out.print(sparseArray[i][j]+"\t");
            }
            System.out.println("");
        }

        //先读取稀疏数组的第一行,根据第一行的数据,创建原始的二维数组
        int[][] originArray = new int[sparseArray[0][0]][sparseArray[0][1]];

        //再读取稀疏数组后几行数据,并赋值给原始的二维数组
        for (int i = 1; i < sparseArray.length; i++) {
            originArray[sparseArray[i][0]][sparseArray[i][1]] = sparseArray[i][2];
        }

        for (int i = 0; i < originArray.length; i++) {
            for (int j = 0; j < originArray[i].length; j++) {
                System.out.print(originArray[i][j]+"\t");
            }
            System.out.println("");
        }
    }
}

队列

概念

  1. 队列是一个有序列表,可以用数组或是链表来实现。
  2. 遵循先入先出的原则。即:先存入队列的数据,要先取出。后存入的要后取出

数组模拟队列

  • queue:用来存储数据的数组
  • maxSize:该队列的最大容量
  • front:对首下标,随着数据输出而改变
  • rear:对尾下标,随着数据输入而改变
  • front==rear时,队列是空的
  • rear<maxSize-1时,则将数据存入rear所指的数据元素中
  • rear=maxSize-1时,队列满了,无法存入数据
class Queue{
    private int maxSize;
    private int front;
    private int rear;
    private int[] arr;

    public Queue(int maxSize){
        this.maxSize = maxSize;
        arr = new int[maxSize];
        //指向队列头部,front是指向队列头的前一个位置
        front = -1;
        //指向队列尾,指向队列尾的数据(即就是队列最后一个数据)
        rear = -1;
    }

    /**
     * @Description 判断队列是否为空
     * @return boolean true表示队列是空的,false表示队列不为空
     * @date 2022/2/21 15:05
     * @auther xmg
     */
    public boolean isEmpty(){
        return front == rear;
    }

    /**
     * @Description  判断队列是否已满
     * @return boolean true表示队列是满的,false表示队列未满
     * @date 2022/2/21 15:18
     * @auther xmg
     */
    public boolean isFull(){
        return rear == maxSize-1;
    }

    /**
     * @Description 添加数据到队列
     * @param data 添加的数据
     * @return void
     * @date 2022/2/21 15:05
     * @auther xmg
     */
    public void add(int data){
        if(isFull()){
            throw new RuntimeException("队列已满");
        }
        arr[++rear] = data;
    }

    /**
     * @Description 获取队列中的数据
     * @return int 获取的数据
     * @date 2022/2/21 15:09
     * @auther xmg
     */
    public int get(){
        if(isEmpty()){
            throw new RuntimeException("队列为空");
        }
        return arr[++front];
    }

    /**
     * @Description 获取队列的对首数据
     * @return int 对首数据
     * @date 2022/2/21 15:35
     * @auther xmg
     */
    public int getHead(){
        if(isEmpty()){
            throw new RuntimeException("队列为空");
        }
        return arr[front+1];
    }

    /**
     * @Description 获取队列的队尾数据
     * @return int 队尾数据
     * @date 2022/2/21 15:37
     * @auther xmg
     */
    public int getTail(){
        if(isEmpty()){
            throw new RuntimeException("队列为空");
        }
        return arr[rear];
    }

    /**
     * @Description 显示队列中的数据
     * @return void
     * @date 2022/2/21 15:34
     * @auther xmg
     */
    public void show(){
        if(isEmpty()){
            System.out.println("队列为空");
            return;
        }
        for (int i = front+1; i <=rear; i++) {
            System.out.print(arr[i]+"\t");
        }
        System.out.println("");
    }

    /**
     * @Description 获取队列中存在的数据长度
     * @return int 数据长度
     * @date 2022/2/21 15:33
     * @auther xmg
     */
    public int Size(){
        return rear-front;
    }
}

问题分析

目前实现了一个一次性的队列(不能复用),获取数据时不能清空原队列中的数据,当队列满之后,就无法添加数据了。

环形队列

  1. front含义调整:表示队列的第一个元素,初始值为0
  2. rear含义调整:表示队列的最后一个元素的下一个位置,初始值(只是初始值)为0
  3. 队列计算公式:(rear+1)%maxSize==front\lparen rear+1 \rparen \% maxSize == front
  4. 队里计算公式:rear== front
  5. 队列中有效元素个数计算公式:(rear+maxSizefront)%maxSize\lparen rear+maxSize-front \rparen \% maxSize

链表

单链表概述

  1. 链表是有序的列表
  2. 链表是以节点的方式来存储,是链式存储
  3. 每个节点包含 data 域next 域(指向下一个节点),最后一个节点的next域为空
  4. 链表的各个节点不一定是连续存储
  5. 链表分带头节点的链表(有效的节点数要除去带头节点)和没有头节点的链表

image.png

单循环链表

  1. 每个节点包含 data 域next 域(指向下一个节点),最后一个节点的next域指向头节点

双向链表概述

  1. 每个节点包含data 域next 域(指向下一个节点),prev域(指向上一个节点)
  2. 头结点的prev域为空,尾结点的next域为空

栈概述

  1. 栈的英文为(stack)
  2. 栈是一个先入后出(FILO-First In Last Out)的有序列表
  3. 栈(stack)是限制线性表中元素的插入和删除只能在线性表的同一端进行的一种特殊线性表。允许插入和删除的一端,为变化的一端,称为栈顶(Top),另一端为固定的一端,称为栈底(Bottom)。
  4. 根据栈的定义可知,最先放入栈中元素在栈底,最后放入的元素在栈顶,而删除元素刚好相反,最后放入的元素最先删除,最先放入的元素最后删除
  5. 图解方式说明出栈(pop)和入栈(push)的概念

image.png

image.png

递归

概念

递归就是方法自己调用自己,每次调用时传入不同的变量.递归有助于编程者解决复杂的问题,同时 可以让代码变得简洁。

重要规则

  1. 执行一个方法时,就创建一个新的受保护的独立空间(栈空间)
  2. 方法的局部变量是独立的,不会相互影响, 比如 n 变量
  3. 如果方法中使用的是引用类型变量(比如数组),就会共享该引用类型的数据.
  4. 递归必须向退出递归的条件逼近,否则就是无限递归,出现 StackOverflowError)
  5. 当一个方法执行完毕,或者遇到 return,就会返回,遵守谁调用,就将结果返回给谁,同时当方法执行完毕或者返回时,该方法也就执行完毕

排序算法

概述

排序也称排序算法(Sort Algorithm),排序是将一组数据,依指定的顺序进行排列的过程。

排序的分类

内部排序

指将需要处理的所有数据都加载到内部存储器(内存)中进行排序。

外部排序

数据量过大,无法全部加载到内存中,需要借助外部存储(文件等)进行排序。

常见分类

image.png

算法的时间复杂度

时间频度

一个算法花费的时间与算法中语句的执行次数成正比例,哪个算法中语句执行次数多,它花费时间就多。一个算法中的语句执行次数称为语句频度或时间频度。记为 T(n)。

时间复杂度

  1. 一般情况下,算法中的基本操作语句的重复执行次数是问题规模 n 的某个函数,用 T(n)表示,若有某个辅助函数 f(n),使得当 n 趋近于无穷大时,T(n) / f(n) 的极限值为不等于零的常数,则称 f(n)是 T(n)的同数量级函数。记作 T(n)=O( f(n) ),称O( f(n) ) 为算法的渐进时间复杂度,简称时间复杂度。

  2. T(n) 不同,但时间复杂度可能相同。 如:T(n)=n²+7n+6 与 T(n)=3n²+2n+2 它们的 T(n) 不同,但时间复杂度相同,都为 O(n²)。

  3. 计算时间复杂度的方法:

    • 用常数 1 代替运行时间中的所有加法常数 T(n)=n²+7n+6=> T(n)=n²+7n+1
    • 修改后的运行次数函数中,只保留最高阶项 T(n)=n²+7n+1 => T(n) = n²
    • 去除最高阶项的系数 T(n) = n² => T(n) = n² => O(n²)

常见的时间复杂度

  • 常数阶 O(1)
  • 对数阶 O(log2n)
  • 线性阶 O(n)
  • 线性对数阶 O(nlog2n)
  • 平方阶 O(n2n^{2})
  • 立方阶 O(n3n^{3})
  • k次方阶 O(nkn^{k})
  • 指数阶 O(2n2^{n}) 说明: 常见的算法时间复杂度由小到大依次为:Ο(1)<Ο(log2n)<Ο(n)<Ο(nlog2n)<Ο(n2n^{2})<Ο(n3n^{3})< Ο(nkn^{k}) <Ο(2n2^{n}) ,随着问题规模 n 的不断增大,上述时间复杂度不断增大,算法的执行效率越低

平均时间复杂度和最坏时间复杂度

  • 平均时间复杂度是指所有可能的输入实例均以等概率出现的情况下,该算法的运行时间。
  • 最坏情况下的时间复杂度称最坏时间复杂度。一般讨论的时间复杂度均是最坏情况下的时间复杂度。

算法的空间复杂度

  • 类似于时间复杂度的讨论,一个算法的空间复杂度(Space Complexity)定义为该算法所耗费的存储空间,它也是问题规模 n 的函数。
  • 空间复杂度(Space Complexity)是对一个算法在运行过程中临时占用存储空间大小的量度。有的算法需要占用的临时工作单元数与解决问题的规模 n 有关,它随着 n 的增大而增大,当 n 较大时,将占用较多的存储单元
  • 在做算法分析时,主要讨论的是时间复杂度。从用户使用体验上看,更看重的程序执行的速度。一些缓存产品(redis, memcache)和算法(基数排序)本质就是用空间换时间

冒泡排序

概述

冒泡排序(Bubble Sorting)的基本思想是:通过对待排序序列从前向后(从下标较小的元素开始),依次比较 相邻元素的值,若发现逆序则交换,使值较大的元素逐渐从前移向后部,就象水底下的气泡一样逐渐向上冒。

bub.gif

优化

因为排序的过程中,各元素不断接近自己的位置,如果一趟比较下来没有进行过交换,就说明序列有序,因此要在 排序过程中设置一个标志 flag 判断元素是否进行过交换。从而减少不必要的比较。

选择排序

概念

选择式排序也属于内部排序法,是从欲排序的数据中,按指定的规则选出某一元素,再依规定交换位置后达到 排序的目的。

思想

选择排序(select sorting)也是一种简单的排序方法。它的基本思想是:第一次从 arr[0]~arr[n-1]中选取最小值,与 arr[0]交换,第二次从 arr[1]~arr[n-1]中选取最小值,与 arr[1]交换,第三次从 arr[2]~arr[n-1]中选取最小值,与 arr[2]交换,…,第 i 次从 arr[i-1]~arr[n-1]中选取最小值,与 arr[i-1]交换,…, 第 n-1 次从 arr[n-2]~arr[n-1]中选取最小值,arr[n-2]交换,总共通过 n-1 次,得到一个按排序码从小到大排列的有序序列。

image.png

sel.gif

插入排序

概念

插入式排序属于内部排序法,是对于欲排序的元素以插入的方式找寻该元素的适当位置,以达到排序的目的。

思想

插入排序(Insertion Sorting)的基本思想是:把 n 个待排序的元素看成为一个有序表和一个无序表,开始时有 序表中只包含一个元素,无序表中包含有 n-1 个元素,排序过程中每次从无序表中取出第一个元素,把它的排 序码依次与有序表元素的排序码进行比较,将它插入到有序表中的适当位置,使之成为新的有序表。

ins.gif

希尔排序

概述

希尔排序是希尔(Donald Shell)于 1959 年提出的一种排序算法。希尔排序也是一种插入排序,它是简单插入排序经过改进之后的一个更高效的版本,也称为缩小增量排序。

思想

希尔排序是将待排序的数组元素 按下标的一定增量分组 ,分成多个子序列,然后对各个子序列进行直接插入排序算法排序;然后依次缩减增量再进行排序,直到增量为1时,进行最后一次直接插入排序,排序结束。

示意图

image.png

快速排序

概述

快速排序(Quicksort)是对冒泡排序的一种改进。

思想

通过一趟排序将要排序的数据分割成独立的两部分,其中一部分的所有数据都比另外一部分的所有数据都要小,然后再按此方法对这两部分数据分别进行快速排序,整个排序过程可以递归进行,以此达到整个数据变成有序序列

qui.gif

归并排序

概述与思想

归并排序(MERGE-SORT)是利用归并的思想实现的排序方法,该算法采用经典的分治(divide-and-conquer) 策略(分治法将问题分(divide)成一些小的问题然后递归求解,而治(conquer)的阶段则将分的阶段得到的各答案"修 补"在一起,即分而治之)。

mer.gif

基数排序(桶排序)

概述

  1. 基数排序(radix sort)属于“分配式排序”(distribution sort),又称“桶子法”(bucket sort)或 bin sort,顾名思义,它是通过键值的各个位的值,将要排序的元素分配至某些“桶”中,达到排序的作用
  2. 基数排序法是属于稳定性的排序,基数排序法的是效率高的稳定性排序法
  3. 基数排序(Radix Sort)是桶排序的扩展

思想

将所有待比较数值统一为同样的数位长度,数位较短的数前面补零。然后,从最低位开始,依次进行一次排序。 这样从最低位排序一直到最高位排序完成以后, 数列就变成一个有序序列。

rad.gif

常用排序算法总结和对比

image.png

术语说明:

  1. 稳定:如果 a 原本在 b 前面,而 a=b,排序之后 a 仍然在 b 的前面;
  2. 不稳定:如果 a 原本在 b 的前面,而 a=b,排序之后 a 可能会出现在 b 的后面;
  3. 内排序:所有排序操作都在内存中完成;
  4. 外排序:由于数据太大,因此把数据放在磁盘中,而排序通过磁盘和内存的数据传输才能进行;
  5. 时间复杂度: 一个算法执行所耗费的时间。
  6. 空间复杂度:运行完一个程序所需内存的大小。
  7. n: 数据规模
  8. k: “桶”的个数
  9. In-place:不占用额外内存
  10. Out-place: 占用额外内存

查找算法

介绍

在 java 中,我们常用的查找有四种:

  1. 顺序(线性)查找
  2. 二分查找/折半查找
  3. 插值查找
  4. 斐波那契查找

顺序(线性)查找(BFPRT)

BFPRT解决的问题十分经典,即从某n个元素的序列中选出第k大(第k小)的元素,通过巧妙的分析,BFPRT可以保证在最坏情况下仍为线性时间复杂度。

步骤

  1. 将n个元素每 5 个一组,分成n/5(上界)组。
  2. 取出每一组的中位数,任意排序方法,比如插入排序。
  3. 递归的调用 selection 算法查找上一步中所有中位数的中位数,设为x,偶数个中位数的情况下设定为选取中间小的一个。
  4. 用x来分割数组,设小于等于x的个数为k,大于x的个数即为n-k。
  5. 若i==k,返回x;若i<k,在小于x的元素中递归查找第i小的元素;若i>k,在大于x的元素中递归查找第i-k 小的元素。
  6. 终止条件:n=1 时,返回的即是i小元素。

二分查找/折半查找

二分查找(Binary Search)也叫作折半查找。二分查找有两个要求,一个是数列有序,另一个是数列使用顺序存储结构(比如数组)。

思想

  1. 初始状态下,将整个序列作为搜索区域(假设为 [B, E]);
  2. 找到搜索区域内的中间元素(假设所在位置为 M),和目标元素进行比对。如果相等,则搜索成功;如果中间元素大于目标元素,表明目标元素位于中间元素的左侧,将 [B, M-1] 作为新的搜素区域;反之,若中间元素小于目标元素,表明目标元素位于中间元素的右侧,将 [M+1, E] 作为新的搜素区域;
  3. 重复执行第二步,直至找到目标元素。如果搜索区域无法再缩小,且区域内不包含任何元素,表明整个序列中没有目标元素,查找失败。

插值查找

插值查找算法类似于二分查找,不同的是插值查找每次从自适应 mid 处开始查找。

思想

可以改变二分查找的区间缩减策略,根据搜索的值来确定区间缩减幅度,使其不再是固定的1/2,这种想法就是“插值查找”,其中间位置计算方式如下:

image.png

如果alpha用于衡量搜索目标值距离左边界的远近,就可以使用下述公式进行表达,其中tar表示目标值,D表述有序数值:

image.png

斐波那契(黄金分割法)查找算法

介绍

  1. 黄金分割点是指把一条线段分割为两部分,使其中一部分与全长之比等于另一部分与这部分之比。取其前三位数字的近似值是 0.618。由于按此比例设计的造型十分美丽,因此称为黄金分割,也称为中外比。这是一个神奇的数字,会带来意向不大的效果。
  2. 斐波那契数列 {1, 1, 2, 3, 5, 8, 13, 21, 34, 55 } 发现斐波那契数列的两个相邻数 的比例,无限接近 黄金分割值0.618

原理

斐波那契查找原理与前两种相似,仅仅改变了中间结点(mid)的位置,mid 不再是中间或插值得到,而是位于黄金分割点附近,即 mid=low+F(k-1)-1(F 代表斐波那契数列),如下图所示

image.png

对 F(k-1)-1 的理解

  1. 由斐波那契数列 F[k]=F[k-1]+F[k-2] 的性质,可以得到 (F[k]-1)=(F[k-1]-1)+(F[k-2]-1)+1 。该式说明:只要顺序表的长度为 F[k]-1,则可以将该表分成长度为 F[k-1]-1 和 F[k-2]-1 的两段,即如上图所示。从而中间位置为 mid=low+F(k-1)-1
  2. 类似的,每一子段也可以用相同的方式分割
  3. 顺序表长度 n 不一定刚好等于 F[k]-1,所以需要将原来的顺序表长度 n 增加至 F[k]-1。这里的 k 值只要能使得 F[k]-1 恰好大于或等于 n 即可,由以下代码得到,顺序表长度增加后,新增的位置(从 n+1 到 F[k]-1 位置),都赋为 n 位置的值即可

哈希表

介绍

散列表(Hash table,也叫哈希表),是根据关键码值(Key value)而直接进行访问的数据结构。也就是说,它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做散列函数,存放记录的数组叫做散列表。

二叉树

概念

  1. 每个节点最多只能有两个子节点的一种形式称为二叉树
  2. 二叉树的子节点分为左节点和右节点
  3. 示意图

image.png

  1. 如果该二叉树的所有叶子节点都在最后一层,并且结点总数= 2^n -1 , n 为层数,则我们称为满二叉树

image.png

  1. 如果二叉树中除去最后一层节点为满二叉树,且最后一层的结点依次从左到右分布,则此二叉树被称为完全二叉树。

image.png

二叉树遍历

  1. 前序遍历: 先输出父节点,再遍历左子树和右子树
  2. 中序遍历: 先遍历左子树,再输出父节点,再遍历右子树
  3. 后序遍历: 先遍历左子树,再遍历右子树,最后输出父节点
  4. 小结: 看输出父节点的顺序,就确定是前序,中序还是后序

顺序存储二叉树

从数据存储来看,数组存储方式和树的存储方式可以相互转换,即数组可以转换成树,树也可以转换成数组

特点

  1. 顺序二叉树通常只考虑完全二叉树
  2. 第 n 个元素的左子节点为2 * n + 1
  3. 第 n 个元素的右子节点为2 * n + 2
  4. 第 n 个元素的父节点为(n-1) / 2
  5. n : 表示二叉树中的第几个元素(按 0 开始编号)

线索化二叉树

  1. n 个结点的二叉链表中含有 n+1【公式 2n-(n-1)=n+1】 个空指针域。利用二叉链表中的空指针域,存放指向该结点在某种遍历次序下的前驱和后继结点的指针(这种附加的指针称为"线索")
  2. 这种加上了线索的二叉链表称为线索链表,相应的二叉树称为线索二叉树(Threaded BinaryTree)。根据线索性质的不同,线索二叉树可分为前序线索二叉树、中序线索二叉树和后序线索二叉树三种
  3. 一个结点的前一个结点,称为前驱结点
  4. 一个结点的后一个结点,称为后继结点

树的实际应用

堆排序

赫夫曼树

赫夫曼编码

二叉排序树

平衡二叉树(AVL树)

多路查找树

二叉树与B树

2-3树

B树、B+树和B*树

概述

表示方式

图的深度优先遍历

图的广度优先遍历

常用的10种算法

二分查找法(非递归)

分治算法

动态规划算法

KMP算法

贪心算法

普里姆算法

克鲁斯卡尔算法

迪杰斯特拉算法

弗洛伊德算法

马踏棋盘算法