title: 慕课网-算法与数据结构—学习总结
date: 2020-08-12 16:21:41
tags: LearnNotes
慕课网-算法与数据结构-学习总结
第一章 引言
第二章 排序基础
- 排序的稳定性
排序后是否改变原序列键值相同的序列的先后关系 - 内排序与外排序
外排序: 由于排序记录个数太多,不能同时放置在内存中,整个排序过程需要在内外存之间多次交换数据才能进行 。外部排序最常用的算法是多路归并排序 - 影响排序的三个方面
- 时间性能
- 比较
- 移动
- 辅助空间
- 算法复杂度
- 时间性能
交换排序
- 冒泡排序
- 基本思想:两两交换,将最大(或最小)的交换至队列前
选择排序
-
简单选择排序
- 基本思想:在未排序的序列种找到最小(或最大)的元素放到前面
-
参考实现代码(cpp)
template<typename T> void selectionSort(T arr[], int n){ for(int i = 0 ; i < n ; i ++){ int minIndex = i; for( int j = i + 1 ; j < n ; j ++ ) if( arr[j] < arr[minIndex] ) minIndex = j; swap( arr[i] , arr[minIndex] ); } }
-
直接选择排序
-
树型选择排序
插入排序及其改进
- 直接插入排序
- 基本思想: 将未排序的元素插入到已经排好序的队列种对应的位置
- 实现代码
template<typename T> void insertionSort(T arr[], int n){ for( int i = 1 ; i < n ; i ++ ) { // 寻找元素arr[i]合适的插入位置 // 写法1 // for( int j = i ; j > 0 ; j-- ) // if( arr[j] < arr[j-1] ) // swap( arr[j] , arr[j-1] ); // else // break; // 写法2 // for( int j = i ; j > 0 && arr[j] < arr[j-1] ; j -- ) // swap( arr[j] , arr[j-1] ); // 写法3 T e = arr[i]; int j; // j保存元素e应该插入的位置 for (j = i; j > 0 && arr[j-1] > e; j--) arr[j] = arr[j-1]; arr[j] = e; } return; }
- 改进:
- 折半插入排序: 找到已排好序种对应位置时 用 折半查找
- 希尔排序:
- 基本思想:交换不相邻的元素以对数组的局部进行排序
- 第一个O(n log n)的排序算法
- 参考代码
template<typename T> void shellSort(T arr[], int n){ // 计算 increment sequence: 1, 4, 13, 40, 121, 364, 1093... int h = 1; while( h < n/3 ) h = 3 * h + 1; while( h >= 1 ){ // h-sort the array for( int i = h ; i < n ; i ++ ){ // 对 arr[i], arr[i-h], arr[i-2*h], arr[i-3*h]... 使用插入排序 T e = arr[i]; int j; for( j = i ; j >= h && e < arr[j-h] ; j -= h ) arr[j] = arr[j-h]; arr[j] = e; } h /= 3; } }
- 希尔排序及其优化
- 基本思想:交换不相邻的元素以对数组的局部进行排序
- 其他: 路插入排序,表插入排序等。
- 基本思想: 将未排序的元素插入到已经排好序的队列种对应的位置
第三章 高级排序问题
归并排序及其优化
- 基本思想: (分治)将排序序列差分成 两个等长的子序列,对子序列进行排序后再归并
- 参考核心代码(cpp)
// 使用优化的归并排序算法, 对arr[l...r]的范围进行排序 template<typename T> void __mergeSort2(T arr[], int l, int r){ // 优化2: 对于小规模数组, 使用插入排序 if( r - l <= 15 ){ insertionSort(arr, l, r); return; } int mid = (l+r)/2; __mergeSort2(arr, l, mid); __mergeSort2(arr, mid+1, r); // 优化1: 对于arr[mid] <= arr[mid+1]的情况,不进行merge // 对于近乎有序的数组非常有效,但是对于一般情况,有一定的性能损失 if( arr[mid] > arr[mid+1] ) __merge(arr, l, mid, r); }
- 优化点:
- 当排序的元素少于一定(如16)时,直接调用 插入排序
- 如果第一个序列的最大值小于第二个序列的最小值,则不用比较,直接合并 - 归并排序的自底向上写法(迭代)
- 核心代码参考
// 使用自底向上的归并排序算法 template <typename T> void mergeSortBU(T arr[], int n){ // Merge Sort Bottom Up 优化 // 对于小数组, 使用插入排序优化 for( int i = 0 ; i < n ; i += 16 ) insertionSort(arr,i,min(i+15,n-1)); for( int sz = 16; sz < n ; sz += sz ) for( int i = 0 ; i < n - sz ; i += sz+sz ) // 对于arr[mid] <= arr[mid+1]的情况,不进行merge if( arr[i+sz-1] > arr[i+sz] ) __merge(arr, i, i+sz-1, min(i+sz+sz-1,n-1) ); // Merge Sort BU 也是一个O(nlogn)复杂度的算法,虽然只使用两重for循环 // 所以,Merge Sort BU也可以在1秒之内轻松处理100万数量级的数据 // 注意:不要轻易根据循环层数来判断算法的复杂度,Merge Sort BU就是一个反例 // 关于这部分陷阱,推荐看(liuyubobo老师)的《玩转算法面试》课程,第二章:《面试中的复杂度分析》:) }
快速排序
- 基本思想: (分治)
将选定的元素放到合适的位置,然后 递归 排序 被该元素分开的 前 后 两个子序列。(分出来的两个子序列可能不等长,相差很大,会影响性能) - 实现代码
// 对arr[l...r]部分进行partition操作
// 返回p, 使得arr[l...p-1] < arr[p] ; arr[p+1...r] > arr[p]
template <typename T>
int _partition(T arr[], int l, int r){
// 随机在arr[l...r]的范围中, 选择一个数值作为标定点pivot
swap( arr[l] , arr[rand()%(r-l+1)+l] );
T v = arr[l];
int j = l;
for( int i = l + 1 ; i <= r ; i ++ )
if( arr[i] < v ){
j ++;
swap( arr[j] , arr[i] );
}
swap( arr[l] , arr[j]);
return j;
}
// 对arr[l...r]部分进行快速排序
template <typename T>
void _quickSort(T arr[], int l, int r){
// 对于小规模数组, 使用插入排序进行优化
if( r - l <= 15 ){
insertionSort(arr,l,r);
return;
}
int p = _partition(arr, l, r);
_quickSort(arr, l, p-1 );
_quickSort(arr, p+1, r);
}
- 优化:
1. 标定点 随机选(保证两个子序列长度相近)(针对基本有序的序列,如果固定选最前面的元素,则分治的两个子问题不平衡,退化为O(n^2)的复杂度)
2. 小规模排序,使用插入排序 - 双路快排
- 基本思想:针对键值重复过多时,分治的两个子序列不等长( < 与 > = 或 者 > 与 < = <与>= 或者 >与<= <与>=或者>与<=),导致分治不平衡
- 核心代码参考
// 双路快速排序的partition // 返回p, 使得arr[l...p-1] <= arr[p] ; arr[p+1...r] >= arr[p] // 双路快排处理的元素正好等于arr[p]的时候要注意,详见下面的注释:) template <typename T> int _partition2(T arr[], int l, int r){ // 随机在arr[l...r]的范围中, 选择一个数值作为标定点pivot swap( arr[l] , arr[rand()%(r-l+1)+l] ); T v = arr[l]; // arr[l+1...i) <= v; arr(j...r] >= v int i = l+1, j = r; while( true ){ // 注意这里的边界, arr[i] < v, 不能是arr[i] <= v // 思考一下为什么? while( i <= r && arr[i] < v ) i ++; // 注意这里的边界, arr[j] > v, 不能是arr[j] >= v // 思考一下为什么? while( j >= l+1 && arr[j] > v ) j --; // 对于上面的两个边界的设定, 有的同学在课程的问答区有很好的回答:) // 大家可以参考: http://coding.imooc.com/learn/questiondetail/4920.html if( i > j ) break; swap( arr[i] , arr[j] ); i ++; j --; } swap( arr[l] , arr[j]); return j; } // 对arr[l...r]部分进行快速排序 template <typename T> void _quickSort(T arr[], int l, int r){ // 对于小规模数组, 使用插入排序进行优化 if( r - l <= 15 ){ insertionSort(arr,l,r); return; } // 调用双路快速排序的partition int p = _partition2(arr, l, r); _quickSort(arr, l, p-1 ); _quickSort(arr, p+1, r); }
- 三路快排
- 基本思想: 针对2路快排的加强,进一步解决键值重复过多的问题(增加一个等值区域)
- 核心代码参考
// 递归的三路快速排序算法 template <typename T> void __quickSort3Ways(T arr[], int l, int r){ // 对于小规模数组, 使用插入排序进行优化 if( r - l <= 15 ){ insertionSort(arr,l,r); return; } // 随机在arr[l...r]的范围中, 选择一个数值作为标定点pivot swap( arr[l], arr[rand()%(r-l+1)+l ] ); T v = arr[l]; int lt = l; // arr[l+1...lt] < v int gt = r + 1; // arr[gt...r] > v int i = l+1; // arr[lt+1...i) == v while( i < gt ){ if( arr[i] < v ){ swap( arr[i], arr[lt+1]); i ++; lt ++; } else if( arr[i] > v ){ swap( arr[i], arr[gt-1]); gt --; } else{ // arr[i] == v i ++; } } swap( arr[l] , arr[lt] ); __quickSort3Ways(arr, l, lt-1); __quickSort3Ways(arr, gt, r); } template <typename T> void quickSort3Ways(T arr[], int n){ srand(time(NULL)); __quickSort3Ways( arr, 0, n-1); } // 比较Merge Sort和双路快速排序和三路快排三种排序算法的性能效率 // 对于包含有大量重复数据的数组, 三路快排有巨大的优势 // 对于一般性的随机数组和近乎有序的数组, 三路快排的效率虽然不是最优的, 但是是在非常可以接受的范围里 // 因此, 在一些语言中, 三路快排是默认的语言库函数中使用的排序算法。比如Java:)
归并排序和快速排序衍生的问题
* 求逆序对(归并排序)
* 求数组中的第N大元素
第四章 堆和堆排序
基本概念认识
- 队列
- 普通队列:先进先出
- 优先队列:根据优先级出队
- 优先队列应用 对动态的数据排序
- 优先队列的三种实现方式
实现方式 入队 出队 普通数组 O(1) O(n) 顺序数组(元素有序) O(n) O(1) 堆 O(log n) O(log n)
堆的基本存储
- 用数组存储二叉堆
- 数组的索引次序 对应 二叉堆中 层序遍历次序
- 对于完全二叉树,对第i个元素,其与其父,其子的关系
- 根节点索引从0开始
parent(i) = (i-1)/2
left child (i) = 2*i +1
right child (i) = 2*i + 2
- 根节点索引从1开始
parent(i) = i/2
left child (i) = 2*i
right child (i) = 2*i + 1
- 根节点索引从0开始
heapfy过程(堆的建立)
- **基本思想:**从最后一个非叶子节点(索引为count/2,在根节点索引从1开始的情况下)开始,shiftdown(),自底向上ShiftDown操作,实现堆化。
- 将n个元素逐个插入空堆中,算法复杂度为O(n log n),而heapfy过程算法复杂度O(n)
- 实现代码
// 构造函数, 通过一个给定数组创建一个最大堆
// 该构造堆的过程, 时间复杂度为O(n)
MaxHeap(Item arr[], int n){
data = new Item[n+1];
capacity = n;
for( int i = 0 ; i < n ; i ++ )
data[i+1] = arr[i];
count = n;
for( int i = count/2 ; i >= 1 ; i -- )
shiftDown(i);
}
ShiftUp(节点上移比较)
- 代码
void shiftUp(int k){ while( k > 1 && data[k/2] < data[k] ){ swap( data[k/2], data[k] ); k /= 2; } }
ShiftDown(节点下移比较)
- 代码
void shiftDown(int k){ while( 2*k <= count ){ int j = 2*k; // 在此轮循环中,data[k]和data[j]交换位置 if( j+1 <= count && data[j+1] > data[j] ) j ++; // data[j] 是 data[2*k]和data[2*k+1]中的最大值 if( data[k] >= data[j] ) break; swap( data[k] , data[j] ); k = j; } }
堆排序及其优化
- 堆排序算法:
- 创建堆: 一种通过不断insert()创建O(nlogn),一种传入数组用heapfy创建O(n)
- 堆顶出堆: 将堆顶与堆尾互换,堆的size-1,并将新的堆顶(原堆尾)shiftDown()
- 重复第二步
- 堆排序的实现
-
借助insert()创建堆,不断将
// heapSort1, 将所有的元素依次添加到堆中, 在将所有元素从堆中依次取出来, 即完成了排序 // 无论是创建堆的过程, 还是从堆中依次取出元素的过程, 时间复杂度均为O(nlogn) // 整个堆排序的整体时间复杂度为O(nlogn) template<typename T> void heapSort1(T arr[], int n){ MaxHeap<T> maxheap = MaxHeap<T>(n); for( int i = 0 ; i < n ; i ++ ) maxheap.insert(arr[i]); for( int i = n-1 ; i >= 0 ; i-- ) arr[i] = maxheap.extractMax(); }
-
借助heapfy() 创建堆
-
// heapSort2, 借助我们的heapify过程创建堆
// 此时, 创建堆的过程时间复杂度为O(n), 将所有元素依次从堆中取出来, 实践复杂度为O(nlogn)
// 堆排序的总体时间复杂度依然是O(nlogn), 但是比上述heapSort1性能更优, 因为创建堆的性能更优
template<typename T>
void heapSort2(T arr[], int n){
MaxHeap<T> maxheap = MaxHeap<T>(arr,n);
for( int i = n-1 ; i >= 0 ; i-- )
arr[i] = maxheap.extractMax();
}
- 堆的实现细节
ShiftUp和ShiftDown种使用复制操作替换swap操作
索引堆
- 基本思想: 分索引数组和数据数组,数据数组存数据元素。索引数组按堆的次序(比较的是实际元素的键值)存对应节点的索引(地址)。ShiftUP和ShiftDown中比较(实际元素的值),交换对应的索引(存储地址),而不交换实际的元素。
- 一点理解: 维护了一个数组,既能有堆的特性(取最值),又能保持原有数组的存储次序(数据数组的索引是原数组的+1)。
- ShfitUp()
// 索引堆中, 数据之间的比较根据data的大小进行比较, 但实际操作的是索引 void shiftUp( int k ){ while( k > 1 && data[indexes[k/2]] < data[indexes[k]] ){ swap( indexes[k/2] , indexes[k] ); k /= 2; } }
- ShiftDown()
// 索引堆中, 数据之间的比较根据data的大小进行比较, 但实际操作的是索引 void shiftDown( int k ){ while( 2*k <= count ){ int j = 2*k; if( j + 1 <= count && data[indexes[j+1]] > data[indexes[j]] ) j += 1; if( data[indexes[k]] >= data[indexes[j]] ) break; swap( indexes[k] , indexes[j] ); k = j; } }
- 增删实现 (插入操作 不是很理解)
// 向最大索引堆中插入一个新的元素, 新元素的索引为i, 元素为item // 传入的i对用户而言,是从0索引的 void insert(int i, Item item){ assert( count + 1 <= capacity ); assert( i + 1 >= 1 && i + 1 <= capacity ); i += 1; data[i] = item; indexes[count+1] = i; count++; shiftUp(count); } // 从最大索引堆中取出堆顶元素, 即索引堆中所存储的最大数据 Item extractMax(){ assert( count > 0 ); Item ret = data[indexes[1]]; swap( indexes[1] , indexes[count] ); count--; shiftDown(1); return ret; }
- 应用: 最小生成树Prim算法中,用来维护 每个节点对应的最小横切面的权重【同时能输出当前所有节点最小横切面中的最小横切面】
涉及堆 的相关问题
- 多路归并排序 多个元素同时比较的时候用,最小(大)堆
- d叉堆 d-ary heap
- 最大最小队列 (最大堆和最小堆同时维护??)
- 二项堆
- 斐波那契堆
桶排序
基本思想: 分配 + 收集 (先排序低位再排序高位)
排序总结
排序 | 平均时间复杂度 | 原地排序 | 额外空间 | 稳定排序 |
---|---|---|---|---|
插入排序(Insertion Sort) | O(n^2) | 是 | O(1) | 是 |
归并排序(Merge Sort) | O(nlogn) | 否 | O(n) | 是 |
快速排序(Quick Sort) | O(nlogn) | 是 | O(logn) | 否 |
堆排序(Heap Sort) | O(nlogn) | 是 | O(1) | 否 |
- 不同排序算法的选择
- n较小:直接插入排序或直接选择排序
- 基本有序序列,直接插入,冒泡,随机的快速排序
- n较大,应选复杂度好的:快速排序,归并排序, 堆排序。
- 快速排序性能平均最好,堆排序更少的辅助空间,归并排序是稳定的排序。(当排序数少于一定时如16,调用直接插入排序)
其他参考
第五章 二分搜索树
5-1 二分查找法
- 基本思想: (递归分治)对有序序列,不需要逐一比较,只要比较中间值,然后确定目标值的可能区域,再在可能区域递归二分查找。
- 细节:
- 计算中间值时, 注意防止越界(基本类型的范围)。
- 合理写法:
int mid = left + (right-left)/2
- 危险写法:
int mid = (left + right)/2
- 合理写法:
- 二分查找返回的只有一个索引,而序列中可能存在重复值。如何返回所有重复值?
- 练习:对于存在重复值的序列,返回目标值索引的floor(第一个索引)和ceil(最后一个索引)
- 计算中间值时, 注意防止越界(基本类型的范围)。
- 改进:(选不同的分割点)
- 插值查找 (按比值分割)
mid = left + (right-left)*{(key-a[left])/(a[right]-a[left])}
- 斐波那契查找
mid = left + F_block - 1
(黄金分割)
- 插值查找 (按比值分割)
5-2 二分搜索树
-
二分搜索树的定义:
- 左子树>(<) 根 >(<) 右子树 [一般不考虑键值重复的问题]
-
二分搜索树的优势
- 高效((O(nlogn))的维护数据的有序性:min,max,floor,ceil,rank,select
-
二分搜索树的节点插入
- 实现方式:
- 递归(返回子树根节点)[从根节点开始搜索,找到key值则替换,未找到则根据比较关系递归子树]
// 向以node为根的二分搜索树中, 插入节点(key, value), 使用递归算法 // 返回插入新节点后的二分搜索树的根 private Node insert(Node node, Key key, Value value)\{ if( node == null ){ count ++; return new Node(key, value); } if( key.compareTo(node.key) == 0 ) node.value = value; else if( key.compareTo(node.key) < 0 ) node.left = insert( node.left , key, value); else // key > node->key node.right = insert( node.right, key, value); return node; }
- 迭代
- 当作练习
- 实现方式:
-
二分搜索书的查找
- 基本思想: 二分查找
-
二分搜索树的遍历
- (深度优先遍历)
- 前序遍历,中序遍历,后序遍历
- (广度优先遍历)
- 层序遍历
- (深度优先遍历)
-
删除最大值,最小值
- 实现:
- 递归:(不断递归(左/右)子树,直到节点没有(左/右)子树,删除该节点,返回其的(右/左)子树赋值给上一节点的(左/右)子树)
- 实现:
-
删除Key对应的节点
- 实现:
- 递归
- 从根节点开始递归搜索
- 如果目标值在根节点则删除根节点,选新的中间值上位(左子树最右(大/小)的节点,右子树最左(小/大)的节点)
- 否则根据比较次序,选择(左/右)子树递归搜索,根节点的(左/右)子树 = 返回删除最值后的子树的根节点
- 从根节点开始递归搜索
- 递归
- 实现:
5-3 二分搜索树的特性
-
顺序性
- minimum/maximum
- successor/predecessor 后继/前继
- floor/ceil (存在键值重复时,索引的范围)
- rank/select 已知key获取排名/已知排名获取key,value
-
局限性:(不平衡) 依照顺序或逆序插入元素,二分搜索树退化为链表(跟节点为第一个元素,在最左,或最右)。
-
支持重复键值的二分搜索树
- 思路一: 让键值重复节点的右子树或左子树包含键值重复的节点。
- 思路二: 每个节点增加一个区域(链表或数组),存键值相等的元素
二叉平衡树
- 平衡二叉树:
- 左右子树高度差不超过1的二叉搜索树,即平衡二叉树所有结点的平衡因子绝对值不超过1(平衡因子 = 结点左子树的高度 - 结点右子树的高度)。
- 实现方式:
- AVL树
- 红黑树
- 2-3树
- Splay树
AVL 树
-
基本思想,每插入一个节点,自底向上维护树的平衡,以及每个节点的平衡因子
-
平衡调整的四种类型:
- LL型 右旋调整 调整后涉及到的节点的平衡因子(bf)均为0
- RR型 左旋调整 调整后涉及到的节点的平衡因子(bf)均为0
- LR型 先左旋调整至LL型, 然后右旋调整 bf的维护如下:
- RL型 先右旋调整至RR型, 然后左旋调整
-
插入操作思路整理:
- 考虑当前节点的子树是否添加了新节点
- 没有 则不用维护,直接返回
- 左子树添加了新节点 >>> 当前节点的平衡因子要加一(
b
f
=
b
f
+
1
bf= bf+1
bf=bf+1)
- 若 b f > 1 bf>1 bf>1(需要平衡调整) >>> 进行左平衡调整
- 右子树添加了新节点 >>> 当前节点的平衡因子要减一(
b
f
=
b
f
−
1
bf = bf -1
bf=bf−1)
- 若 b f < − 1 bf<-1 bf<−1,需要平衡调整 >>> 进行右平衡调整
- 考虑当前节点的子树是否添加了新节点
-
左平衡调整(cur.bf > 1)
- 看cur的左子节点L 的平衡因子 L.bf
- L.bf == 1 >>> LL型 >>> 右旋操作
- L.bf == -1 >>> LR型 >>> 先左旋调整至LL型,然后右旋调整
- 看cur的左子节点L 的平衡因子 L.bf
-
右平衡调整(cur.bf < -1)
- 看cur节点的右孩子节点R 的平衡因子R.bf
- R.bf == -1 >>> RR型 >>> 左旋操作
- R.bf == 1 >>> RL型 >>> 先右旋调整至RR型,然后左旋调整
- 看cur节点的右孩子节点R 的平衡因子R.bf
-
插入操作代码
//插入操作
void insert(K key,V value) {
taller = false; //增加了新的一层??
root = __insert(root,key,value);
}
//返回插入新节点后的根节点
private AVLNode __insert(AVLNode curNode,K key,V value) {
if(curNode==null) {
curNode = new AVLNode(key,value);
taller = true; //增加了新节点
return curNode;//
}
else {
if(key.compareTo(curNode.key)==0) {
return curNode;//节点已经存在,返回(不覆盖旧值)
}
else if(key.compareTo(curNode.key)<0) {
curNode.left = __insert(curNode.left,key,value); //往左子树新增节点
if(taller) {//增加了新节点
switch(curNode.bf) {
case 0: //新增节点没有打破平衡,但bf+1
curNode.bf = 1;//左边新加一个点
taller = true;//此处很重要,自底 向上传递平衡信息
break;
case 1: //本来左子树多一个结点,然后taller为true,左子树又增加了一个结点
curNode = leftBalance(curNode); //左平衡时必须保证 curNode具有左孩子
taller = false; //左平衡后 达到平衡
break;
case -1: //新增节点使得curNode左右平衡
curNode.bf = 0;
taller = false;
break;
}
}
return curNode;
}
else { //往当前节点的右子树增添节点
curNode.right = __insert(curNode.right,key,value);//往右子树增加节点
if(taller) {
switch(curNode.bf) {
case 0:
curNode.bf = -1;
taller = true;
break;
case 1:
curNode.bf = 0;
taller = false;
break;
case -1:
curNode = rightBalance(curNode);
taller = false;
break;
}
}
return curNode;
}
}
}
- 左平衡调整代码
/*
* 左平衡操作
*/
//[当前curNode的bf>0,又再左子树增加节点时需要左平衡操作]
AVLNode leftBalance(AVLNode curNode) {
AVLNode L = curNode.left;
if(L.bf==1) { //LL型需要左旋
curNode.bf = 0;//右旋平衡
L.bf = 0;//右旋平衡
return rightRotate(curNode);
}
else if(L.bf==-1) {//LR型需要 先左旋 后右旋
AVLNode LR = L.right; //左孩子的右孩子
//以下代码不是很理解,,,画一下实际例子可以明白,,但为啥子LR.bf不会一直被维护为0?(因为LR不一定是新增节点?)
switch(LR.bf) {
case 1:
curNode.bf = -1;
L.bf = 0;
break;
case 0:
curNode.bf = 0;
L.bf = 0;
break;
case -1:
curNode.bf = 0;
L.bf = 1;
break;
}
LR.bf = 0;
curNode.left = leftRotate(curNode.left);
return rightRotate(curNode);
}
else {//不需要左平衡处理
return curNode;
}
}
- 右平衡调整代码
//右平衡操作
/*
* curNode bf本已经-1的基础上,在右方又新增节点
*/
AVLNode rightBalance(AVLNode curNode) {
AVLNode R = curNode.right;
if(R.bf==-1) { //RR型
curNode.bf = 0;
R.bf = 0;
return leftRotate(curNode);
}
else if(R.bf==1) {//RL型
AVLNode RL = R.left;
switch(RL.bf) {
case 0:
curNode.bf = 0;
R.bf = 0;
break;
case 1:
R.bf =-1;
curNode.bf = 0;
break;
case -1:
curNode.bf = 1;
R.bf = 0;
break;
}
RL.bf = 0;
curNode.right = rightRotate(R);
return leftRotate(curNode);
}
else {
return curNode;
}
}
- 左旋操作
//左旋操作
AVLNode leftRotate(AVLNode curNode) {
AVLNode subRoot = curNode.right;
curNode.right = subRoot.left;
subRoot.left = curNode;
return subRoot;
}
- 右旋操作
//右旋操作
AVLNode rightRotate(AVLNode curNode) {
AVLNode subRoot = curNode.left;//左孩子上位
curNode.left = subRoot.right;//把你的右孩子给我,作为我的左孩子(保证排序性)
subRoot.right = curNode;//你上位后我成为你的有孩子
return subRoot;
}
- 其他操作实现:
- 可参考<<数据结构从应用到时间(Java版)>>
5-5 多路查找树
-
多路查找树的意义
对于树来说,一个结点只能存储一个元素,那么在元素非常多的时候,就会使得要么树的度非常大(结点拥有子树的个数的最大值),要么树的高度非常大,甚至两者都必须足够大才行,这就使得内存存取外存次数非常多,这显然成了时间效率上的瓶颈,这迫使我们要打破每一个结点只存储一个元素的限制,为此引入了多路查找树的概念
-
2-3 树
- 每个结点都具有两个孩子(我们称它为 2 结点)或三个孩子(我们称它为 3 结点)的树
- 特性
一个 2 结点 包含一个元素和两个孩子(或没有孩子),且左子树数据元素小于该元素右子树数据元素大于该元素
一个 3 结点 包含一小一大两个元素和三个孩子(或没有孩子)
如果有 3 个孩子的话
左子树包含小于较小元素的元素
右子树包含大于较大元素的元素
中间子树包含介于两元素之间的元素
-
2-3-4 树
-
B 树
5-6 树形问题和更多树。
- 平衡二叉树和堆的结合Treap
- trie :查找效率与单词长度有关,与单词中的单词数量无关
- 其他树形(递归)问题:
- 一条龙游戏
- 8 数码问题
- 8 皇后问题
- 数独
- 搬运工
- 更多树的结构
- KD树
- 区间树
- 哈夫曼树
第六章:并查集
6-1 并查集基础
- 解决的问题:
- 连接问题, 数学中集合类的实现,
- 路径问题
- 并查集的基本方法
- union(p,q) 将p与q 联合 (同属一个集合(连通分支))
- find§ 查找p所在集合序号
- isConnect(p,q)
- 并查集的实现
- 数组实现(QuickFind) (数组索引为元素键值,数组值为对应的集合号)
- union(p,q) 需要遍历更新所有元素的集合号,时间复杂度O(n)
- find§ 直接数组下标查找,O(1)
- isConnect() 复杂度O(1)
- 树形实现(数组索引为元素键值,数组值为该节点的父节点的索引)
- union(p,q) 时间复杂度O(h),h为树的高度
- find§ 需要回溯到(所在分支的)跟节点,O(h),h为树的高度
- isConnect() 复杂度O(h,h为树的高度
- 树形实现中 union() 的三种实现方式(不断改进(降低)树的高度h)
- 随机合并
- 基于size的优化
- 维护一个size数组,存每个分支的元素多少
- 将元素较少的树合并到元素较多的的树 新的分支size = size1 + size2
- 基于rank的优化
- 维护一个rank数组,存每个分支对应树的高度
- 将高度小的和平到高度大的
- find() 过程中的 路径压缩(降低树的高度)
- 基本思想: 基于树形实现的并查集,在find()时,需要回溯到分支树的根节点,在回溯的过程,可以将树的高度进行调整。
- 具体实现思路: 回溯过程中,将当前节点的父节点不断向上更新
- 代码
// 查找过程, 查找元素p所对应的集合编号 // O(h)复杂度, h为树的高度 private int find(int p){ assert( p >= 0 && p < count ); // path compression 1 while( p != parent[p] ){ parent[p] = parent[parent[p]]; //指向父节点的父节点 p = parent[p]; } return p; // path compression 2, 递归算法 // if( p != parent[p] ) // parent[p] = find( parent[p] ); // return parent[p]; }
- 数组实现(QuickFind) (数组索引为元素键值,数组值为对应的集合号)
- 并查集的应用
- 题目
- leetcode130_被围绕的区域
- 题目
第七章: 图
7-1 图论基础
- 图的应用
- 通信网路
- 社交网络
- 状态机等
- 图的分类
- 无权图与有权图
- 有向图与无向图
- 图的表示
- (n,m) n个顶点,m条边
- 邻接矩阵 n ∗ n n * n n∗n矩阵 适合稠密图
- 邻接表 适合稀疏图
- 其他
7-2 相邻点迭代器
- 图遍历过程中,避不开的便是遍历当前节点的相邻节点
- 迭代器的思想: 对外提供访问数据的功能,同时避免内部数据直接暴露在外
- 复杂度分析
- 邻接矩阵 O(n)
- 邻接表 O(m)
7-4 图的算法框架
- 读图方式(仅限本课程)
- 不断添加边(需要知道顶点数)
7-5 深度优先遍历和联通分量
- 深度优先遍历(DFS)
- 基本思想 递归
- 复杂分析
- 邻接表O(V+E)
- 邻接矩阵O(V^2)
- 求连通分支
- 维护一个size为顶点数的vis数组 (可以存每个节点是否被访问,也可以每个节点存属于哪个分支)
- 对每一个没有被访问的节点进行深度优先遍历
- 寻路
- 维护一个from 数组 ,from[i]存第i个节点在dfs(s)过程中的上一个节点
- 从 s s s 节点开始 dfs()
- 从目的点d 开始,根据from数组回溯,得到s到d的路径
7-7 广度优先遍历和最短路径
- 广度优先遍历
- 基本思想 迭代
- 实现细节
- 处理环: 相对于树来说 图存在环的可能,所以遍历的时候是将加入队列的元素标记为已访问,而不是访问结点时才标记。
- 复杂度分析
- 邻接表O(V+E)
- 邻接矩阵O(V^2)
- 求 无权图的 最短路径
- 维护一个from(回溯)数组,from[i]存第i个节点到dfs(s)过程中的上一个节点
- 维护一个order数组, order[i] 存 第i个节点在从s开始的bfs过程中的第几层(最短距离)
- 从 s s s 节点开始 bfs()
- 从 d 开始,根据from数组回溯
7-8 更多无权图的应用
- 迷宫生成,ps抠图等
第八章:最小生成树
8-1 最小生成树问题和切分定理
- 最小生成树的应用
- 电缆布线设计,网络设计,电路设计等。
- 最小生成树的适用范围
- 主要针对 带权 无向 连通图
- 切分定理
- 相关概念
- 切分
- 横切边
- 切分定理: 给定任意切分,横切边中权值最小的边必然属于最小生成树
- 相关概念
8-2 Prim算法及其优化
- Lazy Prim 算法
- 基本思想: 迭代/递归 贪心(每次根据切分定理得到局部最优) 动态规划?
- 算法流程
- 递归/迭代遍历节点
- 将节点集合 分为 已经被访问过的节点和未被访问过的节点,并将切分这两个集合的横切边存入最小堆中
- 每次选择最小堆中的横切边 出堆
- 若 该横切边的两个端点均 已经被访问 , 丢弃该边, 不做操作
- 否则, 将该边加入最小生成树中,继续遍历 该边中未被访问的点。
- lazy体现在(局限性):
- 最小堆中,维护的横切边中,有很多不再时横切边的 没有及时丢弃。
- 复杂度 O(ElogE)
- Prim算法的优化
- 用索引堆 (pq)来维护
- 每个已经被访问的点对应的最小横切边 的权重。pq.get(i) 表示第i个节点关联的当前最小横切边权重
- 上述横切边集合中 的 最小横切边的权重 (最小中的最小) (及堆顶)
- 用 edgeTo 辅助维护 边的信息(起始点)
- 用 vis 数组来维护 节点是否已被访问
- Prim 算法流程
- 初始化: 将起始点的横切边 的权值加入索引堆pq中,起始点标记为 已经被访问
- 循环:
- 选pq中 最小权值的横切边e 加入到最小生成树的 边集合
- 递归 选出来边e的两个端点中 未被访问的节点v:
- 端点v 标记为 已经被访问
- 遍历端点v 的 邻接边adjEdge,邻接边的另一个端点w
- 保证**pq[w]**小((与w节点关联的当前最小横切边的权重)
- 若pq[w]) 为空,则将 adjEdge 的权重 更新至 pq[w]
- 或者若adjEdge的权重小于 pq[w] 则将 adjEdge 的权重更新至pq[w]
- 保证**pq[w]**小((与w节点关联的当前最小横切边的权重)
- 复杂度分析 O(ElogV)
- 用索引堆 (pq)来维护
8-3 Krusk算法
- 基本思想: 贪心 不断将不与已添加的边构成圈的最小边 加入到生成树边集合种
- 数据结构
- 用最小堆,维护边集合,保证每次能取出剩余边中的最小边
- 用并查集,维护节点在当前生成树边集合下的连通度,判断新增边是否构成环
- 算法流程
- 对边进行排序(复杂度O(ElogE)),用最最小堆来维护边集合。
- 将最小边出堆,并加入生成树的集合。
- 循环开始:
- 将 剩余边集合中 最小边 出堆
- 判断改边与 生成树边集合中的边是否 构成环 (用并查集判断)
- 若成环 抛弃, 继续找下一个边
- 将改边加入生成树的边集合
- 判断改边与 生成树边集合中的边是否 构成环 (用并查集判断)
- 将 剩余边集合中 最小边 出堆
- 复杂度分析 O(ElogE)
8-6 最小生成树算法的思考
- 其他最小生成树的算法
- Vyssotsky’s Algorithm 将边逐渐添加到生成树中,一旦形成环,删除环中权值最大的边
第九章:最短路径
9-1 最短路径问题和松弛操作
- 最短路径的应用
- 路径规划
- 工作任务计划
- 广度优先遍历 求 无权图 的最短路径
- 最短路径树 (单源最短路径:一点到所有其他点的最短路径)
- 松弛操作(Relaxation)
- 考虑经过该节点的 是否有到达其他节点的更短的路径
9-2 Dijkstra算法的思想
- 适用条件
- 图中不能有负权边
- 复杂度O(E log V)
- 实现
- 数据结构:
- 索引堆: 维护每个节点当前被到达的最短路径, 以及 所有最短路径中的最短路径
- 辅助:访问标记数组: 存当前节点是否进行了松弛操作
- 回溯数组: 存到达该节点的最短路径中的上一个节点
- 索引堆: 维护每个节点当前被到达的最短路径, 以及 所有最短路径中的最短路径
- 数据结构:
- 算法流程
- 初始化数据结构:
- 索引堆
IndexMinHeap(imh)
imh[w] 表示第w个节点当前最短路径的权值 - 访问数组
boolean[] marked
, marked[i] 表示第 i 个节点是否进行过松弛操作,初始都为false - 回溯数组
int[] from
, from[i] 表示目的点为第i个节点的上一个节点,初始均赋值为-1
- 索引堆
- 初始化:将 源节点 s s s 到自身的路径的权重 加入索引堆
- 循环(直至索引堆为空)
- 当前距离源节点
s
s
s 最近的节点
v
v
v , 出堆
- 如果
v
v
v 没有进行过松弛操作(marked[v]==false), 则对其进行 松弛操作:
- 对
v
v
v 所有邻节点
w
w
w :
- 如果
w
w
w 没有进行过松弛操作(
m
a
r
k
e
d
[
v
]
=
=
f
a
l
s
e
marked[v]==false
marked[v]==false)
- s s s 经过 v v v 到达 w w w 的最短路径为: d i s = d i s ( s , v ) + d i s ( v , w ) dis = dis(s,v) + dis(v,w) dis=dis(s,v)+dis(v,w)
if from[w]==-1||ids<imh[w]
imh[w] = dis
from[w] = v
- 如果
w
w
w 没有进行过松弛操作(
m
a
r
k
e
d
[
v
]
=
=
f
a
l
s
e
marked[v]==false
marked[v]==false)
marked[v] = true
- 对
v
v
v 所有邻节点
w
w
w :
- 如果
v
v
v 没有进行过松弛操作(marked[v]==false), 则对其进行 松弛操作:
- 当前距离源节点
s
s
s 最近的节点
v
v
v , 出堆
- 初始化数据结构:
- 实现代码:
- 初始化及核心代码
//初始化
marked = new boolean[graph.V()];
from = new int[graph.V()];
for(int i=0;i<graph.V();i++) {
from[i] = -1;
marked[i] = false;
}
//Dijkstra
IndexMinHeap<Weight> imh = new IndexMinHeap<>(graph.V());
imh.insert(s, (Weight)(Number)(0.0));
while(!imh.isEmpty()) {
int v = imh.extractMinIndex();
if(!marked[v])
Relaxation(v,imh); //Relaxation松弛操作
}
- 松弛操作
//松弛操作
void Relaxation(int v, IndexMinHeap imh) {
marked[v] = true;
//遍历邻边
for(Edge<Weight> e : (List<Edge>)graph.adj(v)) {
int w = e.getOther(v);
if(!marked[w])
{
Double dis = e.getW().doubleValue()+(((Number) imh.getItem(v)).doubleValue());
if(from[w] == -1||imh.getItem(w).compareTo(dis)>0) {
if(!imh.contain(w))
imh.insert(w,dis);
else
imh.change(w, dis);
from[w] = v;
}
}
}
9-3 负权边和Bellman-Ford算法
-
负权边带来的问题
- 每次选择的并不是
- 负权环
-
Bellman-Ford 单源最短路径算法
- 前提: 图中不能有负权环
- Bellman-Ford 可以判断图中是否有负权环
- 如果一个图没有负权环,则:
- 从一个顶点到另一个顶点的最短路径,最多经过所有( V V V)的顶点,即有 V − 1 V-1 V−1条边
- 否则, 存在 顶点 经过了两次, 即 存在负权环
- 如果一个图没有负权环,则:
- 复杂度 O ( E V ) O(EV) O(EV)
-
Bellman-Ford 基本思想
- 对所有点进行 v − 1 v-1 v−1 次 松弛操作,如果还可以继续松弛,则存在负权环
- 对所有点(V)进行松弛操作, 遍历所有的边(E),复杂度为O(VE)??
-
算法流程
- Initial: from[s] = s; distTo[s] = 0;//初始化源节点 信息
- for pass = 1:G.V()-1 : //遍历 G.V()-1 遍
- for(int v=0; v < G.V();v++) : //每一遍对每个节点进行一次松弛操作
- Relaxation(v):
- 如果
v
v
v可达,遍历
v
v
v 的所有邻接点
w
i
wi
wi:
- d i s = d i s ( s , v ) + d i s ( v , w i ) dis = dis(s,v) + dis(v,wi) dis=dis(s,v)+dis(v,wi)
- if from[w]==-1||dis < dis(s,w) :
- dis(s,w) = dis;
- from[w] = v;
- 如果
v
v
v可达,遍历
v
v
v 的所有邻接点
w
i
wi
wi:
- Relaxation(v):
- for(int v=0; v < G.V();v++) : //每一遍对每个节点进行一次松弛操作
- //对每个节点再进行一次松弛操作,如果路径还能再减,则存在负权环
-
实现代码
// Bellman-Ford的过程
// 进行V-1次循环, 每一次循环求出从起点到其余所有点, 最多使用pass步可到达的最短距离
for( int pass = 1 ; pass < G.V() ; pass ++ ){
// 每次循环中对所有的边进行一遍松弛操作
// 遍历所有边的方式是先遍历所有的顶点, 然后遍历和所有顶点相邻的所有边
for( int i = 0 ; i < G.V() ; i ++ ){
// 使用我们实现的邻边迭代器遍历和所有顶点相邻的所有边
for( Object item : G.adj(i) ){
Edge<Weight> e = (Edge<Weight>)item;
// 对于每一个边首先判断e->v()可达
// 之后看如果e->w()以前没有到达过, 显然我们可以更新distTo[e->w()]
// 或者e->w()以前虽然到达过, 但是通过这个e我们可以获得一个更短的距离, 即可以进行一次松弛操作, 我们也可以更新distTo[e->w()]
if( from[e.v()] != null && (from[e.w()] == null || distTo[e.v()].doubleValue() + e.wt().doubleValue() < distTo[e.w()].doubleValue()) ){
distTo[e.w()] = distTo[e.v()].doubleValue() + e.wt().doubleValue();
from[e.w()] = e;
}
}
}
}
- 检测是否有负权环
// 判断图中是否有负权环
boolean detectNegativeCycle(){
for( int i = 0 ; i < G.V() ; i ++ ){
for( Object item : G.adj(i) ){
Edge<Weight> e = (Edge<Weight>)item;
if( from[e.v()] != null && distTo[e.v()].doubleValue() + e.wt().doubleValue() < distTo[e.w()].doubleValue() )
return true;
}
}
return false;
}
-
queue-based Bellman-Ford
- 对Bellman-Ford的优化
-
其他
- 处理负权边通常是针对 有向图, 因为无向图的负权边 本身就是一个负权环
9-4 更多和最短路径相关的思考
- 单源最短路径算法总结
算法 | 边的权重 | 图的有向性 | 复杂度 |
---|---|---|---|
dijkstra | 无负权边 | 有向,无向图均可 | O ( E l o g V ) O(ElogV) O(ElogV) |
Bellman-Ford | 无负权环 | 有向图,或无负权边的无向图 | O ( E l o g V ) O(ElogV) O(ElogV) |
利用拓扑排序 | 有向无环图(DAG) | 有向图 | O ( V + E ) O(V+E) O(V+E) |
- 所有对最短路径算法
- Floyed算法, 处理无负权环的图,复杂度 O ( V 3 ) O(V^3) O(V3)
- 最长路径算法
- 不能有正权环
- 无权图的最长路径问题是指数难度的
- 对于有权图,不能使用Dijkstra求最长路径问题
- 可以使用Bellman-Ford (把所有路径都取负)
第十章:结束语
线性问题 >> 树形问题 >> 图形问题
-
线性问题
- 排序问题
- O ( n 2 ) O(n^2) O(n2) 选择排序 插入排序
- O ( n l o g ( n ) ) O(nlog(n)) O(nlog(n)) 归并排序 快速排序
- 排序问题
-
更多的算法问题
- 数据结构相关
- 双向队列
- 斐波那契堆
- 红黑树
- 区间树
- KD树
- …
- 具体领域相关
- 数学: 数论 计算几何
- 图论: 网络流
- 数据结构相关
-
算法设计相关
- 分治算法
- 归并排序,快速算法, 树结构
- 贪心
- 选择排序; 堆 ; Kruskal ; Prim; Dijkstra ;…
- 递归回溯
- 树的遍历
- 图的遍历
- 动态规划
- 思想: 最优子结构
- Prim ; Dijkstra
- 分治算法
-
大家加油!!