前言:个人笔记比较潦草,内容也不全面,欢迎大家提出不同意见!
Java读取文件
import java.io.*;
public class javaInputOutput {
//第一种方式:使用FileWriter和FileReader,对文件内容按字符读取
public void firstway() throws IOException {
String dir = "D:\\aMyFile\\ideaWorkspace\\file\\jio.txt";
File file = new File(dir);
//如果文件不存在,创建文件
if (!file.exists())
file.createNewFile();
//创建FileWriter对象
FileWriter writer = new FileWriter(file);
//向文件中写入内容
writer.write("the first way to write and read");
writer.flush();
writer.close();
//创建FileReader对象
FileReader reader = new FileReader(file);
char[] ch = new char[100];
reader.read(ch);
for (char c : ch) {
System.out.print(c);
}
System.out.println();
reader.close();
}
//第二种方式:使用BuffredReader和BufferedWriter,对文件内容进行整行读取
public void secondWay() throws IOException {
String dir = "D:\\aMyFile\\ideaWorkspace\\file\\b.txt";
File file = new File(dir);
//如果文件不存在,创建文件
if (!file.exists())
file.createNewFile();
//创建BufferWriter对象并向文件写入内容
BufferedWriter bw = new BufferedWriter(new FileWriter(file));
//向文件中写入内容
bw.write("the second way to write and read");
bw.flush();
bw.close();
//创建Bufferedreader读取文件内容
BufferedReader br = new BufferedReader(new FileReader(file));
String line;
while ((line = br.readLine()) != null) {
System.out.println(line);
}
br.close();
}
//第三种方式:使用FileInputStream和FileOutputStream
//这种方法以字节的形式写入文件,读取文件时先读取字节数组,再将字节数组转换为字符串形式
public void thirdWay() throws IOException{
String dir = "D:\\aMyFile\\ideaWorkspace\\file\\c.txt";
File file = new File(dir);
//如果文件不存在,创建文件
if (!file.exists())
file.createNewFile();
//创建FileOutputStream对象,写入内容
FileOutputStream fos=new FileOutputStream(file);
//向文件中写入内容
fos.write("the third way to write and read".getBytes());
fos.close();
//创建FileInputStream对象,读取文件内容
FileInputStream fis = new FileInputStream(file);
byte[] bys = new byte[100];
while(fis.read(bys,0,bys.length)!=-1){
//将字节数组转换为字符串
System.out.println(new String(bys));
}
fis.close();
}
public static void main(String[] args) throws IOException {
javaInputOutput jio = new javaInputOutput();
jio.firstway();
jio.secondWay();
jio.thirdWay();
}
}
PrintWriter
常用方法:
(1)print(String str):向文件写入一个字符串。
(2)print(char[] ch):向文件写入一个字符数组。
(3)print(char c):向文件写入一个字符。
(4)print(int i):向文件写入一个int型值。
(5)print(long l):向文件写入一个long型值。
(6)print(float f):向文件写入一个float型值。
(7)print(double d):向文件写入一个double型值。
(8)print(boolean b):向文件写入一个boolean型值。
递归
递归过程
- 递归过程在实现时,需要自己调用自己
- 层层向下递归,退出时的次序正好相反
- 外部调用:主程序第一次调用递归过程
- 内部调用:递归过程每次递归调用自己
- 它们返回调用它的过程的地址不同
递归工作栈
- 每一次递归调用,需要为过程中使用的参数、局部变量等另外分配存储空间。
- 每层递归调用需分配的空间形成递归工作记录,按后进先出的栈组织。
例:汉诺塔
汉诺塔的代码
void TOH(int n, char start,char goal.char temp){
if(n==0) return;
if(n==1){
move(start,goal);
return;
}
TOH(n-1,start,temp,goal);
move(start,goal);
TOH(n-1,temp,goal,start);
}
void move(char start,char goal){
System.Out.println("move"+start+"to"+goal);
}
第0章 内排序
没有最好的排序算法,只有最适合某种情形的。
0.1 插入排序
定义:逐个处理待排序的记录,每个新记录与前面已经排序的子序列进行比较,将它插入到子序列中正确的位置。(若在某一次比较后新记录不需要调换位置,说明新记录已经到了正确的位置 -> continued;)
适用情况:基本有序的序列
平均时间复杂度:O( n 2 n^2 n2);空间复杂度:O(1)
稳定性:yes
代码:
static void inssort(Elem[] array) {
for (int i = 1; i < array.length; i++) //插入第i个记录
for (int j = i; j > 0 && array[j].key() < array[j - 1].key(); j--) //往已排序的子序列中插入到合适的位置
swap(array[j], array[j - 1]);
}
0.2 冒泡排序
定义:对于一个n个元素的待排序序列,从第n个元素开始,依次比较相邻2个数据的大小,让小的排在前,直到排到序列的i位置(即第i大的元素通过交换到i下标处);i+1,从第n个元素开始,重复这个过程,到序列的i位置。过程中,越小的元素会经由交换慢慢“冒泡”到数列的顶端。(第i轮循环会找到第i小的数据)
适用情况:需要快速找到最大或第i大的的数据。
平均时间复杂度:O( n 2 n^2 n2);空间复杂度:O(1)
稳定性:yes
代码:
static void bobsort(Elem[] array) {
for (int i = 0; i < array.length; i++) //第i大的记录冒泡到正确的位置
for (int j = array.length - 1; j > i; j--) //往已排序的子序列中插入到合适的位置
if(array[j].key() < array[j - 1].key()) //小的往前冒
swap(array[j], array[j - 1]);
}
0.3 选择排序
定义:遍历序列下标i到n,找到第i小的元素,与下标i的元素交换;重复这个过程。(第i轮循环会找到第i小的数据)(比较次数多,交换次数少)(效果类似“冒泡排序”,只不过交换次数少,代价是不稳定)(拓展:还有一种方法可以降低排序算法用于交换记录所用的时间,即“交换指针”:用一个数组存储指向各个记录的指针,交换只需要交换对应的指针。“用空间换时间”)
适用情况:交换操作花费时间长;需要快速找到最大或第i大的的数据。
平均时间复杂度:O( n 2 n^2 n2);空间复杂度:O(1)
稳定性:no
代码:
static void selsort(Elem[] array) {
for (int i = 0; i < array.length; i++) {//找第i小的元素
int lowIndex = i; //第i小的元素下标
for (int j = i + 1; j <array.length; j++) {//从i+1开始遍历
if(array[i].key() > array[j].key()) //找到更小的
lowIndex = j;
}
swap(array[i], array[lowIndex]);
}
}
0.4 希尔排序(Shell 排序、缩小增量排序)
定义:在不相邻的记录之间(子序列)进行比较与交换,利用插入排序的最佳时间代价——O(0),将待排序序列变成“基本有序”状态,最后一遍用插入排序完成。
适用情况:都行。
平均时间复杂度:O( n 1.5 n^{1.5} n1.5);空间复杂度:O(1)
稳定性:no
代码:
static void shellsort(Elem[] array) {
for (int i = array.length / 2; i > 1; i/=2) //i即增量,每次除以2,对于元素为16,增量分别是8,4,2,1
for (int j = 0; j < i; j++) //增量为i -> 有i个子序列,j为每个子序列的起始坐标
inssort2(array, j, i); //适用于shell排序的改进后的选择排序
inssort2(array,0,1); //最后,对“基本有序”的数据,作选择排序
}
static void inssort2(Elem[] array, int star, int incr) { //先理解简单插入排序
for (int i = star + incr; i < array.length; i+= incr) {
for (int j = i; j > incr - 1 && array[j].key() < array[j - incr].key(); j -= incr) { //理解j>incr-1:前incr个元素为各子序列的初始坐标
swap(array[j - incr].key() , array[j].key())
}
}
}
0.5 快速排序
定义:本质上是“递归”,实现了“分治法”的思想。先选一个轴值,比轴值小的k个元素放在数组最左边的k个位置上,比轴值大的n-k个元素放在数组最右边的n-k个位置上(没计入轴值,轴值下标为k)。这称为数组的一次分割(partition)。(改进1:一种简单的改进是用能够快速处理较小数组的方法替代快速排序,减少后期大量费时间的函数调用。例如:当快速排序的子数组小于9时,使用插入排序)(改进2:可以用栈代替递归进行快速排序,用栈存储待排序序列的首尾坐标,代码见下面第二个代码块)
组成:主体函数、三值选中法、partition函数(左右子序列排序)
适用情况:用得恰当时,是内排序中最快的排序算法。
平均时间复杂度:O( n l o g 2 n nlog_2n nlog2n);
轴值选的不“中间”时,最坏时间复杂度为O( n 2 n^2 n2)。
平均空间复杂度:O( l o g n log_n logn),是因为递归调用。
稳定性:no
快速排序-递归-代码:(以对整数类型数组的快速排序为例)
class sort {
public static void main(String[] args) {
int[] array = {72, 6, 57, 88, 85, 42, 83, 73, 48, 60};
qsort(array, 0, array.length - 1);
}
static void qsort(int[] array, int i, int j) {
int pivotIndex = findPivot(array, i, j);// 三值取中法求得轴值pivotIndex
swap(array, pivotIndex, j); // ? ans:把轴值放在数组末尾,空出前n-1个位置,方便排序左右子序列
//k是右子序列的第一个下标
int k = partition(array, i - 1, j, array[j]); // 在数组前n-1个位置上完成左右子序列的排序(再将轴值和右子序列的第一个数据交换)
swap(array, k, j); // ? ans:子序列排序后,将轴值(位于数组末尾)和右子序列的第一个数据交换
/*
for (int a : array) System.out.print(a + " ");
System.out.println();
*/
if ((k - i) > 1) qsort(array, i, k - 1); //若左子序列元素大于1,继续调用qsort
if ((j - k) > 1) qsort(array, k + 1, j);
}
static int findPivot(int[] array, int i, int j) { //三值选中法,把left、mid、right按顺序排列,输出mid下标
int mid = (i + j) / 2;
if (array[mid] < array[i]) swap(array, i, mid);
if (array[j] < array[i]) swap(array, i, j);
if (array[j] < array[mid]) swap(array, j, mid);
return mid;
}
static void swap(int[] array, int i, int j) { //数组内元素的交换函数,i j分别为待交换下标
int temp = array[i];
array[i] = array[j];
array[j] = temp;
}
static int partition(int[] array, int l, int r, int pivot) { //一次partition,把小于轴值pivot的数放在数组左边,大于pivot的放在右边
do {
while (array[++l] < pivot) ; //移动左边界。为什么不用判定左边界是否出界?
while (r != 0 && array[--r] > pivot) ; //移动右边界。
swap(array, l, r);//交换out-of-place的值
} while (l < r);
swap(array, l, r);//上面循环体的最后一次swap多余
return l;//返回右子序列的第一个下标l
}
}
2024/3/14考古,写了个简略的快速排序如下
class Main {
static void quickSort(int[] nums, int start, int end) {
if (start >= end) {
return;
}
int pivot = (start + end) / 2;
swap(nums, pivot, end);
int i = start;
int j = end - 1;
i--;
j++;
while (i < j) {
while (i<nums.length-1 && nums[++i] < nums[end]);
while (j>0 && nums[--j] > nums[end]);
if (i < j)
swap(nums, i, j);
}
swap(nums, i,end);
quickSort(nums,start,i-1);
quickSort(nums,i+1,end);
}
static void swap(int[] nums, int i, int j) {
int tmp = nums[i];
nums[i] = nums[j];
nums[j] = tmp;
}
public static void main(String[] args) {
int[] nums = { 14, 23, 61, 3, 64, 99, 36, 2, 34,3,0,4,5,3 ,1};
quickSort(nums, 0, nums.length-1);
for(int i = 0;i<nums.length;i++) {
System.out.println(nums[i]);
}
}
}
快速排序-栈-代码:
class qsort {
public static void main(String[] args) {
int[] array = {72, 6, 57, 88, 85, 42, 83, 73, 48, 60};
qsort(array, 0, array.length - 1);
}
static void qsort(int[] array, int oi, int oj) {
int MAXSTACKSIZE = 10;
int THRESHOLD = 1;//子序列小于这个值时用插入排序
int[] Stack = new int[MAXSTACKSIZE];
int listsize = oj - oi + 1;
int top = -1;
int pivot;
int pivotIndex, l, r;
Stack[++top] = oi;//初始化栈
Stack[++top] = oj;
while (top > 0) { //当还存在未处理的子序列
//出栈
int j = Stack[top--];
int i = Stack[top--];
//寻找轴值
pivotIndex = (i + j) / 2;
pivot = array[pivotIndex];
swap(array, pivotIndex, j); //轴值放到数组末尾
//partition
l = i - 1;
r = j;
do {
while (array[++l] < pivot) ; //移动左边界。为什么不用判定左边界是否出界?
while (r != 0 && array[--r] > pivot) ; //移动右边界。
swap(array, l, r);//交换out-of-place的值
} while (l < r);
swap(array, l, r);//抵消循环体最后一次多余的一次swap
swap(array, l, j);//轴值返回来
//把子序列(的首尾坐标)放入Stack
if ((l - i) > THRESHOLD) {
Stack[++top] = i;
Stack[++top] = l - 1;
}
if ((j - l) > THRESHOLD) {
Stack[++top] = l + 1;
Stack[++top] = j;
}
}
/*
inssort(array); //最后用一次插入排序
*/
}
static void swap(int[] array, int i, int j) { //数组内元素的交换函数,i j分别为待交换下标
int temp = array[i];
array[i] = array[j];
array[j] = temp;
}
}
0.6 归并排序
定义:类似快排,基于“分治法”。归并排序①把一个数组分成两个长度相等的子数组,②为每个子数组排序(可以继续调用归并),③最后把他们合并成一个数组(“归并”两个子数组)。(归并排序的运行时间并不依赖于输入数组中元素的组合方式,这样避免了快排中的分割不合理的最差情况)(R.Sedgewick博士改进1:子数组规模小(于THRESHOLD)时,用处理较小数组较快的方法,以减少原版方法后期大量递归调用的耗时)(R.Sedgewick博士改进2:复制array到temp:左子序列正序复制,右子序列倒序复制。使得两个子数组的两端互相成为另一个数组的“监视哨”,不必像上面的算法需要检查子序列被处理完的情况)
适用情况:
平均时间复杂度:O( n l o g n nlogn nlogn);空间复杂度:O(nlogn)
稳定性:no
代码:
class sort {
public static void main(String[] args) {
int[] array = {72, 6, 57, 88, 85, 42, 83, 73, 48, 60};
mergesort(array, new int[10], 0, array.length - 1);
}
static void mergesort(int[] array, int[] temp, int l, int r) { // 归并排序(递归实现)
int mid = (l + r) / 2; // ① 分成两个子数组
if (l == r) return; // 子数组只有一个元素
mergesort(array, temp, l, mid); // ② 左子数组排序
mergesort(array, temp, mid + 1, r); // ② 右子数组排序
for (int i = l; i <= r; i++) //复制array到辅助数组temp
temp[i] = array[i];
// ③ 归并两个子数组
int i1 = l; // 初始化为左子树组的起始坐标
int i2 = mid + 1; // 初始化为右子树组的起始坐标
for (int curr = l; curr <= r; curr++) {
if (i1 == mid + 1) // 1)左子序列已全部排进主序列
array[curr] = temp[i2++];
else if (i2 == r + 1) // 2)右子序列已全部排进主序列
array[curr] = temp[i1++];
else if (temp[i1] <= temp[i2]) // 3)小的值入主序列
array[curr] = temp[i1++];
else // 3)小的值入主序列
array[curr] = temp[i2++];
}
/* 测试用
for(int a:array) System.out.print(a+" ");
System.out.println();
*/
}
}
// 附:R.Sedgewick博士发明的优化归并排序方法
class sort {
public static void main(String[] args) {
int[] array = {72, 6, 57, 88, 85, 42, 83, 73, 48, 60};
mergesort(array, new int[10], 0, array.length - 1);
}
static void mergesort(int[] array, int[] temp, int l, int r) { // 归并排序(递归实现)
int mid = (l + r) / 2; // ① 分成两个子数组
if (l == r) return; // 子数组只有一个元素
int THRESHOLD = 0;// 子数组规模小(于THRESHOLD)时,用处理较小数组较快的方法,以减少原版方法后期大量递归调用的耗时
// ② 左子数组排序
if ((mid - l) >= THRESHOLD)
mergesort(array, temp, l, mid);
else inssort(array, l, mid - l + 1);
// ② 右子数组排序
if ((r - mid) >= THRESHOLD)
mergesort(array, temp, mid + 1, r);
else inssort(array, mid + 1, r - mid);
//复制array到temp:左子序列正序复制,右子序列倒序复制
//倒序复制目的:使得两个子数组的两端互相成为另一个数组的“监视哨”,不必像上面的算法需要检查子序列被处理完的情况
for (int i = l; i <= mid; i++) temp[i] = array[i];
for (int i = 1; i <= r - mid; i++) temp[r - i + 1] = array[i + mid];
// ③ 归并两个子数组
int i = l;
int j = r;
for (int arrayIndex = l; arrayIndex <= r; arrayIndex++) {
// 两个子数组的两端互相成为另一个数组的“监视哨”,不必像上面的算法需要检查子序列被处理完的情况
if (temp[i] <= temp[j])
array[arrayIndex] = temp[i++];
else
array[arrayIndex] = temp[j--];
}
/* 测试用
for (int a : array) System.out.print(a + " ");
System.out.println();
*/
}
static void inssort(int[] array, int star, int len) { //简单插入排序
for (int i = star + 1; i < star + len; i++) //插入第i个记录
for (int j = i; j > star && array[j] < array[j - 1]; j--) //往已排序的子序列中插入到合适的位置
swap(array, j, j - 1);
}
static void swap(int[] array, int i, int j) { //数组内元素的交换函数,i j分别为待交换下标
int temp = array[i];
array[i] = array[j];
array[j] = temp;
}
}
0.7 桶排序
定义:将数组分到有限数量的桶里。
假设:
1.待排序的关键码的都是整数
2.待排序的关键码可以重复且在一个有限的范围内
思路:
1.明确待排关键码的值范围,假设是[0…m-1]。
2.初始化m个桶(每个桶可以用线性表实现)。
3.按顺序扫描待排关键码,对于关键码i,只需要将关键码i插入到对应桶所对应的线性表bucket[i]中。
4.将桶中所有关键码按照桶的序号依次输出。
0.8 基数排序
基本原理:
将整数按位数切割成不同的数字,然后按每个位数分别比较。
过程:
利用桶排序,只不过设置的Bucket的数量不是按照关键码的数值范围进行设置,而是依赖于某种基数r,通过基数r对关键码进行分类存放排序。
0.9 其他排序
堆排序、分配排序等未了解。
排序算法时间复杂度下限:O( n l o g n nlogn nlogn)
第1章 线性表、栈和队列
1.1 线性表(linear list)
线性表的定义
由元素(element)组成的有限的有序的序列。
线性表的特征
- 不含元素时的线性表叫做空表(empty list);
- 元素与位置(下标)一一对应,第一个元素的下标为0;
- 表头无直接前驱,表尾无直接后继,中间元素有一个直接前驱和一个直接后继。
线性表类的接口
即可实现的功能:(书本49页)
interface List{ // 线性表ADT抽象数据类型
// 与指针curr相关的操作
public void insert(Object item); //在curr位置插入元素item
public Object remove(); //移除并返回curr所指的元素
public void setFirst(); //设置curr为表头
public void next(); //指针curr指向下一个位置
public void prev(); //指针curr指向上一个位置
public void setPos(int pos); //设置curr为指定的pos
public void setValue(Object val); //设置curr的值为val
public Object currValue(); //返回当前指针的值
public boolean isInList(); //curr指针所指的元素是否在列表中
public void clear(); //清空列表
public void append(Object item); //在有效数组的表尾添加元素
public int length(); //返回列表长度
public boolean isEmpty(); //列表是否为空
public void print(); //打印所有元素
}
线性表的分类(根据对插入、删除和访问操作的限制)
- list:允许在线性表的任何位置插入、删除或者访问元素。
- stack:只允许在线性表的表尾插入、删除或访问元素,“先进先出”。
- queue:只允许在线性表的一端插入元素,在另一端删除或访问元素,“先进先出”。
- deque:只允许在线性表的表头或表尾插入、删除或者访问元素。
线性表的实现方式
- 数组:连续存储。
- 链表:离散存储。
数组实现线性表__代码
包括List接口和具体实现AList类。
public interface List {
public void clear();
public void insert(Object item);
public void append(Object item);
public Object remove(); // 返回被删除的
public void setFirst();
public void next();
public void prev();
public int length();
public void setPos(int pos);//pos指数组下标
public void setValue(Object val);
public Object currValue();
public boolean isEmpty();
public boolean isInList();
public void print();
} // interface List
class AList implements List {
private static final int defaultSize = 10;
private int msize; // list最大大小
private int numInList;
private int curr;
private Object[] listArray;
AList() {
setup(defaultSize);
}
AList(int sz) {
setup(sz);
}
private void setup(int sz) {
msize = sz;
numInList = curr = 0;
listArray = new Object[sz];
}
public void clear() {
numInList = curr = 0;
}
public void insert(Object it) {
assert numInList < msize : "List is full";
assert ((curr >= 0) && (curr <= numInList)) : "Bad value for curr";
for (int i = numInList; i > curr; i--) {
listArray[i] = listArray[i - 1];
}
listArray[curr] = it;
numInList++;
}
public void append(Object it) {
assert numInList < msize : "List is full";
listArray[numInList++] = it;
}
public Object remove() {
assert !isEmpty() : "Can't delete from empty list";
assert !isInList() : "No current element";
Object it = listArray[curr]; // 返回被删除的元素
for (int i = curr; i < numInList - 1; i--) {
listArray[i] = listArray[i + 1];
}
numInList--;
return it;
}
public void setFirst() {
curr = 0;
}
public void next() {
curr++;
}
public void prev() {
curr--;
}
public int length() {
return numInList;
}
public void setPos(int pos) {
curr = pos;
}
public void setValue(Object it) {
assert isInList() : "No current element";// 指针得指向有效位置,当前位置要有元素
listArray[curr] = it;
}
public Object currValue() {
assert isInList() : "No current element";// 指针得指向有效位置,当前位置要有元素
return listArray[curr];
}
public boolean isEmpty() {
return numInList == 0;
}
public boolean isInList() {
return ((curr>=0)&&(curr<numInList));
}
public void print() {
if (isEmpty()) {
System.out.println("()");
} else {
System.out.print("(");
for (setFirst(); isInList(); next()) {
System.out.print(currValue() + " ");
}
System.out.println(")");
}
}
}
链表实现线性表__代码
包括单个结点Link和链表LLink
public class Link {
private Object element;
private Link next;
// 构造器
public Link(Object it, Link nextval) {
element = it;
next = nextval;
}
Link(Link nextval) {
next = nextval;
}
// 方法
public Link next() {
return next;
}
public Link setNext(Link nextval) {
return next = nextval;
}
public Object element() {
return element;
}
public Object setElement(Object it) {
return element = it;
}
}
//LLink有一个哑结点(表头结点);curr.next()是逻辑上当前的结点
class LLink implements List {
private Link head;// 指向哑结点
private Link tail;// 最后一个结点
protected Link curr;
LLink(int sz) {
setup();
}
LLink() {
setup();
}
private void setup() {
tail = head = curr = new Link(null);
}
public void clear() {
head = tail = curr = new Link(null);
}
public void insert(Object it) {
assert curr != null : "No current element";// 指针位置有效性
curr.setNext(new Link(it, curr.next()));
if (curr == tail) {
tail = curr.next();
} // curr指向tail时,是向表位插入,tail要变
}
public void append(Object it) {
tail.setNext(new Link(it, null));
tail = tail.next();
}
public Object remove() {
assert this.isInList() == true : "no";
Object it = curr.next().element();// 存下被删除的元素
if (tail == curr.next()) {
tail = curr;
} // 若删除的是最后一个元素,也即物理上curr指向tail之前一个
curr.setNext(curr.next().next());// 删除的不是表位
return it;
}
public void setFirst() {
curr = head;
}
public void next() {
if (curr != null) {
curr = curr.next();
}
}
public void prev() {
if ((curr == null) || (curr == head)) {// 当前结点无直接前驱结点
curr = null;
return;
}
Link temp = head;
while ((temp != null) && (temp.next() != curr)) {
temp = temp.next();
}
curr = temp;
}
public int length() {
int cnt = 0;
for (Link temp = head.next(); temp != null; temp = temp.next()) {
cnt++;
}
return cnt;
}
public void setPos(int pos) {// 逻辑上第一个链表元素对应pos==0
curr = head;
for (int i = 0; (curr != null) && (i < pos); i++) {
curr = curr.next();
}
}
public void setValue(Object it) {
if (this.isInList() == true) {
curr.next().setElement(it);
}
}
/**
* public Object currValue() { Object it = null; if (this.isInList() == true) {
* it = curr.next().element(); } return it; }
**/
public Object currValue() {
if (this.isInList() == true) {
return curr.next().element();
} else {
return null;
}
}
public boolean isEmpty() {
return (head.next() == null);
}
public boolean isInList() {// 感觉这种判定不严格,因为判定的前提curr是表中某个位置
return (curr != null) && (curr.next() != null);
}
public void print() {
if (this.isEmpty() == true) {
System.out.println("()");
} else {
System.out.print("(");
for (this.setFirst(); isInList(); next()) {
System.out.print(currValue() + " ");
}
System.out.print(")");
}
}
}
1.2 栈(stack)
栈的定义
限定仅在一端进行插入和删除的线性表。后进先出(Last In First Out)。
栈顶(top):在栈中唯一一个可以访问元素的位置。
压栈(push):向栈顶插入元素的动作。
出栈(pop):从栈顶删除元素的动作。
栈的代码(数组实现)
//顺序栈,基于数组
public class AStack {
private static final int defaultSize = 10;
private int size;
private int top;//下一个元素的位置
private Object[] listarray;
public AStack() {
setup(defaultSize);
}
public AStack(int sz) {
setup(sz);
}
public void setup(int sz) {
size = sz;
top = 0;
listarray = new Object[sz];
}
public void clear() {
top = 0;
}
public void push(Object it) {
assert top < size : "Stack overflow";
listarray[top++] = it;
}
public Object pop() {
assert this.isEmpty()==false:"Empty stack";
return listarray[--top];
}
//topValue()即peak()
public Object topValue() {
assert this.isEmpty()==false:"Empty stack";
return listarray[top-1];
}
public boolean isEmpty() {
return top==0;
}
}
栈的实现(链表实现)
public class LStack {
private Link top;
public LStack() {
setup();
}
public LStack(int sz) {
setup();
}// ignore sz
private void setup() {
top = null;
}
public void clear() {
top = null;
}
public void push(Object it) {
top = new Link(it, top);
}
public Object pop() {
assert this.isEmpty() == false : "Empty stack";
Object it = top.element();
top = top.next();
return it;
}
public Object topValue() {
assert this.isEmpty() == false : "Empty stack";
return top.element();
}
public boolean isEmpty() {
return top == null;
}
}
栈的应用-混合算术表达式的计算
先将中缀表达式转换成后缀表达式,再计算后缀表达式。
一、将中缀表达式转换成后缀表达式
1)在扫描中缀表达式时:
1.如果遇到操作数,则将其直接输出到后缀表达式中。
2.如果遇到运算符,分情况对待:
①当栈为空时,将该运算符压入到栈中。
②当运算符为‘+’、‘-’、‘*’、‘/‘的时候,从栈中弹出(优先级高或等于的)栈元素,直到发现优先级更低的元素为止('(‘运算符是个例外),并将该运算符压入栈中。
③当运算符为’(‘的时候,将该运算符压入到栈中。
④当运算符为’)‘的时候,从栈中弹出栈元素直到遇到’(‘运算符,运算符’(‘和’)'都不输出到后缀表达式中。
3.所有弹出的栈元素(除了特殊情况以外),都需要按照弹出顺序输出到后缀表达式中。
4.如果扫描完整个表达式后,将所有在栈中的元素弹出并输出到后缀表达式中。
二、计算后缀表达式
1)后缀表达式的计算最容易的方法就是使用栈。
2)使用过程:
1.扫描表达式:
①当遇到操作数时,将其压入操作数栈
②当遇到操作符时,从栈中弹出两个操作数(假设我们处理的操作符都是二元的)
2.将弹出的两个操作数和扫描到的运算符进行运算,得到的结果再压入栈中。(注意弹出两个操作数在运算时与运算符的前后顺序,比如当运算符是减号)
3.扫描完整个表达式,留在栈中的那个数据就是最后表达式的最终运算结果。
1.3 队列(queue)
队列的定义
仅在一端插入、删除的线性表。先进先出(First In First Out)。
入队(enqueue):在表的尾端(叫做队尾(rear))插入元素。
出队(dequeue):在表的头部(叫做队首(front))删除元素。
栈的实现(基于数组)
使用循环队列,front指向首元素,rear指向最后一个元素。
1.队列中只有一个元素:front=rear
2.队列已满:(rear+1)%n=front
3.队列为空:front=rear
1.和3.用一个额外的状态变量区分
但是这样需要多维护一个状态变量
改良:为队列添加一个额外的数组元素(类似链表的哑结点)。front指向队首的前驱位置,rear指向队尾。
1.队列中只有一个元素:(front+1)%n=rear
1.队列已满:(rear+1)%n=front
1.队列为空:front=rear
package queue;
public class AQueue {
private static final int defaultSize = 10;
private int size; // queue最大大小
private int front;// 指向队首的前驱
private int rear;// 指向队尾
private Object[] listArray;
//构造器等未写出
public void enqueue(Object it) {
assert !isFull() : "Queue is full";
rear = (rear + 1) % size;
listArray[rear] = it;
}
public Object dequeue(Object it) {
assert !isEmpty() : "Queue is empty";
front = (front + 1) % size;
return listArray[front];
}
public boolean isEmpty() {
return front == rear;
}
public boolean isFull() {
return front == (rear + 1) % size;
}
}
栈的实现(链表)
package queue;
import linear.Link;
public class LQueue {
private Link front;// 指向头结点
private Link rear;// 最后一个结点
public LQueue() {
setup();
}
// 初始化
private void setup() {
front = null;
rear = null;
}
public void enqueue(Object it) {
if (isEmpty()) {
rear = new Link(it, null);
front = rear;
} else {
rear.setNext(new Link(it, null));
rear = rear.next();
}
}
public Object dequeue() {
assert !isEmpty() : "Queue is empty";
Object it = front.element();
front = front.next();
if (front == null) {// 若删除的是最后一个,队尾要更新为null
rear = front;
}
return it;
}
public boolean isEmpty() {
return front == rear && front == null;
}
}
第2章 二叉树
二叉树
定义与特性
- 二叉树(binary tree)由结点(node)的有限集合组成,这个集合或者为空,或者由一个根节点(root)以及两颗不相交的二叉树组成,这两颗子树分别称作这个根的左子树(left subtree)和右子树(right subtree)。
- 叶结点:左右子树为空的结点。
- 分支结点、内部结点:至少有一颗非空子树的结点。
- 从第0层开始,结点的深度(depth)即层数。一棵树的高度=最深结点的深度+1。
- 满二叉树(full binary tree):每一个结点或者是一个分支结点,并恰有两个非空子结点,或者是叶结点。(用处:Huffman数)
- 完全二叉树(complete binary tree):从根结点起每一层从左到右填充。一颗高度为d的完全二叉树除了d-1层以外,每一层都是满的。(用处:堆)
特性:
- 在二叉树的第i层至多有 2 i 2^i 2i个结点(i≥0)
- 深度为k的二叉树至多有 2 i + 1 − 1 2^{i+1}-1 2i+1−1个结点(k≥0)
- 对于任意一颗二叉树,如果其叶结点数为x,具有两棵子树的内部结点数为y,那么x=y+1。
- 一颗非空二叉树的空子树的数目等于其结点数+1。(每多一个节点,少一个空子树的同时多两个空子树 -> 多一个节点,多一个空子树)
- 具有n个结点的完全二叉树的高度为ceil(log(n+1))。
- 如果对一颗有n个节点的完全二叉树(其高度为ceil(log(n+1)))的结点按层序编号(从第0层到第ceil(log(n+1))-1层,每层从左到右),则对任一结点i(0≤i≤n-1),有:
①如果i=0,则结点i是二叉树的根,无双亲;如果i>0,则其双亲Parent(i)是结点floor((i-1)/2)
②LChild(i)=2i+1
③RChild(i)=2i+2
④LSibling(i)=i+1 当i为偶数并且0<i<n
⑤RSibling(i)=i+1 当i为奇数并且i+1<n
基本操作
- 获得一个二叉树的根节点
- 获得二叉树某个结点的父结点
- 获得二叉树某个结点的左子树、右子树。
- 向二叉树的某个结点插入左子树或者右子树
- 删除二叉树中某个结点的左子树或者右子树
- 遍历操作,按某个次序依次访问二叉树中各个结点,并使每个结点只被访问一次
- 清除整颗二叉树
遍历
遍历:按某个次序依次访问二叉树中每个结点,并使每个结点只被访问一次。(“访问”的含义很广,可以是对结点作各种处理)
三种遍历策略:
①先上后下的按层序遍历。(广度优先搜索)
②先序遍历,先中间
③中序遍历
④后序遍历
代码实现
//1.先上后下的按层序遍历。即广度优先搜索
private static void breadthFirst(BinNode binary){
Queue q = new LQueue();
BinNode p = binary;
if(p!=null){
q.enqueue(p);//根节点入队
while(!q.isEmpty()){
p=(BinNode)q.dequeue();
visit(p);
if(p.left()!=null) q.enqueue(p.left());
if(p.right()!=null) q.enqueue(p.right());
}
}
}
//2.先序遍历
private static void preOrder(BinNode binary){
if(binary == null) return;
visit(binary);
preOrder(binary.left());
preOrder(binary.right());
}
//3.中序遍历
private static void inOrder(BinNode binary){
if(binary == null) return;
inOrder(binary.left());
visit(binary);
inOrder(binary.right());
}
//4.后序遍历
private static void postOrder(BinNode binary){
if(binary == null) return;
postOrder(binary.left());
postOrder(binary.right());
visit(binary);
}
三种遍历对结点访问的行进路线是一样的,白:先序、蓝:中序、黑:后序。
计数
- 二叉树遍历的结果是将一个非线性结构中的数据通过访问排列到一个线性序列中。
二叉树的前序序列和中序序列可以唯一地确定一棵二叉树。
二叉树的中序序列和后序序列可以唯一地确定一棵二叉树。 - 关于n个结点的不同二叉树的棵树
b n = 1 n + 1 C 2 n n = 1 n + 1 ( 2 n ) ! n ! n ! b_n=\frac{1}{n+1}C^n_{2n}=\frac{1}{n+1}\frac{(2n)!}{n!n!} bn=n+11C2nn=n+11n!n!(2n)!
实现方式
实现方式:数组 or 链表。
数组。适用于完全二叉树。
链表。每个结点至少要维护三个数据:数据区——结点中存储元素、两个指向子结点的指针。
相关代码
二叉树结点的ADT
//二叉树结点的ADT
public interface BinNode {
public Object element();
public Object setElement(Object val);
public BinNode left();
public BinNode setLeft(BinNode p);
public BinNode right();
public BinNode setRight(BinNode p);
public boolean isLeaf();
}
二叉树节点类
//二叉树节点类 with pointers to children
public class BinNodePtr implements BinNode {
private Object element;
private BinNode left;
private BinNode right;
// 构造器
public BinNodePtr() {
left = right = null;
}
public BinNodePtr(Object val) {
left = right = null;
element = val;
}
public BinNodePtr(Object val, BinNode l, BinNode r) {
left = l;
right = r;
element = val;
}
public Object element() {
return element;
}
public Object setElement(Object val) {
return element = val;
}
public BinNode left() {
return left;
}
public BinNode setLeft(BinNode p) {
return left = p;
}
public BinNode right() {
return right;
}
public BinNode setRight(BinNode p) {
return right = p;
}
public boolean isLeaf() {
return (left == null) && (right == null);
}
}
获得二叉树的高度(递归)
static int height(BinNode binary){
int height = 0, leftheight = 0, rightheight = 0;
if(binary == null) return 0;
leftheight = height(binary.left());
rightheight = height(binary.right());
height = (leftheight > rightheight ? leftheight : rightheight) + 1;
return height;
}
二叉树的意义
二叉树结合了以数组实现的有序线性表和以链表实现的线性表的优点。
Huffman树(最优二叉树)
Huffman树的定义:是一种带权(外部)路径 [Weighted Path Length ] 长度最短的树。(“权”大的叶结点“深度”小)
相关定义:
路径长度:两个结点之间路径上的分支数
树的外部路径长度:各叶结点到根结点的路径长度之和
树的内部路径长度:各非叶结点到根结点的路径长度之和
树的带权路径长度:书中所有叶子结点的带权路径长度之和
如何构造Huffman树?
①根据给定的n个权值{
w
1
w_1
w1,
w
2
w_2
w2,…,
w
n
w_n
wn},构造n课二叉树的集合F={
T
1
T_1
T1,
T
2
T_2
T2,…,
T
n
T_n
Tn},其中每棵二叉树中均只含有一个带权值为
w
i
w_i
wi的根结点,其左、右子树为空树;
②在F中选取其根结点的权值为最小的两棵二叉树,分别作为左、右子树构造一颗新的二叉树,并置这颗新的二叉树根结点的权值为其左、右子树根结点的权值之和。
③从F中删去这两棵树,同时加入刚生成的新树;
④重复②和③两步,直到F中只含一棵树为止。
Huffman编码
Huffman编码的定义:利用Huffman树的特性为使用频率不同的字符编写不等长的编码,从而缩短整个文件的长度。
例如:
“This is isinglass”,t的频度是1、h的频度是1、i的频度是4、s的频度是5、n的频度是1,g的频度是1、a的频度是1、l的频度是1。
如果采用等长的编码形式,上面的八个字母则需要三位二进制编码。长度=15*3=45。
按照上面的字母出现的频度创建一个Huffman树。长度=40
为什么不给频率最高的字母s和i以最短的编号,比如分别是0和1,然后给剩下的字母如下编号:t、h、n、g分别为00、01、10、11,a、l分别为000、001呢?
因为使用Huffman树编制的代码具有前缀特性(prefix)
①一组代码中的任何一个代码都不是另一个的代码的前缀(都是叶子结点)。
②这种前缀特性保证了代码串被反编码时不会有多种可能。
二叉检索树
(英文:Binary Search Tree)
- 提供了查找元素花费logn的时间能力
- 提供了插入和删除元素花费logn的时间能力
例:10000个数据,使用线性表查找元素平均需要比较5000次,使用二叉检索树查找元素平均只需要14次。
定义
二叉检索树的任何一个结点,设其值为K,则该结点左子树的任意一个结点的值都小于K,该结点右子树的任意一个结点的值都大于K。
基本操作
依照关键字查找一个元素;
增加一个元素;
依据关键字删除一个元素;
获得关键字最小的元素;
删除关键字最小的元素;
获得关键字最大的元素;
删除关键字最大的元素;
1. 查找一个元素
传入参数:根节点、要查找元素的关键字
- 设置当前节点指向根结点
- 重复以下步骤:
- 如果当前结点为空,则退出,没找到匹配的元素
- 如果当前节点所包含的元素的关键字大于要查找的,则设当前结点指向其左子结点
- 如果当前节点所包含的元素的关键字小于要查找的,则设当前结点指向其右子结点
- 否则,匹配元素找到,退出
2. 获得关键字最小的元素
形参:根节点
- 递归法:若根节点的左子结点为空,当前根节点即关键字最小的元素,返回其值;否则(左子结点不为空),递归调用原函数,形参传入为根节点的左子结点。(伪递归,可以用循环结构替代)
- 不用递归:设置根节点为当前结点(temp)。若当前结点的左子结点非空,设置当前节点左子结点为当前节点;否则,当前节点即关键字最小的元素。
3. 删除关键字最大的元素▲
调用:
rt.setRight(delete(rt.right()));
使用递归,代码不那么直观理解,可参考代码后的例子。
private BinNode deleteMax(BinNode rt){
if(rt.right() == null) // 此时,rt就是关键字最大的元素
rt = rt.left(); // 目的:让关键字最大的元素的父结点指向关键字最大的元素的左子结点
else
rt.setRight(deleteMax(rt.right()));
return rt;
}
4. 插入一个元素
类似删除关键字最大的元素。
- 步骤
- 先寻找该插入的叶结点或分支节点
- 插入位置应该是所属父结点的空子结点
private BinNode inserthelp(BinNode rt,Elem e){
// 插入元素一定是叶子节点,即左右子结点为null
if(rt == null) // 即找到最后插入的位置,一层层递归退回去
return new BinNode(e,null,null);
Elem it = (Elem)rt.element();
if(it.key() > e.key()) //往左走
rt.setLeft(inserthelp(rt.left(),e));
else //往右走(不考虑有it.key() 等于 e.key()的情况。)
rt.setRight(inserthelp(rt.right(),e));
return rt;
}
5. 删除一个元素
- 被删除元素是叶子节点
- 只需要其父结点的左(右)子结点指向null。
- 被删除元素有一个子结点
- 只需要将其父结点的左(右)子结点指向其唯一的子结点。
- 被删除元素有两个子结点
- 替换删除(不破坏结点间的物理结构)
- 用什么替代?左子树的最大元素或右子树的最小元素(它们只有一个或没有子结点)。
- 步骤:先用右子树的最小元素替代被删除元素,接着删除右子树的最小元素。
private BinNode removehelp(BinNode rt, int key){
if(rt == null) return null; // 删除空结点
Elem it = (Elem)rt.element();
if(it.key() > key)
rt.setLeft(removehelp(rt.left(),key)); // 很重要的一种递归模式
else if(it.key() < key) rt.setRight(removehelp(rt.right(),key)); // 很重要
else{ // 找到该结点
if(rt.left() == null)
rt=rt.right();
else if(rt.right() == null)
rt=rt.left();
else{ // 被删除元素有两个子结点
Elem temp = getMin(rt.right()); // 用右子树的最小元素替代被删除元素
rt.setElement(temp);
rt.setRight(deleteMin(rt.right())); // 删除右子树的最小元素
}
}
}
各种操作的时间代价
- 搜索代价
- 平衡二叉树的操作代价是Θ(logn)
- 非平衡二叉树的操作代价是Θ(n)
- 插入、删除的代价与搜索代价类同
- 周游一个二叉树的代价为Θ(n)
- 使一个二叉树保持平衡才能真正发挥二叉树的作用
优先队列
定义
一种ADT,按照重要性和优先级来组织的对象称为优先队列。
应用
- 在多用户的环境中,操作系统调度程序必须决定在若干进程中运行哪个进程。
- 发送到打印机中的若干个作业可能在某些时候不想按照先来先打印的方式运行(有1个打印1000页的作业和若干个打印1页的作业)
对比一般队列
- 一般队列
- 插入:增加一个元素,这个元素被插入到队列中队尾
- 输出:输出一个队列中队头的那个元素
- 获得头元素:获得队列中队头的那个元素
- 优先队列
- 插入:增加一个带有重要级别的元素,插入到队列中的位置并不在意
- 删除:队列中重要级别最高的那个元素
- 获得头元素:队列中的重要级别最高的那个元素
实现
-
使用没有排序的一般线性表。
- 插入元素:O(1)。线性表中任意一个位置。
- 删除元素:O(n)。扫描整个线性表,找到线性表中优先级别最高的那个元素,删除之。
-
使用排序的线性表
- 插入元素:O(n)。按重要级别扫描到合适的位置,然后插入。
- 删除元素:O(1)。直接删除头元素。
-
使用二叉查找树BST
- 插入元素:O(logn)。在二叉树平衡下情况下。
- 删除元素:O(logn)。在二叉树平衡下情况下。
-
用BST的改进版——堆
- 优先队列只需要获得重要级别最高的元素,不需要查找某个特定关键字的元素。
-
使用堆来实现,堆由两条性质定义。
- 从结构性质来看,堆是一棵完全二叉树,故可以用数组代替链表形式来实现之;
- 从堆序性质来看,堆能够快速地找到重要级别最高的元素(即根元素)
- 对根结点的访问是最快的获取速度
- 根据二叉树的递归定义,我们考虑任意子树也应该是堆,那么 -> 在堆中,对于每一个结点X,X的父亲的重要级别高于(或等于)X的重要级别,除了根结点之外。(根结点无父结点)
堆
堆分为最大值堆和最小值堆。最大值堆,最大值存在于根结点,任意一个结点的关键字值都大于或等于任意一个子结点存储的值;
用数组实现堆(完全二叉树)
堆的操作
插入元素(向上推)
-
时间分析:O(logn)
新的结点总是先被插入到最底层的叶子结点中。该结点按规则会沿着路径向上攀爬。(最坏的攀爬路径长度是logn) -
代码
public void insert(Elem val){ assert n<size:"Heap is full"; int curr = n++; // 当前坐标 elements[curr] = val; // 插入值 while(curr!=0 && elements[curr].key() > elements[parent(curr)].key()){ // 到根结点或小于父结点。停 // 向上推 swap(element,curr,parrent(curr)); curr=parent; } }
删除元素(向下拉)
-
时间复杂度:O(logn)
-
删除过程:
- 保留根结点维护的元素
- 将"最后结点"的元素拷贝到根结点
- 删除“最后”结点
- 重复以下步骤:
- 将这个结点与它的孩子们进行重要性的比较
- 停止条件1:满足堆的性质则结束,否则与重要级别高的孩子结点进行交换
- 停止条件2:当这个结点成为叶结点时结束
public Elem remove(){ if(n==0) return null; assert n>0:"Removing from empty heap"; swap(elements,0,--n); // 交换最后一个元素和根结点,并设置n-- if(n>1) siftdown(0); return elements[n]; } private void siftdown(int pos){ assert pos>0&&pos<n:"Illegal heap position"; while (!isLeaf(pos)){ //不是叶结点 int j = leftchild(pos); // j不是最后一个节点 && 较大孩子是j+1。为了找到最大孩子j if(j<(n-1)&&element[j].key()<element[j+1].key()) j++; // 满足堆的性质则结束 if(element[pos].key()>element[j].key()) return ; // 不满足堆的性质,与重要级别高的孩子结点进行交换 swap(element,pos,j); pos = j; } }
创建堆
- 可以按照一个元素一个元素的方式插入
- 时间代价是O(nlogn)
- 按照堆可以被存放到数组的这种特性,当所有元素都已经存入到数组时,我们可以采取更高效的策略
- 时间代价是O(n)
- (堆中)从下往上,从右往左,即数组中(从右往左):一个个检查,让大的向上走(最大值堆)。只需要调整n/2个
第3章 树
一、定义
1 树的定义
一棵树T是由一个或一个以上结点组成的有限集合,其中有一个特定的结点R称为T的根节点。集合(T-{R})中的其余结点可以被划分为n>=0个不相交的子集,其中每个子集都是树(子树subtree)。
结点的出度(out degree)定义为该结点的子结点的数目。
2 森林的定义
森林:零个或多个树的一个有序集合。
二、树的ADT
interface GTNode{
Object value();
boolean isLeaf();
GTNode parent();
GTNode leftmost_child();
GTNode right_sibling();
void setValue(Object value);
void setParent(GTNode par);
void insert_first(GTNode n);
void insert_next(GTNode n);
void remove_first();
void remove_next();
}
三、树的遍历
-
先序遍历 and 后序遍历
-
先序遍历:先访问根节点,再依次从左往右前序遍历每颗子树
private static void preOrder(GTNode rt){ if(rt==null) return; visit(rt); GTNode temp = rt.leftmost_child(); while(temp!=null){ preOrder(temp); temp=temp.right_sibling(); } }
-
后序遍历:先从左往右后序遍历每颗子树,先访问根节点
private static void postOrder(GTNode rt){ if(rt==null) return; GTNode temp = rt.leftmost_child(); while(temp!=null){ postOrder(temp); temp=temp.right_sibling(); } visit(rt); }
-
四、树的实现
三种实现模式:父指针表示法、子结点表表示法和左子结点/右兄弟结点表示法。
父指针表示法:
定义:每个结点只保存一个指针域指向其父结点。
适用情况:等价类问题的处理(并查集)
缺点:对找到一个结点的最左子结点或右侧兄弟结点这样的重要操作是不够的。
-
数组实现
-
链表实现
数据域+指针域。注意:根节点的父指针域设置为null。
子结点表表示法
定义:每个结点存储一个线性表的指针,该线性表用来储存该结点的所有子节点。
优势:寻找某个点的子结点
劣势:寻找某个点的兄弟结点
-
数组实现
-
链表实现
左子结点/右兄弟结点表示法
定义:每个结点都存储结点的值、最左子结点的位置和右侧兄弟结点的位置
优势:ADT中规定的基础操作容易实现
-
数组实现
-
链表实现(和二叉树的链表的表示方式在物理上是一致的)
五、森林
森林和二叉树之间的转换关系
将森林中的根结点连接起来,并且将每个节点的子结点之间连接起来,最后去掉除每个子结点与最左子节点之外的其余连线。
-
结论:任何二叉树都对应一个唯一的森林
-
定义:
-
设F=(T1,T2,T3,…,Tn)是树的一个森林,对应于F的二叉树B(F)可以严格的定义如下:
- 如果n=0,则B(F)为空
- 如果n≠0,则B(F)的根是root(T1);B(F)的左子树是B(T11,T12,…,T1n),其中T11,T12,…,T1n是T1树的子树;B(F)的右子树是B(T2,T3,…,Tn)
-
森林的遍历
深度优先遍历
-
将树的根去掉后,就成为了森林,所以树和森林的遍历本质是相同的
-
遍历定义中术语的一般性描述
- 给定森林F,若F=空集,则遍历结束
- 否则若F={{T1={r1,T11,…,T1k},T2,…,Tm}},则可以导出先根遍历、后根遍历两种方法。其中,r1是第一棵树的根结点,{T11,…,T1k}是第一棵树中的子树森林,{T2,…,Tm}是去除第一棵树之后剩余的树构成的森林
-
树与森林的深度优先遍历
- 先根遍历
- 后根遍历
-
树和森林的深度先根遍历等同于其转换成对应的二叉树的先根遍历。
-
树和森林的深度后根遍历等同于其转换成对应的二叉树的中序遍历。
-
树与森林的深度优先后根遍历
广度优先遍历
- 树和森林的广度优先遍历不等同于其转换为对应的二叉树的任何遍历
六、不相交集ADT(并查集)
定义
- 是由一组互不相交的集合组成的的一个集合结构,并在此集合上定义了运算Union和Find
- 每一个要处理的元素都仅仅属于一个集合
- 集合之间是不相交的
- 一开始,每个集合包含一个元素
- 每一个集合都有一个名称,这个名称可以用该集合中的任何一个元素名称。
应用
- 主要用来解决等价关系问题
- 若对于每一对元素(a,b),a和b之间满足如下三种关系,则称a和b之间是等价关系
- 自发性
- 对称性
- 传递性
- 应用
- 电器连通性
- 城市之间的连通性
- 计算机网络的连通性
- 若对于每一对元素(a,b),a和b之间满足如下三种关系,则称a和b之间是等价关系
操作
- 需要支持的两个操作
- Find(elementname)
- 返回包含给定元素的集合名字
- 不同于查找方式中的返回结果
- Union(elementname1,elementname2)
- 生成一个新的集合,该集合是elementname1所属的集合set1和elementname2所属的set2的并集
- Find(elementname)
实现
使用数组
- 由一个具有n个元素组成的数组存储各个不相交的集合
- 初始状态:每个元素都隶属于一个集合,该集合的名字就是该元素在数组中的下标 。
- set[i] = i ;
- Union(i,j)
- 对每一个k,如果set[k] == 下标为j的元素所属的集合名称,则设置set[k] = 下标为i的元素所属的集合名称
- i和j反过来也不影响
- find(i)
return set[i];
- 初始状态:每个元素都隶属于一个集合,该集合的名字就是该元素在数组中的下标 。
init(int N){
set = new int[n];
for(int i=0;i<n;i++)
set[i]=i;
}
find(iny i){
return set[i];
}
union(int i,int j){
int setname1 = find(i);
int setname2 = find(j);
for(int k = 0;k<N;k++){
if(set[k] == setname2){
set[k] = setname1;
}
}
}
使用树
-
不相交集可以表示为一个森林
-
森林中的每棵树表示为一个集合
-
树中的每个节点的存放顺序没有任何的约束,所以可以采用树的父指针表示法来描述树。
-
树的实现还是采用数组这种物理形式
ADT(使用树)
- 数组中的每个元素存储树中的每个结点,结点中应该包含父指针信息和所存储的元素内容
public class GTNodeA{
private int par;
private Object element;
public GTNodeA(){this(null,1);}
public GTNodeA(Object e){this(e,-1);}
public GTNodeA(Object e,int parent){
element = e;
par = parent;
}
public int parent(){return par;}
public int setParent(){return par = parent;}
public Object element(){return element;}
public void setElement(Object e){element = e;}
}
// find的时间代价依赖于第i个元素在树中的层次
// 创建的树的深度越小,则执行效率越高
public int find(int i){ // 返回父结点
GTNodeA curr = set[i];
while(curr.parent[i]>=0){ // 父结点
i = curr.parent();
curr = set[i];
}
return i;
}
// 每个union操作所需要的时间 = 2个find操作时间 + 一个操作时间
public void union(int a,int b){
int root1 = find(i);
int root2 = find(j);
if(root1 != root2){ // 两个父结点不同
set[root2].setParent(root1); // 优势:只要改一次
// 注意合并方向的优化,让树的深度降低,从而让find时间减少
}
}
优化树的深度
1.重量权衡平衡原则
(union渐进时间分析->O(nlogn))
- 当两个集合合并时,可以将结点数少的那棵树合并到结点数多的那棵树上。(如何统计结点数呢?让根结点的父指针来存储)
- 通过强归纳证明:按照重量权衡平衡原则所产生的由n个结点的树T,没有结点的高度会超过floor(logn)+1。
- 当一棵树有2个结点时,根结点的父指针置为-2;当一棵树有3个结点时,根结点的父指针置为-3;
- 在合并时,判断两个树的结点数大小,将结点数少的那棵树合并到结点数多的那棵树上。
2.路径压缩
时间代价O(n log*n),近似为O(n)
log※n:Akerman函数的逆,指n经过几次log能小于等于1。例:log※16=3
-
在查找某个元素是否属于某个集合时,将该结点到根结点路径上的所有结点的父指针全部改为指向根结点
-
这种方式可以产生极浅的树
public GNode find(GNode curr){ if(curr.parent() == null) { // curr是根结点 return curr; } return curr.setParent(find(curr.parent())); // 沿途的父指针都指向根结点 }
-
结合重量权衡原则来归并集合的话,对n个结点进行n次find操作的路径开销是:O(n log*n)
第4章 图
目标
- 图的基本概念
- 图的存储表示方法(矩阵or邻接表=链表+数组)
- 若干个常见的图运算
- 图的遍历
- 拓扑排序
- 最短路径算法
- 最小支撑树(prim、kruskal)
1. 定义
-
图由结点(顶点vertex)和边(顶点的偶对edge)组成。
-
图Graph = (V,E) 结点集和边集。
- 顶点个数 |V|,边的个数 | E |。
- 如果G是无向图
- 不考虑顶点到自身的边时,0 < | E | <(|V|-1)/2 。
- 当 |E| 为最大时,该无向图称为完全图(completed graph)
- 如果G是有向图
- 不考虑顶点到自身的边时,0 < | E | <(|V|-1)。
- 当 |E| 为最大时,该无向图称为有向完全图
- 区分稀疏图和稠密图的经验公式
- 稀疏图:|E|<|V|log|V|
- 稠密图:|E|>|V|log|V|
- 带权(weight)的图通常叫做网(network)
- 有向边 弧 <x,y> != <y,x>
无向图 边 (x,y) == (y,x)
-
子图的定义:顶点集和边集都是一个图的子集。
-
顶点是度=入度+出度;
-
路径
- 简单路径:路径中的顶点不重复
- 环:第一个顶点和最后一个顶点相同
- 简单环:既是简单路径、又是环。
-
连通
-
连通:顶点v和顶点u有路径,则称v和u是连通的。
-
若G是无向图
- 若任意两个顶点都是连通的,则G是连通图
- 若无向图G不是连通图,那么该无向图的极大连通子图称为连通分量(connected component)
-
若G是有向图(有强弱之分)
-
若任意两个顶点都是连通的( v i v_i vi可以到 v j v_j vj, v j v_j vj可以到 v i v_i vi),则G是强连通图
-
若有向图G不是强连通图,
-
若有向图G不是强连通图,但是将G中的弧想象为边时能成为一个连通图,我们称这个有向图G为弱连通图
-
有向图中的极大强连通子图称为强连通分量(connected component)
-
-
-
-
无环图(acycle),图中不存在环
- 一个无环图如果既是无向图又是连通图,则该图称为自由树
- 一个无环图如果是有向图,简称为有向无环图DAG(directed acyclic graph)
2. ADT
- 创建一个图
- 向图中添加一条权值的边
- 从图中删除一条边
- 获得图中的顶点个数
- 获得图中的边的个数
- 获得图中某条边的权重
- 获得图中某条边的顶点
- 等等。。。
3. 图的实现
- 有两种常见的表示图的方式
- 相邻矩阵,适合稠密图
- 由|V|*|V|个元素组成的矩阵
- 矩阵中某个坐标对应的元素值表示该坐标所对应的顶点之间的关系
- 邻接表,适合稀疏图
- 以链表为元素的数组
- 数组有|V|个元素,每个元素表示一个顶点
- 第i个元素存储的链表中的内容为第i个顶点连接到的所有顶点信息
- 相邻矩阵,适合稠密图
- 时间代价
- 相邻矩阵
- 判断一条边是否存在:O(1)
- 寻找某个顶点所能到达的相邻顶点:O(|V|)
- 寻找所有的边:O( ∣ V ∣ 2 |V|^2 ∣V∣2)
- 增加或删除一条边:O(1)
- 邻接表
- 判断一条边是否存在:O(|V|)
- 寻找某个顶点所能到达的相邻顶点:O(|V|)
- 寻找所有的边:O(|E|)
- 增加或删除一条边:O(|V|)
- 相邻矩阵
4. 图的遍历
1. 深度优先遍历(DFS)
-
定义:类似于树中的先序遍历,整体思想是:先输出当前结点,在根据一定的次序去递归查找孩子。
-
适用于:给定指定两个顶点之间的路径、判断图是否有回路、判断图是否是连通图,如果不连通,则有几个连通分量。
-
遍历过程:
- 假设初始状态是图中所有顶点未曾被访问,则深度优先搜索可以从图中某个顶点v出发
- 访问这个v顶点,然后依次从v的未被访问的邻接点出发深度优先遍历图,直至图中所有和v有路径相连的顶点都被访问到
- 如果此时图中尚有顶点未被访问,则另选图中一个未曾被访问的顶点作起始点,重复上述过程直至图中所有顶点都被访问到为止
-
代码
public static void DFS(Graph G){ int v; for(v=0;v<G.n();v++) G.setMark(V,UNVISITED); for(v=0;V<G.n();v++) if(G.getMark(v)==UNVISITED) DFSHelp(G,v); } private static void DFSHelp(Graph G,int v){ G.setMark(V,VISITED); for(Edge w=G.first(v);G.isEdge(w);w=G.next(w)) if(G.getMark(G.v2(w))==UNVISITED) DFSHelp(G,G.v2(w)); }
-
时间代价分析
- 图中的每个顶点都需要访问两次
- 一次标记为UNVISTIED
- 一次标记为VISITED
- 图中的每条边都需要访问一次,即使那个终点访问过
- 总的时间为O(|V|+|E|)
- 当使用邻接表表示图时,需要的时间为O(|V|+|E|)
- 当使用相邻矩阵表示图时,需要的时间为O( ∣ V ∣ 2 |V|^2 ∣V∣2)
- 图中的每个顶点都需要访问两次
-
例子,深入了解
1.1 改进DFS
-
增加如下信息记录
- discoveryTime[]:用来记录第一次发现某个结点的时间值
- finishTime[]:用来记录完成了某个结点的探索的时间值
- fatherVertex[]:用来记录第一次探索某个结点时是被哪个结点激发出来的
-
优势:
- 利用fatherVertex数组中记录的信息可以得到图的一个DFS森林。与G的连通分量有关
- 利用discoveryTime和finishTime数组中记录的信息可以得到一个parenthesis structure
- 利用“回链接”和“下连接”的信息,可以检测一个图是否有环存在
一个图是无环的,当且仅当在DFS中未遇到回链接或下链接 - 简单路径:给定两个顶点,检查其在图中是否存在一条链接它们的路径
-
代码:
private static void DFS2Help(Graph G,int v){ G.setMark(V,VISITED); // 记录第一次发现某个结点的时间值 discoveryTime[v]=++time; for(Edge w=G.first(v);G.isEdge(w);w=G.next(w)){ if(G.getMark(w.v2())==UNVISITED){ // 记录第一次探索某个结点时是被哪个结点激发出来 fatherVertex[w.v2()]=v; DFS2Help(G,w.v2()); } } // 记录完成了某个结点的探索的时间值 finishTime[v=++time; }
2. 广度优先遍历(BFS)
-
类似于树中的层次遍历,需要用队列来体现结点访问的次序关系。
-
应用:单源最短路径的Dijkstra算法、最小支撑树的Prim算法
-
可以用于对图的基本操作:给出指定两个顶点之间的“最短”路径、判断图是否是连通图,如果不连通,则有几个连通分量
-
遍历过程:
- 假设从图中某个顶点v出发,在访问了v之后,依次访问v的各个未曾访问过的邻接点,并保证先被访问的顶点的邻接点“要先于”后被访问的页点的邻接点的访问,直至图中所有已被访问的顶点的邻接点都被访问到。
- 若此时图中还有未被访问的顶点,则任选其中之一作为起点,重新开始上述过程,直至图中所有顶点都被访问到
-
访问特征:
- 保证“先被访问的顶点的邻接点”要先于“后被访问的顶点的邻接点”被访问,也就是先到先被访问,这正好是队列的特点,因此可以使用队列来实现。
-
例子:
-
BFS广度优先搜索
- rule:按照从起点到图中顶点的路径长度增长顺序
- update:用邻接点更新
-
代码:
public static void BFS(Graph G){ int v; for(v=0;v<G.n();v++) G.setMark(V,UNVISITED) for(V=0;V<G.n();v++) if(G.getMark(v)==UNVISITED) BFSHelp(G,v); } private static void BFSHelp(Graph G, int v) { Queue q=new LQueue(); q.enqueue(new Integer(v)); G.setMark(v,VISITED); while(!q.isEmpty()){ // 出队,tempv是出队的元素 int tempv=((Integer)q.dequeue()).intValue(); // 在这加入对结点的访问方法 //for循环探索tempv的每个邻接点 for(Edge w=G.first(tempv);G.isEdge(w);w=G.next(w)){ if(G.getMark(G.v2(w))==UNVISITED){ G.setMark(G.v2(w),VISITED); q.enqueue(new Integer(G.v2(w))); // 或在这加入对结点的访问方法 } } } }
-
时间代价分析(DFS和BFS完全一致)
- 图中的每个顶点都需要访问两次
- 一次标记为UNVISTIED
- 一次标记为VISITED
- 图中的每条边都需要访问一次,即使那个终点访问过
- 总的时间为O(|V|+|E|)
- 当使用邻接表表示图时,需要的时间为O(|V|+|E|)
- 当使用相邻矩阵表示图时,需要的时间为O( ∣ V ∣ 2 |V|^2 ∣V∣2)
- 图中的每个顶点都需要访问两次
解决死循环的办法
为图中的每个顶点设置标志位,通过标志位决定顶点是否被访问过;
通过标志位决定经过一次试探后,还有哪些顶点没有被访问过。用来解决非连通图的问题。
5. 拓扑排序(topological sort)(图的应用)
工程的定义
-
一项工程往往可以分解为一些具有相对独立性的子工程,通常称这些子工程为“活动
- 子工程的完成意味着整个工程的完成
- 子工程之间在进行的时间上有着一定的相互制约关系
- 盖大楼的第一步是打地基,而房屋的内装修必须在房子盖好之后才能开始进行等
-
可用一个有向图表示子工程及其相互制约的关系,其中以顶点表示活动,弧表示活动之间的优先制约关系,称这种有向图为活动在顶点上的网络,简称活动顶点网络,或AOV(Activity On Vertex)网
-
AOV网的应用:1、学生为了获得学位,而进行的一系列的学习,这些学习过程必须有序进行的;
AOV网的定义
- 是一个有向图,该图中的顶点表示活动,图中的弧表示活动之间的优先关系
- 前驱(predecessor)、后继(successor)
- 顶点i是顶点j的前驱当且仅当从顶点i有一条有向路径到达顶点i,顶点i也称为顶点i的后继
- 活动之间的优先关系满足传递关系、非自反关系
- 对任意顶点i,i,k,如果i是i的前驱并且i是k的前驱,那么i一定也是k的前驱
- 对任意顶点i,i是i的前驱永远为假
- 不允许有环出现
- 否则意味着某个活动的开始是以这个活动的结束为先决条件的
拓扑排序
-
拓扑排序的定义:
- 一个G中所有顶点的一种线性顺序
- 对于G中的任意顶点i和j,如果i是j的前驱,那么在这个线性顺序中i一定在j之前
-
拓扑排序
- rule:入度数为0
- update:将邻接顶点的入度数减一
-
拓扑排序的过程:
- 扫描整个图,计算每个顶点的入度
- 让入度为0的顶点进入队列
- 如果队列不空,从队列中删除一个顶点并输出,同时将其所有相邻顶点的入度数减1,当某个相邻的顶点的入度数为0时,则将这个顶点插入到列中
- 重复上述步骤直到队列为空
- 如果还有顶点没有输出,那么表明这个图有环,不符合AOV网的定义、
-
代码:
Queue q=new AQueue(G.n()); int[] count=new int[G.n()]; // 存放变化的“入度” int v; for(v=0;v<G.n();v++) count[v]=0; // 初始化入度 for(v=0;v<G.n();v++) for(Edge w=G.first(v);G.isEdge(w);w=G.next(w)) count[w.v2()]++; // 先把入度为0的任务入度 for(V=0;V<G.n();v++) if(count[v]==0) q.enqueue(new Integer(v)); while(!q.isEmpty()){ v=((Integer)q.dequeue()).intValue(); // 出一个 topsorts[topsort index++]=v; // 记录出队的元素 for(Edge w=G.first(v);G.isEdge(w);w=G.next(w)){ // 邻接点的更新度数、若入度==0,入队 count[w.v2()]--; if(count[w.v2()]==0) q.enqueue(new Integer(w.v2())); } }
-
也可以用DFS方式获得一个拓扑序列的算法
- 在图G中任选一个顶点,对这个图做DFS,并计算该图中每个顶点在DFS中的完成时间
- 察看图中是否还有没有访问到的顶点,如果有任选一个重复上面的步骤直到图中的所有顶点全部处理完毕
- 将所有的顶点按照完成时间的逆序输出即为该图的拓扑排序。
6. 最短路径问题
1. 定义
-
路径的代价
-
对于无权图来说,路径的代价就是指路径的长度
-
对于有权图来说,路径的代价是指这个路径所经过的所有边上的权重之和
-
-
最短路径(BFS足以胜任)
- 给定两个顶点A和B,从A到B的一条有向简单路径而且此路径有以下属性:即不存在另外一条这样的路径且有更小的代价
2. 问题分类
-
源点-汇点最短路径(Source Sink Shortest Path)。
从图G= (V,E) 中,给定一个起始顶点s和一个结束顶点t,在图中找出从s到t的一条最短路径 -
单源最短路径 (Single Source Shortest Path)。
从图G= (V,E)中,找出从某个给定源顶点s ∈ V到V中的每个顶点的最短路径 -
全源最短路径 (all-pairs shortest-paths)
对于图G= (V,E),对任意的v,u ∈ V,都能知道v和u之间的最短路径值 -
不带权值的图的最短路径
- 使用广度优先搜索就可以解决
-
带有权值(正值)的图的最短路径
-
单源最短路径(single-source shortest path)
- 使用Dijkstra算法
-
每对顶点间的最短路径(all-pairs shortest-paths)
- 使用|V|次Dijkstra算法
- 使用FLoyd算法
-
-
带有负权值(但不含有负权值环)的图的最短路径
-
单源最短路径
- Bellman-ford算法
-
全源最短路径
- 使用FLoyd算法
-
3. Dijkstra算法
-
Dijkstra算法:利用BFS搜索思想,只不过将顶点从一个集合拉到另一个集合的规则不同
-
思路:
- 按路径代价递增的次序产生最短路径(权值全正)
- 设集合S存放已经求得的最短路径的终点,从V-S中选择一个顶点t,t是目前所有还没有求得最短路径顶点中,与v0之间的距离最短的顶点
- 将t加入到S中,并且更新v0到V-S中所有能够到达顶点之间的距离(同时更新父结点数组)
- 如此反复,直到V-S中没有可以从v0到达的顶点为止
-
算法步骤
- 设置一个一维数组distance,该数组记录从源点v0到任意其他顶点vi的最短路径估计
- 当i=v0时,distance[i]=0
- 当i<>v0时,且<v0,vi>∈E,则distance[i]=w0i
- 当i<>v0时,且<v0,vi>∉E,则distance[i]=∞
- 将v0加入到集合S中
- 从V-S中选择顶点vj,将该顶点加入到集合S中,vj满足如下关系
- distance[ j ] = min{distance[k] | vk∈V-S)
- 对每一个V-S中的顶点vk,修改distance[k]
- distance[ k ] = min{distance[k],distance[j]+wjk}
- 重复步骤3、4,直到V-S中没有可以能够加入到S中的顶点为止。(两种情况:V-S空 或 原点到不了V-S中的结点,保持∞)
- 设置一个一维数组distance,该数组记录从源点v0到任意其他顶点vi的最短路径估计
-
算法分析
- 需要扫描|V|次
- 每次扫描都需要扫描|V|个顶点以求得最短路径值的顶点
- 每扫描到一条边就需要更新一次distance值,由于有|E|条边,所以需更新|E|次
- 总的时间消耗为0(|V|2+ |E|)
- 需要扫描|V|次
-
算法改进
- 利用优先队列,即堆寻找最小值
- 总的时间消耗为0((|V|+|E|) log|El)
- 利用优先队列,即堆寻找最小值
7. 最小支撑树(minimum-cost Spanning Tree)
1. 定义
-
给定一个连通无向图G,且它的每条边均有相应的长度或权值,则MST是一个包括G中的所有顶点及其边子集的图,边的子集满足下列条件:
- 这个子集中所有边的权之和为所有子集中最小的
- 子集中的边能够保证图是连通的
-
一个图的MST可能不唯一
-
MST是一棵有|V|-1条边的自由树
-
应用:通信网络、运输网络
-
环性质
假设T是一个有权无向图G=(V,E)的MST,如果选择一条属于E,但不属于T的边e加入到MST,从而使T形成一个环时,那么这个环中的任意一条边f都满足如下关系:weight(f) ≤ weight(e) -
分割性质
设集合U和W是图G=(V,E)的顶点集合的两个子集这两个顶点子集将图分成了两部分,其中e是所有能够连接两个部分中权最小的边,那么e将是MST的一条边。
2. 算法
- Prim算法
- 与与Dijkstra算法十分相似
- 使用BFS的思想
- Kruskal算法
- 使用Union/Find思想(并查集)
3. Prim算法
算法步骤:
- 选择图中的任意一个顶点N开始,初始化MST为N
- 计算MST中每个顶点到不在MST中的每个顶点之间的距离
- 选择这些距离中最小的那条边,并将这条边中的不在MST中的顶点加入到MST中
- 重复步骤2和3,直到没有可以加入到MST中的顶点为止
4. Kruskal算法(贪心)
算法步骤:
- 将顶点集分为 V 个等价类,每个等价类包括一个顶点
- 将图中的所有边按权值的大小顺序处理
- 在处理某条边时,如果这条边所连接的顶点不在一个等价类中,则将这条边添加到MST中,并把两个等价类合并为一个
- 反复执行2、3,直到剩下一个等价类
第5章 散列
基本定义
-
散列的定义:把任意长度的输入,通过散列算法变换成固定长度的输出
-
Hash Fuction:哈希函数,接收待查找的关键字,返回一个数组(Hash Table)中的索引(Hash Index,即存储关键字所对应数组hashtable中的储存位置下标)
- 最自然的方式是mod取余运算,将哈希码转换成哈希表的索引值范围;如果关键字不是整数类型,将不是整数类型的关键字通过各种方式转换成整数类型,转换后的结果我们称其为hash code。
-
压缩哈希码的方式:直接定址法、数字分析法、平方取中法、折叠法、取余数法
-
hash函数构建的小结:
-
采用何种构造哈希函数的方法取决于建表的关键字集合的情况(包括关键字的范围和形态),总的原则是使产生冲突的可能性降到尽可能地小
-
要计算容易和速度快
-
确定性:对于同一个关键码,不管什么时候计算出来的hash index都应该是确定的
-
散列函数的定义域必须包括需要存储的全部关键码,如果散列表允许有 m 个地址时,其值域必须在0到m-1之间
-
hash冲突?
何为hash冲突?
由于哈希函数的作用是将关键字压缩为有限的数值范围(也就是哈希表的索引范围),所以就会造成冲突。例:key1<>key2,但是H(key1)==H(key2),key1和key2是同义词。
如何处理Hash冲突?
- 开地址法(在哈希表中重新找一个位置)
- 线性探查
- 若k冲突,尝试k+1、k+2…直到可以插入。容易发生一次聚集。
- 删除元素不能真正删除,要加删除标志
- 平方探查
- 若k冲突,尝试k+22、k+22…直到可以插入。容易发生二次聚集。
- 删除元素不能真正删除,要加删除标志
- 双散列探查(最常用)
- 当通过第一个哈希函数得到的哈希索引发生冲突之后,获得的下一个哈希索引应该是第一个哈希索引加上通过第二个哈希函数求得的哈希索引之和(可以一直加下去)
- 对第二个哈希函数的要求
- 有别于第一个哈希函数
- 所求的值也要依赖于关键字
- 不能返回0值
- 避免了基本聚集和二次聚集
- 线性探查
- 开散列法(改变哈希表的结构,使哈希表的一个位置上不再只容纳一个元素,而是可以容纳多个)。
- 哈希表中的每一个位置都不止代表一个元素而可以代表多个
- 我们把能够代表多个元素的位置形象的称为桶,桶可以表示为
线性表、有序线性表等。 - 我们称同一子集中的关键码互为同义词
对哈希的效率衡量
- 完美的哈希函数并不总是实际的,因此冲突的发生是不可避免的
- 为了能够度量哈希的效率,我们需要借用一个称为Load Factor的因子=N/M,N指实际的纪录个数,M指哈希表的长度。
- λ<0.3时,线性探查效率高,接近O(1);
- λ<0.5时,平方探查、双散列探查效率高。
- 每扫描到一条边就需要更新一次distance值,由于有|E|条边,所以需更新|E|次
- 总的时间消耗为0(|V|2+ |E|)
- 算法改进
- 利用优先队列,即堆寻找最小值
- 总的时间消耗为0((|V|+|E|) log|El)
- 利用优先队列,即堆寻找最小值
7. 最小支撑树(minimum-cost Spanning Tree)
1. 定义
-
给定一个连通无向图G,且它的每条边均有相应的长度或权值,则MST是一个包括G中的所有顶点及其边子集的图,边的子集满足下列条件:
- 这个子集中所有边的权之和为所有子集中最小的
- 子集中的边能够保证图是连通的
-
一个图的MST可能不唯一
-
MST是一棵有|V|-1条边的自由树
-
应用:通信网络、运输网络
-
环性质
假设T是一个有权无向图G=(V,E)的MST,如果选择一条属于E,但不属于T的边e加入到MST,从而使T形成一个环时,那么这个环中的任意一条边f都满足如下关系:weight(f) ≤ weight(e) -
分割性质
设集合U和W是图G=(V,E)的顶点集合的两个子集这两个顶点子集将图分成了两部分,其中e是所有能够连接两个部分中权最小的边,那么e将是MST的一条边。
2. 算法
- Prim算法
- 与与Dijkstra算法十分相似
- 使用BFS的思想
- Kruskal算法
- 使用Union/Find思想(并查集)
3. Prim算法
算法步骤:
- 选择图中的任意一个顶点N开始,初始化MST为N
- 计算MST中每个顶点到不在MST中的每个顶点之间的距离
- 选择这些距离中最小的那条边,并将这条边中的不在MST中的顶点加入到MST中
- 重复步骤2和3,直到没有可以加入到MST中的顶点为止
4. Kruskal算法(贪心)
算法步骤:
- 将顶点集分为 V 个等价类,每个等价类包括一个顶点
- 将图中的所有边按权值的大小顺序处理
- 在处理某条边时,如果这条边所连接的顶点不在一个等价类中,则将这条边添加到MST中,并把两个等价类合并为一个
- 反复执行2、3,直到剩下一个等价类
第5章 散列
基本定义
-
散列的定义:把任意长度的输入,通过散列算法变换成固定长度的输出
-
Hash Fuction:哈希函数,接收待查找的关键字,返回一个数组(Hash Table)中的索引(Hash Index,即存储关键字所对应数组hashtable中的储存位置下标)
- 最自然的方式是mod取余运算,将哈希码转换成哈希表的索引值范围;如果关键字不是整数类型,将不是整数类型的关键字通过各种方式转换成整数类型,转换后的结果我们称其为hash code。
-
压缩哈希码的方式:直接定址法、数字分析法、平方取中法、折叠法、取余数法
-
hash函数构建的小结:
-
采用何种构造哈希函数的方法取决于建表的关键字集合的情况(包括关键字的范围和形态),总的原则是使产生冲突的可能性降到尽可能地小
-
要计算容易和速度快
-
确定性:对于同一个关键码,不管什么时候计算出来的hash index都应该是确定的
-
散列函数的定义域必须包括需要存储的全部关键码,如果散列表允许有 m 个地址时,其值域必须在0到m-1之间
-
hash冲突?
何为hash冲突?
由于哈希函数的作用是将关键字压缩为有限的数值范围(也就是哈希表的索引范围),所以就会造成冲突。例:key1<>key2,但是H(key1)==H(key2),key1和key2是同义词。
如何处理Hash冲突?
- 开地址法(在哈希表中重新找一个位置)
- 线性探查
- 若k冲突,尝试k+1、k+2…直到可以插入。容易发生一次聚集。
- 删除元素不能真正删除,要加删除标志
- 平方探查
- 若k冲突,尝试k+22、k+22…直到可以插入。容易发生二次聚集。
- 删除元素不能真正删除,要加删除标志
- 双散列探查(最常用)
- 当通过第一个哈希函数得到的哈希索引发生冲突之后,获得的下一个哈希索引应该是第一个哈希索引加上通过第二个哈希函数求得的哈希索引之和(可以一直加下去)
- 对第二个哈希函数的要求
- 有别于第一个哈希函数
- 所求的值也要依赖于关键字
- 不能返回0值
- 避免了基本聚集和二次聚集
- 线性探查
- 开散列法(改变哈希表的结构,使哈希表的一个位置上不再只容纳一个元素,而是可以容纳多个)。
- 哈希表中的每一个位置都不止代表一个元素而可以代表多个
- 我们把能够代表多个元素的位置形象的称为桶,桶可以表示为
线性表、有序线性表等。 - 我们称同一子集中的关键码互为同义词
对哈希的效率衡量
- 完美的哈希函数并不总是实际的,因此冲突的发生是不可避免的
- 为了能够度量哈希的效率,我们需要借用一个称为Load Factor的因子=N/M,N指实际的纪录个数,M指哈希表的长度。
- λ<0.3时,线性探查效率高,接近O(1);
- λ<0.5时,平方探查、双散列探查效率高。