Coursera 高级数据结构与算法,北京大学
排序算法
着重注意研究排序算法的稳定性 : 一个排序算法是稳定的,意即有相同权值的元素在排序前后键值的相对关系不变
-
插入排序
每个新添加的元素在之前的已排序子序列中找到自己的位置并插入
算法是稳定的 (若新添加的元素与已排序子序列中的某些元素权值相等,插到这段元素末尾即可)
时间复杂度 \(O(N^2)\) -
插入变种:Shell 排序 (不稳定)
采用 Hibbard 增量序列的 Shell 排序时间复杂度可以达到 \(O(N^{1.5})\)
采取特殊的增量序列甚至可以达到 \(O(NlogN)\) -
选择排序
依次选择待排序序列中的最小元素并直接插入已排序序列的尾部直到所有元素处理完
算法 不稳定 :因为具体在算法实现时,我们采取的方法是给每个位置选择当前元素最小的,即找到合适的元素后将其与当前位置上的元素进行swap
例如这样一个序列5 8 5 2 9
第一次交换是在位置 \(1\) 的 \(5\) 与序列中的最小元素 \(2\) 交换,交换过后两个 \(5\) 的相对顺序被破坏
不使用swap
操作的选择排序可以是稳定的
时间复杂度 \(O(N^2)\) -
堆排序
一次建堆 \(O(N)\) \(+\) \(N\) 次取堆顶 \(O(logN)\)
总时间复杂度 \(O(NlogN)\) -
冒泡排序
每次交换存在的逆序对直到不存在逆序对为止
可以证明对于完全逆序序列时时间复杂度是 \(O(N^2)\)
冒泡排序是 稳定的 -
归并排序
归并算法的本质是将待排序序列分为两部分,依次对分得的两个部分再次使用归并排序,之后再对已经排好序的两个子序列进行合并
合并操作很简单,设定两个指针移动赋值即可(值相等先取左边元素以保证算法稳定性)
归并排序是稳定的,时间复杂度 \(O(NlogN)\)
归并排序优化:
短子序列合并不归并,采用插排("短"的定义在不同环境下不同,一般采取 \(28\))
\(R.Sedgewick\) 优化:在归并两段子序列时,将右侧子序列翻转,指针从两边向中间处理:这样可以省去扫尾操作与边界判断,减短代码长度 -
快速排序
算法思想:分治 \(+\) 归并
通过一趟排序将数组分成两个部分,其中一个部分都比轴值小,另一个部分都比轴值大,然后再分别对这两部分进行递归操作,最后就可以达到全部有序
选择好的轴值可以优化快速排序的效率
算法不稳定,时间复杂度稳定在 \(O(NlogN)\) 很优越
代码:18 年 7 月写的,好怀念!
void qsort(int l, int r)
{
int mid = num[(l + r) / 2];
int i = l, j = r;
while(i <= j)
{
while(num[i] < mid) i++; // i 左侧元素一定小于 mid
while(num[j] > mid) j--; // j 右侧元素一定大于 mid
if(i <= j)
{
swap(num[i], num[j]);
i++; // i 左侧元素一定小于等于 mid
j--; // j 右侧元素一定大于等于 mid
}
}
// 结束 while 语句,i > j
// ...... j (可能有若干个 mid) i .......
// i 左侧元素均小于等于 mid -> l ~ j 元素均小于等于 mid
// j 右侧元素均大于等于 mid -> i ~ r 元素均大于等于 mid
if(i < r) qsort(i, r); // 递归处理 i ~ r
if(l < j) qsort(l, j); // 递归处理 l ~ j
}
-
桶排序
桶排序:从后往前收集待排序数组以保证算法稳定性
复杂度 \(O(N+M)\) -
静态基数排序
桶排序只适用于数据范围较小的情况
当数据范围较大时,可以将排序码拆成多个部分 (高位优先法,低位优先法) 进行比较,在多次桶排之后得到结果
在对数进行排序时,低位优先法 (从低位到高位桶排) 效率较高
下面举一个基数排序的例子:
\({012, 234, 005, 098, 345, 034}\) -> 个位桶排 (高位补 \(0\))
\({012, 034, 234, 005, 345, 098}\) -> 十位桶排 (注意:第二次及以后的桶排一定要稳定!)
\({005, 012, 034, 234, 345, 098}\) -> 百位桶排
\({005, 012, 034, 098, 234, 345}\) -> 完成!
贴个代码:
void RadixSort() {
int Radix = 1; // 模进位,用来取 Array[i]
for (int i = 1; i <= d; ++i) { // d :最高位数,i :对第 i 个排序码(即第 i 位)分配
for (int j = 0; j < 10; ++j)
count[j] = 0; // 桶清零
for (int j = 0; j < n; ++j) {
int k = (Array[j] / Radix) % 10; // 取出第 i 位
count[k]++; // 放入对应桶
}
for (int j = 1; j < r; ++j)
count[j] += count[j - 1]; // 对桶取前缀和,可以很方便找到某个元素应该对应的位置
for (int j = n - 1; j >= 0; --j) { // 对于排序码相同的元素,原本较为靠后的在新序列中也应靠后:这里倒序枚举是算法稳定性的关键
int k = (Array[j] / Radix) % 10;
TempArray[--count[k]] = Array[j]; // 将元素存入对应的位置
}
for (int j = 0; j < n; ++j)
Array[j] = TempArray[j]; // 内容复制回 Array 中
Radix *= 10; // 修改 Radix
}
}
复杂度 \(O(d \dot (n+r))\) 而 \(d\) 是 \(logN\) 级别的
-
链式基数排序
相比静态基数排序更好理解:桶中存储的不再是某个排序码对应元素的个数,而是以队列形式存储具体的元素
每次桶排只需要将新元素加入对应桶队列的末端
待所有元素入桶后再将所有桶队列中的元素从头到尾扫一遍即可完成一遍桶排 -
外排序
说实话,由于应用的不多,关于外存排序算法听的有点懵,,,简单记录一下好了
置换选择排序:一组串经过堆筛选后得到另一个顺串
cin >> m >> n;
for (int i = 1; i <= m; ++i) cin >> s[i]; // 串
for (int i = 1; i <= n; ++i) cin >> hp[i]; // 初始堆
make_heap(hp + 1, hp + n + 1, greater<int>()); // 建堆
int count = n;
for (int i = 1; i <= m; ++i) {
if (count <= 0)
break;
if (s[i] >= hp[1])
hp[++count] = s[i]; // 一定要满足输出栈中元素递增 / 递减,所以若串中元素不能入堆,则保留下来留到下一个串
cout << hp[1] << " ";
pop_heap(hp + 1, hp + count + 1, greater<int>());
--count;
}
赢者树与败者树:\(k\) 路归并算法,实际上是一个完全二叉树,每个内部结点记录两儿子比赛的胜方 / 败方
赢者树比败者树更易理解,代码更好实现
而在更新某一个叶子节点的数据时,败方树比赢者树效率更高:其只需要与其父亲进行比赛一路更新至根即可,而赢者树还需要同自己的兄弟比较来更新父亲
检索算法
检索是在一组记录集合中找到关键码值等于给定值的某个记录 (Record),或者找到关键码值符合特定条件的某些记录的过程
检索效率十分重要,衡量检索效率的指标是 平均检索长度 (Average search length) 即检索过程中对关键码的平均比较次数
-
顺序检索
针对线性表中的所有记录,逐个进行关键码与给定值的比较
若某个记录的关键码与给定值相同,检索成功;若遍历完整个线性表仍未找到,则检索失败 -
二分检索
要求线性表中的记录关键码 有序,且要求线性表数据皆为顺序存储
时间复杂度 \(O(logN)\) -
分块检索
可以视作 线性检索与二分检索的折中
将线性表中的 \(n\) 个记录分为 \(b\) (一般 \(b=\sqrt n\)) 个块,建立辅助数组 \(pos\) 标记记录所属块
前一块中的最大关键码必须小于后一块的最小关键码 (或其他特定的排序方式),而块内不需要有序 -
集合检索
用 \(set\) 来实现检索 (包括 \(bitset\)) -
散列检索
在最理想的情况下:根据 关键值码直接找到记录的存储地址 (类似于数组的下标)
但是由于空间要求的限制,达不到理想情况:因此科学家发明了散列方法 (即 Hash方法,哈希法) -
散列函数 (\(Hash\) 函数):
将关键码值映射到存储位置的函数,通常用 \(h\) 表示即 \(address = hash(key)\)
散列函数应该使函数的结果在线性表的存储范围内,且要尽量避免冲突
冲突:某个散列函数对于不相等的关键码计算出了相同的散列地址
常见的散列函数方法有:除余法,乘余除整,数字分析法,基数转换法,折叠法,平方取中法,ELFhash 字符串函数
平方取中法是一种很好的散列函数:先通过求关键码的平方来扩大差别,再取其中的几位或其组合作为散列地址
折叠法:可以与平方取中法复用,将关键码分割成位数相同的几部分,然后取这几部分的叠加和(舍去进位)作为散列地址
ELFhash 字符串函数 对于散列表中的位置不可能产生不平均的分布,且对于长字符串与短字符串同样有效
int ELFhash(char* key) {
unsigned long h = 0;
while(*key) {
h = (h << 4) + *key++;
unsigned long g = h & 0xF0000000L;
if (g) h ^= g >> 24;
h &= ~g;
}
return h % M;
}
- 如何处理冲突
开散列法:很有效很好理解的方法,插入检索删除都很方便
即将有相同哈希值的关键字指向一个链表,表头地址即哈希值
开散列的空单位应当有特殊标记 (\(-1\) 或 \(inf\)) 或者直接是空指针
闭散列法:
\(d_0=h(K)\) 称为 \(K\) 的基地址,当冲突发生时,使用某种方法为关键码 \(K\) 生成一个散列地址探查序列 \(d_1,d_2,... d_i ,... , d_{m-1}\)
所有 \(d_i (0<i<m)\) 是后继散列地址
插入和检索函数都假定每个关键码的探查序列中至少有一个存储位置是空的,每次插入和检索都沿着探查序列进行操作
我们常常用开散列法处理冲突,高效便捷,且闭散列法涉及到的问题较多
闭散列法可能会产生聚集 (Clustering) 问题:散列地址不同的结点,争夺同一后继散列地址,使得探查序列变长
下面是几种常见的探查序列生成方法:
线性探查,二次探查 (\(1^2, -1^2, 2^2, -2^2, ...\)),伪随机数序列探查
以上这三种探查方法消除了基本聚集(基地址不同的 key 探查序列有重叠),但是会产生二次聚集(两个关键码散列到同一个基地址,还是得到同样的探查序列,所产生的聚集)
这是因为原因探查序列只是基地址的函数,而不是原来关键码值的函数(即 \(Hash\))
解决的方法是双散列探查法:即探查序列是原来关键码值的函数,而不仅仅是基位置的函数
\(d_0 = h1(key)\)
\(d_i = (d_0 + i \times h2(key)) % M\)
散列法的删除:开散列法可以真正删除对应的关键字,而闭散列法不行
这是因为,若在闭散列法将一个关键字置为空单位,在检索某个元素时探查序列便会从中断开从而检索不到该元素;也可能会造成重复插入的情况
解决方法是,当删除某个元素时,将其所在位置为墓碑 (tombstone),标志这个位置为删除位
当检索时,遇到墓碑不应视为空位置而停止,而应继续探查;
当插入时,遇到墓碑也不能立刻将元素插入墓碑位置,而应在继续探查找到空位后再插入到墓碑位置,否则可能会出现 插入同一元素的情况
检索算法的这一部分参考博客
对 PPT 总结的很清楚
索引算法
索引算法一般是用于大规模的数据增删与查询,甚至还涉及到内存外存,读写等等,之前完全没有接触过
- 静态索引
- 倒排索引
- \(B\) 树与 \(B^{+}\) 树
\(m\) 阶 \(B\) 树:每个内部结点可以有 \(m\) 个儿子,且至少有 \(\frac{m}{2}\) 个儿子,根节点除外(根节点至少有 \(2\) 个儿子) - 位索引技术
- 红黑树
也是一种自平衡二叉查找树
性质:根与叶子节点是黑色,每个红结点的两个儿子都是黑节点 (一条路径上不会出现连续两个红色节点)
从任一结点到其每个叶子的所有路径都包含相同数目的黑色结点
性质 2 与性质 3 共同决定了红黑树的稳定效率:从根到叶子的最长的可能路径不多于最短的可能路径的两倍长 (最短路径:全黑,最长路径:黑红黑红...黑)
插入,删除时通过复杂的旋转操作维护红黑树的性质
多维数组与广义表
-
多维数组:
稀疏矩阵:用十字链表存储稀疏矩阵(矩阵中含有较多的 \(0\))
十字链表:分为单向十字链表与双向十字链表,每一个元素都有指针指向同一行与同一列与其相邻的元素
双向循环十字链表:双向十字链表中同一行或同一列元素的首尾相连构成双向循环十字链表,也即 Dancing links (舞蹈线),适用于解决精确覆盖问题 (Exact cover problem)
这里 是一篇利用 Dancing Links 解决 数独问题 的论文 -
广义表
线性表:\(L = (x_0, x_1, ..., x_{n-1})\)
\(L\) 为广义表名称,\(x\) 为广义表元素,每一个元素可能是子表(的表头),也可能是 原子 (不可分的元素)
表头 \(head=x_0\) 表尾为去掉表头形成的子表 \((x_1, x_2, ..., x_{n-1})\)
深度为表中所有元素化为原子后的括号层数
纯表:从根节点到任意叶子结点只有一条路径
可重入表:其元素可能会在表中多次出现,若无环对应 DAG
循环表:包含回路,深度无穷大 -
线性表 \(\subseteq\) 纯表(树) \(\subseteq\) 再入表 \(\subseteq\) 图
循环表(递归表) 是带回路的再入表
高级数据结构
- \(Trie\) 树:\(26\) 叉树
- 后缀树:将一个字符串的所有的后缀建立后缀 \(Trie\) 并将结点压缩,得到的就是后缀树
- 后缀数组:在后缀树的标记节点记录该后缀的位置,\(dfs\) 一遍后缀数组,得到的位置序列即 \(SA\) 数组
后缀数组还有其他求法,倍增+基数排序优化 \(O(NlogN)\),\(DC3\) 法可以达到 \(O(N)\),但常数极大,且难打,还是介绍一下第一个方法吧
首先后缀数组 \(SA[i]\) 记录的是排名为 \(i\) 的后缀位置 (后缀开头在原串中的位置)
每个位置在 \(i\) 长度为 \(2^n\) 字串的排名由位置在 \(i\) 长度为 \(2^{n-1}\) 子串排名为第一关键字,位置在 \(i+2^{n-1}\) 长度为 \(2^{n-1}\) 子串排名为第二关键字基数排序即可得到
void getSA() {
for (int i = 1; i <= n; ++i) buc[x[i] = a[i]]++;
// x[i] 数组标记排名:开头为 $i$ 的子串第一关键字的排名
for (int i = 1; i <= m; ++i) buc[i] += buc[i - 1];
for (int i = n; i >= 1; --i) sa[buc[x[i]]--] = i;
// sa[i] 数组标记位置:第一关键字排名为 $i$ 的子串的位置
for (int k = 1; k <= n; k <<= 1) {
int p = 0;
for (int i = n - k + 1; i <= n; ++i) y[++p] = i;
// y[i] 数组标记位置:第二关键字排名为 $i$ 的子串的位置
for (int i = 1; i <= n; ++i) // 按排名从小到大枚举所有长度为 k 的子串
if (sa[i] > k) y[++p] = sa[i] - k;
// 排名为 $i$ 长度为 k 的子串,可以作为 sa[i]-k 位置,长度为 $2k$ 子串的第二关键字
for (int i = 1; i <= m; ++i) buc[i] = 0;
for (int i = 1; i <= n; ++i) buc[x[i]]++;
for (int i = 1; i <= m; ++i) buc[i] += buc[i - 1];
for (int i = n; i >= 1; --i) sa[buc[x[y[i]]]--] = y[i], y[i] = 0;
// x[y[i]] : 第二关键字排名为 i 的长度 k 子串第一关键字的排名,buc[x[y[i]] : 第二关键字排名为 i 长度 2k 位置为 y[i] 子串的总排名
swap(x, y);
// x[i] 为要求的新的开头为 i 长度为 2k 子串的第一关键字排名,y[i] 为开头为 i 长度为 k 的子串第一关键字排名
x[sa[1]] = 1, p = 1;
// 此时的 x 与 sa 互为逆运算,这里的 x 其实就是有些博客所说的 rank 数组
for (int i = 2; i <= n; ++i)
x[sa[i]] = (y[sa[i]] == y[sa[i - 1]] && y[sa[i] + k] == y[sa[i - 1] + k]) ? p : ++p;
// 相同的串排名相同
if (p == n) break; // 若排名达到 n 说明排序完成
m = p;
}
}
代码与各个数组的关系确实很绕,需要多思考,想清楚
并且 x 数组中是可以有相等的排名,而 y 数组中位置对应的排名一定不同:这是因为基数排序中二阶及以上的桶排序一定要是稳定的
另外根据后缀数组求出的后缀最长公共前缀 \(lcp\) 或 \(height\) 也有很重要的应用
最高赞 题解 讲的比较好,可以作为到时候复习的参考
-
\(AVL\) 树
\(AVL\) 树是一种自平衡二叉查找树
为了保持该树的高度始终稳定在 \(logN\) 级别,\(AVL\) 树对每个结点定义了 平衡因子,即其左右子树高度之差的绝对值
\(AVL\) 树满足每个结点的平衡因子最多为 \(1\):当插入或删除结点使得某节点的平衡因子大于 \(1\) 时,\(AVL\) 树通过一系列的旋转操作维持其平衡 -
\(Splay\) 伸展树
也是一种自平衡二叉查找树,\(Splay\) 通过不断将结点旋转至根节点维持树的平衡
旋转主要有以下操作:
rotate
: 又称为zig
单左旋或单右旋,使得树的高度减一且维持中序遍历不变
splay
: 核心操作,伸展:即将某节点不断旋转至另一结点的子节点或是直接旋转至根,其又分为三种操作zig
,zig-zig
或zig-zag
zig
时目标节点 \(R\) 是当前结点的祖父节点;zig-zig
时当前节点,父节点与祖父节点不共线,此时两次旋转当前节点
zig-zag
时当前结点,父节点与祖父节点共线,此时先旋转父节点,带旋转当前节点