𝓝𝓮𝓶𝓸&博客

【数据结构】线性表

引入头结点:

  1. 统一第一个元素结点与其他结点的操作;
  2. 统一空表和非空表的处理。

顺序表

数组有上界和下界,数组的元素在上下界内是连续的。

  • 存储10,20,30,40,50的数组的示意图如下:

  • 数组的特点是:数据是连续的;随机访问速度快。
    数组中稍微复杂一点的是多维数组和动态数组。对于C语言而言,多维数组本质上也是通过一维数组实现的。至于动态数组,是指数组的容量能动态增长的数组;对于C语言而言,若要提供动态数组,需要手动实现;而对于C++而言,STL提供了Vector;对于Java而言,Collection集合中提供了ArrayList和Vector。

链表

链表操作需要特别注意:

  • 在插入和删除等操作时,我们需要保证链表的断裂处的两侧节点被记录下来,否则我们在操作时很容易断链而找不到某个链表节点。
  • 一般地插入删除:都需要前一个结点。

题目:一个链表最常用的操作是在末尾插入结点(即 需要尾结点)和删除结点(即 需要尾的前驱),则选用“带头结点的双循环链表”最省时间。

题目:设对n个元素的线性表的运算只有4种:删除第一个元素(第一的前);删除最后一个元素(尾的前);在第一个元素之前插入新元素(第一的前);在最后一个元素之后插入新元素(),则最好使用“只有头结点指针没有尾结点指针的循环双链表”(这个链表满足前面所述所有条件)。

单向链表

单向链表(单链表)是链表的一种,它由节点组成,每个节点都包含下一个节点的指针。

注意:一般在操作前,加入一个伪头节点,来方便我们操作。

  • 单链表的示意图如下:

    表头为空,表头的后继节点是"节点10"(数据为10的节点),"节点10"的后继节点是"节点20"(数据为10的节点),...

  • 单链表删除节点

    删除"节点30"
    删除之前:"节点20" 的后继节点为"节点30",而"节点30" 的后继节点为"节点40"。
    删除之后:"节点20" 的后继节点为"节点40"。

//删除p之后的s
p->next = s->next;
free(s);
  • 单链表插入节点

    在"节点10"与"节点20"之间添加"节点15"
    添加之前:"节点10" 的后继节点为"节点20"。
    添加之后:"节点10" 的后继节点为"节点15",而"节点15" 的后继节点为"节点20"。
// 在p之后插入s
// 先用s记录下p之后的节点,再挪动p指针,确保不断链
s->next = p->next;
p->next = s;
  • 扩展:对某一节点进行前插操作
    先后插,再交换前后数据,可等价为前插
//将s结点插入到p之前
s->next = p->next;
p->next = s;
swap(s->data,p->data);
  • 扩展:删除结点*p
    将其后继结点的值赋予其自身,再删除p的后继结点
q = p->next;
p->data = p->next->data;
p->next = q->next;
free(q);
  • 单链表的特点是:节点的链接方向是单向的;相对于数组来说,单链表的的随机访问速度较慢,但是单链表删除/添加数据的效率很高。

双向链表

双向链表(双链表)是链表的一种。和单链表一样,双链表也是由节点组成,它的每个数据结点中都有两个指针,分别指向直接后继和直接前驱。所以,从双向链表中的任意一个结点开始,都可以很方便地访问它的前驱结点和后继结点。一般我们都构造双向循环链表。

注意:一般在操作前,加入一个伪头节点伪尾结点,来方便我们操作。

  • 双链表的示意图如下:

    表头为空,表头的后继节点为"节点10"(数据为10的节点);"节点10"的后继节点是"节点20"(数据为10的节点),"节点20"的前继节点是"节点10";"节点20"的后继节点是"节点30","节点30"的前继节点是"节点20";...;末尾节点的后继节点是表头。

  • 双链表删除节点:

    删除"节点30"
    删除之前:"节点20"的后继节点为"节点30","节点30" 的前继节点为"节点20"。"节点30"的后继节点为"节点40","节点40" 的前继节点为"节点30"。
    删除之后:"节点20"的后继节点为"节点40","节点40" 的前继节点为"节点20"。

// 删除p之后的s
p->next = s->next;
s->next->prev = p;
  • 双链表插入节点:

    在"节点10"与"节点20"之间添加"节点15"
    添加之前:"节点10"的后继节点为"节点20","节点20" 的前继节点为"节点10"。
    添加之后:"节点10"的后继节点为"节点15","节点15" 的前继节点为"节点10"。"节点15"的后继节点为"节点20","节点20" 的前继节点为"节点15"。
// 在p之后插入s
// 用待插入节点记录下链表分离处的节点地址,确保不断链
s->prev = p;
s->next = p->next;
s->prev->next = s;
s->next->prev = s;

静态链表

静态链表借助数组来描述线性表的链式存储结构,结点也有数据域data和指针域next,与前面的链表的指针不同的是,这里的指针是结点的相对地址数组下标),又称游标。和顺序表一样,静态链表也要预先分配一块连续的内存空间。

  • 存储结构:
# define MaxSize 50	//静态链表的最大长度
typedef struct {	//静态链表结构类型的定义
	ElemType data;	//存储数据元素
	int next;		//下一个元素的数组下标
}SLinkList[MaxSize];//Static
  • 静态链表以next==-1作为其结束的标志。
  • 静态链表的插入删除操作与动态链表的相同,只需要修改指针,而不需要移动元素。

总体来说,静态链表没有单链表使用起来方便,但在一些不支持指针的高级语言(如 Basic)中,这是一种非常巧妙的设计方法。

顺序表常用算法

注意:数组只是顺序表的实体表现,而不是就是顺序表。这是一个抽象和一个实体的关系。

  • 存储结构:
#define MaxSize 50			//定义线性表的最大长度
typedef struct {
	ElemType data[MaxSize];	//顺序表的元素
	int length;				//顺序表的当前长度
}SqList;					//顺序表的类型定义

线性表的常用算法其实就是双指针算法

在使用双指针算法时,我们需要特别注意:我们使用 while() 函数来进行遍历时,要特别注意指针 p 是否会越界,最好加上p < nums.length或者p >= 0这种限制!
这种越界问题特别常出现在使用双指针的时候!
在循环体中使用的指针都应该在循环条件中标明一下界限范围,以免超过界限,造成越界错误或空指针异常。

指针法

指针法是下面所有算法的根本,可以说,下面的所有算法都是基于指针法的。

当我们面对有多个操作的问题的时候,我们可以将每个操作都分隔开将每个操作都对应一个指针,每个指针都有互不相同的操作,各司其职。

每个指针都可以把它分为一个单独的操作板块,在这个版块中都是该指针需要操作的同类元素。

比如说,遍历前进,那么该板块为数组中所有元素,它们都是该指针的操作对象;
比如说,操作(增删改查),那么该板块只有一个操作的元素。

指针-操作,一一对应:

  • 指针:对应操作

一般分为两种:

  • 平移遍历:左右指针同方向一起前进,一般循环条件为right < nums.length,当右指针遍历结束,即 完成整个遍历。
  • 包围遍历:左右指针相向而行,一般循环条件为left < right,当左右指针相遇,则包围成功,完成整个遍历。

14. 最长公共前缀

编写一个函数来查找字符串数组中的最长公共前缀。

如果不存在公共前缀,返回空字符串 ""。

示例 1:

输入:strs = ["flower","flow","flight"]
输出:"fl"

示例 2:

输入:strs = ["dog","racecar","car"]
输出:""
解释:输入不存在公共前缀。

纵向扫描

方法一是横向扫描,依次遍历每个字符串,更新最长公共前缀。另一种方法是纵向扫描。纵向扫描时,从前往后遍历所有字符串的每一列,比较相同列上的字符是否相同,如果相同则继续对下一列进行比较,如果不相同则当前列不再属于公共前缀,当前列之前的部分为最长公共前缀。
image

class Solution {
    public String longestCommonPrefix(String[] strs) {
        if (strs == null || strs.length == 0) {
            return "";
        }
        int length = strs[0].length();
        int count = strs.length;
        for (int i = 0; i < length; i++) {
            char c = strs[0].charAt(i);
            for (int j = 1; j < count; j++) {
				// 如果指针抵达了某个链表的结尾 或 不相等,那就return
                if (i == strs[j].length() || strs[j].charAt(i) != c) {
                    return strs[0].substring(0, i);
                }
            }
        }
        return strs[0];
    }
}

复杂度分析

  • 时间复杂度:\(O(mn)\),其中 m 是字符串数组中的字符串的平均长度,n 是字符串的数量。最坏情况下,字符串数组中的每个字符串的每个字符都会被比较一次。

  • 空间复杂度:\(O(1)\)。使用的额外空间复杂度为常数。

面试题 17.09. 第 k 个数

有些数的素因子只有 3,5,7,请设计一个算法找出第 k 个数。注意,不是必须有这些素因子,而是必须不包含其他的素因子。例如,前几个数按顺序应该是 1,3,5,7,9,15,21。

示例 1:

输入: k = 5

输出: 9

答案

解题思路
符合要求的数字都是由多个3、5、7相乘得到的,所以只需要开三个指针遍历3、5、7的个数,依次相乘得到的三个值取最小值,即是当前位置的元素。

为了叙述方便,我们就把符合题目要求的这些数叫做丑数。

不难发现,一个丑数总是由前面的某一个丑数 *3 / x5 / x7 得到。
反过来说也是一样的,一个丑数 x3 / x5 / x7 就会得到某一个更大的丑数。

如果把丑数数列叫做 ugly[i],那么考虑一下三个数列(操作板块):

  1. ugly[0]*3, ugly[1]*3, ugly[2]*3, ugly[3]*3, ugly[4]*3, ugly[5]*3……
  2. ugly[0]*5, ugly[1]*5, ugly[2]*5, ugly[3]*5, ugly[4]*5, ugly[5]*5……
  3. ugly[0]*7, ugly[1]*7, ugly[2]*7, ugly[3]*7, ugly[4]*7, ugly[5]*7……

上面这个三个数列合在一起就形成了新的、更长的丑数数列。

如果合在一起呢?这其实就是一个合并有序线性表的问题。

定义三个index 分别指向上面三个数列,下一个丑数一定是三个 index 代表的值中最小的那个。然后相应 index++ 即可。

举个例子

初始值 ugly[0] = 1; index1 = 0; index2 = 0; index3 = 0

ugly[1] = Min(ugly[index1] * 3, ugly[index2] * 5, ugly[index3] * 7)
= Min(1 * 3, 1 * 5, 1 * 7)
= 3
于是 index1++;

ugly[2] = Min(ugly[index1] * 3, ugly[index2] * 5, ugly[index3] * 7)
= Min(3 * 3, 1 * 5, 1 * 7)
= 5
于是 index2++;

以此类推

四个操作、四个指针:

  • 指针i:遍历前进
  • 指针p3:p3指向的数字永远*3
  • 指针p5:p5指向的数字永远*5
  • 指针p7:p7指向的数字永远*7

附上双百代码

class Solution {
    public int getKthMagicNumber(int k) {
		if (k <= 1) {
			return 1;
		}
		
        int[] dp = new int[k];
        int p3 = 0, p5 = 0, p7 = 0;
        dp[0] = 1;
		
		// 动态规划,从1开始填表
        for (int i = 1; i < k; i++) {
			// 几个3、几个5、几个7相乘取最小的元素
            dp[i] = Math.min(Math.min(dp[p3] * 3, dp[p5] * 5), dp[p7] * 7);
			// 将构成那个最小元素的3或5或7的个数 + 1
            if (dp[i] == dp[p3] * 3) p3++;
            if (dp[i] == dp[p5] * 5) p5++;
            if (dp[i] == dp[p7] * 7) p7++;
        }
        return dp[k - 1];
    }
}

204. 计数质数

给定整数 n ,返回 所有小于非负整数 n 的质数的数量 。

示例 1:

输入:n = 10
输出:4
解释:小于 10 的质数一共有 4 个, 它们是 2, 3, 5, 7 。

示例 2:

输入:n = 0
输出:0

示例 3:

输入:n = 1
输出:0

暴力破解

class Solution {
    public int countPrimes(int n) {
        int ans = 0;
        for (int i = 2; i < n; ++i) {
            ans += isPrime(i) ? 1 : 0;
        }
        return ans;
    }

    public boolean isPrime(int x) {
        for (int i = 2; i * i <= x; ++i) {
            if (x % i == 0) {
                return false;
            }
        }
        return true;
    }
}

复杂度分析

  • 时间复杂度:\(O(n\sqrt{n})\)。单个数检查的时间复杂度为 \(O(\sqrt{n})\),一共要检查 \(O(n)\) 个数,因此总时间复杂度为 \(O(n\sqrt{n})\)
  • 空间复杂度:\(O(1)\)

埃氏筛

枚举没有考虑到数与数的关联性,因此难以再继续优化时间复杂度。接下来我们介绍一个常见的算法,该算法由希腊数学家厄拉多塞(Eratosthenes)提出,称为厄拉多塞筛法,简称埃氏筛。

我们考虑这样一个事实:如果 x 是质数,那么大于 x 的 x 的倍数 2x,3x,… 一定不是质数,因此我们可以从这里入手。

我们设 \(\textit{isPrime}[i]\) 表示数 i 是不是质数,如果是质数则为 1,否则为 0。从小到大遍历每个数,如果这个数为质数,则将其所有的倍数都标记为合数(除了该质数本身),即 0,这样在运行结束的时候我们即能知道质数的个数。

这种方法的正确性是比较显然的:这种方法显然不会将质数标记成合数;另一方面,当从小到大遍历到数 x 时,倘若它是合数,则它一定是某个小于 x 的质数 y 的整数倍,故根据此方法的步骤,我们在遍历到 y 时,就一定会在此时将 x 标记为 \(\textit{isPrime}[x]=0\)。因此,这种方法也不会将合数标记为质数。

当然这里还可以继续优化,对于一个质数 x,如果按上文说的我们从 2x 开始标记其实是冗余的,应该直接从 \(x⋅x\) 开始标记,因为 2x,3x,… 这些数一定在 x 之前就被其他数的倍数标记过了,例如 2 的所有倍数,3 的所有倍数等。

class Solution {
    public int countPrimes(int n) {
        int[] isPrime = new int[n];
        Arrays.fill(isPrime, 1);
        int ans = 0;
        for (int i = 2; i < n; ++i) {
            if (isPrime[i] == 1) {
                ans += 1;
                if ((long) i * i < n) {
                    for (int j = i * i; j < n; j += i) {
                        isPrime[j] = 0;
                    }
                }
            }
        }
        return ans;
    }
}

复杂度分析

  • 时间复杂度:\(O(n\log \log n)\)。具体证明这里不再展开,读者可以自行思考或者上网搜索,本质上是要求解 \(\sum_{p}\frac{n}{p}\)的和,其中 p 为质数。当然我们可以了解这个算法一个比较松的上界 \(O(n\log n)\) 怎么计算,这个等价于考虑 \(\sum_{i=1}^{n}\frac{n}{i}\)的和,而 \(O(\sum_{i=1}^{n}\frac{n}{i}) = O(n\sum_{i=1}^{n}\frac{1}{i})\),而 1 到 n 中所有数的倒数和趋近于 \(\log n\),因此 \(O(n\sum_{i=1}^{n}\frac{1}{i})=O(n\log n)\)
  • 空间复杂度:\(O(n)\)。我们需要 \(O(n)\) 的空间记录每个数是否为质数。

标记法

使用一个指针cur来进行遍历前进,使用指针i1i2(或者更多)来标记我们找到的元素进行操作

面试题 17.11. 单词距离

有个内含单词的超大文本文件,给定任意两个单词,找出在这个文件中这两个单词的最短距离(相隔单词数)。如果寻找过程在这个文件中会重复多次,而每次寻找的单词不同,你能对此优化吗?

示例:

输入:words = ["I","am","a","student","from","a","university","in","a","city"], word1 = "a", word2 = "student"
输出:1

答案

一次遍历,使用i1和i2来标记我们找到的元素。

class Solution {
    public int findClosest(String[] words, String word1, String word2) {
        int i1 = -1, i2 = -1;//分别记录word1和Word2的下标索引
        int dist = Integer.MAX_VALUE;
        for (int i = 0; i < words.length; i++) {
            String cur = words[i];
            
            if (cur.equals(word1)) {
                i1 = i;
            } else if (cur.equals(word2)) {
                i2 = i;
            }
            if (i1 != -1 && i2 != -1) {//都找到了,更新dist
                dist = Math.min(dist, Math.abs(i1 - i2));
            }
        }
        return dist;
    }
}

瞻前/顾后法

left = 0; right = 1;
同时从开头出发,向相同方向前进,左边left作为当前元素主力,右边right作为瞻前/顾后指针探路while (right < nums.length)

  1. 如果有下一位,那么以当前位为基准,可以往后(前)看多一位,对比当前位与后一位的关系,从而确定当前位是该如何操作;
  2. 如果没有下一位(即 到队尾了),又该是如何操作。

注意:我们这里以right来遍历,就不会超出界限了。

瞻前顾后是相对概念,你可以以left(i)为元素主力瞻前right(i + 1),也可以以right(i)为元素主力顾后left(i - 1)

我个人喜欢以right(i)为主力顾后left(i - 1),因为我认为只有遍历了某个元素,我们才能知道此元素的情况,才能结合前一个元素进行判断。

适用场景:前后元素有某种逻辑上的关系。

面试题 10.11. 峰与谷

在一个整数数组中,“峰”是大于或等于相邻整数的元素,相应地,“谷”是小于或等于相邻整数的元素。例如,在数组{5, 8, 4, 2, 3, 4, 6}中,{8, 6}是峰, {5, 2}是谷。现在给定一个整数数组,将该数组按峰与谷的交替顺序排序。

示例:

输入: [5, 3, 1, 2, 3]
输出: [5, 1, 3, 2, 3]

答案

假设按照峰-谷-峰的顺序排列数组,那么遍历一遍数组:

  1. 如果i为峰的位置,则判断当前位置是否小于前一个位置(前一个为谷),若小于,则交换,大于则不处理。
    即: if(nums[i]<nums[i-1]) swap(nums[i],nums[i-1]);
  2. 如果i为谷的位置,则判断当前位置是否大于前一个位置(前一个为峰),若大于,则交换,小于则不处理。
    即: if(nums[i]>nums[i-1]) swap(nums[i],nums[i-1]);
class Solution {
    public void wiggleSort(int[] nums) {
        // 偶数峰值(大),奇数谷值(小)
        int left = 0, right = 1;
        while (right < nums.length) {
            if (left % 2 == 0 && nums[left] > nums[right]) {
                int temp = nums[left];
                nums[left] = nums[right];
                nums[right] = temp;
            } else if (left % 2 == 1 && nums[left] < nums[right]) {
                int temp = nums[left];
                nums[left] = nums[right];
                nums[right] = temp;
            }
            left++;
            right++;
        }
    }
}

也可以使用fori来进行瞻前顾后:

class Solution {
    public void wiggleSort(int[] nums) {
        // 偶数峰值(大),奇数谷值(小)
        for (int i = 0; i + 1 < nums.length; i++) {
            if (i % 2 == 0 && nums[i] > nums[i + 1]) {
                int temp = nums[i];
                nums[i] = nums[i + 1];
                nums[i + 1] = temp;
            } else if (i % 2 == 1 && nums[i] < nums[i + 1]) {
                int temp = nums[i];
                nums[i] = nums[i + 1];
                nums[i + 1] = temp;
            }
        }
    }
}

包围法

left = 0; right = nums.length - 1;
一个left从头出发,一个right从尾出发(不会重合),向对立方向前进,包围!

操作:

  • 当其满足某一特定条件时,包围!
  • left++
  • right--

适用场景:因为是从头尾开始包围的,所以指针中途不会重合,不会重复匹配

  1. 两个指针不能重合、不重复、一起开始的场景。
  2. 只需要两个指针共同遍历一遍数组即可的场景。
  3. 要求不重复匹配时的场景
  4. 找满足条件的两个数,如 三数之和

189. 旋转数组

给定一个数组,将数组中的元素向右移动 k 个位置,其中 k 是非负数。

进阶:

尽可能想出更多的解决方案,至少有三种不同的方法可以解决这个问题。
你可以使用空间复杂度为 O(1) 的 原地 算法解决这个问题吗?

示例 1:
输入: nums = [1,2,3,4,5,6,7], k = 3
输出: [5,6,7,1,2,3,4]
解释:
向右旋转 1 步: [7,1,2,3,4,5,6]
向右旋转 2 步: [6,7,1,2,3,4,5]
向右旋转 3 步: [5,6,7,1,2,3,4]

示例 2:
输入:nums = [-1,-100,3,99], k = 2
输出:[3,99,-1,-100]
解释:
向右旋转 1 步: [99,-1,-100,3]
向右旋转 2 步: [3,99,-1,-100]

答案

class Solution {
    public void rotate(int[] nums, int k) {

        // 这里需要注意,k有可能比数组还要大,我们得提前求余,得到它的等价形式
        k %= nums.length;
        // 先整体反转
        reverse(nums, 0, nums.length - 1);
        // 再局部反转
        reverse(nums, 0, k - 1);
        reverse(nums, k, nums.length - 1);
    }

    // 反转数组,包围法
    public void reverse(int[] nums, int left, int right) {

        // left==right没必要交换、反转
        while (left < right) {
            int t = nums[left];
            nums[left] = nums[right];
            nums[right] = t;

            left++;
            right--;
        }
    }
}

977. 有序数组的平方

给你一个按 非递减顺序 排序的整数数组 nums,返回 每个数字的平方 组成的新数组,要求也按 非递减顺序 排序。

示例 1:

输入:nums = [-4,-1,0,3,10]
输出:[0,1,9,16,100]
解释:平方后,数组变为 [16,1,0,9,100]
排序后,数组变为 [0,1,9,16,100]

示例 2:

输入:nums = [-7,-3,2,3,11]
输出:[4,9,9,49,121]

答案

左右两边比大小,大的逆序填入数组

class Solution {
    public int[] sortedSquares(int[] nums) {
        int n = nums.length;
        int[] ans = new int[n];
        int leftIndex = 0, rightIndex = n - 1;
        int index = n - 1;

        while (leftIndex <= rightIndex) {
            if (nums[leftIndex] * nums[leftIndex] >= nums[rightIndex] * nums[rightIndex]) {
                ans[index] = nums[leftIndex] * nums[leftIndex];
                leftIndex++;
            } else {
                ans[index] = nums[rightIndex] * nums[rightIndex];
                rightIndex--;
            }
            index--;
        }
        return ans;
    }
}

快速排序

快速排序用的就是包围法

11. 盛最多水的容器

给你 n 个非负整数 a1,a2,...,an,每个数代表坐标中的一个点 (i, ai) 。在坐标内画 n 条垂直线,垂直线 i 的两个端点分别为 (i, ai) 和 (i, 0) 。找出其中的两条线,使得它们与 x 轴共同构成的容器可以容纳最多的水。

说明:你不能倾斜容器。

示例 1:

输入:[1,8,6,2,5,4,8,3,7]
输出:49
解释:图中垂直线代表输入数组 [1,8,6,2,5,4,8,3,7]。在此情况下,容器能够容纳水(表示为蓝色部分)的最大值为 49。

示例 2:
输入:height = [1,1]
输出:1

示例 3:
输入:height = [4,3,2,1,4]
输出:16

示例 4:
输入:height = [1,2,1]
输出:2

答案

可以看到我们的指针不能重合一起开始

class Solution {
    public int maxArea(int[] height) {

        int l = 0, r = height.length - 1;

        int res = 0;

        while (l < r) {
            // 使用min是不行的,因为传过去之后会变成height[l++] < height[r--] ? height[l++] : height[r--],即 他们会同时加减,不能这样做
//            res = Math.max(res, (r - l) * Math.min(height[l++], height[r--]));
            
            // 获取高度最小的,把高度最小的那个前进
            res = Math.max(res, (r - l) * (height[l] < height[r] ? height[l++] : height[r--]));

        }

        return res;
    }
}

42. 接雨水

给定 n 个非负整数表示每个宽度为 1 的柱子的高度图,计算按此排列的柱子,下雨之后能接多少雨水。

示例 1:
image

输入:height = [0,1,0,2,1,0,1,3,2,1,2,1]
输出:6
解释:上面是由数组 [0,1,0,2,1,0,1,3,2,1,2,1] 表示的高度图,在这种情况下,可以接 6 个单位的雨水(蓝色部分表示雨水)。 

示例 2:

输入:height = [4,2,0,3,2,5]
输出:9

方法一:动态规划

对于下标 i,下雨后水能到达的最大高度等于下标 i 两边的最大高度的最小值,下标 i 处能接的雨水量等于下标 i 处的水能到达的最大高度减去 height[i]。

朴素的做法是对于数组 height 中的每个元素,分别向左和向右扫描并记录左边和右边的最大高度,然后计算每个下标位置能接的雨水量。假设数组 height 的长度为 n,该做法需要对每个下标位置使用 \(O(n)\) 的时间向两边扫描并得到最大高度,因此总时间复杂度是 \(O(n^2)\)

上述做法的时间复杂度较高是因为需要对每个下标位置都向两边扫描。如果已经知道每个位置两边的最大高度,则可以在 \(O(n)\) 的时间内得到能接的雨水总量。使用动态规划的方法,可以在 \(O(n)\) 的时间内预处理得到每个位置两边的最大高度。

创建两个长度为 n 的数组 leftMax 和 rightMax。对于 0≤i<n,leftMax[i] 表示下标 ii 及其左边的位置中,height 的最大高度,rightMax[i] 表示下标 i 及其右边的位置中,height 的最大高度。

显然,leftMax[0]=height[0],rightMax[n−1]=height[n−1]。两个数组的其余元素的计算如下:

  • 当 1≤i≤n−1 时,leftMax[i]=max(leftMax[i−1],height[i]);

  • 当 0≤i≤n−2 时,rightMax[i]=max(rightMax[i+1],height[i])。

因此可以正向遍历数组 height 得到数组 leftMax 的每个元素值,反向遍历数组 height 得到数组 rightMax 的每个元素值。

在得到数组 leftMax 和 rightMax 的每个元素值之后,对于 0≤i<n,下标 i 处能接的雨水量等于 min(leftMax[i],rightMax[i])−height[i]。遍历每个下标位置即可得到能接的雨水总量。

动态规划做法可以由下图体现。

class Solution {
    public int trap(int[] height) {
        int n = height.length;
        if (n == 0) {
            return 0;
        }

        int[] leftMax = new int[n];
        leftMax[0] = height[0];
        for (int i = 1; i < n; ++i) {
            leftMax[i] = Math.max(leftMax[i - 1], height[i]);
        }

        int[] rightMax = new int[n];
        rightMax[n - 1] = height[n - 1];
        for (int i = n - 2; i >= 0; --i) {
            rightMax[i] = Math.max(rightMax[i + 1], height[i]);
        }

        int ans = 0;
        for (int i = 0; i < n; ++i) {
            ans += Math.min(leftMax[i], rightMax[i]) - height[i];
        }
        return ans;
    }
}

复杂度分析

  • 时间复杂度:\(O(n)\),其中 n 是数组 height 的长度。计算数组 leftMax 和 rightMax 的元素值各需要遍历数组 height 一次,计算能接的雨水总量还需要遍历一次。
  • 空间复杂度:\(O(n)\),其中 n 是数组 height 的长度。需要创建两个长度为 n 的数组 leftMax 和 rightMax。

方法二:单调栈

除了计算并存储每个位置两边的最大高度以外,也可以用单调栈计算能接的雨水总量。

维护一个单调栈,单调栈存储的是下标,满足从栈底到栈顶的下标对应的数组 height 中的元素递减。

从左到右遍历数组,遍历到下标 i 时,如果栈内至少有两个元素,记栈顶元素为 top,top 的下面一个元素是 left,则一定有 height[left]≥height[top]。如果 height[i]>height[top],则得到一个可以接雨水的区域,该区域的宽度是 i−left−1,高度是 min(height[left],height[i])−height[top],根据宽度和高度即可计算得到该区域能接的雨水量。

为了得到 left,需要将 top 出栈。在对 top 计算能接的雨水量之后,left 变成新的 top,重复上述操作,直到栈变为空,或者栈顶下标对应的 height 中的元素大于或等于 height[i]。

在对下标 i 处计算能接的雨水量之后,将 i 入栈,继续遍历后面的下标,计算能接的雨水量。遍历结束之后即可得到能接的雨水总量。

class Solution {
    public int trap(int[] height) {
        int ans = 0;
        Deque<Integer> stack = new LinkedList<Integer>();
        int n = height.length;
        for (int i = 0; i < n; ++i) {
            while (!stack.isEmpty() && height[i] > height[stack.peek()]) {
                int top = stack.pop();
                if (stack.isEmpty()) {
                    break;
                }
                int left = stack.peek();
                int currWidth = i - left - 1;
                int currHeight = Math.min(height[left], height[i]) - height[top];
                ans += currWidth * currHeight;
            }
            stack.push(i);
        }
        return ans;
    }
}

复杂度分析

  • 时间复杂度:\(O(n)\),其中 n 是数组 height 的长度。从 0 到 n-1 的每个下标最多只会入栈和出栈各一次。
  • 空间复杂度:\(O(n)\),其中 n 是数组 height 的长度。空间复杂度主要取决于栈空间,栈的大小不会超过 n。

方法三:双指针

动态规划的做法中,需要维护两个数组 leftMax 和 rightMax,因此空间复杂度是 \(O(n)\)。是否可以将空间复杂度降到 \(O(1)\)

对于下标 i,下雨后水能到达的最大高度等于下标 i 两边的最大高度的最小值,下标 i 处能接的雨水量等于下标 i 处的水能到达的最大高度减去 height[i]。

注意到下标 i 处能接的雨水量由 leftMax[i] 和 rightMax[i] 中的最小值决定。由于数组 leftMax 是从左往右计算,数组 rightMax 是从右往左计算,因此可以使用双指针和两个变量代替两个数组。

维护两个指针 left 和 right,以及两个变量 leftMax 和 rightMax,初始时 left=0,right=n−1,leftMax=0,rightMax=0。指针 left 只会向右移动,指针 right 只会向左移动,在移动指针的过程中维护两个变量 leftMax 和 rightMax 的值。

当两个指针没有相遇时,进行如下操作:

  • 使用 height[left] 和 height[right] 的值更新 leftMax 和 rightMax 的值;

  • 如果 height[left]<height[right],则必有 leftMax<rightMax,下标 left 处能接的雨水量等于 leftMax−height[left],将下标 left 处能接的雨水量加到能接的雨水总量,然后将 left 加 1(即向右移动一位);

  • 如果 height[left]≥height[right],则必有 leftMax≥rightMax,下标 right 处能接的雨水量等于 rightMax−height[right],将下标 right 处能接的雨水量加到能接的雨水总量,然后将 right 减 1(即向左移动一位)。

当两个指针相遇时,即可得到能接的雨水总量。

class Solution {
    public int trap(int[] height) {
        int ans = 0;
        int left = 0, right = height.length - 1;
        int leftMax = 0, rightMax = 0;
        while (left < right) {
            leftMax = Math.max(leftMax, height[left]);
            rightMax = Math.max(rightMax, height[right]);
            if (leftMax < rightMax) {
                ans += leftMax - height[left];
                ++left;
            } else {
                ans += rightMax - height[right];
                --right;
            }
        }
        return ans;
    }
}

复杂度分析

  • 时间复杂度:\(O(n)\),其中 n 是数组 height 的长度。两个指针的移动总次数不超过 n。
  • 空间复杂度:\(O(1)\)。只需要使用常数的额外空间。

15. 三数之和

给你一个包含 n 个整数的数组 nums,判断 nums 中是否存在三个元素 a,b,c ,使得 a + b + c = 0 ?请你找出所有和为 0 且不重复的三元组。

注意:答案中不可以包含重复的三元组。

示例 1:
输入:nums = [-1,0,1,2,-1,-4]
输出:[[-1,-1,2],[-1,0,1]]

示例 2:
输入:nums = []
输出:[]

示例 3:
输入:nums = [0]
输出:[]

答案

因为我们的目标是找数,当然使用指针的方式最简单。假若我们的数组为:

[-1, 0, 1, 2, -1, -4]

求解过程如下:首先我们先把数组排个序(原因一会儿说),排完序长这样:

因为我们要同时找三个数,所以采取固定一个数,同时用双指针来查找另外两个数的方式。所以初始化时,我们选择固定第一个元素(当然,这一轮走完了,这个蓝框框我们就要也往前移动),同时将下一个元素和末尾元素分别设上 left 和 right 指针。画出图来就是下面这个样子:

现在已经找到了三个数,当然是计算其三值是否满足三元组。但是这里因为我们已经排好了序,如果固定下来的数(上面蓝色框框)本身就大于 0,那三数之和必然无法等于 0。比如下面这种:

然后自然用脚指头也能想到,我们需要移动指针。现在我们的排序就发挥出用处了,如果和大于0,那就说明 right 的值太大,需要左移。如果和小于0,那就说明 left 的值太小,需要右移。(上面这个思考过程是本题的核心) 整个过程如下图所示:

其中:在第6行时,因为三数之和大于0,所以right进行了左移。最后一行,跳过了重复的-1。

然后啰嗦一句,因为我们需要处理重复值的情况。除了固定下来的i值(蓝框框),left 和 right 当然也是需要处理重复的情况,所以对于 left 和 left+1,以及 right 和 right-1,我们都单独做一下重复值的处理。(其实没啥处理,就是简单的跳过)

暴力破解超时了

class Solution {
    public List<List<Integer>> threeSum(int[] nums) {
        // 暴力破解
        if (nums == null || nums.length < 3)
            return new ArrayList<>();

        Set<List<Integer>> res = new HashSet<>();

        // 先排序确保加入数组后的顺序一致,方便set去重
        Arrays.sort(nums); // O(nlogn)

        // 三指针遍历,如果遇到重复的那就跳过 
        // O(n^3)
        for (int i = 0; i < nums.length; i++) {
            for (int j = i + 1; j < nums.length; j++) {
                for (int k = j + 1; k < nums.length; k++) {
                    if (nums[i] + nums[j] + nums[k] == 0) {
                        res.add(Arrays.asList(nums[i], nums[j], nums[k]));
                    }
                }
            }
        }

        return new ArrayList<>(res);
    }
}

优化:很可惜上面的暴力破解超时了。。。只能另辟蹊径了

class Solution {
    public List<List<Integer>> threeSum(int[] nums) {
        List<List<Integer>> res = new ArrayList<>();
        Arrays.sort(nums); // 排序

		// 固定一个指针,双指针包围遍历
        for (int i = 0; i < nums.length - 2; i++) {

            if(i > 0 && nums[i] == nums[i - 1]) continue; // 去重
            int left = i + 1, right = nums.length - 1;
			
			// 遍历L到R的所有数据,当重合时不需要遍历了
            while (left < right) {
                int sum = nums[i] + nums[left] + nums[right];
                if (sum == 0) { // 如果和为0,那就可以加入到列表
                    res.add(List.of(nums[i], nums[left], nums[right]));
					
					// 如果下一个元素重复了,那就前进到下一个元素不重复的边界
					// 去掉左右指针的重复
                    while (left < right && nums[left] == nums[left + 1]) left++; // 去重
                    while (left < right && nums[right] == nums[right - 1]) right--; // 去重
                    left++;
                    right--;
				
				// 因为数组有序,所以如果sum<0那么就是L太小了,加大一点
                } else if (sum < 0) {
                    left++;
                } else {
                    right--;
                }
            }
        }
        return res;
    }
}

放过法

问题:对长度为n的顺序表L,编写一个时间复杂度为O(n)、空间复杂度为O(1)的算法,该算法删除线性表中所有值为x的数据元素。

此方法我们需要将满足条件的元素一一放过

放过法需要一个指针 k 来记录已经放过的元素,一个一个放过。

有两种方法:

  • 指针 k 指向放过元素
  • 指针 k 指向放过元素的后面一个元素,即 前闭后开

算法:用right进行遍历,将放过的元素使用left管理,指针 left 指向放过元素

bool deleteX(int[] nums, int x) {
	int left = -1, right = 0;		// 记录值不等于x的元素个数,也可以理解为前闭后开
	
	while (right < nums.length) {
		if (nums[right] != x) {  // 如果不等于x,那就放你过,让你进入线性表(新)
			nums[++left] = nums[right];
		}
		right++;
	}
}

for

bool deleteX(SqList &L, ElemType x) {
	int k = 0;		// 记录值不等于x的元素个数,也可以理解为前闭后开
	for(int i=0; i<L.length; i++) {
		if(L.data[i] != x) {		// 如果不等于x,那就放你过,让你进入线性表(新)
			L.data[k] = L.data[i];
			k++;
		}
	}
	L.length = k;
}

删除排序数组中的重复项

给定一个排序数组,你需要在 原地 删除重复出现的元素,使得每个元素只出现一次,返回移除后数组的新长度。

不要使用额外的数组空间,你必须在 原地 修改输入数组 并在使用 O(1) 额外空间的条件下完成。

示例 1:

给定数组 nums = [1,1,2],

函数应该返回新的长度 2, 并且原数组 nums 的前两个元素被修改为 1, 2。

你不需要考虑数组中超出新长度后面的元素。
示例 2:

给定 nums = [0,0,1,1,1,2,2,3,3,4],

函数应该返回新的长度 5, 并且原数组 nums 的前五个元素被修改为 0, 1, 2, 3, 4。

你不需要考虑数组中超出新长度后面的元素。

我的

class Solution {
    public int removeDuplicates(int[] nums) {

        int left = 0, right = 1;

        while (right < nums.length) {
            if (nums[left] != nums[right]) {
                nums[++left] = nums[right];
            }
            right++;
        }

        return left + 1;
    }
}

答案:

解法: 双指针

首先注意数组是有序的,那么重复的元素一定会相邻。

要求删除重复元素,实际上就是将不重复的元素移到数组的左侧。

考虑用 2 个指针,一个在前记作 p,一个在后记作 q,算法流程如下:

1.比较 p 和 q 位置的元素是否相等。

如果相等,q 后移 1 位
如果不相等,将 q 位置的元素复制到 p+1 位置上,p 后移一位,q 后移 1 位
重复上述过程,直到 q 等于数组长度。

返回 p + 1,即为新数组长度。

画个图理解一下

class Solution {
    public int removeDuplicates(int[] nums) {
        // 放过法
        // 1. 指针i前进,遍历数组,遇到不同的元素就放在++index上
        int index = 0;

        for (int i = 0; i < nums.length; i++) {
            
            if (nums[index] != nums[i]) {
                nums[++index] = nums[i];
            }
        }

        // 数目就是index + 1
        return index + 1;
    }
}

其实还有一种方法

方法二:

思路

现在考虑数组包含很少的要删除的元素的情况。例如,\(num=[1,2,3,5,4],Val=4\)num=[1,2,3,5,4],Val=4。之前的算法会对前四个元素做不必要的复制操作。另一个例子是 \(num=[4,1,2,3,5],Val=4\)num=[4,1,2,3,5],Val=4。似乎没有必要将 \([1,2,3,5]\)[1,2,3,5] 这几个元素左移一步,因为问题描述中提到元素的顺序可以更改。

算法

当我们遇到 nums[i] = valnums[i]=val 时,我们可以将当前元素与最后一个元素进行交换,并释放最后一个元素。这实际上使数组的大小减少了 1。

请注意,被交换的最后一个元素可能是您想要移除的值。但是不要担心,在下一次迭代中,我们仍然会检查这个元素。

public int removeElement(int[] nums, int val) {
    int i = 0;
    int n = nums.length;
    while (i < n) {
        if (nums[i] == val) {
            nums[i] = nums[n - 1];
            // reduce array size by one
            n--;
        } else {
            i++;
        }
    }
    return n;
}

复杂度分析

  • 时间复杂度:\(O(n)\),i 和 n 最多遍历 n 步。在这个方法中,赋值操作的次数等于要删除的元素的数量。因此,如果要移除的元素很少,效率会更高。

  • 空间复杂度:\(O(1)\)

26. 删除排序数组中的重复项

给定一个排序数组,你需要在 原地 删除重复出现的元素,使得每个元素只出现一次,返回移除后数组的新长度。

不要使用额外的数组空间,你必须在 原地 修改输入数组 并在使用 O(1) 额外空间的条件下完成。

示例 1:
给定数组 nums = [1,1,2],

函数应该返回新的长度 2, 并且原数组 nums 的前两个元素被修改为 1, 2。

你不需要考虑数组中超出新长度后面的元素。

示例 2:
给定 nums = [0,0,1,1,1,2,2,3,3,4],

函数应该返回新的长度 5, 并且原数组 nums 的前五个元素被修改为 0, 1, 2, 3, 4。

你不需要考虑数组中超出新长度后面的元素。

答案

class Solution {
    public int removeDuplicates(int[] nums) {
        // 放过法,我们只放过那些不同的元素,相同的直接跳过
        // 1. 指针i前进,遍历数组,遇到不同的元素就放在++index上
        int index = 0;

        for (int i = 0; i < nums.length; i++) {
            
            if (nums[index] != nums[i]) {
                nums[++index] = nums[i];
            }
        }

        // 数目就是index + 1
        return index + 1;
    }
}

75. 颜色分类

给定一个包含红色、白色和蓝色、共 n 个元素的数组 nums ,原地对它们进行排序,使得相同颜色的元素相邻,并按照红色、白色、蓝色顺序排列。

我们使用整数 0、 1 和 2 分别表示红色、白色和蓝色。

必须在不使用库的sort函数的情况下解决这个问题。

示例 1:

输入:nums = [2,0,2,1,1,0]
输出:[0,0,1,1,2,2]

示例 2:

输入:nums = [2,0,1]
输出:[0,1,2]

单指针放过

class Solution {
    public void sortColors(int[] nums) {
        int n = nums.length;
        int ptr = 0;
        for (int i = 0; i < n; ++i) {
            if (nums[i] == 0) {
                int temp = nums[i];
                nums[i] = nums[ptr];
                nums[ptr] = temp;
                ++ptr;
            }
        }
        for (int i = ptr; i < n; ++i) {
            if (nums[i] == 1) {
                int temp = nums[i];
                nums[i] = nums[ptr];
                nums[ptr] = temp;
                ++ptr;
            }
        }
    }
}

双指针放过

class Solution {
    public void sortColors(int[] nums) {
        int n = nums.length;
        int p0 = 0, p2 = n - 1;
        for (int i = 0; i <= p2; ++i) {
            while (i <= p2 && nums[i] == 2) {
                int temp = nums[i];
                nums[i] = nums[p2];
                nums[p2] = temp;
                --p2;
            }
            if (nums[i] == 0) {
                int temp = nums[i];
                nums[i] = nums[p0];
                nums[p0] = temp;
                ++p0;
            }
        }
    }
}

逆置

  • 算法:
    折半逆置
//顺数第i个+倒数第i个=总长度n-1
void reverse(SqList &L) {
	ElemType t;
	for(int i = 0; i<L.length/2; i++) {		// 依次交换前后元素
		t = L.data[i];
		L.data[i] = L.data[length-i-1];
		L.data[length-i-1] = t;
	}
}

双指针逆置

class Solution {
    public void reverseString(char[] s) {

        // 折半倒序
        // char t = 0;
        // for (int i = 0; i < s.length / 2; i++) {
        //     t = s[i];
        //     s[i] = s[s.length - 1 - i];
        //     s[s.length - 1 - i] = t;
        // }


        //双指针倒序
        int n = s.length;
        int left = 0;
        int right = n - 1;
        while (left < right) {  // 相等没必要交换倒序了
            char tmp = s[left];
            s[left] = s[right];
            s[right] = tmp;

            // 前进
            left++;
            right--;
        }
        // for (int left = 0, right = n - 1; left < right; ++left, --right) {
        //     char tmp = s[left];
        //     s[left] = s[right];
        //     s[right] = tmp;
        // }
    }
}

344. 反转字符串

编写一个函数,其作用是将输入的字符串反转过来。输入字符串以字符数组 char[] 的形式给出。

不要给另外的数组分配额外的空间,你必须原地修改输入数组、使用 O(1) 的额外空间解决这一问题。

你可以假设数组中的所有字符都是 ASCII 码表中的可打印字符。

示例 1:
输入:["h","e","l","l","o"]
输出:["o","l","l","e","h"]

示例 2:
输入:["H","a","n","n","a","h"]
输出:["h","a","n","n","a","H"]

答案

双指针
思路与算法

对于长度为 N 的待被反转的字符数组,我们可以观察反转前后下标的变化,假设反转前字符数组为 s[0] s[1] s[2] ... s[N - 1],那么反转后字符数组为 s[N - 1] s[N - 2] ... s[0]。比较反转前后下标变化很容易得出 s[i] 的字符与 s[N - 1 - i] 的字符发生了交换的规律,因此我们可以得出如下双指针的解法:

  • 将 left 指向字符数组首元素,right 指向字符数组尾元素。
  • 当 left < right:
    • 交换 s[left] 和 s[right];
    • left 指针右移一位,即 left = left + 1;
    • right 指针左移一位,即 right = right - 1。
  • 当 left >= right,反转结束,返回字符数组即可。

代码

Java

class Solution {
    public void reverseString(char[] s) {
        int n = s.length;
        for (int left = 0, right = n - 1; left < right; ++left, --right) {
            char tmp = s[left];
            s[left] = s[right];
            s[right] = tmp;
        }
    }
}

复杂度分析

  • 时间复杂度:\(O(N)\)O(N),其中 N 为字符数组的长度。一共执行了 N/2 次的交换。
  • 空间复杂度:\(O(1)\)O(1)。只使用了常数空间来存放若干变量。

归并算法

思想:将指针 p1 置为 nums1 的开头,p2 为 nums2 的开头,在每一步将最小值放入输出数组中。即可将两个有序数组归并为一个有序数组。

基础归并算法:

//表A的两段A[low...mid]和A[mid+1...high]各自有序,将它们合并成一个有序表(合并过程中排序)
ElemType *B = (ElemType *)malloc((n+1)*sizeof(ElemType));   //辅助数组B
void Merge(ElemType A[], int low, int mid, int high) {
    for(int k=low; k<=high; k++) {
        B[k] = A[k];        //将A中所有元素复制到B中
    }
    for(i=low, j=mid+1, k=i; i<=mid && j<=high; k++) {
        if(B[i]<=B[j]) {    //比较B的左右两段中的元素
            A[k] = B[i++];  //将较小值复制到A中
        }else {
            A[k] = B[j++];
        }
    }
    while(i <= mid) {       //若有的表中还有元素尚未检测完,全部放入A中
        A[k++] = B[i++];
    }
    while(j <= high) {
        A[k++] = B[j++];
    }
}

合并两个有序数组

给你两个有序整数数组 nums1 和 nums2,请你将 nums2 合并到 nums1 中,使 nums1 成为一个有序数组。

初始化 nums1 和 nums2 的元素数量分别为 m 和 n 。你可以假设 nums1 的空间大小等于 m + n,这样它就有足够的空间保存来自 nums2 的元素。

示例 1:
输入:nums1 = [1,2,3,0,0,0], m = 3, nums2 = [2,5,6], n = 3
输出:[1,2,2,3,5,6]

示例 2:
输入:nums1 = [1], m = 1, nums2 = [], n = 0
输出:[1]


答案1:双指针归并算法

class Solution {
    public void merge(int[] nums1, int m, int[] nums2, int n) {

        // 双指针
        // nums1一个指针p1,nums2一个指针p2
        // 指针从前向后遍历数组,比较两个指针对应值的大小,把小的取出值放在临时数组temp[],指针前进

        int[] temp = new int[m + n];

        int p1 = 0; // nums1数组的头索引
        int p2 = 0; // nums2数组的头索引
        int p = 0;  // temp数组的头索引

        // 双指针遍历两个数组
        while (p1 < m && p2 < n) {
            temp[p++] = nums1[p1] < nums2[p2]? nums1[p1++] : nums2[p2++];
        }

        // 若某表中还有元素没有检测完,此时剩余的元素已经是有序的了,直接放入数组temp即可
        while (p1 < m) {
            temp[p++] = nums1[p1++];
        }

        while (p2 < n) {
            temp[p++] = nums2[p2++];
        }

        // 将临时数组temp移到nums1中
        for (int i = 0; i < temp.length; i++) {
            nums1[i] = temp[i];
        }
}

答案2:逆向双指针归并算法

class Solution {
    public void merge(int[] nums1, int m, int[] nums2, int n) {

        // 双指针
        // nums1一个指针p1,nums2一个指针p2
        // 指针从后向前遍历数组,比较两个指针对应值的大小,把大的取出值放在数组nums1[],指针后退

        int p1 = m - 1; // nums1数组的有效尾索引(最后一个非0数字)
        int p2 = n - 1; // nums2数组的尾索引
        int p = m + n - 1;  // nums1的真正尾索引(最后一个0)


        // 双指针遍历两个数组
        while (p1 >= 0 && p2 >= 0) {
            // 下面这样也可以
            // nums1[p--] = nums1[p1] > nums2[p2]? nums1[p1--] : nums2[p2--];
            nums1[p] = nums1[p1] > nums2[p2]? nums1[p1--] : nums2[p2--];
            p--;
        }

        // 若某表中还有元素没有检测完,此时剩余的元素已经是有序的了,直接放入数组nums1即可

        // 这段其实可以不要,留着是因为提醒自己这俩表都有可能有剩余元素
        while (p1 >= 0) {
            nums1[p--] = nums1[p1--];
        }

        while (p2 >= 0) {
            nums1[p--] = nums2[p2--];
        }
    }
}

链表常用算法

  • 存储结构:
typedef struct LNode {		//定义单链表结点类型
	ElemType data;			//数据域
	struct LNode *next;		//指针域
}LNode, *LinkList;

在做链表算法题时注意!!!如果链表没有头节点,最好加一个头节点再做,这样可以防止第一个数据节点的操作与其他结点的操作不一致,可以方便我们的操作。

在操作链表时,我们一般使用指针cur(current)代表当前元素来遍历前进,使用指针p提取节点进行操作

LNode cur = head;

while (cur != null) {
    p = cur;
    cur = cur.next;
    // 操作p
}

头插法(逆置)

生成后进先出的单链表(链式栈)

  • 逆序(逆置):head 头指针
q->next = head->next;
head->next = q;

尾插法

  • 顺序:tail 尾指针
q->next = tail->next;
tail->next = q;
tail = q;	//尾指针继续指向尾结点

25. K 个一组翻转链表

给你链表的头节点 head ,每 k 个节点一组进行翻转,请你返回修改后的链表。

k 是一个正整数,它的值小于或等于链表的长度。如果节点总数不是 k 的整数倍,那么请将最后剩余的节点保持原有顺序。

你不能只是单纯的改变节点内部的值,而是需要实际进行节点交换。

示例 1:
image

输入:head = [1,2,3,4,5], k = 2
输出:[2,1,4,3,5]

示例 2:
image

输入:head = [1,2,3,4,5], k = 3
输出:[3,2,1,4,5]

我的

新的结果链表使用尾插法,子链表使用头插法逆置

/**
 * Definition for singly-linked list.
 * public class ListNode {
 *     int val;
 *     ListNode next;
 *     ListNode() {}
 *     ListNode(int val) { this.val = val; }
 *     ListNode(int val, ListNode next) { this.val = val; this.next = next; }
 * }
 */
class Solution {
    boolean flag = true;
    public ListNode reverseKGroup(ListNode head, int k) {

        // 结果使用尾插法
        ListNode res = new ListNode(), tail = res;

        // ListNode newHead = new ListNode();
        // newHead.next = head;
        ListNode left = head, right = head;

        // 前进k步,返回是否前进了K步
        while (right != null) {
            right = goK(right, k);
            ListNode cur = right.next;
            if (flag) {
                // 反转left到right的链表,返回链表头节点
                reverse(left, right);
                // 尾插,因为已经反转了,所以头是right,尾是left
                tail.next = right;
                tail = left;
            } else {
				// 未反转,头是left,尾是right
                tail.next = left;
                tail = right;
            }
            
            left = cur;
            right = cur;
        }

        return res.next;
    }
	// 前进K步,这里需要注意Java是值传递,所以没法直接修改node,需要返回
    public ListNode goK(ListNode node, int k) {
        for (int i = 1; i < k; i++) {
            if (node.next != null) {
                node = node.next;
            } else {
                flag = false;
                return node;
            }
        }
        flag = true;
        return node;
    }
	// 反转
    public void reverse(ListNode left, ListNode right) {
        // 子链表使用头插法
        ListNode head = new ListNode();
        ListNode end = right.next;

        ListNode cur = left, p = null;
        while (cur != end) {
            p = cur;
            cur = cur.next;

            p.next = head.next;
            head.next = p;
        }
    }
}

指针法

面试题 02.05. 链表求和

给定两个用链表表示的整数,每个节点包含一个数位。

这些数位是反向存放的,也就是个位排在链表首部。

编写函数对这两个整数求和,并用链表形式返回结果。

示例:

输入:(7 -> 1 -> 6) + (5 -> 9 -> 2),即617 + 295
输出:2 -> 1 -> 9,即912

进阶:思考一下,假设这些数位是正向存放的,又该如何解决呢?

示例:

输入:(6 -> 1 -> 7) + (2 -> 9 -> 5),即617 + 295
输出:9 -> 1 -> 2,即912

我的答案

/**
 * Definition for singly-linked list.
 * public class ListNode {
 *     int val;
 *     ListNode next;
 *     ListNode(int x) { val = x; }
 * }
 */
class Solution {
    public ListNode addTwoNumbers(ListNode l1, ListNode l2) {
        ListNode head = new ListNode(0);
        ListNode tail = head;

        boolean up = false; // 进位

        while (l1 != null || l2 != null || up) {
            int sum = up == true? 1 : 0;
            if (l1 != null) {
                sum += l1.val;
                l1 = l1.next;
            }
            if (l2 != null) {
                sum += l2.val;
                l2 = l2.next;
            }
            if (sum >= 10) {
                up = true;
                sum -= 10;
            } else {
                up = false;
            }
            tail.next = new ListNode(sum);
            tail = tail.next;

        }

        return head.next;
    }
}

方法二:进位用int类型表示

/**
 * Definition for singly-linked list.
 * public class ListNode {
 *     int val;
 *     ListNode next;
 *     ListNode(int x) { val = x; }
 * }
 */
class Solution {
    public ListNode addTwoNumbers(ListNode l1, ListNode l2) {
        int x = 0;  // 进位
        ListNode head = new ListNode(0);   // 哑节点
        ListNode tail = head;
        
        while(l1 != null || l2 != null || x != 0) {
            int sum = x;    // 当前位的和
            if (l1 != null) {
                sum += l1.val;
                l1 = l1.next;
            }
            if (l2 != null) {
                sum += l2.val;
                l2 = l2.next;
            }
            tail.next = new ListNode(sum % 10);
            tail = tail.next;

            x = sum / 10;
        }
        return head.next;
    }
}

大佬答案

非递归实现:

    public ListNode addTwoNumbers(ListNode l1, ListNode l2) {
        ListNode head = new ListNode(0);
        ListNode prev = head;
        int carry = 0;
        while (l1 != null || l2 != null || carry != 0) {
            int sum = (l1 != null ? l1.val : 0) + (l2 != null ? l2.val : 0) + carry;//求两个节点相加的值
            carry = sum / 10;//取进位的值
            prev.next = new ListNode(sum % 10);//sum对10求余后放到节点中
            prev = prev.next;
            l1 = l1 != null ? l1.next : l1;
            l2 = l2 != null ? l2.next : l2;
        }
        return head.next;
    }

递归实现:

    public ListNode addTwoNumbers(ListNode l1, ListNode l2) {
        ListNode head = new ListNode(0);
        helper(head, l1, l2, 0);
        return head.next;
    }

    private void helper(ListNode result, ListNode l1, ListNode l2, int carry) {
        if (l1 == null && l2 == null && carry == 0)
            return;
        int sum = (l1 != null ? l1.val : 0) + (l2 != null ? l2.val : 0) + carry;
        result.next = new ListNode(0);
        result.next.val = sum % 10;
        carry = sum / 10;
        helper(result.next, l1 != null ? l1.next : null, l2 != null ? l2.next : null, carry);
    }

415. 字符串相加

给定两个字符串形式的非负整数 num1 和num2 ,计算它们的和并同样以字符串形式返回。

你不能使用任何內建的用于处理大整数的库(比如 BigInteger), 也不能直接将输入的字符串转换为整数形式。

示例 1:

输入:num1 = "11", num2 = "123"
输出:"134"

示例 2:

输入:num1 = "456", num2 = "77"
输出:"533"

示例 3:

输入:num1 = "0", num2 = "0"
输出:"0"

我的

class Solution {
    public String addStrings(String num1, String num2) {

        int p1 = num1.length() - 1, p2 = num2.length() - 1;
        int up = 0;
        StringBuilder sb = new StringBuilder();
        
        while (p1 >= 0 || p2 >= 0 || up == 1) {
            int sum = up;
            if (p1 >= 0) {
                sum += num1.charAt(p1) - '0';
                p1--;
            }
            if (p2 >= 0) {
                sum += num2.charAt(p2) - '0';
                p2--;
            }
            // sb.insert(0, sum % 10);
            sb.append(sum % 10);
            up = sum / 10;
        }

        return sb.reverse().toString();
    }
}

答案

思路与算法

本题我们只需要对两个大整数模拟「竖式加法」的过程。竖式加法就是我们平常学习生活中常用的对两个整数相加的方法,回想一下我们在纸上对两个整数相加的操作,是不是如下图将相同数位对齐,从低到高逐位相加,如果当前位和超过 10,则向高位进一位?因此我们只要将这个过程用代码写出来即可。
image

具体实现也不复杂,我们定义两个指针 i 和 j 分别指向 \(\textit{num}_1\)\(\textit{num}_2\)的末尾,即最低位,同时定义一个变量 \(\textit{add}\) 维护当前是否有进位,然后从末尾到开头逐位相加即可。你可能会想两个数字位数不同怎么处理,这里我们统一在指针当前下标处于负数的时候返回 0,等价于对位数较短的数字进行了补零操作,这样就可以除去两个数字位数不同情况的处理,具体可以看下面的代码。

class Solution {
    public String addStrings(String num1, String num2) {
        int i = num1.length() - 1, j = num2.length() - 1, add = 0;
        StringBuffer ans = new StringBuffer();
        while (i >= 0 || j >= 0 || add != 0) {
            int x = i >= 0 ? num1.charAt(i) - '0' : 0;
            int y = j >= 0 ? num2.charAt(j) - '0' : 0;
            int result = x + y + add;
            ans.append(result % 10);
            add = result / 10;
            i--;
            j--;
        }
        // 计算完以后的答案需要翻转过来
        ans.reverse();
        return ans.toString();
    }
}

复杂度分析

  • 时间复杂度:\(O(\max(\textit{len}_1,\textit{len}_2))\),其中 \(\textit{len}_1=\textit{num}_1.\text{length}\)\(\textit{len}_2=\textit{num}_2.\text{length}\)。竖式加法的次数取决于较大数的位数。
  • 空间复杂度:\(O(1)\)。除答案外我们只需要常数空间存放若干变量。在 Java 解法中使用到了 StringBuffer,故 Java 解法的空间复杂度为 \(O(n)\)

标记法

使用一个指针cur来进行遍历前进,使用指针i1i2(或者更多)来标记我们找到的元素进行操作

瞻前顾后法

left = head; right = head.next;
同时从开头出发,向相同方向前进,左边left作为当前元素主力,右边right作为瞻前顾后指针探路。

  1. 如果有下一位,那么以当前位为基准,可以往后(前)看多一位,对比当前位与后一位的关系,从而确定当前位是该如何操作;
  2. 如果没有下一位(即 到队尾了),又该是如何操作。

注意:我们这里以right来遍历,就不会超出界限了。

适用场景:前后元素有某种逻辑上的关系。

瞻前顾后法在链表中用的那就相当多了,我们的删除节点就要使用瞻前顾后法,记录目标节点前面的那个节点(瞻前),然后进行删除。

放过法

其实链表无需放过法,其插入和删除元素很方便,不需要移动元素。

所以链表的放过法就是我们重新构造一个伪头节点,来将满足条件的元素一一放在此链表中,放过它。

问题:
在一个递增有序的线性表中,有数值相同的元素存在。若存储方式为单链表,设计算法去掉数值相同的元素,使表中不再有重复的元素。

  • 设计思想:
    由于是有序表,所有相同值域的结点都是相邻的,用p扫描递增单链表L,
    若*p结点的值域等于其后继结点的值域,则删除后者,否则p移向下一个结点。
  • 算法:
void deleteSame(LinkList &L) {
	LNode *p = L->next, *q;		//q是操作指针用来删除
	if(p == NULL) {
		return;
	}
	while(p->next != NULL) {
		if(p->data == p->next->data) {
			q = p->next;		//赋值给q,删除p->next
			p->next = q->next;
			free(q);
		}else {
			p = p->next;	//否则前进
		}
	}
}
  • 效率:
    时间复杂度:O(n)
    空间复杂度:O(1)

面试题 02.04. 分割链表

编写程序以 x 为基准分割链表,使得所有小于 x 的节点排在大于或等于 x 的节点之前。如果链表中包含 x,x 只需出现在小于 x 的元素之后(如下所示)。分割元素 x 只需处于“右半部分”即可,其不需要被置于左右两部分之间。

示例:

输入: head = 3->5->8->5->10->2->1, x = 5
输出: 3->1->2->10->5->5->8

答案

此题我们构造了两个伪头节点,small 链表按顺序存储所有小于 x 的节点,large 链表按顺序存储所有大于等于 x 的节点,即 用了两次放过法。

/**
 * Definition for singly-linked list.
 * public class ListNode {
 *     int val;
 *     ListNode next;
 *     ListNode(int x) { val = x; }
 * }
 */
class Solution {
    public ListNode partition(ListNode head, int x) {
        // 放过法
        ListNode smallHead = new ListNode(0);
        ListNode smallTail = smallHead;

        ListNode largeHead = new ListNode(0);
        ListNode largeTail = largeHead;

        ListNode cur = head;  // 遍历
        ListNode p = null;  // 操作

        while (cur != null) {
            p = cur;
            cur = cur.next;

            if (p.val < x) {

                p.next = smallTail.next;
                smallTail.next = p;
                smallTail = smallTail.next;
            } else {
                p.next = largeTail.next;
                largeTail.next = p;
                largeTail = largeTail.next;
            }
        }

        smallTail.next = largeHead.next;
        return smallHead.next;
    }
}

归并算法

两个已经有序的线性表,归并为一个有序的线性表。(两两比较取较小值放入新的有序线性表

问题:已知两个链表A和B分别表示两个集合,其元素递增序列。编制函数,求A与B的交集,并存放于A链表中。

  • 设计思想:
    采用归并的思想,设置两个工作指针pa和pb,对两个链表进行归并扫描,只有同时出现在两集合中的元素才链接到结果表中且进保留一个,其他的结点全部释放。当一个遍历完毕后,释放另一个表剩下的全部结点。
  • 算法:
LinkList same(LinkList &A, LinkList &B) {
	LNode *pa = A->next, *pb = B->next, *q, *t = A;		//q为操作指针,t为尾指针
	A->next = NULL;		//摘下头结点
	while(pa && pb) {
		if(pa->data == pb->data) {
			q = pa;
			pa = pa->next;
			q->next = t->next;		//将A集合那个元素插入到结果表中(尾插法)
			t->next = q;
			t = q;			//尾插法必备
			
			q = pb;			//将B集合中相同的元素释放
			pb = pb->next;
			free(q);		//这两个集合,每扫描一个元素就释放一个,所以每次都是释放第一个元素,无需前驱后继链接
		}else if(pa->data < pb->data) {		//如果pa中的元素值比较小,那么后移指针,找大一点的(这样才能与pb匹配)
			q = pa;
			pa = pa->next;
			free(u);
		}else {
			q = pb;
			pb = pb->next;
			free(q);
		}
	}
	//如果还有链表非空,释放剩下的部分
	if(pb) {
		pa = pb;
	}
	while(pa) {
		q = pa;
		pa = pa->next;
		free(q);
	}
	free(B);		//释放B表头结点
	return A;
}
  • 效率:
    时间复杂度:O(len1+len2) //while(pa && pb)
    空间复杂度:O(1)

剑指 Offer 25. 合并两个排序的链表

输入两个递增排序的链表,合并这两个链表并使新链表中的节点仍然是递增排序的。

示例1:
输入:1->2->4, 1->3->4
输出:1->1->2->3->4->4

限制:
0 <= 链表长度 <= 1000

答案

/**
 * Definition for singly-linked list.
 * public class ListNode {
 *     int val;
 *     ListNode next;
 *     ListNode(int x) { val = x; }
 * }
 */
class Solution {
    public ListNode mergeTwoLists(ListNode l1, ListNode l2) {

        ListNode head = new ListNode(0);

        ListNode tail = head;   // 尾插,尾指针

        ListNode p = null;  // 操作指针


        // 遍历两个链表,比较谁结点小,谁结点小就尾插到新链表
        while (l1 != null && l2 != null) {

            if (l1.val <= l2.val) {
                p = l1;
                l1 = l1.next;
                
            } else {
                p = l2;
                l2 = l2.next;
            }
            p.next = tail.next;
            tail.next = p;
            tail = tail.next;
        }

        // 把没遍历完的链表直接加在新链表后面
        if (l1 != null) {
            tail.next = l1;
        }

        if (l2 != null) {
            tail.next = l2;
        }

        return head.next;
    }
}

标尺法

找倒数第k个元素

  • 设计思想:
    MY:扫描链表所有结点,得到链表长度n;再从头扫描第n-k+1个结点后,即是倒数第k个,输出。
    标尺法:我们起始就声明一个双指针 p、q,指针 p 固定在开头,指针 q 到指针 p 的长度为 k,这就相当于一把尺,我们将指针 q 遍历到最后时,我们的指针 p 即为倒数第 k 个结点。
    如 p 在第 1 个结点,q 在第 3 个结点,此时我们的 q 是顺数第 3 个节点,那么我们的 q 遍历到最后一个节点时,我们的 p 即为倒数第 3 个结点。

  • 算法:
typedef int ElemType;
typedef struct LNode {
	ElemType data;
	struct LNode *link;
}LNode, *LinkList;
	
int searchK1(LinkList list, int k) {
	LNode *p = list->link;
	int length = 0;
	int count = 0;
	while(p) {		//得到表长
		length++;
		p = p->link;
	}
	if(k<1 || k>length) {	//合法性检验
		return 0;
	}
	
	p = list->link;
	while(p) {			//寻找倒数第k个
		count++;
		if(count == length-k+1) {
			printf("%d", p->data);
			return 1;
		}else {
			p = p->link;
		}
	}
}
  • 标尺法:
int searchK2(LinkList list, int k) {
	LNode *p = list->link, *q = list->link; // 同一起点,到最后会是倒数第1个
	for (int i = 1; i < k; i++) { //所以i从1开始,前进k-1格,找到第k个结点p(标尺长度为k)
		p = p.next;
	}
	if(!p) {		//如果p不存在,即p遍历完了链表,没找到第k个
		return 0;
	}
	while(p->next) {		//同步后移 (走到头,由于标尺长度为k,所以q就是倒数第k个)
		p = p->link;
		q = q->link;
	}
	printf("%d", q->data);
	return 1;
	
}
  • 效率:
    时间复杂度:O(n)
    空间复杂度:O(1)

19. 删除链表的倒数第 N 个结点

给你一个链表,删除链表的倒数第 n 个结点,并且返回链表的头结点。

进阶:你能尝试使用一趟扫描实现吗?

示例 1:

输入:head = [1,2,3,4,5], n = 2
输出:[1,2,3,5]

示例 2:
输入:head = [1], n = 1
输出:[]

示例 3:
输入:head = [1,2], n = 1
输出:[1]

提示:

  • 链表中结点的数目为 sz
  • 1 <= sz <= 30
  • 0 <= Node.val <= 100
  • 1 <= n <= sz

答案

首先,我们可以看到,示例图上的链表没有头节点,所以我们加上头节点,方便操作!
ListNode newHead = new ListNode(0, head);

再就是标尺法了,在这里就不赘述了

/**
 * Definition for singly-linked list.
 * public class ListNode {
 *     int val;
 *     ListNode next;
 *     ListNode() {}
 *     ListNode(int val) { this.val = val; }
 *     ListNode(int val, ListNode next) { this.val = val; this.next = next; }
 * }
 */
class Solution {
    public ListNode removeNthFromEnd(ListNode head, int n) {

        // 双指针标尺法,p指向1,q指向n,当指向n的q前进到最后,p即为倒数第n个结点
        ListNode newHead = new ListNode(0, head);
        ListNode p = newHead;
        ListNode q = newHead;

        // q向后移动n个结点,即 后面p将为倒数第n个的前驱结点
        for (int i = 0; i < n; i++) {
            q = q.next;
        }

        // p、q共同前进
        while (q.next != null) {
            p = p.next;
            q = q.next;
        }

        // 删除p的后继结点,p为倒数第n个的前驱结点
        p.next = p.next.next;

        return newHead.next;
    }
}

同起共进法(同一起点共同前进)(共同后缀)

(同起共进法)共同后缀

问题:str1和str2两个链表,在后面一部分指向了同一条子链表,是公共的,要求出共同后缀。

  • 设计思想:
    1. 分别求出str1和str2所指的两个链表长度m和n。
    2. 将两个链表以表尾对齐:令指针p、q分别指向str1和str2的头结点,
      若m≥n,则指针p先走,使p指向链表中的第m-n+1个结点;(m-n=长度差,又因为从0开始,所以-1
      若m<n,则使q指向链表中的第n-m+1个结点,即 (使指针p和q所指的结点到表尾的长度相等)。
    3. 反复将指针p和q同步向后移动,当p、q指向同一位置时停止,即为共同后缀的起始位置,算法结束。
  • 算法:
typedef struct Node {
	char data;
	struct Node *next;
}SNode;
	//求链表长度
int listlen(SNode *head) {
	int len = 0;
	while(head->next!=NULL) {
		len++;
		head = head->next;
	}
	return len;
}

//找出共同后缀的起始地址(所以不能改变链表),不然地址变了
SNode *find_addr(SNode *str1, SNode *str2) {
	int m, n, i;
	SNode *p, *q;
	m = listlen(str1);
	n = listlen(str2);
	for(i = 0, p=str1; i<m-n+1; i++) {		//找到第m-n+1个结点	//或者i = 1, p=str1; i<=m-n+1; i++
		p = p->next;
	}
	for(i = 0, q=str2; i<n-m+1; i++) {
		q = q->next;
	}
	while(p->next!=NULL && p->next!=q->next) {
		p = p->next;
		q = q->next;
	}
	return p->next;
}
  • 效率:
    时间复杂度:O(len1+len2),其中len1、len2分别为两个链表的长度
    空间复杂度:O(1)
/**
 * Definition for singly-linked list.
 * public class ListNode {
 *     int val;
 *     ListNode next;
 *     ListNode(int x) {
 *         val = x;
 *         next = null;
 *     }
 * }
 */
public class Solution {
    public ListNode getIntersectionNode(ListNode headA, ListNode headB) {
        // 1.让二者分别走到链表末尾,测出各自长度
        int lenA = listLen(headA);
        int lenB = listLen(headB);

        ListNode pA = headA;
        ListNode pB = headB;

	// 2.得到分别链长的差值,让长的先走这个差值
	int diff = lenA - lenB;
	if (diff > 0) {
		int step = diff;
		while (step > 0) {
			pA = pA.next;
			--step;
		}
	} else if (diff < 0) {
		int step = diff;
		while (step < 0) {
			pB = pB.next;
			++step;
		}
	}
	// 3.两指针往前走,相遇即为所求
	while (pA != pB) {
		pA = pA.next;
		pB = pB.next;
	}
	return pA;
    }


    public int listLen(ListNode head) {
        int len = 0;
        while (head != null) {
            len++;
            head = head.next;
        }
        return len;
    }
}

同起共进法优化版:
将两个链表逻辑上拼在一起,凑成相同长度的两个链表,这两个链表尾部对齐后缀对齐,我们就可以在前进的过程中两两比较了,遇到相同后缀,直接返回。
image

/**
 * Definition for singly-linked list.
 * public class ListNode {
 *     int val;
 *     ListNode next;
 *     ListNode(int x) {
 *         val = x;
 *         next = null;
 *     }
 * }
 */

public class Solution {
    // 同起共进我们在主站中提交过了,这里换个方法!
    // 双指针
    public ListNode getIntersectionNode(ListNode headA, ListNode headB) {

        if (headA == null || headB == null) {
            return null;
        }
        
        // 快慢指针
        ListNode pA = headA;
        ListNode pB = headB;

        // 如果A、B链表不相交,那么pA和pB都会在null相遇
        // 如果相交,那就会在结点相遇
        while (pA != pB) {
            // 第一种方法:我们依靠循环链表使两个指针不停的走,由于一定有公共节点(不相交,公共节点就是null),所以一定会相遇,无论是链表长度一致还是不一致,都会相遇
            // 长度不一致,循环遍历会造成速度差,一定会相遇
            // 长度一致,即使没有公共节点,会同时遇到null,也一定会相遇
            // 这虽然也能通过,但是运行时间增加了
            // pA = pA == null ? headA : pA.next;
            // pB = pB == null ? headB : pB.next;

            // 方法二:我们消除A、B链表的长度差,让长度都为A+B,那就一定会相遇,即使没有公共节点,也会同时遇到null
            pA = pA != null ? pA.next : headB;
            pB = pB != null ? pB.next : headA;
        }
        return pA;
    }
}

复杂度分析

  • 时间复杂度 : \(O(m+n)\)
  • 空间复杂度 : \(O(1)\)

剑指 Offer 52. 两个链表的第一个公共节点

输入两个链表,找出它们的第一个公共节点。

如下面的两个链表:

在节点 c1 开始相交。

示例 1:

输入:intersectVal = 8, listA = [4,1,8,4,5], listB = [5,0,1,8,4,5], skipA = 2, skipB = 3
输出:Reference of the node with value = 8
输入解释:相交节点的值为 8 (注意,如果两个列表相交则不能为 0)。从各自的表头开始算起,链表 A 为 [4,1,8,4,5],链表 B 为 [5,0,1,8,4,5]。在 A 中,相交节点前有 2 个节点;在 B 中,相交节点前有 3 个节点。

示例 2:

输入:intersectVal = 2, listA = [0,9,1,2,4], listB = [3,2,4], skipA = 3, skipB = 1
输出:Reference of the node with value = 2
输入解释:相交节点的值为 2 (注意,如果两个列表相交则不能为 0)。从各自的表头开始算起,链表 A 为 [0,9,1,2,4],链表 B 为 [3,2,4]。在 A 中,相交节点前有 3 个节点;在 B 中,相交节点前有 1 个节点。

示例 3:

输入:intersectVal = 0, listA = [2,6,4], listB = [1,5], skipA = 3, skipB = 2
输出:null
输入解释:从各自的表头开始算起,链表 A 为 [2,6,4],链表 B 为 [1,5]。由于这两个链表不相交,所以 intersectVal 必须为 0,而 skipA 和 skipB 可以是任意值。
解释:这两个链表不相交,因此返回 null。

答案

  • 创建两个指针 pA 和 pB,分别初始化为链表 A 和 B 的头结点。然后让它们向后逐结点遍历。
  • 当 pA 到达链表的尾部时,将它重定位到链表 B 的头结点 (你没看错,就是链表 B); 类似的,当 pB 到达链表的尾部时,将它重定位到链表 A 的头结点。
  • 若在某一时刻 pA 和 pB 相遇,则 pA/pB 为相交结点。
  • 想弄清楚为什么这样可行, 可以考虑以下两个链表: A={1,3,5,7,9,11} 和 B={2,4,9,11},相交于结点 9。 由于 B.length (=4) < A.length (=6),pB 比 pA 少经过 2 个结点,会先到达尾部。将 pB 重定向到 A 的头结点,pA 重定向到 B 的头结点后,pB 要比 pA 多走 2 个结点。因此,它们会同时到达交点。
  • 如果两个链表存在相交,它们末尾的结点必然相同。因此当 pA/pB 到达链表结尾时,记录下链表 A/B 对应的元素。若最后元素不相同,则两个链表不相交。

根据题目意思
如果两个链表相交,那么相交点之后的长度是相同的

我们需要做的事情是,让两个链表从同距离末尾同等距离的位置开始遍历。这个位置只能是较短链表的头结点位置。
为此,我们必须消除两个链表的长度差:让两个指针遍历 A+B 和 B+A。

  1. 指针 pA 指向 A 链表,指针 pB 指向 B 链表,依次往后遍历
  2. 如果 pA 到了末尾,则 pA = headB 继续遍历
  3. 如果 pB 到了末尾,则 pB = headA 继续遍历
  4. 比较长的链表指针指向较短链表head时,长度差就消除了
  5. 如此,只需要将最短链表遍历两次即可找到位置

听着可能有点绕,看图最直观,链表的题目最适合看图了
image

/**
 * Definition for singly-linked list.
 * public class ListNode {
 *     int val;
 *     ListNode next;
 *     ListNode(int x) {
 *         val = x;
 *         next = null;
 *     }
 * }
 */

public class Solution {
    // 同起共进我们在主站中提交过了,这里换个方法!
    // 双指针
    public ListNode getIntersectionNode(ListNode headA, ListNode headB) {

        if (headA == null || headB == null) {
            return null;
        }
        
        // 快慢指针
        ListNode pA = headA;
        ListNode pB = headB;

        // 如果A、B链表不相交,那么pA和pB都会在null相遇
        // 如果相交,那就会在结点相遇
        while (pA != pB) {
            // 第一种方法:我们依靠循环链表使两个指针不停的走,由于一定有公共节点(不相交,公共节点就是null),所以一定会相遇,无论是链表长度一致还是不一致,都会相遇
            // 长度不一致,循环遍历会造成速度差,一定会相遇
            // 长度一致,即使没有公共节点,会同时遇到null,也一定会相遇
            // 这虽然也能通过,但是运行时间增加了
            // pA = pA == null ? headA : pA.next;
            // pB = pB == null ? headB : pB.next;

            // 方法二:我们消除A、B链表的长度差,让长度都为A+B,那就一定会相遇,即使没有公共节点,也会同时遇到null
            pA = pA != null ? pA.next : headB;
            pB = pB != null ? pB.next : headA;
        }
        return pA;
    }
}

快慢指针法

适用场景: 有一个元素重复有环循环无限循环环环环!!

  • 题目有可能没有明确的给你一个链表,有可能是隐式链表,里面的数列存在循环,也可使用快慢指针,在数列上进行前进
  • 检测一个链表是否有环
  • 检测链表的公共节点
  • 找链表的中心节点(或1/n)位置
  • 有一个元素重复,有可能是隐式链表,可以组成一个环

运用方法:弗洛伊德循环查找算法(快慢指针法/龟兔指针法)

其实就是一个走得快、一个走得慢,龟兔之间拥有速度差,运用地球是圆的这个原理,不管在循环中从哪里开始,他们一定会相遇。但是,最好还是初始值不相等比较好,不然可能在下一步循环操作时,误判直接相遇。

  • 特性:快指针比慢指针快n倍,所以相同时间内快指针走过的路程也是慢指针的n倍。

注意:这个特性可以用在很多地方。


细节!!!!!!!

为什么我们要规定初始时慢指针在位置 head,快指针在位置 head.next,而不是两个指针都在位置 head(即与「乌龟」和「兔子」中的叙述相同)?

观察下面的代码,我们使用的是 while 循环,循环条件先于循环体。由于循环条件一定是判断快慢指针是否重合,如果我们将两个指针初始都置于 head,那么 while 循环就不会执行。因此,我们可以假想一个在 head 之前的虚拟节点,慢指针从虚拟节点移动一步到达 head,快指针从虚拟节点移动两步到达 head.next,这样我们就可以使用 while 循环了。

当然,我们也可以使用 do-while 循环。此时,我们就可以把快慢指针的初始值都置为 head。

或者我们可以把快慢指针的初始值都置为 head,然后在循环内部进行相遇判断。


方法一:同起点,更具一般性(我喜欢)

  • 起始:慢指针slow = head;,快指针fast = head;
  • 循环条件:当快指针没有为空,没有遍历完,那就一直执行,while (fast != null && fast.next != null)

注意:此时不用判断慢指针,因为快指针在前面,拿快指针探路即可,类比滑动窗口的右指针

  • 循环结束条件:当快慢指针相遇,找到环了,循环结束,if (slow == fast)
public class Solution {
    public boolean hasCycle(ListNode head) {
        
        // 双指针,快慢指针
        if (head == null) {
            return false;
        }
        ListNode slow = head;  // 慢指针
        ListNode fast = head;  // 快指针

		// 如果快指针都遍历完了,那就可以出去了,说明没有环
        // 这里需要注意的是fast.next也需要判断是否为空,因为fast一次走两步,你得判断每一步是否为空遍历完了
        while (fast != null && fast.next != null) {  // 当快指针没有遍历完,就继续遍历
            slow = slow.next;
            fast = fast.next.next;
			
			if (fast == slow) {
                return true;	// 有环
            }
        }

        return false;
    }
}

方法二:不同起点

  • 起始:慢指针slow = head;,快指针fast = head.next;
  • 循环条件:当两个指针没有相遇,那就一直执行,while (slow != fast)
  • 循环结束条件:当快指针遍历完了,循环结束,if (fast == null || fast.next == null)
public class Solution {
    public boolean hasCycle(ListNode head) {
        
        // 双指针,快慢指针
        if (head == null) {
            return false;
        }
        ListNode slow = head;  // 慢指针
        ListNode fast = head.next;  // 快指针

        while (slow != fast) {  // 当他俩不相遇就一直执行
            // 如果快指针都遍历完了,那就可以出去了,说明没有环
            // 这里需要注意的是fast.next也需要判断是否为空,因为fast一次走两步,你得判断每一步是否为空遍历完了
            if (fast == null || fast.next == null) {
                return false;	// 没有环
            }
            slow = slow.next;
            fast = fast.next.next;
        }

        return true;	// 有环
    }
}

理论支撑

下面将讨论两个问题:

    1. 为什么快慢指针可以找到环?
    1. 通常,快指针的移动速度是慢指针的2倍,为什么是2倍? 3倍、4倍可以么?

问题1, 为什么快慢指针可以找到环?

通俗的解释:我们的两个指针相当于两只动物,兔子和乌龟,兔子速度快,乌龟速度慢,两者在环内相互追赶,在某一时刻,兔子肯定会追上乌龟。我们可以用反正法,假设两者不能相遇,两者的速度(匀速)必须相等,否则肯定相遇。

如何用数学的形式证明呢?

假设环的长度为 \(l\), 链表的形式为 xk -> xk+1 -> ... -> xk+l-1 -> xk, 慢指针 p1 的移动速度 \(r1 = 1\), 快指针 p2 的移动速度为 \(r2\)
当 p1 到达 xk时, p2 在环内的xk+s, 其中 \(0<=s<l\)
当 p1 移动了 m 长时,p1 所在结点为 \(xk+(m mod l)\) ,p2 所在结点为 \(xk+((m\*r2+s) mod l)\) ,此时,问题转化为寻找一个 m,使得其满足如下等式 \(m = m\*r2 + s (mod l)\)
我们转换上述公式的形式得(利用一点数论的知识): \(m(1-r2) = s (mod l)\) <=> \(m(l+1-r2) = s (mod l)\)。此时等式的形式为“线性同余等式”,
那么什么情况下可以得到 m 呢?It has a solution m if s is divisible by \(gcd(l+1-r2,l)\) 。也就是说,s 能够被 \(gcd(l+1-r2, l)\) 整除。
如果我们令 \(r2 = 2\) ,那么, \(gcd(l+1-r2,l) = gcd(l-1,l) = 1\) ,此时可以得到一个 m。因此, \(r2 = 2\)\(r1 = 1\) 是一对很好的参数,并且能保能“最快相遇”。

问题2, 通常,快指针的移动速度是慢指针的2倍,为什么是2倍? 3倍、4倍可以么?

符号:
n : lengh of cycle(环的长度)
m : distance of first node of cycle from head(环第一个节点到头的距离)
k : distance of point where slow and fast point meet(快慢指针相遇的距离)
x : Number of complete cyclic rounds made by fast pointer before they meet first time(快速指针在第一次相遇之前所完成的循环次数)
y : Number of complete cyclic rounds made by slow pointer before they meet first time(慢指针在第一次相遇之前所完成的循环次数)
r : ratio of fast and slow point(快慢指针速度比)

如果两个指针相遇,\(快指针走的距离 = r * 慢指针走的距离\);即 \(m + x\*n + k = r \* (m + ny +k)\)
所以,\(n(x-r\*y) = (r-1)(m+k)\),我们可以看出,如果\(r = 1\),即两个指针以同样的速度移动,是检测不到环的;如果 \(r > 1\),那么 \(n = (m+k)\),即(m+k) 是 n 的整数倍,也就是说,\(链表头到环首的距离 + 相遇点到环首的距离 = 环长度的整数倍\)
为了尽可能快的相遇,我们令 \(y = 0\),那么 \((m+k) = n \* (x-r\*y) / (r-1) = n \* x / (r-1)\)。由于 n 和 m 是固定的,那么,k 越小,两者越早相遇(这是显而易见的,k 越小,慢指针走的越少),所以我们调整 \(x/(r-1)\),使其尽可能的小,即 \(x/(r-1) = 1\) <=> \(r = x + 1\), 因为 x 是 快指针走过的环数,当 \(x = 1\) 时,\(r = 2\),即,快指针是慢指针的2倍

[1] [Detect and Remove Loop in a Linked List](Detect and Remove Loop in a Linked List)
[2] [Proof of detecting the start of cycle in linked list](algorithm - Proof of detecting the start of cycle in linked list)
[3] [Why increase pointer by two while finding loop in linked list, why not 3,4,5](algorithm - Why increase pointer by two while finding loop in linked list, why not 3,4,5?)

参考文献:为什么用快慢指针找链表的环,快指针和慢指针一定会相遇?

那会不会存在永远无法相遇的情况呢?

答案是肯定的,会!不同起点的话,速度一个1、一个3,是有可能不会相遇的。

比如说2个节点的环,初始slow=1,fast=2,那么如果slow的速度为1,fast的速度为3,那么永远都不可能相遇。

  1. slow = 1,fast = 2
  2. slow = 2,fast = 1
  3. slow = 1,fast = 2

假如6个节点的环,初始slow=1,fast=2,那永远无法相遇,但是,有两个大前提:初始化 slow 和 fast 是链表的头节点,fast 和 slow步长分别为3和1。但实际上在这个前提下不可能出现进入环的时候fast正好比slow快1,因为假如链表本身就是一个首尾相连的环,那初始化必定slow = fast = 1;假如链表是先走一段距离再进入环,假如这段距离是x,我们假定不管x是多少,这个环依然是编号1-6这六个节点(可以自己画图看看):

  1. 当x=0,就是上面初始化的情况,slow = fast = 1;
  2. 当x=1,进入环的时候可能是 slow=1,fast=3;
  3. 当x=2,进入环的时候可能是 slow=1,fast=5;
  4. 当x=3,同情况1,其实奇数节点的环也类似。

综上只要

  1. 有环
  2. 初始化fast和slow为链表头节点

那不管它们是几倍的关系,都是可以相遇的,只不过是多转几圈的问题。

141. 环形链表

给定一个链表,判断链表中是否有环。

如果链表中有某个节点,可以通过连续跟踪 next 指针再次到达,则链表中存在环。 为了表示给定链表中的环,我们使用整数 pos 来表示链表尾连接到链表中的位置(索引从 0 开始)。 如果 pos 是 -1,则在该链表中没有环。注意:pos 不作为参数进行传递,仅仅是为了标识链表的实际情况。

如果链表中存在环,则返回 true 。 否则,返回 false 。

进阶:

你能用 O(1)(即,常量)内存解决此问题吗?

示例 1:

输入:head = [3,2,0,-4], pos = 1
输出:true
解释:链表中有一个环,其尾部连接到第二个节点。

示例 2:

输入:head = [1,2], pos = 0
输出:true
解释:链表中有一个环,其尾部连接到第一个节点。

示例 3:

输入:head = [1], pos = -1
输出:false
解释:链表中没有环。

答案

方法一:哈希表

思路及算法

最容易想到的方法是遍历所有节点,每次遍历到一个节点时,判断该节点此前是否被访问过。

具体地,我们可以使用哈希表来存储所有已经访问过的节点。每次我们到达一个节点,如果该节点已经存在于哈希表中,则说明该链表是环形链表,否则就将该节点加入哈希表中。重复这一过程,直到我们遍历完整个链表即可。

代码

Java

public class Solution {
    public boolean hasCycle(ListNode head) {
        Set<ListNode> seen = new HashSet<ListNode>();
        while (head != null) {
            if (!seen.add(head)) {
                return true;
            }
            head = head.next;
        }
        return false;
    }
}

复杂度分析

  • 时间复杂度:\(O(N)\)O(N),其中 N 是链表中的节点数。最坏情况下我们需要遍历每个节点一次。

  • 空间复杂度:\(O(N)\)O(N),其中 N 是链表中的节点数。主要为哈希表的开销,最坏情况下我们需要将每个节点插入到哈希表中一次。

Map实现

/**
 * Definition for singly-linked list.
 * class ListNode {
 *     int val;
 *     ListNode next;
 *     ListNode(int x) {
 *         val = x;
 *         next = null;
 *     }
 * }
 */
public class Solution {
    public boolean hasCycle(ListNode head) {
        
        // 首先试试哈希表(node, 出现次数)
        Map<ListNode, Integer> map = new HashMap<>();

        ListNode p = head;  // 操作指针
        while (p != null) {

            // p的出现次数
            int count = map.getOrDefault(p, 0) + 1;
            if (count > 1) {
                return true;
            } else {

                map.put(p, count);
            }

            // 操作指针p前进
            p = p.next;
        }
        return false;
    }
}
方法二:快慢指针

思路及算法

本方法需要读者对「Floyd 判圈算法」(又称龟兔赛跑算法)有所了解。

假想「乌龟」和「兔子」在链表上移动,「兔子」跑得快,「乌龟」跑得慢。当「乌龟」和「兔子」从链表上的同一个节点开始移动时,如果该链表中没有环,那么「兔子」将一直处于「乌龟」的前方;如果该链表中有环,那么「兔子」会先于「乌龟」进入环,并且一直在环内移动。等到「乌龟」进入环时,由于「兔子」的速度快,它一定会在某个时刻与乌龟相遇,即套了「乌龟」若干圈。

我们可以根据上述思路来解决本题。具体地,我们定义两个指针,一快一慢。慢指针每次只移动一步,而快指针每次移动两步。初始时,慢指针在位置 head,而快指针在位置 head.next。这样一来,如果在移动的过程中,快指针反过来追上慢指针,就说明该链表为环形链表。否则快指针将到达链表尾部,该链表不为环形链表。

细节!!!!!!!

为什么我们要规定初始时慢指针在位置 head,快指针在位置 head.next,而不是两个指针都在位置 head(即与「乌龟」和「兔子」中的叙述相同)?

观察下面的代码,我们使用的是 while 循环,循环条件先于循环体。由于循环条件一定是判断快慢指针是否重合,如果我们将两个指针初始都置于 head,那么 while 循环就不会执行。因此,我们可以假想一个在 head 之前的虚拟节点,慢指针从虚拟节点移动一步到达 head,快指针从虚拟节点移动两步到达 head.next,这样我们就可以使用 while 循环了。

当然,我们也可以使用 do-while 循环。此时,我们就可以把快慢指针的初始值都置为 head。

代码

Java

public class Solution {
    public boolean hasCycle(ListNode head) {
        if (head == null || head.next == null) {
            return false;
        }
        ListNode slow = head;
        ListNode fast = head.next;
        while (slow != fast) {
            if (fast == null || fast.next == null) {
                return false;
            }
            slow = slow.next;
            fast = fast.next.next;
        }
        return true;
    }
}

同起点:

public class Solution {
    public boolean hasCycle(ListNode head) {
        // 快慢指针 Java中准确来说称为 对象的引用
        ListNode fast = head;
        ListNode slow = head;

        // 循环内 slow 每次后移一个结点, fast 每次后移 2 个结点
        // fast 和 fast.next 需要不为空,否则会发生 空指针异常
        // fast 不为空,那么 slow 肯定也不为空
        while(fast != null && fast.next != null) {
            slow = slow.next;
            fast = fast.next.next;

            // 汽车追上自行车了,有环路
            if(fast == slow) return true;
        }
        return false;
    }
}

复杂度分析

  • 时间复杂度:\(O(N)\),其中 N 是链表中的节点数。

    • 当链表中不存在环时,快指针将先于慢指针到达链表尾部,链表中每个节点至多被访问两次。
    • 当链表中存在环时,每一轮移动后,快慢指针的距离将减小一。而初始距离为环的长度,因此至多移动 N 轮。
  • 空间复杂度:\(O(1)\)。我们只使用了两个指针的额外空间。

202. 快乐数

编写一个算法来判断一个数 n 是不是快乐数。

「快乐数」定义为:

  • 对于一个正整数,每一次将该数替换为它每个位置上的数字的平方和。
  • 然后重复这个过程直到这个数变为 1,也可能是 无限循环 但始终变不到 1。
  • 如果 可以变为  1,那么这个数就是快乐数。

如果 n 是快乐数就返回 true ;不是,则返回 false 。

设计思想:

通过反复调用 getNext(n) 得到的链是一个隐式的链表。隐式意味着我们没有实际的链表节点和指针,但数据仍然形成链表结构。起始数字是链表的头 “节点”,链中的所有其他数字都是节点。next 指针是通过调用 getNext(n) 函数获得。

意识到我们实际有个链表,那么这个问题就可以转换为检测一个链表是否有环。因此我们在这里可以使用弗洛伊德循环查找算法。这个算法是两个奔跑选手,一个跑的快,一个跑得慢。在龟兔赛跑的寓言中,跑的快的称为 “乌龟”,跑得快的称为 “兔子”。

不管乌龟和兔子在循环中从哪里开始,它们最终都会相遇。这是因为兔子每走一步就向乌龟靠近一个节点(在它们的移动方向上)。但是,最好还是初始值不相等比较好,不然可能在下一步循环操作时,误判直接相遇。

算法

我们不是只跟踪链表中的一个值,而是跟踪两个值,称为快跑者和慢跑者。在算法的每一步中,慢速在链表中前进 1 个节点,快跑者前进 2 个节点(对 getNext(n) 函数的嵌套调用)。

  • 如果 n 是一个快乐数,即没有循环,那么快跑者最终会比慢跑者先到达数字 1。

  • 如果 n 不是一个快乐的数字,那么最终快跑者和慢跑者将在同一个数字上相遇。

class Solution {
     // 得到下一个数
     public int getNext(int n) {
        int totalSum = 0;
        while (n > 0) {
            int d = n % 10;
            n = n / 10;
            totalSum += d * d;
        }
        return totalSum;
    }

    public boolean isHappy(int n) {
        // 初始值从哪开始都行,有速度差只要是循环都能相遇
        int slowRunner = n;
        int fastRunner = getNext(n);  // 但是,这里必须和slowRunner不相等,不然下一步就会判断直接相遇
        while (fastRunner != 1 && slowRunner != fastRunner) {
            slowRunner = getNext(slowRunner);  // 龟走一步
            fastRunner = getNext(getNext(fastRunner));  // 兔走两步
        }
        return fastRunner == 1;
    }
}

复杂度分析

  • 时间复杂度:\(O(\log n)\)。该分析建立在对前一种方法的分析的基础上,但是这次我们需要跟踪两个指针而不是一个指针来分析,以及在它们相遇前需要绕着这个循环走多少次。
    • 如果没有循环,那么快跑者将先到达 1,慢跑者将到达链表中的一半。我们知道最坏的情况下,成本是 \(O(2 \cdot \log n) = O(\log n)\)
    • 一旦两个指针都在循环中,在每个循环中,快跑者将离慢跑者更近一步。一旦快跑者落后慢跑者一步,他们就会在下一步相遇。假设循环中有 k 个数字。如果他们的起点是相隔 k−1 的位置(这是他们可以开始的最远的距离),那么快跑者需要 k-1 步才能到达慢跑者,这对于我们的目的来说也是不变的。因此,主操作仍然在计算起始 n 的下一个值,即 \(O(\log n)\)
  • 空间复杂度:\(O(1)\),对于这种方法,我们不需要哈希集来检测循环。指针需要常数的额外空间。

当然,也可以用哈希集合来判断循环,这里只给出代码,就不分析了,很简单

class Solution {
    private int getNext(int n) {
        int totalSum = 0;
        while (n > 0) {
            int d = n % 10;
            n = n / 10;
            totalSum += d * d;
        }
        return totalSum;
    }

    public boolean isHappy(int n) {
        Set<Integer> seen = new HashSet<>();
        while (n != 1 && !seen.contains(n)) {
            seen.add(n);
            n = getNext(n);
        }
        return n == 1;
    }
}

我的

class Solution {
    public boolean isHappy(int n) {

        int slow = n, fast = n;

        while (true) {
            slow = getNext(slow);
            fast = getNext(getNext(fast));

            if (slow == 1) {
                return true;
            } else if (slow == fast) {
                return false;
            }
        }
    }

    public int getNext(int n) {
        int totalSum = 0;
        while (n > 0) {
            int d = n % 10;
            n = n / 10;
            totalSum += d * d;
        }
        return totalSum;
    }
}

142. 环形链表 II

给定一个链表,返回链表开始入环的第一个节点。 如果链表无环,则返回 null。

为了表示给定链表中的环,我们使用整数 pos 来表示链表尾连接到链表中的位置(索引从 0 开始)。 如果 pos 是 -1,则在该链表中没有环。注意,pos 仅仅是用于标识环的情况,并不会作为参数传递到函数中。

说明:不允许修改给定的链表。

进阶:

你是否可以使用 O(1) 空间解决此题?

示例 1:
image

输入:head = [3,2,0,-4], pos = 1
输出:返回索引为 1 的链表节点
解释:链表中有一个环,其尾部连接到第二个节点。

示例 2:
image

输入:head = [1,2], pos = 0
输出:返回索引为 0 的链表节点
解释:链表中有一个环,其尾部连接到第一个节点。

示例 3:
image

输入:head = [1], pos = -1
输出:返回 null
解释:链表中没有环。

方法一:哈希表

思路与算法

一个非常直观的思路是:我们遍历链表中的每个节点,并将它记录下来;一旦遇到了此前遍历过的节点,就可以判定链表中存在环。借助哈希表可以很方便地实现。

代码

public class Solution {
    public ListNode detectCycle(ListNode head) {
        ListNode pos = head;
        Set<ListNode> visited = new HashSet<ListNode>();
        while (pos != null) {
            if (visited.contains(pos)) {
                return pos;
            } else {
                visited.add(pos);
            }
            pos = pos.next;
        }
        return null;
    }
}

复杂度分析

  • 时间复杂度:\(O(N)\),其中 N 为链表中节点的数目。我们恰好需要访问链表中的每一个节点。

  • 空间复杂度:\(O(N)\),其中 N 为链表中节点的数目。我们需要将链表中的每个节点都保存在哈希表当中。

方法二:快慢指针

思路与算法

我们使用两个指针,\(\textit{fast}\)\(\textit{slow}\)。它们起始都位于链表的头部。随后,\(\textit{slow}\) 指针每次向后移动一个位置,而 \(\textit{fast}\) 指针向后移动两个位置。如果链表中存在环,则 \(\textit{fast}\) 指针最终将再次与 \(\textit{slow}\) 指针在环中相遇。

如下图所示,设链表中环外部分的长度为 a。\(\textit{slow}\) 指针进入环后,又走了 b 的距离与 \(\textit{fast}\) 相遇。此时,\(\textit{fast}\) 指针已经走完了环的 n 圈,因此它走过的总距离为 \(a+n(b+c)+b=a+(n+1)b+nc\)
image

根据题意,任意时刻,\(\textit{fast}\) 指针走过的距离都为 \(\textit{slow}\) 指针的 2 倍。因此,我们有

\[a+(n+1)b+nc=2(a+b) \implies a=c+(n-1)(b+c) \]

有了 \(a=c+(n-1)(b+c)\) 的等量关系,我们会发现:从相遇点到入环点的距离加上 n-1 圈的环长,恰好等于从链表头部到入环点的距离。

因此,当发现 \(\textit{slow}\)\(\textit{fast}\) 相遇时,我们再额外使用一个指针 \(\textit{ptr}\)。起始,它指向链表头部;随后,它和 \(\textit{slow}\) 每次向后移动一个位置。最终,它们会在入环点相遇。

理解:也就是说,ptr(pointer,指针)走\(a\)步,slow会走\(c步+在环内不停的转(n-1)圈\)(即 类似于走c步后原地不动)
记忆:slow和fast快慢指针开始你追我赶,最后进入了一个无限循环,在他们俩相遇后,fast实在是跑不动了,叫了120;120开始出发,slow背着他继续在循环中走,直到slow和120在环的入口处相遇。

代码

/**
 * Definition for singly-linked list.
 * class ListNode {
 *     int val;
 *     ListNode next;
 *     ListNode(int x) {
 *         val = x;
 *         next = null;
 *     }
 * }
 */
public class Solution {
    public ListNode detectCycle(ListNode head) {
        // 快慢指针
        ListNode slow = head, fast = head;

        while (fast != null && fast.next != null) {
            slow = slow.next;
            fast = fast.next.next;

            if (slow == fast) {
                ListNode index = head;
                while (index != slow) {
                    index = index.next;
                    slow = slow.next;
                }
                return index;
            }
        }

        return null;
    }
}

复杂度分析

  • 时间复杂度:\(O(N)\),其中 N 为链表中节点的数目。在最初判断快慢指针是否相遇时,\(\textit{slow}\) 指针走过的距离不会超过链表的总长度;随后寻找入环点时,走过的距离也不会超过链表的总长度。因此,总的执行时间为 \(O(N)+O(N)=O(N)\)

  • 空间复杂度:\(O(1)\)。我们只使用了 \(\textit{slow}\), \(\textit{fast}\), \(\textit{ptr}\) 三个指针。

我的

/**
 * Definition for singly-linked list.
 * class ListNode {
 *     int val;
 *     ListNode next;
 *     ListNode(int x) {
 *         val = x;
 *         next = null;
 *     }
 * }
 */
public class Solution {
    public ListNode detectCycle(ListNode head) {
        
        ListNode slow = head, fast = head;

        ListNode cur = head;

        boolean flag = false;

        while (fast != null && fast.next != null) {

            if (flag == true) {
                cur = cur.next;
            }

            slow = slow.next;
            fast = fast.next.next;

			// 一相遇cur就从原点出发
            if (slow == fast) {
                flag = true;
            }
            if (cur == slow) {
                return cur;
            }
        }

        return null;
    }
}

876. 链表的中间结点

给定一个头结点为 head 的非空单链表,返回链表的中间结点。

如果有两个中间结点,则返回第二个中间结点。

示例 1:

输入:[1,2,3,4,5]
输出:此列表中的结点 3 (序列化形式:[3,4,5])
返回的结点值为 3 。 (测评系统对该结点序列化表述是 [3,4,5])。
注意,我们返回了一个 ListNode 类型的对象 ans,这样:
ans.val = 3, ans.next.val = 4, ans.next.next.val = 5, 以及 ans.next.next.next = NULL.

示例 2:

输入:[1,2,3,4,5,6]
输出:此列表中的结点 4 (序列化形式:[4,5,6])
由于该列表有两个中间结点,值分别为 3 和 4,我们返回第二个结点。

答案

我们可以继续优化方法二,用两个指针 slow 与 fast 一起遍历链表。slow 一次走一步,fast 一次走两步。那么当 fast 到达链表的末尾时,slow 必然位于中间。

class Solution {
    public ListNode middleNode(ListNode head) {
        ListNode slow = head, fast = head;
        while (fast != null && fast.next != null) {
            slow = slow.next;
            fast = fast.next.next;
        }
        return slow;
    }
}

面试题 02.06. 回文链表

编写一个函数,检查输入的链表是否是回文的。

示例 1:

输入: 1->2
输出: false 

示例 2:

输入: 1->2->2->1
输出: true 

进阶:
你能否用 O(n) 时间复杂度和 O(1) 空间复杂度解决此题?

我的

回文链表,左右对称,也就是说,顺序链表与逆序链表一模一样,所以逆序比较一下即可。

/**
 * Definition for singly-linked list.
 * public class ListNode {
 *     int val;
 *     ListNode next;
 *     ListNode(int x) { val = x; }
 * }
 */
class Solution {
    public boolean isPalindrome(ListNode head) {

        ListNode newHead = reverse(head);
        
        while (newHead != null || head != null) {
            if (newHead.val != head.val) {
                return false;
            }

            newHead = newHead.next;
            head = head.next;
        }

        return true;
    }

    public ListNode reverse(ListNode head) {

        ListNode newHead = new ListNode(0);
        newHead.next = null;

        ListNode cur = head;
        
        while (cur != null) {
            ListNode temp = new ListNode(cur.val);
            temp.next = newHead.next;
            newHead.next = temp;

            cur = cur.next;
        }

        return newHead.next;
    }
}

答案

方法三:快慢指针
思路

避免使用 \(O(n)\) 额外空间的方法就是改变输入。

我们可以将链表的后半部分反转(修改链表结构),然后将前半部分和后半部分进行比较。比较完成后我们应该将链表恢复原样。虽然不需要恢复也能通过测试用例,但是使用该函数的人通常不希望链表结构被更改。

该方法虽然可以将空间复杂度降到 \(O(1)\),但是在并发环境下,该方法也有缺点。在并发环境下,函数运行时需要锁定其他线程或进程对链表的访问,因为在函数执行过程中链表会被修改。

算法

整个流程可以分为以下五个步骤:

  1. 找到前半部分链表的尾节点。
  2. 反转后半部分链表。
  3. 判断是否回文。
  4. 恢复链表。
  5. 返回结果。

执行步骤一,我们可以计算链表节点的数量,然后遍历链表找到前半部分的尾节点。

我们也可以使用快慢指针在一次遍历中找到:慢指针一次走一步,快指针一次走两步,快慢指针同时出发。当快指针移动到链表的末尾时,慢指针恰好到链表的中间。通过慢指针将链表分为两部分。

若链表有奇数个节点,则中间的节点应该看作是前半部分。

步骤二可以使用「206. 反转链表」问题中的解决方法来反转链表的后半部分。

步骤三比较两个部分的值,当后半部分到达末尾则比较完成,可以忽略计数情况中的中间节点。

步骤四与步骤二使用的函数相同,再反转一次恢复链表本身。

class Solution {
    public boolean isPalindrome(ListNode head) {
        if (head == null) {
            return true;
        }

        // 找到前半部分链表的尾节点并反转后半部分链表
        ListNode firstHalfEnd = endOfFirstHalf(head);
        ListNode secondHalfStart = reverseList(firstHalfEnd.next);

        // 判断是否回文
        ListNode p1 = head;
        ListNode p2 = secondHalfStart;
        boolean result = true;
        while (result && p2 != null) {
            if (p1.val != p2.val) {
                result = false;
            }
            p1 = p1.next;
            p2 = p2.next;
        }

        // 还原链表并返回结果
        firstHalfEnd.next = reverseList(secondHalfStart);
        return result;
    }

    private ListNode reverseList(ListNode head) {
        ListNode prev = null;
        ListNode curr = head;
        while (curr != null) {
            ListNode nextTemp = curr.next;
            curr.next = prev;
            prev = curr;
            curr = nextTemp;
        }
        return prev;
    }

    private ListNode endOfFirstHalf(ListNode head) {
        ListNode fast = head;
        ListNode slow = head;
        while (fast.next != null && fast.next.next != null) {
            fast = fast.next.next;
            slow = slow.next;
        }
        return slow;
    }
}

287. 寻找重复数

给定一个包含 n + 1 个整数的数组 nums ,其数字都在 [1, n] 范围内(包括 1 和 n),可知至少存在一个重复的整数。

假设 nums 只有 一个重复的整数 ,返回 这个重复的数 。

你设计的解决方案必须 不修改 数组 nums 且只用常量级 O(1) 的额外空间。

示例 1:

输入:nums = [1,3,4,2,2]
输出:2

示例 2:

输入:nums = [3,1,3,4,2]
输出:3

我的快慢指针

我们对 nums 数组建图,每个位置 i 连一条 i→nums[i] 的边。由于存在的重复的数字 target,因此 target 这个位置一定有起码两条指向它的边,因此整张图一定存在环,且我们要找到的 target 就是这个环的入口,那么整个问题就等价于 142. 环形链表 II。

我们先设置慢指针 slow 和快指针 fast ,慢指针每次走一步,快指针每次走两步,根据「Floyd 判圈算法」两个指针在有环的情况下一定会相遇,此时我们再将 slow 放置起点 0,两个指针每次同时移动一步,相遇的点就是答案。

class Solution {
    public int findDuplicate(int[] nums) {

        int slow = 0, fast = 0;
        while (fast < nums.length && nums[fast] < nums.length) {
            slow = nums[slow];
            fast = nums[nums[fast]];

            if (slow == fast) {
                int index = 0;
                while (slow < nums.length) {
                    slow = nums[slow];
                    index = nums[index];
                    if (slow == index) {
                        return index;
                    }
                }
            }
        }

        return 0;
    }
}

归纳总结

  • 对于链表,经常采用的方法有:头插法尾插法逆置法归并法双指针法等,对具体问题需要灵活变通;
  • 对于顺序表,由于可以直接存取,经常结合排序和查找的几种算法设计思路进行设计,如 归并排序二分查找等。

特别注意:有时候给我们的算法题并不是一个很明显的显式链表,而是一个隐式的链表。隐式意味着我们没有实际的链表节点和指针,但数据仍然形成链表结构。起始数字是链表的头 “节点”,链中的所有其他数字都是节点。

本文部分参考:https://www.cnblogs.com/skywang12345/p/3561803.html

实例

阿拉伯数字转化为中文数字

根据《算法的乐趣》简单总结一下:

中文数字的特点
1.中文数字直接“数字+权位”的方式组成数字,比如阿拉伯数字100,中文表示为一百,其中“一”为数字,“百”为权位。常用的数字权位有“十”,“百”,“千”,“万”,“亿”等

2.中文数字的权位和小节

  • 中文数字的特点之一就是每个计数数字都跟着一个权位,这个权位就是数字的量值,相当于阿拉伯数字中的数位。最低位(个位)没有权位,也可以理解为权位为空
  • 中文数字的另一个特点是以“万”为小节(欧美习惯以“千”为小节),每一个小节都有一个节权位,万以下的没有节权位(或节权位为空),万以上的就是万,再大的就是“亿”,每个小节内部都以“十百千”为权位的独立计数。“十百千”这几个权位是不能连续出现的,如二十百,一千千,但万和亿作为节权位却可以和其他权位一起使用,如二十亿等

3.中文数字的零
中文对零的使用总结有以下三条:

  • 规则1:以10000为小节,小节的结尾即使为0,也不使用“零”。
  • 规则2:小节内两个非0数字之间要使用“零”。
  • 规则3:当小节的“千”位为0时,若本小节的前一小节无其他数字,则不用“零”,否则用“零”。

答案

中文数字:四位一小节 + 节权位 + 四位一小节 + 节权位

  • 将一长串数字按四位一小节进行切割
  • 开始遍历此小节,将此小节转化为中文数字
  • 为此小节附加上节权位
public class NumberFormatTest {

    // 中文数字:小节 + 节权位 + 小节 + 节权位
    //数字位
    public static String[] chnNumChar = {"零","一","二","三","四","五","六","七","八","九"};
    //权位
    public static String[] chnUnitChar = {"","十","百","千"};
    //节权位
    public static String[] chnUnitSection = {"","万","亿","万亿"};

    public static void main(String[] args) {
        StringBuilder sb = new StringBuilder();
        NumberToChinese(7001, sb);
        NumberToChinese(404600290, sb);

        System.out.println(sb);
    }

    public static void NumberToChinese(int num, StringBuilder chnStr) {
        int unitPos = 0;   // 小节的位置
        boolean needZero = false;  // 初始默认规则3不需要0
		
        while (num > 0) {
            StringBuilder strSec = new StringBuilder();
			// 取后四位作为一小节,XXXX亿 XXXX万 XXXX
            int section = num % 10000;
            if (needZero) {  // 满足规则3需要添零,根据后面的语句是否修改了needZero来检测是否添加0
                chnStr.insert(0, chnNumChar[0]);
            }

            // 遍历此小节,得出此小节的中文数字
            SectionToChinese(section, strSec);
			
			// 此小节加上节权位
            // 检测当前section是否是0,如果是0的话,则添加空字符串的节权位,否则添加其他的
            if (section != 0) {
                strSec.append(chnUnitSection[unitPos]);
            } else {
                strSec.append(chnUnitSection[0]);
            }
            chnStr.insert(0, strSec);

            // 当前小节的千位为0时,如果前面一小节还有值的时候则添0
			needZero = section / 1000 == 0;
			// 进入下一小节,遍历下一小节
            num /= 10000;
            unitPos++;
        }
    }
	
	// 遍历此小节,得出此小节的中文数字
    public static void SectionToChinese(int section, StringBuilder chnStr) {
        String strSec = "";
        // 当前小节内的当前个数的独立计数的权位
        int unitPos = 0;
        // 先设置needZero为false,为了测试规则二,两个相连的0只留一个
        boolean needZero = false;
		
        while (section > 0) {
            int v = section % 10;
            if (v == 0) {
                // 当不是两个0相连的时候或者 添加0在数字中
                if (needZero) {
                    // 当出现一个0的时候就设置needZero为false,当下一个还是0的时候就不添加0了
                    needZero = false;
                    chnStr.insert(0, chnNumChar[0]);
                }
            } else {
                // 当出现一个不是0的数字的时候就设置当前的needZero标志为true表示下次遇到0的时候还是要添加
                needZero = true;
                strSec = chnNumChar[v];
                strSec += chnUnitChar[unitPos];
                // 将这个strSec插入到总的字符串的开始的位置
                chnStr.insert(0, strSec);
            }
            // 权位增加
            unitPos++;
            // 小节值除以10
            section /= 10;
        }
    }
}
posted @ 2019-08-19 19:02  Nemo&  阅读(901)  评论(0编辑  收藏  举报