链表排序知识 #
1. 链表排序简介 #
在数组排序中,常见的排序算法有:冒泡排序、选择排序、插入排序、希尔排序、归并排序、快速排序、堆排序、计数排序、桶排序、基数排序等。
而对于链表排序而言,因为链表不支持随机访问,访问链表后面的节点只能依靠 next
指针从头部顺序遍历,所以相对于数组排序问题来说,链表排序问题会更加复杂一点。
下面先来总结一下适合链表排序与不适合链表排序的算法:
- 适合链表的排序算法:冒泡排序、选择排序、插入排序、归并排序、快速排序、计数排序、桶排序、基数排序。
- 不适合链表的排序算法:希尔排序。
- 可以用于链表排序但不建议使用的排序算法:堆排序。
希尔排序为什么不适合链表排序?
希尔排序:希尔排序中经常涉及到对序列中第 i + gap
的元素进行操作,其中 gap
是希尔排序中当前的步长。而链表不支持随机访问的特性,导致这种操作不适合链表,因而希尔排序算法不适合进行链表排序。
为什么不建议使用堆排序?
堆排序:堆排序所使用的最大堆 / 最小堆结构本质上是一棵完全二叉树。而完全二叉树适合采用顺序存储结构(数组)。因为数组存储的完全二叉树可以很方便的通过下标序号来确定父亲节点和孩子节点,并且可以极大限度的节省存储空间。
而链表用在存储完全二叉树的时候,因为不支持随机访问的特性,导致其寻找子节点和父亲节点会比较耗时,如果增加指向父亲节点的变量,又会浪费大量存储空间。所以堆排序算法不适合进行链表排序。
如果一定要对链表进行堆排序,则可以使用额外的数组空间表示堆结构。然后将链表中各个节点的值依次添加入堆结构中,对数组进行堆排序。排序后,再按照堆中元素顺序,依次建立链表节点,构建新的链表并返回新链表头节点。
需要用到额外的辅助空间进行排序的算法
刚才我们说到如果一定要对链表进行堆排序,则需要使用额外的数组空间。除此之外,计数排序、桶排序、基数排序都需要用到额外的数组空间。
接下来,我们将对适合链表排序的 8 种算法进行一一讲解。当然,这些排序算法不用完全掌握,重点是掌握 「链表插入排序」、「链表归并排序」 这两种排序算法。
2. 链表冒泡排序 #
2.1 链表冒泡排序算法描述 #
-
使用三个指针
node_i
、node_j
和tail
。其中node_i
用于控制外循环次数,循环次数为链节点个数(链表长度)。node_j
和tail
用于控制内循环次数和循环结束位置。 -
排序开始前,将
node_i
、node_j
置于头节点位置。tail
指向链表末尾,即None
。 -
比较链表中相邻两个元素
node_j.val
与node_j.next.val
的值大小,如果node_j.val > node_j.next.val
,则值相互交换。否则不发生交换。然后向右移动node_j
指针,直到node_j.next == tail
时停止。 -
一次循环之后,将
tail
移动到node_j
所在位置。相当于tail
向左移动了一位。此时tail
节点右侧为链表中最大的链节点。 -
然后移动
node_i
节点,并将node_j
置于头节点位置。然后重复第 3、4 步操作。 -
直到
node_i
节点移动到链表末尾停止,排序结束。 -
返回链表的头节点
head
。
2.2 链表冒泡排序算法实现代码 #
|
|
2.3 链表冒泡排序算法复杂度分析 #
- 时间复杂度:$O(n^2)$。
- 空间复杂度:$O(1)$。
3. 链表选择排序 #
3.1 链表选择排序算法描述 #
- 使用两个指针
node_i
、node_j
。node_i
既可以用于控制外循环次数,又可以作为当前未排序链表的第一个链节点位置。 - 使用
min_node
记录当前未排序链表中值最小的链节点。 - 每一趟排序开始时,先令
min_node = node_i
(即暂时假设链表中node_i
节点为值最小的节点,经过比较后再确定最小值节点位置)。 - 然后依次比较未排序链表中
node_j.val
与min_node.val
的值大小。如果node_j.val < min_node.val
,则更新min_node
为node_j
。 - 这一趟排序结束时,未排序链表中最小值节点为
min_node
,如果node_i != min_node
,则将node_i
与min_node
值进行交换。如果node_i == min_node
,则不用交换。 - 排序结束后,继续向右移动
node_i
,重复上述步骤,在剩余未排序链表中寻找最小的链节点,并与node_i
进行比较和交换,直到node_i == None
或者node_i.next == None
时,停止排序。 - 返回链表的头节点
head
。
3.2 链表选择排序实现代码 #
|
|
3.3 链表选择排序算法复杂度分析 #
- 时间复杂度:$O(n^2)$。
- 空间复杂度:$O(1)$。
4. 链表插入排序 #
4.1 链表插入排序算法描述 #
-
先使用哑节点
dummy_head
构造一个指向head
的指针,使得可以从head
开始遍历。 -
维护
sorted_list
为链表的已排序部分的最后一个节点,初始时,sorted_list = head
。 -
维护
prev
为插入元素位置的前一个节点,维护cur
为待插入元素。初始时,prev = head
,cur = head.next
。 -
比较
sorted_list
和cur
的节点值。- 如果
sorted_list.val <= cur.val
,说明cur
应该插入到sorted_list
之后,则将sorted_list
后移一位。 - 如果
sorted_list.val > cur.val
,说明cur
应该插入到head
与sorted_list
之间。则使用prev
从head
开始遍历,直到找到插入cur
的位置的前一个节点位置。然后将cur
插入。
- 如果
-
令
cur = sorted_list.next
,此时cur
为下一个待插入元素。 -
重复 4、5 步骤,直到
cur
遍历结束为空。返回dummy_head
的下一个节点。
4.2 链表插入排序实现代码 #
|
|
4.3 链表插入排序算法复杂度分析 #
- 时间复杂度:$O(n^2)$。
- 空间复杂度:$O(1)$。
5. 链表归并排序 #
5.1 链表归并排序算法描述 #
- 分割环节:找到链表中心链节点,从中心节点将链表断开,并递归进行分割。
- 使用快慢指针
fast = head.next
、slow = head
,让fast
每次移动2
步,slow
移动1
步,移动到链表末尾,从而找到链表中心链节点,即slow
。 - 从中心位置将链表从中心位置分为左右两个链表
left_head
和right_head
,并从中心位置将其断开,即slow.next = None
。 - 对左右两个链表分别进行递归分割,直到每个链表中只包含一个链节点。
- 使用快慢指针
- 归并环节:将递归后的链表进行两两归并,完成一遍后每个子链表长度加倍。重复进行归并操作,直到得到完整的链表。
- 使用哑节点
dummy_head
构造一个头节点,并使用cur
指向dummy_head
用于遍历。 - 比较两个链表头节点
left
和right
的值大小。将较小的头节点加入到合并后的链表中,并向后移动该链表的头节点指针。 - 然后重复上一步操作,直到两个链表中出现链表为空的情况。
- 将剩余链表插入到合并后的链表中。
- 将哑节点
dummy_dead
的下一个链节点dummy_head.next
作为合并后的头节点返回。
- 使用哑节点
5.2 链表归并排序实现代码 #
|
|
5.3 链表归并排序算法复杂度分析 #
- 时间复杂度:$O(n \times \log_2n)$。
- 空间复杂度:$O(1)$。
6. 链表快速排序 #
6.1 链表快速排序算法描述 #
- 从链表中找到一个基准值
pivot
,这里以头节点为基准值。 - 然后通过快慢指针
node_i
、node_j
在链表中移动,使得node_i
之前的节点值都小于基准值,node_i
之后的节点值都大于基准值。从而把数组拆分为左右两个部分。 - 再对左右两个部分分别重复第二步,直到各个部分只有一个节点,则排序结束。
6.2 链表快速排序实现代码 #
|
|
6.3 链表快速排序算法复杂度分析 #
- 时间复杂度:$O(n \times \log_2n)$。
- 空间复杂度:$O(1)$。
7. 链表计数排序 #
7.1 链表计数排序算法描述 #
- 使用
cur
指针遍历一遍链表。找出链表中最大值list_max
和最小值list_min
。 - 使用数组
counts
存储节点出现次数。 - 再次使用
cur
指针遍历一遍链表。将链表中每个值为cur.val
的节点出现次数,存入数组对应第cur.val - list_min
项中。 - 反向填充目标链表:
- 建立一个哑节点
dummy_head
,作为链表的头节点。使用cur
指针指向dummy_head
。 - 从小到大遍历一遍数组
counts
。对于每个counts[i] != 0
的元素建立一个链节点,值为i + list_min
,将其插入到cur.next
上。并向右移动cur
。同时counts[i] -= 1
。直到counts[i] == 0
后继续向后遍历数组counts
。
- 建立一个哑节点
- 将哑节点
dummy_dead
的下一个链节点dummy_head.next
作为新链表的头节点返回。
7.2 链表计数排序代码实现 #
|
|
7.3 链表计数排序算法复杂度分析 #
- 时间复杂度:$O(n + k)$,其中 $k$ 代表待排序链表中所有元素的值域。
- 空间复杂度:$O(k)$。
8. 链表桶排序 #
8.1 链表桶排序算法描述 #
- 使用
cur
指针遍历一遍链表。找出链表中最大值list_max
和最小值list_min
。 - 通过
(最大值 - 最小值) / 每个桶的大小
计算出桶的个数,即bucket_count = (list_max - list_min) // bucket_size + 1
个桶。 - 定义数组
buckets
为桶,桶的个数为bucket_count
个。 - 使用
cur
指针再次遍历一遍链表,将每个元素装入对应的桶中。 - 对每个桶内的元素单独排序,可以使用链表插入排序、链表归并排序、链表快速排序等算法。
- 最后按照顺序将桶内的元素拼成新的链表,并返回。
8.2 链表桶排序代码实现 #
|
|
8.3 链表桶排序算法复杂度分析 #
- 时间复杂度:$O(n)$。
- 空间复杂度:$O(n + m)$。$m$ 为桶的个数。
9. 链表基数排序 #
9.1 链表基数排序算法描述 #
- 使用
cur
指针遍历链表,获取节点值位数最长的位数size
。 - 从个位到高位遍历位数。因为
0
~9
共有10
位数字,所以建立10
个桶。 - 以每个节点对应位数上的数字为索引,将节点值放入到对应桶中。
- 建立一个哑节点
dummy_head
,作为链表的头节点。使用cur
指针指向dummy_head
。 - 将桶中元素依次取出,并根据元素值建立链表节点,并插入到新的链表后面。从而生成新的链表。
- 之后依次以十位,百位,…,直到最大值元素的最高位处值为索引,放入到对应桶中,并生成新的链表,最终完成排序。
- 将哑节点
dummy_dead
的下一个链节点dummy_head.next
作为新链表的头节点返回。
9.2 链表基数排序代码实现 #
|
|
9.3 链表基数排序算法复杂度分析 #
- 时间复杂度:$O(n \times k)$。其中 $n$ 是待排序元素的个数,$k$ 是数字位数。$k$ 的大小取决于数字位的选择(十进制位、二进制位)和待排序元素所属数据类型全集的大小。
- 空间复杂度:$O(n + k)$。