数据结构与算法之线性表(数组、链表、栈、队列)


在讲线性表常见的数据结构,里面涉及到一些算法的复杂度分析,这是很重要的一步,如果对复杂度分析不是很明确的同学可以先去看这篇文章: 算法复杂度分析

线性表(Linear List),顾名思义,线性表就是数据排成像一条线一样的结构。每个线性表上的数据最多只有前和后两个方向。

线性表结构包含数组、链表、栈、队列这几种,如下图所示:
在这里插入图片描述

下面详细来说明下这几种线性表类型的数据结构。

1 数组

它用一组连续的内存空间,来存储一组具有相同类型的数据。

1.1 随机访问

数组是如何进行随机访问的?

比如声明一个大小为10的数组,int[] a = new int[10],分配了一块连续内存空间1000~1039,如下图所示:
在这里插入图片描述

内存块首地址为1000,计算机是通过地址来访问内存数据的,以这个数组为例,计算机是如何得到每个数组元素对应的内存地址的呢?

  • a[0]就是数组起始地址1000;
  • a[1]的地址就等于1000+4*1=1004(int类型占4个字节);
  • a[9]的地址可以推算出位1000+4*9=1036。

从上面推算可以得出,数组中元素的访问是通过寻址公式计算出该元素存储的内存地址,进而进行访问的,寻址公式如下:

a[i]_address = base_address + i * data_type_size

这也是为什么数组下标从0开始,如果从1开始寻址公式就变成了下面这样:

a[k]_address = base_address + (k-1)*type_size

相当于多了一次减法操作。

数组作为非常基础的数据结构,通过下标随机访问数组元素又是其非常基础的编程操作,效率的优化就要尽可能做到极致。所以为了减少一次减法操作,数组选择了从 0 开始编号,而不是从 1 开始。

你从网上可能看到很多对数组的描述是"数组适合查找,查找时间复杂度为O(1)",这种表述其实不太准确,数组是适合查找操作,但是查找的时间复杂度并不为O(1)。即便是排好序的数组,你用二分查找,时间复杂度也是O(logn)。

正确的表述是"数组支持随机访问,根据下标随机访问的时间复杂度为O(1)"。

1.2 插入和删除元素

数组为了保持内存数据的连续性,会导致插入、删除这两个操作比较低效。

1.2.1 插入操作

我们先来分析下对一个长度为n的数组在某个位置上插入一个元素最好情况最坏情况以及平均时间复杂度各是多少?

因为数组必须保证内存数据的连续性(不然怎么支持随机访问),所以在某个位置插入一个元素,这个位置后面的元素都得往后移动一位。

  • 最好情况时间复杂度:直接在数组末尾插入一个元素,数组中元素都不需要移动,这样时间复杂度就为O(1)

  • 最坏情况时间复杂度:在数组为0的位置插入元素(数组的第一个元素),那么从位置1开始,一直到n-1的元素都得往后移动一步,这样时间复杂度就是O(n)

  • 平均时间复杂度:元素在插入数组每个位置的概率是一样的,都是1/n,插入第0的位置一直到第n-1的位置元素移动次数位n、n-1、…、1,所以可以得出:
    O ( n ) = O ( n + . . . + 1 n ) = O ( n ) O(n) = O(\frac{n+...+1}{n})=O(n) O(n)=O(nn+...+1)=O(n)

可以发现,数组有序时,在某个位置插入一个新的元素时,就必须搬移插入位置后面所有的元素;但如果数组是没有任何规律可言,有什么方式可以减少这种大规模的搬移吗?

举个例子,假设数组a[10]中存储了如下5个元素:a,b,c,d,e,在需要将元素x插入到第3个位置。我们只需要将c放入到a[5],将a[2]赋值为x即可。最后,数组中的元素如下: a,b,x,d,e,c。

在这里插入图片描述

利用这种特定的技巧,在第k个位置插入一个元素的时间复杂度就会降为O(1)

1.2.2 删除操作

跟插入数据类似,如果我们要删除第k个位置的数据,为了内存的连续性,也需要搬移数据,不然中间就会出现空洞,内存就不连续了。

分析时间复杂度与插入操作类似

  • 最好情况时间复杂度:删除数组末尾的数据,O(1);
  • 最坏情况时间复杂度:删除开头的数据,O(n);
  • 平均时间复杂度O(n)

实际上,在某些特殊场景下,我们并不一定非得追求数组中数据的连续性。我们可以通过"伪删除,积攒一波再统一移动元素"的方式提高删除效率。数组 a[10] 中存储了 8 个元素:a,b,c,d,e,f,g,h。现在,我们要依次删除 a,b,c 三个元素。
在这里插入图片描述

删除a,b,c元素为了避免对d,e,f移动3次,我们可以每次删除时"不真正删除",只是标记元素已经被删除了,当数组没有更多空间存储数据时,我们再触发执行一次真正的删除操作,这样就大大减少了删除操作导致的数据搬移。JVM 标记清除垃圾回收算法的核心思想就是通过这种方式。

1.2.3 数组和容器的选择

容器比如Java 中的 ArrayList,它的最大优势就是可以将很多数组操作的细节封装起来,比如插入和删除操作,同时还支持动态扩容

那数组什么场景下可以考虑使用呢?

  1. Java ArrayList 无法存储基本类型,比如 int、long,可以选择数组存储;
  2. 如果数据大小事先已知,并且对数据的操作非常简单,用不到 ArrayList 提供的大部分方法,也可以直接使用数组;
  3. 当表示多维数组时,Object[][] array往往比ArrayListarray看着直接。(取决于个人习惯)。

2 链表

并不需要一块连续的内存空间,它通过“指针”将一组零散的内存块串联起来使用。

链表和数组在内存上很大不同时链表不需要连续的存储空间,下图是数组和链表在内存分配上的区别:
在这里插入图片描述

可以看出链表的内存是非连续的,通过一个个"指针"将不同内存块串起来。

2.1 常见的链表结构

常见的链表结构主要包括:

  1. 单链表;
  2. 循环链表;
  3. 双向链表。

链表和数组的性能优劣之分:

  1. 插入、删除、随机访问操作的时间复杂度正好相反;

    在这里插入图片描述

  2. 内存分配方式不同导致性能有所不同;

    • 数组使用连续的内存空间,可借助CPU缓存机制预读数组中的数据,访问效率高;链表因为是非连续的内存空间,没法做预读。
    • 数组分配的内存是固定的,若声明数组过大,系统没有足够的连续内存空间,出现"out of memory",若声明数组过小,可能不够使用,这时只能再申请一个更大的内存空间,将原数组拷贝过去,这个过程是很耗时的;链表可以申请比较零散的内存,天然支持动态扩容
2.1.1 单链表

最简单、最常用
在这里插入图片描述

如图所示,链表通过指针将一组零散的内存块串联在一起,我们把内存块称为链表的“结点”,为了将所有的结点串起来,每个链表的结点除了存储数据之外,还需要记录链上的下一个结点的地址。

  • 我们把记录下一结点地址的指针叫做后继指针next
  • 第一个结点称为头结点,用来记录链表的基地址,可以通过它来遍历得到整个链表;
  • 最后一个结点称为尾节点;它的next指针是指向一个空地址NULL
插入和删除操作

链表不像数组那样,插入和删除操作需要大量移动元素,因为有了后继指针next的存在,插入和删除操作的时间复杂度都是O(1),如下图所示,我们只需要改变next指针的指向就可以轻松做到插入和删除。
在这里插入图片描述

随机访问操作

链表要想随机访问第k个元素,就没有数组那么高效了。因为链表中的数据并非连续存储的,只能通过next指针一个个的遍历结点往后寻找。

可以把链表想象成一个队伍,队伍中的每个人都只知道自己后面的人是谁,所以当我们希望知道排在第k位的人是谁的时候,我们就需要从第一个人开始,一个一个地往下数。

所以它的随机访问的时间复杂度为O(n)

循环链表

循环链表是一种特殊的单链表。

循环链表如下图所示:
在这里插入图片描述

可以看出和单链表明显的区别是:循环链表尾结点的指针指向的是链表的头结点。

循环链表最大的优点是:链尾到链头比较方便,适合处理具有环形结构特点的数据,比如约瑟夫问题

双向链表

空间换时间的设计思想,实际软件开发,比较常用的链表结构。

双向链表如下图所示:
在这里插入图片描述

可以看出,双向链表多了一个pre指针,用于存储前驱结点地址,如果存储同样多的数据,双向链表要比单链表占用更多的内存空间

但它比单链表更为灵活,支持双向遍历,而且双向链表在删除给定指针指向的结点比单链表更有优势。

下面看下两者在删除给定指针指向结点有啥区别:

  • 要删除某个结点,单链表必须找到这个结点的前驱结点,所以只能从头结点开始循环遍历,直到p->next=q,说明p是q的前驱结点,所以时间复杂度为O(n)
  • 对于双向链表,因为结点已经保存了前驱结点的指针,不需要像单链表那样遍历,所以时间复杂度为O(1)

2.2 写链表代码的一些技巧

2.2.1 掌握指针或引用的概念

指针或引用都是存储所指对象的内存地址。

  • 将某个变量赋值给指针,实际上就是将这个变量的地址赋值给指针
  • 指针中存储了这个变量的内存地址,指向了这个变量,通过指针就能找到这个变量

深刻理解上面两句话的含义,我们看下面两个常见链表代码表示的意思:

  1. p->next=q;表示p结点中的next指针存储了q结点的内存地址;
  2. p->next=p->next->next;表示p结点的next指针存储了p结点的下下一个结点的内存地址。
2.2.2 注意指针丢失和内存泄漏

写链表代码的时候,指针指来指去,要特别注意别把指针给弄丢了。

比如在一个单链表做插入操作,如下图所示:
在这里插入图片描述

现在是在a,b结点之间插入x,当前指针p指向的是a,初学者可能会考虑这样写:

p->next = x; // 将p的next指针指向x结点;
x->next = p->next; // 将x的结点的next指针指向b结点;

先操作p的后继next指针指向x后,你会发现,a结点后面的链表你无法再定位到了,实际上第二行代码含义就变成了x自己指向自己了,整个链表划分为了两个部分,后面那个部分无法再访问了,对于一些语言,比如C语言,没有手动释放内存空间就会导致内存泄露删除链表同样也需要注意内存的释放。(Java有自动管理内存的虚拟机,就不用考虑这些)

插入结点正确的书写应该如下:

x->next = p->next;
p->next = x;

其实这种写法还有问题,下面哨兵结点会给出分析。

2.2.3 增加不存储值的头结点简化链表操作
  1. 插入操作时,如果是空链表就需要增加一段特殊逻辑处理这种情况:

    if (head == null) {
      head = new_node;
    }
    
  2. 删除操作,如果在处理最后一个结点我们也需要特殊处理:

    if (head->next == null) {
       head = null;
    }
    

你会发现链表的插入、删除操作,需要对插入第一个结点和删除最后一个结点的情况进行特殊处理。有没有什么方式简化操作呢?

可以通过引入一个哨兵结点,不存储任何值,不管链表是否为空,head指针一直指向这个哨兵结点。把这种带有哨兵结点的链表称为带头链表。如下图所示:

在这里插入图片描述

因为哨兵结点一直存在,所以插入第一个结点和插入其他结点,删除最后一个结点和删除其他结点,都可以统一为相同的代码实现逻辑了,就不需要做特殊的判断处理了。

哨兵简化编码的技巧在很多算法中都有用到,比如插入排序,归并排序。

2.2.4 注意处理边界条件

可以尝试造以下几种case来确定边界条件是否正确:

  1. 如果链表为空时,代码是否能正常工作?
  2. 如果链表只包含一个结点时,代码是否能正常工作?
  3. 如果链表只包含两个结点时,代码是否能正常工作?
  4. 代码逻辑在处理头结点和尾结点的时候,是否能正常工作?
2.2.5 举例画图辅助思考

当涉及到链表指针变化很复杂时,可以尝试通过举例法画图法找出各种情况的变化规则。

2.2.5 多多练习,孰能生巧

可以尝试反复练习下以下几种链表的操作,达到孰能生巧的地步,通过下面联系,你以后应该不会再害怕写链表代码,这里我贴出了力扣的跳转链接:

  1. 单链表反转
  2. 链表中环的检测
  3. 两个有序的链表合并
  4. 删除链表倒数第 n 个结点
  5. 求链表的中间结点

3 栈

一种操作受限的线性表数据结构,特点是后进者先出,先进者后出

举个简单的例子先了解下栈,如下图所示:
在这里插入图片描述

叠盘子的过程就和栈的思想有异曲同工之妙,放盘子是从下往上一个个放,取是从上往下一个个取。这就是典型的栈结构,先进后出,后进先出

什么场景可以考虑用栈?

当某个数据集合只涉及在一端插入和删除数据,并且满足后进先出、先进后出的特性,我们就应该首选“栈”这种数据结构

3.1 栈的实现

  1. 通过数组实现,叫做顺序栈
  2. 通过链表实现,叫做链式栈

栈的实现空间复杂度为O(1),入栈和出栈只涉及栈顶的个别数据,时间复杂度也是O(1)

3.1.1 固定容量的顺序栈实现

特点:容量首次初始化就固定了,不支持动态扩容。

public class ArrayStack {

    // 存储栈元素的容器
    private String[] items;

    // 当前栈中元素个数
    private int count;

    // 栈的容量大小
    private int n;

    // 初始化栈,实际上是申请一个大小为n的数组空间
    public ArrayStack(int n) {
        this.items = new String[n];
        this.n = n;
        this.count = 0;
    }

    /**
     * 入栈操作
     * @param item 要入栈的元素
     * @return true->操作成功 false->操作失败
     */
    public boolean push(String item) {
        // 先判断栈的容量是否足够入栈,不够直接返回false,入栈失败
        if (n == count) {
            return false;
        }
        // 将item放到下标为count的位置,并且count加一
        items[count] = item;
        ++count;
        return true;
    }

    /**
     * 出栈操作
     * @return 出栈的元素
     */
    public String pop() {
        // 栈中不存在元素,直接返回null
        if (0 == count) {
            return null;
        }
        // 弹出栈顶元素,并且将栈内元素数量减一
        String tmp = items[count-1];
        --count;
        return tmp;
    }
}
3.1.2 链式栈的实现

特点:栈的大小不受限制,但需要存储next指针,内存消耗过多。

public class LinkedListStack {

    /**
     * 初始化栈的顶部结点->对应链表的头部结点
     * 需要保证top一直指向最新插入的结点
     */
    private Node top = null;

    /**
     * 入栈操作
     * @param value 入栈的元素
     */
    public void push(int value) {
        // 初始化要入栈的结点
        Node newNode = new Node(value, null);
        // 判断栈是否为空
        if (null == top) {
            top = newNode;
        } else {
            // 将新结点插入到头部
            newNode.next = top;
            top = newNode;
        }
    }

    /**
     * 出栈操作
     * @return 出栈元素,-1表示栈中没有数据
     */
    public int pop() {
        if (null == top) {
            return -1;
        }
        // 获取到栈顶元素的值
        int val = top.data;
        // 将top指向top的下一个结点,下一个结点升级为栈顶元素
        top = top.next;
        return val;
    }

    /**
     * 打印栈中元素,从栈顶向栈底遍历
     */
    public void printAll() {
        Node p = top;
        while (p != null) {
            System.out.print(p.data + " ");
            p = p.next;
        }
        System.out.println();
    }


    /**
     * 定义链表的结点
     */
    private static class Node {
        // 结点存储的数据
        private int data;

        // 后继指针next
        private Node next;

        public Node(int data, Node next) {
            this.data = data;
            this.next = next;
        }

        public int getData() {
            return data;
        }
    }
}

3.1.3 支持动态扩容的顺序栈实现

支持动态扩容,和链式栈比,同时不需要消耗额外的next指针内存

顺序栈的底层是数组实现的,想要实现动态扩容,只需要底层数组支持动态扩容

前面数组扩容说过,想要实现数组扩容只需要先申请一个更大的数组,将原来的数据搬到新数组中就可以了,如下图所示,元素入栈数组容量不够涉及扩容操作:
在这里插入图片描述

来分析下入栈、出栈操作的时间复杂度。

出栈操作时间复杂度

出栈不会涉及内存的重新申请和数据的搬移,所以出栈的时间复杂度仍然是O(1)

入栈操作的时间复杂度
  • 最好情况时间复杂度:当栈中有空闲空间,入栈的时间复杂度仍未O(1)

  • 最坏情况时间复杂度:当空间不够时,就需要重新申请内存和数据搬移,复杂度这时为O(n)

  • 平均时间复杂度

    假设栈的大小为k,每次扩容都是原数组长度的2倍,现在有2k个元素需要入栈并且整个过程只涉及入栈操作,每次入栈操作的时间复杂度变化如下:

    在这里插入图片描述

    可以很清晰发现,每次扩容时O(k)的时间复杂度可以均摊到后面k-1个元素入栈的O(1)时间复杂度上,符合均摊时间复杂度的特征,所以平均时间复杂度接近O(1)

3.2 栈的应用

栈作为一个比较基础的数据结构,应用场景还是蛮多的。

有下面几种常见的应用场景:

  1. 函数调用栈;
  2. 表达式求值;
  3. 括号匹配问题。
3.2.1 函数调用栈

操作系统给每个线程分配了一块独立的内存空间,这块内存被组织成“栈”这种结构,用来存储函数调用时的临时变量。每进入一个函数,就会将临时变量作为一个栈帧入栈,当被调用函数执行完成,返回之后,将这个函数对应的栈帧出栈。

比如下面这段代码:

int main() {
   int a = 1; 
   int ret = 0;
   int res = 0;
   ret = add(3, 5);
   res = a + ret;
   printf("%d", res);
   reuturn 0;
}

int add(int x, int y) {
   int sum = 0;
   sum = x + y;
   return sum;
}

执行到add()函数时,函数调用栈情况如下图所示:
在这里插入图片描述

3.2.2 表达式求值

一个只包含加减乘除四则运算算术表达式,比如34+13*9+44-12/3,计算机是怎样计算的呢?

通过两个栈实现,一个保存操作数栈,一个保存运算符栈

从左向右遍历:

  1. 当遇到数字,我们就直接压入操作数栈;
  2. 当遇到运算符,与运算符栈的栈顶元素进行比较:
    1. 如果比运算符栈顶元素的优先级高,就将当前运算符压入栈;
    2. 如果比运算符栈顶元素的优先级低或者相同,从运算符栈中取栈顶运算符,从操作数栈的栈顶取2个操作数,然后进行计算,再把计算完的结果压入操作数栈,继续比较

具体计算思路如下图所示:
在这里插入图片描述

代码参考示例如下:

public class ArithmeticExpression {

    /**
     * 34+13*9+44-12/3
     * @param expression 算术表达式
     * @return
     */
    public int expressionCompute(String expression) {
        Stack<Integer> figureStack = new Stack<>();
        Stack<Character> operateStack = new Stack<>();
        char[] chars = expression.toCharArray();
        for (int i = 0; i < chars.length;) {
            char ch = chars[i];
            // 操作符处理
            if (ch < '0' || ch > '9') {
                // 当前操作符优先级若小于或等于操作符栈顶操作符
                while (!operateStack.isEmpty() && !operatePriorityCompare(ch, operateStack.peek())) {
                    int num2 = figureStack.pop();
                    int num1 = figureStack.pop();
                    Character operate = operateStack.pop();
                    figureStack.push(compute(num1, num2, operate));
                }
                operateStack.push(ch);
                i++;
            } else {
                // 如果是数值需要判断连续的字符是否都是数字
                StringBuilder temp = new StringBuilder(String.valueOf(ch));
                while (++i < chars.length) {
                    char chTmp = chars[i];
                    if (chTmp < '0' || chTmp > '9') {
                        break;
                    }
                    temp.append(chTmp);
                }
                int a = Integer.parseInt(temp.toString());
                figureStack.push(a);
            }
        }
        while (!operateStack.isEmpty()) {
            int num2 = figureStack.pop();
            int num1 = figureStack.pop();
            Character operate = operateStack.pop();
            figureStack.push(compute(num1, num2, operate));
        }
        return figureStack.pop();
    }


    /**
     * 操作优先级比较
     * @param operateA 操作A
     * @param operateB 操作B
     * @return 优先级 operateA大于operateB返回true 小于或等于返回false
     */
    private boolean operatePriorityCompare(char operateA, char operateB) {
        int aPriority = operatePriorityCompute(operateA);
        int bPriority = operatePriorityCompute(operateB);
        return aPriority > bPriority;
    }

    private int operatePriorityCompute(char operate) {
        switch (operate) {
            case '+':
            case '-':
                return 0;
            case '*':
            case '/':
                return 1;
            default:
                throw new UnsupportedOperationException("暂不支持该操作");
        }
    }

    private int compute(int num1, int num2, char operate) {
        switch (operate) {
            case '+':
                return num1 + num2;
            case '-':
                return num1 - num2;
            case '*':
                return num1 * num2;
            case '/':
                return num1 / num2;
        }
        throw new UnsupportedOperationException("暂不支持该操作");
    }
3.2.3 括号匹配问题

借助栈来检查表达式中的括号是否匹配。

比如,{[{}]}或[{()}([])]等都为合法格式,而{[}()]或[({)]为不合法的格式。

可以用栈来解决,从左到右依次扫描字符串:

  1. 当扫描到左括号时,则将其压入栈中;

  2. 当扫描到右括号时,从栈顶取出一个左括号,

    如果能够匹配,则继续扫描剩下的字符串。

    遇到不能配对的右括号,或者栈中没有数据,则说明为非法格式。

  3. 当所有的括号都扫描完成之后,如果栈为空,则说明字符串为合法格式;否则,说明有未匹配的左括号,为非法格式。

代码参考示例:

public class BracketMatching {

    /**
     * 遇左括号入栈,遇右括号判断是否和栈顶括号是一套,
     * 是则弹出栈顶元素,最好弹完栈顶元素若栈为空则是匹配的
     * @param expression
     * @return
     */
    public boolean bracketMatching(String expression) {
        Stack<Character> stack = new Stack<>();
        for (int index = 0; index < expression.length(); index++) {
            switch (expression.charAt(index)) {
                case '(':
                case '{':
                case '[':
                    stack.push(expression.charAt(index));
                    break;
                case ')':
                    if (!stack.isEmpty() && stack.peek() == '(') {
                        stack.pop();
                    }
                    break;

                case '}':
                    if (!stack.isEmpty() && stack.peek() == '{') {
                        stack.pop();
                    }
                    break;
                case ']':
                    if (!stack.isEmpty() && stack.peek() == '[') {
                        stack.pop();
                    }
                    break;
                default:
                    throw new UnsupportedOperationException("暂不支持这种括号");
            }
        }
        return stack.isEmpty();
    }
    
}

4 队列

一种操作受限的线性表数据结构,特点是先进先出,后进后出

最基本的两个操作:入队enqueue()出队dequeue()

4.1 队列的实现

队列也可以通过数组和链表实现,用数组实现的队列叫作顺序队列,用链表实现的队列叫作链式队列

4.1.1 顺序队列的实现

代码如下所示:

public class ArrayQueue {

    // 队列底层通过数组实现
    private String[] items;

    // 队列的大小,默认为0
    private int n = 0;

    // 队头下标
    private int head = 0;

    // 队列尾结点的位置
    private int tail = 0;

    // 初始化队列大小
    public ArrayQueue(int capacity) {
        items = new String[capacity];
        n = capacity;
    }

    /**
     * 入队操作
     * @param item 元素
     * @return true:操作成功 false:操作失败
     */
    public boolean enqueue(String item) {
        // 队列满了,不能继续入队了
        if (n == tail) {
            return false;
        }
        items[tail] = item;
        ++tail;
        return true;
    }

    // 出队
    public String dequeue() {
        // 如果head == tail 表示队列为空
        if (head == tail) return null;
        String ret = items[head];
        ++head;
        return ret;
    }
}

实现顺序队列需要两个指针,一个是head指针,指向队头;一个是tail指针,指向队尾

如下图所示,a,b,c,d依次入队后头尾指针的位置:
在这里插入图片描述

当调用2次出队后,头尾指针变化如下图所示:
在这里插入图片描述

可以很清楚看到,数组空间的利用率其实并不高,当head指针移动到数组最右边,即使数组有空闲的空间,也无法继续做入队操作了。

可以通过数据搬移的方式提高内存利用率,当在元素入队空间不够时,可以尝试触发一次数据的搬移操作。
在这里插入图片描述

入队操作代码修改后如下所示:

    /**
     * 入队操作
     * @param item
     * @return
     */
    public boolean enqueueV2(String item) {
        // tail == n表示队列末尾没有空间了
        if (tail == n) {
            // tail ==n && head==0,表示整个队列都占满了
            if (head == 0) {
                return false;
            }
            // 数据搬移
            for (int i = head; i < tail; ++i) {
                items[i-head] = items[i];
            }
            // 搬移完之后重新更新head和tail
            tail -= head;
            head = 0;
        }

        items[tail] = item;
        ++tail;
        return true;
    }

当队列的tail指针移动到数组的最右边后,如果有新的数据入队,我们可以将head到tail之间的数据,整体搬移到数组中0到tail-head的位置。出队操作的时间复杂度是O(1),入队的时间复杂度采用均还分析法仍是O(1)

4.1.2 链式队列的实现

基于链表的实现,我们同样需要两个指针:head指针和tail指针。分别指向链表的第一个结点最后一个结点。如下图所示:
在这里插入图片描述

代码实现如下:

public class LinkedListQueue {

    // 队列的队首
    private Node head = null;

    // 队列的队尾
    private Node tail = null;


    // 入队操作
    public void enqueue(String value) {
        if (null == tail) {
            Node newNode = new Node(value, null);
            head = newNode;
            tail = newNode;
        } else {
            tail.next = new Node(value, null);
            tail = tail.next;
        }
    }

    // 出队
    public String dequeue() {
        if (null == head) {
            return null;
        }
        String value = head.data;
        head = head.next;
        if (head == null) {
            tail = null;
        }
        return value;
    }

    // 从队头打印到队尾
    public void printAll() {
        Node p = head;
        while (p != null) {
            System.out.print(p.data + " ");
            p = p.next;
        }
        System.out.println();
    }



    // 结点的定义
    private static class Node {
        // 结点存储的数据
        private String data;
        // 结点的后继next指针
        private Node next;

        public Node(String data, Node next) {
            this.data = data;
            this.next = next;
        }

        public String getData() {
            return data;
        }
    }
}
4.1.3 循环队列

上面在说顺序队列时,当tail==n,也就是队列满时,会有数据搬移,这样入队的性能很受影响,我们考虑下循环队列看能否解决这个问题?

循环队列如下图所示:
在这里插入图片描述

在a,b依次入队之后,循环队列中的元素就变成了下面的样子:
在这里插入图片描述

这样成功避免了数据搬移操作。

队空和队满条件确定

队空判断条件还是head==tail

队满条件:
在这里插入图片描述

当tail=3,head=4,n=8,是一种队满的情况,得出规律:(3+1)%8=4,很容易得出队满的条件:(tail+1)%n=head

当队列满的时候,tail指向的位置是没有存储数据的,所以,循环队列会浪费一个数组的存储空间

代码如下所示:

public class CircularQueue {

    // 队列底层容器
    private String[] items;

    // 队列容量
    private int n = 0;

    // 队头下标
    private int head = 0;

    // 队尾下标
    private int tail = 0;

    // 申请一个大小为capacity的数组
    public CircularQueue(int capacity) {
        items = new String[capacity];
        n = capacity;
    }

    // 入队操作
    public boolean enqueue(String item) {
        // 队列满了
        if ((tail + 1) % n == head) {
            return false;
        }
        items[tail] = item;
        tail = (tail + 1) % n;
        return true;
    }

    // 出队操作
    public String dequeue() {
        // 队列为空
        if (head == tail) {
            return null;
        }
        String ret = items[head];
        head = (head + 1) % n;
        return ret;
    }
}
4.1.4 阻塞队列和并发队列

阻塞队列:在队列基础上增加了阻塞操作。

  1. 队列为空时,从队头取数据会被阻塞,直到队列中有数据才返回;
  2. 队列已经满了,那么入队操作会被阻塞,直到队列中有空闲位置再入队,然后返回。
单消费者

在这里插入图片描述

阻塞队列实现的“生产者 - 消费者模型”,可以有效地协调生产和消费的速度。当“生产者”生产数据的速度过快,“消费者”来不及消费时,存储数据的队列很快就会满了。这个时候,生产者就阻塞等待,直到“消费者”消费了数据,“生产者”才会被唤醒继续“生产”。

多消费者

可以配置多消费者来提高数据处理能力,但因为是多线程操作队列,这时会存在线程安全问题
在这里插入图片描述

线程安全的队列被称为并发队列。如何实现呢?

  1. 直接在入队和出队操作上加锁

    锁粒度大并发度会比较低,同一时刻仅允许一个存或者取操作。

  2. 通过基于数组的循环队列+CAS

4.2 线程池中的队列思想

实际上,对于大部分资源有限的场景,当没有空闲资源时,基本上都可以通过“队列”这种数据结构来实现请求排队。

线程池没有空闲线程时,新的任务请求线程资源时,线程池该如何处理?

  1. 非阻塞的处理方式,直接拒绝任务请求;
  2. 阻塞的处理方式,将请求排队,等到有空闲线程时,取出排队的请求继续处理。公平考虑,可以使用队列来存储排队的请求。
    • 基于链表的实现,相当于支持无限排队的无界队列(unbounded queue),可能导致请求过多,处理的响应时间增长
    • 基于数组的实现,有界队列(bounded queue),基于数组实现的有界队列(bounded queue)。
  • 28
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
利用C++实现以下经典数据结构算法线性(顺序链表、静态链表、三元组)、(双、共享)、队列(任务调度、循环队列、双向队列、链队列)、数组(特殊矩阵、稀疏矩阵压缩)、串(朴素模式匹配、KMP算法、KMP优化算法)、树(先序遍历、线索二叉树、哈夫曼树等。 利用C++实现以下经典数据结构算法线性(顺序链表、静态链表、三元组)、(双、共享)、队列(任务调度、循环队列、双向队列、链队列)、数组(特殊矩阵、稀疏矩阵压缩)、串(朴素模式匹配、KMP算法、KMP优化算法)、树(先序遍历、线索二叉树、哈夫曼树等。 利用C++实现以下经典数据结构算法线性(顺序链表、静态链表、三元组)、(双、共享)、队列(任务调度、循环队列、双向队列、链队列)、数组(特殊矩阵、稀疏矩阵压缩)、串(朴素模式匹配、KMP算法、KMP优化算法)、树(先序遍历、线索二叉树、哈夫曼树等。 利用C++实现以下经典数据结构算法线性(顺序链表、静态链表、三元组)、(双、共享)、队列(任务调度、循环队列、双向队列、链队列)、数组(特殊矩阵、稀疏矩阵压缩)、串(朴素模式匹配、KMP算法、KMP优化算法)、树(先序遍历、线索二叉树、哈夫曼树等。 利用C++实现以下经典数据结构算法线性(顺序链表、静态链表、三元组)、(双、共享)、队列(任务调度、循环队列、双向队列、链队列)、数组(特殊矩阵、稀疏矩阵压缩)、串(朴素模式匹配、KMP算法、KMP优化算法)、树(先序遍历、线索二叉树、哈夫曼树等。 利用C++实现以下经典数据结构算法线性(顺序链表、静态链表、三元组)、(双、共享)、队列(任务调度、循环队列、双向队列、链队列)、数组(特殊矩阵、稀疏矩阵压缩)、串(朴素模式匹配、KMP算法、KMP优化算法)、树(先序遍历、线索二叉树、哈夫曼树等。 利用C++实现以下经典数据结构算法线性(顺序链表、静态链表、三元组)、(双、共享)、队列(任务调度、循环队列、双向队列、链队列)、数组(特殊矩阵、稀疏矩阵压缩)、串(朴素模式匹配、KMP算法、KMP优化算法)、树(先序遍历、线索二叉树、哈夫曼树等。 利用C++实现以下经典数据结构算法线性(顺序链表、静态链表、三元组)、(双、共享)、队列(任务调度、循环队列、双向队列、链队列)、数组(特殊矩阵、稀疏矩阵压缩)、串(朴素模式匹配、KMP算法、KMP优化算法)、树(先序遍历、线索二叉树、哈夫曼树等。 利用C++实现以下经典数据结构算法线性(顺序链表、静态链表、三元组)、(双、共享)、队列(任务调度、循环队列、双向队列、链队列)、数组(特殊矩阵、稀疏矩阵压缩)、串(朴素模式匹配、KMP算法、KMP优化算法)、树(先序遍历、线索二叉树、哈夫曼树等。 利用C++实现以下经典数据结构算法线性(顺序链表、静态链表、三元组)、(双、共享)、队列(任务调度、循环队列、双向队列、链队列)、数组(特殊矩阵、稀疏矩阵压缩)、串(朴素模式匹配、KMP算法、KMP优化算法)、树(先序遍历、线索二叉树、哈夫曼树等。 利用C++实现以下经典数据结构算法线性(顺序链表、静态链表、三元组)、(双、共享)、队列(任务调度、循环队列、双向队列、链队列)、数组(特殊矩阵、稀疏矩阵压缩)、串(朴素模式匹配、KMP算法、KMP优化算法)、树(先序遍历、线索二叉树、哈夫曼树等。 利用C++实现以下经典数据结构算法线性(顺序链表、静态链表、三元组)、(双、共享)、队列(任务调度、循环队列、双向队列、链队列)、数组(特殊矩阵、稀疏矩阵压缩)、串(朴素模式匹配、KMP算法、KMP优化算法)、树(先序遍历、线索二叉树、哈夫曼树等。 利用C++实现以下经典数据结构算法线性(顺序链表、静态链表、三元组)、(双、共享)、队列(任务调度、循环队列、双向队列、链队列)、数组(特殊矩阵、稀疏矩阵压缩)、串(朴素模式匹配、KMP算法、KMP优化算法)、树(先序遍历、线索二叉树、哈夫曼树等。 利用C++实现以下经典数据结构算法线性(顺序链表、静态链表、三元组)、(双、共享)、队列(任务调度、循环队列、双向队列、链队列)、数组(特殊矩阵、稀疏矩阵压缩)、串(朴素模式匹配、KMP算法、KMP优化算法)、树(先序遍历、线索二叉树、哈夫曼树等。 利用C++实现以下经典数据结构算法线性(顺序链表、静态链表、三元组)、(双、共享)、队列(任务调度、循环队列、双向队列、链队列)、数组(特殊矩阵、稀疏矩阵压缩)、串(朴素模式匹配、KMP算法、KMP优化算法)、树(先序遍历、线索二叉树、哈夫曼树等。
数据结构算法 排序算法 内排序 八大基础排序 选择排序 简单选择排序 思想 每次选择最大的数插入到末尾中 做法 外层for循环控制次数 内层for循环找出最大的值的角标 找出最大角标后,进行交换 优化思路 同时获取最大值和最小值,然后分别插入数组的首部和尾部 堆排序 思想 使用大顶堆的思想来排序,每次建堆后交换 做法 总体:建堆-替换 建堆 只要左子树或右子树大于当前根节点,则替换 替换后会导致下面的子树发生了变化,因此同样需要进行比较,直至各个节点实现父>子这么一个条件(递归) 交换排序 冒泡排序 思想 每次俩俩交换,将最大值交换到末尾 做法 外层for控制循环次数 内层for控制比较次数 每次循环之后,比较次数都会减少一次 优化思路 如果一趟排序后没有发生位置变化,那么此时就是有序了 快速排序 思想 每次将比支点小的放在支点左边,比支点大的放在支点右边 做法 外循环while只要i和j不碰撞查找 内层两个while循环分别查找出比支点小的和比支点大的角标 如果i<=j满足条件,就交换 一趟排序后,支点的左边都比支点小,支点右边都比支点大 只要满足L<j,i0的条件查找出要插入的何时位置 退出内层while循环后就找到了合适的位置插入 优化思路 二分查找插入,找合适位置的时候使用二分查找算法 希尔排序 思想 用增量来将数组进行分隔,直到增量为1。底层干的还是插入排序干的活 做法 最外层for外循环控制增量的数量,每次/2 第二层for循环控制每次增量那组开始进行插入排序,直至完毕 第三层while循环找到要插入到哪个位置 归并排序 思想 将两个已排好序的数组合并成一个有序的数组 做法 递归拆分出两个有序的数组,从mid的位置开始拆分,递归出口:只有一个值的时候就不用拆分了 合并两个有序的数据 分别往两个数组填充已有序的数据 比较两个数组的值谁小,谁小就放到我们的数组中 如果比较完之后还有剩余的数据,那么用while直接添加到我们的总数组中 优化思路 当递归到规模足够小时,利用插入排序 归并前判断一下是否还有必要归并 只在排序前开辟一次空间 基数(桶)排序 思想 分配,回收(分配到不同的位置上,然后回收)..不断分配..回收来进行排序,直到有序 做法 分配一个[array.length][10列]的二维数组来装我们的元素 最外层for循环控制要分配和回收的次数(根据最大值) 将元素的个、十、百位依次放到桶子上(第一次就是放个位,第二次放十位) 依据每列回收桶子,两个for循环 外排序 查找算法 二分查找 分块查找 哈希查找 贪心算法 求最小生成树的Prim算法和Kruskal算法 爬山问题 回溯算法 n皇后问题 动态规划Dynamic Planning 应用 求最长公共子序列LCS 矩阵连乘问题 爬楼梯问题 找零问题 0-1背包问题 分治算法Divide and Conquer 应用:归并排序 其它 Rabin fingerprints 文件指纹算法 BitMap 位图算法 BloomFilter 布隆过
算法数据结构涵盖了以下主要内容: 数据结构(Data Structures): 逻辑结构:描述数据元素之间的逻辑关系,如线性结构(如数组链表)、树形结构(如二叉树、堆、B树)、图结构(有向图、无向图等)以及集合和队列等抽象数据类型。 存储结构(物理结构):描述数据在计算机中如何具体存储。例如,数组的连续存储,链表的动态分配节点,树和图的邻接矩阵或邻接示等。 基本操作:针对每种数据结构,定义了一系列基本的操作,包括但不限于插入、删除、查找、更新、遍历等,并分析这些操作的时间复杂度和空间复杂度。 算法算法设计:研究如何将解决问题的步骤形式化为一系列指令,使得计算机可以执行以求解问题。 算法特性:包括输入、输出、有穷性、确定性和可行性。即一个有效的算法必须能在有限步骤内结束,并且对于给定的输入产生唯一的确定输出。 算法分类:排序算法(如冒泡排序、快速排序、归并排序),查找算法(如顺序查找、二分查找、哈希查找),图论算法(如Dijkstra最短路径算法、Floyd-Warshall算法、Prim最小生成树算法),动态规划,贪心算法,回溯法,分支限界法等。 算法分析:通过数学方法分析算法的时间复杂度(运行时间随数据规模增长的速度)和空间复杂度(所需内存大小)来评估其效率。 学习算法数据结构不仅有助于理解程序的内部工作原理,更能帮助开发人员编写出高效、稳定和易于维护的软件系统。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值