跳转至

逆序数与排序

在线性代数中,定义行列式的时候,我们接触了一个叫做逆序数的概念。

而我们知道,在排列里交换两个数的位置时,逆序数的奇偶性改变。由此,我们可以联想数的排序问题应该和逆序数有关。

逆序对和逆序数

一般地,对于\(n\)个两两不同的数组成的数组\((a_1, \dots, a_n)\)(称为这\(n\)个数的一个排列;通常取\(1,2,\dots,n\)),若存在两个数\((a_i, a_j)\)满足\(i < j\)\(a_i>a_j\),则这对数称为逆序对(inversion);而逆序对的总数则称为这个排列的逆序数(inversion number)。

冒泡排序

冒泡排序简介

给定\(n\)个数\(a_1, \dots, a_n\)构成的有序数组,我们希望找到一种算法,将这列数变成升序排列。其中最简单的一种算法便是冒泡排序(bubble sort)。

冒泡排序算法

Step 1 我们先从数组的第一项\(a_1\)开始,若\(a_1>a_2\),则交换两个数的位置;否则,不做任何操作。继续将第一步得到的\(a_2\)\(a_3\)比较,若\(a_2>a_3\),则交换两个数的位置;否则,不做任何操作。以此类推,继续比较至\(a_{n-1},a_n\),Step 1结束。

Step 1完成后,数组里最后一个数即为数组中最大的数。

Step 2 继续上一步得到的数组,由于最后一个已经最大,我们只需要对前\(n-1\)个数做排序,这样,继续使用Step 1中的方法,从\(a_1,a_2\)比较至\(a_{n-2},a_{n-1}\),Step 2结束。

同样地,Step 2完成后,数组里倒数第二个数是数组里第二大的数。

重复以上过程,到Step \(n-1\)之后,所有的数就排序完成了。

Question

如何用数学归纳法证明这一个排序算法的正确性?

冒泡排序的时间复杂度为 \(\Theta(n^2)\)。下面是这一算法的 C 语言示例。

C语言示例

输入格式:第一行为一个正整数 \(n\),代表数组的长度;第二行为 \(n\) 个整数 \(a_1, \dots, a_n\),表示待排序的数列。

输出格式:一行 \(n\) 个数,表示按升序排列的数列。

#include <stdio.h>

const int MAXN = 100;
int a[MAXN];

void bubble_sort(int *start, int *end)
{
    int tmp;
    for (int* i = end - 1; i > start; i--)
    {
        for (int* j = start; j < i; j++)
        {
            if (*j > *(j + 1))
            {
                tmp = *j;
                *j = *(j + 1);
                *(j + 1) = tmp;
            }
        }
    }
}

int main()
{
    int n;
    scanf("%d", &n);
    for (int i = 1; i <= n; i++)
    {
        scanf("%d", &a[i]);
    }
    bubble_sort(a + 1, a + n + 1);
    for (int i = 1; i <= n; i++)
    {
        printf("%d ", a[i]);
    }
    return 0;
}

冒泡排序与初等变换

因为冒泡排序的“交换两数”恰好对应了矩阵初等变换的交换两行,所以可以利用冒泡排序的思想来解决一些初等变换问题。

行列式逆序

将一个行列式的列通过初等变换变为原来的倒序排列(即,设 \(|A| = \text{det}(c_1, c_2, \dots, c_n)\),我们要将行列式变换为 \(|B| = \text{det}(c_n, c_{n - 1}, \dots, c_1)\)),给出一种具体的变换方案,并确定变换后行列式的符号。

Solution

将一个数列变为原来的反序,也即将排列由 \(n, n - 1, \dots, 1\) 变为 \(1, 2, \dots, n\)。可以利用冒泡排序实现。由于数组是反序的,可以验证每次循环都会交换一次相邻数,因此总共交换了 \(1 + 2 + \dots + (n - 1) = n(n - 1)/2\) 次,符号变为 \((-1)^{n(n - 1)/2}\),即 \(|B| = (-1)^{n(n - 1)/2}|A|\)

冒泡排序与逆序数

在冒泡排序中,若给定的\(n\)个数两两不同,则交换的次数等于数组的逆序数。

Proof

设冒泡排序的交换次数为\(s\),逆序数为\(i\)

冒泡排序中,每次交换都对应消除一对相邻的逆序对,而这两个数与其他数的逆序关系,以及其他数之间的逆序关系都不变,此时逆序数\(i\)减少1。

当冒泡排序结束时,排列变成升序排列,逆序数减少到0。所以有\(i-s=0\),即\(i=s\)

归并排序

归并排序简介

归并排序 (merge sort) 是利用分治算法设计的一种排序方法。其平均和最坏时间复杂度均为 \(O(n \log n)\),是一种优秀的排序算法。

归并排序的原理是,先将数列折半,拆分为两个小数列,如此递归下去直至拆分为单个数。单个数本身是有序的,接下来将小数列不断合并,保证合并后的数列依然有序。因为每次合并需要 \(\Theta(n)\) 的时间,归并排序的复杂度是 \(T(n) = T(n / 2) + \Theta(n)\),根据主定理可以得出 \(T(n) = \Theta(n \log n)\)

归并排序的算法实现

函数输入格式:a 为数组指针,l 为开头下标,r 为结尾下标,均为闭区间。

void merge_sort(int a[], int l, int r)
{
    if (l == r)
    {
        return;
    }
    int mid = (l + r) / 2;
    merge_sort(a, l, mid);
    merge_sort(a, mid + 1, r);

    // 合并两个数组
    int i = l, j = mid + 1, k = 0;
    while (i <= mid || j <= r)
    {
        if (i <= mid && j <= r)
        {
            if (a[i] <= a[j])
            {
                tmp[++k] = a[i];
                i++;
            }
            else
            {
                tmp[++k] = a[j];
                j++;
            }
        }
        else if (i <= mid)
        {
            tmp[++k] = a[i];
            i++;
        }
        else
        {
            tmp[++k] = a[j];
            j++;
        }
    }
    for (int i = 0; i < k; i++)
    {
        a[l + i] = tmp[1 + i];
    }
}

归并排序与逆序数

逆序数可以通过在冒泡排序中统计交换数来计算得到,但是,冒泡排序算法的时间复杂度是 \(\Theta(n^2)\),这种算法效率太低了,有没有更快地方法呢?

答案是存在的,可以在归并排序的过程中统计一个数列的逆序数,这样就可以在 \(\Theta(n \log n)\) 时间内求出逆序数。这种方法的原理如下:

归并排序每次将数列折半分为前、后两个部分,容易验证数列的逆序数 = 前半段逆序数 + 后半段逆序数 + “前半段大于后半段”的逆序对数。前两者可以递归求解,而后者可以如下求解:假设我们在合并 \(a, b\) 两个升序数列,每次加入 \(a_i\) 时,设 \(b\) 已经加入 \(b_1, \dots, b_{j - 1}\),则此时 \(a_i\)\(b_1, \dots, b_{j - 1}\) 都大,因此 \(a_i\) 贡献的逆序数是 \(j - 1\)。这样就可以在合并时顺便统计出后者,得到完整的逆序数。

C 语言代码如下:

long long inversion(int a[], int l, int r)
{
    int mid = (l + r) / 2;
    long long ans = 0;
    if (l < mid)
    {
        ans += inversion(a, l, mid);
    }
    if (mid + 1 < r)
    {
        ans += inversion(a, mid + 1, r);
    }
    int i = l, j = mid + 1, k = 0;
    while (i <= mid || j <= r)
    {
        if (i <= mid && j <= r)
        {
            if (a[i] <= a[j])
            {
                tmp[++k] = a[i];
                i++;
                ans += j - mid - 1;
            }
            else
            {
                tmp[++k] = a[j];
                j++;
            }
        }
        else if (i <= mid)
        {
            tmp[++k] = a[i];
            ans += r - mid;
            i++;
        }
        else
        {
            tmp[++k] = a[j];
            j++;
        }
    }
    memcpy(a + l, tmp + 1, k * sizeof(int));
    return ans;
}