目录

ArrayLists, LinkedLists, Stacks and Queues

Gtx CS1332x I Notes


Introduction

Value/Reference Equality

等价对象的的区别

上面两种比较的主要应用发生在 Primitives type 与 Object type 之间。

int one1 = 1;
int one2 = 1;
int two1 = 2;
one1 == one2; // => true
one1 == two1; // => false
String literals:String literal 会以常量的方式存储在 String pool 中。通过赋值创建的 String Literal 也没有值和引用等价的区分:
String literal = "This is a string.";
literal == "This is a string.";              // => true
但如果以对象的方式创建 String Literal (或者说是以 String Literal 为初始值创建对象),那么值等价和引用等价是会区分开的。
//ref equality
String object = new String("This is a string.");
object == "This is a string.";

//value equality
object.equals("This is a string.");          // => true

Null Checking

String nullObject = null;
nullObject == null;   // => true

String normalObject = "normal";
normalObject.equals(null);       // => false
nullObject.equals(normalObject);  // causes NullPointerException

Wrapper Objects

Wrapper Objects的比较 与 String Literal 类似,也分值等价和引用等价两种情况:

Integer primitive = 1;
Integer object = new Integer(1);
primitive == 1;             // => true
object == 1;                // => true

primitive == object;        // => false
primitive.equals(object);                 // => true
object.equals(primitive);                 // => true

Pass by Value/Reference

  • Java 是 passing by value language.
  • 传递 Object 的时候,实际上是值传递;但传递的是 Object 所在地址的拷贝。因此可以通过该拷贝去更改原有 Object 的内容,这就是所谓 Java 中的 Pass by referenece with object。

Generics

ClassCastException

指在运行期,对象类型与生命类型不匹配。解决方式:使用带 type parameter 的特化版本:

public class Container<T> {
    private T t;
    public void set(T t) {
        this.t = t;
    }
    public T get() {
        return t;
    }
}
特化版本会在编译器确定类型是否匹配。

Generics Syntax

//def
class ArrayList<T> { ... }
//declar
ArrayList<Integer> listOne = new ArrayList<>();

Generic Arrays

//declar
T[] backingArray = (T[]) new Object[10];

Multiple Type Parameters

class HashMap<K, V> { ... }
HashMap<String, Integer> map = new HashMap<>();

Generic Classes as Type Parameters

ArrayList<BSTNode<T>> nodeList = new ArrayList<>();

Iterable and Comparable

Docs:Iterable | Docs:Iterator

Iterable interface

Java 提供了 Iterable 接口;任何实现该接口的类都可以使用 for-each loop。使用需要导入 java.util.iterator

实现上:

Iterator 也可以单独使用。需要重写的方法:

两种简单的示例如下:

import java.util.Iterator;
public class BookList<Book> implements Iterator<Book> {
    
    //override
    public void next() {}
    public boolean hasNext() {}

public static void main(String[] args) {
    //usage suppose bookList is an object with type BookList<Book>
    while(bookList.hasNext())
    {
        System.out.println(BookList.next());
    }
}
}

public class BookList<Book> implements Iterable<Book> {
    
    //In this case, Iterator function needs to be override
    public Iterator<Book> Iterator() {....}

public static void main(String[] args) {

}
    for(Book book : bookList) {
        System.out.println(book);
    }
}
可见 Iterable 是基于 Iterator 实现的。

Comparable and Comparator

//public int compareTo(T y) ;
x.compareTo(y);

public int compare(T x, T y);
comp.compare(x, y);
两者的返回值意义相同:

实现上:

public class HDTV implements Comparable<HDTV>{
    ....
    public int compareTo(HDTV tv) {
        ....
    }
}

class SizeComparator implements Comparator<HDTV> {
    public int compare(HDTV tv1, HDTV tv2) {
        //....
    }
}

  • 进行对象比较的时候使用 compareTo,而不是关系运算符
  • compareTo() 的返回值通常是正 / 负 / 零,而不是 [-1,0,1]

Big-O Concepts

High Level Description

算法在高级层面上通过如下的基础操作(primitive instruactions)进行衡量:

这些基础操作的共同点是,都能在常数时间内完成。因此,统计他们的总次数,就能直观的反应算法的复杂度。

以函数的形式来衡量效率
Big-O notation & Common Complexities

Big-O 在本课中代表 tightest upperbound。

其他符号

Conventions

常数因子 & low order term 在很多特定的条件下是需要考虑的(比如限定了输入次数的场景)。

ArrayLists and Recursion

Arrays

为什么访问 array 元素的复杂度是 $O(1)$?

  • 存储空间是连续的
  • Array 会存储其内存入口地址(首元素 ,index 0)
  • Array 的长度是确定的

因此,只需要三次次算术运算 (new_address = start_address+i * data_size) 即可获取当前访问元素的地址;所以复杂度为 $O(1)$

Abstract Data Types(ADT)

ArrayLists

ArrayLIst 的定义

ArrayList<String> list = new ArrayList<>();

Size 和 Capacity
一些要求
Add to the Back

由于 resize 不是经常发生的,因此 add to the back 的复杂度可以按照 amortized analysis 进行分析;也就是将 resize 的 $O(n)$ 视作多次 $O(1)$ 的操作。因此,可以说 add to the back 的效率为 $\texttt{amortized}(O(1))$ 。

Adding elsewhere
RemoveAtIndex

Amortized Analysis

Amortized Analysis 是一种关注于平均计算成本的分析方法。通常的方法是以操作为单位来衡量计算成本,但有的时候某些高成本的操作出现的几率并不是很大,因此这种情况下使用此类操作来作为 worst case 讨论该算法的意义并不是很大。

以之前的 addToTheBack 为例,通常操作的复杂度为 $O(1)$,而 resize 时的复杂度为 $O(n)$;由于 resize 并不是很常见,那么以 $O(n)$ 来衡量 addTOTheBack 的复杂度显然不太精确。这种情况下,我们可以采用 Amortized Analysis 的方法来进行分析

</WRAP>

Soft v. Hard Removals

除非特别必要,本课程中均为 hard removeal。

Recursion

函数调用自身的方式被称为 Recursion。

三大要素
基本结构

if (baseCase) {
   //termination
}
else {
    // recursive call logic
}

SubProblem

递归的一大重点就是将问题分解为子问题。一些策略:

最大公约数问题(The greatest common denominator problem)

使用递归形式的辗转相除法Euclidean algorithm):

LinkedLists

链表的成员

必要的指针
计数器
Node class

链表的遍历

链表的遍历基于 headcurrent

while (current != null) {
    current = current.next;
}

链表的方法

AddingToFront

算法步骤:

  1. 创建需要插入的节点
  2. 将创建的节点的 next 指向 head 指向的内容
  3. head 原有的指向变更为被创建的节点

边界条件:
无。

public void addingToFront(T data) 
{   
    Node<T> newNode = new Node<>(data);
    newNode.next = head;
    head = newNode;
}

AddingToBack

算法步骤:

  1. 判断 list 是否为空。如果为空,直接创建节点
  2. 否则,遍历到 List 的最后一个元素(while(current.next != null)
  3. 循环结束后,创建节点,并将最后一个元素的 next 指向新创建的节点

边界条件:

public void addingToBack(T data)
{
    if(head == null) {
        head = new Node<T> (data);
    } 
    else {
        Node<T> current = head;
        while(current.next != null) {
            current = current.next;
        }
        current.next = new Node<T> (data);
    }
}

注意循环条件是 while(current.next != null) 而不是 while(current == null)

  • 前者保证的是 current 在循环之后还是有效的,可以被操作的节点。循环完毕后,current 指向尾节点。
  • 后者保证的是 current 在循环是有效的,可以被操作的节点。循环完毕后,current 不指向任何有效的节点。

因为 addingToBack() 的添加元素实现要求 current 在循环后有效,因此使用 current.next 作为判定条件。

RemoveingFromFront

算法步骤:

  1. 如果链表为空,返回 null
  2. head 重新指向第二个节点即可

边界条件:

public void removingFromFront()
{
    if(head == null) {
        return;
    }
    else {
        head = head.next;
    }
}

返回 null 使用 return; 语句

RemoveingFromBack

算法步骤:

  1. 如果链表为空,返回 null
  2. 如果链表只有一个节点,删除节点后 head = null
  3. 否则,遍历链表到倒数第二个节点
    1. 将倒数第二个节点的 next 指向设置为 null

边界条件:

public void removingFromBack()
{
    if(head == null) {
        return;
    }
    else if (head.next == null) {
        head = null;
    }
    else {
        Node<T> current = head;
        while(current.next.next != null) {
            current = current.next;
        }
        current.next = null;
    }
}

while(current.next.next != null) 确保循环结束后,current 指向倒数第二个节点。因为删除最后一个节点需要对倒数第二个节点进行操作。

Optimization

Tail reference

LinkedLists in Code

Iterable in LinkedLists

结构如下:

Recursion in LinkedLists

问题描述

给定有序链表,以递归的方式移除列表中所有重复的元素。

相关思考

递归的关键点就是将大问题分解为性质相同但规模更小的子问题。这个问题中,判断是否重复的行为最终都会转变为两个对象之间的比较。那么在递归的设计上,主要需要实现两点:

实现思路

首先应该对递归的状态进行建模。具体的问题就是:

对于 list 的来说,子问题通常来说就是其 sublist。本例中的数据结构是链表,那么不难想到的是:

根据这个思路,再来考虑一下递归的输入和输出。sublist 是通过移除重复的对象来进行缩减的,而重复的判定是通过相邻的两个对象比较来进行的。如果希望递归完成这个过程,那么递归的参数和返回值都应该是进行比较的对象:

按照这个思路往下看,base case 也可以明确了。当没有东西可以比的时候,也就是参数值为 null 时,就是我们的递归最终开始返回的时候。联系到之前,我们讨论过缩减规模的方向是从链表的尾部开始,因此有两个状态需要讨论:

当这一层返回本层对象时,由于下一层的栈中已经有其对应对象在等待了,因此可以进行正常的比较了。因此,我们的比较逻辑可以确认为:

实现

public void removeDups() {
    head = rRemove(head);
}

private Node<T> rRemove(Node<T> curr) {
    if(curr == null) {
        return null;
    }
    else {
        curr.next = rRemove(curr.next);
        if(curr.next != null && curr.data.equals(curr.next.data)) {
            return curr.next;
        }
        return curr;
    }
}
以一个具体的链表 [233] 来进行栈跟踪:

  1. 首先通过 head = rRemove(head) 开始调用。此时的 currhead 处,值是 2
    1. 此时获取相邻的元素进行比较:curr.next = rRemove(curr.next);由于 curr 不为 null,因此会进行下一次的 rRemove 调用。
  2. 第二次调用,当前栈中的 curr ,也就是上一层的 curr.next,为 3(),依然不为 null,因此继续展开
  3. 第三次调用,当前栈中的 curr3,依然不是 null,因此展开
  4. 第四次调用,此时 curr 的值已经为 null3 已经是链表的最后一位)。至此整个递归调用拿到了第一个返回值,开始向下返回。
  5. 第二次返回,此时 curr 是之前的 3(1st, 逆序)。因为 3(1st) 是链表最后一位,因此 curr.next == null,因此返回值为本层的 curr,也就是 3(1st)
  6. 第三次返回,此时 curr3(2rd)。将其与返回的 3(1st) 比较,相等,因此返回 3(2rd.)curr.next,也就是 3(1st)
  7. 第四次返回,此时 curr2。将其与返回的 3(1st) 比较,不等,因此返回 2。至此栈清空,递归结束。

值得注意的是,在比较的过程中,我们通过 curr.next = return value 将符合条件的返回值串联起来的方式生成了结果链表。这种使用返回值生成结果链表的方法被称为 Pointer reinforcement。其主要的特点:

  • 使用返回值构建结果链表
  • 通过返回值来决定下一个节点的内容,实现了节点挑选逻辑和节点设定的分离

Doubly-Linked Lists (DLL)

Doubly-Linked Lists(双链表)在单链表的基础上添加了一个 previous 指针,用于指向之前的节点。双链表要求同时维护 headtail

AddingToFront(DLL)

算法步骤:

  1. 创建新的节点
  2. 指向下个节点:将新节点的 next 指针指向 head
  3. 指向上个节点:将 head 所在节点的 previous 指针指向新节点
  4. 重置 head:将 head 指向新节点

边界条件:

AddingToBack(DLL)

算法步骤:

  1. 创建新的节点
  2. 指向上个节点:将新节点的 previous 指针指向 tail 所在节点
  3. 指向下个节点:将 tail 所在节点的 next 指向新节点
  4. 重置 tail:将 tail 指向新节点

边界条件:

RemoveFromFront(DLL)

算法步骤:

  1. 重置 head:将 head 指向 head.next 指向的节点(第二个节点)
  2. head 的前置信息清空:将 head.prev 指向设置为 null(第二个节点的 prev

边界条件:

RemoveFromBack(DLL)

算法步骤:

  1. 重置 tail:将 tail 指向 tail.previous 指向的节点(倒数第二个节点)
  2. 指向下个节点:将倒数第二个节点的 next 指向设置为 null(也就是当前的 tail.next

边界条件:

RemoveFromMiddle(DLL)

该算法基于 index 移除内容。

算法步骤:

  1. 判断删除节点的位置否位于首尾 (0,或者 size-1)
    1. 如果是 0,删除节点为首节点,调用 removeToFront
    2. 如果是 size-1,删除节点为尾节点,调用 removesToBack
  2. 如果都不是,那么添加位置处于链表中部。此时需要遍历到被移除节点的前置节点。根据 index 的位置靠前还是靠后,有两种选择:
    1. 如果被移除节点位置靠前 (index < size / 2),那么从链表前端,也就是 head 开始遍历。遍历结束的位置为被移除节点的前一个节点
    2. 否则,被移除节点位置靠后,那么从 tail 开始遍历,遍历结束的位置为被移除节点的后一个节点
    3. 遍历使用 index 作为循环条件:$[0, index)$ 和 $[size-1, index)$
  3. 缓存被移除的数据
  4. 更新链接。以被移除节点为中心:
    1. 其前节点的 next 指向其后节点
    2. 其后节点的 previous 指向其前节点
  5. 维护 size,返回被移除的数据

Circularly-Linked Lists (CLL)

Circularly-Linked Lists (CLL) (环链表) 指尾部节点的 next 指向 head 的链表。

addToFront(O(n))

算法步骤:

  1. 创建新节点
  2. 指向下个节点:将新节点的 next 指向 head 所在节点
  3. 重置 head:将 head 指向新节点
  4. 重置指向 head 的尾节点:需要再进行一次遍历。更新尾部节点的 next,使其指向更新后的 head($O(n)$)
addToFront(O(1))

算法步骤:

  1. 创建新节点:创建一个空的新节点
  2. 插入节点:将新节点插入到 head 之后(index 1)
    1. 将新节点的 next 指向原来的第二个节点
    2. headnext 重定向到新节点
  3. 转移数据:将 head 内的内容拷贝到新节点中
  4. 添加数据:将新的数据写入到 head 对应的节点中
addToBack

算法步骤:

  1. 创建新节点:创建一个空的新节点
  2. 插入节点:将新节点插入到 head 之后(index 1)
  3. 转移数据:将 head 内的内容拷贝到新节点中
  4. 添加数据:将新的数据写入到 head 对应的节点中
  5. 重置 head:将新节点作为新的 head,这样一来原有的 head 就成为了尾部节点。
RemovingFromFront

与单链表不同,环链表中的移除的主要思路是从内容上替代节点。整体的逻辑可以描述为:

算法步骤:

  1. 转移数据:将第二个节点的内容拷贝到 head 节点中。此处不需要考虑数据覆盖的问题,因为 head 节点需要被移除。
  2. 指向下个节点:将 headnext 指向从第二个节点改变为第三个节点。

边界条件:

RemovingFromBack

算法步骤:

  1. 定位新的尾部节点:遍历到倒数第二个节点
  2. 更新链接:将倒数第二个节点的 next 指向 head
RemovingFromMiddle

算法步骤:

  1. 遍历到需要被移除节点的前一个节点
  2. 将该节点的 next 指向被移除节点的 next

Stacks and Queues

Linear Abstract Data Type

Stacks 的原理

Stack(栈) 是 ADT 一种,通常使用 Array 或者 SLL 实现。名如其意, Stack 就是一种看上去是堆起来的数据结构(比如薯片筒就是一种 stack)。因此,Stack 通常都包含了两种基本操作:

值得注意的是,根据 Stack 的概念,无论是添加还是移除,我们处理的对象都只能是栈顶的对象(最上面的薯片),这导致了一下的几个事实:

Stack 的几个基础方法
Stack 的一些应用

Stack 的实现(链表)

链表是实现 Stack 最高效的手段,所有的操作复杂度都是 $O(1)$。一些实现细节:

Push

使用链表实现时,head 总是代表 top。因此每 push 一个对象,head 指向的节点就会更新到最新推送的对象。 算法步骤:

  1. 建立新(对象)节点
  2. 将新节点的 next 指向原来的 head 所代表的节点
  3. 更新 head ,将其指向新的节点

Pop

算法步骤:

  1. head 指向下一个节点即可。

边界条件:

Stack 的实现(数组)

实现细节:

Queues 的原理

Queues队列) 的结构类似于一个两边都开口的薯片桶;一边是入口,另一边是出口。Queues 遵循 FIFO (First in First Out 先进先出) 原则。与 Stack 类似,Queues 不支持任何的随机访问和操作。

Queues 的基础方法

Queues 的实现(链表)

enqueue(SLL)

算法步骤:

  1. 同链表的 addToBack()
dequeue(SLL)

算法步骤:

  1. 同链表的 removeFromFront()

Queues 的实现(数组)

与链表数列相似,Queue 的数组实现主要依靠的是一前以后的两个指针 front & back 来体现出“队列”的概念。Queue 可以被视作存储于数组中的,以 Back 开头,以 Front 结尾的队列。需要注意的是,对效率的考虑给数组数列的组成方式带来了很大的影响:

Front & Back

由于 Array 的 AddToFront() 操作必须要求 Shifting 移位,因此数组队列的 enqueue 是在数组中对象序列的后方,而 dequeue 是在对象序列的前方。那么通常 back 会被视作是队列的入口,而 front 会被视作是队列的出口。

数组与循环机制

我们之前的设想是将队列视作存储在数组中的,有头有尾的一个对象序列。这意味着其占用数组的位置只与数组的 capcity 有关系,而与数组的 Index 没有关系。因此,我们很可能会遭遇如下的存储分布:



无论是哪种,我们都没有办法用 ArrayList 来实现。因为 ArrayList 要求首元素不能为空,且必须连续存储。因此,数组队列只能使用 Array 来实现。

再观察一下第二种情况。以图为例,这种例子出现的情况是:数列 [6,3,11] 存储在数组的最后三位。假设我们此时希望对该数列进行 enqueue 的操作;此时 capcity 为 7,数列长度为 3,即便添加一个元素也没有超过数组的容量。但由于存储的位置处于数组的末端,我们没有办法再往里插入新的元素了。

为了解决这个问题,数组数列的实现引入了循环(WrapAround)的机制。如果 enqueue 的位置超出了数组的上限,但数组本身还有空可以做 enqueue 的话,我们就将 enqueue 的位置(back 的位置)往数组前方的空闲位置插入。具体的计算方式为:
$$ \texttt{back = Index mod Capacity} $$ 以上图为例,我们原来希望插入的位置是 index = 7(当然没有 7),通过上述转换之后就是在 index = 0 处 enqueue 了。

back(起点) 并不是必须的。知道 front(终点)和 size 长度,就可以计算出起点。

enqueue

Priority queues

Priority queues 是另外一种线性的 ADT。其特点是,当前移除的对象是优先级最高的对象 。这种 ADT 通常用于搜寻最大最小值。 由于有大小的存在,Priority queues 中的对象必须是 Comparable 的。

功能与效率性

Priority queues 会分为两个区:一个 Priority 区和一个备选区。Priority 区中存放优先级最高的数据用于操作。当 Priority 区中的数据移除后,备选区需要通过另一轮筛选选出下一个优先级最高的数据推送到 Priority 区中,以便接下来的操作。

可以注意到,每处理一轮 Priority 区中的数据,备选区中的数据就需要排序一次,为了更好的效率,具体实现是基于 heap 的。

Priority queues 是线性类型的 ADT,但使用线性数据结构实现确十分低效。不论使用数组还是链表,我们都需要Priority queue 是一个有序数列。这意味着,每次 enqueue 需要进行一次排序(链表需要遍历到插入新元素的位置),也就是每次 enqueue 的复杂度都在 $O(n)$。这样是非常不效率的。

Deques

Deques,指 Double-ended queueDeque 允许在队列的两端添加和删除元素。Deque 不支持随机访问(添加删除)以及搜寻操作。

Deque 的方法

Deque 的实现(数组)

Deque 的数组实现同样需要使用到循环的方式。

addLast() & removeFirst()
addFirst() & removeLast()

这两个方法也需要通过循环的方式进行位置的维护。但由于其进出方向与之前的两个方法完全相反,如果使用同样的 back & front 指针,那么指针移动的操作是减而不是加。这意味着指针指向的 Index 很可能会变为负数,也就是会出现向前循环的情况。这种情况下,我们需要进行手动判断并做出以下处理:

//this applied to back as well
if (front < 0) {
    front  = capacity - front;
}

所有的操作(除开 resize)都是 $O(1)$,总的说来是 $O(1)^*$

Java 的 modulo 操作

通常情况下,末除是一个分类的过程。所有余数相同的数字都会被放入到一个类中。在这个类中,最小的非负的成员会成为余数。这导致了负数做末除时存在着不同。比如 $9 % 5 = 4$;但进行 $-9 % 5$ 时,因为要保证余数非负,取余的方向实际上需要更往负数方向上进行。比如这里,如果之间按正数的取余方式 $-9$ 去除 $(-1)\times5$ 还有 $-4$,但由于需要余数为正,因此需要再去除一个 $(-1)\times5$。因此余数是 $1$。

但在 Java 中,余数的结果并不要求为正,而是最接近 0 的数。这种情况是我们之前讨论的第一种情况:9 % -5 = -4

Deque 的实现(链表)

Deque 的实现需要使用双链表(DLL):

由于使用双链表,所有的操作都是 ($O(1)$)