数据结构面试题(史上最全面试题,精心整理100家互联网企业,面试必过)

最全面试题,精心整理100家互联网企业面经,祝你面试成功。面试必过(2023优化版)已发布在个人微信公众号【面向Offer学编程】,优化版首先修正了读者反馈的部分答案存在的错误,同时根据最新面试总结,删除了低频问题,添加了一些常见面试题,对文章进行了精简优化,欢迎大家关注。

面试题链接
java基础面试题https://blog.csdn.net/Lycodeboy/article/details/124882642
Java集合容器面试题https://blog.csdn.net/Lycodeboy/article/details/125314255
并发编程面试题https://blog.csdn.net/Lycodeboy/article/details/125314304
Jvm面试题https://blog.csdn.net/Lycodeboy/article/details/125315760
计算机网络面试题https://blog.csdn.net/Lycodeboy/article/details/125373154
操作系统面试题https://blog.csdn.net/Lycodeboy/article/details/125373530
数据结构面试题https://blog.csdn.net/Lycodeboy/article/details/125373608
Spring、Spring MVC、Spring boot、Spring Cloud面试题https://blog.csdn.net/Lycodeboy/article/details/125376943
mysql面试题https://blog.csdn.net/Lycodeboy/article/details/125376977
redis面试题https://blog.csdn.net/Lycodeboy/article/details/125377192
MyBatis面试题https://blog.csdn.net/Lycodeboy/article/details/125377248
Linux 面试题https://blog.csdn.net/Lycodeboy/article/details/125377303
MongoDB面试题https://blog.csdn.net/Lycodeboy/article/details/125377452
MQ、RabbitMQ面试题https://blog.csdn.net/Lycodeboy/article/details/125377539

文章目录

什么是 AVL 树?

AVL 树是平衡二叉查找树,增加和删除节点后通过树形旋转重新达到平衡。右旋是以某个节点为中心,将它沉入当前右子节点的位置,而让当前的左子节点作为新树的根节点,也称为顺时针旋转。同理左旋是以某个节点为中心,将它沉入当前左子节点的位置,而让当前的右子节点作为新树的根节点,也称为逆时针旋转。

什么是红黑树?

红黑树是 1972 年发明的,称为对称二叉 B 树,1978 年正式命名红黑树。主要特征是在每个节点上增加一个属性表示节点颜色,可以红色或黑色。红黑树和 AVL 树类似,都是在进行插入和删除时通过旋转保持自身平衡,从而获得较高的查找性能。与 AVL 树相比,红黑树不追求所有递归子树的高度差不超过 1,保证从根节点到叶尾的最长路径不超过最短路径的 2 倍,所以最差时间复杂度是 O(logn)。红黑树通过重新着色和左右旋转,更加高效地完成了插入和删除之后的自平衡调整。

红黑树在本质上还是二叉查找树,它额外引入了 5 个约束条件:
① 节点只能是红色或黑色。
② 根节点必须是黑色。
③ 所有 NIL 节点都是黑色的。
④ 一条路径上不能出现相邻的两个红色节点。
⑤ 在任何递归子树中,根节点到叶子节点的所有路径上包含相同数目的黑色节点。

这五个约束条件保证了红黑树的新增、删除、查找的最坏时间复杂度均为 O(logn)。如果一个树的左子节点或右子节点不存在,则均认定为黑色。红黑树的任何旋转在 3 次之内均可完成。

AVL 树和红黑树的区别?

红黑树的平衡性不如 AVL 树,它维持的只是一种大致的平衡,不严格保证左右子树的高度差不超过 1。这导致节点数相同的情况下,红黑树的高度可能更高,也就是说平均查找次数会高于相同情况的 AVL 树。

在插入时,红黑树和 AVL 树都能在至多两次旋转内恢复平衡,在删除时由于红黑树只追求大致平衡,因此红黑树至多三次旋转可以恢复平衡,而 AVL 树最多需要 O(logn) 次。AVL 树在插入和删除时,将向上回溯确定是否需要旋转,这个回溯的时间成本最差为 O(logn),而红黑树每次向上回溯的步长为 2,回溯成本低。因此面对频繁地插入与删除红黑树更加合适。

B 树和B+ 树的区别?

B 树中每个节点同时存储 key 和 data,而 B+ 树中只有叶子节点才存储 data,非叶子节点只存储 key。InnoDB 对 B+ 树进行了优化,在每个叶子节点上增加了一个指向相邻叶子节点的链表指针,形成了带有顺序指针的 B+ 树,提高区间访问的性能。

B+ 树的优点在于:
① 由于 B+ 树在非叶子节点上不含数据信息,因此在内存页中能够存放更多的 key,数据存放得更加紧密,具有更好的空间利用率,访问叶子节点上关联的数据也具有更好的缓存命中率。
② B+树的叶子结点都是相连的,因此对整棵树的遍历只需要一次线性遍历叶子节点即可。而 B 树则需要进行每一层的递归遍历,相邻的元素可能在内存中不相邻,所以缓存命中性没有 B+树好。但是 B 树也有优点,由于每个节点都包含 key 和 value,因此经常访问的元素可能离根节点更近,访问也更迅速。

排序有哪些分类?

排序可以分为内部排序和外部排序,在内存中进行的称为内部排序,当数据量很大时无法全部拷贝到内存需要使用外存,称为外部排序。

内部排序包括比较排序和非比较排序,比较排序包括插入/选择/交换/归并排序,非比较排序包括计数/基数/桶排序。

插入排序包括直接插入/希尔排序,选择排序包括直接选择/堆排序,交换排序包括冒泡/快速排序。

直接插入排序的原理?

稳定,平均/最差时间复杂度 O(n²),元素基本有序时最好时间复杂度 O(n),空间复杂度 O(1)。

每一趟将一个待排序记录按其关键字的大小插入到已排好序的一组记录的适当位置上,直到所有待排序记录全部插入为止。

public void insertionSort(int[] nums) {
    for (int i = 1; i < nums.length; i++) {
        int insertNum = nums[i];
        int insertIndex;
        for (insertIndex = i - 1; insertIndex >= 0 && nums[insertIndex] > insertNum; insertIndex--) {
            nums[insertIndex + 1] = nums[insertIndex];
        }
        nums[insertIndex + 1] = insertNum;
    }
}COPY

直接插入没有利用到要插入的序列已有序的特点,插入第 i 个元素时可以通过二分查找找到插入位置 insertIndex,再把 i~insertIndex 之间的所有元素后移一位,把第 i 个元素放在插入位置上。

public void binaryInsertionSort(int[] nums) {
    for (int i = 1; i < nums.length; i++) {
        int insertNum = nums[i];
        int insertIndex = -1;
        int start = 0;
        int end = i - 1;
        while (start <= end) {
            int mid = start + (end - start) / 2;
            if (insertNum > nums[mid])
                start = mid + 1;
            else if (insertNum < nums[mid])
                end = mid - 1;
            else {
                insertIndex = mid + 1;
                break;
            }
        }
        if (insertIndex == -1)
            insertIndex = start;
        if (i - insertIndex >= 0)
            System.arraycopy(nums, insertIndex, nums, insertIndex + 1, i - insertIndex);
        nums[insertIndex] = insertNum;
    }
}
希尔排序的原理?

又称缩小增量排序,是对直接插入排序的改进,不稳定,平均时间复杂度 O(n1.3),最差时间复杂度 O(n²),最好时间复杂度 O(n),空间复杂度 O(1)。

把记录按下标的一定增量分组,对每组进行直接插入排序,每次排序后减小增量,当增量减至 1 时排序完毕。

public void shellSort(int[] nums) {
    for (int d = nums.length / 2; d > 0 ; d /= 2) {
        for (int i = d; i < nums.length; i++) {
            int insertNum = nums[i];
            int insertIndex;
            for (insertIndex = i - d; insertIndex >= 0 && nums[insertIndex] > insertNum; insertIndex -= d) {
                nums[insertIndex + d] = nums[insertIndex];
            }
            nums[insertIndex + d] = insertNum;
        }
    }
}
直接选择排序的原理?

不稳定,时间复杂度 O(n²),空间复杂度 O(1)。

每次在未排序序列中找到最小元素,和未排序序列的第一个元素交换位置,再在剩余未排序序列中重复该操作直到所有元素排序完毕。

public void selectSort(int[] nums) {
    int minIndex;
    for (int index = 0; index < nums.length - 1; index++){
        minIndex = index;
        for (int i = index + 1;i < nums.length; i++){
            if(nums[i] < nums[minIndex]) 
                minIndex = i;
        }
        if (index != minIndex){
            swap(nums, index, minIndex);
        }
    }
}
堆排序的原理?

是对直接选择排序的改进,不稳定,时间复杂度 O(nlogn),空间复杂度 O(1)。

将待排序记录看作完全二叉树,可以建立大根堆或小根堆,大根堆中每个节点的值都不小于它的子节点值,小根堆中每个节点的值都不大于它的子节点值。

以大根堆为例,在建堆时首先将最后一个节点作为当前节点,如果当前节点存在父节点且值大于父节点,就将当前节点和父节点交换。在移除时首先暂存根节点的值,然后用最后一个节点代替根节点并作为当前节点,如果当前节点存在子节点且值小于子节点,就将其与值较大的子节点进行交换,调整完堆后返回暂存的值。

public void add(int[] nums, int i, int num){
    nums[i] = num;
    int curIndex = i;
    while (curIndex > 0) {
        int parentIndex = (curIndex - 1) / 2;
        if (nums[parentIndex] < nums[curIndex]) 
            swap(nums, parentIndex, curIndex);
        else break;
        curIndex = parentIndex;
    }
}

public int remove(int[] nums, int size){
    int result = nums[0];
    nums[0] = nums[size - 1];
    int curIndex = 0;
    while (true) {
        int leftIndex = curIndex * 2 + 1;
        int rightIndex = curIndex * 2 + 2;
        if (leftIndex >= size) break;
        int maxIndex = leftIndex;
        if (rightIndex < size && nums[maxIndex] < nums[rightIndex])
            maxIndex = rightIndex;
        if (nums[curIndex] < nums[maxIndex])
            swap(nums, curIndex, maxIndex);
        else break;
        curIndex = maxIndex;
    }
    return result;
}
冒泡排序的原理?

稳定,平均/最坏时间复杂度 O(n²),元素基本有序时最好时间复杂度 O(n),空间复杂度 O(1)。

比较相邻的元素,如果第一个比第二个大就进行交换,对每一对相邻元素做同样的工作,从开始第一对到结尾的最后一对,每一轮排序后末尾元素都是有序的,针对 n 个元素重复以上步骤 n -1 次排序完毕。

public void bubbleSort(int[] nums) {
    for (int i = 0; i < nums.length - 1; i++) {
        for (int index = 0; index < nums.length - 1 - i; index++) {
            if (nums[index] > nums[index + 1]) 
                swap(nums, index, index + 1)
        }
    }
}

当序列已经有序时仍会进行不必要的比较,可以设置一个标志记录是否有元素交换,如果没有直接结束比较。

public void betterBubbleSort(int[] nums) {
    boolean swap;
    for (int i = 0; i < nums.length - 1; i++) {
        swap = true;
        for (int index = 0; index < nums.length - 1 - i; index++) {
            if (nums[index] > nums[index + 1]) {
                swap(nums, index ,index + 1);
                swap = false;
            }
        }
        if (swap) break;
    }
}
快速排序的原理?

是对冒泡排序的一种改进,不稳定,平均/最好时间复杂度 O(nlogn),元素基本有序时最坏时间复杂度 O(n²),空间复杂度 O(logn)。

首先选择一个基准元素,通过一趟排序将要排序的数据分割成独立的两部分,一部分全部小于等于基准元素,一部分全部大于等于基准元素,再按此方法递归对这两部分数据进行快速排序。

快速排序的一次划分从两头交替搜索,直到 low 和 high 指针重合,一趟时间复杂度 O(n),整个算法的时间复杂度与划分趟数有关。

最好情况是每次划分选择的中间数恰好将当前序列等分,经过 log(n) 趟划分便可得到长度为 1 的子表,这样时间复杂度 O(nlogn)。

最坏情况是每次所选中间数是当前序列中的最大或最小元素,这使每次划分所得子表其中一个为空表 ,这样长度为 n 的数据表需要 n 趟划分,整个排序时间复杂度 O(n²)。

public void quickSort(int[] nums, int start, int end) {
    if (start < end) {
        int pivotIndex = getPivotIndex(nums, start, end);
        quickSort(nums, start, pivotIndex - 1);
        quickSort(nums, pivotIndex + 1, end);
    }
}

public int getPivotIndex(int[] nums, int start, int end) {
    int pivot = nums[start];
    int low = start;
    int high = end;
    while (low < high) {
        while (low <= high && nums[low] <= pivot) 
            low++;
        while (low <= high && nums[high] > pivot) 
            high--;
        if (low < high) 
            swap(nums, low, high);
    }
    swap(nums, start, high);
    return high;
}
归并排序的原理?

归并排序基于归并操作,是一种稳定的排序算法,任何情况时间复杂度都为 O(nlogn),空间复杂度为 O(n)。

基本原理:应用分治法将待排序序列分成两部分,然后对两部分分别递归排序,最后进行合并,使用一个辅助空间并设定两个指针分别指向两个有序序列的起始元素,将指针对应的较小元素添加到辅助空间,重复该步骤到某一序列到达末尾,然后将另一序列剩余元素合并到辅助空间末尾。

适用场景:数据量大且对稳定性有要求的情况。

int[] help;

public void mergeSort(int[] arr) {
    int[] help = new int[arr.length];
    sort(arr, 0, arr.length - 1);
}

public void sort(int[] arr, int start, int end) {
    if (start == end) return;
    int mid = start + (end - start) / 2;
    sort(arr, start, mid);
    sort(arr, mid + 1, end);
    merge(arr, start, mid, end);
}

public void merge(int[] arr, int start, int mid, int end) {
    if (end + 1 - start >= 0) System.arraycopy(arr, start, help, start, end + 1 - start);
    int p = start;
    int q = mid + 1;
    int index = start;
    while (p <= mid && q <= end) {
        if (help[p] < help[q]) 
            arr[index++] = help[p++];
        else 
            arr[index++] = help[q++];
    }
    while (p <= mid) arr[index++] = help[p++];
    while (q <= end) arr[index++] = help[q++];
}

排序算法怎么选择?

数据量规模较小,考虑直接插入或直接选择。当元素分布有序时直接插入将大大减少比较和移动记录的次数,如果不要求稳定性,可以使用直接选择,效率略高于直接插入。

数据量规模中等,选择希尔排序。

数据量规模较大,考虑堆排序(元素分布接近正序或逆序)、快速排序(元素分布随机)和归并排序(稳定性)。

一般不使用冒泡。

堆与栈

请说一说你理解的stack overflow
  • 栈溢出概念:   栈溢出指的是程序向栈中某个变量中写入的字节数超过了这个变量本身所申请的字节数,因而导致栈中与其相邻的变量的值被改变。
  • 栈溢出的原因:
  1. 局部数组过大。当函数内部的数组过大时,有可能导致堆栈溢出。局部变量是存储在栈中的,因此这个很好理解。解决这类问题的办法有两个,一是增大栈空间,二是改用动态分配,使用堆(heap)而不是栈(stack)。
  2. 递归调用层次太多。递归函数在运行时会执行压栈操作,当压栈次数太多时,也会导致堆栈溢出。
  3. 指针或数组越界。这种情况最常见,例如进行字符串拷贝,或处理用户输入等等。
请你回答一下栈和堆的区别,以及为什么栈要快
  • 堆和栈的区别: 1、堆是由低地址向高地址扩展;栈是由高地址向低地址扩展 2、堆中的内存需要手动申请和手动释放;栈中内存是由OS自动申请和自动释放,存放着参数、局部变量等内存 3、堆中频繁调用malloc和free,会产生内存碎片,降低程序效率;而栈由于其先进后出的特性,不会产生内存碎片 4、堆的分配效率较低,而栈的分配效率较高
  • 栈的效率高的原因:   栈是操作系统提供的数据结构,计算机底层对栈提供了一系列支持:分配专门的寄存器存储栈的地址,压栈和入栈有专门的指令执行;而堆是由C/C++函数库提供的,机制复杂,需要一些列分配内存、合并内存和释放内存的算法,因此效率较低。
请你说一说小根堆特点
堆是一棵完全二叉树(如果一共有h层,那么1~h-1层均满,在h层可能会连续缺失若干个右叶子)。
  • 1)小根堆   若根节点存在左子女则根节点的值小于左子女的值;若根节点存在右子女则根节点的值小于右子女的值。
  • 2)大根堆   若根节点存在左子女则根节点的值大于左子女的值;若根节点存在右子女则根节点的值大于右子女的值。
请你解释一下,内存中的栈(stack)、堆(heap) 和静态区(static area) 的用法。并且说明heap和stack有什么区别。

通常我们定义一个基本数据类型的变量,一个对象的引用,还有就是函数调用的现场保存都使用内存中的栈空间;而通过new关键字和构造器创建的对象放在堆空间;程序中的字面量(literal)如直接书写的100、"hello"和常量都是放在静态区中。栈空间操作起来最快但是栈很小,通常大量的对象都是放在堆空间,理论上整个内存没有被其他进程使用的空间甚至硬盘上的虚拟内存都可以被当成堆空间来使用。   栈是一种线形集合,其添加和删除元素的操作应在同一段完成。栈按照后进先出的方式进行处理。堆是栈的一个组成元素。

大顶堆怎么插入删除

插入: 在一个大顶堆之后插入新的元素可能会破坏堆的结构,此时需要找到新插入节点的父节点,对堆进行自下而上的调整使其变成一个大顶堆。 删除: 将堆的最后一个元素填充到删除元素的位置,然后调整堆结构构造出新的大顶堆

请你讲一下动态链表和静态链表的区别

静态链表是用类似于数组方法实现的,是顺序的存储结构,在物理地址上是连续的,而且需要预先分配地址空间大小。所以静态链表的初始长度一般是固定的,在做插入和删除操作时不需要移动元素,仅需修改指针。 动态链表是用内存申请函数(malloc/new)动态申请内存的,所以在链表的长度上没有限制。动态链表因为是动态申请内存的,所以每个节点的物理地址不连续,要通过指针来顺序访问。

数组

请你回答一下Array&List, 数组和链表的区别
  • 数组的特点:    数组是将元素在内存中连续存放,由于每个元素占用内存相同,可以通过下标迅速访问数组中任何元素。数组的插入数据和删除数据效率低,插入数据时,这个位置后面的数据在内存中都要向后移。删除数据时,这个数据后面的数据都要往前移动。但数组的随机读取效率很高。因为数组是连续的,知道每一个数据的内存地址,可以直接找到给地址的数据。如果应用需要快速访问数据,很少或不插入和删除元素,就应该用数组。数组需要预留空间,在使用前要先申请占内存的大小,可能会浪费内存空间。并且数组不利于扩展,数组定义的空间不够时要重新定义数组。
  • 链表的特点:   链表中的元素在内存中不是顺序存储的,而是通过存在元素中的指针联系到一起。比如:上一个元素有个指针指到下一个元素,以此类推,直到最后一个元素。如果要访问链表中一个元素,需要从第一个元素开始,一直找到需要的元素位置。但是增加和删除一个元素对于链表数据结构就非常简单了,只要修改元素中的指针就可以了。如果应用需要经常插入和删除元素你就需要用链表数据结构了。不指定大小,扩展方便。链表大小不用定义,数据随意增删。
  • 各自的优缺点 数组的优点:
  1. 随机访问性强
  2. 查找速度快
  • 数组的缺点:
  1. 插入和删除效率低
  2. 可能浪费内存
  3. 内存空间要求高,必须有足够的连续内存空间。
  4. 数组大小固定,不能动态拓展
  • 链表的优点:
  1. 插入删除速度快
  2. 内存利用率高,不会浪费内存
  3. 大小没有固定,拓展很灵活。
  • 链表的缺点:   不能随机查找,必须从第一个开始遍历,查找效率低
请问如何防止数组越界

由于数组的元素个数默认情况下是不作为实参内容传入调用函数的,因此会带来数组访问越界的相关问题   防止数组越界:   1)检查传入参数的合法性。   2)可以用传递数组元素个数的方法,即:用两个实参,一个是数组名,一个是数组的长度。在处理的时候,可以判断数组的大小,保证自己不要访问超过数组大小的元素。   3)当处理数组越界时,打印出遍历数组的索引十分有帮助,这样我们就能够跟踪代码找到为什么索引达到了一个非法的值   4)Java中可以加入try{} catch(){ }

请回答数组和链表的区别,以及优缺点,另外有没有什么办法能够结合两者的优点
  • 1.数组:   数组是将元素在内存中连续存放,由于每个元素占用内存相同,可以通过下标迅速访问数组中任何元素。但是如果要在数组中增加一个元素,需要移动大量元素,在内存中空出一个元素的空间,然后将要增加的元素放在其中。同样的道理,如果想删除一个元素,同样需要移动大量元素去填掉被移动的元素。如果应用需要快速访问数据,很少插入和删除元素,就应该用数组。
  • 2.链表:   链表中的元素在内存中不是顺序存储的,而是通过存在元素中的指针联系到一起,每个结点包括两个部分:一个是存储数据元素的数据域,另一个是存储下一个结点地址的指针。如果要访问链表中一个元素,需要从第一个元素开始,一直找到需要的元素位置。但是增加和删除一个元素对于链表数据结构就非常简单了,只要修改元素中的指针就可以了。如果应用需要经常插入和删除元素你就需要用链表。
  • 3.区别:   (1)存储位置上: 数组逻辑上相邻的元素在物理存储位置上也相邻,而链表不一定;   (2)存储空间上: 链表存放的内存空间可以是连续的,也可以是不连续的,数组则是连续的一段内存空间。一般情况下存放相同多的数据数组占用较小的内存,而链表还需要存放其前驱和后继的空间。   (3)长度的可变性:链表的长度是按实际需要可以伸缩的,而数组的长度是在定义时要给定的,如果存放的数据个数超过了数组的初始大小,则会出现溢出现象。   (4)按序号查找时,数组可以随机访问,时间复杂度为O(1),而链表不支持随机访问,平均需要O(n);   (5)按值查找时,若数组无序,数组和链表时间复杂度均为O(1),但是当数组有序时,可以采用折半查找将时间复杂度降为O(logn);   (6)插入和删除时,数组平均需要移动n/2个元素,而链表只需修改指针即可   (7)空间分配方面:数组在静态存储分配情形下,存储元素数量受限制,动态存储分配情形下,虽然存储空间可以扩充,但需要移动大量元素,导致操作效率降低,而且如果内存中没有更大块连续存储空间将导致分配失败;即数组从栈中分配空间,,对于程序员方便快速,但自由度小。   链表存储的节点空间只在需要的时候申请分配,只要内存中有空间就可以分配,操作比较灵活高效;即链表从堆中分配空间, 自由度大但申请管理比较麻烦。   哈希表可以结合数组和链表的优点。

排序

1、请你来手写一下快排的代码,并说明其最优情况。

快速排序的最优情况是Partition每次划分的都很均匀,当排序的元素为n个,则递归树的深度为logn+1。在第一次做Partition的时候需对所有元素扫描一遍,获得的枢纽元将所有元素一分为二,不断的划分下去直到排序结束,而在此情况下快速排序的最优时间复杂度为nlogn。

2、请问求第k大的数的方法以及各自的复杂度是怎样的,另外追问一下,当有相同元素时,还可以使用什么不同的方法求第k大的元素

首先使用快速排序算法将数组按照从大到小排序,然后取第k个,其时间复杂度最快为O(nlogn)   使用堆排序,建立最大堆,然后调整堆,知道获得第k个元素,其时间复杂度为O(n+klogn)   首先利用哈希表统计数组中个元素出现的次数,然后利用计数排序的思想,线性从大到小扫描过程中,前面有k-1个数则为第k大的数    利用快排思想,从数组中随机选择一个数i,然后将数组分成两部分Dl,Dr,Dl的元素都小于i,Dr的元素都大于i。然后统计Dr元素个数,如果Dr元素个数等于k-1,那么第k大的数即为k,如果Dr元素个数小于k,那么继续求Dl中第k-Dr大的元素;如果Dr元素个数大于k,那么继续求Dr中第k大的元素。   当有相同元素的时候, 首先利用哈希表统计数组中个元素出现的次数,然后利用计数排序的思想,线性从大到小扫描过程中,前面有k-1个数则为第k大的数,平均情况下时间复杂度为O(n)。

3、请你来介绍一下各种排序算法及时间复杂度

插入排序:对于一个带排序数组来说,其初始有序数组元素个数为1,然后从第二个元素,插入到有序数组中。对于每一次插入操作,从后往前遍历当前有序数组,如果当前元素大于要插入的元素,则后移一位;如果当前元素小于或等于要插入的元素,则将要插入的元素插入到当前元素的下一位中。 希尔排序:先将整个待排序记录分割成若干子序列,然后分别进行直接插入排序,待整个序列中的记录基本有序时,在对全体记录进行一次直接插入排序。其子序列的构成不是简单的逐段分割,而是将每隔某个增量的记录组成一个子序列。希尔排序时间复杂度与增量序列的选取有关,其最后一个值必须为1. 归并排序:该算法采用分治法;对于包含m个元素的待排序序列,将其看成m个长度为1的子序列。然后两两合归并,得到n/2个长度为2或者1的有序子序列;然后再两两归并,直到得到1个长度为m的有序序列。 冒泡排序:对于包含n个元素的带排序数组,重复遍历数组,首先比较第一个和第二个元素,若为逆序,则交换元素位置;然后比较第二个和第三个元素,重复上述过程。每次遍历会把当前前n-i个元素中的最大的元素移到n-i位置。遍历n次,完成排序。 快速排序:通过一趟排序将要排序的数据分割成独立的两部分,其中一部分的所有数据都比另外一部分的所有数据都要小,然后再按此方法对这两部分数据分别进行快速排序,整个排序过程可以递归进行,以此达到整个数据变成有序序列。 选择排序:每次循环,选择当前无序数组中最小的那个元素,然后将其与无序数组的第一个元素交换位置,从而使有序数组元素加1,无序数组元素减1.初始时无序数组为空。 堆排序:堆排序是一种选择排序,利用堆这种数据结构来完成选择。其算法思想是将带排序数据构造一个最大堆(升序)/最小堆(降序),然后将堆顶元素与待排序数组的最后一个元素交换位置,此时末尾元素就是最大/最小的值。然后将剩余n-1个元素重新构造成最大堆/最小堆。   各个排序的时间复杂度、空间复杂度及稳定性如下:

img

4、请问海量数据如何去取最大的k个
  • 1.直接全部排序(只适用于内存够的情况)   当数据量较小的情况下,内存中可以容纳所有数据。则最简单也是最容易想到的方法是将数据全部排序,然后取排序后的数据中的前K个。   这种方法对数据量比较敏感,当数据量较大的情况下,内存不能完全容纳全部数据,这种方法便不适应了。即使内存能够满足要求,该方法将全部数据都排序了,而题目只要求找出topK个数据,所以该方法并不十分高效,不建议使用。
  • 2.快速排序的变形 (只使用于内存够的情况)   这是一个基于快速排序的变形,因为第一种方法中说到将所有元素都排序并不十分高效,只需要找出前K个最大的就行。   这种方法类似于快速排序,首先选择一个划分元,将比这个划分元大的元素放到它的前面,比划分元小的元素放到它的后面,此时完成了一趟排序。如果此时这个划分元的序号index刚好等于K,那么这个划分元以及它左边的数,刚好就是前K个最大的元素;如果index>K,那么前K大的数据在index的左边,那么就继续递归的从index-1个数中进行一趟排序;如果index < K,那么再从划分元的右边继续进行排序,直到找到序号index刚好等于K为止。再将前K个数进行排序后,返回TopK个元素。这种方法就避免了对除了Top K个元素以外的数据进行排序所带来的不必要的开销。
  • 3.最小堆法 这是一种局部淘汰法。   先读取前K个数,建立一个最小堆。然后将剩余的所有数字依次与最小堆的堆顶进行比较,如果小于或等于堆顶数据,则继续比较下一个;否则,删除堆顶元素,并将新数据插入堆中,重新调整最小堆。当遍历完全部数据后,最小堆中的数据即为最大的K个数。
  • 4.分治法   将全部数据分成N份,前提是每份的数据都可以读到内存中进行处理,找到每份数据中最大的K个数。此时剩下NK个数据,如果内存不能容纳NK个数据,则再继续分治处理,分成M份,找出每份数据中最大的K个数,如果M*K个数仍然不能读到内存中,则继续分治处理。直到剩余的数可以读入内存中,那么可以对这些数使用快速排序的变形或者归并排序进行处理。
  • 5.Hash法    如果这些数据中有很多重复的数据,可以先通过hash法,把重复的数去掉。这样如果重复率很高的话,会减少很大的内存用量,从而缩小运算空间。处理后的数据如果能够读入内存,则可以直接排序;否则可以使用分治法或者最小堆法来处理数据。
6、谈一谈,如何得到一个数据流中的中位数?

数据是从一个数据流中读出来的,数据的数目随着时间的变化而增加。如果用一个数据容器来保存从流中读出来的数据,当有新的数据流中读出来时,这些数据就插入到数据容器中。   数组是最简单的容器。如果数组没有排序,可以用 Partition函数找出数组中的中位数。在没有排序的数组中插入一个数字和找出中位数的时间复杂度是 O(1)和 O(n)。    我们还可以往数组里插入新数据时让数组保持排序,这是由于可能要移动 O(n)个数,因此需要O(n)时间才能完成插入操作。在已经排好序的数组中找出中位数是一个简单的操作,只需要 O(1)时间即可完成。   排序的链表时另外一个选择。我们需要O(n)时间才能在链表中找到合适的位置插入新的数据。如果定义两个指针指向链表的中间结点(如果链表的结点数目是奇数,那么这两个指针指向同一个结点),那么可以在O(1)时间得出中位数。此时时间效率与及基于排序的数组的时间效率一样。   如果能够保证数据容器左边的数据都小于右边的数据,这样即使左、右两边内部的数据没有排序,也可以根据左边最大的数及右边最小的数得到中位数。如何快速从一个容器中找出最大数?用最大堆实现这个数据容器,因为位于堆顶的就是最大的数据。同样,也可以快速从最小堆中找出最小数。   因此可以用如下思路来解决这个问题:用一个最大堆实现左边的数据容器,用最小堆实现右边的数据容器。往堆中插入一个数据的时间效率是 O(logn)。由于只需 O(1)时间就可以得到位于堆顶的数据,因此得到中位数的时间效率是 O(1)。

7、对一千万个整数排序,整数范围在[-1000,1000]间,用什么排序最快?

在以上的情景下最好使用计数排序,计数排序的基本思想为在排序前先统计这组数中其它数小于这个数的个数,其时间复杂度为O(n+k),其中n为整数的个数,k为所有数的范围,此场景下的n>>k,所以计数排序要比其他基于的比较排序效果要好。

8、堆排序的思想

将待排序的序列构成一个大顶堆,这个时候整个序列的最大值就是堆顶的根节点,将它与末尾节点进行交换,然后末尾变成了最大值,然后剩余n-1个元素重新构成一个堆,这样得到这n个元素的次大值,反复进行以上操作便得到一个有序序列。

9、topK给出3种解法
  • 1)局部淘汰法 – 借助“冒泡排序”获取TopK 思路

    (1)可以避免对所有数据进行排序,只排序部分;

    (2)冒泡排序是每一轮排序都会获得一个最大值,则K轮排序即可获得TopK。

    时间复杂度空间复杂度

    (1)时间复杂度:排序一轮是O(N),则K次排序总时间复杂度为:O(KN)。

    (2)空间复杂度:O(K),用来存放获得的topK,也可以O(1)遍历原数组的最后K个元素即可。

  • 2)局部淘汰法 – 借助数据结构"堆"获取TopK 思路:

    1)堆:分为大顶堆(堆顶元素大于其他所有元素)和小顶堆(堆顶其他元素小于所有其他元素)。

    (2)我们使用小顶堆来实现。

    (3)取出K个元素放在另外的数组中,对这K个元素进行建堆。

    (4)然后循环从K下标位置遍历数据,只要元素大于堆顶,我们就将堆顶赋值为该元素,然后重新调整为小顶堆。

    (5)循环完毕后,K个元素的堆数组就是我们所需要的TopK。

  • 时间复杂度与空间复杂度

    (1)时间复杂度:每次对K个元素进行建堆,时间复杂度为:O(KlogK),加上N-K次的循环,则总时间复杂度为O((K+(N-K))logK),即O(NlogK),其中K为想要获取的TopK的数量N为总数据量。

    (2)空间复杂度:O(K),只需要新建一个K大小的数组用来存储topK即可

  • 3)分治法 – 借助”快速排序“方法获取TopK 思路:

    (1)比如有10亿的数据,找处Top1000,我们先将10亿的数据分成1000份,每份100万条数据。

    (2)在每一份中找出对应的Top1000,整合到一个数组中,得到100万条数据,这样过滤掉了999%%的数据。

    (3)使用快速排序对这100万条数据进行”一轮“排序,一轮排序之后指针的位置指向的数字假设为S,会将数组分为两部分,一部分大于S记作Si,一部分小于S记作Sj。

    (4)如果Si元素个数大于1000,我们对Si数组再进行一轮排序,再次将Si分成了Si和Sj。如果Si的元素小于1000,则我们需要在Sj中获取1000-count(Si)个元素的,也就是对Sj进行排序

    (5)如此递归下去即可获得TopK。

    时间复杂度与空间复杂度:

    (1)时间复杂度:一份获取前TopK的时间复杂度:O((N/n)logK)。则所有份数为:O(NlogK),但是分治法我们会使用多核多机的资源,比如我们有S个线程同时处理。则时间复杂度为:O((N/S)logK)。之后进行快排序,一次的时间复杂度为:O(N),假设排序了M次之后得到结果,则时间复杂度为:O(MN)。所以,总时间复杂度大约为O(MN+(N/S)logK) 。

    (2)空间复杂度:需要每一份一个数组,则空间复杂度为O(N)。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值