数据结构之数组、链表与跳表:数据结构三剑客

298 阅读11分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 3 天,点击查看活动详情

作者: 千石
支持:点赞、收藏、评论
欢迎各位在评论区交流

前言

题目练习步骤:

  1. 给自己10分钟,读题并思考解题思路
  2. 有了思路以后开始写代码,如果在上一步骤中没有思路则停止思考并且看该题题解
  3. 在看懂题解(暂时没看懂也没关系)的思路后,背诵默写题解,直至能熟练写出来
  4. 隔一段时间,再次尝试写这道题目

正文

数组

数组是一种线性数据结构,用于存储一组相同类型的数据项。每个数据项在数组中都有一个唯一的索引,可以使用索引来对数组中的元素进行访问。

下面是一些常见的数组操作以及它们的复杂度:

  1. 访问:在数组中访问指定索引的元素。复杂度为 O(1)。
  2. 插入:在数组的开头或中间插入新的元素。如果在数组的开头插入,则需要将所有元素向后移动,复杂度为 O(n)。如果在数组的中间插入,则需要将元素向后移动的复杂度为 O(n)。
  3. 删除:从数组中删除指定索引的元素。需要将元素向前移动,复杂度为 O(n)。
  4. 查找:查找数组中指定值的元素。如果使用顺序查找,则复杂度为 O(n)。如果使用二分查找,则复杂度为 O(logn),但前提是数组必须是有序的。

复杂度说明:

  • O(1) 表示与数组的大小无关,因此是常数时间复杂度。
  • O(n) 表示与数组的大小呈线性关系,即随着数组的增大,复杂度也呈线性增长。
  • O(logn) 表示与数组的大小呈对数关系,即随着数组的增大,复杂度以对数的速度增长。

链表

链表是一种动态数据结构,它用于存储一组元素。每个元素都有一个指向下一个元素的指针,这样形成了一个链式结构。最后一个元素的指针指向空值。因此,链表可以动态地增加或删除元素,不需要预先知道数据集的大小。

下面是一些常见的链表操作以及它们的复杂度:

  1. 访问:查找链表中指定位置的元素。需要遍历整个链表,复杂度为 O(n)。
  2. 插入:在链表的开头或中间插入新的元素。插入操作需要更改指针,复杂度为 O(1)。
  3. 删除:从链表中删除指定位置的元素。需要遍历整个链表才能找到该元素,复杂度为 O(n)。
  4. 查找:查找链表中指定值的元素。需要遍历整个链表,复杂度为 O(n)。

复杂度说明:

  • O(1) 表示与链表的大小无关,因此是常数时间复杂度。
  • O(n) 表示与链表的大小呈线性关系,即随着链表的增大,复杂度也呈线性增长。

跳表

弥补链表的缺陷,这里不展开

相关题目

本节题目全部来自于力扣中国

题目一:283. 移动零

给定一个数组 nums,编写一个函数将所有 0 移动到数组的末尾,同时保持非零元素的相对顺序。
请注意,必须在不复制数组的情况下原地对数组进行操作。
示例 1:
输入: nums = [0,1,0,3,12]  
输出: [1,3,12,0,0]  
示例 2:
输入: nums = [0]  
输出: [0]  
提示:
1 <= nums.length <= 104  
-231 <= nums[i] <= 231 - 1

开始解题

法一:双重for循环

这道题可以使用两个 for 循环解决此问题。首先,遍历数组,统计数组中 0 的个数。然后,再次遍历数组,将非零元素向前移动,并在数组末尾添加 0。

代码实现

class Solution:
    def moveZeroes(self, nums: List[int]) -> None:
        zero_count = 0
        for i in range(len(nums)):
            if nums[i] == 0:
                zero_count += 1
            else:
                nums[i - zero_count] = nums[i]
        for i in range(len(nums) - zero_count, len(nums)):
            nums[i] = 0

复杂度分析

时间复杂度:

  • 两个 for 循环共遍历了数组两次,每次遍历的时间复杂度为 O(n)O(n),因此总时间复杂度为 O(n)O(n)

空间复杂度:

  • 该算法只使用了常数个额外变量,因此空间复杂度为 O(1)O(1)

法二:单指针

这道题也可以使用指针进行解决,思路如下:

  1. 创建一个指针,该指针用于跟踪下一个非零元素的位置。
  2. 使用 for 循环遍历数组,如果当前元素不为零,则将该元素移动到指针指向的位置,然后将指针向前移动一位。
  3. 在 for 循环结束后,将指针右边的所有元素设置为 0。

代码实现

class Solution:
    def moveZeroes(self, nums: List[int]) -> None:
        index = 0
        for i in range(len(nums)):
            if nums[i] != 0:
                nums[index] = nums[i]
                index += 1
        for i in range(index, len(nums)):
            nums[i] = 0

复杂度分析

时间复杂度:

  • 该算法遍历了数组一次,每次遍历的时间复杂度为 O(n)O(n),因此总时间复杂度为 O(n)O(n)

空间复杂度:

  • 该算法只使用了常数个额外变量,因此空间复杂度为 O(1)O(1)

法三:双指针法

这道题也可以使用,思路如下:

  1. 创建两个指针 left 和 right,分别指向数组的头部和尾部。
  2. 当 left 指向的元素为 0 时,将 right 指向的元素移动到 left 指向的位置,并将 right 向左移动一位。
  3. 当 left 指向的元素不为 0 时,将 left 向右移动一位。
  4. 重复步骤 2 和 3 直到 left >= right。

代码实现

class Solution:
    def moveZeroes(self, nums: List[int]) -> None:
        left = 0
        right = len(nums) - 1
        while left < right:
            if nums[left] == 0:
                nums[left], nums[right] = nums[right], nums[left]
                right -= 1
            else:
                left += 1

复杂度分析

时间复杂度:

  • 该算法遍历了数组一次,每次遍历的时间复杂度为 O(n)O(n),因此总时间复杂度为 O(n)O(n)

空间复杂度:

  • 该算法只使用了常数个额外变量,因此空间复杂度为 O(1)O(1)

题目二:11. 盛最多水的容器

给定一个长度为 n 的整数数组 height 。有 n 条垂线,第 i 条线的两个端点是 (i, 0) 和 (i, height[i]) 。
找出其中的两条线,使得它们与 x 轴共同构成的容器可以容纳最多的水。
返回容器可以储存的最大水量。
说明:你不能倾斜容器。

示例 1
image.png
输入:[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

提示:
n == height.length
2 <= n <= 105
0 <= height[i] <= 104

法一:暴力枚举法:

  • 枚举数组中的所有两个数作为容器的两边,求出这两个数能构成的容器的面积。
  • 每次求出的面积与最大面积比较,如果更大,更新最大面积。

代码实现

class Solution:
    def maxArea(self, height: List[int]) -> int:
        max_area = 0
        for i in range(len(height) - 1):
            for j in range(i + 1, len(height)):
                area = min(height[i], height[j]) * (j - i)
                max_area = max(max_area, area)
        return max_area

复杂度分析

时间复杂度:

  • 枚举数组中的每一对数,时间复杂度为 O(n2)O(n^2)

空间复杂度:

  • 只需要使用几个变量存储数据,空间复杂度为 O(1)O(1)

法二:中间收敛法

  • 设置左右边界,初始值为 00n1n-1
  • 每次比较左右边界所代表的面积,选择小的一边向中间移动。
  • 更新面积的最大值。
  • 重复步骤 2 和 3,直到左右边界相遇。

代码实现:

class Solution:
    def maxArea(self, height: List[int]) -> int:
        max_area = 0
        left, right = 0, len(height) - 1
        while left < right:
            area = min(height[left], height[right]) * (right - left)
            max_area = max(max_area, area)
            if height[left] < height[right]:
                left += 1
            else:
                right -= 1
        return max_area

复杂度分析

时间复杂度:

  • 每次都能缩小边界范围,只会被遍历 n2\frac{n}{2} 次,时间复杂度为 O(n)O(n)

空间复杂度:

  • 只需要使用几个变量存储数据,空间复杂度为 O(1)O(1)

题目三:70. 爬楼梯

假设你正在爬楼梯。需要 n 阶你才能到达楼顶。
每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?

示例 1:
输入:n = 2
输出:2
解释:有两种方法可以爬到楼顶。
1. 1 阶 + 1 阶
2. 2 阶

示例 2:
输入:n = 3
输出:3
解释:有三种方法可以爬到楼顶。
1. 1 阶 + 1 阶 + 1 阶
2. 1 阶 + 2 阶
3. 2 阶 + 1 阶

提示:
1 <= n <= 45

开始解答

法一:递归:

  • 假设爬 nn 阶台阶的方案数为 f(n)f(n)
  • n1n-1 阶台阶的方案数为 f(n1)f(n-1),爬 n2n-2 阶台阶的方案数为 f(n2)f(n-2)
  • nn 阶台阶的方案数就是爬 n1n-1 阶台阶的方案数加爬 n2n-2 阶台阶的方案数,得出递推公式(其实就是斐波那契数列): f(n)=f(n1)+f(n2)f(n) = f(n-1) + f(n-2)

代码实现

class Solution:
    def climbStairs(self, n: int) -> int:
        if n == 1:
            return 1
        if n == 2:
            return 2
        return self.climbStairs(n-1) + self.climbStairs(n-2)

复杂度分析

时间复杂度:

  • 因为对于每个状态都会调用两次递归,而状态数即为爬到楼顶的方法数,因此是指数级别的,时间复杂度是 O(2^n)。

空间复杂度:

  • 因为递归的最大深度为 n,空间复杂度为 O(n)。

法二:动态规划

思路同上,这里直接给出代码

class Solution:
    def climbStairs(self, n: int) -> int:
        if n == 1:
            return 1
        if n == 2:
            return 2
        dp = [0] * (n + 1)
        dp[1] = 1
        dp[2] = 2
        for i in range(3, n + 1):
            dp[i] = dp[i-1] + dp[i-2]
        return dp[n]

复杂度分析

时间复杂度:

  • 因为最多有 n 个状态,因此只需要进行 n 次计算,时间复杂度为 O(n)

空间复杂度:

  • 因为需要存储每个状态的答案,空间复杂度为 O(n)。

题目四:15. 三数之和

给你一个整数数组 nums ,判断是否存在三元组 [nums[i], nums[j], nums[k]] 满足 i != j、i != k 且 j != k ,同时还满足 nums[i] + nums[j] + nums[k] == 0 。请
你返回所有和为 0 且不重复的三元组。
注意:答案中不可以包含重复的三元组。

示例 1:

输入:nums = [-1,0,1,2,-1,-4]
输出:[[-1,-1,2],[-1,0,1]]
解释:
nums[0] + nums[1] + nums[2] = (-1) + 0 + 1 = 0 。
nums[1] + nums[2] + nums[4] = 0 + 1 + (-1) = 0 。
nums[0] + nums[3] + nums[4] = (-1) + 2 + (-1) = 0 。
不同的三元组是 [-1,0,1][-1,-1,2] 。
注意,输出的顺序和三元组的顺序并不重要。
示例 2:

输入:nums = [0,1,1]
输出:[]
解释:唯一可能的三元组和不为 0 。
示例 3:

输入:nums = [0,0,0]
输出:[[0,0,0]]
解释:唯一可能的三元组和为 0 。

提示:

3 <= nums.length <= 3000
-105 <= nums[i] <= 105

开始解答

Tips:本题解不涉及hash表,后面更新对应内容后会回来更新链接

法一:暴力枚举

枚举三元组中的每一个数,将其他的数与其做匹配,看是否存在两个数的和与枚举的数的相反数相等。

代码实现

class Solution:
    def threeSum(self, nums: List[int]) -> List[List[int]]:
        nums.sort()
        n = len(nums)
        res = []
        for i in range(n - 2):
            if i > 0 and nums[i] == nums[i - 1]:
                continue
            l, r = i + 1, n - 1
            while l < r:
                s = nums[i] + nums[l] + nums[r]
                if s == 0:
                    res.append([nums[i], nums[l], nums[r]])
                    while l < r and nums[l] == nums[l + 1]:
                        l += 1
                    while l < r and nums[r] == nums[r - 1]:
                        r -= 1
                    l += 1
                    r -= 1
                elif s < 0:
                    l += 1
                else:
                    r -= 1
        return res

复杂度分析

时间复杂度:

  • 因为枚举三元组中的每一个数需要遍历整个数组,匹配两个数的和需要再次遍历整个数组,因此总时间复杂度是O(n3)O(n^3)

空间复杂度:

  • 因为仅仅需要使用常数个变量来保存结果,不需要额外的存储空间,空间复杂度是 O(1)O(1)

法二:双指针法

  1. 先将数组进行排序,这样可以确保指针移动的顺序。
  2. 对于每一个数,从它的下一个数开始,利用双指针指向数组的头尾。
  3. 如果三数之和为零,则将结果加入答案列表;如果三数之和大于零,则移动尾指针;如果三数之和小于零,则移动头指针。

代码实现

class Solution:
    def threeSum(self, nums: List[int]) -> List[List[int]]:
        res = []
        nums.sort()
        for i in range(len(nums)-2):
            if i > 0 and nums[i] == nums[i-1]:
                continue
            l, r = i+1, len(nums)-1
            while l < r:
                s = nums[i] + nums[l] + nums[r]
                if s == 0:
                    res.append([nums[i], nums[l], nums[r]])
                    while l < r and nums[l] == nums[l+1]:
                        l += 1
                    while l < r and nums[r] == nums[r-1]:
                        r -= 1
                    l += 1
                    r -= 1
                elif s < 0:
                    l += 1
                else:
                    r -= 1
        return res

复杂度分析

时间复杂度分析:

  • 主要时间消耗在了双指针遍历数组的过程,因此时间复杂度为 O(n2)O(n^2)

空间复杂度分析:

  • 只使用了常数级别的额外空间。除了存储结果数组外,算法不需要额外的空间,因此空间复杂度为 O(1)O(1)

题目五:141. 环形链表 - 力扣(LeetCode)

给你一个链表的头节点 head ,判断链表中是否有环。

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

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

示例 1:


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


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


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

提示:

链表中节点的数目范围是 [0, 104]
-105 <= Node.val <= 105
pos 为 -1 或者链表中的一个 有效索引 。

代码实现

# Definition for singly-linked list.
# class ListNode(object):
#     def __init__(self, x):
#         self.val = x
#         self.next = None

class Solution(object):
    def hasCycle(self, head):
        """
        :type head: ListNode
        :rtype: bool
        """
        # 双指针法
        slow = fast = head
        while fast and fast.next:
            slow = slow.next
            fast = fast.next.next
            if slow == fast:
                return True
        return False