Điểm thực hiện một Stack sử dụng hai hàng đợi là gì?


34

Tôi có câu hỏi bài tập về nhà sau đây:

Thực hiện các phương thức ngăn xếp đẩy (x) và pop () bằng hai hàng đợi.

Điều này có vẻ kỳ lạ với tôi bởi vì:

  • Một ngăn xếp một hàng đợi (LIFO)
  • Tôi không thấy lý do tại sao bạn cần hai hàng đợi để thực hiện nó

Tôi tìm kiếm xung quanh:

và tìm thấy một vài giải pháp. Đây là những gì tôi đã kết thúc với:

public class Stack<T> {
    LinkedList<T> q1 = new LinkedList<T>();
    LinkedList<T> q2 = new LinkedList<T>();

    public void push(T t) {
        q1.addFirst(t);
    }

    public T pop() {
        if (q1.isEmpty()) {
            throw new RuntimeException(
                "Can't pop from an empty stack!");
        }

        while(q1.size() > 1) {
            q2.addFirst( q1.removeLast() );
        }

        T popped = q1.pop();

        LinkedList<T> tempQ = q1;
        q1 = q2;
        q2 = tempQ;

        return popped;
    }
}

Nhưng tôi không hiểu lợi thế của việc sử dụng một hàng đợi là gì; hai phiên bản xếp hàng dường như vô cùng phức tạp.

Giả sử chúng tôi chọn các lần đẩy để hiệu quả hơn trong số 2 (như tôi đã làm ở trên), pushsẽ vẫn như cũ và popchỉ cần yêu cầu lặp lại thành phần cuối cùng và trả lại nó. Trong cả hai trường hợp, pushsẽ là O(1), và popsẽ là O(n); nhưng phiên bản xếp hàng đơn sẽ đơn giản hơn nhiều. Nó chỉ nên yêu cầu một vòng lặp duy nhất.

Tui bỏ lỡ điều gì vậy? Bất kỳ cái nhìn sâu sắc ở đây sẽ được đánh giá cao.


17
Một hàng đợi thường đề cập đến cấu trúc FIFO trong khi ngăn xếp là cấu trúc LIFO. Giao diện cho LinkedList trong Java là giao diện (hàng đợi kết thúc kép) cho phép truy cập cả FIFO và LIFO. Hãy thử thay đổi lập trình sang giao diện Hàng đợi thay vì triển khai LinkedList.

12
Vấn đề thông thường hơn là thực hiện một hàng đợi bằng cách sử dụng hai ngăn xếp. Bạn có thể thấy cuốn sách của Chris Okasaki về các cấu trúc dữ liệu hoàn toàn chức năng thú vị.
Eric Lippert

2
Dựa trên những gì Eric nói, đôi khi bạn có thể thấy mình trong một ngôn ngữ dựa trên ngăn xếp (chẳng hạn như dc hoặc tự động đẩy xuống với hai ngăn xếp (tương đương với một máy turing bởi vì, tốt, bạn có thể làm nhiều hơn)) nơi bạn có thể tìm thấy chính mình nhiều ngăn xếp, nhưng không có hàng đợi.

1
@MichaelT: Hoặc bạn cũng có thể thấy mình đang chạy trên CPU dựa trên ngăn xếp
slebetman

11
"Một ngăn xếp là một hàng đợi (LIFO)" ... uhm, một hàng đợi là một hàng chờ. Giống như dòng cho việc sử dụng một nhà vệ sinh công cộng. Các dòng bạn chờ đợi có bao giờ cư xử theo kiểu LIFO không? Ngừng sử dụng thuật ngữ "LIFO queue", điều này là vô nghĩa.
Mehrdad

Câu trả lời:


44

Không có lợi thế: đây là một bài tập hoàn toàn học tập.

Một rất lâu rồi khi tôi còn là một sinh viên năm nhất ở trường đại học tôi đã có một bài tập tương tự 1 . Mục tiêu là dạy cho sinh viên cách sử dụng lập trình hướng đối tượng để thực hiện các thuật toán thay vì viết các giải pháp lặp bằng forcác vòng lặp với các bộ đếm vòng lặp. Thay vào đó, kết hợp và tái sử dụng các cấu trúc dữ liệu hiện có để đạt được mục tiêu của bạn.

Bạn sẽ không bao giờ sử dụng mã này trong Real World TM . Những gì bạn cần lấy từ bài tập này là làm thế nào để "nghĩ bên ngoài hộp" và sử dụng lại mã.


Xin lưu ý rằng bạn nên sử dụng giao diện java.util.Queue trong mã của mình thay vì sử dụng trực tiếp triển khai:

Queue<T> q1 = new LinkedList<T>();
Queue<T> q2 = new LinkedList<T>();

Điều này cho phép bạn sử dụng các Queuetriển khai khác nếu muốn, cũng như ẩn 2 phương thức LinkedListcó thể xoay quanh tinh thần của Queuegiao diện. Điều này bao gồm get(int)pop()(trong khi mã của bạn biên dịch, có một lỗi logic trong đó đưa ra các ràng buộc của bài tập của bạn. Khai báo các biến của bạn Queuethay vì LinkedListsẽ tiết lộ nó). Đọc liên quan: Tìm hiểu về lập trình trên một giao diệnmột giao diện hữu ích?

1 Tôi vẫn còn nhớ: bài tập là đảo ngược Stack chỉ bằng các phương thức trên giao diện Stack và không có phương thức tiện ích nào trong java.util.Collectionshoặc các lớp tiện ích "chỉ tĩnh" khác. Giải pháp đúng liên quan đến việc sử dụng các cấu trúc dữ liệu khác làm đối tượng lưu giữ tạm thời: bạn phải biết các cấu trúc dữ liệu khác nhau, thuộc tính của chúng và cách kết hợp chúng để làm điều đó. Làm vấp ngã hầu hết lớp CS101 của tôi, người chưa bao giờ lập trình trước đó.

2 Các phương thức vẫn còn đó, nhưng bạn không thể truy cập chúng mà không có kiểu phôi hoặc phản xạ. Vì vậy, không dễ để sử dụng các phương pháp không xếp hàng.


1
Cảm ơn. Tôi đoán nó có lý. Tôi cũng nhận ra rằng tôi đang sử dụng các hoạt động "bất hợp pháp" trong đoạn mã trên (đẩy lên phía trước của một FIFO), nhưng tôi không nghĩ điều đó thay đổi bất cứ điều gì. Tôi đã đảo ngược tất cả các hoạt động, và nó vẫn hoạt động như dự định. Tôi sẽ đợi một chút trước khi tôi chấp nhận vì tôi không muốn ngăn cản bất kỳ người nào khác đưa ra đầu vào. Cảm ơn bạn mặc dù.
Carcigenicate

19

Không có lợi thế. Bạn đã nhận ra một cách chính xác rằng việc sử dụng Hàng đợi để thực hiện Stack dẫn đến sự phức tạp thời gian khủng khiếp. Không có lập trình viên (có thẩm quyền) nào từng làm điều gì đó như thế này trong đời thực.

Nhưng nó có thể. Bạn có thể sử dụng một cách trừu tượng để thực hiện cái khác và ngược lại. Một ngăn xếp có thể được triển khai theo hai hàng đợi và tương tự như vậy, bạn có thể triển khai một hàng đợi theo hai ngăn xếp. Ưu điểm của bài tập này là:

  • bạn tóm tắt lại ngăn xếp
  • bạn tóm tắt hàng đợi
  • bạn đã quen với tư duy thuật toán
  • bạn học các thuật toán liên quan đến ngăn xếp
  • bạn có thể nghĩ về sự đánh đổi trong các thuật toán
  • bằng cách nhận ra sự tương đương của Hàng đợi và Ngăn xếp, bạn kết nối các chủ đề khác nhau của khóa học của mình
  • bạn có được kinh nghiệm lập trình thực tế

Trên thực tế, đây là một bài tập tuyệt vời. Tôi nên tự làm điều đó ngay bây giờ :)


3
@JohnKugelman Cảm ơn bạn đã chỉnh sửa, nhưng tôi thực sự có nghĩa là sự phức tạp thời gian khủng khiếp. Đối với một đống danh sách liên kết dựa trên push, peekpophoạt động được trong thời gian O (1). Điều tương tự đối với ngăn xếp dựa trên mảng có thể thay đổi được, ngoại trừ trường hợp pushđược khấu hao O (1), với trường hợp xấu nhất là O (n). So với điều đó, một ngăn xếp dựa trên hàng đợi kém hơn rất nhiều với O (n) đẩy, O (1) pop và peek, hoặc thay thế O (1) đẩy, O (n) pop và peek.
amon

1
"Sự phức tạp thời gian khủng khiếp" và "vô cùng thấp kém" là không chính xác. Độ phức tạp khấu hao vẫn là O (1) cho đẩy bật. Có một câu hỏi thú vị trong TAOCP (vol1?) Về điều đó (về cơ bản bạn phải chỉ ra rằng số lần một phần tử có thể chuyển từ ngăn xếp này sang ngăn xếp khác là không đổi). Hiệu suất trường hợp xấu nhất cho một thao tác là khác nhau, nhưng sau đó tôi hiếm khi nghe bất kỳ ai nói về hiệu suất O (N) để đẩy trong ArrayLists - không phải con số thường thú vị.
Voo

5

Chắc chắn có một mục đích thực sự để thực hiện một hàng đợi trong hai ngăn xếp. Nếu bạn sử dụng các cấu trúc dữ liệu bất biến từ một ngôn ngữ chức năng, bạn có thể đẩy vào một chồng các mặt hàng có thể đẩy và kéo từ danh sách các mặt hàng có thể mở được. Các mục poppable được tạo khi tất cả các mục đã được bật ra và ngăn xếp poppable mới là đảo ngược của ngăn xếp có thể đẩy, trong đó ngăn xếp có thể đẩy mới hiện đang trống. Đó là hiệu quả.

Đối với một ngăn xếp làm bằng hai hàng đợi? Điều đó có thể có ý nghĩa trong bối cảnh bạn có sẵn một loạt các hàng đợi lớn và nhanh. Nó chắc chắn vô dụng vì loại bài tập Java này. Nhưng nó có thể có ý nghĩa nếu đó là các kênh hoặc hàng đợi nhắn tin. (ví dụ: N tin nhắn được liệt kê, với thao tác O (1) để di chuyển (N-1) các mục ở phía trước vào hàng đợi mới.)


Hmm .. điều này làm tôi suy nghĩ về việc sử dụng các thanh ghi thay đổi làm cơ sở của điện toán và về kiến ​​trúc vành đai / nhà máy
slebetman

wow, CPU Mill thực sự thú vị. Một "Máy xếp hàng" chắc chắn.
Rob

2

Các bài tập không cần thiết từ một quan điểm thực tế. Vấn đề là buộc bạn sử dụng giao diện của hàng đợi một cách thông minh để thực hiện ngăn xếp. Ví dụ: giải pháp "Một hàng đợi" của bạn yêu cầu bạn lặp lại hàng đợi để nhận giá trị đầu vào cuối cùng cho thao tác "pop" ngăn xếp. Tuy nhiên, cấu trúc dữ liệu hàng đợi không cho phép lặp lại các giá trị, bạn bị hạn chế truy cập chúng trong lần nhập trước, xuất trước (FIFO).


2

Như những người khác đã lưu ý: không có lợi thế trong thế giới thực.

Dù sao, một câu trả lời cho phần thứ hai của câu hỏi của bạn, tại sao không chỉ đơn giản là sử dụng một hàng đợi, nằm ngoài Java.

Trong Java, ngay cả Queuegiao diện cũng có một size()phương thức và tất cả các cài đặt chuẩn của phương thức đó là O (1).

Điều đó không nhất thiết đúng với danh sách liên kết ngây thơ / chính tắc như một lập trình viên C / C ++ sẽ thực hiện, nó sẽ chỉ giữ các con trỏ đến phần tử đầu tiên và cuối cùng và mỗi phần tử một con trỏ đến phần tử tiếp theo.

Trong trường hợp đó size()là O (n) và nên tránh trong các vòng lặp. Hoặc việc thực hiện là mờ đục và chỉ cung cấp tối thiểu trần add()remove().

Với cách triển khai như vậy, trước tiên bạn phải đếm số lượng phần tử bằng cách chuyển chúng vào hàng đợi thứ hai, chuyển n-1các phần tử trở lại hàng đợi đầu tiên và trả về phần tử còn lại.

Điều đó nói rằng, nó có thể sẽ không tạo ra một cái gì đó như thế này nếu bạn sống ở vùng đất Java.


Điểm hay về size()phương pháp. Tuy nhiên, trong trường hợp không có size()phương thức O (1) , việc ngăn xếp theo dõi kích thước hiện tại của nó là chuyện nhỏ. Không có gì để ngăn chặn việc thực hiện một hàng đợi.
cmaster

Tất cả phụ thuộc vào việc thực hiện. Nếu bạn có một hàng đợi, được triển khai chỉ với các con trỏ chuyển tiếp và các con trỏ tới phần tử đầu tiên và cuối cùng, bạn vẫn có thể viết và thuật toán loại bỏ một phần tử, lưu nó vào một biến cục bộ, thêm phần tử trước đó trong biến đó vào cùng một hàng cho đến khi yếu tố đầu tiên được nhìn thấy một lần nữa. Điều này chỉ hoạt động nếu bạn có thể xác định duy nhất một yếu tố (ví dụ thông qua con trỏ) và không chỉ một cái gì đó có cùng giá trị. O (n) và chỉ sử dụng add () và remove (). Dù sao, nó dễ dàng hơn để tối ưu hóa, rằng để tìm một lý do để thực sự làm điều đó, ngoại trừ suy nghĩ về các thuật toán.
Thearh

0

Thật khó để tưởng tượng việc sử dụng để thực hiện như vậy, đó là sự thật. Nhưng hầu hết các điểm là để chứng minh rằng nó có thể được thực hiện .

Tuy nhiên, về mặt sử dụng thực tế cho những thứ này, tôi có thể nghĩ đến hai. Một cách sử dụng cho việc này là triển khai các hệ thống trong các môi trường bị hạn chế không được thiết kế cho nó : ví dụ, các khối đá đỏ của Minecraft hóa ra là một hệ thống hoàn chỉnh Turing, mà mọi người đã sử dụng để thực hiện các mạch logic và thậm chí toàn bộ CPU. Trong những ngày đầu tiên chơi game dựa trên kịch bản, nhiều bot trò chơi đầu tiên cũng được thực hiện theo cách này.

Nhưng bạn cũng có thể áp dụng nguyên tắc này ngược lại, đảm bảo rằng một cái gì đó không thể có trong một hệ thống khi bạn không muốn nó . Điều này có thể xuất hiện trong bối cảnh bảo mật: ví dụ: hệ thống cấu hình mạnh có thể là một tài sản, nhưng vẫn có những mức độ mà bạn có thể không cung cấp cho người dùng. Điều này hạn chế những gì bạn có thể cho phép ngôn ngữ cấu hình thực hiện, kẻo bị kẻ tấn công lật đổ, nhưng trong trường hợp này, đó là điều bạn muốn.

Khi sử dụng trang web của chúng tôi, bạn xác nhận rằng bạn đã đọc và hiểu Chính sách cookieChính sách bảo mật của chúng tôi.
Licensed under cc by-sa 3.0 with attribution required.