数据结构与算法

本文介绍了数据结构的基本概念,如数组、链表、栈、队列、树和图,以及它们的常用操作和复杂度分析。同时,文章涵盖了常见的排序算法,如冒泡排序、选择排序、插入排序、快速排序、归并排序、堆排序、计数排序、桶排序和基数排序,讨论了它们的时间复杂度和适用场景。此外,还对比了数组和链表的优缺点及其常用操作。最后,提供了在线练习资源以帮助读者巩固所学知识。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

数据结构

定义

数据结构(Data Structure)是计算机科学中的一个重要概念,指的是组织和存储数据的方式。在计算机科学中,数据通常是以某种形式存储在内存或磁盘中,并被程序访问和操作。数据结构可以用于解决各种计算问题,例如搜索、排序、图形算法等。

常见的数据结构包括:数组、链表、栈、队列、树、图等。每种数据结构都有其特定的用途和优缺点。例如,数组具有随机访问和常数时间的访问和更新,但在插入和删除时效率较低;链表具有动态添加和删除元素的能力,但访问元素的时间复杂度为 O(n)。

数据结构的设计和选择通常需要考虑以下几个方面:

  • 数据的特性:数据的类型、规模、密度等因素都会影响数据结构的设计和选择。
  • 访问模式:程序对数据的访问方式也会影响数据结构的选择,例如顺序访问、随机访问、插入删除等操作。
  • 算法的复杂度:不同的数据结构对于不同的算法可能会有不同的时间和空间复杂度,需要根据具体情况选择适合的数据结构。

总之,数据结构是计算机科学中非常重要的一部分,合适的数据结构可以使算法的效率更高,更快地解决问题

数据结构可以按照不同的特点进行分类,常见的分类方式包括以下几种:

  1. 线性结构:数据元素之间存在一对一的线性关系,最常见的有数组、链表、栈和队列等。
  2. 非线性结构:数据元素之间存在一对多或多对多的关系,最常见的有树和图等。
  3. 静态结构:在程序运行时,数据元素个数是固定的,不可修改,例如数组。
  4. 动态结构:在程序运行时,数据元素的个数是可以动态变化的,例如链表。
  5. 存储结构:数据在内存中的存储方式,最常见的有顺序存储和链式存储两种。
  6. 算法适用性:数据结构适用的算法不同,例如用于搜索和排序的数据结构不同。
  7. 抽象程度:数据结构可以按照其所表示的实体的抽象程度进行分类,例如线性表、树和图等。

常见数据结构

  1. 数组(Array) 数组是一种线性数据结构,由一组相同类型的元素组成,每个元素都可以通过一个索引访问。数组的特点是访问元素速度快,但插入和删除元素的效率较低。
  2. 链表(Linked List) 链表也是一种线性数据结构,但每个元素都指向下一个元素。它的插入和删除元素的效率高,但访问元素的效率较低。
  3. 栈(Stack) 栈是一种后进先出(LIFO)的数据结构,只能在一端插入和删除元素。可以使用栈来实现函数调用的跟踪、括号匹配等功能。
  4. 队列(Queue) 队列是一种先进先出(FIFO)的数据结构,可以在队列的一端插入元素,在另一端删除元素。可以使用队列来实现广度优先搜索等功能。
  5. 树(Tree) 树是一种非线性数据结构,由节点和边组成。每个节点可以有多个子节点,但只有一个父节点。树的常用应用包括二叉树、平衡树、堆、哈夫曼树等。
  6. 图(Graph) 图也是一种非线性数据结构,由节点和边组成。与树不同的是,图中的节点可以有多个父节点和多个子节点。图的常用应用包括最短路径、最小生成树等。
  7. 哈希表(Hash Table) 哈希表是一种基于哈希函数实现的数据结构,它可以高效地进行插入、删除和查找操作。哈希表的常用应用包括字典、缓存等。

复杂度分析

当我们选择一种数据结构时,需要考虑其时间复杂度和空间复杂度。以下是常见数据结构的时间复杂度和空间复杂度分析:

  1. 数组(Array) 时间复杂度:
  • 访问元素:O(1)
  • 插入元素:最坏O(n),平均O(n/2)
  • 删除元素:最坏O(n),平均O(n/2)

空间复杂度:O(n)

  1. 链表(Linked List) 时间复杂度:
  • 访问元素:O(n)
  • 插入元素:O(1)
  • 删除元素:O(1)

空间复杂度:O(n)

  1. 栈(Stack) 时间复杂度:
  • 入栈:O(1)
  • 出栈:O(1)

空间复杂度:O(n)

  1. 队列(Queue) 时间复杂度:
  • 入队:O(1)
  • 出队:O(1)

空间复杂度:O(n)

  1. 树(Tree) 时间复杂度:
  • 遍历树:O(n)
  • 查找节点:O(log n)
  • 插入节点:O(log n)
  • 删除节点:O(log n)

空间复杂度:O(n)

  1. 图(Graph) 时间复杂度:
  • 遍历图:O(V+E)
  • 查找节点:O(1) - O(V)
  • 插入节点:O(1) - O(V)
  • 删除节点:O(1) - O(V)

空间复杂度:O(V+E)

  1. 哈希表(Hash Table) 时间复杂度:
  • 插入元素:平均O(1),最坏O(n)
  • 查找元素:平均O(1),最坏O(n)
  • 删除元素:平均O(1),最坏O(n)

空间复杂度:O(n)

以上时间复杂度和空间复杂度只是给出了一个大致的参考。在实际应用中,复杂度可能会因为具体实现和算法优化而有所不同。因此,在实际应用中,我们需要结合具体情况进行分析和优化。

算法与排序

public static void BubbleSort(int[] arr)
{
    int n = arr.Length;
    for (int i = 0; i < n - 1; i++)
    {
        for (int j = 0; j < n - i - 1; j++)
        {
            // 如果前面的数比后面的数大,则交换位置
            if (arr[j] > arr[j + 1])
            {
                int temp = arr[j];
                arr[j] = arr[j + 1];
                arr[j + 1] = temp;
            }
        }
    }
}
public static void SelectionSort(int[] arr)
{
    int n = arr.Length;
    for (int i = 0; i < n - 1; i++)
    {
        int minIndex = i;
        for (int j = i + 1; j < n; j++)
        {
            // 找到未排序区间中最小元素的下标
            if (arr[j] < arr[minIndex])
            {
                minIndex = j;
            }
        }
        // 将最小元素放到已排序区间的末尾
        int temp = arr[i];
        arr[i] = arr[minIndex];
        arr[minIndex] = temp;
    }
}
public static void InsertionSort(int[] arr)
{
    int n = arr.Length;
    for (int i = 1; i < n; i++)
    {
        int key = arr[i];
        int j = i - 1;
        // 将比 key 大的元素向后移动一位
        while (j >= 0 && arr[j] > key)
        {
            arr[j + 1] = arr[j];
            j--;
        }
        // 将 key 插入到合适的位置
        arr[j + 1] = key;
    }
}
public static void MergeSort(int[] arr, int left, int right)
{
    if (left < right)
    {
        int mid = (left + right) / 2;
        // 分治递归,将数组一分为二
        MergeSort(arr, left, mid);
        MergeSort(arr, mid + 1, right);
        // 合并两个有序数组
        Merge(arr, left, mid, right);
    }
}

public static void Merge(int[] arr, int left, int mid, int right)
{
    int[] temp = new int[arr.Length];
    int i = left, j = mid + 1, k = left;
    while (i <= mid && j <= right)
    {
        if (arr[i] <= arr[j])
        {
            temp[k++] = arr[i++];
        }
        else
        {
            temp[k++] = arr[j++];
        }
    }
    while (i <= mid)
    {
        temp[k++] = arr[i++];
    }
    while (j <= right)
    {
        temp[k++] = arr[j++];
    }
    // 将临时数组的元素复制回原数组
    for (int l = left; l <= right; l++)
    {
        arr[l] = temp[l];
    }
}
public static void HeapSort(int[] arr)
{
    int n = arr.Length;
    // 构建大根堆
    for (int i = n / 2 - 1; i >= 0; i--)
    {
        Heapify(arr, n, i);
    }
    // 依次将堆顶元素放到末尾,然后重新调整堆
    for (int i = n - 1; i >= 0; i--)
    {
        int temp = arr[0];
        arr[0] = arr[i];
        arr[i] = temp;
        Heapify(arr, i, 0);
    }
}

public static void Heapify(int[] arr, int n, int i)
{
    int largest = i;
    int left = 2 * i + 1;
    int right = 2 * i + 2;
    // 找到三个节点中的最大值
    if (left < n && arr[left] > arr[largest])
    {
        largest = left;
    }
    if (right < n && arr[right] > arr[largest])
    {
        largest = right;
    }
    // 如果最大值不是当前节点,则交换位置,并继续调整堆
    if (largest != i)
    {
        int temp = arr[i];
        arr[i] = arr[largest];
        arr[largest] = temp;
        Heapify(arr, n, largest);
    }
}
public static void CountingSort(int[] arr)
{
    int n = arr.Length;
    int max = arr[0], min = arr[0];
    // 找到数组中的最大值和最小值
    for (int i = 1; i < n; i++)
    {
        if (arr[i] > max)
        {
            max = arr[i];
        }
        if (arr[i] < min)
        {
            min = arr[i];
        }
    }
    // 统计每个元素出现的次数
    int[] count = new int[max - min + 1];
    for (int i = 0; i < n; i++)
    {
        count[arr[i] - min]++;
    }
    // 对统计数组进行变形,每个元素存储前面所有元素的累加和
    for (int i = 1; i < count.Length; i++)
    {
        count[i] += count[i - 1];
    }
    // 遍历原始数组,从统计数组中找到正确的位置,并输出到临时数组中
    int[] temp = new int[n];
    for (int i = n - 1; i >= 0; i--)
    {
        temp[--count[arr[i] - min]] = arr[i];
    }
    // 将临时数组的元素复制回原数组
    for (int i = 0; i < n; i++)
    {
        arr[i] = temp[i];
    }
}
public static void RadixSort(int[] arr)
{
    int n = arr.Length;
    if (n <= 1)
    {
        return;
    }
    // 找到最大值,确定要排序几次
    int max = arr[0];
    for (int i = 1; i < n; i++)
    {
        if (arr[i] > max)
        {
            max = arr[i];
        }
    }
    int exp = 1;
    // 对每一位进行排序
    while (max / exp > 0)
    {
        // 计数排序的过程
        int[] count = new int[10];
        int[] output = new int[n];
        for (int i = 0; i < n; i++)
        {
            count[(arr[i] / exp) % 10]++;
        }
        for (int i = 1; i < 10; i++)
        {
            count[i] += count[i - 1];
        }
        for (int i = n - 1; i >= 0; i--)
        {
            output[count[(arr[i] / exp) % 10] - 1] = arr[i];
            count[(arr[i] / exp) % 10]--;
        }
        for (int i = 0; i < n; i++)
        {
            arr[i] = output[i];
        }
        exp *= 10;
    }
}
public class TrieNode
{
    public TrieNode[] children = new TrieNode[26];
    public bool isWord = false;
}

public class Trie
{
    private TrieNode root;

    public Trie()
    {
        root = new TrieNode();
    }

    public void Insert(string word)
    {
        TrieNode node = root;
        for (int i = 0; i < word.Length; i++)
        {
            char c = word[i];
            if (node.children[c - 'a'] == null)
            {
                node.children[c - 'a'] = new TrieNode();
            }
            node = node.children[c - 'a'];
        }
        node.isWord = true;
    }

    public bool Search(string word)
    {
        TrieNode node = root;
        for (int i = 0; i < word.Length; i++)
        {
            char c = word[i];
            if (node.children[c - 'a'] == null)
            {
                return false;
            }
            node = node.children[c - 'a'];
        }
        return node.isWord;
    }

    public bool StartsWith(string prefix)
    {
        TrieNode node = root;
        for (int i = 0; i < prefix.Length; i++)
        {
            char c = prefix[i];
            if (node.children[c - 'a'] == null)
            {
                return false;
            }
            node = node.children[c - 'a'];
        }
        return true;
    }
}
public static void BucketSort(int[] arr)
{
    int n = arr.Length;

    // 1. 创建 n 个桶
    List<int>[] buckets = new List<int>[n];
    for (int i = 0; i < n; i++)
    {
        buckets[i] = new List<int>();
    }

    // 2. 将数组元素分配到对应的桶中
    for (int i = 0; i < n; i++)
    {
        int bucketIndex = (int)((double)n * arr[i] / (n + 1));
        buckets[bucketIndex].Add(arr[i]);
    }

    // 3. 对每个桶内的元素进行排序
    for (int i = 0; i < n; i++)
    {
        buckets[i].Sort();
    }

    // 4. 将所有桶内的元素依次放入原数组中
    int index = 0;
    for (int i = 0; i < n; i++)
    {
        for (int j = 0; j < buckets[i].Count; j++)
        {
            arr[index++] = buckets[i][j];
        }
    }
}
public static void QuickSort(int[] arr, int left, int right)
{
    if (left < right)
    {
        // 1. 选取基准元素
        int pivotIndex = left;
        int pivotValue = arr[pivotIndex];

        // 2. 分割数组
        int i = left, j = right;
        while (i < j)
        {
            while (i < j && arr[j] >= pivotValue) j--;
            if (i < j) arr[i++] = arr[j];
            while (i < j && arr[i] < pivotValue) i++;
            if (i < j) arr[j--] = arr[i];
        }
        arr[i] = pivotValue;

        // 3. 递归排序左右子数组
        QuickSort(arr, left, i - 1);
        QuickSort(arr, i + 1, right);
    }
}

以下是这十大常见算法的常见应用场景:

  1. 冒泡排序:适用于小数据量的排序,简单易懂,但时间复杂度较高,不适用于大规模数据排序。
  2. 选择排序:同样适用于小数据量的排序,时间复杂度较冒泡排序略低,但还是不适合大规模数据排序。
  3. 插入排序:适用于对小规模数据的排序,具有稳定性和高效性,可以在数据已经部分有序的情况下提高效率。
  4. 快速排序:适用于大规模数据排序,快速、高效、灵活,是常用的排序算法之一。
  5. 归并排序:同样适用于大规模数据排序,稳定、高效,比快速排序多了一步合并操作,但相对复杂一些。
  6. 堆排序:适用于大规模数据排序,相对于归并排序、快速排序等算法,堆排序的常数项小,但实现稍微复杂一些。
  7. 计数排序:适用于数据范围不大的整数排序,时间复杂度为O(n+k),其中k为数据范围,但是需要额外的存储空间。
  8. 桶排序:适用于数据范围分布比较均匀的情况下,时间复杂度为O(n),但是需要额外的存储空间。
  9. 基数排序:适用于数据范围较小的排序,时间复杂度为O(dn),其中d为数字位数,但需要较多的额外存储空间。
  10. Trie树:适用于字符串的存储和查找,常用于搜索提示和字符串匹配等场景。

以下是十大常见算法的时间复杂度和空间复杂度:

算法平均时间复杂度最坏时间复杂度空间复杂度是否稳定排序
冒泡排序O(n^2)O(n^2)O(1)
选择排序O(n^2)O(n^2)O(1)
插入排序O(n^2)O(n^2)O(1)
快速排序O(nlogn)O(n^2)O(logn)
归并排序O(nlogn)O(nlogn)O(n)
堆排序O(nlogn)O(nlogn)O(1)
计数排序O(n+k)O(n+k)O(k)
桶排序O(n)O(n^2)O(n+k)
基数排序O(d(n+k))O(d(n+k))O(n+k)
Trie树O(mn)O(mn)O(mn)N/A

其中,n为数据规模,k为数据范围,m为字符串长度,d为数字位数。需要注意的是,算法的时间复杂度和空间复杂度是一个理论上的概念,具体的时间和空间消耗可能会受到多种因素的影响,如具体实现、硬件环境等。

数组与链表

数组和链表都是线性数据结构,可以用于存储一系列相同类型的数据。它们的主要区别在于存储方式和性能方面。

数组(Array)是一种固定大小的线性数据结构,它由连续的内存地址组成,并且所有元素具有相同的数据类型。数组中每个元素都可以通过索引来访问,这个索引通常是整数类型。数组可以用于实现各种算法和数据结构,例如矩阵、堆栈、队列和哈希表等。

数组的优点是访问元素的时间复杂度是 O(1),因为数组中的元素都是连续存储的,可以通过简单的数学运算来计算出元素的内存地址。但是,数组的缺点是插入和删除元素的时间复杂度是 O(n),因为在数组中插入或删除元素需要将后续元素向前或向后移动,这是一个很费时间的过程。

链表(Linked List)是一种动态数据结构,它的每个元素由一个值和一个指向下一个元素的指针组成。由于链表中的元素可以存储在任何地方,因此它们不必是连续的。链表有单向链表、双向链表和循环链表等多种形式。

链表的优点是插入和删除元素的时间复杂度是 O(1),因为只需要调整相邻元素的指针即可。但是,访问元素的时间复杂度是 O(n),因为在链表中没有索引,需要遍历链表来找到元素。

总之,数组和链表都有自己的优点和缺点,在实际应用中需要根据具体需求来选择合适的数据结构。如果需要随机访问元素或元素个数是固定的,使用数组更合适;如果需要频繁插入或删除元素或元素个数可能变化,使用链表更合适。

数组常用操作

创建数组


int[] array = new int[10];

访问元素

int[] array = new int[10];
array[0] = 1;
int firstElement = array[0];

遍历数组

int[] array = new int[10];
for (int i = 0; i < array.Length; i++)
{
    Console.WriteLine(array[i]);
}

插入元素

int[] array = new int[10];
array[0] = 1;
for (int i = array.Length - 1; i > 0; i--)
{
    array[i] = array[i - 1];
}
array[0] = 0;

删除元素

int[] array = new int[10];
for (int i = 0; i < array.Length; i++)
{
    if (array[i] == 5)
    {
        for (int j = i; j < array.Length - 1; j++)
        {
            array[j] = array[j + 1];
        }
        array[array.Length - 1] = 0;
        break;
    }
}

链表常用操作

创建链表

public class ListNode
{
    public int val;
    public ListNode next;
    public ListNode(int val = 0, ListNode next = null)
    {
        this.val = val;
        this.next = next;
    }
}

ListNode head = new ListNode(1);
ListNode node1 = new ListNode(2);
ListNode node2 = new ListNode(3);
head.next = node1;
node1.next = node2;

访问节点

ListNode head = new ListNode(1);
ListNode node1 = new ListNode(2);
ListNode node2 = new ListNode(3);
head.next = node1;
node1.next = node2;
int firstElement = head.val;
int secondElement = head.next.val;

遍历链表

ListNode head = new ListNode(1);
ListNode node1 = new ListNode(2);
ListNode node2 = new ListNode(3);
head.next = node1;
node1.next = node2;
ListNode current = head;
while (current != null)
{
    Console.WriteLine(current.val);
    current = current.next;
}

插入节点

ListNode head = new ListNode(1);
ListNode node1 = new ListNode(2);
ListNode node2 = new ListNode(3);
head.next = node1;
node1.next = node2;

ListNode newNode = new ListNode(4);
newNode.next = node1.next;
node1.next = newNode;

删除节点

ListNode head = new ListNode(1);
ListNode node1 = new ListNode(2);
ListNode node2 = new ListNode(3);
head.next = node1;
node1.next = node2;

ListNode current = head;
while (current.next != null)
{
    if (current.next.val == 2)
    {
        current.next = current.next.next;
        break;
    }
    current = current.next;
}

练习题

数组

  1. 将一个数组中的元素按照反转的顺序重新排列,例如给定数组[1, 2, 3, 4, 5],则反转后的数组为[5, 4, 3, 2, 1]。
  2. 给定两个有序整数数组nums1和nums2,将nums2合并到nums1中,使得nums1成为一个有序数组,假定nums1有足够的空间来容纳nums2中的元素。
  3. 给定一个数组,其中的元素表示每个台阶的高度,假定你在地面上,每次可以跳跃的高度为1或2个台阶,请问跳到最后一个台阶所需要的最少步数是多少?

链表

  1. 给定一个单向链表,反转该链表。
  2. 给定一个单向链表和一个整数k,将该链表分成若干个长度为k的链表,并将每个链表翻转。
  3. 给定两个单向链表,表示两个非负整数,将它们相加并以链表的形式返回结果。

数组

  1. 反转数组:
public void ReverseArray(int[] nums)
{
    int left = 0;
    int right = nums.Length - 1;

    while (left < right)
    {
        int temp = nums[left];
        nums[left] = nums[right];
        nums[right] = temp;
        left++;
        right--;
    }
}
  1. 合并有序数组:
public void MergeSortedArray(int[] nums1, int m, int[] nums2, int n)
{
    int i = m - 1;
    int j = n - 1;
    int k = m + n - 1;

    while (i >= 0 && j >= 0)
    {
        if (nums1[i] > nums2[j])
        {
            nums1[k--] = nums1[i--];
        }
        else
        {
            nums1[k--] = nums2[j--];
        }
    }

    while (j >= 0)
    {
        nums1[k--] = nums2[j--];
    }
}
  1. 跳跃游戏:
public int JumpGame(int[] nums)
{
    int steps = 0;
    int maxPos = 0;
    int end = 0;

    for (int i = 0; i < nums.Length - 1; i++)
    {
        maxPos = Math.Max(maxPos, i + nums[i]);

        if (i == end)
        {
            end = maxPos;
            steps++;
        }
    }

    return steps;
}

1.给出两个非空的链表,表示两个非负整数。其中,它们各自的位数是按照逆序的方式存储的,并且它们的每个节点只能存储一位数字。

将这两个数相加起来,则会返回一个新的链表来表示它们的和。

你可以假设除了数字 0 之外,这两个数都不会以 0 开头。

示例:

输入:(2 -> 4 -> 3) + (5 -> 6 -> 4) 输出:7 -> 0 -> 8 原因:342 + 465 = 807

思路:

从链表的头结点开始遍历两个链表,将对应位置上的节点值相加,同时记录进位。

需要注意的是,当一个链表比另一个链表短时,应该在短链表的高位补零。

public class ListNode {
    public int val; // 当前节点的值
    public ListNode next; // 下一个节点
    public ListNode(int x) { val = x; }
}

public class Solution {
    public ListNode AddTwoNumbers(ListNode l1, ListNode l2) {
        // 创建虚拟头节点,并用curr指向链表的最后一个节点
        ListNode dummyHead = new ListNode(0);
        ListNode p = l1, q = l2, curr = dummyHead;
        int carry = 0; // 进位值
        while (p != null || q != null) {
            int x = (p != null) ? p.val : 0; // 取出l1当前节点的值,如果为空则取0
            int y = (q != null) ? q.val : 0; // 取出l2当前节点的值,如果为空则取0
            int sum = carry + x + y; // 当前节点的值
            carry = sum / 10; // 进位值
            curr.next = new ListNode(sum % 10); // 创建新节点并插入到新链表尾部
            curr = curr.next; // 更新指针
            if (p != null) p = p.next; // 如果l1不为空,则指针后移
            if (q != null) q = q.next; // 如果l2不为空,则指针后移
        }
        if (carry > 0) { // 如果进位值不为0,则需要在新链表的尾部添加一个节点
            curr.next = new ListNode(carry);
        }
        return dummyHead.next; // 返回虚拟头节点的下一个节点
    }
}

时间复杂度:O(max(m, n)),其中 m 和 n 分别为两个链表的长度。

空间复杂度:O(max(m, n)),需要创建一个新的链表来存储结果。

在线练习网站

  1. LeetCode(https://leetcode.com/problemset/all/)
  2. HackerRank(https://www.hackerrank.com/domains/data-structures)
  3. Codility(https://app.codility.com/programmers/lessons/)
  4. GeeksforGeeks(https://www.geeksforgeeks.org/practice-questions-for-linked-list-and-recursion/)

这些网站都提供了大量的数据结构和算法练习题,可以帮助你巩固和提升自己的编程能力。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值