排序
排序中的稳定
简单来说,它的定义是:如果待排序的序列中存在多个键值相等 的元素,在排序前后,这些元素的相对顺序保持不变,那么这个排序算法就是稳定的。
归并排序是稳定的,快速排序是不稳定的
两种排序的常见模板(快排、归并):
快速排序
思路
快排的思路是,把一个区间内的数字重新排列:选定一个标杆数字,比它小的放左边,比它大的放右边。这个排列的实现有三种(对向双指针、同向双指针、非原地额外空间法)(快速记忆:对向、同向、非原地),yxc给的模板用的对向双指针,给链表快排用到的是额外空间法和lomuto分区法(链表排序首选是归并,只不过快排也能做)。
常用的两种方法,也就是前两个方法,本质也是一种双指针
模板
目前是有三种
对向双指针(hoare 分区)
这是模板,不要纠结里面的边界问题,理解背诵即可
java
//y总板子--快速排序
public void quick_sort(int[] arr, int l, int r) {//快排的边界就比较恶心了
if (l >= r) return; // 相撞直接return,不用排
int i = l - 1, j = r + 1, x = arr[l + (r - l) / 2];//x一定得是值,不能是该值的下标,因为该值会被移动
//双指针swap及寻找中间值下标j
while (i < j) {
do i++; while (arr[i] < x);
do j--; while (arr[j] > x);
if (i < j) swap(arr, i, j);//相等的时候也交换
}
quick_sort(arr, l, j);// j会指向下标最大的小于x的数,注意,这里只可以用j作为分区!
quick_sort(arr, j + 1, r);
}
注意点,在递归时,左边区间右端点只能使用j,不能用i:
cpp
quick_sort(arr, l, j); // 注意,这里只可以用j作为分区!
quick_sort(arr, j + 1, r);
同向双指针(Lomuto 分区)
这里讲到的是左指针起始于
l-1的标准写法。
Lomuto 分区法用一个左指针 i 记录"小于等于 pivot 的区域",另一个右指针 j 遍历整个数组并进行比较。
整个过程中我们维护:
text
[ ≤pivot 区 | >pivot 区 | 未处理区 | pivot ]
l ...... i i+1 ... j-1 j ... r-1 r
其中,i始终指向[小于等于 pivot 的区间]中的末尾元素,区间为0时为-1。
以 [3, 7, 1, 8, 4, 2, 5]、pivot = arr[r] = 5 为例,走一遍完整流程(先看图理解,再看代码):
text
Lomuto 分区:pivot = arr[r] = 5,i = l-1 = -1
不变式(始终维护):
i 指向 "≤pivot 区间" 的最后一个元素
j 向右扫描,遇到 ≤pivot 就 i++ 再 swap(i, j)
[ ≤ pivot | > pivot | 未处理 | pivot ]
l ... i i+1 ... j-1 j ... r-1 r
↑ ↑
i j
──────────────────────────────────────────────
初始: i=-1
下标 0 1 2 3 4 5 6
[3] [7] [1] [8] [4] [2]│[5] pivot=5
j │
i 在 -1(数组左边界之外)
j=0: arr[0]=3 ≤ 5 → i++→0, swap(0,0)
[3] [7] [1] [8] [4] [2]│[5]
i,j
└≤┘
j=1: arr[1]=7 > 5 → 不动,j 继续
[3] [7] [1] [8] [4] [2]│[5]
i j
j=2: arr[2]=1 ≤ 5 → i++→1, swap(1,2)
[3] [1] [7] [8] [4] [2]│[5]
└≤─┘ i j
(7 和 1 换位)
j=3: arr[3]=8 > 5 → 不动
[3] [1] [7] [8] [4] [2]│[5]
i j
j=4: arr[4]=4 ≤ 5 → i++→2, swap(2,4)
[3] [1] [4] [8] [7] [2]│[5]
└─≤──┘ i j
(7 和 4 换位)
j=5: arr[5]=2 ≤ 5 → i++→3, swap(3,5)
[3] [1] [4] [2] [7] [8]│[5]
└──≤───┘ i j
(8 和 2 换位)
── 循环结束 (j 到 r-1) ──
最后一步: swap(i+1, r) = swap(4, 6),把 pivot 放到分界点
[3] [1] [4] [2] [5] [8] [7]
└── ≤5 ──┘ ↑ └ >5 ┘
i+1=4
return 4 ← pivot 的最终位置,左右不再含它
结果: [3,1,4,2] 全 ≤5 | 5 | [8,7] 全 >5
然后递归排左半 [l, p-1] 和右半 [p+1, r]
cpp
// 最经典的写法
void q_sort(vector<int>& arr, int l, int r) {
if (l >= r) return;
int pivot = arr[r];
int i = l - 1; // 标准写法就是中l-1开始的,l开始的是另一种写法
for (int j = l; j < r; j++) {
if (arr[j] <= pivot) {
i++;
swap(arr[i], arr[j]);
}
}
swap(arr[i + 1], arr[r]);
// 此时划分结束,[l,i+1]是小于等于pivot的部分,i+1不需进行排序,[i+2,r]是大于pivot的部分
q_sort(arr, l, i);
q_sort(arr, i + 2, r);
}
通常来说,如果用lomuto分区,会抽取一个函数出来,这样更好看一点。
cpp
int partition(vector<int>& arr, int l, int r) {
int pivot = arr[r]; // 选择最右侧元素作为基准值(pivot)
int i = l - 1; // 较小元素的索引
for (int j = l; j < r; j++) {
if (arr[j] <= pivot) {// 如果当前元素小于等于基准值
i++; // 增加较小元素的索引
swap(arr[i], arr[j]);
}
}
swap(arr[i + 1], arr[r]); // 将基准值放到正确的位置
return i + 1; // 返回分区点的索引
}
void q_sort(vector<int>& arr, int l, int r) {
if (l >= r) return;
int p = partition(arr, l, r);
q_sort(arr, l, p - 1); // partition返回i+1,这里就可以写的好看一点,对称一些
q_sort(arr, p + 1, r);
}
一个特点
swap(arr[i + 1], arr[r]); // 要注意,要把基准点放在正确位置上
代码最后一步,是吧基准点放在正确的位置上,这点很关键,同时,这也会造成一个鲜明的特点:
每一次迭代到会让[pivot]这个元素落到正确的位置上,也就是i+1的地方。
为什么 i+1(pivot 的最终落点)一定是正确位置?
循环结束后,内存状态是:
[ l ........... i ][ i+1 ......... r-1 ][ r ]
全部 ≤ pivot 全部 > pivot pivot
这时做 swap(i+1, r),把 pivot 换到 i+1:
[ l ........... i ][ i+1 ][ i+2 ......... r ]
全部 ≤ pivot pivot 全部 > pivot
i+1 是正确位置的理由只有一句话:
i+1左边全是 ≤ pivot 的,右边全是 > pivot 的------在一个升序排列里,pivot 就该待在这个位置,不可能再移动了。
为什么 Lomuto 默认选最右边
纯粹是实现方便 。Lomuto 的循环是 j 从 l 扫到 r-1,pivot 如果放在 r,它就天然地不参与扫描,循环结束后一次 swap(i+1, r) 就能归位,代码最简洁。如果 pivot 选别的位置,需要先把它挪到 r,或者改写循环逻辑------功能一样,但代码变复杂了。
其他元素可以做 pivot 吗?
任何元素都可以,但选法影响性能:
| 选法 | 平均性能 | 最坏情况 | 说明 |
|---|---|---|---|
| 固定选最右(或最左) | O(n log n) | O(n²) | 已排序数组会退化 |
| 随机选一个 | O(n log n) | O(n²) 概率极低 | 工程最常用 |
| 三数取中(首/中/尾) | O(n log n) | 更难触发退化 | 稍复杂但稳健 |
最坏情况是怎么触发的: 固定选最右,输入是已排序数组时,每轮 pivot 都跑到端点,分区极度不均衡,递归深度退化成 n 层,整体 O(n²)。
工程做法------随机化 pivot,只需在循环前加一行:
cpp
int randIdx = l + rand() % (r - l + 1);
swap(arr[randIdx], arr[r]); // 换到最右,后续代码不变
这样攻击者无法构造必然退化的输入,期望复杂度稳定在 O(n log n)。
非原地额外空间法
思路最直接:开一个额外数组,遍历区间,比 pivot 小的放一组、大的放一组,再拼回去。
不在原数组上操作,所以叫「非原地」。
- 优点:好理解、好写,相等元素相对顺序天然保留(可做到稳定)。
- 缺点:需要 O(n) 额外空间,失去了快排「原地排序」的核心优势。
- 用途:本身在数组快排里不常用,但链表快排因为没法随机访问,用它反而顺手(见后文链表部分)。不过链表排序还是首选归并排序,只是不好理解,用快排的非原地法好理解一点。
- 结合下面快排的抽取中的partition函数来理解,会非常好理解
快排的抽取
快排其实还可以抽象一下,本质上是partition函数的使用,partition函数的作用是把一个范围内的数进行左右分区。即把比某数小的放左边,大的放右边。
不过实现partition函数的方式不同,实现的效果也会有细微的不同,例如如果用的是lomuto分区,那么左边的区域是小于等于pivot,而右边的区域是严格大于pivot,返回数是pivot的位置,不进行排序;如果是hoare分区,则pivot会均匀的分布在左右两个区间里。
关于partition函数的实现,有三种:对向双指针(hoare 分区)、同向双指针(Lomuto 分区)、额外空间法。第一种就是yxc的写法。第二、三种写法可以用来处理单线链表。
根据yxc的模板抽象:(hoare分区)
cpp
int partition(int arr[], int l, int r) { // 对向双指针(hoare 分区)
int i = l - 1, j = r + 1;
int pivot = arr[l + (r - l) / 2]; // 安全的主元选择方式
while (i < j) {
do i++; while (arr[i] < pivot);
do j--; while (arr[j] > pivot);
if (i < j) swap(arr[i], arr[j]);
}
return j; // 返回分界点
}
void q_sort(int arr[], int l, int r) {
if (l >= r) return;
int p = partition(arr, l, r);
q_sort(arr, l, p);
q_sort(arr, p + 1, r);
}
cpp
// 第二种写法
void q_sort(vector<int>& arr, int l, int r) {
if (l >= r) return;
int pivot = arr[r];
int i = l - 1;
for (int j = l; j < r; j++) {
if (arr[j] <= pivot) {
i++;
swap(arr[i], arr[j]);
}
}
swap(arr[i + 1], arr[r]);
q_sort(arr, l, i);
q_sort(arr, i + 1, r);
}
使用partition抽取:
cpp
int partition(vector<int>& arr, int l, int r) { // Lomuto 分区
int pivot = arr[r]; // 选择最后一个元素作为基准
int i = l - 1; // i 是小于基准的区域的边界
for (int j = l; j < r; j++) {
if (arr[j] <= pivot) { // 这个地方是等于
i++;
swap(arr[i], arr[j]);
}
}
swap(arr[i + 1], arr[r]); //
return i + 1;
}
void q_sort(vector<int>& arr, int l, int r) {
if (l >= r) return;
int p = partition(arr,l,r);
q_sort(arr, l, p-1);
q_sort(arr, p+1, r);
}
链表的快排
关于链表快排,有两种写法(不同的partition),一种朴素,一种双指针交换:《链表排序优先使用归并排序》
cpp
1. partition使用双指针的做法(同向双指针法,即Lomuto Partition)
(还有一种拼接的朴素做法,更方便实现结点直接的拼接)
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* ListNode *next;
* ListNode(int x) : val(x), next(NULL) {}
* };
*/
using namespace std;
class Solution {
public:
ListNode* partition(ListNode* head,ListNode* tail){ // 双指针做法,代码少,速度快
// p指向最后一个小于key值的结点,q指向大于key的结点,并不断遍历
ListNode *p = head, *q = head->next;
int key = head->val;
while(q != tail){
if(q->val < key){
p=p->next;
swap(p->val,q->val); // 这种做法是值交换
}
q=q->next;
}
if (p != head) swap(head->val, p->val);
return p;
}
void sort(ListNode* head,ListNode* tail){
if(head == tail) return;
ListNode* pivot = partition(head,tail);
sort(head,pivot);
sort(pivot->next,tail);
}
ListNode* sortList(ListNode* head) {
sort(head,nullptr);
return head;
}
};
2.朴素的做法
using namespace std;
class Solution {
public:
ListNode* partition(ListNode* &head,ListNode* tail){// 这里不用传入tail
// if(head == tail) return head;
auto l = new ListNode(-1); auto r = new ListNode(-1); // 需要new出来新的dummy结点,用以拼接
auto lt = l, rt = r;
int key = head -> val;
for(auto cur = head; cur; cur = cur->next){
if(cur->val < key) {
lt->next = cur;
lt = lt->next;
}else{
rt->next = cur;
rt = rt->next;
}
}
lt->next = rt->next = nullptr;
lt->next = r->next;
auto p = r->next;
head = l->next;
delete l;
delete r;
return p;
}
void sort(ListNode* &head,ListNode* tail){ // 涉及到结点指向了,所以结点需要被改变
if(head == tail) return;
ListNode* pivot = partition(head,tail);
sort(head,pivot);
sort(pivot->next,tail);
}
ListNode* sortList(ListNode* head) {
sort(head,nullptr);
return head;
}
};
归并排序
算法思路
分治思想的算法,归并有点像树的后序遍历,l,r是正常的边界,闭区间
模板
java
//y总板子--归并排序
static void merge_sort(int[] arr, int l, int r) {//这种算法的边界还算是比较正常的
if (l >= r) return;
int mid = l + (r - l) / 2;
merge_sort(arr, l, mid);// 一定要这样分区,一个程序skill,背住
merge_sort(arr, mid + 1, r);
int k = 0, i = l, j = mid + 1;//三指针,k是tmp指针,i是前数组指针,j是后数组指针
int[] tmp = new int[r - l + 1];//我自己加的,可以用局部的,也可以用全局的,随便
while (i <= mid && j <= r) {
if (arr[i] <= arr[j]) tmp[k++] = arr[i++];
else tmp[k++] = arr[j++];
}
while (i <= mid) tmp[k++] = arr[i++];
while (j <= r) tmp[k++] = arr[j++];
int start = l;
for (int t : tmp) arr[start++] = t;
}
其实后面一部分可以抽出来作为一个方法:
cpp
//y总板子--归并排序
void merge(int arr[], int l,int r, int mid){
int k = 0, i = l, j = mid + 1;//三指针,k是tmp指针,i是前数组指针,j是后数组指针
int tmp[r - l + 1];//我自己加的,可以用局部的,也可以用全局的,随便// 最好使用全局,这是变长数组,非C++标准库支持
while (i <= mid && j <= r) {
if (arr[i] <= arr[j]) tmp[k++] = arr[i++]; // 稳定排序的来源,<=
else tmp[k++] = arr[j++];
}
while (i <= mid) tmp[k++] = arr[i++];
while (j <= r) tmp[k++] = arr[j++];
int start = l;
for (int t : tmp) arr[start++] = t;
}
void merge_sort(int arr[], int l, int r) {//这种算法的边界还算是比较正常的
if (l >= r) return;
int mid = l + (r - l) / 2;
merge_sort(arr, l, mid);
merge_sort(arr, mid + 1, r);
merge(arr,l,r,mid);
}
关键点
为什么稳定?
是否稳定在于如何处理相同的元素,在快排中,遇见相同元素被扫描到的顺序是随机的,根据扫描算法的不同被排序的顺序都有可能不同,而在归并排序中,如果遇见两个元素相同,会始终让左边的元素处于前面,这保证了算法的问题。
注,在排序对比的时候,必须是
if (arr[i] <= arr[j]) tmp[k++] = arr[i++];才可以。即元素相同时,让左边数组先放置,如果这里用<,会丧失稳定性。
边界
快排中,使用lo分区时piv点,每轮排序中会放置到正确的位置,因此进入下一轮时无需把piv点放入,使用hoare分区则不同,每次排序piv点的位置并非正确的,所以进入下一轮时依旧是全区间进入。
在归并排序中,每次排序并非让mid进入到了正确的位置,mid只是一个机械的切割点,没有数学含义,所以进入下一次循环时,就是机械的切割重点,然后区分。
但是,在设置下一次递归的边界时候,mid必须要小心,这是一个程序技术问题,如果是用了下方第二种,在一些情况是无法正确返回,会陷入死循环。
merge_sort(arr, l, mid);
merge_sort(arr, mid + 1, r);
merge_sort(arr, l, mid-1);
merge_sort(arr, mid, r);
示例:
假设此时我们的子区间只剩下 2 个元素,即 l = 0, r = 1。
写法二(错误):mid = l + (r - l) / 2
同样是 l = 0, r = 1,计算出来的 mid 依然是 0。
带入第二种写法:
merge_sort(arr, l, mid - 1)→ \rightarrow →merge_sort(arr, 0, -1)------ 触发基准条件,直接返回。merge_sort(arr, mid, r)→ \rightarrow →merge_sort(arr, 0, 1)------ 致命错误发生了!
你会发现,原本进入函数的区间是 [0, 1],经过这次计算后,右半边再次进入递归的区间依然是 [0, 1] 。
这导致子问题的规模完全没有缩小,它会无限次地调用 merge_sort(arr, 0, 1),直到计算机的系统栈被撑爆,报出 Stack Overflow。
关于tmp临时数组
上面的示范代码并不好:每次进入 merge 都要新建一个临时数组,而两种语言的代价不同:
- cpp 版
int tmp[r-l+1]是栈上的变长数组(VLA) ------C99 特性、C++ 标准并不支持(靠 GCC 扩展才能用)。它占栈空间,递归深时各层栈帧的临时数组叠加,数据一大就可能栈溢出。 - java 版
new int[r-l+1]是堆上的数组对象(Java 里所有数组都是这样,没有 VLA)。每次调用都重新 new 一块,方法返回后等 GC 回收(不会立刻释放),带来反复分配和 GC 压力。
两种都造成了不必要的开销。更好的做法是全程复用一个临时数组 ------cpp 开一个全局 tmp[N],java 用成员变量,避免每次进入方法都重新分配。
原地并归是存在的,但是实现起来非常复杂,使用临时数组是最经典的做法。
链表归并排序
链表排序的正确做法(用快排太慢,要用归并)
为什么要用归并而不是快排
快排也可以链表排序,只要partition函数写好,用lo分区可以不用内存,但是这并非是指针重组而是值交换,如果想要指针重组只能申请额外空间,那么这样还不如直接使用归并排序,实现还简单。
一旦快排为了逃避"值交换",被迫走向"new 辅助节点进行拼接"这条路时,它就在与归并排序的竞争中全方位落了下风------论空间,它需要频繁申请堆内存;论时间,它无法保证稳定的 O ( n log n ) O(n \log n) O(nlogn),同时还没有稳定性。
cpp
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* ListNode *next;
* ListNode() : val(0), next(nullptr) {}
* ListNode(int x) : val(x), next(nullptr) {}
* ListNode(int x, ListNode *next) : val(x), next(next) {}
* };
*/
class Solution {
public:
ListNode* get_middle(ListNode* head){
/*get_middle 使用 fast = head->next ,这样做的好处是:可以在 slow 停在中间前一个节点时停止;
并且 slow->next = nullptr 就能直接断开前半部分链表;不需要额外 prev 指针去维护 slow 前驱。*/
auto fast = head->next;
auto slow = head;
while(fast && fast->next){
fast = fast->next->next;
slow = slow->next;
}
auto p = slow->next;
slow->next = nullptr;
return p;
}
ListNode* merge(ListNode* cur1, ListNode* cur2){
auto dummy = new ListNode(-1);
auto res = dummy;
while(cur1 && cur2){
if(cur1->val < cur2->val){
dummy->next = cur1;
cur1=cur1->next;
}else{
dummy->next = cur2;
cur2=cur2->next;
}
dummy = dummy->next;
}
if(cur1) dummy->next = cur1;
if(cur2) dummy->next = cur2;
// 更优雅的写法
dummy->next = cur1 ? cur1 : cir2;
return res->next;
}
ListNode* sort(ListNode* head){
if(!head || !head->next) return head;
auto m = get_middle(head);
auto l = sort(head);
auto r = sort(m);
return merge(l,r);
}
ListNode* sortList(ListNode* head) {
return sort(head);
}
};
快排与归并
两个算法都是分治算法,不过实现各有不同
这里有个有意思的点,就是从实现方法直观来看,并归是先进入递归然后进行逻辑指令,快排是反过来的,先进行逻辑指令,然后进入递归;这种朴素的不同其实代表着两种不同的思想------自底向上与自顶向下。
伪代码
cpp
// 归并排序:先递归,后逻辑
void mergeSort(int[] arr, int low, int high) {
if (low >= high) return;
int mid = low + (high - low) / 2;
mergeSort(arr, low, mid); // 【1】先闭眼递归划分左半边
mergeSort(arr, mid + 1, high); // 【2】再闭眼递归划分右半边
merge(arr, low, mid, high); // 【3】最后执行核心逻辑:合并两个有序数组
}
cpp
// 快速排序:先逻辑,后递归
void quickSort(int[] arr, int low, int high) {
if (low >= high) return;
int p = partition(arr, low, high); // 【1】先执行核心逻辑:切分并确定基准位置
quickSort(arr, low, p - 1); // 【2】然后递归解决左半边
quickSort(arr, p + 1, high); // 【3】然后递归解决右半边
}
题单
题单:
AcWing 1451. 单链表快速排序 (亦可归并)
数学证明是难点
可以用自定义排序来解
板子题
我用模拟过的,官解还是吊,直接对链表进行归并排序,其实如果这样做,需要两个额外的知识:1.快慢指针找链表终点;2.合并双链表(使用dummy头结点,返回dummy.next)
剑指 Offer II 078. 合并排序链表--hard
归并排序的更深理解,另外需要一点上一题的相关内容(融合两个指针)。