数据结构和算法

前言

本篇博客主要分享数据结构和算法,通过分享可以梳理好散乱的知识点

稀疏数组

稀疏数组是只有少数几个元素的值不为零,大部分元素的值都是零的数组。
对于这种数组,我们可以使用稀疏数组来保存空间。稀疏数组包括:

  1. 数组的原始长度
  2. 非零元素的个数
  3. 非零元素的索引及值
    比如,有一个数组:

    int arr[12] = {0, 0, 1, 0, 0, 0, 2, 0, 0, 0, 0, 0}; 

    我们可以将它表示为稀疏数组:

    int sparseArr[3][3] = 
    {
    {12, 2},   // 数组长度和非零元素个数
    {1, 1},     // 第一个非零元素的索引和值
    {6, 2}      // 第二个非零元素的索引和值
    }

    这样我们只需要保存3个值,就可以表示原数组,从而节省空间。
    稀疏数组常用于游戏开发中保存棋盘状态,图像处理中的矩阵等。运用稀疏数组可以有效压缩数据量,加速运算速度。
    总的来说,对于有大量零元素的数组,使用稀疏数组可以极大地节省内存空间,这在处理大规模数据时很有意义。

循环队列

循环队列是一种特殊的队列,它的头尾指针可以循环使用。当尾指针达到数组末尾时,它自动返回到数组头部。
循环队列需要以下三个变量:

  1. 队列数组:array[]
  2. 头指针:front,指向队头元素
  3. 尾指针:rear,指向队尾元素的下一个位置
    循环队列的enqueue和dequeue操作如下:

    // enqueue操作 
    void enqueue(int data) 
    { 
    // 判断队列是否已满
    if ((rear + 1) % MAXSIZE == front) 
    {
        printf("Queue is full!\n");
        return;
    }
    // 将新元素加入队尾
    array[rear] = data;
    //  rear指针移动
    rear = (rear + 1) % MAXSIZE; 
    }
    // dequeue操作
    void dequeue() 
    {
    // 判断队列是否为空
    if (front == rear) 
    {
        printf("Queue is empty!\n");
        return ; 
    }
    // front指针移动
    front = (front + 1) % MAXSIZE;
    }

    循环队列由于采用循环数组,可以充分利用数组空间,无需像普通队列那样重新分配更大的内存空间,所以空间利用率高。
    但是,循环队列也存在两个问题:

  4. 队列满时,无法继续入队,会溢出
  5. 队列空时,无法继续出队,会下溢
    所以,循环队列适用于元素入队出队次数相近的场景。

    单链表

    单链表是一种线性表数据结构,它的每个节点包含两个域:

  6. 数据域:存储数据元素
  7. 指针域:存储后继节点的指针
    单链表的结构如下:

    
    struct Node {
    int val;
    struct Node *next;
    };
单链表需要一个头指针来指向第一个节点。
单链表的基本操作有:
1. 遍历:从头指针开始,沿next指针遍历每个节点
```css
void traverse(struct Node *head) {
    struct Node *p = head;
    while (p != NULL) {
        printf("%d ", p->val);
        p = p->next;
    }
}
  1. 插入:在指定节点之前/之后插入新节点
    // 将新节点插入在p节点之后
    void insertAfter(struct Node *p, int val) {
    struct Node *q = (struct Node*)malloc(sizeof(struct Node));
    q->val = val;
    q->next = p->next;
    p->next = q;
    }
  2. 删除:删除指定节点
    // 删除p节点之后的节点
    void deleteAfter(struct Node *p) {
    struct Node *q = p->next;
    p->next = q->next;
    free(q);
    }
  3. 查找:根据值查找节点
    struct Node* search(struct Node *head, int val) {
    struct Node *p = head;
    while (p != NULL && p->val != val)
        p = p->next;
    return p;
    }

    单链表相比数组的优点是:插入和删除方便,不需要移动大量元素。但是查找和访问则比较慢,需要从头指针开始遍历。
    所以,单链表适用于插入和删除频繁的场景。

    单链表面试题

    单链表常见的面试题有:

  4. 反转单链表:将节点的next指针反向指向前一个节点,实现链表反转。
    struct Node* reverse(struct Node* head) {
    struct Node *prev = NULL;
    struct Node *cur = head;
    while (cur != NULL) {
        struct Node *next = cur->next;
        cur->next = prev;
        prev = cur;
        cur = next;
    }
    return prev;
    }
  5. 找出中间节点:使用快慢指针,慢指针一次走一步,快指针一次走两步,当快指针到达尾节点时,慢指针指向中间节点。
    struct Node* middleNode(struct Node* head) {
    struct Node *slow = head;
    struct Node *fast = head;
    while (fast != NULL && fast->next != NULL) {
        slow = slow->next;
        fast = fast->next->next;
    }
    return slow;
    }
  6. 两个单链表相交的节点:使用两个指针遍历两个链表,若遇到相同节点,则为相交节点。否则当一个链表遍历完时,指向另一个链表的头继续遍历。
    struct Node* getIntersectionNode(struct Node* headA, struct Node* headB) {
    struct Node *pA = headA;
    struct Node *pB = headB;
    while (pA != pB) {
        pA = pA == NULL ? headB : pA->next;
        pB = pB == NULL ? headA : pB->next;
    }
    return pA; 
    } 
  7. 删除倒数第k个节点:使用快慢指针,快指针先走k步,然后快慢指针同时走,当快指针到达尾节点时,慢指针指向倒数第k个节点。
    void removeNthFromEnd(struct Node* head, int n) {
    struct Node *fast = head;
    struct Node *slow = head;
    while (n > 0 && fast != NULL) {
        fast = fast->next;
        n--;
    }
    while (fast != NULL) {
        fast = fast->next;
        slow = slow->next;
    }
    slow->next = slow->next->next;
    }

    环形链表解决约瑟夫问题

    环形链表可以解决约瑟夫问题。约瑟夫问题为:
    n个人围坐一圈,从第k个人开始报数,数到m的人出列,然后再从下一人开始报数。重复这个过程,直到所有人出列。要求出列的顺序。
    使用环形链表可以这样解决:

  8. 创建一个长度为n的环形链表,每个节点的值为1到n
  9. 设两个指针p和q,初始都指向头节点
  10. p先移动k-1步,到达第k个节点
  11. p和q同时移动,每m个节点,删除q所指节点
  12. p和q继续同时移动,直到q指向最后一个节点
  13. 环形链表此时剩余节点的值为出列顺序
    代码实现如下:

    struct Node {
    int val;
    struct Node *next;
    };
    struct Node* createCircularList(int n) {
    struct Node *head = (struct Node*)malloc(sizeof(struct Node));
    struct Node *cur = head;
    for (int i = 1; i <= n; i++) {
        struct Node *newNode = (struct Node*)malloc(sizeof(struct Node));
        newNode->val = i;
        cur->next = newNode;
        cur = newNode;
    }
    cur->next = head;  // 合成环型
    return head;
    }
    int josephus(struct Node* head, int k, int m) {
    struct Node *p = head;
    struct Node *q = head;
    while (--k > 0) {
        p = p->next;     // p移动k-1步
    }
    q = p->next;    // q从p的下一个节点开始
    while (q != p) {  // 未形成环
        for (int i = 0; i < m-1; i++) {
            p = p->next;
            q = q->next;
        }
        struct Node *del = q;  // 待删除节点
        q = q->next;          // q移到下一个节点
        p->next = q;          // 删除节点
        printf("%d ", del->val);    // 输出删除节点的值
        free(del);
    }
    }

    栈实现综合计算器

    可以使用栈实现一个简单的综合计算器。思路如下:

  14. 遇到数字,将数字字符转换为整数,压入栈
  15. 遇到运算符,弹出栈顶的两个数a和b,进行运算,并将结果压入栈
  16. 遇到括号,判断是左括号还是右括号
    • 左括号:直接压入栈
    • 右括号:弹出栈顶元素进行运算,直到遇到左括号,然后弹出左括号
  17. 重复上述步骤,直到表达式结束
  18. 最后弹出栈中唯一的元素,即为表达式结果
    代码实现如下:

    int calculate(char *expr) {
    stack<int> s;    // 存放数字的栈
    char c;
    int a, b, num = 0;
    for (int i = 0; c = expr[i]; i++) {
        // 遇到数字,转换为整数,压入栈
        if (isdigit(c)) {
            num = num * 10 + c - '0';
        } else if (c == '(') {  // 遇到左括号,压入栈
            s.push(c);
        } else if (c == ')') {  // 遇到右括号,弹出栈顶元素运算直到左括号
            while (s.top() != '(') {
                b = s.top(); s.pop();
                a = s.top(); s.pop(); 
                s.push(calculate(a, b));     // 计算两个数的结果
            }
            s.pop();    // 弹出'('
        } else {   // 遇到运算符,弹出两个数字运算
            b = s.top(); s.pop();
            a = s.top(); s.pop();
            switch (c) {
                case '+': s.push(a+b); break;
                case '-': s.push(a-b); break;
                case '*': s.push(a*b); break;
                case '/': s.push(a/b); break;
            }
        }
        // 如果num不为0,则压入栈
        if (num != 0) {
            s.push(num);
            num = 0;
        }
    }
    // 计算最终结果,栈中只剩一个元素
    return s.top();
    }

    中缀表达式转为后缀表达式,实现逆波兰计算器

    中缀表达式转后缀表达式,可以使用栈实现。算法如下:

  19. 从左到右扫描中缀表达式
  20. 遇到操作数,直接输出
  21. 遇到左括号'(',直接入栈
  22. 遇到右括号')',弹出栈顶元素直到左括号,左括号出栈但不输出
  23. 遇到操作符,比较栈顶元素优先级
    • 高优先级:压入栈
    • 相等优先级:弹出栈顶元素,当前操作符入栈
    • 低优先级:弹出栈顶元素输出,再比较栈顶元素
  24. 重复步骤3-5,直到表达式结束
  25. 将栈中所有元素弹出输出
  26. 最后输出的序列为后缀表达式
    代码实现如下:

    void infixToPostfix(char *infix, char *postfix) {
    stack<char> s;
    char c;
    for (int i = 0; c = infix[i]; i++) {
        // 遇到操作数,直接输出
        if (isdigit(c) || isalpha(c)) {
            *postfix++ = c;
        } else if (c == '(') {   // 遇到左括号,压入栈
            s.push(c);
        } else if (c == ')') {   // 遇到右括号,弹出栈顶元素直到左括号
            while (s.top() != '(') {
                *postfix++ = s.top();
                s.pop();
            }
            s.pop();  // 弹出'('
        } else {  // 遇到操作符,比较栈顶元素优先级 
            while (!s.empty() && precedence(c) <= precedence(s.top())) {
                *postfix++ = s.top();
                s.pop();
            }
            s.push(c);
        }
    }
    // 将栈中剩余元素弹出输出
    while (!s.empty()) {
        *postfix++ = s.top();
        s.pop();
    }
    }

    使用后缀表达式可以构建逆波兰计算器。逆波兰计算器不需要括号,直接从左到右计算即可。这是因为后缀表达式的运算符总是在两个操作数的后面,所以可以直接拿来计算。
    后缀表达式具有运算符在操作数后面的特点,计算简单高效,无需考虑运算符优先级和括号。

    递归之迷宫问题

    迷宫问题是递归算法的典型例子。问题描述如下:
    给定一个n*m的迷宫,入口在(0, 0)位置,出口在(n-1, m-1)位置。迷宫中可能存在障碍,用1表示,空路径用0表示。要求找出从入口到出口的路径。
    思路:

  27. 如果当前位置为出口,直接返回true
  28. 如果当前位置在迷宫外或者是障碍,直接返回false
  29. 如果当前位置已经访问过,直接返回false
  30. 标记当前位置已访问
  31. 上、右、下、左四个方向递归调用,只要有一条路径返回true,则返回true
  32. 如果四个方向都走不通,返回false
    代码实现:

    bool maze(int **maze, int n, int m, int x, int y) {
    // 递归终止条件
    if (x == n-1 && y == m-1) return true;
    if (x < 0 || x >= n || y < 0 || y >= m || maze[x][y] == 1) return false;
    if (maze[x][y] == 2) return false;   // 已访问过
    // 标记当前位置已访问
    maze[x][y] = 2;
    // 上、右、下、左四个方向递归调用
    if (maze(maze, n, m, x-1, y) ||  // 上
        maze(maze, n, m, x, y+1) ||  // 右
        maze(maze, n, m, x+1, y) ||  // 下
        maze(maze, n, m, x, y-1)) {  // 左
        return true;
    }
    return false;
    }

    迷宫问题是典型的使用回溯算法解决的问题。回溯的思想是从一条路走到尽头,若是死路就返回来尝试其他路径,最终找到一条通路或所有路都试完依然没找到,此问题无解。
    递归和回溯是解决这类问题的有力手段,需要对递归的机制有清晰的理解。

    谁发明的八皇后,本宫赐你一丈红

    八皇后问题是著名的规划问题,最初由德国数学家卡尔·弗里德里希·高斯在19世纪提出。
    八皇后问题是指在8*8棋盘上摆放8个皇后,使其不能互相攻击,即任意两个皇后都不能处在同一行、同一列或同一斜线上,问有多少种摆法。
    高斯提出这个问题的目的是为了研究数学中的排列与组合,他自己使用回溯算法给出了92种解法。
    八皇后问题的基本解法思路为:

  33. 以行为序,对每行试探所有的列,检查是否和前面已经摆放的皇后冲突。
  34. 如果冲突,继续试探下一列;如果不冲突, Fixed 这一行的皇后,并递归地摆放下一行。
  35. 递归终止条件是已经摆放完第8行的皇后。
    典型的回溯解决方案代码如下:

    bool isValid(int row, int col) { /* ... */ }
    void backtrack(int row) {
    if (row == 8) {  // 找到一个解
        solutions++;
        return;
    }
    for (int col = 0; col < 8; col++) {
        if (isValid(row, col)) {   // 检查这一行的皇后摆放在col列是否合适
            board[row][col] = true; // 摆放皇后
            backtrack(row + 1);     // 递归摆放下一行
            board[row][col] = false;// 撤销摆放
        }
    }
    }
    int totalNQueens(int n) {
    int solutions = 0;
    backtrack(0);
    return solutions;
    } 

    这就是八皇后问题的提出者、解决思路和基本实现。高斯使用这个问题深入研究了回溯算法,为后来的算法发展做出了重要贡献,对此高斯确实应该得到赞扬。

    算法的时间复杂度和空间复杂度

    算法的时间复杂度和空间复杂度是评估算法效率的两大关键指标。
    时间复杂度:表示算法执行所需的时间量。以大O符号表示,常见的有O(1)、O(n)、O(n2)、O(nlogn)、O(n!)等。
    空间复杂度:表示算法执行所需的存储空间量。也用大O符号表示,与时间复杂度相同。
    我们以快排算法为例,分析时间复杂度和空间复杂度:
    快排的基本思想是选择一个pivot,将数组分为两部分,左边都比pivot小,右边都比pivot大。然后递归左右两部分。

    public void quickSort(int[] nums, int left, int right) {
    if (left >= right) return;
    int pivot = partition(nums, left, right);
    quickSort(nums, left, pivot - 1);
    quickSort(nums, pivot + 1, right);
    }
    private int partition(int[] nums, int left, int right) {
    int pivot = nums[left];
    int i = left, j = right;
    while (i < j) {
        while (i <= j && nums[i] <= pivot) i++;
        while (i <= j && nums[j] > pivot) j--;
        swap(nums, i, j);
    }
    swap(nums, left, j);
    return j;
    }

    时间复杂度:每次选取一个pivot将数组分成两部分,如果每次都最为平均分,那么递归深度为logn,partition的时间复杂度为O(n),所以总时间复杂度为O(nlogn)。
    空间复杂度:由于递归调用栈,空间复杂度也是O(logn)。
    对算法效率的分析需要从时间复杂度和空间复杂度两个方面考虑。在实际应用中,还需要综合考虑算法的稳定性和实现难易程度。这些都是正确选择和设计算法的关键指标。

    常见的7种排序算法

  36. 冒泡排序:交换相邻元素,使较大的元素往后移。时间复杂度O(n2),空间复杂度O(1)。
  37. 选择排序:从未排序的元素中找到最小的元素,放到开头。时间复杂度O(n2),空间复杂度O(1)。
  38. 插入排序:将未排序的元素插入到前面已排序的序列中,使之成为新的有序序列。时间复杂度O(n2),空间复杂度O(1)。
  39. 快速排序:选择一个pivot,左边小于等于pivot,右边大于等于pivot,然后递归左右两部分。时间复杂度O(nlogn),空间复杂度O(logn)。
  40. 归并排序:递归将序列一分为二,到只剩一个元素为止;然后合并两个有序序列,使之成为一个新的有序序列。时间复杂度O(nlogn),空间复杂度O(n)。
  41. 希尔排序:通过增量分组,将元素移动到适当位置。时间复杂度O(nlogn),空间复杂度O(1)。
  42. 计数排序:统计每个数出现的次数,然后依次输出。时间复杂度O(n+k),空间复杂度O(n+k),k为序列最大值。
    这7种排序算法涵盖了比较排序和非比较排序,各有优缺点,需要根据实际情况选择合适的排序算法。
    比较排序:通过比较判断元素间的相对顺序,如冒泡排序、选择排序、插入排序、快速排序、归并排序、希尔排序。
    非比较排序:不通过比较判断元素顺序,如计数排序。
    非比较排序在数据范围较小时效率高,但无法应对范围较大时的排序需求。所以通常先使用非比较排序缩小数据范围,再使用比较排序完成最终排序。这是排序算法的一个重要综合运用方法。
    理解不同排序算法的时间复杂度、空间复杂度及适用场景,对算法的选择至关重要。这也是面试中常考察的内容之一。

    二分查找、插值查找、斐波那契查找

    二分查找、插值查找和斐波那契查找都是折半查找,可以在有序数组中快速查找元素。
    二分查找:
    特点:每次都将查找区间折半,所以间隔大小一直都是2的n次方,即logn。
    实现:

    int binarySearch(int[] nums, int target) {
    int left = 0, right = nums.length - 1;
    while (left <= right) {
        int mid = left + (right - left) / 2;
        if (nums[mid] == target) return mid;
        else if (nums[mid] < target) left = mid + 1;
        else right = mid - 1;
    }
    return -1;
    }

    时间复杂度:O(logn)
    空间复杂度:O(1)
    插值查找:
    特点:根据查找值和数组的最大最小值,计算出一个可能的中间位置。
    实现:

    int interpolationSearch(int[] nums, int target) {
    int left = 0, right = nums.length - 1;
    while (left <= right && target >= nums[left] && target <= nums[right]) {
        int mid = left + (right - left) * (target - nums[left]) / (nums[right] - nums[left]);
        if (nums[mid] < target) {
            left = mid + 1; 
        } else if (nums[mid] > target) {
            right = mid - 1;
        } else {
            return mid;
        }
    }
    return -1;
    }

    时间复杂度:O(log(logn)),n为数组长度,在数据均匀分布的情况下最优。
    空间复杂度:O(1)
    斐波那契查找:
    特点:每次折半区间Fib(k)的大小,k为斐波那契数列的数值。
    实现:

    int fibSearch(int[] nums, int target) {
    int left = 0, right = nums.length - 1, k = 0;
    int mid = 0;
    while (nums[left] < target) {  // 找到大于target的第一个数字,用于后续的折半
        left = mid + 1;
        k++;
    } 
    int fibM = fib(k - 1);  // F(k-1) 
    while (left < right) {   // 斐波那契折半
        mid = left + fibM - 1;  
        if (nums[mid] < target) {
            left = mid + 1;    
            k--;
            fibM = fib(k - 1);
        } else {
            right = mid;
        }
    }         
    if (nums[left] == target) return left;
    return -1;
    }

    时间复杂度:O(logn),n为数组长度
    空间复杂度:O(1)
    这三种折半查找算法各有优点,实际使用时根据需要选择合适的算法。

    二叉树

    二叉树是非常重要的数据结构,用来表示这样的树结构:每个节点最多有两个子节点(左子节点和右子节点)。
    二叉树常用来实现二叉搜索树和二叉堆。
    二叉树的基本结构:

    public class TreeNode {
    int val;
    TreeNode left;
    TreeNode right;
    TreeNode(int x) { val = x; }
    }

    二叉树常用操作:

  43. 添加节点:找到节点应该插入的位置,插入节点。如在二叉搜索树中按值的大小插入。
  44. 删除节点:删除叶节点直接删除,删除有一子节点的节点直接用子节点替代,删除有两个子节点的节点用后继节点或前驱节点替代。
  45. 先序遍历:根-左-右。递归实现如下:
    public void preorder(TreeNode root) {
    if (root == null) return;
    System.out.println(root.val);
    preorder(root.left);
    preorder(root.right); 
    }
  46. 中序遍历:左-根-右。递归实现如下:
    public void inorder(TreeNode root) {
    if (root == null) return;
    inorder(root.left);
    System.out.println(root.val);
    inorder(root.right);
    } 
  47. 后序遍历:左-右-根。递归实现如下:
    public void postorder(TreeNode root) {
    if (root == null) return;
    postorder(root.left);
    postorder(root.right); 
    System.out.println(root.val);
    }
  48. 求最大/小值:递归左子节点和右子节点,比较大小得到最大/小值。
  49. 求高度:递归左子节点和右子节点的高度,取最大值加1。
  50. 求叶子节点个数:递归终止条件,若为叶子节点则个数加1;否则分别递归左子节点和右子节点。
    二叉树是非常基础的数据结构,要熟练掌握各种操作和遍历算法。这也是面试中常考察的内容。

    线索化二叉树与堆排序

    线索化二叉树是二叉树的一种改进,能够快速访问前驱和后继节点。
    实现思路:

  51. 将所有叶子节点的left和right都指向前驱和后继节点
  52. 非叶子节点的left仍指向自己的左孩子,right指向前驱节点或后继节点
  53. 采用中序遍历对二叉树进行线索化
    具体实现代码如下:

    public class ThreadedBinaryTree {
    private TreeNode pre;  // 保存当前节点的前驱节点
    public void inOrderThreading(TreeNode root) {
        if (root == null) return;
        inOrderThreading(root.left);  // 线索化左子树
        if (root.left == null) { // 没有左子节点 
            root.left = pre;   
            root.leftType = 1;  // 左线索
        } 
        if (pre != null && pre.right == null) { // 前驱节点right为空
            pre.right = root;  
            pre.rightType = 1; // 右线索
        }
        pre = root;  // 前驱指向当前节点
        inOrderThreading(root.right);  // 线索化右子树 
    }
    }

    线索化二叉树的好处是可以快速访问节点的前驱和后继,不需要递归遍历,时间复杂度为O(1)。这在某些应用中具有很高的优势,如树的遍历、删除等操作。
    堆排序利用二叉堆的结构实现排序,具体做法为:
    1.将无序序列构建成一个二叉堆,根节点为最大值。
    2.每次取出二叉堆的根节点,并进行调整使其重新符合二叉堆。
    3.重复步骤2,直到堆为空,则获得一个有序序列。
    具体实现代码如下:

    public void heapSort(int[] nums) {
    if (nums == null || nums.length < 2) return;
    // 构建初始堆,i从最后一个有孩子节点的开始调整
    for (int i = nums.length / 2 - 1; i >= 0; i--) {
        heapify(nums, i, nums.length);
    }
    // 进行n-1次调整,第i次得到i个元素的有序序列
    for (int i = nums.length - 1; i > 0; i--) {
        swap(nums, 0, i);  // 交换堆顶和最后一个元素
        heapify(nums, 0, i);   // 重新调整
    }
    }
    // 调整使以start为根节点的子树成为一个堆
    public void heapify(int[] nums, int start, int end) {
    int parent = start, left = 2 * start + 1, right = 2 * start + 2;
    int max = parent;   // 默认根节点最大
    // 找到最大值,交换到根节点
    if (left < end && nums[left] > nums[max]) max = left;
    if (right < end && nums[right] > nums[max]) max = right;
    if (max != parent) {
        swap(nums, max, parent);
        heapify(nums, max, end); // 继续调整
    }
    }

    堆排序的时间复杂度为O(nlogn),空间复杂度为O(1)。利用二叉堆的结构实现,效率很高。

数据结构和算法

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注

滚动到顶部