归并排序与逆序对

归并排序

例题:P1177 【模板】排序

介绍一种新的排序算法——归并排序。要理解归并排序,首先要理解归并。考虑这样一个问题:给定两个有序的序列 \(a,b\),把两个序列合并成一个序列,使得合并出的这个序列是有序的。

算法的过程很简单,维护两个位置 \(i\)\(j\),代表当前考虑 \(a\) 数组的第 \(i\) 个元素与 \(b\) 数组的第 \(j\) 个元素。如果 \(a_i \le b_j\),则在答案数组添加一个 \(a_i\),同时 \(i\) 向后移动。如果 \(a_i > b_j\),则在答案数组添加一个 \(b_j\),同时 \(j\) 向后移动。注意到,如果 \(a,b\) 两个数组中有一个被合并完了,可以直接把另一个数组剩下的部分接到答案数组最后面。

比如有两个有序数组 \(a=[1,3,7,8], b=[2,4,6,9]\),对这两个数组进行归并:

image

有了归并算法之后,要对一个长度为 \(n\) 的序列进行排序,可以考虑采用分治的思想来解决:如果 \(n=1\),这个序列自然是有序的,所以不用进行排序——这就是可以直接解决的子问题。否则,将序列分为两个长 \(\frac{n}{2}\) 的子序列,对这两个子序列分别递归地进行排序——这是把一个复杂的问题转换为若干个简单一些的问题,然后递归下去解决这些更简答的问题。

当两个子序列有序后,对这两个子序列进行归并,使当前这个长度为 \(n\) 的序列有序——这就是当每个子问题都处理完之后,合并子问题的答案得到原问题的答案。

归并排序的时间复杂度为 \(T(n)=2T(\frac{n}{2})+O(n)=O(n \log n)\)

推导

image

image

#include <cstdio>
const int N = 1e5 + 5;
int a[N], tmp[N]; // tmp是合并时用的临时数组
void mergesort(int l, int r) { // 实现对a[l]~a[r]完成排序
	if (l==r) { // 只剩一个数,无需排序,直接返回
		return;
	}
	int mid=(l+r)/2; // a[l]~a[mid]   a[mid+1]~a[r]
	mergesort(l,mid); mergesort(mid+1,r); // 递归到更小的子问题
	// 上面这两个递归调用返回之后意味着左半边和右半边内部已经有序
	// 接下来要解决合并的问题
	// a[l]~a[mid]    a[mid+1]~a[r]
	// 先合并到 tmp[l]~tmp[r]
	// 最后再搬回 a
	int i=l, j=mid+1; // 两部分的合并进度
	int k=l; // 下一个数据合并到tmp的什么位置
	while (i<=mid && j<=r) {
		if (a[i] <= a[j]) {
			tmp[k]=a[i]; i++;
		} else {
			tmp[k]=a[j]; j++;
		}
		k++;
	}
	// 上面循环结束时必然是左右半区的其中一个已经合并完成
	// 另一个必然还剩下最后一段没有合并进去
	while (i<=mid) {
		tmp[k]=a[i]; i++; k++;
	}
	while (j<=r) {
		tmp[k]=a[j]; j++; k++;
	}
	// 此时tmp[l]~tmp[r]已经合并完成,搬回原数组a
	for (int i=l;i<=r;i++) a[i]=tmp[i];
}
int main()
{
	int n; scanf("%d",&n);
	for (int i=1;i<=n;i++) scanf("%d",&a[i]);
	mergesort(1,n);
	for (int i=1;i<=n;i++) printf("%d ",a[i]);
    return 0;
}

递归版的归并排序是“自顶向下的”:它先把一个大数组不断地对半切分,直到每个子数组只剩一个元素(天然有序),然后再一层一层地把这些有序的子数组合并起来。

非递归版(自底向上)则完全相反。它放弃了递归的“切分”过程,直接从“合并”开始。

想象一下,任何单个元素本身都是一个有序的“子数组”。非递归归并排序的思路就是:

  1. 第一轮:将数组中相邻的** 1 个元素 1 个元素**进行合并,形成若干个长度为 2 的有序子数组。
  2. 第二轮:将数组中相邻的长度为 2 的有序子数组进行合并,形成若干个长度为 4 的有序子数组。
  3. 第三轮:将数组中相邻的长度为 4 的有序子数组进行合并,形成若干个长度为 8 的有序子数组。
  4. ...以此类推,每一轮都将上一轮合并好的、更长的有序子数组进行两两合并,直到整个数组合并成一个,排序完成。

这个过程就像从最小的砖块(单个元素)开始,一步一步搭建成一堵有序的墙(整个数组),所以被称为“自底向上”。

算法步骤

整个算法由两个嵌套的循环控制:

  1. 外层循环:控制每次要合并的子数组的长度 sub_len。这个长度从 1 开始,每次循环后翻倍(sub_len = 1, 2, 4, 8, ...)。
  2. 内存循环:根据当前的 sub_len,遍历整个数组,对所有相邻的、长度为 sub_len 的子数组进行两两合并。
    • 例如,当 sub_len = 1 时,它合并 arr[0]arr[1],然后合并 arr[2]arr[3],以此类推。
    • sub_len = 2 时,它合并 arr[0...1]arr[2...3],然后合并 arr[4...5]arr[6...7],以此类推。

假设有数组 [8, 3, 6, 4, 9, 2, 7, 5]

第 1 轮(sub_len = 1:合并长度为 1 的子数组

  • 合并 [8][3],得到 [3, 8]
  • 合并 [6][4],得到 [4, 6]
  • 合并 [9][2],得到 [2, 9]
  • 合并 [7][5],得到 [5, 7]
    数组变为:[3, 8, 4, 6, 2, 9, 5, 7]

第 2 轮(sub_len = 2:合并长度为 2 的有序子数组

  • 合并 [3, 8][4, 6],得到 [3, 4, 6, 8]
  • 合并 [2, 9][5, 7],得到 [2, 5, 7, 9]
    数组变为:[3, 4, 6, 8, 2, 5, 7, 9]

第 3 轮(sub_len = 4:合并长度为 4 的有序子数组

  • 合并 [3, 4, 6, 8][2, 5, 7, 9],得到 [2, 3, 4, 5, 6, 7, 8, 9]
    数组变为:[2, 3, 4, 5, 6, 7, 8, 9]

此时 sub_len 变为 8,大于等于数组长度,循环结束。排序完成。

关键点:处理边界情况。当数组长度不是 2 的幂时,最后一组可能不完整(只有一个子数组,或者第二个子数组比 sub_len 短),代码需要能正确处理这种情况。

参考代码
#include <cstdio>
#include <vector>
#include <algorithm>
using std::vector;
using std::min;
/**
 * @brief 归并两个有序子数组 arr[left...mid] 和 arr[mid+1...right]
 * 
 * @param arr 待排序的数组
 * @param left 左子数组的起始索引
 * @param mid 左子数组的结束索引
 * @param right 右子数组的结束索引
 */
void merge(vector<int> &arr, int left, int mid, int right) {
    // 创建一个临时数组来存储归并后的结果
    vector<int> temp(right - left + 1);
    int i = left;       // 左子数组的指针
    int j = mid + 1;    // 右子数组的指针
    int k = 0;          // 临时数组的指针
    // 当两个子数组都还有元素时,比较并放入临时数组
    while (i <= mid && j <= right) {
        if (arr[i] <= arr[j]) {
            temp[k++] = arr[i++];
        } else {
            temp[k++] = arr[j++];
        }
    }
    // 如果左子数组还有剩余元素,全部复制到临时数组
    while (i <= mid) {
        temp[k++] = arr[i++];
    }
    // 如果右子数组还有剩余元素,全部复制到临时数组
    while (j <= right) {
        temp[k++] = arr[j++];
    }
    // 将排序好的临时数组内容复制回原数组的对应位置
    for (int l = 0; l < temp.size(); l++) {
        arr[left + l] = temp[l];
    }
}
void mergeSortIterative(vector<int> &arr) {
    int n = arr.size();
    if (n <= 1) return; // 数组为空或只有一个元素,无需排序
    for (int sub_len = 1; sub_len < n; sub_len *= 2) { // 外层循环:控制每次合并的子数组长度(1, 2, 4, 8, ...)
        // 内层循环:遍历数组,找到需要合并的左右子数组对
        for (int left_start = 0; left_start < n - 1; left_start += 2 * sub_len) {
            // 计算左子数组的结束位置
            int mid = min(left_start + sub_len - 1, n - 1);
            // 计算右子数组的结束位置
            int right_end = min(left_start + 2 * sub_len - 1, n - 1);
            // 调用归并函数,合并 arr[left_start...mid] 和 arr[mid+1...right_end]
            // 注意:如果右子数组不存在(mid >= right_end),则不会发生合并
            if (mid < right_end) {
                merge(arr, left_start, mid, right_end);
            }
        }
    }
}
int main()
{
    int n; scanf("%d", &n);
    vector<int> a(n);
    for (int i = 0; i < n; i++) scanf("%d", &a[i]);
    mergeSortIterative(a);
    for (int i = 0; i < n; i++) {
        printf("%d%c", a[i], i == n - 1 ? '\n' : ' ');
    }
    return 0;
}

归并排序算法是由伟大的数学家和计算机科学家约翰·冯·诺依曼(John Von Neumann)发明的。

他在 1945 年首次提出了这个算法。当时,冯·诺依曼正在参与早起电子计算机 EDVAC 的设计工作,归并排序是他为这台计算机设想的排序程序的一部分。

冯·诺依曼的这一发明在计算机科学史上具有里程碑式的意义,因为:

  • 它是最早为电子计算机设计的算法之一。
  • 它的时间复杂度稳定在 \(O(n \log n)\),在最坏情况下也表现优异。
  • 它为后来的许多算法设计(尤其是基于分治思想的算法)提供了重要的启示。

选择题:设 A 和 B 是两个长度为 n 的有序数组,现在需要将 A 和 B 合并成一个排好序的数组,问任何以元素比较作为基本运算的归并算法,在最坏情况下至少要做多少次比较?

  • A. \(n^2\)
  • B. \(n \log n\)
  • C. \(2n\)
  • D. \(2n-1\)
答案

D

回顾归并的算法流程,每进行一次比较,就会有一个元素被确定并放入结果数组

最好情况

假设数组 A 的所有元素都小于数组 B 的所有元素(例如 A = {1,2,3}, B = {4,5,6})。算法会先将 A 的所有元素逐一与 B 的第一个元素比较。在 n 次比较之后,A 数组的所有元素都被放入结果数组中,A 数组被耗尽。此时,B 数组的所有元素将直接被复制到结果数组的末尾,不再需要任何比较。因此,最好情况下的比较次数是 n 次。

最坏情况

为了让比较次数最多,需要让“其中一个数组被耗尽”的过程尽可能晚。所以,最坏情况发生在两个数组的元素被交替选入结果数组中,直到最后一刻才有一个数组被耗尽。构造一个最坏的例子,A = {1,3,5,...}, B = {2,4,6,...}。在合并过程中,A 的第一个元素和 B 的第一个元素比较,A 的第二个元素和 B 的第一个元素比较,A 的第二个元素和 B 的第二个元素比较,……,两个数组的指针会交替前进。这个比较过程会一直持续,直到结果数组中已经放入了 2n-1 个元素。此时,只剩下最后一个元素(它必然是所有元素中最大的),它无需再比较,直接放入结果数组的末尾即可。因为每放入一个元素(除了最后一个)都需要一次比较,所以放入 2n-1 个元素就需要 2n-1 次比较。


逆序对

例题:P1908 逆序对

对于给定的一段正整数序列 \(a\),逆序对是序列中 \(a_i>a_j\)\(i<j\) 的有序对。求一个长度为 \(n\) 的序列的逆序对个数,其中 \(1 \le n \le 5 \times 10^5\)

分析:对于这个问题,可以在归并排序的过程中同时求出序列的逆序对数。

如果 \(n=1\),这个序列的逆序对个数自然是 \(0\)——这就是可以直接解决的子问题。否则,将序列分为两个长度为 \(\frac{n}{2}\) 的子序列,对这两个子序列分别递归地求出其内部的逆序对——这是把一个复杂的问题转换为若干个简单一些的问题,然后递归下去解决简单一些的问题。

当递归计算了两个子序列内部的逆序对数后,考虑怎么合并这两个子序列。可以发现逆序对还有一种来源,前一个序列中某个元素和后一个序列中某个元素所构成的逆序对,因此还要计算这部分的个数——这就是当每个子问题都处理完之后,合并子问题的答案得到原问题的答案。

image

那么如何算这种一前一后的情况呢?由于在序列位置中,前一个子序列中的元素一定在后一个子序列中的元素的前面,所以逆序对的 \(i<j\) 已经自然满足了,只需要再考虑 \(a_i>a_j\)

回顾归并排序的归并过程。对两个有序数组 \(a\)\(b\) 归并的时候,如果某次比较之后在答案数组中放入的元素是 \(b_j\),而和 \(b_j\) 做比较的元素是 \(a_i\),那么一定有 \(a_i, a_{i+1}, \dots\) 均比 \(b_j\) 大,所以在归并排序的过程中,每当在答案数组中放入 \(b_j\) 时,会产生一批逆序对,这样就可以边归并排序边求出整个序列的逆序对数了。

时间复杂度和归并排序一样,为 \(O(n \log n)\)

#include <cstdio>
using ll = long long;
const int N = 5e5 + 5;
int a[N], tmp[N]; // tmp是合并时用的临时数组
ll mergesort(int l, int r) { // 实现对a[l]~a[r]完成排序
	if (l==r) { // 只剩一个数,无需排序
		return 0;
	}
	int mid=(l+r)/2; // a[l]~a[mid]   a[mid+1]~a[r]
	ll sum=0;
	sum += mergesort(l,mid); 
	sum += mergesort(mid+1,r);
	// 上面这两个递归调用返回之后意味着左半边和右半边内部已经有序
	// 接下来要解决合并的问题
	// a[l]~a[mid]    a[mid+1]~a[r]
	// 先合并到 tmp[l]~tmp[r]
	// 最后再搬回 a
	int i=l, j=mid+1; // 两部分的合并进度
	int k=l; // 下一个数据合并到tmp的什么位置
	while (i<=mid && j<=r) {
		if (a[i]<=a[j]) { // a[i]<=a[j]说明这次合并取左边的数
			tmp[k]=a[i]; i++;
		} else { // a[i]>a[j] 取右边的数
			// (a[i],a[j]) 构成了逆序对
			// (a[i+1,...mid],a[j]) 都构成了逆序对
			sum+=(mid-i+1);
			tmp[k]=a[j]; j++;
		}
		k++;
	}
	// 上面循环结束时必然是左右半区的其中一个已经合并完成
	// 另一个必然还剩下最后一段没有合并进去
	while (i<=mid) {
		tmp[k]=a[i]; i++; k++;
	}
	while (j<=r) {
		tmp[k]=a[j]; j++; k++;
	}
	// 此时tmp[l]~tmp[r]已经合并完成,搬回原数组a
	for (int i=l;i<=r;i++) a[i]=tmp[i];
	return sum;
}
int main()
{
	int n; scanf("%d",&n);
	for (int i=1;i<=n;i++) scanf("%d",&a[i]);
	ll ans=mergesort(1,n);
	printf("%lld\n",ans);
    return 0;
}

例题:UVA10810 Ultra-QuickSort

只通过比较和交换相邻两个数值的排序方法,实际上就是冒泡排序。在排序过程中每找到一对大小颠倒的相邻数值,把它们交换,就会使整个序列的逆序对个数减少 \(1\)。最终排好序后逆序对个数显然为 \(0\),所以对 \(a\) 进行冒泡排序需要的最少交换次数就是序列 \(a\) 中逆序对的个数,直接使用归并排序求出 \(a\) 的逆序对数就是本题的答案。

参考代码
#include <cstdio>
using ll = long long;
const int N = 5e5 + 5;
int a[N], tmp[N];
ll mergesort(int l, int r) {
    if (l == r) return 0;
    int mid = l + (r - l) / 2;
    ll res = 0;
    res += mergesort(l, mid); 
    res += mergesort(mid + 1, r);
    int i = l, j = mid + 1, k = l;
    while (i <= mid && j <= r) {
        if (a[i] <= a[j]) {
            tmp[k] = a[i]; i++;
        } else {
            tmp[k] = a[j]; j++;
            res += mid - i + 1;
        }
        k++;
    }
    while (i <= mid) tmp[k++] = a[i++];
    while (j <= r) tmp[k++] = a[j++];
    for (int p = l; p <= r; p++) a[p] = tmp[p];
    return res;
}
int main()
{
    int n;
    while (true) {
        scanf("%d", &n);
        if (n == 0) break;
        for (int i = 1; i <= n; i++) scanf("%d", &a[i]);
        printf("%lld\n", mergesort(1, n));
    }
    return 0;
}

例题:P10451 Innovative Business

看起来像是排序但不是严格意义上的排序,因为没有传递性虽然没有传递性,但只需要保证相邻元素的关系成立,而归并排序的过程是可以保证这一点的。

归并排序的核心过程是将两个有序序列合并,每次都会比较 \(a\)\(b\) 两个有序序列剩余部分的第一个元素,用“穿针引线”的方式合并起来,这个过程中涉及到的比较操作的结果最终都会反映到合并后的结果序列中的相邻两个元素中。

参考代码
#include <iostream>
using namespace std;

const int N = 1005;
int a[N], tmp[N];

// 交互函数
bool compare(int a, int b)
{
    cout << "? " << a << ' ' << b << endl;
    bool t;
    cin >> t;
    return t;
}

// 归并排序
void merge_sort(int l, int r) {
    if (l >= r) {
        return; // 区间内最多一个元素,自然有序
    }

    // 分治
    int mid = l + (r - l) / 2;
    merge_sort(l, mid);
    merge_sort(mid + 1, r);

    // 归并
    int i = l, j = mid + 1, k = l;
    while (i <= mid && j <= r) {
        // 比较左右两个有序子数组的当前元素
        if (compare(a[i], a[j])) {
            tmp[k++] = a[i++];
        } else {
            tmp[k++] = a[j++];
        }
    }

    // 将剩余的元素(如果有的话)复制到tmp数组
    while (i <= mid) {
        tmp[k++] = a[i++];
    }
    while (j <= r) {
        tmp[k++] = a[j++];
    }

    // 将排好序的归并结果从tmp复制回原数组a
    for (int p = l; p <= r; ++p) {
        a[p] = tmp[p];
    }
}

int main() {
    int n;
    cin >> n;

    // 初始化数组为 1, 2, ..., n
    for (int i = 1; i <= n; ++i) {
        a[i] = i;
    }

    // 调用归并排序
    merge_sort(1, n);

    // 按格式输出答案
    cout << "!";
    for (int i = 1; i <= n; ++i) {
        cout << " " << a[i];
    }
    cout << endl;

    return 0;
}

例题:P10454 奇数码问题

奇数码游戏两个局面可达,当且仅当两个局面下网格中的数依次写成一行 \(n^2-1\) 个元素的序列后(不考虑空格),逆序对个数的奇偶性相同,例如题目描述中的第一个局面写成 \(5,2,8,1,3,4,6,7\)。该结论的必要性很容易证明:空格左右移动时,写成的序列显然不变;空格向上(下)移动时,相当于某个数与它后(前)边的 \(n-1\) 个数交换了位置,因为 \(n-1\) 是偶数,所以逆序对数的变化也只能是偶数。该结论的充分性证明较为复杂,这里不展开讨论。

上面的结论还可以扩展到 \(n\) 为偶数的情况,此时两个局面可达,当且仅当两个局面对应网格写成序列后,“逆序对数之差”和“两个局面下空格所在的行数之差”奇偶性相同。事实上,在 \(n \times m\) 网格上(\(n,m \ge 2\))也服从上述两个结论之一(根据列数奇偶性分情况讨论)。

总而言之,\(n \times m\) 数码问题的有解性判定,可以转化为归并排序求逆序对来解决。

参考代码
#include <cstdio>
using ll = long long;
const int N = 500 * 500 + 5;
int a[N], tmp[N];
ll mergesort(int l, int r) {
    if (l >= r) return 0; 
    int mid = l + (r - l) / 2;
    ll res = 0;
    res += mergesort(l, mid); 
    res += mergesort(mid + 1, r);
    int i = l, j = mid + 1, k = l;
    while (i <= mid && j <= r) {
        if (a[i] <= a[j]) {
            tmp[k++] = a[i++];
        } else {
            tmp[k++] = a[j++];
            res += mid - i + 1;
        }
    }
    while (i <= mid) tmp[k++] = a[i++];
    while (j <= r) tmp[k++] = a[j++];
    for (int p = l; p <= r; p++) a[p] = tmp[p];
    return res;
}
int main()
{
    int n;
    while (~scanf("%d", &n)) {
        int cnt = 0;
        for (int i = 1; i <= n * n; i++) {
            int val;
            scanf("%d", &val);
            if (val != 0) {
                a[++cnt] = val;
            }
        }
        ll inv1 = mergesort(1, cnt);
        
        cnt = 0;
        for (int i = 1; i <= n * n; i++) {
            int val;
            scanf("%d", &val);
            if (val != 0) {
                a[++cnt] = val;
            }
        }
        ll inv2 = mergesort(1, cnt);

        if (inv1 % 2 == inv2 % 2) {
            printf("TAK\n");
        } else {
            printf("NIE\n");
        }
    }
    return 0;
}
posted @ 2026-07-18 20:34  RonChen  阅读(5)  评论(0)    收藏  举报