时间复杂度和空间复杂度
时间复杂度大 O 表示法:表示代码执行时间随这数据规模增大的变化趋势。
空间复杂度大 O 表示法:表示代码占用的存储空间随数据规模增大的变化趋势。
数组
编程语言中一般会有数组这种数据类型。不过,它不仅是编程语言中的数据类型,还是基础的数据结构。
数组是一种线性表数据结构,它用一组连续的内存空间存储一组具有相同类型的数据。数组可以在 O(1) 时间复杂度内按照下标快速访问数组中的元素。
数组适合查找,不适合插入和删除,当插入元素到数组中间时,需要将后边的元素往后搬移一个位置,如果这时数组空间不够,还需要扩容数组后插入元素。
二分查找
二分查找,也称为折半查找。二分查找的思想非常简单,但是,越简单的东西往往越难掌握,想要灵活应用就更加困难。
随手写一个 0~99 的数字,让你猜写的是什么,每猜一次,会告诉你猜的数字比写的数字是大还是小。重复上述过程,直到猜中为止。要怎么猜才能在最小的次数中猜出答案呢?即每次都猜区间中的中间数。
二分查找是一种非常高效的查找算法,它的时间复杂度是对数级别的 O(logn)。但是使用二分查找有个前提条件,就是需要数据是有序的
链表
链表也是一种线性数据结构,适合插入和删除,时间复杂度是 O(1),不适合查找,时间复杂度是 O(n)。
链表有许多变体,如:
- 循环链表:链表尾节点的 next 指针指向头节点。
- 双向链表:链表除了包含 next 指针指向后继节点,还包含 prev 指针指向前驱节点。
- 双向循环链表:循环链表和双向链表的结合。
栈
从栈的操作特性上看,栈是一种 “操作受限” 的线性表,只允许一端插入和删除数据。栈只能从栈顶插入和取出元素,栈中元素遵守先进后出规则。
栈可以用数组实现,也可以用链表实现,用数组实现的栈叫顺序栈,用链表实现的栈叫链式栈。
队列
队列在结构和操作上与栈类似,队列只能从队尾入队,从队头出队,队列中元素遵守先进后出规则。
队列可以用数组实现,也可以用链表实现,用数组实现的队列叫顺序队列,用链表实现的队列叫链式队列。
哈希表
哈希表是数组的一种扩展,有 数组演化而来,底层依赖数组支持按下标快速访问元素的特性。
哈希函数
哈希表底层依赖数组,所以哈希表中的数据最后是存储在数组中,那么当插入数据的时候怎么知道要插入到哪个位置上呢?这就是哈希函数的作用,哈希函数传入待插入元素,返回该元素应该插入到数组中的索引。然后我们将元素插入到指定索引位置中即可。
哈希冲突
所有的哈希函数,都只能尽量减少冲突的概率,理论上是没有办法做到完全不冲突的。
之所以无法做到零冲突,是基于组合数学中一个基础理论:鸽巢理论。如果我们把 11 只鸽子放在 10 个鸽巢内,那么肯定有 1 个鸽巢中的鸽子数量多于 1 个,换句话说,肯定有两只鸽子在同一个鸽巢内。
既然我们无法避免哈希冲突,那么,究竟该如何解决哈希冲突呢?常用的方法有两类:开放寻址法和链表法。
开放寻址法
开发寻址法的核心思想:一旦出现哈希冲突,就通过重新探测新位置的方法解决冲突,如何重新探测新位置呢?
最简单的探测方法是线性探测法。
当向哈希表中插入数据时,如果某个数据经过哈希函数计算之后,对应的存储位置已经被占用了,我们就从这个位置开始,在数组中依次往后查找,直到找到空闲位置为止。
对于开发寻址法,除了线性探测法之外,还有另外两种经典的探测方法:二次探测法和双重哈希法。
二次探测法与线性探测法很像,线性探测法的探测步长是 1,探测的下标序列是 hash(key)+0、hash(key)+1…而二次探测法的探测步长变成了原来的“二次方”,探测的下标序列是 hash(key)+0、hash(key)+12、hash(key)+22…。双重哈希法适用多个哈希函数:hash1(key)、hash2(key)…如果第一个哈希函数计算得到的存储位置已经被占用,再用第二个哈希函数重新计算存储位置,依次类推,直到找到空闲的存储位置为止。
链表法
链表法是一种更加常用的解决哈希冲突的方法,相比开放寻址法,它要简单得多。在哈希表中,每个 “桶” 或者 “槽” 会对应一个链表,我们把哈希值相同的元素放到相同槽位对应的链表中。
装载因子
因为哈希表的底层是数组,数组在开始创建的时候就指定的容量,当哈希表中的数据原来越多,插入数据时发生哈希冲突的概率会越来越高,这时我们需要动态扩容底层数组的容量。
我们引入装载因子来表示哈希表中数据个数和哈希表长度的比值。装载因子=哈希表中元素个数/哈希表的长度。
如果装载因子过高,哈希表的性能就会下降,我们可以给装载因子设定一个阈值,如果当前哈希表的装载因子超过了这个阈值,则动态扩容哈希表容量为原来的两倍。
当哈希表中元素变少的时候,为了减少内存的占用量,我们还可以设定装载因子低于某个阈值的时候对哈希表进行缩容。
位图
位图指的是用二进制中的 “位” 来表示元素的状态,因为二进制只有 0 和 1,所以只能表示两种状态。位图中的二进制位用来表示元素是否存在,如果存在是 1,不存在则为 0。
也因为用位来存储一个元素的状态,所以位图这种数据结构非常节省内存。
假如有 1000 万个范围在 1 ~ 1 亿的整数。如何快速查找某个整数是否出现在这 1000 万个整数中?这个问题可以用位图来解决。
我们申请 1 亿个二进制位,如果某个数字存在,就将该数字的二进制位置为 1,在高级程序设计语言中,一般不提供表示位的数据类型,在 Java 中,我们可以用其他的数据类型代替,比如 Char,Char 在 Java 中占用两个字节,也就是 16 个二进制位,那么我们需要申请 1 亿/16 大小的 char 类型数组即可。
在存取位图中的数据时,我们用数据除 16,得到这个数据存储在哪个数组元素中后,用数据与 16 取余,得到数据存储在这个数组元素中的哪个二进制位上。例如,对于 53,与 16 相除得到的结果是 3,也就是说,数据存储在 a[3] 这个数组元素上,然后,将 53 与 16 取余的结果是 5,也就是说,数据存在在 a[3] 这个数组元素的第 5 个二进制位上。
布隆过滤器
不过,位图的应用场景有一定的局限性,就是数据所在的范围不能太大。如果数据所在的范围很大,如在 1000 万个整数中查找数据这个问题,数据范围不是 1 ~ 1亿,而是 1 ~ 10 亿,那么位图就要占用 10 亿个二进制位,也就是 120 MB 大小的内存空间,相比哈希表,内存占用不降反增。
为了解决内存占用不降反增这个问题,我们对位图进行改进和优化,于是,布隆过滤器就产生了。
还是刚才提到的在 1000 万个整数中查找数据这个例子,数据个数是 1000 万,数据的范围变成了 1 ~ 10 亿。布隆过滤器的做法:尽管数据范围增大了,但我们仍然使用包含 1 亿个二进制位的位图,通过哈希函数对数据进行处理,让哈希值落在 1 ~ 1 亿这个范围内。例如,我们把哈希函数设计成简单的取余操作:f(x)=x%n。其中,x 表示数据,n 表示位图的大小。
我们知道,如果用 1 亿个二进制位表示 1 ~ 10 亿的数据范围,对于 100000001 和 1 这两个数字,经过上面那个求余取模的哈希函数处理之后,最后的哈希值都是 1,即存在哈希冲突。这就导致我们无法区分 BitMap[1] = true 表示的是 1 还是 100000001 了。既然存在哈希冲突,就表示会有误判的可能,所以布隆过滤器适合用在不需要完全准确、运行存在小概率误判的大规模场景,比如去重,页面的每日访问用户等。
当然,为了减低哈希冲突发生的概率,我们可以设计一个更复杂、更随机的哈希函数。除此之外,还有其他方法吗?我们看一下布隆过滤器的处理方法。既然一个哈希函数可能会存在冲突,那么使用多个哈希函数一起定位一个数据,是否能减低冲突发生的概率呢?
我们使用 K 个哈希函数,分别对同一个数据计算哈希值,得到的结果分别记作 X1、X2、X3…XK。我们把这 K 个哈希值作为位图的下标,将对应的 BitMap[X1]、BitMap[X2]、BitMap[X3]…BitMap[XK] 都设置成 true,也就是说,我们用 K 个二进制位而非一个二进制位来表示一个元素是存在的。
当要查询某个元素是否存在时,只有当 BitMap[X1]、BitMap[X2]、BitMap[X3]…BitMap[XK] 都为 true 时,才能说明这个元素存在。
对于两个不同的数据,经过一个哈希函数处理之后,可能会产生相同的哈希值。但是,经过 K 个哈希函数处理之后,K 个哈希值都相同的概率就非常底了。不过,这种处理方式又带来了新的问题,那就是会产生误判。比如数据 146 的哈希值为 0,3,6;196 的哈希值为 2,4,7。在插入这两个元素之后,插入 177,它的哈希值是 0,2,7。尽管 177 并不存在,但是 BitMap 数组中下标为 0,2,7 的元素值都为 1 ,因此,就会误判为数据 177 存在。
布隆过滤器误判有一个特定:只有在判断其存在的情况下,才有可能发生误判,也就是说,判定为存在时有可能并不存在。如果某个数据经过布隆过滤器后判断为不存在,就说明这个数据是真的不存在,这种情况是不会存在误判的。
二叉树
前面讲的数据结构都属于线性表,而树属于非线性表。树这种数据结构倒过来看,很像现实生活中的树。我们把树上的每个元素称为节点。节点与节点之间具有一定的关系;上下节点为 “父子” 节点,左右节点为 “兄弟” 节点。
树的结构多种多样,不过,最常用的还是二叉树。
对于二叉树,每个节点最多有两个 “叉”,也就是两个子节点,分别是左子节点和右子节点。不过,二叉树并不要求每个节点都必须要有两个子节点,允许有的节点只有左子节点,有的节点只有右子节点。
二叉树中,叶子节点全在最底层,且除叶子节点之外,每个节点都有左右两个子节点的二叉树称为满二叉树。
二叉树中,叶子节点分布在最下面一层和倒数第二层,而且,最下面一层的叶子节点都靠左排列的二叉树称为完全二叉树。
二叉树的存储
要完全理解二叉树的定义,我们需要先了解如何表示(或存储)一颗二叉树。
存储一颗二叉树一般有两种方法:一种是基于指针的链式存储方式,另一种是基于数组的顺序存储方式。
链式存储方式
我们先来看一下比较简单、直观的链式存储方式。在链式存储方式中,每个节点包含 3 个字段:数据本身,以及指向左右子节点的两个指针。我们从根节点开始,通过左右子节点的指针,就可以把整棵树串起来。这种存储方式比较常用。大部分二叉树相关的代码是通过这种结构来存储二叉树的。
二叉树的链式存储方式的 Java 代码实现如下所示:
public class Node{public int data;public Node left;public Node right;
}
顺序存储方式
我们用数组来存储所有的节点。对于节点之间的父子关系,通过数组下标计算得到。如果节点 X 存储在数组中下标为 i 的位置,那么,下标为 2i 的位置存储的就是它的左子节点,下标为 2i+1 的位置存储的就是它的右子节点,下标为 i/2 的位置存储的就是它的父节点。
通过这种方式,我们只要知道根节点存储的位置,就可以通过对下标的计算,把整棵树串起来。
如果树是非完全二叉树,按照上面的存储规则,其实会浪费比较多的数组存储空间。因此,对于非完全二叉树,我们一般会使用链式存储方式来存储。对于完全二叉树,相对于链式存储方式,基于数组的顺序存储方式更节省内存,不需要记录左右子节点指针。
二叉树的遍历
如何将二叉树中的所有节点遍历输出?经典的方式有 3 种:前序遍历、中序遍历和后序遍历。其中,前序、中序和后序指的是节点与它的左右子树节点遍历输出的先后顺序。
前序遍历:对于树中的任意节点,首先输出它自己,然后输出它的左子树,最后输出它的右子树。
中序遍历:对于树中的任意节点,首先输出它的左子树,然后输出它自己,最后输出它的右子树。
后序遍历:对于树中的任意节点,首先输出它的左子树,然后输出它的右子树,最后输出它自己。
实际上,对树的很多操作非常适合用递归来实现。二叉树的前、中、后序遍历就是典型的递归过程。例如前序遍历,其实就是首先输出根节点,然后递归输出左子树,最后递归输出右子树。
写递归代码的关键是写出递推公式,而写递推公式的关键是将问题分解为子问题。要解决问题 A,我们首先假设子问题 B 和子问题 C 已经解决,然后来看如何利用子问题 B 和 子问题 C 的解来得出问题 A 的解。前、中、后序遍历的递推公式如下所示:
前序遍历的递推公式:
preOrder(root) = print root -> preOrder(root.left) -> preOrder(root.right)
中序遍历的递推公式:
inOrder(root) = inOrder(root.left) -> print root -> inOrder(root.right)
后序遍历的递推公式:
postOrder(root) = postOrder(root.left) -> postOrder(root.right) -> print root
二叉查找树
前面讲过,二叉树是树中常用的一种类型,而二叉查找树又是二叉树中常用的一种类型。二叉查找树是为了实现快速查找而产生的。不过,它不仅支持快速查找数据,还支持快速插入、删除数据。它是怎么做到的呢?
实际上,这归功于二叉查找树特有的结构。对于二叉查找树中的任意一个节点,其左子树中每个节点的值都要小于这个节点的值,而右子树中每个节点的值都要大于这个节点的值。
查找操作
首先,我们看一下如何在二叉查找树中查找一个节点。我们先取根节点,如果要查找的数据等于根节点的值,就直接返回根节点。如果要查找的数据比根节点的值小,按照二叉查找树的定义,要查找的数据只有可能出现在左子树中,就在左子树中继续递归查找。同理,如果要查找的数据比根节点的值大,就在右子树中继续递归查找。
插入操作
我们从根节点开始,依次比较要插入的数据和二叉查找树中节点的值的大小关系,来寻找合适的插入位置。
如果要插入的数据比当前节点的值大,并且当前节点的右子树为空,我们就将新数据直接插到右子节点的位置;如果右子树不为空,我们就再递归遍历右子树,直到找到插入位置。同理,如果要插入的数据比当前节点的值小,并且当前节点的左子树为空,我们就将新数据插入到左子节点的位置;如果左子树不为空,我们就再递归遍历左子树,直到找到插入位置。
删除操作
相比二叉查找树的查找、插入操作,二叉查找树的删除操作要复杂一些。针对待删除节点的子节点个数的不同,我们分 3 种情况来处理。
第一种情况:要删除的节点没有子节点,我们只需要直接将父节点中指向要删除节点的指针置为 null。
第二种情况:要删除的节点只有一个子子节点,我们只需要更新父节点中指向要删除节点的指针,让它重新指向要删除节点的子节点。
第三种情况:要删除的节点有两个子节点,这种情况比较复杂。我们需要找到这个节点的右子树中的 “最小节点”,把它和要删除的节点互换。然后,删除替换后的节点,因为 “最小节点” 肯定没有左子节点,所以可以应用上面两条规则来删除替换后的节点。
平衡二叉查找树
二叉树中任意一个节点的左右子树的高度相差不能大于 1,我们称符合该条件的二叉树为平衡二叉树。
二叉树查找树支持快速插入、删除和查找操作,各个操作的时间复杂度与树的高度成正比,在理想的情况下,时间复杂度为 O(logn)。
不过,二叉查找树在频繁的动态更新过程中,可能会出现树的高度远大于 log2^n 的情况,从而导致各个操作的效率下降。在极端情况下,二叉树会退化为链表,相应的,时间复杂度就会退化为 O(n)。因此我们寻求一种算法,在插入和二叉树的元素的时候,使二叉树尽可能矮胖而非高瘦。而平衡二叉树就符合这种要求,所以,即符合二叉查找树定义又符合平衡二叉树定义的二叉树就是平衡二叉查找树。
堆
我们先来看一下堆的定义。只要满足下面两个要求的二叉树就是堆。
- 堆必须是一个完全二叉树。
- 堆中的每个节点的值必须大于或等于(或者小于或等于)其子树中每个节点的值。
如果堆中每个节点的值都大于或等于子树中每个节点的值,我们把这种堆称为 “大顶堆”。如果堆中每个节点的值都小于或等于子树中每个节点的值,我们就把这种堆称为 “小顶堆”。
通过定义我们知道,对于大顶堆,堆顶元素是最大值,获取堆顶元素,实际上就相当于取集合中的最大值。获取堆顶元素的操作非常简单,如果堆是顺序存储方式存储的,那只需要返回数组中下标为 1 的元素,因此,时间复杂度为 O(1)。
插入操作
在往堆中插入新的元素时,我们需要继续让堆满足定义中的两个要求。
如果我们把新元素插到堆的末尾,此时的堆就不满足定义中的第一个要求了。于是,我们就需要进行调整,让其重新满足堆的定义。我们给这个调整的过程起了一个名字,称为 “堆化”。
堆化分两种:自上而下和自下而上。这里我们先讲自下而上这种堆化方法。堆化的过程非常简单。在大顶堆中,假设要堆化的节点是 a。我们顺着节点 a 所在的路径向上对比,如果节点 a 大于父节点,就将节点 a 和父节点互换,然后继续用节点 a 与新的父节点对比,重复这个过程,直到节点 a 小于或等于父节点为止。如果是小顶堆,则当节点 a 小于父节点时才交换。
删除操作
堆的删除操作分为两种情况,删除堆顶节点和删除任意节点。
首先我们讲删除堆顶节点。
对于大顶堆,堆顶节点就是最大节点。我们把最后一个节点放到堆顶代替需要被删除的堆顶节点,然后利用自上而下的堆化方式让堆重新满足定义。自上而下的堆化方式与前面讲到的自下而上的堆化方式类似。从堆顶元素开始,堆父子节点进行对比(可以从左子节点开始),对于不满足大小关系的父子节点互换位置,并且重复这个过程,直到父子节点之间满足大小关系为止。
删除任意节点。
删除任意节点时,我们可以借助删除堆顶节点的方法,将最后一个节点替换到要删除的节点的位置,然后针对替换之后的节点,执行堆化操作。
不同的是,我们需要根据替换元素与删除元素的大小关系,选择不同的堆化方式,此处讲的是大顶堆,如果替换元素大于删除元素,则进行自下而上的堆化;如果替换元素小于删除元素,就进行自上而下的堆化;如果替换元素等于删除元素,那么不需要堆化。
跳表
在讲二分查找算法的时候,数据是存储在数组中的,因为二分查找算法底层依赖数组支持按照下标快速访问元素的特性。不过,如果数据存储在链表中,就真的无法用二分查找算法了吗?
实际上,我们只需要对链表稍加改造,就可以支持类似 “二分” 的查找算法。我们把改造之后的数据结构称为跳表。跳表有很多应用,如 Redis 中的有序集合就是用的跳表实现的。
对于单链表,即便链表中存储的数据是有序的,如果想要在其中查找某个数据,也只能从头到尾遍历,查找效率很低,时间复杂度为 O(n),如下图所示:
怎样才能提高查找效率呢?我们对链表建立一级 “索引”,每两个节点提取一个节点到索引层。索引层中的每个节点包含一个 down 指针,指向下一级节点,如下图所示:
假设我们要查找某个节点,如查找上图中 16 这个节点。我们首先在索引层遍历,当遍历到索引层中的 13 这个节点时,发现下一个节点是 17,要查找的节点 16 肯定就在 13 和 17 这两个节点之间。然后,我们通过 13 这个索引层节点的 down 指针,下降到原始链表这一层,继续在原始链表中遍历。此时,我们只需要在原始链表中再遍历两个节点,就可以找到 16 这个节点了。查找 16 这个节点,原来需要遍历 10 个节点,现在只需要遍历 7 个节点。
在上面示例中可以看出,加上一层索引之后,查找一个节点需要遍历的节点个数减少了,也就是说,查找效率提高了。如果再加一级索引,那么效率会不会更高呢?
与建立第一级索引的方式类似,我们在第一级索引的基础之上,每两个节点抽出一个节点到第二级索引。查找 16 这个节点,现在只需要遍历 6 个节点。
图
树是一种非线性表,本节介绍的图也是一种非线性表。不过,和树比起来,图更加复杂。树中的元素称为节点,对应地,图中的元素称为顶点。图中的顶点可以与任意其他顶点建立连接关系,我们把这种连接关系称为边。与顶点相连接的边的条数称为顶点的度。图中的边也可以有方向,我们把边有方向的图称为 “有向图”,把边没有方向的图称为 “无向图”。我们再介绍一种图的新类型:带权图。在带权图中,每条边都有一个权重。
在有向图中,我们把度分为入度和出度。顶点的入度表示有多少条边指向这个顶点;顶点的出度,表示有多少条边是以这个顶点为起点指向其他顶点。
图的存储
了解了图的概念之后,我们再来探讨一下,如何在内存中存储图这种数据结构?
邻接矩阵
图最直观的存储方式是邻接矩阵。邻接矩阵底层依赖二维数组。对于无向图,如果顶点 j 与 顶点 j 之间有边,我们就将 A[i][j] 和 A[j][i] 标记为 1;对于有向图,如果有一条边从顶点 i 指向顶点 j 的边,我们就将 A[i][j] 标记为 1。同理,如果有一条从顶点 j 指向顶点 i 的边,我们就将 A[j][i] 标记为 1。对于带权图,数据中存储相应的权重。
因为邻接矩阵底层依赖数组,所以,在邻接矩阵中,获取两个顶点之间关系的操作相当。除此之外,这种存储方式还有一个好处,就是可以将图中的很多运算转换成矩阵运算,方便计算。
对于无向图的邻接矩阵存储方式,如果 A[i][j] 等于 1,那么 A[j][i] 肯定等于 1,只需要存储其中一个就可以了。我们用对角线把无向图的二维数组划分为左下和右上两部分,这两部分是沿对角线对称的,我们只需要利用左下或右上这样一半的存储空间,就能表示一个无向图。
邻接表
对于稀疏图,顶点很多,但每个顶点的边并不多,如果用邻接矩阵来存储,会非常浪费存储空间。针对这个问题,我们来看另外一种图的存储方式:邻接表。
上图是一张邻接表。邻接表是不是有点像之前介绍过的哈希表?每个顶点对应一条链表。对于有向图,每个顶点对应的链表存储的是它指向的顶点。对于无向图,每个顶点的链表存储的是与这个顶点有边相连的顶点。
邻接矩阵存储起来比较浪费空间,但是使用起来比较高效。相反,邻接表存储起来比较节省空间,但是使用起来就没有那么高效。
图的搜索
算法是作用于具体数据结构之上的,大部分搜索算法是基于 “图” 这种数据结构的。但是图的表达能力很强,大部分涉及搜索的场景可以抽象成 “图”。
所谓 “搜索”,最直接的理解就是,在图中寻找从一个顶点出发到另一个顶点的路径。针对不同的需求和场景,对应有不同的算法。其中,深度优先搜索、广度优先搜索是比较简单的、针对无权图的搜索算法。
广度优先搜索
广度优先搜索其实是一种 “地毯式” 层层推进的搜索策略,首先查找离起始顶点 s 最近的,然后是次近的,依次往外搜索,直到找到最终顶点 t。实际上,通过广度优先搜索找到的源点到终点的路径也是顶点 s 到顶点 t 的最短路径。
深度优先搜索
前面讲到,广度优先搜索是一种 “地毯式” 的搜索策略,而深度优先搜索就是一种 “不撞南墙不回头” 的搜索策略。
有关深度优先搜索的直观的例子就是 “走迷宫”。假设我们站在迷宫的某个岔路口,然后想找到出口。我们随意选择一个岔路口来走,然后发现走不通的时候,就回退到上一个岔路口,重新选择一条路继续走,直到最终找到出口。
实际上,深度优先搜索利用了一种比较著名的算法思想:回溯算法思想。这种算法思想解决问题的过程非常适合用递归来实现。
递归
递归是一种编程技巧,递归有几个特定:
- 待请求问题的解可以分解为几个子问题的解
- 待求解问题与分解之后的子问题,只有数据规模不同,求解思路完全相同
- 存在递归终止条件
尾递归
在编写递归代码时我们要警惕递归太深导致堆栈溢出,我们可以把递归代码改成尾递归,尾递归指递归调用出现在 return 语句中,并且没有任何局部变量和递归调用计算。
尾递归是编程语言的优化,只有编程语言支持尾递归优化才能使用尾递归避免递归过深导致的堆栈溢出问题。
排序
冒泡排序
将一组元素分成待排序区和已排序区,每次从待排序区中对比相邻两个元素,将较大的元素交换到后面,经过一轮排序后待排序区最后一个元素就是待排序区的最大元素,将这个元素归到已排序区,然后进行下一轮排序,直到待排序区元素个数为零。
当数据规模为 n 时,需要经过 n-1 轮排序,数据才能是有序的。
public void bubbleSort(int[] arr, int n){for (int i = 1; i < n; i++) {for (int j = 0; j < n - i; j++) {if (arr[j] > arr[j+1]) {swap(arr, j, j+1);}}}
}public void swap(int[] arr, int i, int j){int temp = arr[i];arr[i] = arr[j];arr[j] = temp;
}
冒泡排序是稳定排序算法,是原地排序算法,时间复杂度是 O(n^2)。
插入排序
我们将数组中的数据分为两个区间:已排序区间和未排序区间。初始已排序区间只有一个元素,就是数组中的第一个元素。插入算法的核心思想是取未排序区间中的元素,在已排序区间中找到合适的插入位置将其插入,并保证已排序区间数据一直有序。重复这个过程,直到未排序区间中元素为控,算法结束 。
public void insertSort(int[] arr, int n){if (n <= 1) return;for (int i = 1; i < n; i++) {int value = arr[i];int j = i - 1;for (; j >= 0; j--) {if (arr[j] > value) {arr[j+1] = arr[j];} else {break;}}arr[j+1] = value;}
}
插入排序是稳定排序算法,是原地排序算法,时间复杂度是 O(n^2)。
选择排序
选择排序的实现思路类似插入排序,也将整个数组划分为已排序区间和未排序区间。两者的不同点在于:选择排序每次从未排序区间中找到最小的元素,将其放到已排序区间的末尾。
public void selectSort(int[] arr, int n){if (n <= 1) return;for (int i = 0; i < n - 1; i++) {int minPos = i;for (int j = i; j < n; j++) {if (arr[j] < arr[minPos]) {minPos = j;}}swap(arr, i, minPos);}
}
选择排序不是稳定排序算法,是原地排序算法,时间复杂度是 O(n^2)。
归并排序
归并排序使用的是分治算法思想。分治,顾名思义,就是分而治之,将一个大问题分解成小的子问题并逐个解决。小的子问题解决了,大问题也就解决了。
我们对待排序数组从中间切分成左右两个子数组,然后对左右子数组排序,最后将已排序的左右子数合并就是已经排好序的数组了。切分子数组时我们一直切分到子数组中已有一个元素,这时子数组肯定是有序的,然后一步步合并子数组,合并成的最后一个数组既是有序的数组。
归并排序适用适用递归,而我们再写递归代码时,可以先写出递推表达式,通过递推表达式编写递归代码会简单明了很多。
递推公式:merge_sort(p, r) = merge(merge_sort(p, q), merge_sort(q+1, r))
终止条件:p >= r,不用再继续分解。
public void mergeSort(int[] arr, int n){if (n <=1) return;mergeSortCal(arr, 0, n-1);
}public void mergeSortCal(int[] arr, int l, int r){if (l >= r) return;int m = (l+r)/2;mergeSortCal(arr, l, m);mergeSortCal(arr, m+1, r);merge(arr, l, m, r);
}public void merge(int[] arr, int start, int mid, int end){int[] temp = new int[arr.length];int i = start;int j = mid + 1;int lPos = 0,rPos = 0;int k = 0;while (i <= mid && j <= end) {if (arr[i] <= arr[j]) {temp[k++] = arr[i++];} else {temp[k++] = arr[j++];}}while (i <= mid) {temp[k++] = arr[i++];}while (j <= end) {temp[k++] = arr[j++];}for (int m = 0; m < k; m++) {arr[m + start] = temp[m];}}
归并排序算法是稳定排序算法,空间复杂度是 O(n),时间复杂度是 O(nlogn)。
归并排序的时间复杂度非常稳定,可以做到最好情况,最坏情况,平均情况的时间复杂度都是 O(nlogn),即使是快速排序,也无法达到像归并排序这样的性能表现,在最坏情况下,快速排序的时间复杂度也要达到 O(n^2)。但是因为归并排序的空间复杂度过高,导致归并排序并没有像快速排序那样被广泛应用。
快速排序
排序算法的思想非常简单,在待排序的数列中,我们首先找一个数字作为基准数。为了方便,我们一般选择第 1 个数字作为基准数。接下来我们需要把这个待排序的数列中小于基准数的元素移动到待排序的数列的左边,把大于基准数的元素移动到待排序的数列的右边。这时,左右两个分区的元素就相对有序了;接着把两个分区的元素分别按照下面两种方法继续对每个分区找出基准数,然后移动,直到各个分区只有一个数时为止。
这是典型的分治思想。下面我们对一个实际例子进行算法描述,讲解快速排序的排序步骤。
以 47、29、71、99、78、19、24、47 的待排序的数列为例进行排序,为了方便区分两个 47,我们对后后面的 47 增加一个下划线,即待排序的数列为 47、29、71、99、78、19、24、47。
首先我们需要在数列中选择一个基准数,我们一般会选择中间的一个数或者头尾的数,这里直接选择第 1 个数 47 作为基准数,接着把比 47 小的数字移动到左边,把比 47 大的数字移动到右边,对于相等的数字不做移动。所以实际上我们需要找到中间的某个位置 k,这样 k 左边的值全部比 k 上的值小,k 右边的值全部比 k 上的值大。
接下来开始移动元素。怎么移动呢?其实冒泡排序也涉及对元素的移动,但是那样移动起来很累,比如把最后一个元素移动到第 1 个,就需要比较 n-1 次,同时交换 n-1 次,效率很低。其实,只需把第 1 个元素和最后一个元素交换就好了,这种思想是不是在排序时可以借鉴呢?之前说快速排序就是对冒泡排序的一个改进,就是这个原因。
快速排序的操作是这样的:首先从数列的右边开始往左边找,我们设这个下标为 i,也就是进行减减操作(i–),找到第 1 个比基准数小的值,让它与基准数交换;接着从左边开始往右边找,设这个下标为 j,然后执行加加操作(j++),找到第 1 个比基准数大的值,让它与基准数交换;然后继续寻找,直到 i 和 j 相遇时结束,最后基准值所在的位置即 k 的位置,也就是说 k 左边的值均比 k 上的值小,而 k 右边的值都比 k 上的值大。
public class QuickSort {private int[] array;public QuickSort(int[] array) {this.array = array;}public void sort() {quickSort(array, 0, array.length - 1);}public void print() {for (int i = 0; i < array.length; i++) {System.out.println(array[i]);}}/*** 递归排序* @param src* @param begin* @param end*/private void quickSort(int[] src, int begin, int end) {if (begin < end) {int key = src[begin];int i = begin;int j = end;while (i < j) {while (i < j && src[j] > key) {j--;}if (i < j) {src[i] = src[j];i++;}while (i < j && src[i] < key) {i++;}if (i < j) {src[j] = src[i];j--;}}src[i] = key;quickSort(src, begin, i - 1);quickSort(src, i + 1, end);}}
}
由于快速排序需要对数列中的元素来回移动,优势还是会改变相对顺序,所以快速排序并不是一个稳定的排序算法,快速排序的时间复杂度是 O(nlogn)。
桶排序
桶排序的核心思想是先定义几个有序的 “桶”,将要排序的数据分到这几个 “桶” 里,对每个 “桶” 里的数据单独进行排序,再把每个 “桶” 里的数据按照顺序单独进行排序,再把每个 “桶” 里的数据按照顺序依次取出,组成的序列就是有序的了。
桶排序比较适合用在外部排序中。所有的外部排序就是数据存储在外部磁盘中,数据量比较大,而内存有限,无法将数据全部加载到内存中处理。
例如有 10GB 的订单数据,我们希望按照订单金额进行排序,但是机器的内存有限,只有几百 MB,没办法把 10GB 的数据一次性全部加载到内存中。这时候我们可以借助桶排序的处理思想来解决这个问题。我们先扫描一遍文件,查找订单金额所在的数据范围。假设经过扫描之后我们得到:订单金额最小是 1 元,最大是 10 万元。我们将所有订单根据金额划分到 100 个 “桶” 里,第一个 “桶” 存储金额在 1 元 ~ 1000 元的订单,第二个 “桶” 存储金额在 1001 元 ~ 2000 元的订单,依次类推。每一个 “桶” 对应一个文件,并且按照金额范围的大小顺序编号命名(00、01、02…99)。
在理想的情况下,如果订单金额在 1 元 ~ 10 万元均匀分布,那么订单会被均匀划分到 100 个小文件中,每个小文件存储大约 100MB 的订单数据。这样我们就可以将这 100 个小文件依次放到内存中,用快速排序算法来排序,排完序之后重新写回文件。
等所有文件都排好序之后,我们只需要按照文件编号,从小到大依次读取每个小文件中的订单数据,并将其写入到一个大文件中。这个大文件存储的就是按照金额从小到大排好序的所有订单数据了。