基本算法和数据结构回顾


个人感觉,学习算法和数据结构后一段时间,往往会忘掉很多东西,于是我给自己写了这 个回顾系列,什么东西想不起来时就翻看一下。

排序

如果存在多个排序码相同的记录,经过排序后,相同排序码记录的相对次序如果保持不变 ,则称这种排序方法是稳定的,对应Linux系统中sort命令的-s--stable参数。

插入排序

插入排序的基本方法是,每一步将一个待排序的记录,按其排序码大小,插到前面已经排 序的数据中的适当位置,直到全部插入完成为止。

直接插入排序

直接插入排序就是从第二个数据开始,和前面的所有数据比较,使前面的数据都是有序的 ,时间复杂度最好情况下是O(n),最坏情况下是O(n^2)。

二分法插入排序

通过二分法比较,平均复杂度为O(n^2)。

Shell排序

首先取一个整数d1 < n,把全部记录分成 d1 个组,所有距离为 d1 的倍数的记录放在 一组中,先在各组内排序,然后取d2 < d1重复上述分组和排序工作,直到d1 = 1,即 所有记录放在一组中为止。

Shell排序的时间复杂度大约是O(n^1.3)。

一个简单的Shell排序程序如下:

/* shellsort,按递增顺序对v[0]...v[n-1]进行排序 */
void shellsort(int v[], int n) {
    int gap, i, j, temp;
    /* 控制两个被比较元素之间的距离 */
    for (gap = n/2; gap > 0; gap /= 2)
        /* 用于在元素间移动位置,使每一组间隔gap的元素排序 */
        for (i = gap; i < n; i++)
            /* 从最左边开始,每隔gap取一个元素,排序
             * 注意当取到该组的第k个元素时,组内之前的元素已经排好序了
             * 所以之需要找到组内从右向左第一个小于它的元素,并把第k个元素
             * 与这个元素后面的那个元素互换,就能保证这k个元素是排好序的
             */
            for (j=i-gap; j>=0 && v[j]>v[j+gap]; j-=gap) {
                temp = v[j];
                v[j] = v[j+gap];
                v[j+gap] = temp;
            }
}

选择排序

选择排序的基本方法是:每步从待排序的记录中选出排序码最小的记录,顺序放在已经排 序的记录序列的后面,直到全部排完。

直接选择排序

就是“选择排序”的最基本语义的定义。时间复杂度为O(n^2)。

堆排序

使用一个堆存放未排序的数据,每次从堆中取出堆顶数据放到已排序数据后面,然后重新 调整堆,再取新的堆顶数据,直到堆中元素个数为0。

堆排序的时间复杂度为O(n*log2(n))

交换排序

交换排序的基本方法是:两两比较待排序记录,如果不满足顺序要求则交换,直到全部满 足为止。

冒泡排序

冒泡排序通过相邻记录之间的比较和交换,使较大的逐步向后移动,而较小的向前移动, 每一趟冒泡后,最大的元素就会在最后,然后再对之前的数据进行一次冒泡,如果某一趟 冒泡中没有发生数据交换,说明数据已经排序好了。

当文件是正序时,显然时间复杂度为O(n),最坏时间复杂度为O(n^2)。

快速排序

快速排序的基本思想是:在待排序的n个记录中任意取一个记录为分区标准,把所有小于该 记录的移动到左边,大于该记录的移动的右边,称之为一趟排序;然后,对前后两个子序 列分别重复上述过程。

void swap(int v[], int i, int j) {
    int tmp;
    tmp = v[i];
    v[i] = v[j];
    v[j] = tmp;
}

/* 快速排序,对v[left] ... v[right]进行排序 */
void qsort(int v[], int left, int right) {
    int i, last;
    if (left >= right)
        return;

    /* 取中间一个元素为划分子集的元素, 并且把它和第一个元素交换 */
    swap(v, left, (left + right)/2);
    /* last表示的位置是其左边(包括它自己)的所有元素都小于特定元素 */
    /* 首先把last定位到开头,然后逐个比较元素是否小于特定元素 */
    /* 如果小于的话,就把它和last的下一位交换,同时last增加1 */
    /* 这样仍然满足last以及last之前的所以元素都小于特定元素 */
    last = left;
    /* 由于之前已经把特定元素放到开头了,所以从第二个元素开始和v[left]比较 */
    for (i = left+1; i <= right; i++)
        /* 如果小于的话,就把它和last的下一位交换,同时last增加1 */
        if (v[i] < v[left])
            swap(v, ++last, i);
    /* 注意,一开始的时候我们是把特定元素放到第一项的, 而last表示的是小于 */
    /* 特定元素的最后一项,现在交换它们的位置,也就是把特定元素放到中间 */
    /* 左边的都小于它,右边的都大于它 */
    swap(v, left, last);

    /* 对剩下的两部分进行同样的操作 */
    qsort(v, left, last-1);
    qsort(v, last+1, right);
}

当待排序记录已经排序时,用快速排序算法的执行时间最长。最坏情况下时间复杂度是 O(n^2),平均是O(n*log2(n))。快速排序是不稳定的。

分配排序

分配排序的思想是把排序码分解成若干部分,然后通过对各个部分排序码的分别排序,最 终达到整个排序码的排序。

考虑排序扑克牌,那么可以按照花色先分成4部分,黑-红-梅-方,然后再在各个部分内部 排好序。

对于数字,可以先按照个位排序,然后按照十位排序,依次类推。

归并排序

归并排序的主要思想是:把待排序的文件分成若干个子文件,先将每个子文件内的记录排 序,再将已排序的子文件合并,得到完全排序的文件。合并时开始只要比较各子文件第一 个记录的排序码,排序码最小的记录为排序后的第一个记录,取出该记录,继续比较各子 文件的第一个记录,找出排序后的第二个记录,如此反复,经过一次扫描,得到排序结果 。

/*
 * 归并排序
 */

/*
 * v[row]到r[m]和r[m+1]到r[high]是存储在同一个数组中且相邻的两个有序的子文件
 * merge函数将这两个子文件合并成一个有序的文件,并存放在v1[low]到v1[high]中
 */
void merge(int v[], int v1[], int low, int m, int high) {
    int i = low;
    int j = m + 1;
    int k = low;
    /* 不断从两个有序文件中的第一个记录中选出最小的记录 */
    while ((i <= m) && (j <= high)) {
        if (v[i] <= v[j])
            v1[k++] = v[i++];
        else
            v1[k++] = v[j++];
    }
    /* 分别复制两个文件的剩余记录,事实上只会有一个文件有记录 */
    while (i <= m)
        v1[k++] = v[i++];
    while (j <= high)
        v1[k++] = v[j++];
}

/*
 * 一趟归并算法,对长度为n的v做一趟归并,结果放在v1中, seg表示子文件的长度
 * 子文件应该是:v[0]~v[seg-1], v[seg]~v[2*seg-1], ...
 * 要注意,最后一个的长度有可能小于seg
 */
void merge_pass(int v[], int v1[], int size, int seg) {
    int i = 0;
    int j = 0;
    /* 两个两个地对文件进行归并 */
    while (i + 2 * seg -1 < size) {
        merge(v, v1, i, i+seg-1, i+2*seg-1);
        i += 2 * seg;
    }

    if (i+seg-1 < size-1)    /* 剩下两个文件,其中一个长度可能小于seg */
        merge(v, v1, i, i+seg-1, size-1);
    else                  /* 只剩下最后一个文件,直接复制即可 */
        for (j=i; j<size; j++)
            v1[j] = v[j];
}

/*
 * 二路归并算法
 * 通过改变seg的大小一边一遍地对数据进行merge_pass
 */
void merge_sort(int v[], int size) {
    int *temp = malloc(size * sizeof(int));
    int seg = 1;
    while (seg < size) {
        /* 把v进行一趟归并,存放到temp中 */
        merge_pass(v, temp, size, seg);
        seg += seg;
        /* 增加seg的值后对temp进行一次归并,存放到v中 */
        merge_pass(temp, v, size, seg);
        seg += seg;
    }
    free(temp);
}

二路归并排序的时间复杂度是O(n*log2(n))

二叉树

一些概念

节点的层数:根的层数为0,其他节点的层数为其父节点层数加1。

节点的度数:节点的非空子树的个数。

完全二叉树:只有最下面两层节点度数小于2,其余节点度数都为2。 对于完全二叉树,如果用数组从上到下,从左到右地表示树的所有节点,则节点i如果有父 节点,则父节点是(i-1)/2;如果有左子节点,则为2i+1,如果有右子节点,则为 2i+2

堆与优先队列

堆的定义

n个元素的序列 K = (k[0], k[1], …, k[n-1]),满足:

(1) { k[i] >= k[2i+1], k[i] >= k[2i+2] } 或者 (2) { k[i] <= k[2i+1], k[i] <= k[2i+2] }

(1)叫做大根堆,(2)叫做小根堆。

可以用完全二叉树表示堆,对于小根堆,就表示每个子二叉树的根都小于其左、右子节点 ,这种堆叫做二叉堆。

优先队列:就是“最小元素先出”的队列。

二叉堆中实现的优先队列,要取出最小元素,直接把堆顶元素取出即可。 取出后,把堆的最后一个元素放到根节点处,此时根节点的左右两个子树都是二叉堆,通 过下面的算法可以把整个堆调整为二叉堆:

比较根节点和左右子节点,找出最小的和根节点互换,那么这三个节点满足堆的特性,对 于发生互换的那个子树,比较其根节点与左右子节点,取最小的与根互换,依次类推直到 最后。

哈夫曼树(最优二叉树)

如果数中的所有节点都有一个权重,那么对“从根节点到各个外部节点的路径长度与相应节 点权重的乘积”求和,这个和叫做“带权重的外部路径长度(WPL)”,使这个WPL最小的树, 就叫“哈夫曼树”。

构造哈夫曼树的基本思想是:

  1. 每个数据构成一颗树(只有根节点);

  2. 从所有数据中找出最小和第二小的两个树,最为左子树和右子树构成二叉树,根节点的 权重是两个子节点权重只和;

  3. 把根节点作为数据,和剩下的其他数据比较,再选出第一小和第二小的两个树,重复步 骤2,知道最后只剩下一颗树。

二叉排序树

二叉排序树又叫二叉搜索树,它的所有子树都满足:左子节点小于根节点,右子 节点大于根节点。

二叉排序树的搜索和插入都比较简单直观,我们看一下它的删除。

  1. 若p结点为叶子结点,即PL(左子树)和PR(右子树)均为空树。由于删去叶子结点不破 坏整棵树的结构,则只需修改其双亲结点的指针即可。
  2. 若p结点只有左子树PL或右子树PR,此时只要令PL或PR直接成为其双亲结点f的左 子树(当p是左子树)或右子树(当p是右子树)即可,作此修改也不破坏二叉排序 树的特性。
  3. 若p结点的左子树和右子树均不空。在删去p之后,为保持其它元素之间的相对位 置不变,可按中序遍历保持有序进行调整,可以有两种做法:
    • 在p的左子树中,找出关键码最大的一个节点r(r处于p的左子树中最右下角的位置 ,r一定无右子女),将r的右指针指向p的右子女,用p的左子女代替p节点。
    • 同上一种方法找到r节点,用r节点代替被删除的节点p,p原来的左右子女不变。并 且用原来r的左子女代替原来的r节点。

平衡二叉排序树

二叉排序树容易“一边倒”,这样搜索起来就会很慢,于是有了动态保持平衡的二叉排序树 ,叫做平衡二叉排序树,或者叫AVL树。这种树的左右子树高度之差的绝对值不超 过1。

查找、插入和删除在平均和最坏情况下都是O(log n)。增加和删除可能需要通过一次或 多次树旋转来重新平衡这个树。

关于AVL树的调整平衡以及其他操作,可以参考 AVL树

B树和B+树

下面两个图能表达出两种树的意思。

B树

B树

B+树

B+树


参考资料: