基础数据结构及常见应用

数组

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

所以数组的量大特点:

  • 连续的内存空间

  • 相同类型的数据

正这两个限制成就了数组的杀手锏特性:随机访问效率非常高,O(1)的操作。

ps:HashMap的O(1)查找本质上也是依赖于底层是基于数组来实现的,而其核心就是数组高效的O(1)的随机访问。

数据结构中的数组和不同语言中数组的区别

但也正是因为这两个限制,数组的删除和插入,可能会涉及大量元素的移动,所以效率都不高。而导致需要移动元素的原因就是数组是连续的空间,在某个指定位置插入就需要将后面的元素一次移动一下;

在某个元素删除一个元素,就需要将后面的元素全都往前移动一下。

可以看到,其实本质是因为插入和删除的是任意位置,如果插入和删除都是在尾部操作,就不需要移动元素了(当然对于插入来说,空间要够,空间不够同样是需要申请空间+copy)。所以在一些特殊场景下,数组的插入和删除优化,可以巧妙的将操作转换到尾部,从而达到O(1)

  1. 对插入的优化:

前提:数组不要求有序、且不关注元素的位置。

  • 将末尾元素直接copy到待删除的指定位置

  • 末尾元素置空

ps:另外一种思路就是,删除的时候标记但不动数据,在适当的时候来整理。其实这个就是java gc的标记-整理gc算法。这么搞的问题一个是实现复杂了(需要有结构来标记数组每个位置上的元素是否删除),另外就是整理的时候不可避免的要暂停这个数组的所有操作(stop the world)

  1. 对插入的优化

前提:数组不要求有序、且不关注元素的位置,且数组有足够的空间

  • 只支持在尾部插入

  • 或者将插入元素copy到尾部,然后将待插入元素放到指定位置

凡是数据结构使用了数组,那么优化的思路全都是往O(1)的随机访问上去靠就对了。

比如二分查找,本质其实也是依靠的是数组O(1)的随机访问,这也是为啥二分算法的限制就是底层使用的是数组的原因

散列表(hash表)

数组可以支持O(1)的下标访问,但是如果下标不是正整数,但是又需要O(1)访问效率,那怎么办呢?答案就是映射,将非正整数的key通过某种方式映射成正整数,那就可以利用上数组的O(1)的下标访问了。这个某种方式的映射就是hash函数,将这种通过映射而使用上数组O(1)高效的访问进行一次封装,就是我们所说的散列表。

所以散列表的核心就是:数组+散列函数。所以hash表的问题都是围绕这两点展开的:

  1. hash函数:将一个非正整数经过计算转换成一个非负整数,作为数组的下表访问。因为key的范围不确定,但是数组下表范围是固定的,再加上hash函数本身的原因,就有可能将两个不同的key经过计算后得到的下非负整数是一样的,这就是所谓的hash冲突。

  1. hash冲突

  1. 开放寻址法:如果出现了散列冲突,我们就重新探测一个空闲位置,将其插入。比如jdk中ThreadLocal使用的Map就是用了这种方式

  1. 线性探查:如果hash(key)+0被占用,就看hash(key)+1,依次类推,直到找到一个空闲位置。

  1. 二次探测(Quadraticprobing):和线性探查一样,只是探查的步长变成了k^2而已。即hash(key)+0,hash(key)+1^2,hash(key)+2^2

  1. 双重散列(Doublehashing):就是冲突的时候不是找下一个位置,而是换个hash函数来计算。

  1. 链表法:数组的元素类型是个链表,当多个key因为hash冲突定位到一个数组下标的时候,那就将这多个元素放到链表中。比如jdk中的HashMap就是这种方式

  1. 装载因子:指的是当hash表元素个数/数组容量达到一个阈值的时候,就自动扩容,将数组扩大一倍,这个阈值就是状态因子。其目的就是为了减小hash冲突。比如jdk中HashMap默认的状态因子就是0.75

二分查找

二分查找的本质就是,利用数组高效的O(1)随机访问的特点,在有序数组中,当不满足条件的时候,可以直接定位的中间位置的前半段/后半段里去查找,而不需要依次顺序比对,所以效率很高。

所以二分查找的两个关键点:

  1. 需要底层支持数据结构支持O(1)的随机访问。

  1. 有序。因为只有有序才能够做出不满足的时候,直接跳转到中间位置去向前/向后比较。即只有有序才能够得出结论:目标元素要么在当前位置的后半段、要么在当前位置的前半段。不可能出两边都出现。所以才能一次就排除一半。

所以,正确写出二分算法的三个要点:

  1. 循环退出条件:注意是low<=high,而不是low<high。

  1. 中间位置mid的取值:mid=(low+high)/2是有问题的,因为如果low和high比较大的话,两者之和就有可能会溢出。改进的方法是将mid的计算方式写成low+(high-low)/2。更进一步,如果要将性能优化到极致的话,我们可以将这里的除以2操作转化成位运算low+((high-low)>>1)。因为相比除法运算来说,计算机处理位运算要快得多。

  1. 两端位置low和high的更新:

low=mid+1,high=mid-1。注意这里的+1和-1,如果直接写成low=mid或者high=mid,就可能会发生死循环。比如,当high=3,low=3时,如果a[3]不等于value,就会导致一直循环不退出。

需要注意的是,我们说的朴素的二分查找,其实是查找任意一个位置就可以了,基本实现

 // 查找任意一个相等的
    private static int binarySearch(int[] arr, int target) {
        int l = 0;
        int r = arr.length - 1;
        int mid = (l + r) / 2;//如果数组比较大,可能溢出。改成会减少溢出风险:low+(high-low)/2。除以二可以改成移位运算:low+((high-low)>>1
        while (l <= r) {
            if (arr[mid] == target) {
                return mid;
            }

            if (arr[mid] > target) {
                r = mid - 1;
            }
            if (arr[mid] < target) {
                l = mid + 1;
            }
            mid = (l + r) / 2;
        }
        return -1;
    }

递归版本:

// 递归查找任意相等的
    private static int binanrySearchR(int[] arr, int target) {
        return doSearch(arr, target, 0, arr.length - 1);
    }

    private static int doSearch(int[] arr, int target, int beg, int end) {
        int mid = (beg + end) / 2;
        if (beg > end) {
            return -1;
        }
        if (arr[mid] == target) {
            return mid;
        }
        if (arr[mid] < target) {
            return doSearch(arr, target, mid + 1, end);
        }
        if (arr[mid] > target) {
            return doSearch(arr, target, beg, mid - 1);
        }
        return -1;
    }

所以常见的二分查找变形问题

查找第一个值等于给定值的元素

区别在于:当arr[mid]==tar的时候不是直接返回,还需要判断下arr[mid-1]是否等于tar。如果等,其实就可以一直往前循环、或者继续二分往前版本分查找。

// 查找第一个相等的元素
    private static int binarySearchFirstTarget(int[] arr, int target) {
        int l = 0;
        int r = arr.length - 1;
        int mid = (l + r) / 2;

        while (l <= r) {
            if (arr[mid] == target) {
                if (mid == 0) {// 为了防止mid-1越界
                    return mid;
                }
                if (arr[mid - 1] == target) {
                    r = mid - 1;
                } else {
                    return mid;
                }
            } else if (arr[mid] < target) {
                l = mid + 1;
            } else {
                r = mid - 1;
            }
            mid = (l + r) / 2;
        }
        return -1;
    }
查找最后一个值等于给定值的元素

这个和查找第一个相等元素一模一样,无非就是第一个是向前找;而最后一个是向后找

 // 查找最后一个相等的
    private static int binarySearchLastTarget(int[] arr, int target) {
        int l = 0;
        int r = arr.length - 1;
        int mid = (l + r) / 2;

        while (l <= r) {
            if (arr[mid] == target) {
                if (mid == arr.length - 1) {// 防止mid+1越界
                    return mid;
                }
                if (arr[mid + 1] == target) {
                    l = mid + 1;
                } else {
                    return mid;
                }
            } else if (arr[mid] < target) {
                l = mid + 1;
            } else {
                r = mid - 1;
            }
            mid = (l + r) / 2;
        }
        return -1;
    }
查找第一个大于等于给定值的元素
// 第一个大于等于的
    private static int binarySearchFirstBiggerThenTarget(int[] arr, int target) {
        int l = 0;
        int r = arr.length - 1;
        int mid = (l + r) / 2;

        while (l <= r) {
            // 中间比target小,那么第一个比target大的一定在右边
            if (arr[mid] < target) {
                l = mid + 1;
            } else {
                if (mid - 1 == 0 || arr[mid - 1] < target) {
                    return mid;
                } else {
                    r = mid - 1;
                }
            }
            mid = (l + r) / 2;
        }
        return -1;
    }
查找最后一个小于等待与给定值的元素
// 最后一个小于等于
    private static int binarySearchLastLessThanThenTarget(int[] arr, int target) {
        int l = 0;
        int r = arr.length - 1;
        int mid = (l + r) / 2;

        while (l >= r) {
            // 中间比target大,那么第一个比target小的一定在左边
            if (arr[mid] < target) {
                r = mid - 1;
            } else {
                if (mid + 1 == arr.length || arr[mid + 1] > target) {
                    return mid;
                } else {
                    l = mid + 1;
                }
            }
            mid = (l + r) / 2;
        }
        return -1;
    }
利用二分实现区间查找

这个其实就是查找第一个大于等于给定值、查找第一个小于等于给定值的一个综合,在一个有序列表中,只要确定了下届和上届,那也就确定了数据范围了。

查找有序数组中绝对值最小的

问题的本质是找到正数中最小的、或者复数最大的。换句话说,这个绝对值最小的值,一定是正负数交叉的那个地方的值。所以换个说法就是找到正负数交界的地方。

所以,利用二分查找,

  • 如果中间值是正数,则在去左边查找

  • 如果中间值是负数,则去右边查找;

  • 边界的处理:都是正数和都是负数

  • 如果arr[0]是正数,说明整个数组都是正数,那么绝对值最小值就是arr[0]

  • 如果arr[n-1]是负数,说明整个数组都是负数,那么绝对值最小的就是arr[n-1]

 private static int binarySeachAbsMin(int[] arr) {
        // 都是正数
        if (arr[0] > 0) {
            return arr[0];
        }
        // 都是负数
        if (arr[arr.length - 1] < 0) {
            return arr[arr.length - 1];
        }

        // 正负数都有:则mid为正数,则向左;mid为负,则向右。最终l和r就会指向分界点
        int l = 0;
        int r = arr.length - 1;
        int mid = (l + r) / 2;
        while (l <= r) {
            if (arr[mid] == 0) {
                return arr[mid];
            }

            if (arr[mid] > 0) {
                r = mid - 1;
            } else {
                l = mid + 1;
            }
            mid = (l + r) / 2;
        }

        // 最后l和r就是边界的地方,比较两个数的绝对值
        if (Math.abs(arr[l]) > Math.abs(arr[r])) {
            return arr[r];
        } else {
            return arr[l];
        }
    }
循环数组(旋转数组)查找最小值

循环有序数组,比如60,50,40,1,2,3,4,5,则返回1。

因为这个数组的特点,其实这个最小值,也是数组旋转的边界,所以寻找边界和寻找最小值其实都是同一个问题。

  • 一个最朴素的办法就是遍历一遍,不管什么数组遍历一遍都能求出最值

  • 依然是遍历,根据数组特点,比较遍历到的两个值:如果arr[n] < arr[n+1],说明已经数组已经开始递增了。那么arr[n]就是那个最小值。这个其实也是O(n),只是说比普通的遍历可能会少遍历一些元素,这个就看旋转了多少元素了。

  • 根据局部有序的特点,来使用二分法

从数组特点看:前半部分是递减的、后半部分是递增的,且前半部分的元素都是大于后半部分的元素的。

所以,是比较容易想到一个办法的:用前后部分的任何一个值作为标的,比如arr[len-1],然后和mid元素比较:

  1. arr[len-1] > arr[mid] 说明mid在后半部分,应该向前找最小元素

  1. arr[len-1] < arr[mid] 说明mid在前半部分,应该向后找最小元素。

  1. arr[len-1] = arr[mid] 这种情况是不太容易相同处理。这种情况其实就是不知道最小值在前版本分还是在后半半部分,所以没办反一次减小一半的区间来查找了。所以就只能将区间缩小一个元素。即l++或者r--都可以。极端情况下,所有元素都相等,那每次都只是缩小了一个元素,你其实就退化成遍历了。

这个方法处理不了两种特殊情况:

  • 全部旋转,那数组就是一个递减的数组。这种情况arr[mid] < arr[0]和原来结论:mid在右半部分、最小值应该在mid左侧的结论是相悖的。这种递减的数组arr[mid] < arr[0]始终是满足的,但是最小值其实是在右侧、而不是左侧。

但是这个也好办:只需要开头判断下arr[lenth-1] > arr[0]

  • 旋转数组的收尾有相等的情况,比如1,0,1,1,1,1,1。这种情况因为arr[mid] >= arr[0] 说明不了mid在左半部分,比如这个例子其实mid就是在右部分了

如下处理方式就是ok的

 private static int binarySeachMinInSpinArr2(int[] arr) {
        int l = 0;
        int r = arr.length - 1;
        int mid = l + (r - l) / 2;
        
        while (l < r) {
            if (arr[l] < arr[r]) { //[l,r]区间已经是非递减序列,说明l已经到了右半部分了。
                return arr[l];
            }

            if (arr[mid] < arr[r]) { // 说明[mid,r]是递增的,那就说明mid在右半部分了,所以应该去左边找
                r = mid - 1;
            } else if (arr[mid] > arr[r]) { // 说明mid在作半部分,r可能在左边、可能在右边但不重要,那么个最小值一定是在右边了。
                l = mid + 1;
            } else {
                // mid和r相等的时候,区间[l,r]缩小一个,那么l++/r--其实都是一样的。
                // 注意:二分之所以是O(logn),是因为每次区间缩小了一半、这里每次缩小一个,其实就是遍历了,那效率就是O(n)了。
                // 所以当数组有大量相等元素的时候,效率基本上就是O(n)了。比如所有元素相同、只有两个不同值的元素{1, 0, 1, 1, 1, 1}效率都退化成O(n)了
                l++;//r--;都一样,本质就是将区间缩小一个元素,其实就是遍历了
            }
            mid = l + (r - l) / 2;
        }
        return arr[l]; //l和r所确定的区间当中只有一个元素。因为循环里有arr[l] < arr[r]才退出,所以l处就是最小值
    }
局部排序,在特定场景下的二分。

所以,凡是数组的特点是有序/局部有序,那么就可以考虑是否能够使用二分法来提高效率。

更加准确的说,只要是数组的特点满足:在分界点出,可以准确的知道目标值是在分界点前面还是分界点后面,那就可以使用二分的思路来解决问题。只是说这个地方不同的场景的分解条件可能会不一样。基本思路:

  1. 确定什么情况下,能百分百确定排除一半的数据、去另一半去寻找。ps:如果不能排除掉一般,那么能不能排除1/3? 极端情况下,一次排除一个,那么其实就是O(n)的遍历。

  1. 确定什么情况下结束:这个很重要。既然是查找,就有可能找到、有可能找不到。

  1. 找到的条件:这个就需要根据不同的情况来确定,也是能够写出正确二分的决定性条件。

比如:查找任意一个相等,那就是arr[mid]=targe就返回;第一个小于等于指定值,那就是arr[mid]<target,且arr[mid-1] > target等等

  1. 找不到的条件:这个其实好确定,就是l > n的时候,还没有满足条件的,那就是找不到了

其实能用二分查找能解决的,绝大部分场景,都是可以用hash表或者二叉查找树来解决,他们的区别就是hash表和二叉查找树需要更多的内存,但是这种数据结构对数据的变更更加友好,而在实际生产场景中,数据不变更是不太可能的,所以往往这些查找场景,更多的会选择用散列表或者二叉树来实现。但是hash表/二叉树的查找对于范围查找就有点力不从心了,这种近似的范围查找又是二分查找的优势(比如mysql的B+树其实就是结合了树和二分算法:先是树的查找定位到page、page内用二分查找)

链表

数组和链表应该是最基础的两个数据结构,很多其他更高级的数据结构几乎都是以这两个数据结构为基础的,比如HashMap,底层就是数组+链表;优先队列底层往往是个链表;堆的实现底层可能是数组也可能是链表等等。

而链表有很多中形态,最基础的单链表,在此基础之上扩展出双向链表、循环链表、双向循环链表等等。

链表是将数据存放在节点,节点之间通过指针串联到一起的一种数据结构,因为这种结构本身就决定了链表的节点在内存中不需要连续的内存空间:

  • 节点在内存中不要求连续空间

  • 节点之间通过指针串联到一起。

这两个特点决定了链表没办法做大快速的访问指定节点,必须作遍历,但是对于删除和插入是一个比较高效的O(1)操作,只需要修改指针就好了。

针对单链表的插入和删除,如果是删除/插入指定节点的下一个节点,那么是个O(1)的操作,但是如果是在当前节点之前插入/删除当前节点,那其实就比较费劲了,必须从头遍历,拿到当前节点的上个节点,才能够实现。所以常见的链表优化:

  • 对于单链表不支持O(1)的随机访问问题

往往搭配一个HashMap,利用HashMap来提供O(1)的随机访问,比如LRU的实现往往会这么实现

  • 单链表中,删除指定节点。

前提:只是关心数据,而不关心节点本身

将下一个节点的数据copy到当前节点,然后删除下一个节点。

如下图:删除c节点。

这种方式的问题是,如果删除的是尾节点,就不好使了,还是必须得从头遍历,拿到倒数第二个节点。不过这个也能解决,就是使用待尾节点哨兵的链表,就不存在这个问题了。

如果删除的是e,那么无非就是将e变成哨兵,然后删除原来的哨兵节点。

  • 对于在指定节点前插入元素

道理是一样的,交换数据后,在指定节点后插入,即将待插入节点和指定节点的数据交换,然后在指定节点后插入:

如果是在头结点插入,那么这里到不用遍历,因为链表是一定会维护一个指针指向头节点的,所以修改头节点指针就好了,但是就需要判断指定节点是不是头结点,如果是对头结点特殊处理。

如果这里加上一个头节点哨兵,那么就不需要这个特殊判断了,逻辑就是统一的。

前提:这个依然是有个前提,链表不care数据的先后,不care节点本身。

哨兵

链表的插入、删除操作,需要对插入第一个结点和删除最后一个结点的情况进行特殊处理

对于指定节点后的插入,需要判断是不是头节点:

public void add(Node pos, node n){
    if(head == null){ // 对头结点进行判空
        head = n;
        return;
    }
    n.next = pos.next;
    pos.next=n;
}

而对于删除指定节点后的节点,则需要对只有头节点的情况特殊处理:

public void delete(Node pos, Node n){
    if(head.next == null){
        head = null;
    }
    pos.next = pos.next.next;
}

不管是插入还是删除,之所以需要特殊处理,就是因为插入前和删除后链表可能为空,需要对插入前和删除后为空的情况特殊处理?

所以解决办法就是,让链表始终不为空,永远都有一个数据为空的的哨兵节点,那么就需要解决这个问题了。

除了使用头头结点哨兵(带头链表),解决指定节点后插入、删除指定节点后节点,其实也可以再给链表尾部增加一个哨兵,来简化一些边界的判断。

链表的常见边界条件如下:

  • 如果链表为空时,代码是否能正常工作?

  • 如果链表只包含一个结点时,代码是否能正常工作?

  • 如果链表只包含两个结点时,代码是否能正常工作?

  • 代码逻辑在处理头结点和尾结点的时候,是否能正常工作

在数据结构中,哨兵元素常常用来简化边界的判断,比如上述的链表、再比如树的层序遍历打印每层需要换行的时候,也可以利用哨兵来判断一层遍历结束了。

常见的链表算法题

求链表的中间结点

  • 如果是数组,就很好办了,直接取中间下标就好了

  • 如果是单链表,借鉴数组的思路,可以两次遍历:第一个遍历获得数组的元素总个数、然后获得中间元素的位置;然后第二次遍历得到中间元素

  • 快慢指正:一个移动两步,一个移动一步。则快的到达末尾,慢的正好到中间位置

这种方式需要分节点个数奇偶:

如果是偶数:

那么最终快节点是指向了null、慢节点执行的是中间节点(靠后的那个)

如果节点个数是奇数:

那么最终快节点指向了尾巴,满节点正好是中间节点。

public static Node findMid(Node head) {
        // 防止fast.next.next的npe
        if (head.next == null) {
            return head;
        }
        Node fast = head;
        Node slow = head;

        // 节点个数为奇数的时候,最终fast指向尾巴,所以fast.next==null
        //节点个数为偶数的时候,最终fast执行了尾巴的下一个节点,所以fast==null
        while (fast != null && fast.next != null) {
            fast = fast.next.next;
            slow = slow.next;
        }
        return slow;
    }

如果是偶数个节点,要求返回的中间那两个的前面一个,那么就再加一个slowPre,记录下来。这样最终就可以根据fast是否是null就能够判断出节点个数的奇偶,然后如果是偶数,则返回slowPre。

    public static Node findMid(Node head) {
        // 防止fast.next.next的npe
        if (head.next == null) {
            return head;
        }
        Node fast = head;
        Node slow = head;
        Node slowPre = null;

        // 节点个数为奇数的时候,最终fast指向尾巴,所以fast.next==null
        //节点个数为偶数的时候,最终fast执行了尾巴的下一个节点,所以fast==null
        while (fast != null && fast.next != null) {
            fast = fast.next.next;
            slowPre = slow;
            slow = slow.next;
        }

        // fast==null说明节点个数是偶数个,所以返回slowPre,即中间两个的前一个
        if (fast == null){
            return slowPre;
        }
        return slow;
    }

寻找倒数第k个节点

  • 同样可以两次遍历:第一次遍历获得链表节点的个数,这样就可以计算出倒数第k个节点的顺序;然后再次遍历就可以得到倒数第k个节点了。

  • 快慢指针:快指针先走k步、然后快慢指正一起走。当快指针遍历结束,那么慢指针指向的就是倒数第k个节点。

public static ListNode findRevertK(ListNode head, int revertPostionK) {
        ListNode fast = head;
        ListNode slow = head;

        // 快指针先走k步骤,注意:这么写k就是从1开始的。如果是i<=revertPostionK,那么k就是从0开始的
        for (int i = 0; i < revertPostionK; i++) {
            if (fast == null) {
                return null;
            }
            fast = fast.next;
        }

        // 快慢指针一起走
        while (fast != null) {
            fast = fast.next;
            slow = slow.next;
        }

        return slow;
    }

这里需要注意的就两点:

  1. 当节点个数不足k个的时候,那么在快指针先走k步的时候需要处理这种情况

  1. k的其实是从0还是、还是从1开始。那么在快指针先走k补的时候,结束条件是不一样的。

删除链表倒数第n个结点

这个其实和寻找倒数第k个节点是一样的,无非就是单链表删除指定节点需要拿到前驱,所以这个问题就转换为寻找倒数第n-1个节点,然后删除后继。

链表中环的检测

创建两个指针一个快指针一次走两步一个慢指针一次走一步,若相遇则有环,若先指向null则无环

public static boolean hasCycle(Node head){
        if (head==null){
            return false;
        }
        Node slow = head;
        tNode fast = head.next;// 注意初始值,别搞成两个就相等了
        while (fast != null){
            if (fast == slow){
                return true;
            }
            fast = fast.next.next;
            slow = slow.next;
        }
        return false;
    }

两个有序的链表合并

public static ListNode mergeSortedList(Node ascSortedListHead1, Node ascSortedListHead2) {
        if (ascSortedListHead1 == null) {
            return ascSortedListHead2;
        }
        if (ascSortedListHead2 == null) {
            return ascSortedListHead1;
        }

        Node list1Cur = ascSortedListHead1;
        Node list2Cur = ascSortedListHead2;

        Node mergedHead = new Node();//加个哨兵方便处理,返回的时候返回哨兵的next就好
        Node mergedCur = mergedHead;

        while (true) {
            // 一个
            if (list1Cur == null) {
                mergedCur.next = list2Cur;
                return mergedHead.next;
            }
            if (list2Cur == null) {
                mergedCur.next = list1Cur;
                return mergedHead.next;
            }
            // 比较待合并链表,将较小的加入到合并后链表
            if (list1Cur.data > list2Cur.data) {
                mergedCur.next = list2Cur;
                list2Cur = list2Cur.next;
            } else {
                mergedCur.next = list1Cur;
                list1Cur = list1Cur.next;
            }
            mergedCur = mergedCur.next;
        }
    }

只是需要注意两点:

  1. 边界的判断:有链表为空的情况、一个链表的所有元素都大于另一个

  1. 合并后的链表,增加一个哨兵元素,可以方便处理

  1. 这里是两个链表的合并,所以直接比较链表头,一个比较操作就能获得哪个大哪个小。如果是n个有序链表合并呢?那其实就需要在n个当中找到最值,在n个元素中找到最值的朴素方法是一个O(n)操作,但是如果引入一个堆,那么就可以实现O(logn)在n个元素找那个找到最值。

删除有序的链表的重复元素

 public static Node deleteDuplicatedInSortedList(Node head) {
        if (head == null || head.next == null) {
            return head;
        }
        Node cur = head;
        Node next = head.next;
        while (next != null) {
            // 这里一定不能是if,一定要while循环,等到pre和cur都不相等了,才能两个指针一起往下走
            // 这里一定要注意所有元素都相等的时候,next最终会指向null
            while (next != null && cur.data.equals(next.data)) {
                next = next.next;
                deleteNext(cur);
            }

            // 全部元素都相等的情况下,next就会指向null,这个时候链表里只剩一个元素
            if (next == null) {
                return head;
            }
            cur = cur.next;

            next = next.next;
        }

        return head;
    }

    // 删除指定节点的后继
    private static void deleteNext(Node cur) {
        if (cur.next != null) {
            cur.next = cur.next.next;
        }
    }

思路其实很简单,就是挨着的两个指针,同步往下走,如果相等了,就删除快的那个,有两点需要注意:

  1. 遇到相等的,删除后继,next指向了下一个,应该继续判断,直到两者不等,才能两个同步王下走

  1. 注意所有元素都一样的npe的判断。

链表反转

 public static ListNode revertList(Node head) {
        if (head == null || head.next == null) {
            return head;
        }
        
        // 只有两个节点,直接反转
        if (head.next.next == null) {
            ListNode tail = head.next;
            tail.next = head;
            return tail;
        }

        Node pre = head;
        Node cur = head.next;
        Node next = head.next.next;
        head.next = null;// 处理原来的头结点,没有这句,那么反转后的链表在尾部就会有个环
        while (next != null) {
            cur.next = pre;
            pre = cur;
            cur = next;
            next = next.next;
        }

        // next为null的时候,cur指向了尾节点;但是cur的next还是指向null的,所以一定要执行一次这个
        cur.next = pre;
        return cur;
    }

这个其实是个细活,本身不是很难,但是一次性写对不容易,主要的问题就是容易造成断裂,核心思路:

  1. 三个指正:pre、cur、next。pre、next用来交换指正执行;next的作用就是cur.next=pre后,通过cur就再也找不到cur原来的后继了,所以需要next记录下来

  1. 头结点的处理,如果不处理头结点,就会造成有环。

  1. 最后一步很关键,容易忽略,进而导致中间断裂了。

完整过程:

  • 判断回文

  • 如果是数组、双向链表,就非常好办了。收尾指针法一次遍历就非常方便判断是否是回文。

  • 如果是单链表

  • 比价容易想到的是一个空间复杂度为O(n)的方式:遍历一遍链表,一次压栈;然后再次遍历链表依次弹栈并好栈顶元素比较,也能很方便的判断回文。

  • 骚操作的方式:

  1. 找到链表中间节点

  1. 将中间节点钱不部分(或者后半部分反转)

  1. 然后双指正:一个指向前半部分头结点、一个执行中间节点(反转后的后半部分头结点),然后开始比较判断回文

  1. 后半部分再次反转还原。

跳跃表

有序数组的查找,可以使用二分算法来实现log(n)的高效快速查找,但是我们也说了二分查找是依赖于底层数据结构的高效是随机访问的,所以二分查找就只能使用在有序数组中。数组要求连续内存来存放元素,这即是它能够高效O(1)随机访问的前提,但同时也制约了数据的一些使用场景:比如任意位置的插入、删除比较低效、不能利用随便内存等,而这些缺点又正好是链表的优点。

那如果是链表,怎么才能做到和二分查找一样的速度呢?

答案就是跳跃表。所以跳跃表其实就是在链表之上构建n级索引的一个结构,通过这n级索引,也可以实现O(log(n))的快速查找

对于普通链表:

如果要在这个链表里查找16,那么就只能顺序遍历,一共需要遍历16个节点。

那么如果我们按照每间隔两个节点构造一级索引,如下:

查找过程就变成了:

  1. 在一级索引遍历查找:1->4->7->9->13->17,遍历到17时发现17已经大于16了,说明目标节点肯定在13和16之间,所以去下层遍历

  1. 在下一层去遍历查找:->16,就找到了目标数据

所以会发现,一共就只需要遍历10个节点。ps:注意一级索引其实只是一个指针,它指向的就是实际的数据节点,可以直接通过指针就能获取到16。

如果在一级所以上再构造3二级索引呢?,就变成了如下:

查找过程就变成了:

  • 在第一级索引中遍历查找:1->7->13,发现16大于13,则继续到下一层去照抄

  • 在第二级索引中遍历查找:->17,发现16是小于17的,说明目标节点在13和17之间

  • 在下一层去遍历查找:->16,获取到目标数据。

会发现加了两级索引过后,总共就只需要遍历5个节点了。

如果我们要查找的数据是13,那么普通链表需要遍历9个节点、加了一级索引需要5个、加了二级索引只需要3个(即目标数据在索引中的情况)

其实还可以继续创建第三层索引:

稍微变换一下指针,就会发现,其实就是一个树状结构,而且搜索过程都跟B+树的搜索是类似的:

所以说,其实跳跃表的搜索其实是可以做到log(n)的,只是说它要占用更多的空间,来构建索引。

当构建索引的时候,如果是间隔两个元素构建一个索引,其实会发现,每多构建一级索引,查找扫描的元素就会少一半(之所以是这个规律,其本质其实就是因为索引是间隔两个),这个效率其实就是二分法查找的效率。将索引变成一个树结构的时候,其实就是一个类似的2-3树。

所以在构建跳跃表索引的时候,需要注意两个参数:

  1. 每隔多少个元素构建一个索引

  1. 构建多少层索引

这两个参数决定了效率和空间的一个权衡。

高效的O(logN)的插入/删除

在数组指定的节点后插入一个节点/删除后继,是O(1),但是要找到这个节点确实一个O(N)的操作。但是如果构建了n级索引后的跳跃表,那么找到一个节点就变成了O(logN)了,所以插入节点也就是个O(logN)操作了。

但是如果只是意味的插入,不去更新索引,那么最终其实就会机会退化成链表的效率了,所以在插入/删除节点后,都需要去修改索引。这个时候对索引的修改其实并不一定要严格按照每间隔k个元素构建,然后每一层索引都去修改,只要从整体上保持大致的均匀就够了。所以往往是使用一个随机函数,来决定修改哪一层/几层索引中。注意的是这个随机函数的选择还是有讲究,从概率上来讲,要能够保证跳表的索引大小和数据大小平衡性,不至于性能过度退化。

其实想红黑树这种平衡的树结构,也能够做到O(logN)的查找、插入、删除。但是跳跃表有一个不可拒绝的特点,那就是范围查找,这个是红黑树做不到的。并且相比于红黑树,跳跃表的实现相对来说要简单一些,所以在默写时候,跳跃表是可以替换红黑树的。

队列

实现队列的最关键点:确定好队空和队满的判定条件。

不管是用数组还是链表实现,不管是实现非循环队列、还是循环队列,队满和队空的判断正确了,基本上也就正确了一大半了。

比如:使用数组实现的循环队列的队满判断:(tail+1)%n=head

在数据结构上,队列没有太多可以来说来聊的,因为在数据结构这个范畴,队列其实是比较简单的。但实际生产中,队列的使用场景非常多。比如jdk里线程池中工作队列使用的阻塞队列、schedule使用的优先级队列、生产者-消费者模型中依赖的队列(kafka等这类消息中间件,其最根本上的也是队列)、而消息队列的内存版本Disruptor(高性能的内存队列)也被广泛使用,比如log4j的实现中,logger其实就是丢了一条消息到Disruptor、然后appender去队列中消费输出到不同的目的的。

栈这个结构在应用开发的编程中,其实存在感是比较弱的,而且在和队列一样,在数据结构这个范畴,讨论队列也不是很多,其本质原因就是栈这个结构是比较简单的。从jdk提供的默认这些集合类的实现上,会发现,队列、栈都不会有专门的实现,只是在底层的LinkedList等结构上,叠加了队列、栈的操作,从而提供队列、栈的服务。

而栈的应用真正发挥其价值的地方在一些工具类软件中,使用的特别的多,甚至在程序的底层运行机制上,栈这个结构被广泛使用。

  • 方法调用:用来存储函数调用时的临时变量

  • 表达式运算:一个保存操作数的栈,另一个是保存运算符的栈

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

  • 撤销和重做:两个栈很好解决。一个栈压入正常操作(用于撤销)、撤销操作压入另一个栈(用于重做)

用数组实现的栈叫作顺序栈,用链表实现的栈叫作链式栈

推的特点:

  • 完全二叉树;

  • 堆中每一个节点的值都必须大于等于(或小于等于)其子树中每个节点的值。ps:二叉排序树要求大于左子树、小于右子树。堆只是要求大于等于(或者小于等于)其子树节点,对左右子树没有限制

数据结构的堆和内存管理中堆/栈的区别:

  1. 内存管理中,堆和栈强调的是数据的声明周期、内存的分配/释放是否有顺序要求等

  1. 数据结构上强调的是数据的逻辑组织形式,这种组织方式对外提供的服务满足一定的要求:

  1. 栈对外只是提供入栈和出栈服务,且满足FIFO

  1. 堆对外提供向堆添加元素、和从堆顶取出元素,但是始终保持堆顶是最大/最小值

堆的应用

首先根据堆顶元素的情况可以分为:

  • 大顶堆:堆顶元素始终是最大值

  • 小顶堆:堆顶元素始终是最小值

正式由于堆的这个特性,所以堆天生就是满足优先队列的特性,只不过保持堆的特性的时候,比较的时候是用优先级来比较的

  • 出队:就是从堆顶弹出一个元素。因为堆顶始终是最大值/最小值,所以弹出来肯定是优先级最大/最小的元素

  • 入队:就是往堆里插入一个元素,插入后依然保持堆的特点:堆顶是最大值/最小值

优先队列的应用场景

合并有序小文件

如果是合并两个,那么始终直接比较两个文件头部的元素,就能得到相对最小的值;那如果是多个文件,那就需要取这多个元素的最小值/最大值。所以问题就转换成了从这n个文件中各取得一个元素中寻找最值,如果将从n个文件各取的一个元素放到数组里,在数组里寻找最值,那么一次寻找最值就是个O(n)的操作,这里如果是放到堆里,那么插入堆和从堆中取最值,都是O(logn)的操作,从而提交效率

高性能的任务调度

按照任务设定的执行时间进行排序,放到优先队列中,队列首部(即小顶堆的堆顶)就是接下来应该最先执行的任务。

这样,就不需要周期的去扫描全量任务列表了,只需要去看队头(堆顶)的任务就好了

LRU

如果堆里的比较是按照元素的访问时间,那堆天生就是个LRU。

topK

直接维护一个包含k个元素的堆,然后遍历一遍所有元素:

  • 当堆中的元素小于k个时,直接进入堆

  • 当堆中的元素个数大于k时

  • 当遍历的元素 小于 堆顶元素的时候,忽略

  • 当遍历的元素 大于 堆顶元素的时候,则堆顶元素弹出,将遍历的元素放到堆中

遍历结束后,堆中的元素就是topK。遍历是O(n)操作,弹出堆顶元素、向堆中插入元素都是O(logk),所以整体的时间复杂度就是O(nlogK)。

这这种方式对动态数据是比较友好的,只要数据变了,O(logK)就能保持堆中始终保持的就是topk

ps:这个其实跟小文件合并的本质是一样的,每次需要从k个元素中找到最值,如果是用数组,那么其实需要遍历才能找到最大值,所以转而使用堆。不过这个使用数组其实也可以,无外乎是在数组之外,需要维护一个最大值的索引,这样也能实现,而且效率其实比堆还高。空间复杂度也是o(1),因为只是增加了一个变量来记录数组中的最大值索引。只是有一个问题:数组中K个元素是无序的,如果需要按顺序去除这k个元素,就需要再次排序,而堆的好处就是不需要再次排序了

而对于有序小文件合并,其实道理也是一样的,同样可以用数组+最值索引的方式来替代堆。只是到最后的时候,需要对数组元素进行一次排序。

求中位数、tp90、tp99等

对于一个有序的数组,中位数就是中间小标位置的那个数,如果数组元素个数是奇数,则中位数就是index=n/2+1位置的那个数;如果数组个数是偶数,中间位置就有两个:n/2和n/2+1位置。

所以如果是一个静态数组,那其实可以先排序、然后再取固定位置的数就好了,排序代价再大,也只是排一次,所以是比较OK的。

麻烦的是动态数据,如果每次取中位数,都需要一次排序的话,那代价相对比较大,目前最高效的排序算法也是O(nlogn)。

如果使用堆,就可以做到O(logn)的效率,获取到中位数。办法就是:维护两个堆,一个大顶堆,一个小顶堆。大顶堆中存储前半部分数据,小顶堆中存储后半部分数据,且小顶堆中的数据都大于大顶堆中的数据。

  • 新加入的数据小于等于大顶堆的堆顶元素,我们就将这个新数据插入到大顶堆;否则,我们就将这个新数据插入到小顶堆

  • 如果仅仅是这个规则,则不满足前半部分在大顶堆,后半部分在小顶堆。所以还需要调整,保证大顶堆中的元素最多只比小顶堆中多一个,如果不符合就将堆顶元素从一个堆移动到另外一个堆中

这样就能保证,数组排序后的前半部分放在了大顶堆中;后半部分放在了小顶堆中。那么堆顶就是中位数(如果元素个数是技术,大顶堆堆顶就是中位数;如果元素个数为偶数,大顶堆和小顶堆都是中位数)

tp99、tp999等

所谓的中位数,其实就是大于前面的50%,小于后面的50%。其实就是tp50的分界点。在性能监控中,我们老说的tp90、tp99、t9999,其实就是有一个分界点,大于前面90%、小于后面的10%。

所以这种也是可以用两个堆:一个大顶堆和一个小顶堆来实现动态实时求tp9、tp99、t999等的。

其方法也是:

  • 新加入的数据小于等于大顶堆的堆顶元素,我们就将这个新数据插入到大顶堆;否则,我们就将这个新数据插入到小顶堆

  • 保证大顶堆中的元素个数占整个元素的90%、小顶堆占10%,即大顶堆中元素:小顶堆元素=9:1

这样,大顶堆中就是tp90了。

总结:

  1. 合并k个有序小文件、topK问题,其实本质就是要多次查找k个元素的最值。数组中查找最值最快也是O(n),所以借助堆的特性:堆顶就是最值,从而实现O(logn)的下利率

ps:如前述,使用数组+记录最值,其实效率也是刚刚的

  1. 中位数、tp90、tp99问题,其实都寻找有序后的某个分界点的问题。利用一个大顶堆和小顶堆来将数据进行拆分,则两个堆顶就直接是分界点。

排序

评价一个排序算法常用的三个指标:

  1. 时间复杂度:用时间复杂度来评价排序算法的时候,往往是看比较和移动(交换)的次数

  1. 空间复杂度

  1. 稳定性:前两个好理解,任何一个算法的评价几乎都会从这两方面来考虑。但是稳定性是评价排序算法特有的指标。

排序前,红色的4在绿色的4前面

如果排序后,依然保持这两个4之间的先后关系,那么算法就是稳定的,即排序后:

但如果排序后两个4之间的位置交换了,那排序算法就是不稳定的,即排序后:

这么将排序算法的稳定性,体感不是很明显,仅从数组的排序上好像没什么区别,值都是一样的,哪个在前哪个在后没有任何影响。考虑下真正生产中会使用的场景,如下表,按年龄排序,分批取数据,每批取2条:

select * from user where age=23 order by age limit 1,2;

select * from user where age=23 order by age limit 2,2;

select * from user where age=23 order by age limit 3,2;

这么写不一定能取出所有age=23的数据,就是因为mysql的排序算法的不稳定性,不同批次可能会取出的数据有交叉,从而导致问题。

所以mysql的best practice中的一条就是:排序加上唯一键,来规避这个问题:

比如select * from user where age=23 order by age,id

基础排序

冒泡排序

  • 相邻元素两两比较,将较大的元素交换到后面,这样一轮比较后,最后一个元素就是最大的了

  • 经历n轮过后整个数组就是有序的了。

    public static void bubbleAscSort(int[] arr) {
        // 这里的arr.length可以用一个变量存下来,否则每一次循环都会执行一次arr.lenth,
        // 这里是数组还好,因为数组的长度是直接记录在了对象头,不涉及方法调用,但如果是方法调用,那么这里一定不要写在循环里
        for (int i = 0; i < arr.length; i++) {// 这个循环就是执行n轮相邻元素的两两比较
            for (int j = 0; j < arr.length - 1 - i; j++) {// 这个循环就是一轮相邻元素的两两比较
                if (arr[j] > arr[j + 1]) {// 相邻两元素比较:如果第一个比第二个大、就交换。这里只能是大才能保证稳定、如果是大于等于,那就不稳定了
                    swap(arr, j, j + 1);
                }
            }
        }
    }

插入排序

基本思想就是往一个有序的数组中插入元素,插入的时候保持有序。所以初始的时候有序数组只有第一个元素、然后将第二个元素插入到有序数组中,插入的时候通过移动保持有序,依次类推。这样经历n轮插入过后,数组变得有序。

选择排序

锚定一个位置,比如数组最后一个位置,然后从所有元素中找到最大值、和锚定的这个位置交换;然后锚定位置往前挪一个,继续在前面生效的数据中找最大值并进行交换,经历n轮这种找最大值并交换后,整个数组变成有序。

其实会发现插入排序和选择排序的思路是一致的:都是将元素组分成了有序部分和没有排序部分,依次将没有排序的部分移动到有序部分,当没有排序的部分为空的时候,整个数组变成有序的。

    public static void selectAscSort(int[] arr) {
        for (int i = 0; i < arr.length; i++) {// n轮寻找最小值,和锚定位置交换

            int minIndex = findMin(arr, i);// 寻找指定范围的最小值
            if (arr[i] > arr[minIndex]) {// 如果锚定位置比后面的最小值海啸,则交换
                swap(arr, i, minIndex);
            }
        }
    }

    private static int findMin(int[] arr, int startPos) {
        int minIndex = startPos;
        for (int i = startPos; i < arr.length; i++) {
            if (arr[i] < arr[minIndex]) {// 这里可以用变量记录下arr[minIndex],就不用每次都去随机访问下标元素值了
                minIndex = i;
            }
        }
        return minIndex;
    }

线性排序

所谓的线性排序就是排序算法的时间复杂度O(n)

桶排序

核心思想:

  • 将要排序的数据分到几个有序的桶里,每个桶里的数据再单独进行排序。

  • 桶内排完序之后,再把每个桶里的数据按照顺序依次取出,合并成最终有序的

所以桶排序实现的三个步骤:

  1. 分桶:如果按照单一维度分桶导致不均匀,可以进行二次、三次甚至更多次的分桶

  1. 桶内排序

  1. 聚合:桶见是有序的,按照桶的序号遍历,得到的就是有序的数据了

桶排序对数据的要求:

  • 数据能够比较明显的划分到m个桶,划分的各个桶之间有着天然的大小顺序(不同桶之间不能有数据顺序上的交叉),只有这样每个桶内的数据都排序完之后,桶与桶之间的数据不需要再进行排序

  • 数据在各个桶之间的分布是比较均匀的。如果数据经过桶的划分之后,有些桶里的数据非常多,有些非常少,那桶内数据排序的时间复杂度就不是常量级了。在极端情况下,如果数据都被划分到一个桶里,其实就是不分桶

桶排序比较适合用在外部排序中。

不管是冒泡、插入、选择、快速排序这些都是内存排序算法,即要将所有的数据都加载到内存后,在内存中进行排序,如果待排序数据超过了内存的限制,又要对数据进行排序,这个时候就要借助磁盘。桶排序就比较适合磁盘排序,即每个桶就是一个磁盘文件,桶内排序使用内存排序,所以在分桶的时候,就会切分到每个桶内的数据都在内存限制之内。所以当一个维度分桶导致数据不匀均的时候,可以继续进行拆分分桶

计数排序

计数排序是桶排序的一种特殊情况,普通的桶排序,每个桶里装的是数据,然后各个桶内需要进行排序。但是有些特殊情况,如果桶里放到是同一个数据,甚至只是某个数据的技术的时候,那么就不需要桶内进行排序了。而计数排序描述的就是这种情况,所以计数排序对数据有一定的要求:

  1. 数据范围不能是很大。范围内的每个数据独占一个桶,桶内是这个数据出现的次数。比如年龄、分数等这种有明确数据范围的数据排序。

  1. 计数排序的数据需要是非负整数。这个限制是有因为桶往往是用数组来实现,数组下标用来表示桶的序号、同时也是数据的范围。如果说数据范围存在其他类型数据,那么就需要在不改变相对大小的情况下,转换成非负整数。

 private static int[] countAscSort(int[] arr) {
        // 假设数据的范围就是0~99,如果说数据范围确定有限,但不知道最大值,可以先找出arr的最大值。
        int[] bucket = new int[100];

        // 桶的下标i,表示的是i在原数组arr中出现的次数
        for (int i = 0; i < arr.length; i++) {
            bucket[arr[i]]++;
        }

        // 遍历桶,就能获得排序后的数组了
        int index = 0;
        int[] sortedArr = new int[arr.length];
        for (int bucketIndex = 0; bucketIndex < bucket.length; bucketIndex++) {
            int count = bucket[bucketIndex];
            for (int j = 0; j < count; j++) {// 桶未上的技术不为0,说明对应下标的数据在原数组中出现了k次,所以循环k次填充到排序数组中就好了
                sortedArr[index] = bucketIndex;
                index++;
            }
        }
        // 这里是没改变原数组的数据的,如果要将原数组排序,那么这里只需要深拷贝一次就好了。
        // 注意java方法调用是引用传递,直接arr=sortedArr改变的只是局部变量arr的指向,并没有改变原数组
        return sortedArr;
    }

基数排序

在数据比较大小的时候,如果两个数中,一个数的高位更大,那么这个数一定就更大,就不用继续比较低位了,即只有高位相同,才需要继续比较低位。借助这个思路,也可以按照位来进行排序:高位大的肯定排在高位小的前面

基数排序就是利用了这个思路的一种排序算法,它的基本步骤就是:从低位到高位,依次按位进行排序,当最高位也排序完成的时候,数据就是有序的了。

基数排序对要排序的数据的限制:

  • 需要可以分割出独立的“位”来比较,而且位之间有递进的关系,如果a数据的高位比b数据大,那剩下的低位就不用比较了

  • 每一位的数据范围不能太大,要可以用线性排序算法来排序,否则,基数排序的时间复杂度就无法做到O(n)了

  • 按照每位来排序的排序算法要是稳定的,否则这个实现思路就是不正确的。因为如果是非稳定排序算法,那最后一次排序只会考虑最高位的大小顺序,完全不管其他位的大小关系,那么低位的排序就完全没有意义了。

  • 所有数据是等长的,比如电话号码、身份证号等,如果出现了不等长的,可以使用不影响排序的字符补齐高位(即用比可能出现在所有字符中还要小的字符补齐)

快速排序

基础排序算法中冒泡、选择、插入排序,其时间复杂度都是O(n^2)的,对于数据量小的场景,其实是比较不错的。而线性排序其实都是对特定的场景排序有比较高的效率(都是O(n)的效率,但空间复杂度都是O(n),做不到原地排序)。

那么对于追求效率的通用排序算法的场景,出现了一些快速排序算法,他们都能够实O(nlogn)的时间复杂度。

归并排序

先把数组从中间分成前后两部分,然后对前后两部分分别排序,再将排好序的两部分合并在一起,这样整个数组就都有序了。

快速排序

归并排序和快速排序,本质上是利用的一种分治的思想来进行排序。目前我看到的所有介绍这两种排序都是从排序步骤上来介绍,然后根据步骤来分析器效率和稳定性,这种步骤网上到处都是。目前我没有更多的简单直接直观的方式来描述这两个排序算法怎么就做到了O(nlog(n))的效率的,以及根据这个思路不用去记住代码就可以很快理解并写出来。所以也就不重复了。后续如果有更好的思路,再补充。

分治是一种解决问题的处理思想,递归是一种编程技巧

堆排序

堆排序其实利用的就是堆的操作是O(logn),然后需要n次堆操作后,就可以对数组进行排序了。从而实现O(nlogn)的高效排序:

  1. 将数组构建成一个堆

  1. 然后依次从堆顶元素删除构建成数组,结果数组就是有序的。

这么描述起来,好像堆排序的空间复杂度不是O(1),不是个原地排序算法。但实际上,在实际实现的时候,其实是在原数组中去构建堆,然后从堆顶取出元素后,也是放在原数组的,所以其实堆排序可以做到原地排序的(其本质就是堆是个完全二叉树,可以通过下标计算得到其左右孩子)

其他数据结构

其实除了这些基础数据结构,还有一些更高级数据结构,只是说要么这些数据结构更加复杂,比如图结构、平衡树等等,因为复杂,所以在日常场景中(比如面试)就讨论的比较少,但并不代表其不重要。

另外就是一些数据结构是服务于特定场景的,比如倒排索引,主要是用在全文搜索的索引上;B+树当前主要应用基于磁盘的数据库上等等。这里就不对这些进行展开了

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值