数据结构和算法之一:基础结构线性表

数据结构基础知识

一个简单的问题开始

问题:如何判断一个数是2的N次?

思路1, 对于这个题目,判断一个数是否是2的N次方,可以通过数学公式, 给定的数字 x ,它是否等于 x = 2 ^ n, 即 x = 2 * 2 * … * 2 , 那么我们可以使用一个循环来解决, 伪代码如下

while(x > 1){

​ x % 2 != 0; return false;

​ x = x / 2;

}

思路2, 要判断一个数是否是2的N次方,假如我们设定一个数据范围,比如100万,那么我们可以先将在这个范围之内的所有2的N次方的数字枚举出来,比如2的0次方是1, 1次方是2, 2次方是4,3次方是8 … 2的20次方是1048576。 这样我们只需要开一个数组,里面就存放这20个数,当判断一个数是否是2的N次方时,只需要在这个20个元素的数组中查询一下,就知道是不是了。

这种算法思想叫枚举,也叫穷举,也就是将求解枚举出来,这种适用于解空间有限的场景。

思路3, 在计算机中,所有数据都是2进制表示的, 比如数字1,用8位二进制表示为 0000 0001, 数字2表示为0000 0010, 数字4表示为0000 0100 ,8表示为0000 1000, 不知道大家有没有发现一个规律,2的N次方就是二进制表示中,只有一个1,其他为0,n等于1后面0的个数。因此给定一个数,我们只需要判断它的二进制表示是否满足这个规律。那么如何来判断呢?那么可以使用位运算。

简单的说明下位运算逻辑,位运算主要与(&)、或(|)、非(~)、异或(^)。

与表示同一位上都为1时才为1,否则为0; 比如

0010 & 1010 = 0010

或表示同一位上只要有一个是1就为1,全部都是0时才为0; 比如

0010 | 1010 = 1010

异或表示同一位上不相同时为1,相同时为0; 比如

0010 ^ 1010 = 1000

回到问题上来, 如何通过位运算来判断一个二进制,比如 00100000,满足只有一个1,其他为0?

其实我们可以找一个数,它的二进制为除了那个1所在的位为0,其他的位都为1的,其实这个数就是给定的数减去1,然后做&运算。

0010 0000 & 0001 1111 = 0000 0000,结果为0,那么满足;如果结果不为0,比如0010 0010 & 0001 1111 = 0000 0010 ,结果不为0, 那么不满足,就不是2的N次方。

经过分析,判断一个数是不是2的N次方,其实直接判断 x & (x - 1) == 0 即可。

这就是学习数据结构与算法的魅力。

虽然3中方法都可以解决问题,但是方法1显然是最差的,执行时间最长; 方法2次之; 方法3最优雅。

评价算法的指标

在前面的题目中,直观的通过观察,我们说方法1最差,方法3最好;那么有没有一种比较科学的度量方法,来评价我们的算法的优劣呢?

这就是时间复杂度和空间复杂度概念。简单来说,时间复杂度来评判一个算法的运行时间,显然运行时间越少越好;空间复杂度用来评判一个算法的使用的内存空间,显然也是空间占用越少越好。那么是不是每个算法我们都是需要时间和空间都是越少越好,不一定,实际上很多算法是在这两个中间找平衡,有的算法为了提高效率,会牺牲空间;而有的算法为了节省空间,会牺牲时间。

有了这两个概览之后,我们来看看它们是怎么评估计算出来的?

时间复杂度的评估方法, 找代码中循环的地方,评估循环的次数。

下面我们通过一个代码例子来简单认识下:

/**
 * 时间复杂度DEMO
 *  时间复杂度是评价算法优劣的最重要的指标,是学习和理解算法的重要概念。
 *  1. 明白时间复杂度的概念后,你才知道一个算法运行时间长还是短
 *  2. 要会计算常见的时间复杂度,这样才能对一个算法的优化提供理论依据
 */
public class BigO {

    public static void main(String[] args) {
        //常数级  O(1)
        int a = 1; // 这句代码运行1次,O(1)

        for (int i = 0; i < 10; i++) {
            a += 1;  // 这句代码运行10次,O(10) --> O(1). 在计算时间复杂度是,所有确定的数字都可以忽略
        }

        int n = new Random().nextInt(100000000); //n表示一个不确定的数,这儿赋予一个值是为了解决语法错误。
        //对数级
        int x = 0;
        while (x < n ){
            x = x * 2;  //这句代码运行的次数这么计算  2^k = n, 那么k = log2n, 去除常数 记为O(logn),如果是log3n,log10n,通通都是logn
        }

        //线性级
        for (int i = 0; i < n; i++) {
            x += i; //这句代码运行n次, O(n)
        }

        //线性对数级
        for (int i = 0; i < n; i++) {
            //外层循环运行n次
            while (x < n ){
                x = x * 2;  //每次外层循环,内层循环运行log2n次,那么一共就是n*log2n次,去除常数,为O(nlogn)
            }
        }

        //平方级
        for (int i = 0; i < n; i++) {
            //外层循环运行n次
            for (int j = 0; j < n; j++) {
                x += i; //每次外层循环,内层循环运行n次,那么一共运行n*n,就是n^2  O(n^2)
            }
        }

        for (int i = 0; i < n; i++) {
            //外层循环运行n次
            for (int j = i; j < n; j++) {
                x += i; //每次外层循环,内层循环运行次数不一样
                //外层第1次循环,内部循环执行n次
                //外层第2次循环,内部循环执行n-1次
                //外层第3次循环,内部循环执行n-2次
                //....
                //外层第n次循环,内部循环执行1次
                //那么运行的总次数为 1 + 2 + 3 + ... + n = n*(n+1)/2 --> 去除常数就是n^2 --> O(n^2)
            }
        }
    }
}

从上面的例子,我们可以看出常用的时间复杂度,使用大写的O表示,从低到高就那么几种

O(1),常数级,运行次数是固定的,不会随着问题规模增大而改变。注意这个1表示的是常数,不是说只运行一次。

O(logn),对数级,除了O(1)之外,最优的时间复杂度。

O(n), 线性级, 运行次数随着问题规模增大而线性增长。

O(nlogn),线性对数级,比线性稍微低一点。

O(n^2) , 平方级, 运行次数随着问题规模增大而指数级别增长。
在这里插入图片描述

空间复杂度的分析相对比较简单了,就是看消耗的多少的内存空间,比如申请一个int[1000]的数组,会消耗4000Byte的空间。

基础数据结构-数组

数组是数据结构中最基础,最简单的数据结构了,但是你觉得你真的用好它了吗?

我们来看一个问题,给你一个文件,里面包含了全国人名的年龄数据(14亿),现在统计一下每个年龄下有多少人?

你可以先花一分钟,思考下怎么解决这个问题。

我们先来看下数组的定义和结构,数组是内存中连续的一段空间,数组中的每个元素一个紧挨着一个往后依次排放,可通过数组的下标,随机访问数组中的元素,下标从0开始。

图示如下

在这里插入图片描述

数组中的元素能不能是不同的类型?当然不行,数组的最大优势就是随机访问元素能力,性能极高;但是能随机访问的前提是,能够通过寻址公式直接计算出来要访问元素的地址,这也就要求元素占用的空间必须一样,因此需要样的元素类型(不要抬杠说不通的元素类型,但是占用空间一样)。

为什么很多编程语言中的数组下标要从0开始呢? 从1开始有什么不好的?

其实从1开始也是可以的, 不过寻址公式就需要修改一下,a[n] = a的地址+(n-1)*元素大小,依然可以实现高效寻址。不过数组的寻址计算公式在一个系统的运行过程中,是非常庞大的,新的计算公式多了一个-1的操作看是没什么问题,但是在及其庞大的计算次数下,还是会带来额外的计算开销的。

数组的核心操作有初始化时申请空间,插入元素,空间满了之后的扩容,删除元素,查找元素这几个操作,我们一个一个来看下。

初始化:数组的初始化需要指定长度,这样才知道要申请多大的一块连续的内存空间出来。

插入元素:

  1. 如果插入是尾部插入,只需要定位到尾部位置,直接插入即可。

  2. 如果插入是非尾部插入(头部或者中间),那么需要从后往前依次移动元素,将插入的位置空出来之后,再插入。

删除元素:
同插入一样,如果是尾部删除,只需定位到尾部位置,直接删除;如果是非尾部删除,则删除对应位置元素之后,其后续的元素需要往前移。

在这里插入图片描述

扩容:因为数组一旦分配空间,就固定了,因此当数组被用满了之后,再往里面插入元素时,就会触发扩容操作;扩容的步骤, 1 申请一个更大的连续空间,通常是原数组的2倍; 2 将原数组的所有元素依次拷贝到新数组中 3 释放原空间(java中就是会被GC了)

查找:直接通过下标随机访问。

通过分析这些核心操作,我们可以发现,数组的最大优势是随机访问,时间复杂度为O(1). 尾部插入和删除,效率也很高,时间复杂度O(1); 但是其他的如中间插入、删除,扩容操作就比较耗时了,时间复杂度为O(n); 空间上,数组存在一定的空间浪费,因此在使用的时候最好是要先预估下数据量,数据量太大,就不合适用数组了。

数组的代码实现:

/**
 * 数组的简单实现
 *
 * 数组的特点是,存储相同类型的元素,并且是分配连续的内存空间,这样带来的好处就是支持随机访问
 * 也就是通过下标,能够通过寻址算法直接访问到对应的元素,查找速度非常快,时间复杂度为O(1).
 * 寻址计算公式:元素地址 = 数组起始地址 + 下标 * 元素大小 (这个也叫做偏移量)。
 *
 * 因为数组是连续的地址空间,所以缺点也很明显,
 * 1. 插入和删除慢,时间复杂度为O(n). 这是因为在非尾部插入和删除时,需要移动元素,以保证元素连续存放的特性
 * 2. 空间是固定的,当空间不够时会触发扩容,通常扩容会分配一块更大的连续空间,再将原数据的元素依次copy过去,又是一个O(n)的操作。
 */
public class MyArray {

    private int size;  //数组大小
    private int[] data; //存放数据
    private int position;  //数据存放到的位置

    public MyArray(int size){
        this.size = size;
        data = new int[size];
    }

    /**
     * 插入值到指定位置
     * @param loc
     * @param value
     * @return
     */
    public boolean insert(int loc, int value){
        checkLoc(loc);
        if(position  < size-1){  //还有空闲空间
            //将loc位置以及之后元素,往后移动
            for (int i = size-1; i > loc; i--) {
                data[i] = data[i-1];
            }
            //loc位置插入value
            data[loc] = value;
            //数据存放位置+1
            position++;
            return true;
        }else{  //已经存满了, 那么就需要扩容了

            return false;

        }
    }

    /**
     * 校验位置是否正确,不能越界
     * @param loc
     */
    private void checkLoc(int loc) {
        if(loc<0 || loc>=size){
            throw new IllegalArgumentException("位置参数不合法");
        }
    }

    /**
     * 删除指定位置
     * @param loc
     * @return
     */
    public boolean delete(int loc){
        checkLoc(loc);

        //将loc位置以及之后的元素,往前移动
        for (int i = loc; i < size-1; i++) {
            data[i] = data[i+1];
        }
        //数据存放位置-1
        position--;
        return true;
    }

    /**
     * 获取
     * @param loc
     * @return
     */
    public int get(int loc){
        checkLoc(loc);
        return data[loc];
    }

    /**
     * 更新
     * @param loc
     * @param value
     * @return
     */
    public int update(int loc, int value){
        checkLoc(loc);
        int oldValue = data[loc];
        data[loc] = value;
        return oldValue;
    }


    public void print(){
        System.out.println("数组值");
        for (int i = 0; i < size; i++) {
            System.out.print(data[i]+",");
        }
        System.out.println();
    }

    public static void main(String[] args) {
        MyArray array = new MyArray(10);
        array.print();

        array.insert(0, 10);
        array.insert(1, 20);
        array.insert(2, 30);
        array.print();

        array.insert(2, 25);
        array.print();

        array.delete(2);
        array.print();

        System.out.println(array.get(2));

        array.update(2, 22);
        array.print();

    }
}

现在,回到前面的问题上来, 如果用数组来解决,巧用数组下标来表示业务含义,你会发现非常容易。

下面是给出的思路和代码, 你可以参考一下:

/**
 * 现有一个文件中存放了全国人民的年龄(14亿数据,差不多7个G),请统计下每个年龄的人数?
 * 注意:不能使用现成的集合来实现,并且资源限制为1C1G
 *
 * 思路
 * 全国人民大概14亿,而且资源是被限制了的,那么肯定不能一下就load到系统中计算,需要一个一个来
 * 年龄 取值范围 0 - 150
 *
 * 如果将年龄作为数组的下标,元素的值作为统计的人数,
 * 那么每读取一个年龄数据,就对对应下标的元素+1,处理完成后就是最终的统计结果了。
 * 整个算法的时间复杂度O(n),空间复杂度仅为一个int数组的开销,非常小。
 */
public class AgeStatisticDemo {

    private static Random random = new Random();
    private static int count = 100000000; //1亿次模拟
    private static int endFlag = -1;

    public static void main(String[] args) {

        long startTime = System.currentTimeMillis();

        int[] ageStatistic = new int[150]; //统计数组,下标代表年龄,元素值代表统计的人数

        int age = readOneAge();
        while(age != endFlag){
            ageStatistic[age]++; //对应年龄的统计值+1
            age = readOneAge();//读取下一个
        }

        System.out.println("1亿次年龄统计计算耗时:"+ (System.currentTimeMillis() - startTime) + "ms");

        for (int i = 0; i < ageStatistic.length; i++) {
            System.out.println(i + "-->" + ageStatistic[i]);
        }
    }

    private static int readOneAge() {
        if(count-->0) {
            //模拟从文件中读取一个年龄数据
            return random.nextInt(150);
        }
        return endFlag;
    }
}

多维数组,内存中实际上一维数组没区别,还是一块连续的地址空间,同样支持随机访问,只不过寻址公式发生点变化而已。比如二维数组 int[m][n] 中,a[i][j]的寻址公式吗?

基础数据结构-链表

链表是除数组之外的另外一种最基本的数据结构,但是它的威力不小,用好了可以解决很多实际问题。

比如问题1, 设计一个LRU缓存淘汰算法。 问题2, 约瑟夫问题。

链表和数组不一样,在内存中,是有一组零散的内存块串接在一起,一个个的内存块我们称为结点,结点之间的连线,我们称为指针。

在这里插入图片描述

通常,链表中会有头结点,有了它之后,我们就可以沿着它往后顺藤摸瓜,把整个链表上的结点都找出来。

链表的核心操作也有,初始化,插入,删除,查找。

初始化:初始化一个空链表,因此可以就是null,也可以是只有一个头结点。

插入:如果是头部插入,只需要将新插入节点的next指针指向头节点,然后将头指针执行新插入的节点。

如果不是头部插入,那么需要先定位到要插入的地方,然后通过修改指针的方式完成插入。

删除:同插入一样,修改下指针即可完成删除。

查找:查找的话,需要从头节点开始一个一个往后查找,它就没有数组的那种直接定位能力了,因此效率比较低。

插入和删除图示

在这里插入图片描述

现在,理解了这些核心操作之后,你应该可以自己分析出他们的时间复杂度了吧。

链表根据指针的指向,还分为双向链表,循环链表,如下图所示

在这里插入图片描述

为了简单起见,我们来实现一个简单的单向链表.

/**
 * 单向链表实现
 *   实现和阅读代码时,脑中要有链表的结构图,以及插入、删除操作时,指针的变化。
 *
 */
public class SingleDirectionList {

    Node head = null;  //头节点
    Node tail = null;  //位节点
    int size = 0;      //节点数量


    public void insertHead(int value) {
        Node newNode = new Node(value);
        if (!isEmpty()) {
            newNode.next = head;  //新加入的节点的后继指向当前的head
        }else{
            tail = newNode;
        }
        head = newNode;           //新的head指向新插入的节点
        size++;
    }

    public void insertPosition(int position, int value) {
        //position值需要校验,在0到size之间,0表示头部,size表示尾部,中间的值表示中间插入。略

        if (isEmpty() || position==0) {    //空链表,或者positon为0时,直接插入头节点
            insertHead(value);
        }else{
            Node cur = head;
            for (int i = 0; i < position-1; i++) {
                cur = cur.next;  //往后遍历到position的位置前面
            }
            Node newNode = new Node(value);
            //插入节点,cur是新节点的前驱节点  cur.next 是新节点的后继节点
            newNode.next = cur.next;
            cur.next = newNode;

            if(cur == tail){ //如果插入到尾部,更新tail
                tail = newNode;
            }

            //size添加
            size++;
        }
    }

    public void deleteHead() {
        if (isEmpty()) {
            return;
        }
        head = head.next; //head指向其后继即可
        size--;
    }

    public void delete(int position) {
        //position值校验 略

        if(position == 0){
            deleteHead();
            return;
        }

        Node cur = head;
        for (int i = 0; i < position-1; i++) {
            cur = cur.next;
        }
        //cur是要删除的前一个节点位置,删除操作只需要让cur.next 指向其后继的后继,即跳过要删除的节点即可
        if(cur.next == tail){//删除的是tail
            tail = cur;
        }
        cur.next = cur.next.next;


        size--;

    }

    public int get(int position) {
        //position值校验 略

        Node cur = head;
        for (int i = 0; i < position; i++) { //遍历到指定位置。
            cur = cur.next;
        }
        return cur.data;
    }

    public boolean isEmpty() {
        return size == 0;
    }

    public void print() {
        if (isEmpty()) {
            System.out.println("空链表");
            return;
        }
        Node cur = head;
        System.out.print(cur.data + ",");  // 头结点
        while (cur.next != null){ //cur.next == null 表示是最后一个节点
            cur = cur.next;  //往后遍历一个节点
            System.out.print(cur.data + ",");
        }
        System.out.println();
    }


    public static void main(String[] args) {
        SingleDirectionList list = new SingleDirectionList();
        list.print();

        list.insertHead(11);
        list.insertHead(22);
        list.print();

        list.insertPosition(0, 10);
        list.print();

        list.insertPosition(1, 13);
        list.print();

        System.out.println(list.get(0));
        System.out.println(list.get(1));
        System.out.println(list.get(3));

        list.deleteHead();
        list.print();

        list.delete(0);
        list.print();

        list.delete(1);
        list.print();
    }


    /**
     * 单向链表节点信息
     */
    private static class Node{
        int data; //数据域
        Node next; //后继指针

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

有了链表的知识之后,现在我们可以来解决前面提到了两个问题。

LRU缓存淘汰算法解决思路,通过一个单向链表轻松解决。

* LRU缓存淘汰算法
*
* 最近最少使用淘汰
*
* 思路:通过单向链表实现,限长的单向链表保存着缓存值,head最新,tail最旧
* 新来一次查询请求时,查询链表,
* 1. 如果命中,则删除命中节点,并插入到头部,保持最新
* 2. 如果未命中,则查询源,然后插入头部,保持最新
*    2.1 新增插入时,如果超过限制,则删除tail节点。

约瑟夫问题思路,通过一个单向循环链表也是轻松解决。

* 约瑟夫问题(自杀问题,丢手绢)
*
* N个人围成一圈,从第M个人报数(从1开始),数到K个人时,这个人退出,
* 然后下一个人接着报数,数到K个人时,这个人退出,如此循环下去,直到剩下最后一人。
*
* 思路
* 使用单向循环链表,N个人表示为N个节点,模拟游戏的数数过程,退出的动作使用删除节点实现,
* 最后只剩下一个人时退出。

以上两个问题的代码实现,你可以自己实现一下。

数组和链表总结对比一下:

1.数组随机访问O(1),链表查询慢O(n);数组插入删除(非尾部)不友好O(n),链表插入删除(不关心查找)O(1)

2.数组简单易用,在实现上使用的是连续的内存空间,可以借助CPU的缓存机制,预读数组中的数据,所以访问效率更高。因此,如果空间可控的情况下,能用数组就用数组。

3.链表在内存中并不是连续存储,所以对CPU缓存不友好,没办法有效预读。

4.数组的缺点是大小固定,一经声明就要占用整块连续内存空间。如果声明的数组过大,系统可能没有足够的连续内存空间分配给它,导致“内存不足(out ofmemory)”。如果声明的数组过小,则可能出现不够用的情况。

5.动态扩容:数组需再申请一个更大的内存空间,把原数组拷贝进去,非常费时。链表本身没有大小的限制,天然地支持动态扩容。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值