数据结构

228 阅读21分钟

1. 绪论

1.1 什么是数据结构

  • 用程序代码把现实世界的问题信息化
  • 如何用计算机高效地处理这些信息

1.2 基本概念

  1. 数据: 数据是信息的载体,描述客观事实属性的数、字符以及能输入到计算机中被计算机程序识别和处理的符号的集合。
  2. 数据项和数据元素:数据元素是数据的基本单位,通常作为一个整体进行考虑和处理。一个数据元素可以由若干个数据项组成,数据项是构成数据元素的不可分割的最小单位。 基本概念
  3. 数据对象:具有相同性质的数据元素的集合,是数据的一个子集。
  4. 数据结构:相互之间存在一种或多种特定关系的数据元素的集合。
  5. 同样的数据元素,可以组成不同的数据结构。不用的数据元素,可以组成相同的数据结构。

1.3 数据结构三要素

  1. 逻辑结构
  • 集合结构:各个元素属于一个集合,没有其他关系。
  • 线性结构:串成一个串,一对一的关系。除了第一个元素,所有元素都有唯一前驱,除了最后一个元素,所有元素都有唯一后继。
  • 数形结构:一对多。
  • 图结构:多对多。
  1. 数据的运算

根据某种逻辑结构,结合实际需求,定义基本运算。

例:线性:查找第i个元素/在第i个位置插入新数据/删除第i个位置的元素

  • 运算的定义是针对逻辑结构的,指出运算的功能。
  • 运算的实现是针对存储结构的,指出运算的具体操作步骤。
  1. 物理结构(存储结构)

如何用计算机表示数据元素之间的逻辑关系?

  • 顺序存储:物理位置就是相邻的。需要连续的存储空间。
  • 链式存储:指针
  • 索引存储:建立索引表 <key,value>
  • 散列存储:根据关键字计算出存储地址。又称为hash存储

存储结构会影响存储空间分配的方便程度和对数据运算的速度。

1.3 什么是算法

对特定问题求解步骤的一种描述,它是指令的有限序列,其中的每条指令表示一个或者多个操作。

  • 有穷性:算法必须总在执行有穷之后结束,且每一步都在有穷时间内完成。(算法是有穷的,程序可以是无穷的)
  • 确定性:算法中每条指令必须有确切的含义,对于相同的输入只能得到相同的输出。
  • 可行性:算法中描述的操作都可以通过已经实现的基本运算执行有限次来实现。
  • 一个算法有零个或多个输入,这些输入取自于某个特定的对象的合集。
  • 一个算法有一个或多个输出,这些输出是与输入有着某种特定关系的量。

1.3.1 什么是好算法

  • 正确性:正确解决问题
  • 可读性:良好的可读性
  • 健壮性:输入非法数据时,算法能适当地处理
  • 高效率:时间复杂度低
  • 低存储量需求:空间复杂度低

1.3.2 算法的时间复杂度

时间复杂度

1.3.3 算法的空间复杂度

不能事后统计运行时间:

  • 和机器性能有关
  • 和编程语言有关
  • 和编译程序产生的机器指令质量有关

事前估计时间开销(T)和问题规模 (n) 的关系

时间复杂度

2. 线性表 Linear List

2.1 定义和基本操作

  1. 定义: 具有相同数据类型的n个数据元素的有限序列。n是表长。

    • 每个数据元素所占空间一样大
    • 有次序
    • 有限
    • 除了第一个元素,所有元素都有唯一前驱,除了最后一个元素,所有元素都有唯一后继
  2. 基本操作: 初始化/销毁/插入/删除/按值查找/按位查找/求表长/输出/判空

2.2 顺序表(顺序存储)

  1. 定义: 用顺序存储的方式实现线性表顺序存储。把逻辑上相邻的元素存储在位置上也相邻的存储单元中。
  2. 存储空间是静态的,表长确定后无法更改
  3. 动态分配顺序表
  4. 特点:
    • 随机访问:O(1)时间找到第i个元素
    • 存储密度高,每个节点只存储数据元素
    • 扩展容量不方便
    • 插入、删除不方便,需要移动大量元素
  5. 插入/删除:O(n) Time
  6. 查找:
    • 按位查找:找第i位的元素 O(1) Time
    • 按值查找:找值为i的元素 O(n) Time

2.3 链表(链式存储)

2.3.1 单链表

  1. 定义:
    • 用链式存储(存储结构)实现了线性结构(逻辑结构)
    • 一个节点存储一个数据元素
    • 各个节点间的先后关系用指针表示
public class ListNode{
    int val;
    ListNode next;
    ListNode(){};
    ListNode(int val){this.val = val;}
    ListNode(int val, ListNode next){this.val = val; this.next = next;}
}

头指针指向头节点,在题目中,一般head就是第一个带数据的节点。所以要写一个dummy head,并指向head。这样对第一个数据节点和后续数据节点的处理可以用相同的逻辑。

  1. 插入和删除
  • 按位序插入
public boolean ListInsert(ListNode head, int i, int e){
    //head 是链表头节点
    //i是要插入的位置
    //e是插入的值
    if(i < 1){
        return false;
    }
    ListNode cur = new ListNode(-1, head);//虚拟头节点
    int curposition = 0;
    while(cur != null && curposition < i - 1){
        p = p.next;
        curposition++;
    }
    if(p == null){//i值不合法
        return false;
    }
    ListNode temp = new ListNode(e);
    temp.next = cur.next;
    cur.next = temp;
    return true;
}
  • 指定节点后插
  • 指定节点前插
  • 删除第i个位置
  • 删除指定节点

局限性:无法逆向检索,只能从头节点顺序查找。O(n) Time

  1. 查找
  • 按位查找
  • 按值查找
  • 求链表长度
  1. 单链表的建立
  • 尾插法
  • 头插法

2.3.2 双链表

public class ListNode{
    int val;
    ListNode next;
    ListNode pre;
}
  • 插入
  • 删除
  • 遍历

2.3.3 循环链表

  • 循环单链表 last.next = head;
  • 循环双链表 last.next = head; head.pre = last;

2.3.4 静态链表

1.定义:分配一整片连续的内存空间,各个节点集中安置。每个节点:数据元素 + 下一个节点的数组下表(游标)

3. 栈和队列和矩阵

3.1 栈

  1. 定义:栈是只允许在一端进行插入或删除操作的线性表
  2. 后进先出 Last In First Out (LIFO)
  3. n个不同元素进栈,出栈元素不同排列的个数为1n+1C2nn\frac{1}{n+1}C_{2n}^{n}。这个公式称为卡特兰(Catalan)数
  4. 初始化: Stack<Integer> stack = new Stack<>();
  5. 进栈/出栈: stack.push()/stack.pop()
  6. 共享栈
  7. 链栈:链式存储的栈
  8. 栈的应用:括号匹配[20] 。遇到左括号就入栈,遇到右括号就弹出一个左括号。看左右括号是否匹配
    • 特殊情况1:遇到后括号栈空,右括号不匹配。
    • 特殊情况2: 循环完毕栈非空,左括号不匹配。
  9. 栈的应用2: 表达式求值reverse polish(逆波兰数 [150])计算后重新压入栈。注意减法和除法的操作顺序,先弹出的是被减数/被除数。
    • 判断等号相等: "+".equals(tokens[i])
    • String转Integer Integer.valueOf(tokens[i])
  10. 栈的应用3: 递归。函数调用也是FIFO

3.2 队列

  1. 只允许在一端插入,另一边删除的线性表
  2. 先进先出 First In First Out(FIFO)
  3. 循环队列
  4. 初始化:Queue<Integer> queue = new LinkedList<>();
  5. 进队/出队:queue.offer()/queue.poll()
  6. 链式存储队列
  7. 双端队列:只允许两端插入,两端删除的线性表Deque<Integer> deque = new LinkedList<>();
  8. 队列的应用:树的层序遍历
  9. 队列的应用2:图的广度优先遍历

3.3 矩阵的压缩存储

  1. 一维数组
  2. 二维数组:存储方法:行优先/列优先
  3. 特殊矩阵:
    • 对称矩阵,ai,j == aj,i。压缩存储:只存储对角线+下三角区,按行/列优先将各个元素存入一维数组。
    • 三角矩阵:上/下三角矩阵。除了对角线和上/下三角区,其余元素都相同。
    • 三对角矩阵(带状矩阵),|i-j|>1时,ai,j == 0
    • 稀疏矩阵:非零元素远远少于矩阵元素的个数。

4.字符串

4.1 串的基本知识

  1. 定义:由零个或多个字符组成的有限序列。
  2. 一些定义:
    • 子串:串中任意个连续的字符组成的子序列
    • 主串:包含子串的串
    • 字符在主串中的位置:字符在串中的序号。
    • 空串和空格串:M = '' N = ' '
  3. 串是一种特殊的线性表,串的数据对象限定为字符集。串的基本操作,通常以子串为操作对象
  4. 串的比较
  5. 字符集编码:确定字符和二进制数的对应规则。英文:ASCII,中英文:Unicode
  6. 串的存储结构:顺序存储/链式存储/静态数组

4.2 朴素模式匹配算法

主串长度为n,模式串长度为m。将主串中所有长度为m的子串依次与模式串对比,直到找到一个完全匹配的子串,或所有的子串都不匹配为止。(最多匹配n-m+1次)。O(mn) Time。

public int Index(String S, String T){
    int i = 0;
    int j = 0;
    while(i < S.length() && j < T.length()){
        if(S.charAt[i] == T.charAt[j]){
            i++;
            j++;//继续比较后面的字符
        }else{
            i = i - j + 1;
            j = 0;//指针后退重新开始匹配
        }
    }
    if(j > T.length()){
        return i - T.length();
    }else{
        return 0;
    }
}

4.3 KMP算法

优化:主串指针不回溯。在匹配之前,根据模式串T,计算出next数组,利用next数组进行匹配,从而实现主串指针不回溯。 O(m+n) Time。其中求next数组O(m),匹配过程O(n).

public int IndexKMP(String s, Stirng t, int[] next){
    int i = 0;
    int j = 0;
    while(i < s.length() && j < t.length()){
        if(j == 0 || s.charAt(i) == t.charAt(j)){
            i++;
            j++;  //继续比较后面字符
        }else{
            j = next[j]; //模式串向右移动
        }
    }
    if(j > t.length()){
        return i - t.length();
    }else{
        return 0; //匹配成功
    }
}

求next数组


// 返回值是子串在str1中的起始下标
class Solution {


    public static int strStr(String str1, String str2) {
        if (str1 == null || str2 == null || str1.length() < str2.length()) {
            return -1;
        }

        return process(str1.toCharArray(), str2.toCharArray());
    }

    public static int process(char[] str1, char[] str2) {
        // i1是str1的匹配指针,i2是str2的匹配指针
        int i1 = 0;
        int i2 = 0;

        // 获取str2所有字符的最长前缀和后缀
        int[] next = getMaximalPrefixAndSuffix(str2);

        // 匹配过程只要i1越界或者i2越界匹配都会终止(i1和i2也可能同时越界)
        while (i1 < str1.length && i2 < str2.length) {
            // KMP加速过程中只有三种情况
            if (str1[i1] == str2[i2]) {
                // 对应位置一样,str1和str2并行向后继续匹配
                i1 ++;
                i2 ++; // 只有匹配字符相等时i2才会往后走
            } else if (i2 == 0) { // next[i2] == -1
                // 对应位置不一样,但是str2的匹配指针在0位置,说明i2跳到0位置了,意味着str1前面一整段没有位置能和str2匹配成功
                // str1匹配指针向后移一位开始下一段与str2的匹配
                i1 ++;
            } else {
                // 对应位置不一样,且str2的匹配指针不在0位置,此时i2需要跳到最长前缀的下一位,进行下一次比较
                // i2跳的这个过程就是KMP常数加速的操作
                i2 = next[i2];
            }
        }

        // 如果i2越界,那么说明str1中匹配成功了str2,那么就返回str1中子串的首位
        // 如果i1越界,那么说明str1中没有任何一位能够与str2匹配成功,返回-1
        return i2 == str2.length ? i1 - i2 : -1;
    }

    // 统计最大前后缀的本质就是确定str2每一位的最大前缀,预估的最大前缀在没有匹配成功时每一轮都会缩小,直到收缩到0,则表示该位置没有最大前缀
    public static int[] getMaximalPrefixAndSuffix(char[] str2) {
        // 如果str2中只有一个字符,那么一定是next[0]
        if (str2.length == 1) {
            return new int[] {-1};
        }

        // 如果不止一个字符,那么手动给next[0]和next[1]赋值
        int[] next = new int[str2.length];
        next[0] = -1;
        next[1] = 0;

        // str2的游标,从str2[2]后开始给next填值
        int i = 2;

        // prefix是c[i]目前最有可能的最长前缀的后一位的下标
        //prefix 跟i-1的位置比较
        int prefix = 0;

        // 当i越界时表示next已经全部填满
        while (i < str2.length) {
            if (str2[i - 1] == str2[prefix]) {
                // str2[i]的前一位和str2[i]当前最有可能的最长前缀的后一位的下标相同,说明最长前缀还能延长,需要包含str2[prefix]
                // 同时当前第i位匹配成功
                next[i] = prefix + 1;
                i++;
                prefix++;
            } else if (prefix > 0) {
                // 如果str2[i]的前一位和str2[i]当前最有可能的最长前缀的后一位的下标不相同,说明最长前缀必须缩小,prefix需要向前跳
                // prefix需要跳到c[prefix]最长前缀的后一位
                // 当前第i位匹配失败,下一轮继续匹配第i位
                prefix = next[prefix];
            } else {
                // 当prefix跳到第0位时,还和第i位匹配不上,说明str2[i]没有最长前缀,置为0
                // 同时当前第i位匹配成功
                next[i] = 0;
                i++;
            }
        }

        return next;
    }
}

KMP进一步优化:优化next数组。如果a位字符与它的next值(即next[a])指向的b位字符相等(即p[a] == p[next[a]]),则a位的next值就指向b位的next值即(next[ next[a] ])。

5. 二叉树

5.1 基本概念

  1. 根节点
  2. 叶子节点
  3. 各种节点:
    • 祖先节点
    • 子孙节点
    • 父节点
    • 孩子节点
    • 兄弟节点
    • 堂兄弟:同一层
  4. 节点之间的路径:只能从上往下。路径长度:经过几条边。
  5. 节点的层次(深度):从上往下数
  6. 节点的高度:从下往上数
  7. 树的高度:层数
  8. 节点的度:有几个孩子(分支)
  9. 树的度:各个节点的度的最大值
  10. 有序树:各子树从左到右是有次序的,不能互换。/无序树:无次序,可以换。
  11. 森林:m棵互不相交的树的集合

5.2 树的性质

  1. 节点树 = 总度数 + 1
  2. 度为m的树和m叉树的区别:度为m的树,一定是非空树,至少有m+1个节点。m叉树可以空
  3. m叉树的树第i层至多有mi-1个节点(i >= 1)
  4. 高度为h的m叉树至多有(mh-1)/m-1个节点(等比数列求和),最少有h个节点
  5. 具有n个节点的m叉树最小高度为logm(n(m-1)+1)

5.3 二叉树的定义

  1. 特点:每个节点最多有两棵子树,左右子树不能颠倒
  2. 特殊二叉树:
    • 满二叉树:一棵高度为h,且含有2h-1个节点的二叉树。(只有最后一层有叶子节点,按层序从1开始编号,节点i左孩子为2i,右孩子为2i+1)
    • 完全二叉树:当且仅当其每个节点都与高度为h的满二叉树中编号为1~n的节点一一对应时,称为完全二叉树。(i<=n/2为分支节点,i>n/2为叶子节点)
    • 二叉排序树:左子树上所有节点的数都小于跟节点的数,右子树上所有节点的数都大于跟节点的数。左右子树又各是一棵二叉排序树。
    • 平衡二叉树:树上任一节点的左子树和右子树的深度差不超过1

5.4 二叉树的性质

  1. 叶子节点比二分支节点多一个
  2. 完全二叉树:有n个节点,高度h为log2(n+1)或者log2n + 1

5.5 二叉树的存储结构

  1. 顺序存储: 用数组,按从上到下,从左到右存储完全二叉树
  2. 链式存储:每个节点,有数据域和左、右孩子指针

5.6 二叉树的遍历:递归DFS

递归:每个节点都会被路过3次

  1. 前序:根左右(第一次路过时访问)
  2. 中序:左根右(第二次路过时访问)
  3. 后序:左右根(第三次路过时访问)

5.7 二叉树的层序遍历:BFS

使用队列,放入根节点,若队列非空,则把第一个节点出队,并将其左、右孩子插入队尾。直到队列空。

5.8 由遍历序列构造二叉树

单个遍历序列可能对应多种二叉树形态,不能唯一确定一棵二叉树。

Key:找到树的根节点,并根据中序序列划分左右子树,再找到左右子树根节点

  1. 前序+中序:

    前+中

  2. 后序+中序:

    后+中

  3. 层序+中序:

    层+中

5.9 线索二叉树

为了更方便的找到节点的前驱和后继,只用线索二叉树。遍历也更加方便。

  1. 中序线索二叉树: 中序线索二叉树

  2. 存储方式:使用一个flag,如果flag是0,表示指针指向左右孩子,flag为1,表示指针指向线索

  3. 先序线索二叉树:

    先序线索二叉树

  4. 后序线索二叉树: 后序线索二叉树

5.10 树的存储结构

  1. 双亲表示法:顺序存储。每个节点中保存指向双亲的指针。缺点:查指定节点的孩子只能从头遍历。
  2. 孩子表示法:顺序加链式存储。顺序存储各个节点,每个节点中保存孩子链表的指针。
  3. 孩子兄弟表示法:链式存储。左指针指向第一个孩子,右指针指向右边相邻的第一个兄弟。二叉树转换成树。

5.11 森林

  1. 森林和二叉树的转化:孩子兄弟表示法
  2. 森林的遍历:
    • 先序
    • 中序

5.12 哈夫曼树

  1. 带权路径长度

    • 节点的权:有某种现实意义的数值
    • 节点的带权路径长度:从树的根到该节点的路径长度(经过的边树)与该节点上权值的乘积
    • 树的带权路径长度(WPL):树中所有叶节点的带权路径长度之和
  2. 定义:在含有n个带权叶节点的二叉树中,其中带权路径长度(WPL)最小的二叉树称为哈夫曼树,也称最优二叉树。

  3. 构造方法: 构造方法

  4. 哈夫曼编码:

    • 固定长度编码
    • 可变长度编码:前缀编码
    • 由哈夫曼树得到哈夫曼编码:字符集中的每个字符作为一个叶子节点,各个字符出现的频率作为节点的权值,构造哈夫曼树

5.13 并查集 Disjoint Set

是逻辑结构--集合的一种具体实现,只进行并和查两个基本操作。

  1. 如何表示集合关系

将各个元素划分为若干个互不相交的子集。把子集中的元素形成树。

如何查到一个元素属于哪一个集合?从指定元素出发,网上找,找到根节点。

如何判断两个元素是否属于同一个集合?分别查到两个元素的根,判断根节点是否相同。

如何合并为一个集合?把一棵树变为另一棵树的子树。

  1. 并查集的实现

存储结构:双亲表示法:顺序存储。每个节点中保存指向双亲的指针。好处:并和查很方便。

class DisjointSet{
    int sets[size];
    initial(sets);
    //initial 
    public void initial(int[] s){
        for(int i = 0; i < s.length; i++){
            s[i] = -1;   //初始化所有都是独立的n个子集
        }
    }
    //find
    public int find(int[] s, int x){
        while(s[x] >= 0){
            x = s[x];
        }
        return x;
    }
    
    // union
    public void union(int[] s, int root1, int root2){
        //要求root1和root2是不同集合
        if(root1 == root2){
            return;
        }
        //将根root2连接到root1下面g
        s[root2] = root1;
    }
}
  1. 并查集的优化 union: O(1) Time. Find O(n) Time

用根节点的绝对值表示树的节点总数,union时让小树合并到大树。

优化后,find O(log n)

public void union(int[] s, int root1, int root2){
    //要求root1和root2是不同集合
    if(root1 == root2){
        return;
    }
    //将根root2连接到root1下面g
    if(s[root2] > s[root1]){ //root2节点更少
        s[root1] += s[root2];  // 累加总和
        s[root2] = root1; // 小树合并到大树
    }else{
        s[root2] += s[root1];
        s[root1] = root2;
    }
}

再优化:压缩路径:find操作,先找到根节点,再将查找路径上所有节点都挂到根节点下。

public int find(int[] s, int x){
    int root = x;
    while(s[root] >= 0){ //循环找到根
        root = s[root];
    }
    while(x != root){//压缩路径
        int t = s[x];//t指向x的父节点
        s[x] = root;//x直接挂到根节点下
        x = t;
    }
    return root;//返回根节点
}

并查集优化

6. 图

6.1 图的定义

图的定义

无向图和有向图

简单图

定点的度

顶点 无向图中,若从顶点v到顶点w以及从顶点w到顶点v有路径,则称这两个顶点是强连通的

连通图

子图

连通分量无向图

强连通有向图

生成树

生成森林

带权图

完全图

树:不存在回路,且连通的无向图。 有向树:一个顶点的入度为0,其余顶点的入度均为1的有向图。

6.2 图的存储

6.2.1 邻接矩阵

邻接矩阵 定义二维数组存储

点

带权图

性能分析:O(V2) Space. V是顶点数量。

6.2.2 邻接表

顺序存储 + 链式存储

邻接表

邻接表表示方式不唯一。顺序可能变换。

6.2.3 十字链表

存储有向图

十字链表

6.2.4 邻接多重表

存储无向图

6.3 图的基本操作

找顶点的边/插入顶点/删除顶点/增加边/找顶点的第一个邻接点/找到下一个邻接点

6.4 图的遍历

6.4.1 BFS

  1. 找到与一个顶点相邻的所有顶点
  2. 标记哪些顶点被访问过
  3. 需要一个辅助队列
public void BFS(int[] mVexs, int v){//传入顶点集合以及第一个访问的顶点
    LinkedList<Integer> queue = new LinkedList<>();//辅助队列
    boolean[] visited = new boolean[mVexs.length];
    System.out.print(v);
    visited[v] = true;
    queue.offer(v);
    while(!queue.isEmpty()){
        queue.poll();
        for(int w = firstVertex(i); w >= 0; w = nextVertex(i, w)){//遍历所有顶点,检测v所有的邻接点
            if(!visited[w]){
                System.out.print(w);
                visited[w] = true;
                queue.offer(w);
            }
        }
    }
}

广度优先生成树,由广度优先查找的顺序生成的树。

广度优先生成森林。

6.4.2 DFS

递归

public void DFS(int[] mVexs, int v){//传入顶点集合以及第一个访问的顶点
    boolean[] visited = new boolean[mVexs.length];
    System.out.print(v);
    visited[v] = true;
    for(int w = firstVertex(i); w >= 0; w = nextVertex(i, w)){//遍历所有顶点,检测v所有的邻接点
        if(!visited[w]){
            DFS(mVexs, w);
        }
    }
}

深度优先生成树。由深度优先查找的顺序生成的树。

6.5 最小生成树 MST

MST

6.5.1 Prim

从某一顶点开始构建生成树,每次将代价最小的新顶点纳入生成树,直到所有顶点都纳入为止。(本质是动态规划,选局部最优,最后成为总体最优: O(n2) Time.

6.5.2 Kruskal

每次选择权值最小的边,使这条边的两头连通(原本已经连通的就不选),直到所有节点都连通。(本质是贪心,选当前看来最优的):O()

6.6 最短路径

6.6.1 单源最短路径

6.6.1.1 BFS

无权图: 每一次BFS,找到的点与源点的距离就是上一次的点与源点的距离+1

6.6.1.2 Dijkstra

有权图,无权图:

dijkstra

Dijkstra不适用于有负权值的带权图

6.6.2 各个顶点间的最短路径 Floyd

有权图,无权图: floyd O(V3) Time/O(V2) space

适用于负权值的图,但不适用于带有负权值回路的图

6.7 有向无环图 DAG

描述表达式

6.8 拓扑排序

AOV网:Activity on Vertex NetWork,用顶点表示活动的网,一定是有向无环图。

拓扑排序 找到做事的顺序

拓扑实现 拓扑实现:也可以使用DFS

逆拓扑排序:找出度为0的点。实现:DFS算法

6.9 关键路径

AOE网:Activity On Edge NewWork

AOE

AOE2

从源点到汇点的有向路径可能有多条,所有路径中,具有最大路径长度的路径成为关键路径,关键路径上的活动成为关键活动。完成整个工程的最短时间就是关键路径的长度,若关键活动不能按时完成,则整个工程的完成时间就会延长。

一些时间

  1. 求所有事件的最早发生时间
  2. 求所有事件的最迟发生时间:逆拓扑排序
  3. 求所有活动的最早发生时间
  4. 求所有活动的最迟发生时间
  5. 求所有活动到的时间余量

7.查找

7.1 基本概念

  1. 查找:在数据集合中寻找满足某种条件的数据元素的过程
  2. 查找表:用于查找的数据集合,由同一类型的数据元素组成
  3. 关键字:数据元素中唯一标识该元素的某个数据项的值,使用基于关键词的查找,查找结果应该唯一
  4. 查找长度:在查找运算中,需要对比关键字的次数
  5. 平均查找长度(ASL, Average Search Length):所有查找过程中进行关键字的比较次数的平均值,可以用来评价查找算法的效率

7.2 顺序查找

又称线性查找,通常用于线性表。算法思想:从头到尾或者从尾到头依次查找。

O(n) Time. O(1) Space.

7.3 二分查找

仅适用于有序的顺序表。O(log n) Time

7.4 分块查找

又称索引顺序查找,数据分块存储,块内无序,块间有序

7.5 二叉排序树 BST

又称二叉查找树(BST,Binary Search Tree):左子树节点值<根节点值<右子树节点值。进行中序遍历,可以得到递增的有序序列

  1. 查找: BST查找

  2. 插入: BST插入

  3. 构造:不同关键字序列可能得到不同的BST

  4. 删除:先找到删除目标

    • 目标是叶子节点,直接删除
    • 目标只有一棵左子树或右子树,让子树代替目标
    • 目标有左、右两颗子树,另节点的直接后继(右子树最左下)/或直接前驱(左子树最右下)代替目标。
  5. 查找效率:O(log n) Time

7.6 平衡二叉树 AVL

Balanced Binary Tree,简称AVL树:树上任一节点的左子树和右子树的高度差不超过1。平衡二叉树必须是二叉排序树。查找:O(log n) Time

每个节点都有平衡因子,平衡因子是左右子树高度差,差应该不大于1.

  1. 插入:插入时不平衡,找到最小不平衡子树调整:

    最小不平衡子树

    最小不平衡子树调整

  2. 删除 AVL delete

7.7 红黑树

平衡二叉树AVL:插入/删除很容易破坏平衡特性,需要频繁调整树的形态,时间开销大

红黑树:插入/删除不破坏红黑性质,无需频繁调整树的形态,即使调整,也在常数级别内。

  1. 红黑树定义: 红黑树

节点的黑高:从某节点出发(不含该节点)到达任一空叶节点的路径上黑节点总数

  1. 性质:

    • O(log n) Time。与BST,AVL查找相同
    • 从根节点到叶节点的最长路径不大于最短路径的2倍
    • 有n个内部节点的红黑树高度h <= 2log2(n + 1)
  2. 插入: 红黑树插入

  3. 删除: 红黑树删除

7.8 B树

多路平衡查找树 Btree

B4

B插入删除

7.9 B+树

B+

在B+树中,叶节点包含信息,所有非叶节点仅仅起索引作用,非叶节点的每个索引相只含有对应子树的最大关键字和指向该子树的指针,不含有该干茧子对应记录的存储地址。

7.10 散列查找 hash

散列表(Hash Table),又称哈希表。是一种数据结构,特点是数据元素的关键字与其存储地址直接相关。

常见散列函数: image.png

哈希冲突:储存地址相同。

解决方法:

  1. 链地址法:把地址相同的值,存储在一个链表中 (HashMap,HashSet)

  2. 开放定址:

    • 线性探测:冲突时,每次往后探测相邻的下一个单元是否为空
    • 平方探测法
    • 伪随机序列
  3. 再散列法:多准备几个散列函数,有冲突时,使用下一个函数再次计算

8. 排序 sort

8.1 排序定义

重新排列表中元素,使表中的元素满足按关键字有序的过程

算法的稳定性:关键字相同的元素,在排序后相对位置是否改变。不变则稳定。

8.2 插入排序

8.2.1 插入排序

每次讲一个待排序的记录按关键字大小插入到前面已经排好序的子序列中,直到全部记录插入完成

public void insertSort(int[] arr){
    for(int i = 1; i < arr.length; i++){   // 将各个元素插入已经排好序的序列中
        if(arr[i] < arr[i - 1]){   //arr[i] 小于前面
            int temp = arr[i];    // 用temp暂存arr[i]
            for(int j = i - 1; j >= 0 && arr[j] > temp; j--){   //检查所有前面已经排好序的元素
                arr[j + 1] = arr[j];    // 所有大于temp的元素向后诺
            }
            arr[j + 1] = temp;       //把temp插入
        }
    }
}

O(n2) Time/O(1) Space。稳定

优化:二分插入排序。先用二分查找找到应该插入的位置,再移动元素。

8.2.2 希尔排序

先追求局部有序,再追求全局有序

shell

public void shellSort(int[] arr){
    for(int d = arr.length / 2; d >= 1; d = d/2){  //步长变化
        for(int i = d + 1; i <= arr.length; i++){
            if(arr[i] < arr[i - d]){   //将arr[i]插入有序增量子表
                int temp = arr[i];
                for(j = i - d; j > 0 && temp < arr[j]; j -= d){//记录后移,查找插入位置
                    arr[j + d] = arr[j];
                }
                arr[j + d] = temp;   //插入
            }
        }
    }
}

时间复杂度不能用数学方法计算,但优于插入。不稳定

8.3 交换排序

基于交换的排序:根据序列中两个元素关键字的比较结果来对换两个元素在序列中的位置。

8.3.1 冒泡排序

从后往前两两比较相邻元素的值,若为逆序(即a[i - 1] > a[i]),则交换他们,直到序列比较完成。

public void bubbleSort(int[] arr){
    for(int i = 0; i < arr.length - 1; i++){
        boolean flag = false;  //表示本次发生冒泡交换
        for(int j = arr.length - 1; j > i; j--){  // 一次冒泡
            if(arr[j - 1] > arr[j]){     //若为逆序
                int temp = arr[j - 1];   //交换
                arr[j - 1] = arr[j];
                arr[j] = temp;
                flag = true;
            }
        }
        if(flag == false){  // 本次遍历后没有发生交换,说明表已经有序
            return;
        }
    }
}

O(n2) Time. 稳定。

8.3.2 快速排序

基于交换的排序:根据序列中两个元素关键字的比较结果来对换两个元素在序列中的位置。

快排

public void quickSort(int[] arr){
    int low = 0;
    int high = arr.length - 1;
    if(low < high){//递归结束条件
        int pivot = partition(arr, low, high); //划分
        quickSort(arr, low, pivot - 1);//划分左子表
        quickSort(arr, pivot + 1, high);//划分右子表
    }
}
//用第一个元素将待排序列划分为左右两部分
public void partition(int[] arr, int low, int high){
    int pivot = arr[low];  //第一个元素作为pivot
    while(low < high){
        while(low < high && arr[high] >= pivot){//比pivot小放左边
            high--;
            arr[low] = arr[high];
        }
        while(low < high && arr[low] <= pivot){//比pivot大放右边
            low++;
            arr[high] = arr[low];
        }
    }
    arr[low] = pivot; //pivot放入最终位置
    return low;   //返回pivot位置
}

O(nlog n) Time. 不稳定

8.4 选择排序

每一趟在待排序元素中选取关键字最小(或最大)的元素加入有序子序列

8.4.1 简单选择排序

每一趟在待排序元素中选取关键字最小的元素,放在开头

public void selectSort(int arr[]){
    for(int i = 0; i < arr.length - 1; i++){   //一共n-1趟
        int min = i;      //记录最小元素位置
        for(int j = i + 1; j < arr.length; j++){ //选择最小元素
            if(arr[j] < arr[min]){
                min = j;  //更新最小元素位置
            }
        }
        if(min != i){
            int temp = arr[min];  //更新最小元素
            arr[min] = arr[j];
            arr[j] = temp;
        }
    }
}

O(n2) Time/ O(1) space。不稳定

8.4.2 堆排序

堆就是完全二叉树。 大小根堆

大根堆:完全二叉树中,根 >= 左、右 小根堆:完全二叉树中,根 <= 左、右

思路:先建立大根堆:把所有非叶节点都检查一遍,是否满足大根堆的要求,不满足进行调整。然后,每一趟将堆顶元素加入有序子序列(与待排序序列中的最后一个元素交换),并将待排序元素序列再次调整为大根堆。

public void buildMaxHeap(int arr[]){
    for(int i = len/2; i > 0; i--){  //从后往前调整所有非终端节点
        headAdjust(arr, i, arr.length);
    }
}
//将以k为根的子树调整为大根堆
public void headAdjust(int[] arr, int k, int len){
    int temp = arr[k];   
    for(int i = 2 * k; i <= len; i *= 2){//沿key较大的子节点向下筛选
        if(i < len && arr[i] < arr[i + 1]){//
            i++;//去key较大的子节点的下标
        }
        if(temp >= arr[i]){
            break;//筛选结束
        }else{
            arr[k] = arr[i];//将arr[i]调整到双亲节点上
            k = i;//修改k值,以便继续向下筛选
        }
    }
    arr[k] = temp;//被筛选节点的值放入最终位置
}

public void heapSort(int arr[]){
    buildMaxHeap(arr);  //建立初始堆
    for(int i = len; i > 1; i--){
        int temp = arr[i]; //堆顶元素和堆底元素交换
        arr[i] = arr[1];
        arr[1] = temp;
        headAdjust(arr, 1, i - 1);//把剩余的待排序元素整理成堆
    }
}

建立堆O(n) Time/排序 O(n log n) Time。不稳定

堆的插入和删除

堆的插入和删除

8.5 归并排序

归并排序

public void merge(int[] arr, int low, int mid, int high){
    int[] temp = new int[arr.length];  //辅助数组
    for(int k = low; k <= high; k++){
        temp[k] = arr[k];  //把arr元素全部复制到辅助数组
    }
    for(int i = low, j = mid + 1, k = i; i <= mid && j <= high; k++){
        if(temp[i] <= temp[j]){
            arr[k] = temp[k++];  //较小值复制到arr
        }else{
            arr[k] = temp[j++];
        }
    }
    while(i <= mid){
        arr[k++] = temp[i++];
    }
    while(j <= high){
        arr[k++] = temp[j++];
    }
}

public void mergeSort(int[] arr){
    int low = 0;
    int high = arr.length -1;
    if(low < high){
        int mid  = low + (high - low)/2;  //从中间划分
        mergeSort(arr,low,mid);   //对左半部分归并
        mergeSort(arr,mid + 1, high);//对右半部分归并
        merge(arr, low, mid, high);//归并
    }
}

O(nlog n) Time/O(n) space。稳定

8.6 基数排序 radix

不基于比较的排序算法

O(d(n+r)) Time(d是关键字分组,r是每组关键字的取值范围)/O(n) space。稳定。适用于d,r小,n大的情况

8.7 外部排序

8.7.1 基本外部排序

外部排序

8.7.2 败者树

败者树,可以视为一棵完全二叉树(多了一个根节点上面的节点)。用来优化外部排序(多路平衡归并)。

败者树

8.7.3 置换-选择排序

置换-选择排序

8.7.4 最佳归并树

最佳归并树