算法特点:有穷性 确切性 输入性 输出性 可行性。
三:线性表
1:顺序表
数组
2:链表
2.1:单链表
2.2:双链表
2.3:循环链表
2.4:静态链表
通过数组实现一个链表
结构体形式:
#define MaxSize 50
#define ElementType char
typedef struct {
ElementType data;
int next; // next==-1表示最后一个元素
} StaticLinkedList[MaxSize];
四:栈和队列
4.2:队列
队列(Queue)
遵循先进先出的原则。
1:顺序存储结构
1.1:普通队列:
以数组的形式存储元素,不同设定下的条件,出队入队的情况不同。存在假溢出的情况。
1.2:循环队列
初始时: Q.front=Q.rear=0
出队: Q.front=(Q.front+1)%MaxSize
入队: Q.rear=(Q.rear+1)%MaxSize
长度: (Q.rear+MaxSize-Q.front)%MaxSize
判断队列是否满还是是否空?
1:牺牲一个存储空间,当队尾指针的下一个元素是对头指针的时候队满?
队满: (Q.rear+1)%MaxSize=Q.front
队空: Q.front=Q.rear
元素个数:(Q.rear+MaxSize-Q.front)%MaxSize
2:在类型中增加一个字段标记size
。
3:新增加tag
成员变量,tag=0
时,如果因为删除导致Q.front=Q.rear
则表示队列为空,tag=1
的时候,如果因为插入导致Q.front=Q.rear
则表示队满。
2:链式存储结构
带有队头指针和队尾指针,多种链的形式。
3:双端队列
两端都可以进行操作。
4.3:栈的应用
1:括号匹配
public boolean isValid(String s) {
Stack<Character> stack = new Stack<>();
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
if (c == '(' || c == '{' || c == '[') {
stack.push(c);
} else {
if (stack.size() == 0) {
return false;
} else {
char pop = stack.pop();
if (c == ')')
if (pop != '(')
return false;
if (c == ']')
if (pop != '[')
return false;
if (c == '}')
if (pop != '{')
return false;
}
}
}
return stack.size() == 0;
}
2:表达式求值
中缀表达式,后缀表达式,前缀表达式
中缀表达式 | 后缀表达式 | 前缀表达式 |
---|---|---|
a+b | ab+ | +ab |
a+b-c | ab+c- | -+abc |
a+b-c*d | ab+cd*- | -+ab*cd |
a*(b+c)-d | abc+*d- | -*a+bcd |
中缀表达式运算符在中间,后缀表达式的运算符在两个操作数的后面,同时运算符分左右,不可调换,前缀表达式的运算符在两个操作数的前面,同时运算符分左右,不可调换。因为表达式有相同的优先级,所以有些生成的后缀以及前缀表达式结果不唯一。
2.1:中缀转后缀
手算:
(1): 首先找到第一个最小的运算单元。然后将操作数按照顺序写下来,然后将运算符写在后面。
(2): 然后将第一步得到的结果作为一个整体和另一个数继续按照(1)写出来。
(3): 如果第二步中没有和上一步结果直接进行运算的数,则寻找表达式中其余的最小计算单元,然后重复步骤一。
(4): 以上过程的表达式顺序和原表达式一致。遵循左优先原则,从左向右扫描表达式。
例如:
a*(b+c)-d
:
首先寻找第一个计算单元 (b+c)
,写成 bc+
,然后bc+
的结果应当和a
合并,写成abc+*
,然后结果再于d
合并,写成abc+*d-
机算:
初始化一个栈用来保存运算符。
(1): 从左到右处理各个元素直到末尾。
(2): 遇到操作数直接加入后缀表达式中。
(3): 遇到界限符,遇到(
直接入栈,遇到)
则依次弹出栈内运算符并加入后缀表达式,直到弹出(
为止,但是(
并不加入后缀表达式,因为后缀表达式中不会出现界限符。
(4): 遇到运算符,依次弹出栈中优先级高于或等于当前运算符的所有运算符,并加入后缀表达式中,如果碰到(
或栈空,则停止,之后再把当前运算符入栈(此处的(
并不需要弹出,这里可以peek
一下)。
后缀表达式的计算:
从左向右扫描表达式,碰到运算符就开始计算他前面两个数。
(1): 从左到右扫描每一个元素。直到全部处理完成。
(2): 如果扫描到的是操作数则压入栈中,并且返回到步骤(1),否则执行(3).
(3): 如果扫描到的是运算符,则弹出两个栈顶元素,第一个弹出的是右操作数,第二个弹出的是左操作数。
2.2:中缀转前缀
整体步骤和后缀相同,不同的是操作符要写在前面,同时遵循的是右优先原则。从右向左扫描表达式,碰到运算符就开始计算他前面两个数。
前缀表达式的计算:
(1): 从右向左扫描每一个元素。直到全部处理完成。
(2): 如果扫描到的是操作数则压入栈中,并且返回到步骤(1),否则执行(3).
(3): 如果扫描到的是运算符,则弹出两个栈顶元素,第一个弹出的是左操作数,第二个弹出的是右操作数。
4.4:矩阵的压缩存储
规定
L=sizeof(ElemType).
1:数组
数组是一个线性的连续空间,数组的地址也就是数组中第一个元素的地址,通常都是从0
开始,如果从1
开始,要改变计算。数组中任意一个元素的位置为 loc(i) = loc(0) + i*L 0<=i<=(n-1)
.
对于二维数组采用行优先和列优先的方式存储。
假设二维数组的行下标为
[0,m]
,列下标范围为[0,n]
.(即n
行m
列)
行优先:
loc<i,j> = loc<0,0>+(i*(m+1)+j)*L
每行拥有m+1
个元素,共有i
个完整的行,在第i
行里,有j+1
个地址单元,但是要减去以及算进去的loc<0,0>
地址单元。
列优先:
loc<i,j> = loc<0,0> + (j*(n+1)+i) * L
2:对称矩阵
行优先:根据行优先的排列规律,第一行一个元素,第二行二个,第三个三个…
loc<i,j> = 1 + 2 + 3 +...+ (i-1) + (j-1)
==> i(i-1)/2+j-1 (i>=j>=1)
.
同理:
loc<i,j> = j(j-1)/2+i-1 (i<j)
3:三角矩阵
3.1:下三角矩阵:
最后设置一个位置储存上三角中的常量。总内存大小:n(n+1)/2+1
坐标代换:
行优先:
loc<i,j> = i(i-1)/2+j-1 (i>=j)
loc<i,j> = n(n+1)/2 (i<j)
数组从下标0
开始,矩阵行列从1
开始,推导和对称矩阵一样。
3.2:上三角矩阵:
最后设置一个位置储存上三角中的常量。总内存大小:n(n+1)/2+1
loc<i,j> = n(n+1)/2 (i>j)
loc<i,j> = (i-1)(2n-i+2)/2+(j-1) (i<=j)
数组从下标0开始,矩阵行列从1开始,推导和对称矩阵一样。
4:三对角矩阵
所有非零元素都集中在主对角线及其两侧|i-j|<=1
。总内存大小3n-2
.
坐标代换:
loc<i,j> = 2*i+j-3
反之,若知道数组下标K
,可得:
i = (k+1)/3+1
j = k-2*i+3
其中矩阵的行列从1
开始,数组下标从0
开始。
推导过程:

首先前(i-1)
行元素的个数是(i-2)*3 +2
(第一行元素的个数为2),又a[i,j]
属于第i
行被选中元素的第j-i+2
个元素,所以k= (i-2)*3 +2 + j-i+1 = 2*i+j-3
5:稀疏矩阵
使用三元组存储稀疏矩阵<i,j,v>
.
五:串
1:暴力搜索
int index(string s, string t) {
int i = 1, j = 1;
if (s[i] == t[j]) {
i++;
j++;
} else {
i = i - j + 2;
j = 1;
}
if (j > t.length())
return i - t.length();
else
return 0;
}
2:KMP
算法
KMP
算法的核心是求得next
数组。
最长公共前后缀:字符串前后子字符串的公共最大长度。abcac
的最长公共前后缀值为:0,0,0,1,0
手算:
将手算的值整体右移一位,然后将第一个值补为-1
,就变为-1,0,0,0,1
,然后整体再加1
,变为0,1,1,1,2
,变为最终的next
数组。
代码求解:
// 字符串和next数组的索引都是从1开始的。
int getNext(char ch[], int length, int next[]){
next[1] = 0;
int i = 1, j = 0;
while(i<=length){
// j=0说明此时指针停留在第一个字符的位置,没有最长公共子前缀,经过next数组的整体加1规则之后就是1.也就是++j
// ch[i]==ch[j]时j不在第一个位置,但是i和j指向的字符是相等的,就让此处的j位置加1,然后同时向后移动一位.
if(j==0 || ch[i]==ch[j]){
next[++i]=++j;
} else {
j=next[j]; // 让指针回退到上一次的最长公共子数组的后一个位置。
}
}
}

j=next[j]
的意思是在16
无法和8
匹配成功的情况下,让指针j
回退到8
所在的最长公共前后缀的位置,也就是4
,一直回退到字符相等或者j==0
为止。
next[j]
的含义是:在子串的第j
个位置匹配失败的时候,就跳到子串的第next[j]
个位置继续匹配,不需要移动主串。
int kmp(char *s, char *p, int *next) {
int i = 1, j = 1;
while (i <= strlen(s) && j <= strlen(p)) {
if (j == 0 || s[i] == p[j]) {
i++;
j++;
} else {
j = next[j];
}
}
if (j > strlen(p)) {
return i - strlen(p); // 匹配成功,返回存储位置
} else {
return 0;
}
}
改进后的next数组:
如果出现了
Pj=P(next[j])
,则继续向下进行递归,直到两者不相等为止。
void getNextVal(char *p, int *nextVal) {
int i=1, j=0;
nextVal[1] = 0;
while (i < strlen(p) - 1) {
if (j == 0 || p[i] == p[j]) {
j++;
i++;
if(p[i] != p[j])
nextVal[i] = j;
else
nextVal[i] = nextVal[j];
} else {
j = nextVal[j];
}
}
}
六:排序
6.1:插入排序
每次将一个待排序的记录按期关键字大小插入到前面已经排好的子序列。
1:直接插入排序
void directInsertSort(int *arr, int len) {
int i, j;
int tmp; // 存放每次需要插入的值
for (i = 1; i < len; i++) { // 从1开始比较,index=0,自动有序
tmp = arr[i];
j = i - 1; // 开始和前面一个元素比较
while (j >= 0 && tmp < arr[j]) { // 不需要每次都交换,只要将比tmp大的后移一位就可,然后将j停止下来的位置插入tmp
arr[j + 1] = arr[j];
--j;
}
arr[j + 1] = tmp; // 停止下来的位置j最小值是-1,代表前面所有的元素都比tmp大,
}
}
2:折半插入排序
void binaryInsertSort(int *arr, int len) {
int i, j;
int low, high, mid;
for (i = 1; i < len; i++) {
int tmp = arr[i];
low = 0;
high = i - 1;
while (low <= high){
mid = (low + high) / 2;
if (tmp < arr[mid])
high = mid - 1;
else
low = mid + 1;
}
for (j = i - 1; j >= high + 1; j--)
arr[j + 1] = arr[j];
arr[high + 1] = tmp;
}
}
/**
* 此时 high的值要么是i-1要么是mid-1.
* 假如是i-1。说明前面已经有序,tmp最大,不需要移动任何元素。arr[high+1]=arr[i-1+1]=arr[i]=tmp
* 如果是mid-1.说明arr[mid]大于tmp.我们将[mid(high+1)~i-1]中的元素全部后移一位,
* 然后令arr[mid]=tmp <=> arr[high+1]=tmp
* 综上 折半查找中,arr[high+1](arr[mid])的位置就是我们最终要插入的位置
*/
3:希尔排序
希尔排序是对直接插入排序的一种优化。属于增量式排序。首先取一个步长step,然后将数据以步长为间距分为n组,对每组内的数据实行直接插入排序,然后将步长缩小为原来的一半,继续分组,直到步长为1,排序完成。
例如:对于10个数据,首先以5作为步长,索引0-5,1-6,2-7,3-8,4-9分为5组,每组内进行直接插入排序。然后将步长缩减为5/2=3,继续间距为3继续分组,索引为0-3-6-9,1-4-7,2-5-8分为3组继续排序,然后依次类推,直到间距为1,完成排序。
希尔排序在每组内进行排序时又可以分为移动法和交换法两种,移动法是交换法的优化。
void shellSort(int *arr, int len) {
for (int step = len / 2; step > 0; step = step / 2) { // 步长
for (int i = step; i < len; ++i) { // 每个步长范围内的子序列使用直接排序
int j = i - step;
int temp = arr[i];
while (j >= 0 && temp < arr[j]) {
arr[j + step] = arr[j];
j -= step;
}
arr[j + step] = temp;
}
}
}
6.2:插入排序
1:冒泡排序
通过一次次的比较,将最大的值放在后面。
void swap(int *arr, int index1, int index2) {
int tmp = arr[index2];
arr[index2] = arr[index1];
arr[index1] = tmp;
}
void bubbleSort(int *arr, int len) {
for (int i = 0; i < len - 1; ++i) {
bool flag = false;
for (int j = len - 1; j > i; j--) {
if (arr[j - 1] > arr[j]) {
swap(arr, j - 1, j);
flag = true;
}
}
if (!flag)
return;
}
}
2:快速排序
选取一个基准点,然后通过一趟排序,使大于当前基准点的数据在后面,小于当前基准点的数据在前面,这称为一趟排序。然后递归的对两个子序列再次进行上述排序,直到全部有序为止。
int partition(int *arr, int low, int high) {
// 基准轴
int pivot = arr[low];
while (low < high) {
while (low < high && arr[high] >= pivot)
--high;
arr[low] = arr[high];
while (low < high && arr[low] <= pivot)
++low;
arr[high] = arr[low];
}
arr[low] = pivot;
return low;
}
void quickSort(int *arr, int low, int high) {
if (low < high) {
int pivotPos = partition(arr, low, high);
quickSort(arr, low, pivotPos - 1);
quickSort(arr, pivotPos + 1, high);
}
}
6.3:选择排序
首先在未排序序列中找到最小(大)元素,存放到排序序列的起始位置,然后,再从剩余未排序元素中继续寻找最小(大)元素,然后放到已排序序列的末尾。以此类推,直到所有元素均排序完毕。
插入排序的重点在于遍历有序表, 而选择排序的重点在于遍历无序表。
1:简单选择排序
void selectSort(int *arr, int len) {
int i, j, minIndex;
for (i = 0; i < len - 1; i++) {
minIndex = i;
for (j = i + 1; j < len; j++)
if (arr[j] < arr[minIndex])
minIndex = j;
if (minIndex != i)
swap(arr, i, minIndex);
}
}
简单选择排序只有在交换的时候才用移动位置,一共需要n
次,而需要比较的次数为n-1+n-2+n-3+...+1=n(n-1)/2
。
2:堆排序
堆是一个完全二叉树。将无序序列构造成一个完全二叉树。并不是一棵满二叉树。
一般用数组来表示堆,下标为i
的结点的父结点下标为(i-1)/2
;其左右子结点分别为(2i+1),(2i+2)
。
堆可以分为大顶堆和小顶堆。
/**
* 将以i对应的非叶子节点的数调整为大顶堆
* 步骤:
* 1: 首先将该非叶子节点的值使用tmp保存下来,
* 使用的是直接插入排序的移动法的思想,第一次就保存下来,避免了后面交换法的反复操作,提高算法效率
* 2: 在合法的范围内找出左右子节点的最大值和该非叶子节点比较,目的就是将三个节点中的最大值放在该非叶子节点上。
* 3: 然后让k指向左右子节点中较大值的节点。因为如果发生交换,说明其中某一个节点的值发生了变化,可能会影响该节点下面的值
* 所以要递归向下调整。
* 4: 当调整到某个节点时,该节点的值不再大于tmp,就将该节点的值更新为tmp.
* 因为i永远指向一个非叶子节点。循环能够退出的条件是:
* ①: arr[k] <= tmp 说明此时的非叶子节点的左右节点的值都比tmp要小。而此时i节点的值就是arr[k]已经被交换走了
* (arr[i] = arr[k];i=k)所以直接令arr[i]=tmp;
* ②: k >= len 说明较大的那个子节点已经没有子节点了而 i=k,要么发生了要么没执行。
* 如果没执行,说左右子节点都比非叶子节点小直接退出循环。
* 如果发生了i已经指向了那个较大的子节点,而那个节点的值已经被替换走了,也可以直接插入。
*
* @param arr 待调整数组
* @param i 非叶子节点在数组中的索引
* @param len 表示后面还有多少个元素,因为要寻找叶子节点.不能让叶子节点超出界限。在构建大顶堆的时候这个值显然就是数组的长度
* 但是在排序的过程中。因为最大的节点值被调整到后面了,所以此处的值应该是随着排序的次数依次递减。
*/
void headAdjust(int *arr, int i, int len) {
int tmp = arr[i];
for (int k = 2 * i + 1; k < len; k = k * 2 + 1) {
if (k + 1 < len && arr[k] < arr[k + 1]) // 使K指向左右子节点中较大的那个节点
k++;
if (arr[k] > tmp) { // 子节点大于父节点
arr[i] = arr[k]; // 把较大的值赋给当前节点
i = k; // i指向k继续向下调整
} else {
break;
}
}
arr[i] = tmp;
}
/**
* 首先排序最后一个非叶子节点 index=len/2-1
* 调整完最后一个节点之后逐渐调整前面的节点。最后一个节点的前面每个元素一定都是一个非叶子节点。
*
* 构建完大顶堆以后, 将堆顶元素与末尾元素进行交换,使末尾元素最大。
* 然后继续调整堆,再将堆顶元素与末尾元素交换,得到第二大元素。
* 如此反复进行交换、重建、交换
*/
void heapSort(int *arr, int len) {
for (int i = len / 2 - 1; i >= 0; i--)
headAdjust(arr, i, len);
for (int i = len - 1; i > 0; i--) {
swap(arr, i, 0);
headAdjust(arr, 0, i);
}
}
6.4:归并排序
归并排序,将一个数组按照左右分别进行递归,直到长度为1,然后自底向上逐渐进行归并。
/**
* 给定一个数组data,其中(low,mid),(mid,high)分别是两个有序子数组
* 该方法可以将这两个有序子数组重新合并成为一个在(low,high)区间的大有序数组
*
* 步骤 :
* 1: 首先申请一个长度为 (high - low + 1)的辅助数组tmp(最后要free释放内存)
* 2: 将两个子数组中的值依次拷贝到临时数组中,拷贝原则是申请两个指针,分别指向low和mid+1,两个指针谁指向的值较小
* 就将该较小的值放入tmp中然后将该指针+1,直到其中某一个指针达到临界点.
* 3: 将tmp中的值重新拷贝会data中
* 4: 释放tmp数组
*/
void merge(int data[], int low, int mid, int high) {
int i = low, j = mid + 1, p = 0;
int *tmp = (int *) malloc((high - low + 1) * sizeof(int));
if (!tmp) {
printf("malloc temp array error!\n");
exit(1);
}
// 顺序合并 (i++,先执行i后++)
while (i <= mid && j <= high) {
if (data[i] < data[j])
tmp[p++] = data[i++];
else
tmp[p++] = data[j++];
}
// 将剩余的数据拷贝到tmp中
while (i <= mid)
tmp[p++] = data[i++];
while (j <= high)
tmp[p++] = data[j++];
for (i = low; i < high + 1; i++) {
data[i] = tmp[i - low];
}
free(tmp);
}
void mergeSort(int data[], int low, int high) {
if (low < high) {
int mid = (low + high) / 2;
// 左半区排序
mergeSort(data, low, mid);
// 右半区排序
mergeSort(data, mid + 1, high);
// 左右半区合并
merge(data, low, mid, high);
}
}
6.5:基数排序
基数排序以10进制为例,将所有的数据按照位数上的值分配到0-9个桶里面(数据必须按照原来的顺序存放,不能调换顺序),然后从0开始收集每个桶中的数据,每个桶中的数据从索引0开始收集。一共如此往复排序d趟,即可完成排序过程。d是待排序序列的最长位数。
基数排序有两种方式,低位优先(LSD)和高位优先(MSD)。
// 寻找arr中的最大值
int getMaxNum(const int *arr, int len) {
int index;
int max = arr[0];
for (index = 1; index < len; index++) {
if (arr[index] > max)
max = arr[index];
}
return max;
}
// 获取数字的位数
int getDigit(int num){
int count = 1;
int tmp = num / 10;
while(tmp != 0){
count++;
tmp = tmp / 10;
}
return count;
}
/**
* 将数字分配到各自的桶中,然后按照桶的顺序输出排序结果。
*
* @param loop 当前排序的位数(个位->1, 十位->2,百位->3)
* @param radixLen 基数个数
* @param arrLen 排序数组长度
*/
void pushBucket(int *arr, int loop, int radixLen, int arrLen) {
int buckets[radixLen][arrLen];
for (int i = 0; i < radixLen; ++i) {
for (int j = 0; j < arrLen; ++j) {
buckets[i][j] = INTEGER_MAX;
}
}
int divisor = (int) pow(10, loop - 1);
int i, j;
for (i = 0; i < arrLen; i++) {
int row_index = (arr[i] / divisor) % 10;
for (j = 0; j < arrLen; j++) {
if (buckets[row_index][j] == INTEGER_MAX) {
buckets[row_index][j] = arr[i];
break;
}
}
}
// 收集 将桶中的数,倒回到原有数组中
int k = 0;
for (i = 0; i < radixLen; i++) {
for (j = 0; j < arrLen; j++) {
if (buckets[i][j] != INTEGER_MAX) {
arr[k] = buckets[i][j];
buckets[i][j] = 0;
k++;
}
}
}
}
// 基数排序
void radixSort(int *arr, int arrLen) {
// 获取数组中的最大数
int maxNum = getMaxNum(arr, arrLen);
// 获取最大数的位数,次数也是再分配的次数。
int loopTimes = getDigit(maxNum);
int i;
// 对每一位进行桶分配
for (i = 1; i <= loopTimes; i++) {
pushBucket(arr, i, 10, arrLen);
}
}
6.6:性能分析
算法名称 | 最好时间复杂度 | 平均时间复杂度 | 最坏时间复杂度 | 空间复杂度 | 稳定性 |
---|---|---|---|---|---|
插入排序 | O(n) | O(n2) | O(n2) | O(1) | 稳定 |
冒泡排序 | O(n) | O(n2) | O(n2) | O(1) | 稳定 |
简单选择排序 | O(n2) | O(n2) | O(n2) | O(1) | 不稳定 |
希尔排序 | O(1) | 不稳定 | |||
快速排序 | O(nlong2n) | O(nlong2n) | O(n2) | O(long2n) | 不稳定 |
堆排序 | O(nlong2n) | O(nlong2n) | O(nlong2n) | O(1) | 不稳定 |
归并排序 | O(nlong2n) | O(nlong2n) | O(nlong2n) | O(1) | 稳定 |
基数排序 | O(d(n+r)) | O(d(n+r)) | O(d(n+r)) | O(r) | 稳定 |
6.7:外部排序
1:待排序文件过大,一次放不下,需存放在外存的文件中排序。
2:为减少平衡归并中外存读写次数所采用的方法:增大归并路数和减少归并段的个数。
3:利用败者树增大归并路数。
4:利用置换-选择排序增大归并段长度来减少归并段个数。
5:由长度不等的归并段,进行多路平衡归并,需要构造最佳归并树。
外部排序通常采用的是归并排序。根据内存缓冲区的大小,然后将外存上的文件分成若干个子文件,读入内存按照内部排序的方法进行排序,最后将文件重新写入外存,这些子段称为归并段或顺串,然后对这些归并段主键归并,使归并段由小到大,直到全部排序完成。
1:多路平衡树与败者树
增加归并路数K
,减少归并趟数,从而减少I/O
次数。
如果归并路数为K
,则从k
个元素中选择最小的记录一共需要k-1
次比较,如果每趟归并n
个元素,则需要做(n-1)(k-1)
次比较,S
趟归并一共需要比较的次数为:
比较次数:S(n-1)(k-1)= ⌈ log k r ⌉ \lceil \log_k{r} \rceil ⌈logkr⌉(n-1)(k-1)= ⌈ log 2 r ⌉ \lceil \log_2{r} \rceil ⌈log2r⌉(k-1)(n-1) ⌈ log 2 k ⌉ \lceil \log_2{k} \rceil ⌈log2k⌉
通过败者树快速选择每趟的最小值。

败者树的选择过程:
每两个排序段选择一个最小值作为胜者,败者写入上一层节点,然后将这次的胜者之间再次比较,败者再次写入上一层,最后最上层一个是最小值节点,下面一个是第二小的节点。当选出一个最小节点的时候,从其原本位置选取下一个节点然后向上再次比较一遍,从而全部选取结束。
比较次数:S(n-1) ⌈ log 2 k ⌉ \lceil \log_2{k} \rceil ⌈log2k⌉= ⌈ log k r ⌉ \lceil \log_k{r} \rceil ⌈logkr⌉(n-1) ⌈ log 2 r ⌉ \lceil \log_2{r} \rceil ⌈log2r⌉=(n-1) ⌈ log 2 r ⌉ \lceil \log_2{r} \rceil ⌈log2r⌉
使用败者树之后,内部归并排序的比较次数虽然与k
无关,但是归并路数k
并不是越大越好,当内存空间不变的情况下,归并路数变大,就要减少输入缓冲区的容量,外存交换次数增大,读写外存的次数仍会增加。
2:置换-选择排序
置换-选择排序的目的是为了生成初始归并段,生成外初始归并段之后可以交给败者树进行归并排序。
算法过程:
1:首先从初始文件中输入w
个记录到内存工作区中。(w
为工作区大小)
2:从内存工作区中选出关键字最小的记录,将其记为min
记录
3:然后将min
记录输出到归并段文件中
4:此时内存工作区中还剩余w-1
个记录,若初始文件不为空,则从初始文件中输入下一个记录到内存工作区中填满工作区
5:从内存工作区中的所有比min
值大的记录中选出值最小的关键字的记录,作为新的min
记录
6:重复过程3-5
,直至在内存工作区中选不出新的min
记录为止,由此就得到了一个初始归并段
7:重复2-6
,直至内存工作为空,由此就可以得到全部的初始归并段
3:最佳归并树
讨论的是在已经建立好初始归并段之后的合并问题,根据置换-选择排序的特点,每个归并段的长度是不固定的,该怎么选择呢?
统计每个初始归并段的长度,然后将其构造成一棵哈夫曼树。这棵归并树就称为最佳归并树。
我们需要构造的是一棵严格的K
叉树,但是如果树的节点不能正好满足一棵严格k
叉树,我们需要添加一些长度为0
的虚段。
证明:
假设度为0
的节点一共有n0个,度为k
的节点一共有nk个,则:
从节点数的角度来看:n = n0 + nk
从分支树的角度来看:n-1 = knk
因此:(k-1)nk=n0-1,nk=(n0-1)/(k-1)
1:如果(n0-1)%(k-1)=0,不需要添加虚段。
2:如果(n0-1)/(k-1)=u,则需要增加k-u-1
个空段,然后按照规则构造哈夫曼树。
6.8:例题
1:在数据局部有序的情况下,直接插入排序能取得最好的效率。时间复杂度为O(n)
。
2:选择排序的排序过程和比较次数无关。
七:树
1:常用术语
术语 | 定义 |
---|---|
满二叉树 | 该二叉树的所有叶子结点都在最后一层,并且节点总数为 2 n − 1 2^n-1 2n−1,n为层数 |
完全二叉树 | 所有叶子节点在最后一层或者倒数第二层,且在最后一层左边连续,倒数第二层右边连续 |
树:
(1)
:树的度为节点的的最大度数。一个节点的度为孩子节点的个数。
(2)
:树中的节点数等于所有节点的度数加1
。(这个1
就是根节点)
(3)
:度为m
个树中第i
层最多有m^i-1^
个节点。
(4)
:高度为h
个m
二叉树最多有(m^h^-1)/(m-1)
个节点。
(5)
:具有n
个节点的m
叉树的最小高度为{log~m~(n(m-1)+1)}
(6)
:总结点树等于总分支数加1
。
二叉树:
(1):非空二叉树叶子节点数等于度为2的节点数加1。 n0 = n2 + 1
。
// N 表示节点数 B表示分支数
N = n0 + n1 + n2
N = n1 + 2n2 + 1
B = n1 + 2n2
n0 = n2 + 1
完全二叉树:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-FY6Th4Fr-1650385203411)(%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84.assets/image-20210901151012688.png)]
{}
表示向下取整,[]
表示向上取整。假设高度为h
,结点数为n
。
(1)
:i<=(n/2)
都是分支结点,否则为叶子节点。
(2)
:只可能出现一个度为1
的节点,且该节点没有右子树只有左子树。
(3)
:如果n
为奇数,每个分支结点都有左孩子和右孩子,n
为偶数,编号n/2
的节点只有左孩子,其余节点左右孩子都有。
(4)
:树的高度为
⌈
l
o
g
2
(
n
+
1
)
⌉
\lceil log_2(n+1) \rceil
⌈log2(n+1)⌉或者
⌊
l
o
g
2
n
⌋
\lfloor log_2{n} \rfloor
⌊log2n⌋+1
(5)
:节点i
的编号所在层次为{log~2~i}+1
(该结论和第四点结论是一样的,第四点的两个表达式一个代表是每层的第一个节点一个代表的最后一个节点,他们实际上是同一层。)
(6)
:当i>1
时,节点i的双亲编号为{i/2}
,
即当i为偶数时,其双亲编号为i/2
,他为双亲的左孩子。
当i
为奇数时,其双亲编号为(i-1)/2
,他是双亲的右孩子。
(7)
:当2i<n
时,节点i
的左孩子编号为2i
,否则没有左孩子。
(8)
:当2i+1<n
时,节点i
的右孩子编号为2i+1
,否则无右孩子。
(9)
:高度为h
的完全二叉树,最多有2^h^-1
个节点.最少为2^h-1^+1
个节点。
证明:i的左节点的孩子编号为2i,右孩子2的编号为2i+1
解:
下面先证明 完全二叉树中任何一层最左的节点编号n,则其左子树为2n,右子树为2n+1.
显然,每个节点的编号N = 按层遍历位于该节点前面的节点数目+1。对于第L层的最左节点,在它之前的节点即为第1层到第L-1层的所有节点,共2^0+2^1+...+2^(L-2) = 2^(L-1)-1个(注意第i层共有2^(i-1)个节点)。则第L层最左节点编号为2^(L-1),其左子树为第L+1层的最左节点,故编号为2^L。这样结论就被证明了。
下面证明 完全二叉树中任一节点编号n,则其左子树为2n,右子树为2n+1.
任取一节点N,其编号为n。设N所在的这一层L的最左节点为M,编号为m。显然,L层中位于N左边的节点数为n-m个。N的左子树NL位于第L+1层,由于是完全二叉树,第L+1层中位于NL之前的节点数为2(n-m).由(1)可知第L+1层的最左节点编号为2m,那么NL的编号为2m+2(n-m)=2n.
由此得证。
2:创建树
3:遍历树
-
前序遍历
顺序:
-
中序遍历
顺序:
-
后序遍历
顺序:
在树的节点类
中进行递归调用遍历方法:
class HeroNode {
private int id;
private String name;
private HeroNode left;
private HeroNode right;
public HeroNode(int id, String name) {
super();
this.id = id;
this.name = name;
}
// toString() & getter() & setter()
// 前序遍历
public void preOrderTraversal() {
System.out.println(this);
if (this.left != null)
this.left.preOrderTraversal();
if (this.right != null)
this.right.preOrderTraversal();
}
// 中序遍历
public void sequentialTraversal() {
if (this.left != null)
this.left.sequentialTraversal();
System.out.println(this);
if (this.right != null)
this.right.sequentialTraversal();
}
// 后序遍历
public void postOrderTraversal() {
if (this.left != null)
this.left.postOrderTraversal();
if (this.right != null)
this.right.postOrderTraversal();
System.out.println(this);
}
}
public class BinaryTreeDemo01 {
public static void main(String[] args) {
BinaryTree root = new BinaryTree();
HeroNode node1 = new HeroNode(1,"宋江");
HeroNode node2 = new HeroNode(2,"吴用");
HeroNode node3 = new HeroNode(3,"卢俊义");
HeroNode node4 = new HeroNode(4,"林冲");
HeroNode node5 = new HeroNode(5,"鲁智深");
root.setRoot(node1);
node1.setLeft(node2);
node1.setRight(node3);
node2.setLeft(node4);
node2.setRight(node5);
System.out.println("前序遍历: ");
root.preTraversal(); // 1 2 4 5 3
System.out.println("中序遍历: ");
root.seqTraversal(); // 4 2 5 1 3
System.out.println("后序遍历: ");
root.postTraversal(); // 4 5 2 3 1
}
}
class BinaryTree {
private HeroNode root;
public void setRoot(HeroNode root) {
this.root = root;
}
// 前序遍历
public void preTraversal() {
if (this.root != null)
this.root.preOrderTraversal();
else
System.out.println("二叉树为空不能遍历");
}
// 中序遍历
public void seqTraversal() {
if (this.root != null)
this.root.sequentialTraversal();
else
System.out.println("二叉树为空不能遍历");
}
// 后序遍历
public void postTraversal() {
if (this.root != null)
this.root.postOrderTraversal();
else
System.out.println("二叉树为空不能遍历");
}
}
4:查找
在节点类中添加查找方法,进行递归查找。
class HeroNode {
private int id;
private String name;
private HeroNode left;
private HeroNode right;
public HeroNode(int id, String name) {
super();
this.id = id;
this.name = name;
}
// toString() & getter() & setter()
// 前序查找
public HeroNode perOrderSearch(int id) {
HeroNode temp = null;
if (this.id == id)
return this;
if (this.left != null)
temp = this.left.perOrderSearch(id);
if (temp != null)
return temp;
if (this.right != null)
temp = this.right.perOrderSearch(id);
return temp;
}
// 中序查找
public HeroNode sequentialSearch(int id) {
HeroNode temp = null;
if (this.left != null)
temp = this.left.sequentialSearch(id);
if (temp != null)
return temp;
if (this.id == id)
return this;
if (this.right != null)
temp = this.right.sequentialSearch(id);
return temp;
}
// 后序查找
public HeroNode postOrderSearch(int id) {
HeroNode temp = null;
if (this.left != null)
temp = this.left.postOrderSearch(id);
if (temp != null)
return temp;
if (this.right != null)
temp = this.right.postOrderSearch(id);
if (temp != null)
return temp;
if (this.id == id)
return this;
return temp;
}
}
主类:
public class BinaryTreeDemo01 {
public static void main(String[] args) {
BinaryTree root = new BinaryTree();
HeroNode node1 = new HeroNode(1,"宋江");
HeroNode node2 = new HeroNode(2,"吴用");
HeroNode node3 = new HeroNode(3,"卢俊义");
HeroNode node4 = new HeroNode(4,"林冲");
HeroNode node5 = new HeroNode(5,"鲁智深");
root.setRoot(node1);
node1.setLeft(node2);
node1.setRight(node3);
node2.setLeft(node4);
node2.setRight(node5);
System.out.println("查找卢锡安(id=7): ");
HeroNode heroNode1 = root.preSearch(7);
if (heroNode1 != null) {
System.out.println(heroNode1);
}
else {
System.out.println("该英雄不存在");
}
System.out.println("查找林冲(id=4): ");
HeroNode heroNode2 = root.preSearch(4);
if (heroNode2 != null) {
System.out.println(heroNode2);
}
else {
System.out.println("该英雄不存在");
}
}
}
class BinaryTree {
private HeroNode root;
public void setRoot(HeroNode root) {
this.root = root;
}
public HeroNode perSearch(int id) {
if (root != null)
return root.perOrderSearch(id);
else
return null;
}
public HeroNode seqSearch(int id) {
if (root != null)
return root.sequentialSearch(id);
else
return null;
}
public HeroNode postSearch(int id) {
if (root != null)
return root.postOrderSearch(id);
else
return null;
}
}
5:删除节点
6:顺序存储二叉树
顺序存储二叉树通常情况下只考虑完全二叉树:
1: 第n个元素的左子节点为 2*n+1
2: 第n个元素的右子节点为 2*n+2
3: 第n个元素的父节点为 (n-1)/2
7:线索化二叉树
二叉树的线索化后的前驱和后继与其线索化的方式有关。不同的线索化对应的节点的前驱与后继不同。
n个节点的二叉树中一共有n+1个空指针。
证明:
每个叶节点有两个空指针,每个度为1的节点有一个空指针。所以总的空指针树为 2n0+n1,又因为n0=n2+1,所以空指针数为n0+n1+n2+1 = n+1。
ltag;0 代表指向左孩子 1 代表指向前驱。
rtag;0 代表指向左孩子 1 代表指向前驱。
1:中序线索化二叉树
算法思想:
求出一颗二叉树的中序遍历序列,每个节点的前后节点就是前驱和后继节点。
算法步骤:
设置一个指针pre代表刚刚访问过的节点,指针p指向正在访问的节点(pre永远指向p的前驱),遍历过程中,如果p的左指针为空,则让左指针指向pre。如果pre的右指针为空,就让pre的右指针指向p。
根据中序遍历的规则,如果一个节点没有左指针,则它就是下一个被访问的节点,所以他之前被访问的节点(pre)一定就是他的前驱。同理,如果上一个被访问的节点pre的右孩子为空,则此时p肯定在pre的后继节点上,因为中序遍历是左中右,如果’右’为空,则是pre肯定在’中’上,p在上一层的’中’上。
算法实现:
优化:
给中序线索化加入一个头结点,使头结点的左指针指向根节点,右指针指向中序遍历的左后一个节点,中序遍历第一个节点的前驱节点指向头指针,最后一个节点的后继节点指向头指针。
遍历:
2:先序线索化二叉树
寻找后继节点:
如果有左孩子,左孩子就是后继节点。
如果无左孩子但是有右孩子,则右孩子就是其后继。
如果为叶节点,则右链域直接指示了节点的后继。
3:后序线索化二叉树
寻找后继节点:
1:若节点x是二叉树的根,则后继为空。
2:若节点x是其双亲的右孩子,或是其双亲的左孩子且其双亲没有右孩子,则其后继即为双亲。
3:若节点x是其双亲的左孩子,且其双亲有右子树,则其后继为双亲的右孩子上按后序遍历列出的第一个节点。
8:树和森林
8.1:存储形式
1:双亲表示法
使用数组储存每个节点,增加一个伪指针指向父节点。
2:孩子表示法
每个结点的孩子都被使用单链表添加在一棵链上。n个节点就有n个链表。
3:孩子兄弟表示法
即以二叉链表作为树的存储结构。左孩子右兄弟。
8.2:树和二叉树的转换
1:树转化为二叉树:
每个节点的左指针指向该结点的第一个孩子,右指针指向他在树中的相邻的右兄弟。
2:森林转化为二叉树:
先将森林中的每棵树转换为二叉树,然后将每棵二叉树接在上一棵二叉树的根节点的右孩子上。
8.3:树的遍历
1:先根遍历
首先访问根节点,然后依次遍历根节点的每个子树。遍历子树使用相同的规则。遍历结果与这棵树对应的二叉树的先序序列相同。
2:后根遍历
首先依次遍历根节点的每个子树,然后再访问根节点,遍历子树使用相同的规则。遍历结果与这棵树对应的二叉树的中序序列相同。
3:层次遍历
依次访问每层的节点。
4:举例

先根遍历 结果:``ABEFCDG``。 解释:首先遍历根节点``A``,然后开始依次遍历每个子树,首先是``BEF``,然后是``C``,然后是``DG``。
后根遍历 结果:``EFBCGDA``。 解释:首先遍历子树,``EFB``,然后是``C``,然后是``GD``,最后是``A``。
8.4:森林的遍历
1:先序遍历
先访问第一棵树的根节点,然后先序遍历第一棵树中根节点的子树森林,然后先序遍历森林中其余的树。
2:中序遍历
中序遍历第一棵树中根节点的子树森林,然后访问第一棵树的根节点,然后中序遍历森林中其余的树。
3:举例
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-v0R1RNQ5-1650385203414)(%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84.assets/image-20210909194505056.png)]
先序森林遍历 结果:``ABCDEFGHI``。 解释:首先遍历第一棵子树,然后先序遍历第一棵子树,得到序列,``ABCD``,然后是第二棵子树,``EF``,然后是第三棵子树,``GHI``。
中序遍历森林 结果:``BCDAFEHIG``。 解释:首先中序遍历第一棵子树,``BCDA``,然后是第二棵子树``FE``,然后是第三棵子树``HIG``。注意这里不能是``HGI``,因为此处并不是二叉树。
8.5:例题
1:一棵树中的叶子结点数等于对应二叉树的叶子结点数。
错误。只有当树中任意两个叶子结点都没有相同的父节点时才成立,因为如果两个叶子结点有同一个父节点,第二个叶子结点就会被挂在第一个节点的右节点上,导致少一个叶子结点。
2:高度为h
的完全二叉树对应的森林所含的树的个数一定是h
。
错误:只有满二叉树才具有该性质。当一个节点只有左孩子没有右孩子时,就会少一棵树。
3:设F
是一个森林,B
是由F
变换而来的二叉树,若F
中有n
个非终端节点,则B
中右指针为空的节点有n+1
个。
每个非终端节点的孩子节点连接在一起,最后一个节点其右孩子一定为空,又因为最后一棵树的右节点一定为空,所有一定会有n+1
个节点。
4:将森林F
转化为对应的二叉树T
,F
中叶节点的个数等于:T
中左孩子指针为空的结点个数。
当一个二叉树中某个节点的左孩子为空的时候表明该节点没有孩子。所以是叶节点。
5:若T1
是由T
转换而来的二叉树,则T
中节点的后根序列就是T1
中节点的中序序列。
举例子证明。后序遍历树就是首先从最后一层叶子节点开始访问,然后访问父节点,在对应的二叉树中,每个叶子节点的左节点为空,直接开始访问根节点然后访问右节点,正好对应中序遍历序列。


6:根据二叉树的前序遍历和中序遍历可以唯一确定一棵二叉树。
// TODO 证明
7:设X
是树T
中的一个非根节点,B
是T
对应的二叉树,在B
中,X
是其双亲节点的右孩子,下列结论正确的是:
A:在树T中,X是其双亲节点的第一个孩子。
B:在树T中,X一定无右边节点。
C:在树T中,X一定是叶子节点。
D:在树T中,X一定有左边兄弟。
解释:设X的双亲节点为W,根据树的二叉链表表示法,X一定和W是兄弟节点,且W在X的左边。
8:已知森林F及与之对应的二叉树T,若F的先根遍历序列为abcdef,中根遍历序列是badefc,则T的后根遍历序列是:
解释:森林F的先根遍历序列对应于其二叉树T的先序遍历序列,F的中根遍历序列对应于其二叉树T的中序遍历序列,即T的先序序列为abcdef,中序遍历序列为badfec。由此可以确定一棵二叉树。
9:二叉排序树
二叉排序树(BST
):右子结点的值大于根节点大于左子节点的值。
9.1:查找
折半查找。
非递归算法
递归算法
9.2:插入
新添加的节点一定是插入叶子节点的左子树或右子树上。不可能是非叶子节点上。如果二叉树为空,则是插入到根节点。
9.3:构造
根据一个数组构造二叉排序树。本质就是一个个插入节点。
9.4:删除
(1): 如果被删除节点时叶节点,则直接删除。
(2):如果节点只有一个左子树或者右子树。则让节点的子树成为节点父节点的的子树,替代z
的位置。
(3):如果结点存在左右子树,则令节点的直接后继替代z
,然后转换为第一或者第二种情况,然后删除该直接后继。(在被删除节点的右子树上找中序遍历的第一个节点就是直接后继)。
9.5:查找
最坏情况下查找效率为0(n)
查找成功的平均查找长度为:ASL(a)=(层数*每层的节点数)/总节点数。(层数从1开始)
查找失败的平均查找长度为:补全叶子节点之后,(补全的层数*补全的个数)/补全的个数 (层数从0开始)

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-1fMnuusH-1650385203415)(%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84.assets/image-20210822204248346.png)]
注意:
1:二叉排序树的中序遍历会得到有序序列。
2:
10:平衡二叉树
在进行二叉树操作时保证左右子树高度差的绝对值不超过1。
平衡因子:结点左子树与右子树高度差。
10.1:插入
首先找到插入路径上离插入结点最近的平衡因子的绝对值大于1的节点A,然后以A为根节点进行旋转,使之重新成为一个二叉平衡树。该树称为最小不平衡树。
调整分为以下四种情况:LL,LR,RR,RL
1.1:LL
指在A的左孩子的左子树上插入结点使之不平衡。
需要进行一次右旋。将A结点的左孩子B向右上旋转代替A成为根节点,将结点A向右下进行旋转成为B的右子树,B的右子树成为A的左子树。平衡二叉树也是一颗二叉树,调整节点的时候注意要满足二叉树的性质.
例如:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-iyUVP2Of-1650385203415)(%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84.assets/image-20210822165856242.png)]
这是一颗刚好平衡的二叉树。现在插入13作为新的节点。

结点13应该被插入到节点14的左子树上,对于节点15来说,平衡因子等于2,需要调整二叉树。

1.2:RR
在节点A的右孩子的右子树上插入了新的节点。
需要进行一次左旋。将A
的右孩子B
向左上旋转代替A
成为根节点,将A
向左下旋转成为B
的左子树,而B
的原左子树成为A
的右子树。
1.3:LR
在A的左孩子的右子树上插入了新的节点。
先左旋再右旋。
先将A
的左孩子B
的右孩子C
向左上旋转提升到B
的位置(左旋),然后将该C
节点向右上旋转提升到A
节点的位置(右旋)。
1.4:RL
在A的右孩子的左子树上插入了新的节点。
先右旋再左旋。
先将A
的右孩子B
的左孩子C
向右上旋转提升到B
的位置(右旋),然后将该C
节点向左上旋转提升到A
节点的位置(右旋)。
2: 查找
二叉平衡树的查找和二叉排序树一样。
n个结点的平衡二叉树最大深度为0(log2n)。
10:哈夫曼树
1:带权节点

a节点的带权路径长度为:7 * 2 = 14 (7为节点的权值,2是路径长度)
该树的带权路径长度为 7 * 2 + 5 * 2 + 2 * 2 + 4 * 2 = 36
名称 | 含义 | 公式 |
---|---|---|
结点的带权路径长度 | 叶节点的权值 * 节点的路径长度 | W*L |
树的带权路径长度 | 所有叶节点的带权路径长度之和 | 略 |
2:定义
在含有n个带权叶节点的二叉树中,其中带权路径长度(WPL)最小的二叉树称为哈夫曼树,也称为最优二叉树。
3:构造
(1):从n个节点中选取权值最小的两个节点组成一颗二叉树,新添加一个根节点,权值为两个节点的权值之和。
(2):从n个节点中删除刚才选取的两个节点,然后加入刚才新创建的一个根节点。
(3):重复上述步骤,直到所有的节点全部组成一棵树。
4:性质
(1):n个节点的哈夫曼树需要合并n-1
次,组成的树中一共有2n-1
个结点。n
个叶子节点,n-1
个非叶子节点。
(2):哈夫曼树中不存在入度为1
的节点,因为每次都选取两个节点作为子节点。
(3):哈夫曼树不唯一,但是所有哈夫曼树的最小权值都是一样的。
(4):每个初始节点都是叶节点,而且权值越小的节点离根节点越远。
5:哈夫曼编码
数据通信中编码分为两种:固定长度编码和可变长长度编码。哈夫曼编码就是可变长长度编码。
前缀编码:没有一个编码是另一个编码的前缀编码。
1:含有20个节点的平衡二叉树的最大深度:6
解:当平衡二叉树所有非叶子节点的平衡因子都为1的时候,所需节点最少,高度最高,递归表达式为:
C1 = 1 C2=2 Ch = Ch-1 + Ch-2 + 1
由此可知,C6=20.
2:若度为m的哈夫曼树中,叶子节点的个数为n,则非叶子节点的个数为___

3:
六:图
1:基本概念
1:分类
图分为简单图和多重图(仅仅讨论简单图):关键在于是否存在重复的边和是否存在顶点到自身的边两个条件。
2:完全图:
无向完全图 | 有向完全图 |
---|---|
边的个数为n(n-1)/2。即任意两个点之间都有一条且仅有一条弧,且不存在指向自身的弧。 | 边的个数为n(n-1)。即任意两个顶点之间都要两天相反方向的弧。 |
3:连通图:
无向图:
连通 | 连通图 | 连通分量 |
---|---|---|
顶点v和w之间有路径存在,称v,w是连通的 | 图中任意两个顶点都是连通的 | 极大连通子图 |
1:有n
个顶点的图,如果边数小于n-1
则一定是非连通的。因为连通图只要求路径存在,不一定是直接连通。
2:连通图的极大连通子图就是他自己,非连通图有多个极大连通子图,每一部分本身就是一个连通子图,合在一起构成该图的连通子图。
3:极小连通子图,首先只有连通图才有极小连通子图这个概念。要求连通图只含有最少得边。
4:对于n个顶点的无向图G,如果G是连通的:则最少有n-1条边。如果G是非连通的,最多有
C
n
−
1
2
C_{n-1}^2
Cn−12条边。排除掉一个节点,其余的顶点皆为完全图.
有向图:
强连通 | 强连通图 | 强连通分量 |
---|---|---|
顶点v和w之间有路径存在同时w到v也有路径存在,称v,w是强连通的 | 图中任意两个顶点都是强连通的 | 极大强连通子图 |
1:对于n个顶点的有向图G:如果G是强连通图,则最少有n条边。(形成回路)。如果G是非强连通图。
4:子图
子图 | 生成子图 |
---|---|
子图G’中所有的顶点和边均包含于原图G。即E’∈E,并且V’∈V。 | 生成子图G’中顶点个数V’必须和原图G中V的数量相同,而E’∈E即可。即V(G’)=V(G),对边没有过多的要求。 |
子图和生成子图。子图和生成首先要是一个图。

5:生成树
生成树 | 生成森林 |
---|---|
无向连通图的生成树是包含图中全局顶点的一个极小连通子图。n个顶点只需要选择n-1条边即使其生成树,则生成树不唯一。 | 在非连通图中,连通分量的生成树构成了非连通图的生成森林 |
6:度
无向图 | 有向图 |
---|---|
顶点的度指的是该顶点的边的条数。 | 顶点的度指的是入度和出度之和。 |
无向图的全部顶点的度的和等于边数的2倍。因为每条边总和两个顶点两连。 | 有向图的入度等于出度,因为一条弧在两个顶点之间。并且都等于边数。 |
7:权
当图的边上带有具有某种意义的数值该数值就叫权值,这种图称为带权图,也称网。
8:稀疏图
边数很多的图称为稠密图,边数很少的图称为稀疏图,是一种模糊的相对概念。一般E<VlogV
称为稀疏图。
9:路径
顶点V
到P
的一条路径是指连接两个顶点的路径序列Vv,…Vp (路径使用顶点表示)其中边的个数称为路径长度,第一个顶点和最后一个顶点相同的回路称为环,如果一个图有n
个顶点,并且有大于n-1
条边,则此图一定有环。顶点不重复出现的路径叫简单路径,除第一个和最后一个顶点外其余顶点不重复出现的回路叫简单回路。
10:有向树
一个顶点的入度为0,其余顶点的入度均为1的有向图称为有向树。
1: 一个有n个顶点和n条边的无向图一定是:连通的。
解:具有n个顶点和n-1条边的无向图可以使其连通但是必然没有环,如果再多一条边,一定会构成一个环(不考虑重边的情况)。
2:图的存储
1:邻接矩阵
1:使用n阶矩阵存储具有n
个节点的图,如果两条边相连使用1
表示,否则使用0
表示。如果存储的是有权图,1则用权值代替。
2:邻接矩阵适合于存储稠密图。
3:邻接矩阵的空间复杂度为0(n2),n表示图的顶点个数。
4:Am[i][j]表示从顶点i出发到顶点j的长度为m个路径的个数。
5:对于无向图来说:
1:图的表示法唯一。
2:任意行或列的非0元素的个数表示该顶点的度。
3:无向图的邻接矩阵是一个对称矩阵,主对角线元素为0,可以采用压缩存储的方法。
6:对于有向图:
1:第i行的非0元素的个数代表该顶点的出度,第i列的非0元素的个数代表该顶点的入度。
2:邻接表
1:邻接表适合于存储稀疏图,
2:邻接表表示法中存在顶点表节点和边表节点。顶点表节点使用顺序存储的方法存储。
3:表示法不唯一,和建立顶点的顺序有关。
3:十字链表
4:邻接多重表
3:搜索
1:广度优先搜索
类似于二叉树的层次遍历。需要借助一个辅助队列,将每次访问的节点添加进队列之中,然后再次弹出。
bool visited[MAX_VERTEX_NUM];
void BFS(Graph g, int v){
visit(v);
visited[v] = true;
enqueue(Q, v);
while(!isEmpty(Q)) {
Dequeue(Q, v);
for (int i = firstNeighbor(g, v); i >= 0 ; i = nextNeighbor(g, v, w)) {
if(!visited[i]) {
visite(i);
visited[i] = true;
enqueue(Q, i);
}
}
}
}
void BFSTraverse(Graph g){
for (int i = 0; i < g.verNum; ++i) {
visited[i] = false;
}
initQueue(queue);
for (int i = 0; i < g.verNum; ++i) {
if(!visited[i]){
BFS(g, i);
}
}
}
2:深度优先搜索
3:对比
广度优先搜索:
邻接表:
时间复杂度:O(|V|+|E|)
空间复杂度:O(|V|)
邻接矩阵:
时间复杂度:O(|V|^2^)
空间复杂度:O(|V|)
深度优先搜索:
邻接表:
时间复杂度:O(|V|+|E|)
空间复杂度:O(|V|)
邻接矩阵:
时间复杂度:O(|V|^2^)
空间复杂度:O(|V|)
4:图的应用
1:最小生成树
一个连通图的生成树,是指包含所有的顶点,并且包含尽可能少的边,边数为顶点数减去1
即(n-1)
。对于带权连通无向图来说,权值之和最小的树为最小生成树,又称最小代价树。
性质:
1:最小生成树不唯一,当各边权值不同时,权值之和最小的为最小生成树,此时生成树唯一。但是即使各边权值相同时,最小生成树也可能唯一。即当图本身就是一颗树的时候。
2:最小生成树的权值之和唯一.
1.1:Prim
算法
从某一个顶点开始构造最小生成树,每次都将代价最小的顶点纳入生成树中,直到所有的顶点都纳入生成树中。普里姆算法查找最小生成树的过程,采用了贪心算法的思想。对于包含N
个顶点的连通网,普里姆算法每次从连通网中找出一个权值最小的边,这样的操作重复N-1
次,由N-1
条权值最小的边组成的生成树就是最小生成树。
算法过程:
从连通网中选择任意一个顶点,然后选择和该顶点相连的权值最小的边,将已经选择的顶点作为一个整体,寻找和该整体相连的最短的边,然后重复上述过程直到选择完所有的节点。实例:

算法过程:
首先选择一个节点S
,然后选择最短的边<S,A,7>
,然后(SA)
最为一个整体选择和该整体相连的最短边<A,C,3>
,然后将(SAC)
最为一个整体,选择和该整体相连的最短边<C,D,3>
,然后重复上述过程选择<D,B,2>
,当(SACDB)
最为一个整体之后继续选择和该整体相连的边<D,T,2>
,由此最小生成树构造完成。

1: Dijkstra
算法
最短路径算法。适用于有权图和无权图,但是无法使用于带有负权值的图。是一种单源最短路径问题,求得是从一点到其余顶点的最短距离。严蔚敏教材:Dijkstra算法适合求解有回路的带权图的最短路径,也可以求任意两个顶点之间的最短路径,不适合求带负权值的最短路径问题。
算法实现:
辅助数组:
final 记录该顶点是否被访问过。
dist 记录该顶点到已经形成的路径上某一点的最短路径。
path 记录该顶点在形成最短路径的时候其前驱节点,便于后面寻找路径。
假设我们需要求得是从vo出发到某一点的最短路径:
1:初始化
将final数组中的元素全部初始化为false,v0初始化为true,将dist路径数组更新为和v0相连的相应的权值,如果没有和v0相连接则设置为无穷大标记。(此处的相连接指的是存在从v0到该顶点的路径,无穷大指的是一个标记,具体问题可以设置一个合适的标记。),然后更新path数组,和v0相连接的设置为0,其余的设置为-1。v0也设置为-1。
2:第一轮
选择一个dist值最小的节点设置V,然后将V的final值更新为true,表示该节点已经访问过了。然后遍历所有和V相连接并且其final值为false的节点设置M,如果V所对应的dist值加上V到M的路径值小于原本M的dist值,则将M的dist值进行更新,然后将M的path值也更新为V的编号,表示最短路径是从V出发得到的。(注意此时并不会将M的final值更新为true。M的final值只会在每轮开始扫描的时候更新。)
然后重复第二步的过程直到所有的final值都为false。
时间复杂度:O(|V|2) 每次都要扫描一次dist数组时间复杂度为O(n),扫描完该数组之后每次都要扫描一遍邻接表或者邻接矩阵查看和该顶点相关联的顶点也是O(n).
2: Floyd
算法
求解各顶点之间的最短路径问题。无法解决带有负权回路的问题。时间复杂度O(|V|3)
算法实现:
首先初始化一个有向图的邻接矩阵和一个path
矩阵。A(-1),path(-1)
,A表示各顶点之间的最短路径,path
表示其中的两个顶点之间的中转点。
然后依次添加每个顶点表示中转顶点,然后扫描整个A
矩阵,如果在添加当前顶点之后的路径值小于原本的路径值则更新路径最小值,同时更新中转点path
矩阵。如果碰到A
矩阵中出现无穷大的数值则可以直接跳过,进行剪枝。
Floyed
算法是典型的DP
问题,
如果
A(k-1)[i][j] > A(K-1)[i][k] + A(K-1)[k][j]
则
A(k)[i][j] = A(K-1)[i][k] + A(K-1)[k][j]
path(k)[i][j] = k
否则
A(K)和path(k)
保持不变。
Floyd
算法求解最短路径的时候,当路径发生更改的时候,pathk-1就不是pathk的子集。
3:有向无环图
如果一个有向图中不存在环,则称为有向无环图。简称DAG
图。有向无环图可以用来很好的描述带有公共子表达式的表达式。可以节省空间。
做题技巧:
最终的图中不可能出现重复的字母。
根据运算符的生效顺序标注所有运算符,然后按照顺序将所有运算符加入图中。
然后自底向上逐层检查同层的运算符是否可以合体。
4:拓扑排序
AOV
网,用顶点表示活动的网,用DAG
图表示一个工程,顶点代表活动,有向边<Vi,Vj>代表Vi必须在Vj之前开始执行。
拓扑排序:
(1): 每个顶点出现且仅出现一次。
(2): 如果顶点A
在B
的前面,则在图中不存在从顶点B
到A
的路径。
步骤:
(1): 从AOV
网中选择一个没有前驱的顶点并输出。
(2): 从AOV
网中删除该顶点和所有以它为起点的有向边。
(3): 重复前两步直到AOV
网为空或当前网中不存在无前驱的顶点为止,后一种情况说明有向图中必然存在环。
拓扑排序的结果不唯一。逆拓扑排序是拓扑排序的逆过程。DFS可以求逆拓扑序列。
5:关键路径
AOE
网,以顶点表示事件,以有向边表示活动,以边上的权值表示完成该活动所需要的时间。
只有某个顶点的事件发生以后,该顶点指向的各个顶点的事件才能发生。只有在进入该顶点的事件都发生以后,该顶点的事件才能发生。另外,有些活动是可以并行发生的。
从顶点到汇点的有向路径可能有多条。所有路径中,具有最大路径长度的路径称为关键路径上的关键活动不能按时完成,整体工程的完成时间就会延长。
其中时间余量为0
的活动表示关键活动,根据关键活动的路径称为关键路径。缩短关键活动的时间可以缩短整个工程的工期。当缩短到一定程度时,关键活动就会变为非关键活动。如果存在多条关键路径,只提高一条关键路径上的关键活动时间(此处指减少时间)并不能缩短整个工程的工期,只有加快那些包括在所有关键路径上的关键活动才能达到缩短工期的目的。
关键名词:
1:事件Vk的最早发生时间ve()
: 决定了所有从Vk开始的活动能够开工的最早时间。
2: 活动的最早开始时间e()
:弧尾顶点的最早开始时间。
3: 事件VK的最迟发生时间vl()
: 它是指在不推迟整个工程完成的前提下,该事件最迟必须发生的时间。
4: 活动的最迟开始时间l()
:弧的终点所表示的事件的最迟发生时间与该活动所需时间之差。
5: 时间余量d()
:活动的最迟开始时间l()
-活动的最早开始时间e()
。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-sb5ri8zL-1650385203416)(%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84.assets/image-20210731144238220.png)]
解析:
在求事件的最早发生时间时,首先求得拓扑序列(也可以直接在图上看),一个顶点的最早开始时间就是他的前驱节点的最早开始时间加上路径的权值。第一个顶点的最早开始时间为0
,如果一个顶点有多个前驱节点,例如V4
,他的最早开始时间就是每一条路径上的最长时间取最大值。
然后求该图的逆向拓扑序列, 最后一个顶点的最迟开始时间就是该顶点对应的最早开始时间。对于其余的节点,他的最迟开始时间就是所对应弧头顶点的最早开始时间减去对应弧的权值。例如V4
顶点,他的最迟开始时间就是V6
的最迟开始时间减去弧所对应的权值。对于V3
这种节点,他有两条出边,他的最迟开始时间就是两边路径的最小值。
活动的最早开始时间对应其弧尾节点的最早开始时间。
活动的最晚开始时间对应弧头节点所对应的最晚发生时间和该活动所需要的时间。
活动余量根据最晚开始时间和最早开始时间直插算得,活动余量为0
的边组成的路径为关键路径。
总结:
6:例题
1:若对n个顶点,e条弧的有向图采用邻接表存储,则拓扑排序的时间复杂度为: 0(n+e)
.
解析:根据拓扑排序的算法,n个顶点都要经历入栈和出栈的过程,时间复杂度为O(n)
对于每一个顶点来说,采用邻接表的形式都要遍历该顶点的出边所连接的弧,并不需要扫描整个邻接表,只需要扫描其中的一行就行,然后将该弧的顶点的入度减1
,所以时间复杂度为0(n+e)
。
2: 下面的哪些方法可以判断有向图是否有环:
A:深度优先遍历 B:拓扑排序 C:求最短路径 D:求关键路径
解析:ABD。深度优先遍历可以根据是否在遍历某个节点时重新回到本来来判断。拓扑排序如果有个节点没有被加入进结果集也能说明存在环。最短路径允许有环。关键路径本身不能有环,但是算法本身无法判断是否有环。
3:下图的不同的拓扑排序个数为:

解析:aebcd,abced,abecd
。根据拓扑排序的性质,c
只能在ab
完成之后执行,d
只能在ec
完成之后执行,拓扑排序的每个顶点事件只能在自己的入度的弧所对应的顶点事件全部完成之后才能执行。a
结束之后可以走e,b
,如果选择e
之后之智能走bcd
,如果选择b之后可以走e
或c
,选择e
之后之能走cd
,选择c
之后只能走ed
。
4:只要无向图中有权值相同的边,其最小生成树一定不唯一。
解析:错误。如果该无向图正好是一棵树,则最小生成树唯一。最小生成树不唯一,但是最小生成树的代价是唯一的。
5:若有向图的拓扑有序序列唯一,则图中的每个顶点的入度和出度最多为1。
解析:错误。如下图:

6:下列说法中正确的是:A
A:在图G的最小生成树中,某条边的权值可能会超过未选边的权值。
B:若有向无环图的拓扑序列唯一,则可以唯一确定该图。
解析:最小生成树的目标是最后的权值之和最小,但是不一定选的就是权值最小的边,根据贪心策略,每次都选最小边,并不能保证选择的都是最小的边。根据例题5,其拓扑序列存在两种,还可以是纯线性序列,但是他们的拓扑排序序列是唯一的。
7:若一个有向图具有有序的拓扑排序序列,则它的邻接矩阵必定为:C
A:对称 B:稀疏 C:三角 D:一般。
解析:可以证明,对有向图中的顶点适当的编号,使其邻接矩阵为三角矩阵且主对角元素全为0的充分必要条件是,该有向图可以进行拓扑排序,且必然不存在环。
8:若用邻接矩阵存储有向图,矩阵中主对角线以下的元素均为0,则关于该图拓扑序列的结论是:
A:存在,且唯一
B:存在,且不唯一
C:存在,可能不唯一
D:无法确定是否存在
解析:上三角矩阵,可以确定无向图中必不存在环路,肯定存在拓扑序列,但是拓扑不一定唯一,如果存在三个节点,一和二,三分别连接则拓扑序列不唯一。
七:查找
1:顺序查找和折半查找
线性表分为:顺序表(数组表示),链表(链表)。按照是否有序分为有序表和无序表。
顺序查找:都适用,分为一般线性表的顺序查找和有序表的顺序查找。
折半查找:适用于有序的顺序表。
1.1:顺序查找
一般线性表的平均查找长度
(1):成功时 (n+1)/2
(2):失败时 (n+1)
有序线性表的平均查找长度
(1):成功时 (n+1)/2
(2):失败时 n/2 + n/(n+1)
int seqSearch(const int *arr, int key, int len) {
int i;
for (i = 0; i < len; i++)
if (arr[i] == key)
return i;
return -1;
}
1.2:折半查找
平均查找长度
(1):成功时 ceil(log2(n+1)) - 1
(2):失败时 floor(log2(n)) + 1
或者 ceil(log2(n+1))
折半查找的判定树是一棵二叉平衡树,最大平衡因子是1。
int binarySearch(const int *arr, int key, int len) {
int low = 0, high = len - 1;
int mid;
while (low <= high) {
// 标准写法
mid = low + ((high - low) >> 1);
if (arr[mid] == key)
return mid;
else if (arr[mid] < key)
low = mid + 1;
else
high = mid - 1;
}
return -1;
}
1.3:分块查找
将查找表分为若干子块,块内元素是无序的,块间元素是有序的。
平均查找长度:索引查找和块内查找的平均长度之和。
设元素总数为n,分为b快,每块有s个记录。
ASL = (b+1)/2 + (s+1)/2 = (s^2^ + 2s + n)/2s
如果平均分配,即 s = sqrt(n),则平均查找长度最小为 sqrt(n) + 1。
如果对索引使用折半查找,平均查找长度为 ASL = ceil(log2(b+1)) + (s+1)/2
2:B
树和B+
树
B树又称为多路平衡二叉树。m叉树,
性质:
(1):每个节点最多有m-1
个关键字。
(2):除了根节点,每个节点最少有两个子树。如果只有一个根节点,对关键字个数没有要求。
八:跳表
用于有序元素序列快速搜索查找的一个数据结构,跳表是一个随机化的数据结构,实质就是一种可以进行二分查找的有序链表。跳表在原有的有序链表上面增加了多级索引,通过索引来实现快速查找。跳表不仅能提高搜索性能,同时也可以提高插入和删除操作的性能。它在性能上和红黑树,AVL
树不相上下,但是跳表的原理非常简单,实现也比红黑树简单很多。
,每次都选最小边,并不能保证选择的都是最小的边。根据例题5,其拓扑序列存在两种,还可以是纯线性序列,但是他们的拓扑排序序列是唯一的。
7:若一个有向图具有有序的拓扑排序序列,则它的邻接矩阵必定为:C
A:对称 B:稀疏 C:三角 D:一般。
解析:可以证明,对有向图中的顶点适当的编号,使其邻接矩阵为三角矩阵且主对角元素全为0的充分必要条件是,该有向图可以进行拓扑排序,且必然不存在环。
8:若用邻接矩阵存储有向图,矩阵中主对角线以下的元素均为0,则关于该图拓扑序列的结论是:
A:存在,且唯一
B:存在,且不唯一
C:存在,可能不唯一
D:无法确定是否存在
解析:上三角矩阵,可以确定无向图中必不存在环路,肯定存在拓扑序列,但是拓扑不一定唯一,如果存在三个节点,一和二,三分别连接则拓扑序列不唯一。
七:查找
1:顺序查找和折半查找
线性表分为:顺序表(数组表示),链表(链表)。按照是否有序分为有序表和无序表。
顺序查找:都适用,分为一般线性表的顺序查找和有序表的顺序查找。
折半查找:适用于有序的顺序表。
1.1:顺序查找
一般线性表的平均查找长度
(1):成功时 (n+1)/2
(2):失败时 (n+1)
有序线性表的平均查找长度
(1):成功时 (n+1)/2
(2):失败时 n/2 + n/(n+1)
int seqSearch(const int *arr, int key, int len) {
int i;
for (i = 0; i < len; i++)
if (arr[i] == key)
return i;
return -1;
}
1.2:折半查找
平均查找长度
(1):成功时 ceil(log2(n+1)) - 1
(2):失败时 floor(log2(n)) + 1
或者 ceil(log2(n+1))
折半查找的判定树是一棵二叉平衡树,最大平衡因子是1。
int binarySearch(const int *arr, int key, int len) {
int low = 0, high = len - 1;
int mid;
while (low <= high) {
// 标准写法
mid = low + ((high - low) >> 1);
if (arr[mid] == key)
return mid;
else if (arr[mid] < key)
low = mid + 1;
else
high = mid - 1;
}
return -1;
}
1.3:分块查找
将查找表分为若干子块,块内元素是无序的,块间元素是有序的。
平均查找长度:索引查找和块内查找的平均长度之和。
设元素总数为n,分为b快,每块有s个记录。
ASL = (b+1)/2 + (s+1)/2 = (s^2^ + 2s + n)/2s
如果平均分配,即 s = sqrt(n),则平均查找长度最小为 sqrt(n) + 1。
如果对索引使用折半查找,平均查找长度为 ASL = ceil(log2(b+1)) + (s+1)/2
2:B
树和B+
树
B树又称为多路平衡二叉树。m叉树,
性质:
(1):每个节点最多有m-1
个关键字。
(2):除了根节点,每个节点最少有两个子树。如果只有一个根节点,对关键字个数没有要求。
八:跳表
用于有序元素序列快速搜索查找的一个数据结构,跳表是一个随机化的数据结构,实质就是一种可以进行二分查找的有序链表。跳表在原有的有序链表上面增加了多级索引,通过索引来实现快速查找。跳表不仅能提高搜索性能,同时也可以提高插入和删除操作的性能。它在性能上和红黑树,AVL
树不相上下,但是跳表的原理非常简单,实现也比红黑树简单很多。