======ArrayLists, LinkedLists, Stacks and Queues====== //Gtx CS1332x I Notes// ---- ====Introduction==== ===Value/Reference Equality=== * Reference Equality: 查看两个对象是否为同一个对象(处于同一片内存区域),使用 ''=='' 检测 * Value Equality:按照用于自定义的规则来判断两者是否等价。使用自定义的,重写自 ''Object'' 类的 ''equals()'' 方法来判断。 ==等价对象的的区别== 上面两种比较的主要应用发生在 Primitives type 与 Object type 之间。 * primitive 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== * Object 是否为 ''null'' 使用 ''=='' 来确定(null & 非 null 对象均可使用) String nullObject = null; nullObject == null; // => true * 如果对象为 ''null'',调用 ''equal()'' 会导致 //NPE //(NullPointerException )异常 * 非 ''null'' 对象可以调用 ''equal()'' 与 null / null 对象比较 String normalObject = "normal"; normalObject.equals(null); // => false nullObject.equals(normalObject); // causes NullPointerException ==Wrapper Objects== * 某些场景需要使用 primtive type 对应的对象。这类对象被称为 //Wrapper Objects// * Java 会通过 autoBoxing / unboxing 自动进行转换 Wrapper Objects的比较 与 String Literal 类似,也分值等价和引用等价两种情况: * Literal 的比较是值的比较 Integer primitive = 1; Integer object = new Integer(1); primitive == 1; // => true object == 1; // => true * Object 的比较是引用的比较 primitive == object; // => false primitive.equals(object); // => true object.equals(primitive); // => true ===Pass by Value/Reference=== * Pass By Value:传递的是值,更改 parameter 不影响 argument * Pass By Reference:传递的是引用,更改 parameter 影响 argument * Java 是 passing by value language. * 传递 Object 的时候,实际上是值传递;但传递的是 Object 所在地址的拷贝。因此可以通过该拷贝去更改原有 Object 的内容,这就是所谓 Java 中的 Pass by referenece with object。 ===Generics=== ==ClassCastException== 指在运行期,对象类型与生命类型不匹配。解决方式:使用带 type parameter 的特化版本: public class Container { private T t; public void set(T t) { this.t = t; } public T get() { return t; } } 特化版本会在编译器确定类型是否匹配。 ==Generics Syntax== * Declaring Generic Classes //def class ArrayList { ... } //declar ArrayList listOne = new ArrayList<>(); ==Generic Arrays== //declar T[] backingArray = (T[]) new Object[10]; ==Multiple Type Parameters== class HashMap { ... } HashMap map = new HashMap<>(); ==Generic Classes as Type Parameters== ArrayList> nodeList = new ArrayList<>(); ===Iterable and Comparable=== Docs:[[https://docs.oracle.com/javase/8/docs/api/java/lang/Iterable.html|Iterable]] | Docs:[[https://docs.oracle.com/javase/8/docs/api/java/util/Iterator.html|Iterator]] ==Iterable interface== Java 提供了 //Iterable// 接口;任何实现该接口的类都可以使用 for-each loop。使用需要导入 ''java.util.iterator''\\ \\ 实现上: * 需要重写 ''iterator()'' 方法,该方法会返回一个 iterator 对象。 //Iterator// 也可以单独使用。需要重写的方法: * 需要重写 ''next()'' 方法:返回当前 iterator 指向的对象,然后将 iterator 移动至下一个对象 * 需要重写 ''hasNext()'' 方法:当 iterator 指向的对象值不为 ''null'' 时,返回 ''true'' * 可选重写 ''remove()'' 方法:移除 ''next()'' 返回的对象 两种简单的示例如下: * 显式的使用 //Iterator//: import java.util.Iterator; public class BookList implements Iterator { //override public void next() {} public boolean hasNext() {} public static void main(String[] args) { //usage suppose bookList is an object with type BookList while(bookList.hasNext()) { System.out.println(BookList.next()); } } } * 使用 //Iterable//: public class BookList implements Iterable { //In this case, Iterator function needs to be override public Iterator Iterator() {....} public static void main(String[] args) { } for(Book book : bookList) { System.out.println(book); } } 可见 //Iterable// 是基于 //Iterator// 实现的。 ==Comparable and Comparator== * 两者都是独立实现的,并不互相依赖 * ''Comparable'' 允许直接将自身与目标进行比较(使用 ''comareTo'' 方法,单参数) * 允许实现类基于返回值定义 //Nature Ordering//(**升序**) //public int compareTo(T y) ; x.compareTo(y); * ''Comparator'' 并不比较自身与目标,而是比较两个参数(使用 ''compare()'' 方法,双参数) * 允许实现类基于返回值定义 //Ordering//。这个顺序可以是基于任何值自定义的,与 //Nature Ordering// 有区别: * 比如基于学生的年龄进行降序排列 * 比如基于学生专业的第一个字母进行基于 alphabet 的顺序排列 public int compare(T x, T y); comp.compare(x, y); 两者的返回值意义相同: * 负数:x < y * 0:x = y * 正数:x > y 实现上: * ''Comparable'' 的实现需要实现 ''Comparable'' 接口,并重写 ''compareTo'' 方法 * ''Comparable'' 已经包含在 ''java.uitl'' 中,因此不需要任何导入 public class HDTV implements Comparable{ .... public int compareTo(HDTV tv) { .... } } * ''Comparator'' 的实现需要导入 ''java.uitl.Comarator'' * ''Comparator'' 需要实现的接口是 ''Comparator'',需要重写 ''compare()'',接收的是两个参数 class SizeComparator implements Comparator { public int compare(HDTV tv1, HDTV tv2) { //.... } } * 进行对象比较的时候使用 ''compareTo'',而不是关系运算符 * ''compareTo()'' 的返回值通常是正 / 负 / 零,而不是 [-1,0,1] ===Big-O Concepts=== * 算法&数据结构的效率通过时间和空间两个方面进行衡量 * 衡量是独立的,与平台无关。 * 衡量只研究输入规模变更时,算法的变化 ==High Level Description== 算法在高级层面上通过如下的基础操作(//primitive instruactions//)进行衡量: * 赋值 * 算术运算 * 比较两个实体 * 函数的调用、函数的返回 * 访问、引用数据结构 这些基础操作的共同点是,都能在**常数时间**内完成。因此,统计他们的总次数,就能直观的反应算法的复杂度。 ==以函数的形式来衡量效率== * $f(n)$ 代表了在输入大小为 $n$ 时,**基础操作的总数** * 三种要考虑的情况: * worst case (本课主要的讨论点) * best case * Avergae case ==Big-O notation & Common Complexities== // // {{ :cs:dsa:courses:gtx_1332x:i_lists:complecity.jpg |}} Big-O 在本课中代表 tightest upperbound。 ==其他符号== * $o(f(n))$:long-term performance is **significantly** upperbounded by f(n)。主要强调的是算法复杂度增长的很慢。 * $\Omega(f(n))$:$f(n)$ 被当做了衡量解决一个问题的难度;也就是告诉我们,解决该问题付出什么样的成本是合理的。 * $\Theta(f(n))$:$f(n)$ 同时作为 upperbound 和 lowerbound。不是所有的算法都有该复杂度,因为该复杂度需要算法足够稳定。 ===Conventions=== * 常量因子会被省略:比如 $O(5n)$ = $O(n)$ * 非 domiate 的项会被省略:比如 $O(n^2+100n-3) = O(n^2)$ * $log(n)$ 的 base 通常是 $2$ 常数因子 & low order term 在很多特定的条件下是需要考虑的(比如限定了输入次数的场景)。 ====ArrayLists and Recursion==== ===Arrays=== * 在内存中连续存储 * 通过 index 访问元素,访问的复杂度是 $O(1)$ * 内存是静态分配的,如果超出了需要申请新的,更大的连续空间,然后将元素全部拷贝过去。拷贝的复杂度是 $O(n)$ 为什么访问 array 元素的复杂度是 $O(1)$? * 存储空间是连续的 * Array 会存储其内存入口地址(首元素 ,index 0) * Array 的长度是确定的 因此,只需要三次次算术运算 (new_address = start_address+i * data_size) 即可获取当前访问元素的地址;所以复杂度为 $O(1)$ ==Abstract Data Types(ADT)== * 抽象数据类型(//ADT//)是一种描述数据类型的模型,通过其行为和操作定义。在 Java 中,//ADT// 与 Interface 类似(有定义,但没有具体的实现)。 * //ADT// 避免了在讨论需求的时候过多的陷入细节 * 不涉及算法的具体实现和效率(数据结构才做这些讨论) * 数据处理的具体实现被称为数据结构 ===ArrayLists=== * Generic type(只支持 object 类型);使用时需要指定对象类型(可以被视作是 array 的 wrapper) * 基于 Array 实现的 List,会自动 ''resize'',空间增长会申请全新的空间,并将旧元素拷贝过来 * 数据是连续存储的,初始大小为 ''10'' * 定义于 ''java.util'' ==ArrayLIst 的定义== ArrayList list = new ArrayList<>(); ==Size 和 Capacity== * Size 指 ArrayList 中**已经**存在的元素数量 * Capacity 指 ArrayList 中可以存放的元素**上限** ==一些要求== * 存储是连续性的(中间有 ''null'' 是不行的) * zero-aligned(必须存在首元素) ==Add to the Back== * $O(1)$ * 如果要求 resize,则是 $O(n)$ 由于 resize 不是经常发生的,因此 add to the back 的复杂度可以按照 //amortized analysis// 进行分析;也就是将 resize 的 $O(n)$ 视作多次 $O(1)$ 的操作。因此,可以说 add to the back 的效率为 $\texttt{amortized}(O(1))$ 。 ==Adding elsewhere== * $O(n)$:需要移动元素的同时保证已有元素内容不被覆盖。 ==RemoveAtIndex== * remove from the back: $O(1)$ * remove from elsewhere $O(n)$ ===Amortized Analysis=== //Amortized Analysis// 是一种关注于**平均**计算成本的分析方法。通常的方法是以操作为单位来衡量计算成本,但有的时候某些高成本的操作出现的几率并不是很大,因此这种情况下使用此类操作来作为 worst case 讨论该算法的意义并不是很大。 \\ \\ 以之前的 addToTheBack 为例,通常操作的复杂度为 $O(1)$,而 resize 时的复杂度为 $O(n)$;由于 resize 并不是很常见,那么以 $O(n)$ 来衡量 addTOTheBack 的复杂度显然不太精确。这种情况下,我们可以采用 //Amortized Analysis// 的方法来进行分析 * 首先需要分析 worst case 的情况分哪几种:\\ * 如果没有 resize,每次 addToTheBack 只有两次操作:添加元素和增加 size 的计数。 那么 addToTheBack 的复杂度为: $$\texttt{Amortized Cost} = \frac{\texttt{Total cost of all operations}}{\texttt{times of operations}} = \frac{2n}{n} = 2 = O(1).$$ * 如果发生了 resize:如果存在 $n$ 个元素,那么 resize 之后的 capacity 是 $2n$,那么对于每个元素来说,包含了 4次操作: * resize 有两次操作:访问元素,将元素拷贝到新的 arrayList * addToTheBack 有两次操作:添加元素,size 计数加一 * 因此对于 $n$ 次操作,其 //Amortized Cost// 的计算方式为:$$\begin{align*} \texttt{Amortized Cost} &= \frac{\texttt{Total cost of all operations}}{\texttt{times of operations}} \\ &= \frac{\texttt{Resize Cost} + \texttt{Normal Operation Cost}}{n} \\ &= \frac{2n + 2n}{n} = 4 = O(1). \end{align*}$$ 以上两种复杂度都可以用于描述 addToTheBack 。如果需要强调该算法是基于 //Amortized Analysis// ,通常使用 $O(1)^*$ 来标注。 ==Soft v. Hard Removals== * Soft Removals: 被删除的数据会被保留在数据结构中 * Hard Removals:被删除的数据会被完全移除出数据结构,并在必要的时候手动设置 ''null'' 作为代替 除非特别必要,本课程中均为 hard removeal。 ===Recursion=== 函数调用自身的方式被称为 Recursion。 ==三大要素== * Base Case:必须至少有一个,作为递归终结的条件。 * Recursive Case:用于调用自身。当递归调用发生的时候,当前层的递归会暂停,知道调用返回结果以后才会继续运行。 * Progress to Base Case:所有的递归调用必须都往终结条件方向前景。 ==基本结构== if (baseCase) { //termination } else { // recursive call logic } ==SubProblem== 递归的一大重点就是将问题分解为子问题。一些策略: * 先写出 base case, 再写出其他的递归方法 * 避免 base case 重复 * 将程序清晰的分解为不同的 case ==最大公约数问题(The greatest common denominator problem)== 使用递归形式的**辗转相除法**(//Euclidean algorithm//): * Base case: 余数 ''x mod y== 0'' * Recursive case: ''gcd(x,y) = gcd(y, x mod y)'' ====LinkedLists==== * 链表(//LinkedLists// )由一系列的节点(//Node//)组成。数据存放在节点中。 * 链表中,前后节点通过指针链接。 * 链表不需要连续的存储空间。 ===链表的成员=== ==必要的指针== * ''head'':指向链表的头元素 * ''current'':在遍历中,指向当前元素 * (可选)''tail'':指向链表的尾部元素 ==计数器== * ''size'':记录链表的长度 * 添加元素的时候 ''size'' 加一 * 删除元素的时候 ''size'' 减一 ==Node class== * 成员:数据 ''T data'',指向下个节点的指针 ''Node next'' * 方法:构造函数 ===链表的遍历=== 链表的遍历基于 ''head'' 和 ''current'': * 起始条件(位置)为 ''current = head'' * 结束条件(位置)为 ''current == null'' * 循环条件:当当前节点的下一个节点不为空时,将下一个节点作为当前节点。 while (current != null) { current = current.next; } ===链表的方法=== ==AddingToFront== **//算法步骤://** - 创建需要插入的节点 - 将创建的节点的 ''next'' 指向 ''head'' 指向的内容 - 将 ''head'' 原有的指向变更为被创建的节点 **//边界条件://** \\ 无。 {{ :cs:dsa:courses:gtx_1332x:i_lists:link_list_addtofront.svg?300 |}} public void addingToFront(T data) { Node newNode = new Node<>(data); newNode.next = head; head = newNode; } ==AddingToBack== **//算法步骤://** - 判断 list 是否为空。如果为空,直接创建节点 - 否则,遍历到 List 的最后一个元素(''while(current.next != null)'') - 循环结束后,创建节点,并将最后一个元素的 ''next'' 指向新创建的节点 **//边界条件://** * 链表为空的时候不需要遍历,直接将 ''head'' 指向新创建的节点即可。 {{ :cs:dsa:courses:gtx_1332x:i_lists:linked_list_addtoback.svg?350 |}} public void addingToBack(T data) { if(head == null) { head = new Node (data); } else { Node current = head; while(current.next != null) { current = current.next; } current.next = new Node (data); } } 注意循环条件是 ''while(current.next != null)'' 而不是 ''while(current == null)'': * 前者保证的是 ''current'' 在循环**之后**还是有效的,可以被操作的节点。循环完毕后,''current'' 指向尾节点。 * 后者保证的是 ''current'' 在循环**中**是有效的,可以被操作的节点。循环完毕后,''current'' 不指向任何有效的节点。 因为 //addingToBack()// 的添加元素实现要求 ''current'' 在循环后有效,因此使用 ''current.next'' 作为判定条件。 ==RemoveingFromFront== **//算法步骤://** - 如果链表为空,返回 ''null'' - 将 ''head'' 重新指向第二个节点即可 **//边界条件://** * 链表为空,返回 ''null'' {{ :cs:dsa:courses:gtx_1332x:i_lists:linked_list_removingfromfront.svg?300 |}} public void removingFromFront() { if(head == null) { return; } else { head = head.next; } } 返回 ''null'' 使用 ''return;'' 语句 ==RemoveingFromBack== **//算法步骤://** - 如果链表为空,返回 ''null'' - 如果链表只有一个节点,删除节点后 ''head = null'' - 否则,遍历链表到**倒数第二个节点** - 将倒数第二个节点的 ''next'' 指向设置为 ''null'' **//边界条件://** * 链表为空,返回 ''null'' * 链表只有一个节点,删除节点后 ''head = null'' {{ :cs:dsa:courses:gtx_1332x:i_lists:linked_list_remove_from_back.svg?300 |}} public void removingFromBack() { if(head == null) { return; } else if (head.next == null) { head = null; } else { Node current = head; while(current.next.next != null) { current = current.next; } current.next = null; } } ''while(current.next.next != null)'' 确保循环结束后,''current'' 指向倒数第二个节点。因为删除最后一个节点需要对倒数第二个节点进行操作。 ===Optimization== ==Tail reference== * ''tail'' 指针指向链表中最后一个节点。维护该指针可以在使用 //addingToBack()// 的时候避免重复遍历链表($O(n)$ to $O(1)$) * ''tail'' 指针对 //removeFromBack()// 没有任何优化。该方法需要访问倒数第二个节点,而不是最后一个节点。 * ''tail'' 指针使用的边界条件: * 当链表为空时:''head'' 和 ''tail'' 都应该为 ''null'' * 当链表只有一个节点时:''head'' 和 ''tail'' 都指向该节点。如果试图删掉该节点,那么 ''head'' 和 ''tail'' 都应该为 ''null'' ===LinkedLists in Code=== ==Iterable in LinkedLists== * 需要一个实现 ''Iterable'' 的类 * 需要一个实现 ''Iterator'' 的类,用于支持 ''Iterable'' 中的 ''Iterator()'' 方法的重写 * 在实现 ''Iterator'' 的时候需要重写: * ''next()'' * ''hasNext()'' 结构如下: {{ :cs:dsa:courses:gtx_1332x:i_lists:linke_list_iterable.svg?200 |}} {{ :cs:dsa:courses:gtx_1332x:i_lists:iterlinkedlist.java |Demo Code}} ==Recursion in LinkedLists== //**问题描述**://\\ \\ 给定有序链表,以递归的方式移除列表中所有重复的元素。\\ \\ //**相关思考**://\\ \\ 递归的关键点就是将大问题分解为性质相同但规模更小的子问题。这个问题中,判断是否重复的行为最终都会转变为两个对象之间的比较。那么在递归的设计上,主要需要实现两点: * 如何通过递归的方式来进行比较 * 比较的逻辑 //**实现思路**://\\ \\ 首先应该对递归的状态进行建模。具体的问题就是: * 递归的输入是什么?输出是什么? * 递归是如何通过输入输出来缩小目标问题的规模的? 对于 list 的来说,子问题通常来说就是其 sublist。本例中的数据结构是链表,那么不难想到的是: * 本题的 sublist 都应该是与目标链表 ''head'' 相同,但长度不同的子链表 * 缩减链表的方向因该是**自尾部向头部进行** 根据这个思路,再来考虑一下递归的输入和输出。sublist 是通过移除重复的对象来进行缩减的,而重复的判定是通过**相邻**的两个对象比较来进行的。如果希望递归完成这个过程,那么递归的参数和返回值都应该是进行比较的对象: * 接收的参数参与当前的比较 * 返回值作为下一轮的值进行比较 按照这个思路往下看,base case 也可以明确了。当没有东西可以比的时候,也就是参数值为 ''null'' 时,就是我们的递归最终开始返回的时候。联系到之前,我们讨论过缩减规模的方向是从链表的尾部开始,因此有两个状态需要讨论: * 当参数值为 ''null'',也就是当前比较位置已经越过链表最后一位时,此时没有办法比较,直接返回 ''null'' * 此时位置位于链表最后一位的栈接收到了 ''null'',但 ''null'' 依然没法与当前元素比较,因此只能返回当前元素 ''curr'' 当这一层返回本层对象时,由于下一层的栈中已经有其对应对象在等待了,因此可以进行正常的比较了。因此,我们的比较逻辑可以确认为: * 如果参数对象的值为 ''null'',则返回 ''null'' * 如果函数返回值为 ''null'',则返回当前对象 * 否则,则判断当层栈中对象与返回的对象是否相等。如果相等,继续返回函数的返回值;否则则返回当层栈中的对象。 //**实现**://\\ \\ * wrapper 函数:公共接口,封装使用。 public void removeDups() { head = rRemove(head); } * 核心函数 private Node rRemove(Node 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]'' 来进行栈跟踪: \\ \\ {{ :cs:dsa:courses:gtx_1332x:i_lists:linked_list_rec.svg?600 |}} - 首先通过 ''head = rRemove(head)'' 开始调用。此时的 ''curr'' 在 ''head'' 处,值是 ''2'' - 此时获取相邻的元素进行比较:''curr.next = rRemove(curr.next)'';由于 ''curr'' 不为 ''null'',因此会进行下一次的 ''rRemove'' 调用。 - 第二次调用,当前栈中的 ''curr'' ,也就是上一层的 ''curr.next'',为 ''3()'',依然不为 ''null'',因此继续展开 - 第三次调用,当前栈中的 ''curr'' 为 ''3'',依然不是 ''null'',因此展开 - 第四次调用,此时 ''curr'' 的值已经为 ''null'' (''3'' 已经是链表的最后一位)。至此整个递归调用拿到了第一个返回值,开始向下返回。 - 第二次返回,此时 ''curr'' 是之前的 ''3''(1st, 逆序)。因为 ''3(1st)'' 是链表最后一位,因此 ''curr.next == null'',因此返回值为本层的 ''curr'',也就是 ''3(1st)''。 - 第三次返回,此时 ''curr'' 是 ''3(2rd)''。将其与返回的 ''3(1st)'' 比较,相等,因此返回 ''3(2rd.)'' 的 ''curr.next'',也就是 ''3(1st)'' 。 - 第四次返回,此时 ''curr'' 是 ''2''。将其与返回的 ''3(1st)'' 比较,不等,因此返回 ''2''。至此栈清空,递归结束。 值得注意的是,在比较的过程中,我们通过 ''curr.next = return value'' 将符合条件的返回值串联起来的方式生成了结果链表。这种使用返回值生成结果链表的方法被称为 //Pointer reinforcement//。其主要的特点: * 使用返回值构建结果链表 * 通过返回值来决定下一个节点的内容,实现了节点挑选逻辑和节点设定的分离 {{ :cs:dsa:courses:gtx_1332x:i_lists:recursivelinkedlist.java |Demo Code}} ===Doubly-Linked Lists (DLL)=== {{ :cs:dsa:courses:gtx_1332x:i_lists:doublylinkedlist.java |DoublyLinkedList Demo}} //Doubly-Linked Lists//(双链表)在单链表的基础上添加了一个 ''previous'' 指针,用于指向之前的节点。双链表要求同时维护 ''head'' 与 ''tail''。 ==AddingToFront(DLL)== **//算法步骤://** - 创建新的节点 - 指向下个节点:将新节点的 ''next'' 指针指向 ''head'' - 指向上个节点:将 ''head'' 所在节点的 ''previous'' 指针指向新节点 - 重置 ''head'':将 ''head'' 指向新节点 **//边界条件://** * 起始为空链表时,''head'' 与 ''tail'' 都需要指向新创建的节点 {{ :cs:dsa:courses:gtx_1332x:i_lists:doubly_linked_list_add_front.svg?350 |}} ==AddingToBack(DLL)== **//算法步骤://** - 创建新的节点 - 指向上个节点:将新节点的 ''previous'' 指针指向 ''tail'' 所在节点 - 指向下个节点:将 ''tail'' 所在节点的 ''next'' 指向新节点 - 重置 ''tail'':将 ''tail'' 指向新节点 **//边界条件://** * 起始为空链表时,''head'' 与 ''tail'' 都需要指向新创建的节点 {{ :cs:dsa:courses:gtx_1332x:i_lists:doubly_linked_list_add_to_back.svg?350 |}} ==RemoveFromFront(DLL)== **//算法步骤://** - 重置 ''head'':将 ''head'' 指向 ''head.next'' 指向的节点(第二个节点) - 将 ''head'' 的前置信息清空:将 ''head.prev'' 指向设置为 ''null''(第二个节点的 ''prev'') **//边界条件://** * 双链表长度为 ''1'' 时,移除节点需要 ''head'' 与 ''tail'' 同时被设置为 ''null'' {{ :cs:dsa:courses:gtx_1332x:i_lists:doubly_linked_list_remove_from_front.svg?350 |}} ==RemoveFromBack(DLL)== **//算法步骤://** - 重置 ''tail'':将 ''tail'' 指向 ''tail.previous'' 指向的节点(倒数第二个节点) - 指向下个节点:将倒数第二个节点的 ''next'' 指向设置为 ''null''(也就是当前的 ''tail.next'') **//边界条件://** * 双链表长度为 ''1'' 时,移除节点需要 ''head'' 与 ''tail'' 同时被设置为 ''null'' {{ :cs:dsa:courses:gtx_1332x:i_lists:doubly_linked_list_remove_from_back_.svg?350 |}} ==RemoveFromMiddle(DLL)== 该算法基于 index 移除内容。\\ \\ **//算法步骤://** - 判断删除节点的位置否位于首尾 (''0'',或者 ''size-1'') - 如果是 ''0'',删除节点为首节点,调用 ''removeToFront'' - 如果是 ''size-1'',删除节点为尾节点,调用 ''removesToBack'' - 如果都不是,那么添加位置处于链表中部。此时需要遍历到被移除节点的前置节点。根据 index 的位置靠前还是靠后,有两种选择: - 如果被移除节点位置靠前 (''index < size / 2''),那么从链表前端,也就是 ''head'' 开始遍历。遍历结束的位置为被移除节点的前一个节点 - 否则,被移除节点位置靠后,那么从 ''tail'' 开始遍历,遍历结束的位置为被移除节点的后一个节点 - 遍历使用 ''index'' 作为循环条件:$[0, index)$ 和 $[size-1, index)$ - 缓存被移除的数据 - 更新链接。以被移除节点为中心: - 其前节点的 ''next'' 指向其后节点 - 其后节点的 ''previous'' 指向其前节点 - 维护 ''size'',返回被移除的数据 ===Circularly-Linked Lists (CLL)=== //Circularly-Linked Lists (CLL)// (环链表) 指尾部节点的 ''next'' 指向 ''head'' 的链表。 * 通常不包含 ''tail'' * 循环终止的条件是 ''curr.next.equals(head)'' ==addToFront(O(n))== **//算法步骤://** - 创建新节点 - 指向下个节点:将新节点的 ''next'' 指向 ''head'' 所在节点 - 重置 ''head'':将 ''head'' 指向新节点 - 重置指向 ''head'' 的尾节点:需要再进行一次遍历。更新尾部节点的 ''next'',使其指向更新后的 ''head''($O(n)$) ==addToFront(O(1))== **//算法步骤://** - 创建新节点:创建一个**空的**新节点 - 插入节点:将新节点插入到 ''head'' 之后(index 1) - 将新节点的 ''next'' 指向原来的第二个节点 - 将 ''head'' 的 ''next'' 重定向到新节点 - 转移数据:将 ''head'' 内的内容拷贝到新节点中 - 添加数据:将新的数据写入到 ''head'' 对应的节点中 ==addToBack== **//算法步骤://** - 创建新节点:创建一个**空的**新节点 - 插入节点:将新节点插入到 ''head'' 之后(index 1) - 转移数据:将 ''head'' 内的内容拷贝到新节点中 - 添加数据:将新的数据写入到 ''head'' 对应的节点中 - 重置 ''head'':将新节点作为新的 ''head'',这样一来原有的 ''head'' 就成为了尾部节点。 ==RemovingFromFront== 与单链表不同,环链表中的移除的主要思路是从**内容**上替代节点。整体的逻辑可以描述为: * 使用第二个节点代替头节点被移除 * 使用头节点模拟第二个节点 * 改变头节点的指向到第三个节点 **//算法步骤://** - 转移数据:将第二个节点的内容拷贝到 ''head'' 节点中。此处不需要考虑数据覆盖的问题,因为 ''head'' 节点需要被移除。 - 指向下个节点:将 ''head'' 的 ''next'' 指向从第二个节点改变为第三个节点。 **//边界条件://** * 当链表的长度为 1 时: * ''head'' 需要设置为 ''null'' * ''head.next'' 需要指向其自身 ==RemovingFromBack== **//算法步骤://** - 定位新的尾部节点:遍历到倒数第二个节点 - 更新链接:将倒数第二个节点的 ''next'' 指向 ''head'' ==RemovingFromMiddle== **//算法步骤://** - 遍历到需要被移除节点的前一个节点 - 将该节点的 ''next'' 指向被移除节点的 ''next'' ====Stacks and Queues==== ==Linear Abstract Data Type== * Linear: * 有限 * 每个对象只有一个前置对象 * 每个对象只有一个后置对象 * ADT: * 包含了有限数量的数据结构 * 被存储的对象之间存在联系 * 运算(行为)定义了 ADT ===Stacks 的原理=== //Stack//(栈) 是 ADT 一种,通常使用 Array 或者 SLL 实现。名如其意, //Stack// 就是一种看上去是堆起来的数据结构(比如薯片筒就是一种 stack)。因此,//Stack// 通常都包含了两种基本操作: * **Push**:将对象**添加**到 //Stack// 中的操作(把薯片放进桶里) * **Pop**:将对象从 //Stack// 中移除的操作(把薯片拿出来) 值得注意的是,根据 //Stack// 的概念,无论是添加还是移除,我们处理的对象都只能是**栈顶的对象**(最上面的薯片),这导致了一下的几个事实: * //Stack// 是一个 //LIFO// (Last In First Out ,**后进先出**的数据结构)(最先放进去的薯片肯定在最底下) * //Stack// 中不存在 random access & remove。唯一能够操作的就是栈顶的,最后 push 进去的对象。(只能拿到最上面的薯片) * //Stack// 无法支持搜索 ==Stack 的几个基础方法== * ''void push(x)'':添加对象 * ''T pop()'':移除栈顶元素并返回 * ''T peek'' or ''T top'':返回栈顶元素(只读) * ''boolean isEmpty()'':通过 size,或是 ''top'' 是否为 ''null'' 来判断栈是否为空。 * ''clear()'':设置 ''top'' 为 ''null'' 并清空栈。 ==Stack 的一些应用== * undo 功能 * 手机 app 的多开 ===Stack 的实现(链表)=== 链表是实现 //Stack// 最高效的手段,所有的操作复杂度都是 $O(1)$。一些实现细节: * 链表的 ''head'' 用于模拟 //Stack// 的 ''top'' * 空栈的情况下,''head'' 为 ''null'' * 不需要 ''tail'' ==Push== 使用链表实现时,''head'' 总是代表 top。因此每 push 一个对象,''head'' 指向的节点就会更新到最新推送的对象。 **//算法步骤://** - 建立新(对象)节点 - 将新节点的 ''next'' 指向原来的 ''head'' 所代表的节点 - 更新 ''head'' ,将其指向新的节点 {{ :cs:dsa:courses:gtx_1332x:i_lists:stack_push.svg?350 |}} ==Pop== **//算法步骤://** - 将 ''head'' 指向下一个节点即可。 **//边界条件://** * 当 //Stack// 大小为 1 时,移除元素需要将 ''head'' 设置为 ''null'' ===Stack 的实现(数组)=== 实现细节: * 是否为空通过检查 ''size'' 是否为 ''0'' 来确认。 * 从效率的角度出发,不实现物理上的栈顶: * 将最右边的元素视作栈顶(''size - 1''),避免了每次 push / pop 都要做 $o(n)$ 的移动操作 * ''clear'' 的方式: * (不推荐)将 ''size'' 归零 * (不推荐)手动将所有数组元素以新数据覆盖(比如 ''0''), $O(n)$ 复杂度 * 使用该数组名指向新的数组,利用 java 的GC 功能清除数据 ,$O(1)$ 复杂度 ===Queues 的原理=== //Queues//(**队列**) 的结构类似于一个两边都开口的薯片桶;一边是入口,另一边是出口。//Queues// 遵循 //FIFO// (First in First Out 先进先出) 原则。与 //Stack// 类似,//Queues// 不支持任何的**随机访问**和操作。 ==Queues 的基础方法== * ''void enqueue(x)'':添加 ''x'' 到队列的最后 * ''x dequeue()'':从队列中移除最先进入的元素 * ''x peak() & x top()'':返回队列的最新 dequeue 的元素 * ''boolean isEmpty()'':检查 size 是否为 0 或者 front 是否为 Null * ''void clear()'':将 front 设置为 null ===Queues 的实现(链表)=== * 由于 Queues 出入口不同,因此需要同时维护 ''head'' 和 ''tail'' 两个指针 * 鉴于链表在尾部的 remove 操作总是 $O(n)$ 复杂度,处于效率考虑,Queues 的**添加入口在尾部,而删除出口在头部**(addToBack & removeFromFront)。这样能保证添加删除的操作的复杂度都是 $O(1)$ ==enqueue(SLL)== **//算法步骤://** - 同链表的 //addToBack()// ==dequeue(SLL)== **//算法步骤://** - 同链表的 //removeFromFront()// ===Queues 的实现(数组)=== 与链表数列相似,//Queue// 的数组实现主要依靠的是一前以后的两个指针 ''front'' & ''back'' 来体现出“队列”的概念。//Queue// 可以被视作存储于数组中的,以 //Back// 开头,以 //Front// 结尾的队列。需要注意的是,对效率的考虑给数组数列的组成方式带来了很大的影响: ==Front & Back== 由于 Array 的 AddToFront() 操作必须要求 Shifting 移位,因此数组队列的 enqueue 是在数组中对象序列的**后方**,而 dequeue 是在对象序列的**前方**。那么通常 ''back'' 会被视作是队列的入口,而 ''front'' 会被视作是队列的出口。 ==数组与循环机制== 我们之前的设想是将队列视作存储在数组中的,有头有尾的一个对象序列。这意味着其占用数组的位置只与数组的 capcity 有关系,而与数组的 Index 没有关系。因此,我们很可能会遭遇如下的存储分布: \\ \\ {{ :cs:dsa:courses:gtx_1332x:i_lists:deque_cases.svg?300 |}} \\ \\ 无论是哪种,我们都没有办法用 //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 queue//。//Deque// 允许在队列的两端添加和删除元素。//Deque// 不支持随机访问(添加删除)以及搜寻操作。 ==Deque 的方法== * ''void addFirst(x)'':在 Deque 队首添加元素 * ''void addLast(x)'':在 Deque 队尾添加元素 * ''x removeFirst()'':在 Deque 队首删除元素 * ''x removeLast()'':在 Deque 队尾删除元素 * ''boolean isEmpty()'' * ''int size()'' ===Deque 的实现(数组)=== //Deque// 的数组实现同样需要使用到循环的方式。 ==addLast() & removeFirst()== * addLast() 通过 ''back % capacity'' 维护插入位置 * removeFirst() 通过 ''front % capacity'' 维护删除位置 ==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): * //add first// & //remove first// 均在 head 处操作 * //add fisrt// & //remove last// 均在 tail 处操作 由于使用双链表,所有的操作都是 ($O(1)$)