数据结构与算法笔记1

本笔记记录王争专栏数据结构与算法之美的学习记录,以便自己复习回顾,代码部分均已经过验证,可直接使用

文章目录

day01 入门

1. 概念

为什么大部分书把这两个概念放在一起讲呢?

数据结构与算法是相辅相成的。数据结构是为算法服务的,算法要作用在特定数据结构之上。

比如,因为数组具有随机访问的特点,常用的二分查找算法要用数组来存储数据,但如果我们选择链表这种数据结构,二分查找算法就无法工作,因为链表不支持随机访问。

数据结构是静态的,是组织数据的一种方式,如果不在它的基础上操作、构建算法,孤立存在的数据结构就是没用的。

不太恰当的比方:列车需要铁轨,汽车需要公路

2. 学习的重点

复杂度分析

20个最常用的知识点

10个数据结构:

数组、链表、栈、队列、散列表、二叉树、堆、跳表、图、Trie树

10个算法

递归、排序、二分查找、搜索、哈希算法、贪心算法、分治算法、回溯算法、动态规划、字符串匹配算法

3. 如何学习

关注内容:它的来历、自身的特点、适合解决的问题、实际的应用场景。

怎么学?

  1. 边学边练,每周花1-2小时集中攻关三节课设计的数据结构和算法,用java写出来
  2. 主动提问,多思考,多互动,通过多种途径解决自己想到的问题
  3. 自我激励,每次学习完做一篇学习笔记
  4. 掌握基础,不要贪心

day02 复杂度分析:如何分析、统计算法的执行效率和资源消耗?

1. 为什么需要复杂度分析?

事后统计法

代码跑一遍,通过统计、监控,得到算法执行的时间和占用的内存大小

局限性

  1. 测试结果非常依赖测试环境
  2. 测试结果受数据规模的影响很大

需求

需要一个不用具体的测试数据来测试,就可以粗略估计算法的执行效率的方法

2. 大O时间复杂度

T(n) = O(f(n))

T(n)代表代码执行的时间,n代表数据规模的大小;f(n)代表每行代码执行的次数总和

大O时间复杂度:不具体表示代码真正的执行时间,而是表示代码执行时间随着数据规模增长的变化趋势,也叫渐进时间复杂度(asymptotic time complexity),简称时间复杂度。

时间复杂度分析

三种方法

  1. 只关注循环执行次数最多的一段代码
  2. 加法法则: 总复杂度等于量级最大的那段代码的复杂度
  3. 乘法法则: 嵌套代码的复杂度等于嵌套内外代码复杂度的乘积

复杂度量级

常量阶 O(1)
对数阶 O(logn)
线性阶 o(n)
线性对数阶 o(nlogn)
平方阶、立方阶、n次方阶 o(n²) o(nⁿ)
指数阶(非多项式量级) o(2ⁿ)
阶乘阶(非多项式量级) o(n!)

非多项式量级的算法问题叫NP(Non-Deterministic Polynomial,非确定多项式)问题

数据规模n越大,非多项式量级算法的执行时间会急剧增加,求解问题的执行时间无限增长。非常低效,不关注。

对数阶

伪代码:

i = 1;
while (i<=n) {
    i = i * 2;
}

第三行执行次数最多,变量i的值从1开始取,每循环一次乘以2,最终2^x = n; x=log₂n,复杂度为O(log₂n),只要log为底,都可以视为O(logn)

O(m+n) 、O(m*n)

伪代码

int cal(int m, int n) {
	int sum_1 = 0;
	int i = 1;
	for (; i < m; ++i) {
		sum_1 = sum_1 + i;
	} 
	int sum_2 = 0;
	int j = 1;
	for (; j < n; ++j) {
		sum_2 = sum_2 + j;
	} 
	return sum_1 + sum_2;
}

复杂度为O(m+n)

3. 空间复杂度

全称 渐进空间复杂度(asymptotic space complexity),表示算法的存储空间与数据规模之间的增长关系

伪代码

void print(int n) {
    int i = 0;
    int[] a = new int[n];
    for (i;i<n;++i){
        a[i]=i*i;
    }
    
    for(i = n-1;i>=0;--i){
        print out a[i]
    }
}

跟时间复杂度分析一样,第二行申请了一个空间存储变量i,常量阶,可以忽略;第三行申请了大小为n的int类型数组,此外,剩余代码没有占用更多的空间,空间复杂度为O(n)

4. 时间复杂度分类

最好情况时间复杂度

best case time complexity,在最理想的情况下,执行这段代码的时间复杂度。

int find(int[] array,int n,int x){
    int i=0;
    int pos = -1;
    for(;i<n;++i) {
        if (array[i]==x) {
            pos = i;
            break;
        }
    }
    return pos;
}

要查找的变量x可能出现在数组的任意位置,如果数组中第一个元素整好是x,时间复杂度为O(1),如果数组不存在该变量,需要把整个数组遍历一遍,时间复杂度为O(n),不同情况,时间复杂度不一样。

最坏情况时间复杂度

在最糟糕的情况下,执行这段代码的时间复杂度。

上段代码,要查找变量x在数组中的位置,有n+1种情况,在数组的0~n-1位置中和不在数组中。每种情况下,查找需要遍历的元素个数累加,除以n+1,得到需要遍历的元素个数的平均值
在这里插入图片描述

公式简化,平均时间复杂度为O(n)

然而,这n+1种情况,出现的概率并不一样,要查找的x,假设出现在数组中和不在数组中的概率都为1/2,另外,要查找的数据出现在0n-1这n个位置的概率也是一样的,所以,根据概率乘法法则,出现在0n-1中任意位置的概率为1/2n

在这里插入图片描述

这个值是概率论中的加权平均值,也叫期望值。所以平均时间复杂度的全称应叫加权平均时间复杂度或期望时间复杂度。仍为O(n)

大部分情况,只需要使用一个复杂度就可以满足需求,只有在同一块代码在不同情况下,时间复杂度有量级的差距,才会使用三种复杂度表示法区分。

均摊时间复杂度

均摊时间复杂度应用场景比平均复杂度更特殊,更有限

// array表示一个长度为n的数组
// 代码中的array.length等于n
int[] array = new int[n];
int count = 0;
void insert(int val) {
  if (count == array.length) {
    int sum = 0;
    for (int i = 0; i < array.length; ++i){
      sum = sum+array[i];
    }
    array[0] = sum;
    count = 1;
  }
  array[count] = val;
  ++count;
}

这段代码实现了一个往数组中插入数据的功能。当数组满了之后,用for循环遍历数组求和,清空数组,将求和之后的sum值放到数组的第一个位置,再把新的数据插入。如果数组一开始就有空闲空间,直接将数据插入数组。

求平均时间复杂度。假设数组长度n,根据插入位置,分为n种情况,每种情况复杂度为O(1);此外,额外情况为数组没有空闲空间时插入一个数据,复杂度为O(n),这n+1种情况发生概率都是1/(n+1),平均时间复杂度为

在这里插入图片描述
其实并不需要这么复杂,对比这个insert()和前面的find()的例子,find()在极端情况下,复杂度才是O(1),但是insert()在大部分情况下都是O(1),此外,insert()的O(1)时间复杂度的插入和O(n)时间复杂度的插入,出现的频率非常有规律,也有前后时序关系,一个O(n)插入后,紧跟着n-1个O(1)插入,循环往复。

针对这种情况,引入更简单的分析方法:摊还分析法,通过摊还分析得到的时间复杂度叫均摊时间复杂度

如何使用?每一次O(n)的插入,都会跟着n-1次O(1)的插入,把耗时多的那次操作均摊到n-1次耗时少的操作,这一组连续操作的均摊时间复杂度就是O(1)。均摊时间复杂度就是一种特殊的平均时间复杂度

day03. 数组

1. 概念

数组

Array,是一种线性表数据结构。它用一组连续的内存空间,来存储一组具有相同类型的数据。

线性表(Linear List)

数据排成像一条线一样的结构,每条线性表最多只有前和后两个方向。

2. 数组特点

  • 随机访问 连续的内存空间和相同类型的数据,计算机通过地址来访问内存中的数据,随机访问数据的某个元素,会首先通过寻址公式,计算该元素存储的内存地址。数组支持随机访问,根据下标随机访问的时间复杂度为O(1)。 即便是排好序的数组,你用二分查找,时间复杂度也是O(logn)。
  • “插入”和“删除”低效。

3. 为什么导致低效?如何改进?

1. 插入操作

数组长度为n,将数据插入到第k个位置,需要将k~n的元素都顺序的往后挪一位。最坏时间复杂度为O(n),平均时间复杂度为(1+2+…+n)/n=O(n)

改进:如果数组的数据没什么规律,只是被当做存储数据的集合,为避免大规模的数据搬移,可以直接将第k位的数据搬移到数组元素的最后,把新元素直接放到第k个位置,时间复杂度降为O(1)

2. 删除操作

类似插入,如果要删除第k个位置的数据,为了内存的连续性,也要搬移数据。

改进办法:

在某些特殊场景下,不一定要追求数组中数据的连续性,先记录已经删除的数据,每次的删除操作并不是真正地搬移数据,只是记录数据已经被删除。多次删除集中在一起执行,大大减少了删除操作导致的数据搬移。

4. 思考题:你理解的标记清除垃圾回收算法。

java的JVM标记清除垃圾回收算法的核心思想就是这种,当数组没有更多空间存储数据时,再真正的删除操作,减少删除操作导致的数据搬移。

具体:大多数主流虚拟机采用可达性分析算法来判断对象是否存活,在标记阶段,会遍历所有 GC ROOTS,将所有 GC ROOTS 可达的对象标记为存活。只有当标记工作完成后,清理工作才会开始。

5. 容器能否完全替代数组?

java的ArrayList,容器类,最大的优势就是把很多数组操作的细节封装起来,此外,就是支持动态扩容,空间不够时,自动扩容为1.5倍大小。扩容耗时,如果事先确定存储的数据大小,最好在创建ArrayList时就事先指定数据大小。如从数据库中取出10000条数据。

ArrayList<User> users = new ArrayList(10000);
for(int i=0;i<10000;i++) {
  users.add(xxx);
}

什么时候用数组更合适呢?

  • java ArrayList无法存储基本类型,如int,long,需要封装为Integer,Long类,自动装箱、拆箱消耗一定的性能,如果特别关注性能,或者希望用基本类型,选用数组。
  • 如果 数组大小事先已知,并且对数据的操作非常简单,也可以直接用数组
  • 做一些非常底层的开发,如开发网络框架,性能的优化需要做到极致。

6. 为什么大多数编程语言,数组从0开始编号?而不是1?

从数组存储的内存模型来看,“下标”最确切的定义应该是“偏移”(offset),a[0]就是偏移为0的位置,也就是首地址,a[k]就表示偏移k个type_size的位置。计算a[k]的内存地址公式

a[k]_address = base_address+k*type_size 

如果从1开始,计算公式为

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

每次随机访问数组元素都多了一次减法运算,多了一次减法指令。

7. 元素存取特点

  • 查找元素快:通过索引,可以快速访问指定位置的元素
  • 增删元素慢
  • 指定索引位置增加元素:需要创建一个新数组,将指定新元素存储在指定索引位置,再把原数组元素根据索引,复制到新数组对应索引的位置。
  • **指定索引位置删除元素:**需要创建一个新数组,把原数组元素根据索引,复制到新数组对应索引的位置,原数组中指定索引位置元素不复制到新数组中。

8. 相关代码

  • 基本数组
public class GenericArray<T> {
    private  T[] data;
    private int size;

    // 根据传入容量,构造Array
    public GenericArray(int capacity){
        data = (T[]) new Object[capacity];
        size = 0;
    }

    // 无参构造方法,默认数组容量10
    public GenericArray(){
        this(10);
    }

    // 获取数组容量
    public int getCapacity(){
        return data.length;
    }

    // 获取当前元素个数
    public int count(){
        return size;
    }

    // 判断数组是否为空
    public boolean isEmpty(){
        return size==0;
    }

    // 修改index位置的元素
    public void set(int index,T e){
        checkIndex(index);
        data[index] = e;
    }

    // 获取对应index位置的元素
    public T get(int index){
        checkIndex(index);
        return data[index];
    }

    // 查看数组是否包含元素e
    public boolean contains(T e){
        for (int i = 0; i < size; i++) {
            if(data[i].equals(e)){
                return  true;
            }
        }
        return false;
    }

    // 获取对应元素的下标,未找到,返回-1
    public int find(T e){
        for (int i = 0; i < size; i++) {
            if(data[i].equals(e)){
                return i;
            }
        }
        return -1;
    }

    // 在index位置,插入元素e,时间复杂度O(m+n)
    public void add(int index,T e){
        checkIndex(index);
        // 如果当前元素个数等于数组容量,则将数组扩容至原来的2倍
        if(size == data.length){
            resize(2 * data.length);
        }
        for(int i=size -1; i>=index;i--){
            data[i+1] = data[i];
        }
        data[index] = e;
        size++;
    }

    // 向数组头插入元素
    public void addFirst(T e){
        add(0,e);
    }

    // 向数组尾插元素
    public void addLast(T e){
        add(size,e);
    }

    // 删除index位置的元素,并返回
    public T remove(int index){
        checkIndexForRemove(index);

        T ret = data[index];
        for(int i=index+1;i<size;i++){
            data[i-1]=data[i];
        }
        size--;
        data[size]=null;

        // 缩容
        if(size==data.length/4 && data.length/2!=0){
            resize(data.length/2);
        }

        return ret;
    }

    // 删除第一个元素
    public T removeFirst(){
        return remove(0);
    }

    // 删除末尾元素
    public T removeLast(){
        return remove(size-1);
    }

    // 从数组中删除指定元素
    public void removeElement(T e){
        int index=find(e);
        if(index !=-1){
            remove(index);
        }
    }

    @Override
    public String toString() {
        StringBuilder builder = new StringBuilder();
        builder.append(String.format("Array size=%d, capacity=%d \n",size,data.length));
        builder.append('[');
        for (int i = 0; i < size; i++) {
            builder.append(data[i]);
            if(i!=size -1){
                builder.append(", ");
            }
        }
        builder.append(']');
        return builder.toString();
    }

    // 扩容方法,时间复杂度O(n)
    private void resize(int capacity){
        T[] newData = (T[]) new Object[capacity];

        for (int i = 0; i < size; i++) {
            newData[i] = data[i];
        }
        data = newData;
    }

    private void checkIndex(int index){
        if(index <0 || index >size){
            throw new IllegalArgumentException("remove failed! Require index>=0 and index<=size.");
        }
    }

    private void checkIndexForRemove(int index){
        if(index <0 || index >=size){
            throw new IllegalArgumentException("remove failed! Require index >=0 and index<size.");
        }
    }
}

实践

public class Array {
    // 定义整型数据data保存数据
    public int data[];
    // 定义数组长度
    private int n;
    // 定义实际个数
    private int count;

    // 构造方法,定义数组大小
    public Array(int capacity){
        this.data = new int[capacity];
        this.n = capacity;
        this.count = 0;//一开始一个数都没有存
    }

    // 根据索引,找到数组汇总的元素并返回
    public int find(int index){
        if(index<0 || index>=count )
            return -1;
        return  data[index];
    }

    // 插入元素,头插和尾插
    public boolean insert(int index,int value){
        // 数组空间已满
        if(count ==n){
            System.out.println("没有可插入的位置");
            return false;
        }
        // 如果count没满,既可以插入数据到数组
        // 1.位置不合法
        if(index<0 || index >count){
            System.out.println("位置不合法");
            return false;
        }
        // 2. 位置合法
        for (int i=count;i>index;--i){
            data[i] = data[i-1];
        }
        data[index]=value;
        ++count;
        return true;
    }

    // 根据索引,删除数组中元素
    public boolean delete(int index){
        if(index<0 || index >=count)
            return false;
        // 从删除位置开始,将后面的元素向前移动一位
        for(int i=index +1;i<count; ++i){
            data[i-1] = data[i];
        }
        --count;
        return true;
    }

    public void printAll(){
        for (int i = 0; i < count; i++) {
            System.out.println(data[i]+" ");
        }
        System.out.println();
    }

    public static void main(String[] args) {
        Array array = new Array(5);
        array.printAll();
        array.insert(0,3);
        array.insert(0,4);
        array.insert(1,5);
        array.insert(3,9);
        array.insert(3,10);
        array.printAll();//4 5 3 10 9
    }
}

day04. 链表

1. 概念

链表:linked list,由一系列结点node(链表中每一个元素称为结点)组成,结点可以在运行时动态生成。每个结点包括两个部分:一个是存储数据元素的数据域,另一个是存储下一个结点地址的指针域。我们常说的链表结构有单向链表与双向链表,那么这里给大家介绍的是单向链表

有两个结点是比较特殊的,它们分别是第一个结点和最后一个结点。我们习惯性地把第一个结点叫作头结点,把最后一个结点叫作尾结点。其中,头结点用来记录链表的基地址。有了它,我们就可以遍历得到整条链表。而尾结点特殊的地方是:指针不是指向下一个结点,而是指向一个空地址NULL,表示这是链表上最后一个结点。

在这里插入图片描述

弊端:链表中的数据并非连续存储的,所以无法像数组那样,根据首地址和下标,通过寻址公式就能直接计算出对应的内存地址,而是需要根据指针一个结点一个结点地依次遍历,直到找到相应的结点。链表随机访问的性能没有数组好,**需要O(n)**的时间复杂度。

采用该结构的集合,对元素的存取的特点:

  • 多个节点之间,通过地址进行连接,后继指针next记录下个节点的位置
  • 头结点
  • 查找元素慢,需要通过连接的节点,依次向后查找指定的元素
  • 增删元素快 增加元素:修改连接下一个元素的地址 ; 删除元素:修改连接下个元素的地址

2. 双向链表和循环链表

双向链表

支持两个方向,每个结点不止有一个后继指针next指向后面的结点,还有一个前驱指针prev指向前面的结点。

双向链表需要额外的两个空间来存储后继结点和前驱结点的地址。所以,如果存储同样多的数据,双向链表要比单链表占用更多的内存空间。虽然两个指针比较浪费存储空间,但可以支持双向遍历,这样也带来了双向链表操作的灵活性

循环链表

循环链表是一种特殊的单链表。跟单链表唯一的区别就在尾节点。单链表的尾节点指向空地址,而循环链表的尾节点指向链表的头结点。像“环”一样首尾相连。

循环链表的优点是从链尾到链头比较方便,当要处理的数据具有环形结构特点,就适合采用单链表,如约瑟夫问题。

3. 链表和数组的对比

底层的存储结构:

  • 数组需要一块连续的内存空间来存储
  • 链表不需要连续的内存空间,通过“指针”将一组零散的内存块串联使用
  1. 数组简单易用,在实现上使用的是连续的内存空间,可以借助CPU的缓存机制,预读数组中的数据,所以访问效率更高。而链表在内存中并不是连续存储,所以对CPU缓存不友好,没办法有效预读。
  2. 数组的缺点是大小固定,一经声明就要占用整块连续内存空间。如果声明的数组过大,系统可能没有足够的连续内存空间分配给它,导致“内存不足(out of memory)”。如果声明的数组过小,则可能出现不够用的情况。这时只能再申请一个更大的内存空间,把原数组拷贝进去,非常费时。链表本身没有大小的限制,天然地支持动态扩容,我觉得这也是它与数组最大的区别。
  3. 链表的每个节点都需要消耗额外的存储空间去存储一份指向下一个节点的指针,内存消耗会翻倍,而且,对链表进行频繁的插入、删除操作,会导致频繁的内存申请和释放,容易造成内存碎片,java的话会导致频繁的GC垃圾回收。

4. LRU缓存淘汰算法,如何实现?

维护一个有序单链表,越靠近链表尾部的节点是越早访问的,新的数据被访问,从链表头开始顺序遍历链表。

  1. 如果此数据之前被缓存在链表中了,我们遍历得到这个数据对应的结点,并将其从原来的位置删除,然后再插入到链表的头部。
  2. 如果此数据没有在缓存链表中,又可以分为两种情况:
    • 如果此时缓存未满,则将此结点直接插入到链表的头部;
    • 如果此时缓存已满,则链表尾结点删除,将新的数据结点插入链表的头部。

这样我们就用链表实现了一个LRU缓存

缓存是一种提高数据读取性能的技术,在硬件设计、软件开发中都有非常广泛的应用,如CPU缓存、数据库缓存、浏览器缓存等。

缓存的大小有限,缓存用满后如何淘汰?常见策略:先进先出FIFO、最少使用策略LFU和最近最少使用策略LRU 书房的书

5. 双向链表相比单向链表的优势

实际软件开发中删除操作情况

  • 删除节点中“值等于某个给定值”的节点;
  • 删除给定指针指向的节点

对于第一种,都要从头节点遍历,再通过指针操作删除。时间复杂度为O(n)

对于第二种,删除节点前需要知道前驱节点,单链表不支持直接获得前驱节点,从头遍历;双向链表已经保存前驱节点的指针,不需要遍历,时间复杂度O(1)

插入操作

也是。

按值查询

此外,对于有序链表,双向链表的按值查询的效率也比单链表高,平均只需要查一半。比如LinkedHashMap的实现。

更加抽象的设计思想:空间换时间的设计思想。如果我们追求代码的执行速度,可以选择空间复杂度相对较高,而时间复杂度相对较低的算法或数据结构。消耗更多内存进行优化。

6. 几个写链表代码技巧

技巧一:理解指针或引用的含义

c语言的指针,也就是java的引用

对于指针的理解:

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

p->next=q p结点中的next指针存储了q结点的内存地址。

p->next=p->next->next p结点的next指针存储了p结点的下下一个结点的内存地址。

技巧二:警惕指针丢失和内存泄漏

我们希望在结点a和相邻的结点b之间插入结点x,假设当前指针p指向结点a。如果我们将代码实现变成下面这个样子,就会发生指针丢失和内存泄露。

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

p->next指针在完成第一步操作之后,已经不再指向结点b了,而是指向结点x。第2行代码相当于将x赋值给x->next,自己指向自己。因此,整个链表也就断成了两半,从结点b往后的所有结点都无法访问到了。

对于有些语言来说,比如C语言,内存管理是由程序员负责的,如果没有手动释放结点对应的内存空间,就会产生内存泄露。所以,我们插入结点时,一定要注意操作的顺序。要先将结点x的next指针指向结点b,再把结点a的next指针指向结点x,这样才不会丢失指针,导致内存泄漏。

同理,删除链表结点时,也一定要记得手动释放内存空间,否则,也会出现内存泄漏的问题。当然,对于像Java这种虚拟机自动管理内存的编程语言来说,就不需要考虑这么多了。

技巧三:利用哨兵简化实现难度

如果我们在结点p后面插入一个新的结点,只需要下面两行代码就可以搞定。

new_node->next = p->next;
p->next = new_node;

但是,当我们要向一个空链表中插入第一个结点,刚刚的逻辑就不能用了。我们需要进行下面这样的特殊处理,其中head表示链表的头结点。所以,从这段代码,我们可以发现,对于单链表的插入操作,第一个结点和其他结点的插入逻辑是不一样的。

if (head == null) {
  head = new_node;
}

我们再来看单链表结点删除操作。如果要删除结点p的后继结点,我们只需要一行代码就可以搞定。

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

但是,如果我们要删除链表中的最后一个结点,前面的删除代码就不work了。跟插入类似,我们也需要对于这种情况特殊处理。写成代码是这样子的:

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

这样代码实现起来就会很繁琐,不简洁,而且也容易因为考虑不全而出错。如何来解决这个问题呢?

哨兵,解决的是国家之间的边界问题。同理,这里说的哨兵也是解决“边界问题”的,不直接参与业务逻辑。

如何表示一个空链表吗?head=null表示链表中没有结点了。其中head表示头结点指针,指向链表中的第一个结点。

如果我们引入哨兵结点,在任何时候,不管链表是不是空,head指针都会一直指向这个哨兵结点。我们也把这种有哨兵结点的链表叫带头链表。相反,没有哨兵结点的链表就叫作不带头链表

哨兵结点是不存储数据的。因为哨兵结点一直存在,所以插入第一个结点和插入其他结点,删除最后一个结点和删除其他结点,都可以统一为相同的代码实现逻辑了。

技巧四:重点留意边界条件处理

用来检查链表代码是否正确的边界条件有这样几个:

  • 如果链表为空时,代码是否能正常工作?
  • 如果链表只包含一个结点时,代码是否能正常工作?
  • 如果链表只包含两个结点时,代码是否能正常工作?
  • 代码逻辑在处理头结点和尾结点的时候,是否能正常工作?

技巧五:举例画图,辅助思考

找一个具体的例子,把它画在纸上,释放一些脑容量,留更多的给逻辑思考。当我们写完代码之后,也可以举几个例子,画在纸上,照着代码走一遍,很容易就能发现代码中的Bug。

技巧六:多写多练,没有捷径

5个常见的链表操作。

  • 单链表反转
  • 链表中环的检测
  • 两个有序的链表合并
  • 删除链表倒数第n个结点
  • 求链表的中间结点

day05. 栈

1. 概念

stack,又称堆栈,它是运算受限的线性表,其限制是仅允许在标的一端进行插入和删除操作,不允许在其他任何位置进行添加、查找、删除等操作。

2. 栈的特点

  • “操作受限”的线性表

  • 存取元素特点: 先进后出 ,入口和出口都是栈的顶端

3. 栈的实现

栈既可以用数组来实现,也可以用链表来实现。用数组实现的栈,我们叫作顺序栈,用链表实现的栈,我们叫作链式栈

// 基于数组实现的顺序栈
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;
    }
    
    // 入栈操作
    public boolean push(String item){
        // 如果数组空间不够,直接返回false,入栈失败
        if (count==n) return false;
        // 将item放到下标为count的位置,并且count+1
        items[count] = item;
        count++;
        return true;
    }
    
    // 出栈操作
    public String pop(){
        // 栈为空,直接返回null
        if (count==0) return null;
        // 返回下标为count-1的数组元素,并且栈中元素个数count-1
        String tmp = items[count-1];
        count--;
        return tmp;
    }
}

4. 应用场景

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

1.栈在函数调用中的应用

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

2.栈在表达式求值中的应用(比如:34+13*9+44-12/3)

利用两个栈,其中一个用来保存操作数,另一个用来保存运算符。我们从左向右遍历表达式,当遇到数字,我们就直接压入操作数栈;当遇到运算符,就与运算符栈的栈顶元素进行比较,若比运算符栈顶元素优先级高,就将当前运算符压入栈,若比运算符栈顶元素的优先级低或者相同,从运算符栈中取出栈顶运算符,从操作数栈顶取出2个操作数,然后进行计算,把计算完的结果压入操作数栈,继续比较。

3.栈在括号匹配中的应用(比如:{}{()})

用栈保存为匹配的左括号,从左到右一次扫描字符串,当扫描到左括号时,则将其压入栈中;当扫描到右括号时,从栈顶取出一个左括号,如果能匹配上,则继续扫描剩下的字符串。如果扫描过程中,遇到不能配对的右括号,或者栈中没有数据,则说明为非法格式。
当所有的括号都扫描完成之后,如果栈为空,则说明字符串为合法格式;否则,说明未匹配的左括号为非法格式。

4.如何实现浏览器的前进后退功能?

我们使用两个栈X和Y,我们把首次浏览的页面依次压入栈X,当点击后退按钮时,再依次从栈X中出栈,并将出栈的数据一次放入Y栈。当点击前进按钮时,我们依次从栈Y中取出数据,放入栈X中。当栈X中没有数据时,说明没有页面可以继续后退浏览了。当Y栈没有数据,那就说明没有页面可以点击前进浏览了。

5. JVM内存管理中有个“堆栈”的概念。栈内存用来存储局部变量和方法调用,堆内存用来存储Java中的对象。那JVM里面的“栈”跟我们这里说的“栈”是不是一回事呢?如果不是,那它为什么又叫作“栈”呢?

内存中的堆栈和数据结构堆栈不是一个概念,可以说内存中的堆栈是真实存在的物理区,数据结构中的堆栈是抽象的数据存储结构。
内存空间在逻辑上分为三部分:代码区、静态数据区和动态数据区,动态数据区又分为栈区和堆区。
代码区:存储方法体的二进制代码。高级调度(作业调度)、中级调度(内存调度)、低级调度(进程调度)控制代码区执行代码的切换。
静态数据区:存储全局变量、静态变量、常量,常量包括final修饰的常量和String常量。系统自动分配和回收。
栈区:存储运行方法的形参、局部变量、返回值。由系统自动分配和回收。
堆区:new一个对象的引用或地址存储在栈区,指向该对象存储在堆区中的真实数据。

day06. 队列

1. 概念

队列queue,简称队,它同堆栈一样,也是一种运算受限的线性表,其限制是仅允许在表的一端进行插入,而在表的另一端进行删除。

2. 元素存取特点

  • 先进先出(即,存进去的元素,要在后它前面的元素依次取出后,才能取出该元素)。例如,小火车过山洞,车头先进去,车尾后进去;车头先出来,车尾后出来。
  • 队列的入口、出口各占一侧。

3. 队列的代码实现

数组实现

public class ArrayQueue {
    // 数组:items 数组大小:n
    private String[] items;
    private int n=0;
    // head 表示队头下标,tail队尾下标
    private int head=0;
    private int tail=0;
    
    // 申请大小为capacity的数组
    public ArrayQueue(int capacity){
        items = new String[capacity];
        n = capacity;
    }
    
    // 入队
    public boolean enqueue(String item){
        // 如果tail==n表示队列已满
        if(tail==n) 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都会持续的往后操作,当tail移动到最右边,无法再往队列中添加数据了,如何解决该问题?

在出队时不用搬移数据,如果没有空闲空间,只需要在入队时,再集中触发一次数据搬移,出队保持不变,入队改造代码如下

    // 入队,数据搬移
    public boolean enqueue(String item){
        // 如果tail==n表示队列已满
        if(tail==n) {
            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;
    }

day07. 递归

按照字面意思理解,递就是去的过程,归就是回来的过程,想象课程中的内存图。

1. 递归需要满足的三个条件

  1. 一个问题的解可以分解为多个子问题的解,如电影院的”自己在哪一排“分解为”前一排的人在哪一排“这样一个子问题
  2. 这个问题与分解之后的子问题,除了数据规模不同,求解思路完全相同。
  3. 存在递归终止条件,如第一排的人知道自己在哪一排f(1)=1

2. 如何写递归代码?

最关键的是写出递推公式,找到终止条件

3. 递归的注意事项

遇到递归,把他抽象成一个递推公式,不用想一层层的调用关系,用人脑分解递归的每个步骤

警惕栈内存溢出。

案例:

/*
    题目:n个台阶,每次下台阶有两种方法,下一级和下二级。请问10级台阶多少个方法
    思路:递归,根据第一步的走法将所有走法分为两类
            第一类是第一步走了一个台阶,另一类是第一步走了两个台阶
            n个台阶的走法就是先走1阶后n-1个台阶的走法,加上
            先走2阶后n-2个台阶的走法
            f(n) = f(n-1)+f(n-2)
            终止条件: 有1个台阶时,只有f(1)=1,用n=2,n=3来验证
                n=2时,f(2)=f(1)+f(0),显然f(0)没有意义,不合常理
                可以把f(2)=2作为条件,f(2)两个台阶有两种走法
                递归终止条件为f(2)=2,f(1)=1
*/

public class Demo01 {
    public static void main(String[] args) {
        int i = getTaijie(10);
        System.out.println(i);
    }
    public static int getTaijie(int n){
        if(n==1){
            return 1;
        }else if (n==2){
            return 2;
        }else{
            return getTaijie(n-1)+getTaijie(n-2);
        }
    }
}

在上述案例中,就进行了重复计算,想要计算f(5),就需要计算f(4)和f(3),计算f(4)还需要计算f(3),如何避免?可以通过数据结构如hash表保存已经求解过的f(k)。

public class Demo02 {
    public static void main(String[] args) {
        int i = getTaijie(10);
        System.out.println(i);
    }
    public static int getTaijie(int n){
        if(n==1){
            return 1;
        }
        if (n==2){
            return 2;
        }
        // hasSolvedList可以理解为一个Map,key是n,value是f(n)
        Map hasSolvedList = new HashMap<>();

        int ret = getTaijie(n-1) + getTaijie(n-2);
        hasSolvedList.put(n,ret);
        return ret;
    }
}

4. 递归的利弊

缺点

在时间效率上,递归代码多了很多函数调用,调用数量较大时,积聚成一个可观的时间成本;空间复杂度上,调用一次就会在栈内存保存一次现场数据,需要额外考虑这部分,空间复杂度不是O(1),而是O(n);存在栈内存溢出风险,存在重复计算。

优势

表达力强,写起来简洁

5. 转换非递归

public class Demo03 {
    public static void main(String[] args) {
        int i = getTaijie(10);
        System.out.println(i);
    }
    public static int getTaijie(int n){
        if(n==1){
            return 1;
        }
        if (n==2){
            return 2;
        }
        int ret = 0;// 返回值
        int pre = 2;// n=2的情况
        int prepre = 1;// n=1的情况
        for(int i=3;i <= n; i++){
            ret = pre + prepre;
            prepre = pre;
            pre = ret;
        }
        return ret;
    }
}

6. 调试递归

  1. 打印日志,发现递归值
  2. 结合条件断点调试

猜你喜欢

转载自blog.csdn.net/wjl31802/article/details/91037976