算法红皮书(第四版)

算法红皮书 3.2.1

二叉查找树 #

  • 使用每个结点含有两个链接(链表中每个结点只含有一个链接)的二叉查找树来高效地实现符号表

  • 该数据结构由结点组成,结点包含的链接可以为空(null)或者指向其他结点

  • 一棵二叉查找树(BST)是一棵二叉树,其中每个结点都含有一个Comparable 的键(以 及相关联的值)且每个结点的键都大于其左子树中的任意结点的键而小于右子树的任意结点的键。

  • ly-20241212142101353

基本实现 #

  • 数据表示

    • 每个结点都含有一个键、一个值、一条左链接、一条右链接和一个结点计数器 左链接指向一棵由小于该结点的所有键组成的二叉查找树,右链接指向一棵由大于该节点的所有键组成的二叉查找树,变量N给出了以该结点为根的子树的结点总数
    • 对于任意节点总是成立 size(x)=size(x.left)+size(x.right)+1
  • 多棵二叉查找树表示同一组有序的键来实现构建和使用二叉查找树的高校算法 ly-20241212142101579

  • 查找

    • 在符号表中查找一个键可能得到两种结果:如果含有该键的结点存在表中,我们的查找就命中了,然后返回值;否则查找未命中(返回null)
    • 递归:如果树是空的,则查找未命中;如果被查找的键和根节点的键相等,查找命中,否则在适当的子树中查找:如果被查找的键较小就选择左子树,否则选择右子树
    • 下面的get()方法,第一个参数是一个结点(子树根节点),第二个参数是被查找的键,代码会保证只有该结点所表示的子树才会含有和被查找的键相等的结点
    • 从根结点开始,在每个结点中查找的进程都会递归地在它的一个子结点上展开,因此一次查找也就定义了树的一条路径。对于命中的查找,路径在含有被查找的键的结点处结束。对于未命中的查找,路径的终点是一个空链接 ly-20241212142101684
  • 基于二叉查找树的符号表

    public class BST<Key extends Comparable<Key>, Value>
    {
    	private Node root;
    	// 二叉查找树的根结点
    	private class Node
    	{
    		private Key key;
    		// 键
    		private Value val;
    		// 值
    		private Node left, right;
    		// 指向子树的链接
    		private int N;
    		// 以该结点为根的子树中的结点总数
    		public Node(Key key, Value val, int N)
    		{
    			this.key = key;
    			this.val = val;
    			this.N = N;
    		}
    	}
    	public int size()
    	{
    		return size(root);
    	}
    	private int size(Node x)
    	{
    		if (x == null) return 0; else return x.N;
    	}
    	public Value get(Key key)
    	// 请见算法3.3(续1)
    	public void put(Key key, Value val)
    	// 请见算法3.3(续1)
    	// max()、min()、floor()、ceiling()方法请见算法3.3(续2)
    	// select()、rank()方法请见算法3.3(续3)
    	// delete()、deleteMin()、deleteMax()方法请见算法3.3(续4)
    	// keys()方法请见算法3.3(续5)
    }
    
    • 每个Node 对象都是一棵含有N 个结点的子树的根结点,它的左链接指向一棵由小于该结点的所有键组成的二叉查找树,右链接指向一棵由大于该结点的所有键组成的二叉查找 树。root 变量指向二叉查找树的根结点Node 对象(这棵树包含了符号表中的所有键值对)
  • 二叉查找树的查找和排序方法的实现

    ...

算法红皮书 3.1.1-3.1.7

查找 #

  • 经典查找算法

  • 符号表这个词来描述抽象的表格,将信息(值)存储在其中,然后按照指定的来获取这些信息

  • 符号表也被称为字典

    • 在英语字典里,键就是单词,值就是单词对应的定义、发音和词源
    • 符号表有时又叫索引
    • 在一本书的索引中,键就是术语,而值就是书中该术语出现的所有页码
  • 下面学习三种经典的数据类型:二叉查找树、红黑树和散列表

符号表 #

  • 符号表最主要的目的是将联系起来

  • 用例能够将一个键值对插入符号表并希望在之后能够从符号表的所有键值对中按照键直接找到相对应的值

  • 符号表是一种存储键值对的数据结构,支持两种操作:插入(put),即将一组新的键值对存入表中;查找(get),即根据给定的键得到相应的值

  • 典型的符号表应用 ly-20241212142100034

API #

  • 符号表是一种典型的数据类型 :代表着一组定义清晰的值及相应的操作。使用应用程序编程接口(API)来精确地定义这些操作 一种简单的泛型符号表API ST(Symbol Table) ly-20241212142100261

  • 泛型 对于符号表,我们通过明确地指定查找时键和值的类型来区分它们的不同角色【key和value】

  • 重复的键

    • 这里假设每个键只对应着一个值(表中不允许重复值)
    • 当用例代码向表中存入的键值对和表中已有的键(及关联的值)冲突时,新的值会替代旧的值
    • 上述定义了关联数组的抽象形式,可以将符号表想象成数组,键即索引,值即数组中的值
    • 在一个关联数组中,键可以是任意类型,但我们仍然可以用它来快速访问数组的值
    • 非Java使用st[key]来替代st.get(key),用st[key]=val来替代st.put(key,val)
  • 键不能为空

  • 值不能为空(因为规定当键不存在时get()返回空) 当值为空表示删除

  • 删除操作

    • 延时删除,先将键对应的值置空,之后在某个时刻删除所有值为空的键

    • 即时删除,立即从表中删除指定的键 put实现的开头:

      if(val == null){
       delete(key);
       return;
      }
      
    • 便捷方法 ly-20241212142100362

    • 迭代 在API第一行加上implements Iterable<Key> ,所有实现都包含iterator()方法来实现hasNext()和next()方法的迭代器;这里采用另一种方式:定义keys返回一个Iterable<Key>对象以方便便利所有的键,且允许遍历一部分

    • 键的等价性 自定义的键需要重写equals()方法;且最好使用不可变数据类型作为键

有序符号表 #

  • 一种有序的泛型符号表的API ly-20241212142100464
  • 最大值和最小值、向下取整和向上取整、排名和选择
  • 对于0到size()-1的所有i都有i==rank(select(i)),且所有的键都满足key == select(rank(key))
  • 范围查找
  • 例外情况 当一个方法需要返回一个键但表中没有合适的键可以返回时,我们约定抛出一个异常
  • 有序符号表中冗余有序性方法的默认实现 ly-20241212142100610
  • 所有Comparable类型中compareTo()方法和equals()方法的一致性
  • ★★成本模型 在学习符号表的实现时,我们会统计比较的次数(等价性测试或是键的相互比较),在内循环**不进行比较(极少)**的情况下,我们会统计数组的访问次数

用例举例 #

如何使用

...

算法红皮书 2.5

  • 排序如此有用的原因是,在有序的数组中查找一个元素,要比在一个无序的数组中查找简单得多
  • 通用排序算法是最重要的
  • 算法思想虽然简单,但是适用领域广泛

将各种数据排序 #

  • Java的约定使得我们能够利用Java的回调机制将任意实现Comparable接口的数据类型排序

    • 我们的代码直接能够将String、Integer、Double 和一些其他例如File 和URL 类型的数组排序,因为它们都实现了Comparable 接口
  • 交易事务 商业数据的处理,设想一家互联网商业公司为每笔交易记录都保存了所有的相关信息

    public int compareTo(Transaction that)
    {
    	return this.when.compareTo(that.when);
    }
    
  • 指针排序 我们使用的方法在经典教材中被称为指针排序,因为我们只处理元素的引用而不移动数据本身

  • 不可变的键 用不可变的数据类型作为键,比如String、Integer、Double和File等

  • 廉价的交换

    • 使用引用的另一个好处是不必移动整个元素对于几乎任意大小的元素,使用引用使得在一般情况下交换的成本和比较的成本几乎相同(代价是需要额外的空间存储这些引用)

    • 研究将数字排序的算法性能的一种方法就是观察其所需的比较和交换总数,因为这里隐式地假设了比较和交换的成本是相同的

  • 多种排序方法

    • 根据情况将一组对象按照不同的方式排序。Java 的Comparator 接口允许我们在一个类之中实现多种排序方法
  • 多键数组

    • 一个元素的多种属性都可能被用作排序的键

      • 我们可以定义多种比较器,要将Transaction 对象的数组按照时间排序可以调用: Insertion.sort(a, new Transaction.WhenOrder()) 或者这样来按照金额排序: Insertion.sort(a, new Transaction.HowMuchOrder())
    • 使用Comparator的插入排序

      public static void sort(Object[] a, Comparator c)
      {
      	int N = a.length;
      	for (int i = 1; i < N; i++)
      	for (int j = i; j > 0 && less(Comparator, a[j], a[j-1]); j--)
      	exch(a, j, j-1);
      }
      private static Boolean less(Comparator c, Object v, Object w)
      {
      	return c.compare(v, w) < 0;
      }
      private static void exch(Object[] a, int i, int j)
      {
      	Object t = a[i];
      	a[i] = a[j];
      	a[j] = t;
      }
      
    • 使用比较器实现优先队列

      ...

算法红皮书 2.4

优先队列 #

  • 有些情况下,不需要要求处理的元素全部有序,只要求每次都处理键值最大的元素,然后再收集更多的元素,然后再处理键值最大的元素
  • 需要一种数据结构,支持操作:删除最大元素和插入元素,这种数据类型叫做优先队列
  • 优先队列的基本表现形式:其一或两种操作都能在线性时间内完成
  • 基于二叉堆数据结构的优先队列,用数组保存元素并按照一定条件排序,以实现高效的删除最大元素和插入元素

API #

  • 抽象数据类型,最重要的操作是删除最大元素和插入元素 delMax()和insert()

  • 用“最大元素”代替“最大键值”或是“键值最大的元素”

  • 泛型优先队列的API ly-20241212142058095

  • 优先队列的调用示例 从N各输入中找到最大的M各元素所需成本 ly-20241212142058324

    • 优先队列的用例 pq里面最多放5个,当大于5个的时候,就从中剔除1个

      public class TopM
      {
      	public static void main(String[] args)
      	{
      		// 打印输入流中最大的M行
      		int M = Integer.parseint(args[0]);
      		MinPQ<Transaction> pq = new MinPQ<Transaction>(M+1);
      		while (StdIn.hasNextLine())
      		{
      			// 为下一行输入创建一个元素并放入优先队列中
      			pq.insert(new Transaction(StdIn.readLine()));
      			if (pq.size() > M)
      			  pq.delMin();
      			// 如果优先队列中存在M+1个元素则删除其中最小的元素
      		}
      		// 最大的M个元素都在优先队列中
      		Stack<Transaction> stack = new Stack<Transaction>();
      		while (!pq.isEmpty()) stack.push(pq.delMin());
      		for (Transaction t : stack) StdOut.println(t);
      	}
      }
      
    • 应用 ly-20241212142058429

      ...

算法红皮书 2.1.2-2.1.3

排序 #

初级排序算法 #

选择排序 #

  • 命题A。对于长度为N 的数组,选择排序需要大约 N^2/2 次比较和N 次交换。

  • 代码

    public class Selection
    {
    	public static void sort(Comparable[] a)
    	{
    		// 将a[]按升序排列
    		int N = a.length;
    		// 数组长度
    		for (int i = 0; i < N; i++)
    		{
    			// 将a[i]和a[i+1..N]中最小的元素交换
    			int min = i;
    			// 最小元素的索引
    			for (int j = i+1; j < N; j++)
    			if (less(a[j], a[min])) min = j;
    			exch(a, i, min);
    		}
    	}
    	// less()、exch()、isSorted()和main()方法见“排序算法类模板”
    }
    
  • 特点

    • 运行时间与输入无关,即输入数据的初始状态(比如是否已排序好等等)不影响排序时间
    • 数据移动是最少的(只使用了N次交换,交换次数和数组的大小是线性关系

插入排序 #

  • 命题B。对于随机排列的长度为N 且主键不重复的数组,平均情况下插入排序需要~ N2/4 次比较以及~ N2/4 次交换。最坏情况下需要~ N2/2 次比较和~ N2/2 次交换,最好情况下需要N-1次比较和0 次交换。

    ...

算法红皮书 2.1.1

排序 #

排序就是将一组对象按照某种逻辑顺序重新排序的过程

  • 对排序算法的分析有助于理解本书中比较算法性能的方法
  • 类似技术能解决其他类型问题
  • 排序算法常常是我们解决其他问题的第一步

初级排序算法 #

  • 熟悉术语及技巧
  • 某些情况下初级算法更有效
  • 有助于改进复杂算法的效率

游戏规则 #

  • 主要关注重新排序数组元素的算法,每个元素都会有一个主键

  • 排序后索引较大的主键大于索引较小的主键

  • 一般情况下排序算法通过两个方法操作数据,less()进行比较,exch()进行交换

  • 排序算法类的模板

    public class Example
    {
    	public static void sort(Comparable[] a)
    	{
    		/* 请见算法2.1、算法2.2、算法2.3、算法2.4、算法2.5或算法2.7*/
    	}
    	private static Boolean less(Comparable v, Comparable w)
    	{
    		return v.compareTo(w) < 0;
    	}
    	private static void exch(Comparable[] a, int i, int j)
    	{
    		Comparable t = a[i];
    		a[i] = a[j];
    		a[j] = t;
    	}
    	private static void show(Comparable[] a)
    	{
    		// 在单行中打印数组
    		for (int i = 0; i < a.length; i++)
    		StdOut.print(a[i] + " ");
    		StdOut.println();
    	}
    	public static Boolean isSorted(Comparable[] a)
    	{
    		// 测试数组元素是否有序
    		for (int i = 1; i < a.length; i++)
    		if (less(a[i], a[i-1])) return false;
    		return true;
    	}
    	public static void main(String[]
    	args)
    	{
    		// 从标准输入读取字符串,将它们排序并输出
    		String[] a = In.readStrings();
    		sort(a);
    		assert isSorted(a);
    		show(a);
    	}
    }
    

算法红皮书 1.5.1-1.5.3

案例研究:union-find 算法 #

  • 设计和分析算法的基本方法
    • 优秀的算法能解决实际问题
    • 高效的算法也可以很简单
    • 理解某个实现的性能特点是一项有趣的挑战
    • 在解决同一个问题的多种算法间选择,科学方法是一种重要工具
    • 迭代式改进能让算法效率越来越高

动态连通性 #

  • 从输入中读取整数对p q,如果已知的所有整数对都不能说明p,q相连,就打印出pq
  • 网络:整个程序能够判定是否需要在pq之间架设一条新的连接才能进行通信
  • 变量名等价性(即指向同一个对象的多个引用)
  • 数学集合:在处理一个整数对pq时,我们是在判断它们是否属于相同的集合
  • 本节中,将对象称为触点,整数对称为连接,等价类称为连通分量或是简称分量
  • 连通性 问题只要求我们的程序能够判别给定的整数对pq是否相连,并没有要求给两者之间的通路上的所有连接
  • union-find算法的API
    ly-20241212142056628
  • 数据结构和算法的设计影响到算法的效率

实现 #

public class UF
{
	private int[]	id;
	/* 分量id(以触点作为索引) */
	private int	count;
	/* 分量数量 */
	public UF( int N )
		{
		/* 初始化分量id数组 */
		count	= N;
		id	= new int[N];
		for ( int i = 0; i < N; i++ )
					id[i] = i;
	}
	public int count()
		{
		return(count);
	}
	public Boolean connected( int p, int q )
		{
		return(find( p ) == find( q ) );
	}
	public int find( int p )
		public void union( int p, int q )
	/* 请见1.5.2.1节用例(quick-find)、1.5.2.3节用例(quick-union)和算法1.5(加权quick-union) */
	public static void main( String[] args )
		{
		/* 解决由StdIn得到的动态连通性问题 */
		int	N	= StdIn.readint();
		/* 读取触点数量 */
		UF	uf	= new UF( N );
		/* 初始化N个分量 */
		while ( !StdIn.isEmpty() )
				{
			int	p	= StdIn.readint();
			int	q	= StdIn.readint();
			/* 读取整数对 */
			if ( uf.connected( p, q ) )
							continue;
			/* 如果已经连通则忽略 */
			uf.union( p, q );
			/* 归并分量 */
			StdOut.println( p + " " + q );
			/* 打印连接 */
		}
		StdOut.println( uf.count() + "components" );
	}
}

union-find的成本模型:union-find API的各种算法,统计的是数组的访问次数,不论读写

...

算法红皮书 1.4.1-1.4.10

算法分析 #

使用数学分析为算法成本建立简洁的模型,并使用实验数据验证这些模型

科学方法 #

  • 观察、假设、预测、观察并核实预测、反复确认预测和观察
  • 原则:实验可重现

观察 #

  • 计算性任务的困难程度可以用问题的规模来衡量

  • 问题规模可以是输入的大小或某个命令行参数的值

  • 研究问题规模和运行时间的关系

  • 使用计时器得到大概的运行时间 ly-20241212142054451

    • 典型用例

      public static void main(String[] args) {
              int N = Integer.parseInt(args[0]);
              int[] a = new int[N];
              for (int i = 0; i < N; i++)
                  a[i] = StdRandom.uniform(-1000000, 1000000);
              Stopwatch timer = new Stopwatch();
              int cnt = ThreeSum.count(a);
              double time = timer.elapsedTime();
              StdOut.println(cnt + " triples " + time + " seconds");
          }
      
    • 使用方法 ly-20241212142054686

    • 数据类型的实现

      public class Stopwatch {
          private final long start;
      
          public Stopwatch() {
              start = System.currentTimeMillis();
          }
      
          public double elapsedTime() {
              long now = System.currentTimeMillis();
      
              return (now - start) / 1000.0;
          }
      }
      

数学模型 #

  • 程序运行的总时间主要和两点有关:执行每条语句的耗时;执行每条语句的频率

    ...

算法红皮书1.3.3.1-1.3.4

背包、队列和栈 #

链表 #

  • 链表是一种递归的数据结构,它或者为空(null),或者是一个指向一个结点(node)的引用,该节点含有一个泛型的元素和一个指向另一条链表的引用。

结点记录 #

  • 使用嵌套类定义结点的抽象数据类型

    private class Node
    {
    	Item item;
    	Node next;
    }
    
    • 该类没有其它任何方法,且会在代码中直接引用实例变量,这种类型的变量称为记录

构造链表 #

  • 需要一个Node类型的变量,保证它的值是null或者指向另一个Node对象的next域指向了另一个链表
  • 如下图 ly-20241212142053630
  • 链表表示的是一列元素
  • 链式结构在本书中的可视化表示 长方形表示对象;实例变量的值写在长方形中;用指向被引用对象的箭头表示引用关系
  • 术语链接表示对结点的引用

在表头插入结点 #

  • 在首结点为first 的给定链表开头插入字符串not,我们先将first 保存在oldfirst 中, 然后将一个新结点赋予first,并将它的item 域设为not,next 域设为oldfirst

  • 时间复杂度为O(1)

  • 如图 ly-20241212142053870

从表头删除结点 #

  • 将first指向first.next

  • 原先的结点称为孤儿,Java的内存管理系统最终将回收它所占用的内存

  • 如图 ly-20241212142053982

在表尾插入结点 #

  • 每个修改链表的操作都需要增加检查是否要修改该变量(以及做出相应修改)的代码

  • 例如,当删除链表首结点时可能改变指向链表的尾结点的引用,因为链表中只有一个结点时它既是首结点又是尾结点

  • 如图 ly-20241212142054097

其他位置的插入和删除操作 #

删除指定结点;在指定节点插入新结点

  • 需要将链表尾结点的前一个节点中的链接(它指向的是last)值改为null
  • 为了找到指向last的结点,需要遍历链表,时间复杂度为O(n)
  • 实现任意插入和删除操作的标准解决方案是双向链表

遍历 #

  • 将x初始化为链表首结点,然后通过x.item访问和x相关联的元素,并将x设为x.next来访问链表中的下一个结点,知道x=null(没有下一个结点了,到达链表结尾)

    ...

算法红皮书 1.3.1.1-1.3.2.5

背包、队列和栈 #

  • 数据类型的值就是一组对象的集合,所有操作都是关于添加、删除或是访问集合中的对象
  • 本章将学习三种数据类型:背包Bag、队列Queue、栈Stack
    • 对集合中的对象的表示方式直接影响各种操作的效率
    • 介绍泛型和迭代
    • 介绍并说明链式数据结构的重要性(链表)

API #

  • 泛型可迭代的基础集合数据类型的API

    • 背包
      ly-20241212142051025
    • 队列(先进先出FIFO)
      ly-20241212142051259
    • 下压(后进先出,LIFO)栈 ly-20241212142051367
  • 泛型

    • 泛型,参数化类型
    • 在每份API 中,类名后的<Item> 记号将Item 定义为一个类型参数,它是一个象征性的占位符,表示的是用例将会使用的某种具体数据类型
  • 自动装箱

    • 用来处理原始类型
    • Boolean、Byte、Character、Double、Float、Integer、Long 和Short 分别对应着boolean、byte、char、double、float、int、long 和short
    • 自动将一个原始数据类型转换为一个封装类型称为自动装箱,自动将一个封装类型转换为一个原始数据类型被称为自动拆箱
  • 可迭代的集合类型

    • 迭代访问集合中的所有元素
  • 背包是一种不支持从中删除元素的集合数据类型–帮助用例收集元素并迭代遍历所有收集到的元素(无序遍历

    • 典型用例,计算标准差
  • 先进先出队列

    • 是一种基于先进先出(FIFO)策略的集合类型
    • 使用队列的主要原因:集合保存元素的同时保存它们的相对顺序
    • 如图
      ly-20241212142051478
    • Queue用例(先进先出)
      ly-20241212142051591
  • 下压栈

    • 简称栈,是一种基于后进先出LIFO策略的集合类型
    • 比如,收邮件等,如图
      ly-20241212142051703
    • Stack的用例
      ly-20241212142051815
  • 用栈解决算数表达式的问题
    (双栈算数表达式求值算法)
    ly-20241212142051919

集合类数据类型的实现 #

  • 定容栈,表示容量固定的字符串栈的抽象数据类型

    • 只能处理String值,支持push和pop

    • 抽象数据类型
      ly-20241212142052029

    • 测试用例

      ly-20241212142052135

    • 使用方法
      ly-20241212142052243

    • 数据类型的实现
      ly-20241212142052353

  • 泛型

    • public class FixedCapacityStack<Item>
    • 由于不允许直接创建泛型数组,所以 a =new Item[cap] 不允许,应该改为
      a=(Item[])new Object[cap];
    • 泛型定容栈的抽象数据类型
      ly-20241212142052474
    • 测试用例
      ly-20241212142052593
    • 使用方法
      ly-20241212142052746
    • 数据类型的实现
      ly-20241212142052865
  • 调整数组大小

    ...