排序算法|快速排序与归并排序

排序

排序中的稳定

简单来说,它的定义是:如果待排序的序列中存在多个键值相等 的元素,在排序前后,这些元素的相对顺序保持不变,那么这个排序算法就是稳定的。

归并排序是稳定的,快速排序是不稳定的


两种排序的常见模板(快排、归并):

快速排序

思路

快排的思路是,把一个区间内的数字重新排列:选定一个标杆数字,比它小的放左边,比它大的放右边。这个排列的实现有三种(对向双指针、同向双指针、非原地额外空间法)(快速记忆:对向、同向、非原地),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 的循环是 jl 扫到 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

带入第二种写法:

  1. merge_sort(arr, l, mid - 1) → \rightarrow → merge_sort(arr, 0, -1) ------ 触发基准条件,直接返回。
  2. 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. 单链表快速排序 (亦可归并)

剑指 Offer II 074. 合并区间

数学证明是难点

剑指 Offer II 075. 数组相对排序

可以用自定义排序来解

剑指 Offer II 076. 数组中的第 k 大的数字

板子题

剑指 Offer II 077. 链表排序

我用模拟过的,官解还是吊,直接对链表进行归并排序,其实如果这样做,需要两个额外的知识:1.快慢指针找链表终点;2.合并双链表(使用dummy头结点,返回dummy.next)

剑指 Offer II 078. 合并排序链表--hard

归并排序的更深理解,另外需要一点上一题的相关内容(融合两个指针)。